Skip to content

Async Dynamic Tool Registration #30

@MiguelsPizza

Description

@MiguelsPizza

Porting this from a discussion in the MCP contributors discord

Dynamic tool registration in MCP, especially WebMCP, suffers from a race condition where tools/list_changed notifications lag behind tools/call results, causing agents to use stale toolsets (VSCode #261611). The idea is to add registers, unregisters, and updates fields to CallToolResult to declare expected toolset changes upon a successful tool call. Clients with a wait_for capability can delay forwarding results until the toolset updates with the expected tools.

While it's a feature of traditional MCP, I've come to the conclusion that directly registering tools from other tools is an anti-pattern in WebMCP (after writing many WebMCP servers). WebMCP works best when tools are a function of UI state. (I'll make another issue about this)

The issue this causes is the tool list updates to come after the tool response has been sent to the model. The model then makes its next move before the tool list has been updated. For example in a multi-page app, when you call a tool which navigates to the new page, the model might choose to call a tool on the old page which no longer exists.

I've set a timeout after each tool call response in MCP-B but this forces a tradeoff between UX and reliability based on the timeout length.

This is what I have in mind for the implementation (Feedback very welcome):

interface CallToolResult {
  _meta?: { [key: string]: unknown };
  content: ContentBlock[];
  is_error?: boolean;
  structured_content?: { [key: string]: unknown };
  /**
   * Tools expected to register post-execution. Clients wait for these in tools/list_changed.
   */
  registers?: string[]; // Default: [] (no wait)
  /**
   * Tools expected to unregister post-execution. Clients wait for absence in tools/list_changed.
   */
  unregisters?: string[]; // Default: [] (no wait)
  /**
   * same as the others but for tools expected to have internal changes as a result of just executed tool
   */
  updates?: string[]; // Default: [] (no wait)
}
interface ClientCapabilities {
  tools: {
    wait_for: boolean;
    wait_for_timeout: number; // how long to wait before sending the response if we don't get the expect tool updates
  };
}

Example tool call response:

{
  "content": ["..."],
  "registers": ["read_file", "write_file"],
  "unregisters": ["temp_tool"],
  "updates": ["foo_tool"]
}
sequenceDiagram
    participant M as Model
    participant C as Client
    participant S as WebMCP Server
    participant UI as UI State
    Note over M,C: Initial tools: [unlock_files]
    M->>C: Call unlock_files
    C->>S: tools/call unlock_files {args}
    S->>UI: Execute, update state
    S->>C: tools/call result {registers: ['read_file', 'write_file'], unregisters: [], updates: []}
    Note over C: Wait for tools/list_changed matching registers/unregisters/updates (re-fetch list)
    UI->>S: Register read_file
    S->>C: tools/list_changed [unlock_files, read_file]
    Note over C: Partial match continue waiting
    UI->>S: Register write_file
    S->>C: tools/list_changed [unlock_files, read_file, write_file]
    alt Matches expectations
        C->>S: tools/list request
        S->>C: tools/list response [unlock_files, read_file, write_file]
        C->>M: Forward result (read_file, write_file available)
    else Timeout (e.g., after 5s)
        C->>M: Forward result (may be stale log warning)
    end
    Note over M,C: Next turn: read_file, write_file ready
Loading

@bwalderman @khushalsagar have you run into this issue in the chromium implementation/testing?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions