Skip to content

Wiring MCP Tools to an LLM

MCP tells you how to discover and invoke tools. It says nothing about language models. The bridge between the two is host code you write: convert MCP tool definitions into whatever your model provider expects, let the model pick a tool, route the call to the right client, and feed the result back. This is the agent loop, and every MCP host has one.

sequenceDiagram
  participant U as User
  participant H as Host
  participant M as LLM
  participant C as MCP client
  participant S as MCP server
  U->>H: message
  H->>C: tools/list (once, cached)
  C->>S: tools/list
  S-->>C: tools[]
  H->>M: messages + tool definitions
  M-->>H: tool_use (name, input)
  H->>H: ask user? (annotations)
  H->>C: callTool(name, input)
  C->>S: tools/call
  S-->>C: content[], isError
  H->>M: tool_result blocks
  M-->>H: final text
  H-->>U: answer
The agent loop inside a host

An MCP tool has name, description, and a JSON Schema inputSchema. The Anthropic Messages API wants name, description, and input_schema. The mapping is nearly a rename — the important host decision is namespacing: prefix each name with the server it came from so two servers that both expose search do not collide, and so you can route the call back later.

import type Anthropic from '@anthropic-ai/sdk';
import type { Client } from '@modelcontextprotocol/client';
type Route = { client: Client; toolName: string };
const routes = new Map<string, Route>();
async function collectTools(servers: Record<string, Client>): Promise<Anthropic.Tool[]> {
const defs: Anthropic.Tool[] = [];
for (const [serverName, client] of Object.entries(servers)) {
const { tools } = await client.listTools();
for (const t of tools) {
const name = `${serverName}__${t.name}`;
routes.set(name, { client, toolName: t.name });
defs.push({
name,
description: t.description ?? '',
input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
});
}
}
return defs;
}

Keep tool order stable between turns. Servers are asked to return tools/list in a deterministic order for exactly this reason: the tools array is part of the cached prompt prefix, and reshuffling it invalidates the cache.

An MCP result is a content array of blocks (text, image, audio, resource_link, resource) plus optional structuredContent and isError. A tool_result block for the Messages API accepts text and image content. Convert what maps, summarize what does not, and — crucially — pass isError through as is_error so the model knows to try again.

function toToolResult(
toolUseId: string,
result: Awaited<ReturnType<Client['callTool']>>,
): Anthropic.ToolResultBlockParam {
const content: Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam> = [];
for (const block of result.content as any[]) {
if (block.type === 'text') content.push({ type: 'text', text: block.text });
else if (block.type === 'image')
content.push({ type: 'image', source: { type: 'base64', media_type: block.mimeType, data: block.data } });
else content.push({ type: 'text', text: `[${block.type}] ${block.uri ?? ''}` });
}
return { type: 'tool_result', tool_use_id: toolUseId, content, is_error: result.isError === true };
}

Call the model. If it stops with tool_use, execute every tool_use block in that turn (they may be parallel), collect the results, and return them all in one user message. Splitting results across several messages silently teaches the model to stop parallelizing. Loop until the stop reason is something else.

import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
async function runAgent(userText: string, tools: Anthropic.Tool[]) {
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: userText }];
while (true) {
const response = await anthropic.messages.create({
model: 'claude-opus-5',
max_tokens: 16000,
tools,
messages,
});
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason !== 'tool_use') return response;
const uses = response.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');
const results = await Promise.all(
uses.map(async (use) => {
const route = routes.get(use.name)!;
if (!(await userApproves(use))) {
return { type: 'tool_result', tool_use_id: use.id, content: 'User denied this call.', is_error: true } as const;
}
const result = await route.client.callTool({ name: route.toolName, arguments: use.input as Record<string, unknown> });
return toToolResult(use.id, result);
}),
);
messages.push({ role: 'user', content: results }); // all results, one message
}
}

Two details are easy to miss. First, always parse tool_use.input as JSON data, never by string-matching the serialized form. Second, when the user denies a call, still return a tool_result for that tool_use_id — marked is_error — so the conversation stays well-formed and the model can explain or choose another route.

The spec asks hosts to keep a human in the loop with the power to deny any invocation. Tool annotations are the cheapest signal you have:

AnnotationReasonable host policy
readOnlyHint: trueAuto-approve, show in a log
destructiveHint: trueAlways prompt, show the exact arguments
idempotentHint: trueSafe to retry after a timeout
openWorldHint: trueWarn that the tool reaches external systems

Annotations are hints from the server, and the spec says to treat them as untrusted unless the server is trusted. A malicious server can label a destructive tool as read-only. Use annotations to reduce friction for servers you trust, never as the only gate for servers you do not.

  • Cache tools/list. Respect ttlMs; refresh when notifications/tools/list_changed arrives on your subscriptions/listen stream.
  • Budget tool output. A tool that returns 200 KB of JSON will eat the context window. Truncate or summarize host-side, and prefer structuredContent when the tool has an outputSchema.
  • Bound every call. Use a timeout per tools/call; on timeout, return an is_error result rather than hanging the loop.
  • Log for audit. Record which server ran which tool with which arguments. Servers may be third parties.
When the model returns three tool_use blocks in one turn, how should the host return the results?
Why prefix MCP tool names with the server name before giving them to the model?
A tool returns isError: true with the text "quantity must be positive". What should the host do?
How should a host treat a tool whose annotations say readOnlyHint: true from an unknown server?