Skip to content

MCP / WebMCP Architecture

This tutorial is a deep dive into the architecture of webmcp-auto-ui. You will not write code here, but you will understand how every piece fits together. By the end, you will know exactly what happens when a user asks a question and a widget appears on screen.

Understand the complete architecture: the two protocols (MCP and WebMCP), their symmetry, uniform prefixing, lazy loading, the schema pipeline, and the canonical resolver.

A deep understanding of the architecture that will let you:

  • Debug tool routing issues
  • Add new MCP and WebMCP servers
  • Understand agent loop logs
  • Extend the system with new protocols

The system relies on two symmetric protocols:

  • MCP (Model Context Protocol) provides remote data — SQL queries, REST APIs, scraping, etc.
  • WebMCP provides local display — widgets, canvas, browser interactions.

Each exposes tools (atomic actions) and recipes (composition guides).

graph TB
subgraph MCP ["MCP (remote data)"]
direction TB
M1[query_sql]
M2[fetch_document]
M3[search_recipes]
end
subgraph WebMCP ["WebMCP (local display)"]
direction TB
W1[widget_display]
W2[canvas]
W3[search_recipes]
end
LLM[LLM Agent] -->|HTTP| MCP
LLM -->|JS in-process| WebMCP
DimensionMCPWebMCP
RoleData, APIs, databasesDisplay, interaction
TransportHTTP Streamable / stdioIn-process JS calls
ExecutionRemote serverLocal browser
LatencyNetwork (50-500ms)Instant (<1ms)
Example toolsquery_sql, fetch_documentwidget_display, canvas
RecipesDescribe the dataDescribe the presentation
Package@webmcp-auto-ui/core@webmcp-auto-ui/core

The key design principle is symmetry: the LLM does not distinguish an MCP tool from a WebMCP tool. Both protocols expose the same interface:

  • search_recipes() — discover available recipes
  • get_recipe() — get the schema and instructions
  • Specific tools — execute actions

From the LLM’s perspective, an MCP call and a WebMCP call follow the same cycle: discovery, schema reading, execution. Only the internal routing differs.

graph LR
LLM -->|tool_call| Router{Router}
Router -->|protocol = mcp| HTTP[McpClient.callTool via HTTP]
Router -->|protocol = webmcp| JS[WebMcpServer.executeTool in JS]
HTTP --> ServerMCP[Remote server]
JS --> Browser[Local browser]

All tools follow the naming convention:

{serverName}_{protocol}_{toolName}

Examples with multiple connected servers:

Full prefixed toolserverNameprotocoltoolName
tricoteuses_mcp_query_sqltricoteusesmcpquery_sql
datagouv_mcp_fetch_datasetdatagouvmcpfetch_dataset
autoui_webmcp_widget_displayautouiwebmcpwidget_display
designkit_webmcp_widget_displaydesignkitwebmcpwidget_display

The agent loop parses this prefix with a regex:

/^(.+?)_(mcp|webmcp)_(.+)$/

Then dispatches:

  • protocol === 'mcp' —> McpClient.callTool(toolName, params)
  • protocol === 'webmcp' —> WebMcpServer.executeTool(toolName, params)

This guarantees no name collisions even with 10 servers connected simultaneously.

graph TD
Call["tricoteuses_mcp_query_sql(...)"] --> Parse[Regex parse]
Parse --> SN["serverName = tricoteuses"]
Parse --> P["protocol = mcp"]
Parse --> TN["toolName = query_sql"]
P -->|mcp| HTTP["McpClient(tricoteuses).callTool('query_sql', params)"]
P -->|webmcp| JS["WebMcpServer(name).executeTool('toolName', params)"]

buildSystemPrompt(layers) generates a recipe-driven prompt adapted to the connected servers. The prompt enforces a strict 4-step workflow:

graph LR
A["1. Discovery"] --> B["2. Reading"]
B --> C["3. Execution"]
C --> D["4. Display"]
A -.- A1["search_recipes()"]
B -.- B1["get_recipe()"]
C -.- C1["query_sql(), fetch_data()..."]
D -.- D1["widget_display(), canvas()"]

The tool lists at steps 1, 2, and 4 are placeholders: buildSystemPrompt automatically injects the prefixed names based on the connected layers. With 2 MCP servers and 1 WebMCP, step 1 will contain:

tricoteuses_mcp_search_recipes(), datagouv_mcp_search_recipes(), autoui_webmcp_search_recipes()

At startup, the agent loop does not expose all tools from all servers. It only provides discovery tools:

ProtocolTools exposed at start
MCPsearch_recipes, get_recipe
WebMCPsearch_recipes, get_recipe, widget_display, canvas, recall
sequenceDiagram
participant LLM
participant Loop as Agent Loop
participant MCP as MCP Server
Note over Loop: Start: only search_recipes, get_recipe
LLM->>Loop: search_recipes("data")
Loop->>MCP: search_recipes
MCP-->>Loop: recipes found
LLM->>Loop: query_sql(...)
Note over Loop: First call to this server!
Loop->>Loop: activateServerTools(server)
Note over Loop: All server tools now active
Loop->>MCP: query_sql
MCP-->>Loop: results

With 4 servers and 50 tools total, discovery mode exposes about 20 tools instead of 50 — saving 3,000—5,000 tokens in the initial prompt.


Widget schemas follow a 4-step pipeline from Svelte component to runtime:

graph LR
A["interface Props (Svelte)"] -->|sync-schemas.ts| B["JSON Schema"]
B -->|frontmatter .md| C["Recipe"]
C -->|registerWidget| D["WebMCP Server"]
D -->|widget_display| E["Runtime validation"]

When the LLM calls widget_display(name, params), the WebMCP server validates params against the JSON Schema before passing them to the renderer:

sequenceDiagram
participant LLM
participant WS as WebMCP Server
participant V as validateJsonSchema
participant R as Renderer
LLM->>WS: widget_display('stat', {label: "Test"})
WS->>V: validate(params, schema)
alt Valid
V-->>WS: {valid: true}
WS->>R: render widget
R-->>WS: {id: "w_abc123"}
WS-->>LLM: OK, id=w_abc123
else Invalid
V-->>WS: {valid: false, errors: [...]}
WS-->>LLM: Error + expected schema
Note over LLM: Self-correction on next turn
end

Here is the complete sequence when the user asks: “Show me the profile of MP Jean Dupont”.

sequenceDiagram
participant User
participant App
participant Loop as Agent Loop
participant LLM as LLM
participant MCP as MCP Server
participant WebMCP as WebMCP Server
User->>App: "Show me MP Jean Dupont's profile"
App->>Loop: runAgentLoop(message, options)
Note over Loop: Iteration 1: Discovery
Loop->>LLM: prompt + discovery tools
LLM-->>Loop: tool_call: search_recipes("MP profile")
Loop->>MCP: search_recipes
MCP-->>Loop: recipe "deputy-profile"
Note over Loop: Iteration 2: Reading + Execution
Loop->>LLM: recipe results
LLM-->>Loop: tool_call: get_recipe("deputy-profile")
Loop->>MCP: get_recipe
MCP-->>Loop: schema + instructions
Note over Loop: Iteration 3: Data
Loop->>LLM: widget schema
LLM-->>Loop: tool_call: query_sql(...)
Loop->>MCP: query_sql
MCP-->>Loop: deputy JSON data
Note over Loop: Iteration 4: Display
Loop->>LLM: data
LLM-->>Loop: tool_call: widget_display("profile", {...})
Loop->>WebMCP: widget_display
WebMCP-->>Loop: {id: "w_xyz"}
Loop-->>App: AgentResult
App-->>User: Profile widget displayed!

Two safeguards prevent infinite loops:

  1. No-render iteration counter — after 4 iterations without widget_display, discovery tools are removed. After 5, a nudge message is injected.
  2. maxIterations (default 5) — the loop stops even if the LLM has not finished.

After each iteration, previous tool_result entries are compressed: texts longer than 300 characters are truncated to 200 with a recall('id') hint. The LLM can retrieve the full result via the recall tool.


Multiple MCP and WebMCP servers coexist thanks to uniform prefixing.

graph TB
Agent[Agent Loop] --> MCP1["tricoteuses_mcp_*"]
Agent --> MCP2["wikipedia_mcp_*"]
Agent --> WMCP1["autoui_webmcp_*"]
Agent --> WMCP2["designkit_webmcp_*"]
MCP1 --> S1[Tricoteuses Server]
MCP2 --> S2[Wikipedia Server]
WMCP1 --> W1[Native Widgets]
WMCP2 --> W2[Design Widgets]

Each server is a complete namespace. If autoui and designkit both expose a widget_display tool, the LLM sees:

  • autoui_webmcp_widget_display — standard widgets (stat, chart, map…)
  • designkit_webmcp_widget_display — design widgets (mockup, wireframe…)

No confusion possible.


MCP servers do not always use the exact names search_recipes and get_recipe. The canonical resolver identifies equivalent tools via 4 matching layers:

LayerStrategyExample
Layer 1Exact name matchsearch_recipes
Layer 2Decomposition (action, resource)list_skills —> search_recipes
Layer 3Description scan for keywordsdescription contains “recipe” + “search”
Layer 4Fallback: no recipe tool, list raw toolsserver without recipes
graph TD
Tool["list_skills"] --> L1{Layer 1: exact match?}
L1 -->|no| L2{Layer 2: decomposition?}
L2 -->|action=list, resource=skills| Match["= search_recipes"]
L2 -->|no| L3{Layer 3: description?}
L3 -->|no| L4["Layer 4: fallback (raw tools)"]

The resolver registers aliases in a local map:

aliasMap.set('server_mcp_search_recipes', 'server_mcp_list_skills');

The system prompt uses the canonical name (search_recipes), and the agent loop resolves the alias at execution time.


The layer-based architecture accommodates new server types without modifying the agent loop.

ToolLayer is a discriminated union by protocol. Adding a third type = new union member + a case in buildToolsFromLayers().

// Today
export type ToolLayer = McpLayer | WebMcpLayer;
// Tomorrow
export type ToolLayer = McpLayer | WebMcpLayer | WasmLayer;

graph TB
subgraph Browser
App[SvelteKit App]
Agent[Agent Loop]
Canvas[Canvas Store]
Widgets[WidgetRenderer]
WebMCPs[Local WebMCP Servers]
end
subgraph Cloud
LLMApi[Remote LLM API]
MCP1[MCP Server 1]
MCP2[MCP Server 2]
end
App -->|message| Agent
Agent -->|prompt + tools| LLMApi
LLMApi -->|tool_calls| Agent
Agent -->|mcp calls| MCP1
Agent -->|mcp calls| MCP2
Agent -->|webmcp calls| WebMCPs
Agent -->|widget data| Canvas
Canvas -->|reactive blocks| Widgets
Widgets -->|DOM render| App

ConceptImplementation
ProtocolsMCP (remote) + WebMCP (local), symmetric
Prefixing{server}_{protocol}_{tool}
LayersMcpLayer[] + WebMcpLayer[] = ToolLayer[]
Lazy loadingbuildDiscoveryTools() + activateServerTools()
System promptbuildSystemPrompt(layers) — dynamic
Schema pipelineTS Props —> sync-schemas —> .md —> WebMCP
ValidationJSON Schema at runtime before rendering
Multi-serverIsolated namespaces, aliases, recipe filtering
Canonical resolver4 layers: exact, decomposition, description, fallback
Context compressionTruncation + recall() for long results
ExtensibilityDiscriminated union ToolLayer, new type

ProblemLikely causeSolution
”Unknown tool” in logsPrefix doesn’t match any serverVerify serverName in the layer matches
LLM ignores an MCP serverNo recipes, LLM doesn’t know what to askAdd a recipes.json file to the server
Infinite loopLLM never finishesReduce maxIterations or check system prompt
Tools not visibleServer missing from layersVerify layers contains the server’s layer

  • Implement a new protocol: extend the ToolLayer union and add a case in buildToolsFromLayers()
  • Create a native bridge: implement a WebMCP server that communicates with native code (Swift, Kotlin)
  • Optimize lazy loading: use buildDiscoveryCache() to pre-compute discovery tools