# MCP App Fallback Architecture ## Graceful Degradation for Hosts Without Full Interactive Support **Date:** 2026-02-03 **Status:** Proposed **Applies to:** GoHighLevel MCP UI Kit React App --- ## Executive Summary The ext-apps SDK provides explicit **host capability negotiation** via `McpUiHostCapabilities`. After the `ui/initialize` handshake, the View knows exactly which features the host supports. We use this to implement a **three-tier progressive enhancement** model: Static → Context-Synced → Fully Interactive. --- ## 1. Feature Detection (The Foundation) ### How It Works When the View connects, the host returns `McpUiHostCapabilities` in the `ui/initialize` response. The `App` class exposes this via `app.getHostCapabilities()`. **The key capability flags:** ```typescript interface McpUiHostCapabilities { serverTools?: { listChanged?: boolean }; // Can proxy tools/call to MCP server serverResources?: { listChanged?: boolean }; // Can proxy resources/read updateModelContext?: {}; // Accepts ui/update-model-context message?: {}; // Accepts ui/message (send chat messages) openLinks?: {}; // Can open external URLs logging?: {}; // Accepts log messages sandbox?: { permissions?: {...}; csp?: {...} }; } ``` **The critical check: `serverTools` tells you if `callServerTool` will work.** ### Implementation: `useHostCapabilities` Hook ```typescript // hooks/useHostCapabilities.ts import { useMemo } from "react"; import { useMCPApp } from "../context/MCPAppContext.js"; import type { McpUiHostCapabilities } from "@modelcontextprotocol/ext-apps"; export type InteractionTier = "static" | "context-synced" | "full"; export interface HostCapabilityInfo { /** Raw capabilities from the host */ raw: McpUiHostCapabilities | undefined; /** Whether callServerTool() will work */ canCallTools: boolean; /** Whether updateModelContext() will work */ canUpdateContext: boolean; /** Whether sendMessage() will work */ canSendMessages: boolean; /** The interaction tier this host supports */ tier: InteractionTier; } export function useHostCapabilities(): HostCapabilityInfo { const { app } = useMCPApp(); return useMemo(() => { const raw = app?.getHostCapabilities?.() as McpUiHostCapabilities | undefined; const canCallTools = !!raw?.serverTools; const canUpdateContext = !!raw?.updateModelContext; const canSendMessages = !!raw?.message; // Determine tier let tier: InteractionTier = "static"; if (canCallTools) { tier = "full"; } else if (canUpdateContext) { tier = "context-synced"; } return { raw, canCallTools, canUpdateContext, canSendMessages, tier }; }, [app]); } ``` ### Add to MCPAppContext ```typescript // In MCPAppContext.tsx — extend the context value: export interface MCPAppContextValue { // ... existing fields ... /** Host capability info, available after connection */ capabilities: HostCapabilityInfo; /** Safe tool call: uses callServerTool if available, falls back to updateModelContext, or no-ops */ safeCallTool: ( toolName: string, args: Record, options?: { localOnly?: boolean } ) => Promise; } ``` --- ## 2. The Three-Tier Model ### Tier 1: Static (No host capabilities) - Pure data visualization — charts, tables, badges, timelines - All data comes from the initial `ontoolresult` payload - No server communication, no context updates - **Components work as-is:** BarChart, PieChart, StatusBadge, DataTable, Timeline, etc. ### Tier 2: Context-Synced (`updateModelContext` available, no `serverTools`) - Local interactivity works (drag, edit, toggle, form fills) - User actions update LOCAL state immediately - State changes are synced to the LLM via `updateModelContext` - The LLM can then decide to call tools itself on the next turn - **Key pattern:** User drags a Kanban card → local state updates → `updateModelContext` tells the LLM what happened → LLM calls `move_opportunity` on its own ### Tier 3: Full Interactive (`serverTools` available) - Everything works as currently designed - Components call `callServerTool` directly for real-time server mutations - Optimistic UI with server confirmation ``` ┌─────────────────────────────────────────────────┐ │ TIER 3: FULL │ │ callServerTool ✓ updateModelContext ✓ │ │ Direct server mutations, optimistic UI │ │ ┌─────────────────────────────────────────────┐ │ │ │ TIER 2: CONTEXT-SYNCED │ │ │ │ callServerTool ✗ updateModelContext ✓ │ │ │ │ Local state + LLM-informed sync │ │ │ │ ┌─────────────────────────────────────────┐│ │ │ │ │ TIER 1: STATIC ││ │ │ │ │ Read-only data visualization ││ │ │ │ │ Charts, tables, badges, timelines ││ │ │ │ └─────────────────────────────────────────┘│ │ │ └─────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────┘ ``` --- ## 3. Component Classification ### Always Static (work everywhere) These components are pure data display — they render from the UITree data and never call tools: | Component | Category | |-----------|----------| | BarChart, LineChart, PieChart, SparklineChart, FunnelChart | charts | | StatusBadge, ProgressBar, CurrencyDisplay, StarRating | data | | DataTable, KeyValueList, TagList, AvatarGroup | data | | Timeline, MetricCard, DetailHeader, InfoBlock | data | | PageHeader, Section, Card, StatsGrid, SplitLayout | layout | | ChatThread, TranscriptView, EmailPreview, ContentPreview | comms | | CalendarView, TreeView, FlowDiagram, DuplicateCompare | viz | ### Context-Syncable (Tier 2+) These work with local state and sync via `updateModelContext`: | Component | Local Behavior | Context Sync | |-----------|---------------|--------------| | KanbanBoard | Drag cards between columns locally | Report new board state to LLM | | EditableField | Edit text inline locally | Report edited values to LLM | | FormGroup | Fill form fields locally | Report form state to LLM | | SelectDropdown | Select options locally | Report selection to LLM | | AmountInput | Adjust amounts locally | Report new amounts to LLM | | ChecklistView | Toggle checkboxes locally | Report checklist state to LLM | | FilterChips | Toggle filters locally | Report active filters to LLM | | TabGroup | Switch tabs locally | Report active tab to LLM | | SearchBar | Type search queries locally | Report search to LLM | ### Full Interactive Only (Tier 3) These **require** server tool calls and degrade gracefully at lower tiers: | Component | Tier 3 | Tier 2 Fallback | Tier 1 Fallback | |-----------|--------|-----------------|-----------------| | ActionButton | Calls tool | Shows "Ask assistant" hint | Disabled/hidden | | AppointmentBooker | Books via tool | Shows form, reports to LLM | Shows read-only schedule | | InvoiceBuilder | Creates via tool | Builds locally, reports to LLM | Shows existing invoice data | | OpportunityEditor | Saves via tool | Edits locally, reports to LLM | Shows read-only data | | ContactPicker | Searches via tool | Shows static options list | Shows read-only display | --- ## 4. Concrete Implementation ### 4a. `safeCallTool` — The Universal Action Handler ```typescript // In MCPAppProvider const safeCallTool = useCallback( async ( toolName: string, args: Record, options?: { localOnly?: boolean } ): Promise => { // Tier 3: Full tool call if (capabilities.canCallTools && !options?.localOnly) { return callTool(toolName, args); } // Tier 2: Inform LLM via updateModelContext if (capabilities.canUpdateContext && app) { const markdown = [ `---`, `action: ${toolName}`, `timestamp: ${new Date().toISOString()}`, ...Object.entries(args).map(([k, v]) => `${k}: ${JSON.stringify(v)}`), `---`, `User performed action "${toolName}" in the UI.`, `Please execute the corresponding server-side operation.`, ].join("\n"); await app.updateModelContext({ content: [{ type: "text", text: markdown }], }); // Return a synthetic "pending" result return { content: [ { type: "text", text: `Action "${toolName}" reported to assistant. It will be processed on the next turn.`, }, ], } as CallToolResult; } // Tier 1: No-op, return null console.warn(`[MCPApp] Cannot execute ${toolName}: no host support`); return null; }, [capabilities, callTool, app] ); ``` ### 4b. `` — Tier-Aware Wrapper ```tsx // components/shared/InteractiveGate.tsx import React from "react"; import { useHostCapabilities, type InteractionTier } from "../../hooks/useHostCapabilities.js"; interface InteractiveGateProps { /** Minimum tier required to show children */ requires: InteractionTier; /** What to render if the tier isn't met */ fallback?: React.ReactNode; /** If true, hide entirely instead of showing fallback */ hideOnUnsupported?: boolean; children: React.ReactNode; } const tierLevel: Record = { static: 0, "context-synced": 1, full: 2, }; export const InteractiveGate: React.FC = ({ requires, fallback, hideOnUnsupported = false, children, }) => { const { tier } = useHostCapabilities(); if (tierLevel[tier] >= tierLevel[requires]) { return <>{children}; } if (hideOnUnsupported) return null; if (fallback) return <>{fallback}; // Default fallback: subtle message return (
ℹ️ This feature requires a supported host. Ask the assistant to perform this action.
); }; ``` ### 4c. Refactored `ActionButton` with Fallback ```tsx // components/shared/ActionButton.tsx (revised) export const ActionButton: React.FC = ({ label, variant = "secondary", size = "md", disabled, toolName, toolArgs, }) => { const { safeCallTool } = useMCPApp(); const { tier, canCallTools, canSendMessages } = useHostCapabilities(); const [loading, setLoading] = useState(false); const [pendingMessage, setPendingMessage] = useState(null); const handleClick = useCallback(async () => { if (!toolName || loading || disabled) return; setLoading(true); setPendingMessage(null); try { const result = await safeCallTool(toolName, toolArgs || {}); // Tier 2: show "reported to assistant" feedback if (!canCallTools && result) { setPendingMessage("Reported to assistant"); setTimeout(() => setPendingMessage(null), 3000); } } catch { // handled by context } finally { setLoading(false); } }, [toolName, toolArgs, loading, disabled, safeCallTool, canCallTools]); // Tier 1 with no context sync: show disabled with hint if (tier === "static" && toolName) { return ( ); } return ( ); }; ``` ### 4d. Refactored `KanbanBoard` — Local-First + Context Sync The KanbanBoard already uses optimistic local state. The only change: fall back to `updateModelContext` when `moveTool` can't be called directly. ```tsx // In KanbanBoard onDrop handler (revised): const onDrop = useCallback(async (e: React.DragEvent, toStageId: string) => { e.preventDefault(); setDropTargetStage(null); const drag = dragRef.current; if (!drag) return; const { cardId, fromStageId } = drag; dragRef.current = null; setDraggingCardId(null); if (fromStageId === toStageId) return; // 1. Optimistic local update (same as before) let movedCard: KanbanCard | undefined; const prevColumns = columns; setColumns(prev => { /* ... same optimistic logic ... */ }); // 2. Use safeCallTool — works at Tier 2 AND Tier 3 if (moveTool) { try { const result = await safeCallTool(moveTool, { opportunityId: cardId, pipelineStageId: toStageId, }); // Tier 2: result is a "reported" message — no revert needed // Local state IS the source of truth until LLM processes it if (!capabilities.canCallTools && result) { // Also report the full board state so LLM has context await reportBoardState(); } } catch (err) { // Only revert if we attempted a real tool call (Tier 3) if (capabilities.canCallTools) { console.error("KanbanBoard: move failed, reverting", err); setColumns(prevColumns); } } } }, [columns, moveTool, safeCallTool, capabilities]); // Report full board state to LLM for context const reportBoardState = useCallback(async () => { if (!app || !capabilities.canUpdateContext) return; const summary = columns.map(col => `**${col.title}** (${col.cards?.length ?? 0}):\n` + (col.cards || []).map(c => ` - ${c.title} (${c.value || 'no value'})`).join('\n') ).join('\n\n'); await app.updateModelContext({ content: [{ type: "text", text: `---\ncomponent: kanban-board\nupdated: ${new Date().toISOString()}\n---\nCurrent pipeline state:\n\n${summary}`, }], }); }, [app, capabilities, columns]); ``` ### 4e. Refactored `EditableField` — Works at All Tiers ```tsx // In EditableField (revised handleSave): const handleSave = async () => { setIsEditing(false); if (editValue === value) return; if (saveTool) { const result = await safeCallTool(saveTool, { ...saveArgs, value: editValue, }); // Tier 2: field stays locally updated; LLM is informed // Tier 3: server confirms; UI may refresh via ontoolresult // Tier 1: nothing happens — but user sees their edit locally if (!result && tier === "static") { // Show hint that the change is display-only setLocalOnlyHint(true); setTimeout(() => setLocalOnlyHint(false), 3000); } } }; ``` --- ## 5. Alternative Interaction: `updateModelContext` as Primary Channel For hosts that support `updateModelContext` but not `serverTools`, we can use a **declarative intent** pattern instead of imperative tool calls: ```typescript // hooks/useReportInteraction.ts export function useReportInteraction() { const { app } = useMCPApp(); const { canUpdateContext } = useHostCapabilities(); return useCallback( async (interaction: { component: string; action: string; data: Record; suggestion?: string; // What we suggest the LLM should do }) => { if (!canUpdateContext || !app) return; const text = [ `---`, `component: ${interaction.component}`, `action: ${interaction.action}`, `timestamp: ${new Date().toISOString()}`, ...Object.entries(interaction.data).map(([k, v]) => `${k}: ${JSON.stringify(v)}`), `---`, interaction.suggestion || `User performed "${interaction.action}" in ${interaction.component}.`, ].join("\n"); await app.updateModelContext({ content: [{ type: "text", text }], }); }, [app, canUpdateContext] ); } ``` **Usage in any component:** ```tsx const reportInteraction = useReportInteraction(); // In a form submit handler: await reportInteraction({ component: "AppointmentBooker", action: "book_appointment", data: { contactId, calendarId, startTime, endTime }, suggestion: "User wants to book this appointment. Please call create_appointment with these details.", }); ``` --- ## 6. Server-Side: Dual-Mode Tool Registration The MCP server should register tools that work both ways: ```typescript // Server-side: Register tool as visible to both model and app registerAppTool(server, "move_opportunity", { description: "Move an opportunity to a different pipeline stage", inputSchema: { opportunityId: z.string(), pipelineStageId: z.string(), }, _meta: { ui: { resourceUri: "ui://ghl/pipeline-view", visibility: ["model", "app"], // ← Both can call it }, }, }, handler); ``` With `visibility: ["model", "app"]`: - **Tier 3 hosts:** App calls it directly via `callServerTool` - **Tier 2 hosts:** App reports intent via `updateModelContext`, LLM sees the tool in its tool list and calls it on the next turn - **Tier 1 hosts:** Tool still works as text-only through normal MCP --- ## 7. `window.__MCP_APP_DATA__` Pre-Injection (Tier 0) The existing `getPreInjectedTree()` in App.tsx already supports a non-MCP path. For environments where the iframe loads but the ext-apps SDK never connects: ```typescript // App.tsx already handles this: useEffect(() => { const preInjected = getPreInjectedTree(); if (preInjected && !uiTree) { setUITree(preInjected); } }, []); ``` This serves as **Tier 0**: pure server-side rendering. The server injects the UITree directly into the HTML. No SDK connection needed. All components render in static mode. --- ## 8. CSS for Degraded States ```css /* styles/fallback.css */ /* Unsupported interactive elements */ .btn-unsupported { opacity: 0.6; cursor: not-allowed; position: relative; } .btn-unsupported::after { content: "↗"; font-size: 0.7em; margin-left: 4px; opacity: 0.5; } /* "Reported to assistant" badge */ .btn-pending-badge { margin-left: 8px; font-size: 0.75em; color: var(--color-text-info, #3b82f6); animation: fadeIn 0.2s ease-in; } /* Unavailable feature hint */ .interactive-unavailable { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: var(--border-radius-md, 6px); background: var(--color-background-info, #eff6ff); color: var(--color-text-secondary, #6b7280); font-size: 0.85em; } /* Local-only edit hint */ .ef-local-hint { font-size: 0.75em; color: var(--color-text-warning, #f59e0b); margin-top: 2px; } /* Editable fields in static mode: remove edit affordance */ [data-tier="static"] .ef-edit-icon { display: none; } [data-tier="static"] .ef-display { cursor: default; } ``` --- ## 9. Decision Matrix | Scenario | Detection | Behavior | |----------|-----------|----------| | Claude Desktop (full support) | `serverTools` ✓ | Tier 3: all features work | | Host with context-only support | `updateModelContext` ✓, `serverTools` ✗ | Tier 2: local + LLM sync | | Minimal host / basic iframe | No capabilities | Tier 1: static display | | Pre-injected `__MCP_APP_DATA__` | No SDK connection | Tier 0: SSR static | | `callServerTool` fails at runtime | Error caught | Downgrade to Tier 2 dynamically | --- ## 10. Summary: What to Implement ### Phase 1 (Essential) 1. **`useHostCapabilities` hook** — reads `app.getHostCapabilities()`, computes tier 2. **`safeCallTool` in MCPAppContext** — wraps callTool with fallback to updateModelContext 3. **Update `ActionButton`** — use safeCallTool, show disabled state at Tier 1 4. **Update `KanbanBoard`** — already local-first, just wire up safeCallTool + reportBoardState 5. **`fallback.css`** — styles for degraded states ### Phase 2 (Full Coverage) 6. **`useReportInteraction` hook** — standard way to inform LLM of UI actions 7. **`` component** — declarative tier gating in templates 8. **Update all interactive components** — EditableField, FormGroup, AppointmentBooker, etc. 9. **Runtime downgrade** — if a Tier 3 call fails, auto-downgrade to Tier 2 ### Phase 3 (Polish) 10. **Toast notifications** for tier-specific feedback ("Action sent to assistant") 11. **`data-tier` attribute** on root for CSS-only degradation 12. **Testing matrix** — automated tests per tier per component --- ## Key Insight The ext-apps spec was **designed** for this: *"UI is a progressive enhancement, not a requirement."* Our architecture mirrors this philosophy — every component starts as a static data display and progressively gains interactivity based on what the host reports it can do. The KanbanBoard's existing optimistic-update pattern is already the correct Tier 2 pattern; we just need to formalize it and apply it consistently.