Go-High-Level-MCP-2026-Comp.../docs/FALLBACK-ARCHITECTURE.md
Jake Shore 4f2a8d6ab5 GHL MCP full update — 2026-02-06
=== 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
2026-02-06 06:27:05 -05:00

21 KiB
Raw Blame History

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 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

// 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)

  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)

  1. useReportInteraction hook — standard way to inform LLM of UI actions
  2. <InteractiveGate> component — declarative tier gating in templates
  3. Update all interactive components — EditableField, FormGroup, AppointmentBooker, etc.
  4. Runtime downgrade — if a Tier 3 call fails, auto-downgrade to Tier 2

Phase 3 (Polish)

  1. Toast notifications for tier-specific feedback ("Action sent to assistant")
  2. data-tier attribute on root for CSS-only degradation
  3. 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.