How a tool call flows through the Model Context Protocol, traced across the specification, TypeScript SDK, and reference server implementations.
During initialize, the server declares it supports tools:
{ "capabilities": { "tools": { "listChanged": true } } }The client notes this capability and knows it can call tools/list and
tools/call. The listChanged flag indicates whether the server will send
notifications/tools/list_changed when its tool set changes at runtime.
Spec reference: docs/specification/draft/server/tools.mdx
Client ──► Server: { method: "tools/list" }
Server ──► Client: { tools: [{ name, description, inputSchema, outputSchema?, annotations? }] }
Each Tool object includes:
| Field | Required | Purpose |
|---|---|---|
name |
✓ | Programmatic identifier (1–128 chars, case-sensitive) |
title |
Human-readable display name | |
description |
What the tool does (for LLM and human consumption) | |
inputSchema |
✓ | JSON Schema defining expected arguments |
outputSchema |
JSON Schema for structured results | |
annotations |
Behavioral hints (see below) | |
execution |
Task execution support configuration |
Annotations are hints only — clients must not make security decisions based on untrusted server annotations.
interface ToolAnnotations {
readOnlyHint?: boolean; // Doesn't modify environment
destructiveHint?: boolean; // May perform destructive updates
idempotentHint?: boolean; // Safe to call repeatedly
openWorldHint?: boolean; // Interacts with external entities
}tools/list supports cursor-based pagination via PaginatedRequest /
PaginatedResult for servers with large tool sets.
Schema reference: schema/draft/schema.ts — ListToolsRequest (line ~1507),
ListToolsResult (line ~1519), Tool (line ~1730)
This is the core flow. The sequence below traces a tool call from the LLM's decision through the full protocol stack.
sequenceDiagram
participant LLM
participant Client
participant Server
LLM->>Client: Select tool to use
Client->>Server: tools/call { name, arguments }
Note over Server: Protocol layer: _onrequest()
Note over Server: Lookup handler in _requestHandlers map
Note over Server: Validate request against CallToolRequestSchema
Note over Server: Find registered tool by name
Note over Server: Validate arguments against inputSchema
Note over Server: Execute tool handler callback
Note over Server: Validate result against outputSchema
Server-->>Client: CallToolResult { content, structuredContent?, isError? }
Note over Client: Validate structuredContent against outputSchema
Client->>LLM: Process result
Request:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"location": "New York"
}
}
}Successful response:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy"
}
],
"isError": false
}
}CallToolResult.content is an array of content blocks. Supported types:
TextContent— plain text resultsImageContent— base64-encoded imagesAudioContent— base64-encoded audioResourceLink— links to external resourcesEmbeddedResource— full resource with URI and content
Schema reference: schema/draft/schema.ts — CallToolRequest (line ~1619),
CallToolResult (line ~1549)
Servers can notify clients when their tool set changes at runtime:
Server ──) Client: { method: "notifications/tools/list_changed" }
Client ──► Server: { method: "tools/list" } (re-fetches)
This enables servers that dynamically add/remove tools (e.g., based on user permissions or loaded plugins).
Schema reference: schema/draft/schema.ts — ToolListChangedNotification
(line ~1632)
MCP distinguishes between protocol errors and tool execution errors:
| Level | When | Example |
|---|---|---|
| Protocol error (JSON-RPC) | Unknown tool, malformed request | { error: { code: -32602, message: "Unknown tool" } } |
| Tool execution error (in result) | Tool ran but failed | { content: [{ type: "text", text: "..." }], isError: true } |
Why two levels? Tool execution errors are returned as results (not JSON-RPC errors) so LLMs can see the failure message and self-correct with adjusted parameters. Protocol errors indicate structural problems the LLM is unlikely to fix.
Example tool execution error:
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [
{
"type": "text",
"text": "Invalid departure date: must be in the future."
}
],
"isError": true
}
}Source: modelcontextprotocol/typescript-sdk
┌─────────────────────────────────────────────────────┐
│ McpServer (high-level API) │
│ packages/server/src/server/mcp.ts │
│ • registerTool() — user-facing registration │
│ • Input/output validation pipeline │
│ • Tool lookup by name │
├─────────────────────────────────────────────────────┤
│ Server (protocol implementation) │
│ packages/server/src/server/server.ts │
│ • setRequestHandler() override for tools/call │
│ • Schema validation wrapper │
│ • Task vs non-task result handling │
├─────────────────────────────────────────────────────┤
│ Protocol (base class) │
│ packages/core/src/shared/protocol.ts │
│ • JSON-RPC dispatch (_onrequest) │
│ • Handler registry (_requestHandlers map) │
│ • Capability assertion │
├─────────────────────────────────────────────────────┤
│ Transport (stdio, SSE, StreamableHTTP) │
│ packages/core/src/shared/transport.ts │
│ • Wire-level message sending/receiving │
└─────────────────────────────────────────────────────┘
McpServer.registerTool() accepts a name, config (with Zod schemas), and a
callback:
registerTool(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: ZodSchema;
outputSchema?: ZodSchema;
annotations?: ToolAnnotations;
},
cb: ToolCallback
): RegisteredToolUnder the hood, _initializeToolHandlers() registers two protocol handlers:
tools/list— iterates registered tools, converts Zod schemas to JSON Schematools/call— looks up tool by name, validates input, executes callback, validates output
When a tools/call message arrives:
- Protocol._onrequest() — looks up handler in
_requestHandlersmap - Server.setRequestHandler() wrapper — validates request against
CallToolRequestSchema, validates result againstCallToolResultSchema(orCreateTaskResultSchemafor task-based execution) - McpServer handler — finds tool by name, validates input with
validateToolInput(), callsexecuteToolHandler(), validates output withvalidateToolOutput() - Errors caught → wrapped as
{ content: [...], isError: true }
Client.callTool() sends the request and validates the response:
async callTool(params: CallToolRequest['params'], options?: RequestOptions) {
const result = await this._requestWithSchema(
{ method: 'tools/call', params },
CallToolResultSchema,
options
);
// Validate structuredContent against tool's outputSchema if present
const validator = this.getToolOutputValidator(params.name);
if (validator && !result.isError) {
const validationResult = validator(result.structuredContent);
if (!validationResult.valid) throw new ProtocolError(...);
}
return result;
}The SDK provides composable fetch middleware for HTTP transports:
- OAuth middleware (
withOAuth) — attaches bearer tokens, handles 401 retry - Logging middleware (
withLogging) — request/response timing - Custom middleware — any
(next: FetchLike) => FetchLikefunction
const transport = new StreamableHTTPClientTransport({
url: 'https://api.example.com',
fetchMiddleware: applyMiddlewares(withOAuth(provider), withLogging())
});Source: packages/client/src/client/middleware.ts
Source: modelcontextprotocol/servers
The filesystem server registers tools with Zod schemas and annotations:
const ReadTextFileArgsSchema = z.object({
path: z.string(),
tail: z.number().optional(),
head: z.number().optional()
});
server.registerTool(
"read_text_file",
{
title: "Read Text File",
description: "Read the complete contents of a file from the file system.",
inputSchema: { path: z.string(), tail: z.number().optional(), head: z.number().optional() },
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
async (args) => {
const validPath = await validatePath(args.path);
const content = await readFileContent(validPath);
return {
content: [{ type: "text" as const, text: content }],
structuredContent: { content }
};
}
);The fetch server uses @server.list_tools() and @server.call_tool()
decorators with Pydantic for validation:
class Fetch(BaseModel):
url: AnyUrl = Field(description="URL to fetch")
max_length: int = Field(default=5000)
start_index: int = Field(default=0)
raw: bool = Field(default=False)
@server.list_tools()
async def list_tools() -> list[Tool]:
return [Tool(name="fetch", description="...", inputSchema=Fetch.model_json_schema())]
@server.call_tool()
async def call_tool(name, arguments: dict) -> list[TextContent]:
args = Fetch(**arguments) # Pydantic validation
content, prefix = await fetch_url(str(args.url))
return [TextContent(type="text", text=f"{prefix}\n{content}")]Regardless of language, all reference servers follow the same pattern:
- Validate inputs — parse and validate against schema
- Normalize/authorize — check paths, permissions, robots.txt, etc.
- Execute — perform the actual operation
- Format output — return
CallToolResultwith content blocks
Tools also participate in the Sampling API, where servers request LLM
completions through the client. The server can include tool definitions in a
sampling/createMessage request, enabling multi-turn tool use loops:
sequenceDiagram
participant Server
participant Client
participant User
participant LLM
Server->>Client: sampling/createMessage (messages + tools)
Client->>User: Present request for approval
User-->>Client: Approve
Client->>LLM: Forward request with tools
LLM-->>Client: Response with tool_use (stopReason: "toolUse")
Client->>User: Present tool calls for review
User-->>Client: Approve
Client-->>Server: Return tool_use response
Note over Server: Execute tool(s) locally
Server->>Client: sampling/createMessage (history + tool_results + tools)
Client->>LLM: Forward with tool results
LLM-->>Client: Final text response (stopReason: "endTurn")
Client-->>Server: Return final response
Key difference: In this flow, the server executes tools locally and manages the multi-turn loop. The client maintains human oversight and LLM access control.
Spec reference: docs/specification/draft/client/sampling.mdx
- Validate all tool inputs against schema
- Implement proper access controls
- Rate limit tool invocations
- Sanitize tool outputs
- Prompt for user confirmation on sensitive operations
- Show tool inputs to user before calling server
- Validate tool results before passing to LLM
- Implement timeouts for tool calls
- Log tool usage for audit purposes
- Tool annotations are hints only — not security boundaries
- Human-in-the-loop should be default, especially with untrusted servers
- Clear UI should indicate which tools are being invoked
| Component | Location |
|---|---|
| Tool specification | docs/specification/draft/server/tools.mdx |
| Schema types | schema/draft/schema.ts (lines ~1500–1800) |
| Request/response examples | schema/draft/examples/CallToolRequest/ etc. |
| SDK Protocol base | typescript-sdk: packages/core/src/shared/protocol.ts |
| SDK Server (low-level) | typescript-sdk: packages/server/src/server/server.ts |
| SDK McpServer (high-level) | typescript-sdk: packages/server/src/server/mcp.ts |
| SDK Client | typescript-sdk: packages/client/src/client/client.ts |
| SDK Client middleware | typescript-sdk: packages/client/src/client/middleware.ts |
| Filesystem server (TS) | servers: src/filesystem/index.ts |
| Fetch server (Python) | servers: src/fetch/src/mcp_server_fetch/server.py |
| Sampling specification | docs/specification/draft/client/sampling.mdx |