=== DONE === - MCP Apps UI system added (11 apps with _meta.ui.resourceUri) - 19 new tool modules added - Tool count: 269 → 461 across 38 categories - Upstream changes merged - All tools tagged with _meta labels - Package lock updated === TO-DO === - [ ] Fix 42 failing edge case tests (BLOCKER — Stage 11) - [ ] Live API testing with GHL credentials - [ ] App design approval for Stage 7→8
21 KiB
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:
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
// 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
// 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<string, any>,
options?: { localOnly?: boolean }
) => Promise<CallToolResult | null>;
}
2. The Three-Tier Model
Tier 1: Static (No host capabilities)
- Pure data visualization — charts, tables, badges, timelines
- All data comes from the initial
ontoolresultpayload - 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 →
updateModelContexttells the LLM what happened → LLM callsmove_opportunityon its own
Tier 3: Full Interactive (serverTools available)
- Everything works as currently designed
- Components call
callServerTooldirectly 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
// In MCPAppProvider
const safeCallTool = useCallback(
async (
toolName: string,
args: Record<string, any>,
options?: { localOnly?: boolean }
): Promise<CallToolResult | null> => {
// 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. <InteractiveGate> — Tier-Aware Wrapper
// 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<InteractionTier, number> = {
static: 0,
"context-synced": 1,
full: 2,
};
export const InteractiveGate: React.FC<InteractiveGateProps> = ({
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 (
<div className="interactive-unavailable" role="note">
<span className="interactive-unavailable-icon">ℹ️</span>
<span>This feature requires a supported host. Ask the assistant to perform this action.</span>
</div>
);
};
4c. Refactored ActionButton with Fallback
// components/shared/ActionButton.tsx (revised)
export const ActionButton: React.FC<ActionButtonProps> = ({
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<string | null>(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 (
<button className={`btn ${vCls} ${sCls} btn-unsupported`} disabled title="Ask the assistant to perform this action">
{label}
</button>
);
}
return (
<button className={`btn ${vCls} ${sCls}`} disabled={disabled || loading} onClick={handleClick}>
{loading && <span className="loading-spinner" style={{ width: 14, height: 14, marginRight: 6, borderWidth: 2, display: "inline-block" }} />}
{label}
{pendingMessage && <span className="btn-pending-badge">{pendingMessage}</span>}
</button>
);
};
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.
// 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
// 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:
// hooks/useReportInteraction.ts
export function useReportInteraction() {
const { app } = useMCPApp();
const { canUpdateContext } = useHostCapabilities();
return useCallback(
async (interaction: {
component: string;
action: string;
data: Record<string, any>;
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:
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:
// 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:
// 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
/* 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)
useHostCapabilitieshook — readsapp.getHostCapabilities(), computes tiersafeCallToolin MCPAppContext — wraps callTool with fallback to updateModelContext- Update
ActionButton— use safeCallTool, show disabled state at Tier 1 - Update
KanbanBoard— already local-first, just wire up safeCallTool + reportBoardState fallback.css— styles for degraded states
Phase 2 (Full Coverage)
useReportInteractionhook — standard way to inform LLM of UI actions<InteractiveGate>component — declarative tier gating in templates- Update all interactive components — EditableField, FormGroup, AppointmentBooker, etc.
- Runtime downgrade — if a Tier 3 call fails, auto-downgrade to Tier 2
Phase 3 (Polish)
- Toast notifications for tier-specific feedback ("Action sent to assistant")
data-tierattribute on root for CSS-only degradation- 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.