Skip to main content

What is MCP?

Model Context Protocol (MCP) is an open standard that enables communication between AI models and external tools and services. Thousands of MCP servers exist for filesystems, databases, browsers, SaaS APIs, and more. Timbal agents can connect to any MCP server with MCPServer. The server’s tools are discovered at runtime and exposed to the LLM exactly like native tools — schemas, streaming, tracing, and error handling included.
This page covers consuming MCP servers from your agents. For the reverse direction — connecting your editor to the Timbal platform’s MCP server — see MCP Integration.

Basic Usage

Add an MCPServer to the agent’s tools list. Two transports are supported:
That’s it — before each LLM call, the agent lists the server’s tools and offers them to the model alongside any other tools. When the model calls one, the arguments are forwarded to the server and the result comes back as a tool result. With name= set, a tool declared as list_files on the filesystem server appears to the LLM as filesystem__list_files. The bare name is still used on the wire.

The project API

For routes exposed by the TypeScript SDK’s timbalMcp() plugin, use:
The endpoint and authorization resolve at execution time from the current run. Local development uses the API port’s /mcp; deployed projects use the project origin’s /api/mcp. Do not point MCP at the UI origin’s /mcp, capture a caller bearer during module import, or put a project service secret in source. Service mode cannot be combined with a manual URL or headers. Discovery and each tool call open and close their own connection, so concurrent users never share a connection authenticated as someone else. This adds a handshake per operation; explicit-URL MCP servers retain their existing persistent connection behavior. Discovery does not invoke application tools, and tool approvals still apply.

Configuration

Authentication

For servers that require auth, pass headers (http) or environment variables (stdio). Read secrets from the environment instead of hardcoding them:

How Results Are Handled

MCP tool results are converted so the LLM always receives something useful:
  • Text content becomes a plain string (or a list of strings for multiple blocks)
  • Images, audio, and binary resources become Timbal Files, forwarded to the model as file content — vision models can see returned images directly
  • Structured content is returned as-is when the server sends no text representation
  • Errors (isError) raise inside the tool call, so the model receives a proper error tool result and can self-correct

Approval from Annotations

MCP tools carry ToolAnnotations such as readOnlyHint and destructiveHint. approval= turns them into a client-side gate that runs through Timbal’s normal approval flow — a server cannot skip it:
  • "destructive" gates tools whose annotations mark them destructive (destructiveHint not false and readOnlyHint not true). Tools with no annotations at all are not gated — gating them would mean gating every tool of every server that never annotated. Note the spec default: a tool that sets any annotation without destructiveHint: false counts as destructive, so a server that annotates readOnlyHint: false on every writer gates every writer.
  • "all" gates every tool.
  • A one-argument callable receives the server’s mcp.types.Tool once, at resolve time, and returns True to gate every call of that tool.
  • A two-argument callable receives the tool and the call’s arguments and is evaluated per call, so one tool that multiplexes subcommands can gate some and wave others through. It may be async and may return a bool, a dict, or an ApprovalPolicyDecision (the same contract as requires_approval):
Gated tools emit an ApprovalEvent with kind="mcp_tool", the server’s input schema as input_schema, and ui={"server", "tool", "title", "annotations"}. Resume exactly like any other approval: resume={approval_id: True}. The gate runs client-side before anything is sent, so it is unaffected by parallel tool calls and survives reconnecting or rebuilding the MCPServer between the pause and the resume — the approval id is derived from the tool path and input, not the session.

Elicitation

Servers can ask the user a question mid tool call (elicitation) — for example, “Swap KB 1069 → 1586 (562 agents → 3)?” before a destructive operation. Timbal advertises the capability by default and bridges each request to suspend(): the run ends with status input_required and an InteractionEvent of kind="mcp_elicitation".
Resume values: Because the tool is re-invoked on resume, the server’s tool must be idempotent up to the elicit() call and its message deterministic for identical input — the suspension id is a hash of the tool path, the server’s message and schema, and the LLM’s tool-call id. Two or more tool calls in flight on the same session cannot be told apart when one of them elicits (the protocol carries no correlation), so the question is answered cancel for it: a call the server then fails gets an error asking the model to call the tool on its own; a call the server answers normally (a server that treats a cancelled confirmation as “not swapped”) succeeds with a note appended to its text result saying the question was auto-cancelled — every call that was in flight carries the note, since which one asked is unknowable. Prefer approval= for confirmations on tools the model is likely to call in parallel. URL-mode elicitations surface payload["url"]; resume with True once the out-of-band flow is complete. Set elicitation=False to hide the capability. Servers should then fail closed on tools that need confirmation rather than proceeding silently.
Stateless HTTP servers cannot do push elicitation or sampling. A 2025-era server that issues no Mcp-Session-Id treats every POST as independent, so the client’s answer lands on a transport that has no idea who asked and the server’s elicit() waits forever. Timbal detects this at connect time (a warning is logged) and, when such a server asks, fails the tool call immediately with a clear error instead of hanging. Use approval= for confirmations against stateless servers. One case is invisible to any client: a server that sends the request without related_request_id drops it before it leaves the server; only timeout= bounds that.

Server Notifications

  • tools/list_changed marks the cached tool list stale. It is refetched at the next run, never mid-run — swapping tools under the model’s feet would also invalidate the prompt cache.
  • Log messages (notifications/message) are forwarded to structlog as "MCP server log" with server, level, logger, and data.
  • Sampling (sampling/createMessage) is served with sampling_model= when set. The call runs as an mcp_sampling LLM span under the in-flight tool call, so its usage shows up in the trace.

Connection Lifecycle

Connections are lazy: nothing is spawned or contacted until the agent first needs the server’s tools. The tool list is fetched once, sorted by name (so the LLM’s tools prefix stays stable for prompt caching), and cached for the life of the connection. Close the connection when you’re done:
For long-running processes (servers, bots), create the MCPServer once and reuse it across runs — reconnecting per request adds latency, especially for stdio servers that spawn a subprocess.
Each connection is owned by a dedicated task, so close() can be called from any task and in any order across servers. Failure handling on top of the SDK:
  • Transport dies mid-call (server process exits, HTTP connection refused): the in-flight call fails immediately with connection lost during tools/call ... instead of hanging, the connection is forgotten, and the next call reconnects. The cached tools stay valid.
  • Stale session (an HTTP server restarted or expired the session while you were idle, so it 404s your Mcp-Session-Id with Session terminated): the session is reset and the request retried once, transparently. Safe for any request — a 404 means the server never saw it.
  • Server unreachable: ConnectionError("MCP server 'name' failed to connect: ConnectError: ...") with the real cause, not anyio’s unhandled errors in a TaskGroup.
  • Server accepts but never answers: timeout= (default 600s, per tool via tool_timeouts=) turns it into a TimeoutError; the session stays up and the next call works. The handshake is bounded only by connect_timeout= (default None, but the TCP connect itself is capped at 30s).
  • Slow HTTP tools: the transport’s read timeout is unbounded, so a tools/call that takes minutes completes. (A bare httpx.AsyncClient applied its 5-second default to the SSE read and the SDK swallowed the resulting ReadTimeout, so any HTTP tool slower than 5s hung the call forever.) Connect stays bounded at 30s (or connect_timeout) even when connect_timeout=None.
A tools/call that loses its connection mid-flight is never retried automatically — the tool may already have run. tools/list is.

Dynamic Resolution

MCPServer is a ToolSet: tools are resolved at runtime, not import time. This means the available tools always reflect what the server currently offers, and you can mix MCP servers freely with functions, built-in tools, and other agents in the same tools list.

Codegen CLI

The codegen CLI can add MCP servers to an agent’s source file:
Re-running add-mcp with the same --name replaces the server spec. Remove a server with remove-tool --name <server_name> — the assignment and import are cleaned up automatically.