Skip to content

Tool Calling and Dispatch

Tool calling is the central mechanism of WebMCP Auto-UI. The LLM agent generates tool_use blocks, and the dispatcher routes them to the right server — remote MCP or local WebMCP. This page traces the complete journey of a tool call, from the LLM to the result.

sequenceDiagram
participant LLM as LLM Agent
participant DISP as Dispatcher
participant ALIAS as Alias Map
participant MCP as MCP Server (remote)
participant WMCP as WebMCP Server (local)
participant CANVAS as Canvas
LLM->>DISP: tool_use: weather_mcp_get_forecast
DISP->>ALIAS: Resolve alias
ALIAS-->>DISP: weather_mcp_get_weather_data
DISP->>DISP: Parse prefix:<br/>server=weather, proto=mcp, tool=get_weather_data
DISP->>MCP: callTool("get_weather_data", {location: "Paris"})
MCP-->>DISP: {temp: 18, wind: 12}
DISP-->>LLM: tool_result
LLM->>DISP: tool_use: autoui_webmcp_widget_display
DISP->>WMCP: executeTool("widget_display", {name: "stat", params: ...})
WMCP-->>DISP: {widget: "stat", id: "w_abc"}
DISP->>CANVAS: onWidget("stat", data)
DISP-->>LLM: tool_result

When the LLM decides to use a tool, it produces a structured block:

{
type: 'tool_use',
id: 'toolu_1234', // Unique call identifier
name: 'weather_mcp_get_forecast', // Canonical name with prefix
input: { location: 'Paris', days: 3 } // Tool parameters
}

The name follows the {serverName}_{protocol}_{toolName} convention. This format tells the dispatcher immediately:

  • Which server to contact (weather)
  • Which protocol to use (mcp = remote, webmcp = local)
  • Which tool to call (get_forecast)

The dispatcher is the routing core. For each tool_use block, it:

  1. Resolves aliases (canonical names to actual server names)
  2. Parses the prefix to identify server, protocol, and tool
  3. Routes to MCP or WebMCP
for (const block of toolBlocks) {
// Step 1: Resolve alias
const resolvedName = aliasMap.get(block.name) ?? block.name;
// Step 2: Parse prefix
const match = resolvedName.match(/^(.+?)_(mcp|webmcp)_(.+)$/);
if (!match) {
result = `Error: unknown tool format`;
} else {
const [, serverName, protocol, realToolName] = match;
// Step 3: Routing
if (protocol === 'mcp') {
// → Network call via MCP client
} else if (protocol === 'webmcp') {
// → Local execution via WebMCP server
}
}
}

Before normal dispatch, two pseudo-tools are intercepted locally. They do not trigger server activation:

if (toolMatch[3] === 'list_tools' || toolMatch[3] === 'search_tools') {
const layer = layers.find(l =>
sanitizeServerName(l.serverName) === serverName &&
l.protocol === protocol
);
if (pseudoTool === 'list_tools') {
// Return the complete tool list for the server
result = JSON.stringify(layer.tools);
} else {
// Filter by keyword in name or description
const query = String(block.input.query ?? '').toLowerCase();
const matches = layer.tools.filter(t =>
t.name.toLowerCase().includes(query) ||
t.description.toLowerCase().includes(query)
);
result = JSON.stringify(matches);
}
}

Why intercept? So the agent can explore available tools without triggering a network connection. The agent can call list_tools() as often as it wants with zero network cost.

On the first real call to a server’s tool:

const serverKey = `${serverName}_${protocol}`;
if (!activatedServers.has(serverKey)) {
activatedServers.add(serverKey);
const layer = layers.find(l =>
sanitizeServerName(l.serverName) === serverName &&
l.protocol === protocol
);
if (layer) {
// Add ALL server tools to active tools
activeTools = activateServerTools(activeTools, layer);
}
}

After activation, the LLM receives the full tool list for the server on its next call. Activation is a one-shot operation: it happens once per session.

MCP dispatch flow from tool_use block to agent messages

MCP dispatch goes through an SSE (Server-Sent Events) client connected to the remote server:

if (protocol === 'mcp') {
if (!client) {
result = `Error: no MCP client available for tool ${name}`;
} else {
// Call the tool on the remote server
const mcpResult = await client.callTool(realToolName, block.input);
// Extract text content from the MCP response
const textContent = mcpResult.content?.find(
(c) => c.type === 'text'
) as { text?: string } | undefined;
const rawResult = textContent?.text ?? JSON.stringify(mcpResult);
// Truncate if result exceeds limit (default: 10,000 chars)
result = truncateResults
? truncateResult(rawResult, maxResultLength)
: rawResult;
}
}

Truncation prevents a large result (a 10,000-row table, for example) from filling the context window. The full result is stored in the resultBuffer and accessible via recall().

WebMCP dispatch flow with recall, widget_display, canvas and executeTool branches

WebMCP dispatch executes the tool locally in the browser:

if (protocol === 'webmcp') {
// Special case: recall (re-read a compressed result)
if (realToolName === 'recall' && resultBuffer.size > 0) {
const recallId = (block.input as { id: string }).id;
result = resultBuffer.get(recallId)
?? `No result found for id '${recallId}'.`;
} else {
// Normal dispatch to WebMCP server
const webmcpServer = webmcpServers.get(serverName);
if (!webmcpServer) {
result = `Error: no WebMCP server "${serverName}" found.`;
} else {
const toolResult = await webmcpServer.executeTool(
realToolName,
block.input
);
result = typeof toolResult === 'string'
? toolResult
: JSON.stringify(toolResult);
}
}
}

WebMCP dispatch handles three special cases beyond standard execution:

When the called tool is widget_display, the dispatcher detects the result and triggers the rendering callback:

if (realToolName === 'widget_display') {
const wr = toolResult as Record<string, unknown>;
if (wr.widget && wr.data && !wr.error) {
const widgetResult = callbacks.onWidget?.(
wr.widget as string,
wr.data as Record<string, unknown>
);
if (widgetResult?.id) {
result = JSON.stringify({ ...wr, id: widgetResult.id });
}
}
}

The onWidget callback is responsible for adding the widget to the canvas. The returned id lets the agent manipulate the widget in subsequent iterations (move, resize, update).

The canvas tool lets the agent manipulate existing widgets:

if (realToolName === 'canvas') {
const action = block.input.action as string;
const id = block.input.id as string;
const actionParams = block.input.params as Record<string, unknown>;
switch (action) {
case 'clear': callbacks.onClear?.(); break;
case 'update': callbacks.onUpdate?.(id, actionParams ?? {}); break;
case 'move': callbacks.onMove?.(id, x, y); break;
case 'resize': callbacks.onResize?.(id, width, height); break;
case 'style': callbacks.onStyle?.(id, styles); break;
}
}
ActionDescriptionExample Use
clearEmpty the entire canvas”Start from scratch”
updateModify a widget’s dataChange a stat’s value
moveReposition a widget (CSS transform)Rearrange layout
resizeResize a widgetEnlarge a chart
styleApply CSS stylesChange background color

recall is a pseudo-tool that re-reads a compressed result. It is intercepted before normal dispatch:

if (realToolName === 'recall' && resultBuffer.size > 0) {
const recallId = (block.input as { id: string }).id;
result = resultBuffer.get(recallId) ?? `No result found...`;
}

widget_display() is the primary tool for UI rendering. Here is the complete journey of a call:

widget_display validation, sanitization and rendering pipeline
// 1. Agent calls widget_display
{
type: 'tool_use',
id: 'toolu_5678',
name: 'autoui_webmcp_widget_display',
input: {
name: 'stat-card',
params: {
label: 'Conversion',
value: '3.2%',
trend: 'up',
variant: 'success'
}
}
}

The dispatcher executes these steps in sequence:

  1. Resolution: find the stat-card widget definition in the registry.
  2. Validation: check that params matches the JSON Schema (label and value required, trend must be one of [up, down, stable]).
  3. Sanitization: verify image URLs (if present) against an allowlist of domains.
  4. ID generation: create a unique identifier w_1a2b3c.
  5. Callback: call onWidget('stat-card', {...params}) to add the widget to the canvas.
  6. Canvas store: the store adds { id: 'w_1a2b3c', type: 'stat-card', data: {...} } to the block list.
  7. Svelte rendering: WidgetRenderer detects the new block and mounts the StatCard component with props.
  8. Display: the widget appears on the canvas.
// Agent omits a required field
{
name: 'autoui_webmcp_widget_display',
input: {
name: 'stat',
params: { label: 'Sales' } // Missing 'value' (required)
}
}
// Dispatcher returns error + schema
{
type: 'tool_result',
content: JSON.stringify({
error: 'Validation failed',
details: [{ path: '/value', message: 'required' }],
expected_schema: { type: 'object', required: ['label', 'value'], ... }
})
}

The agent receives the expected schema and can retry with the correct fields. This error-correction pattern is natural for LLMs.

// MCP server not connected
if (!client) {
result = `Error: no MCP client available for tool ${name}`;
}
// WebMCP server not registered
if (!webmcpServer) {
result = `Error: no WebMCP server "${serverName}" found.`;
}
try {
const toolResult = await webmcpServer.executeTool(realToolName, block.input);
result = ...;
} catch (e) {
call.error = e instanceof Error ? e.message : String(e);
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: `Error: ${call.error}`
});
}

Execution errors are caught and returned to the agent as normal tool_result messages. The agent can decide to retry, use a different tool, or report the failure to the user.

Every tool call is tracked with metrics:

const call: ToolCall = {
id: block.id, // Unique call ID
name: block.name, // Canonical tool name
args: block.input, // Parameters
result: result, // Result (or error)
error: undefined, // Error message if failed
elapsed: Math.round(performance.now() - t1), // Duration in ms
guided: wasDiscovering, // true if preceded by a discovery tool
};
metrics.toolCalls++;
callbacks.onToolCall?.(call);

The onToolCall callback enables:

  • Logging every call for debugging
  • Measuring MCP server latency
  • Counting tools called per iteration
  • Detecting loops (same tool called repeatedly with same parameters)

The <AgentConsole> component from @webmcp-auto-ui/ui displays these metrics in real time in the interface.

After approximately 2 iterations, old tool_result messages are compressed to save context:

// Before compression
{
type: 'tool_result',
tool_use_id: 'toolu_1234',
content: '{"data": [item1, item2, ... item1000], "total": 1000}'
// 5000 characters
}
// After compression (mutated in place in the messages array)
{
type: 'tool_result',
tool_use_id: 'toolu_1234',
content: '{"data": [item1, item2... [recall(\'toolu_1234\') for full result, 5000 chars]'
// 200 characters
}
// Full result stored in buffer
resultBuffer.set('toolu_1234', '{"data": [item1, item2, ... item1000]}');

The agent can re-read the full result at any time by calling recall('toolu_1234'). This mechanism is transparent: the agent decides whether it needs the full result or if the preview is enough.

flowchart TD
A[LLM generates tool_use] --> B{Valid format?}
B -->|No| ERR1[Error: unknown format]
B -->|Yes| C[Resolve alias]
C --> D{Pseudo-tool?}
D -->|Yes| E[Local response<br/>list/search_tools]
D -->|No| F{First call<br/>to this server?}
F -->|Yes| G[Activate all<br/>server tools]
F -->|No| H{Protocol?}
G --> H
H -->|MCP| I[Network call<br/>via SSE]
H -->|WebMCP| J{Special tool?}
J -->|recall| K[Read from<br/>resultBuffer]
J -->|widget_display| L[Validate + mount<br/>widget on canvas]
J -->|canvas| M[Manipulate<br/>existing widgets]
J -->|Standard| N[executeTool]
I --> O[Truncate if needed]
K --> O
L --> O
M --> O
N --> O
E --> O
O --> P[Compress old<br/>results]
P --> Q[Return tool_result<br/>to agent]

Why the {server}_{proto}_{tool} format instead of just the tool name?

Section titled “Why the {server}_{proto}_{tool} format instead of just the tool name?”

Because the same tool name can exist on multiple servers. For example, list_items could be defined on both a database and a filesystem server. The prefix disambiguates.

In theory, as many as the context window allows. In practice, beyond 50 active tools, LLM choice quality degrades. The lazy loading mechanism limits tools to only the servers actually used.

What happens if an MCP server does not respond?

Section titled “What happens if an MCP server does not respond?”

The call waits until a timeout (configurable via AbortSignal). The agent receives an error and can decide what to do next (retry, use a different tool, inform the user).

Pseudo-tools are managed in the dispatcher. To add one, you need to modify the interception logic in loop.ts. This is intentionally limited: pseudo-tools should be rare and well-defined.