MCP servers as native tools¶
McpClient adopts one MCP server into the kit's own tool contract. Its tools are
ordinary Tool values: arguments are validated in this process before a call leaves it,
the per-tool timeout is the kit's, failures land in the kit's error taxonomy, and every
result comes back inside the untrusted-data envelope. Nothing about an MCP tool is
exempt from what a locally defined tool is held to.
flowchart LR
Config[McpServerConfig<br/>name + allowlist + bounds] --> Client[McpClient]
Session[McpSession<br/>stdio / HTTP transport] --> Client
Client -->|discover| Tools[native kit Tools]
Tools -->|invoke| Client
Client -->|sealed result| Envelope[untrusted-data envelope]
The client speaks to an McpSession, which is a protocol: initialize, list_tools,
call_tool, close. Transports implement it, tests implement it in process, and
adopting a server therefore says nothing about how that server is reached.
Declare a server¶
Install the optional integration in a source checkout first:
For an application dependency, add mcp to the exact tagged artifact selected through
Keep agents current safely.
A server is configuration, resolved through the kit's normal precedence — code over environment over file over default:
from tesserix_adk.core.config import McpConfig, McpServerConfig
config = McpConfig(
servers=(
McpServerConfig(
name="handbook",
endpoint="http://handbook.internal/mcp",
allow=("search", "write_note"),
timeout_seconds=15.0,
max_tools=40,
max_result_bytes=64 * 1024,
),
)
)
The same declaration from the environment, where the environment is the layer that owns it, is JSON under one variable:
AdkConfig.mcp.server("handbook") returns the declaration or raises ConfigurationError
for a name nobody declared. A name may be declared only once.
Adopt its tools¶
from tesserix_adk.adapters import McpClient
from tesserix_adk.tools import ToolRegistry
async with McpClient(session, config=config.server("handbook")) as client:
discovery = await client.discover(known=registry.names)
registry = ToolRegistry(discovery.tools)
discover returns what was adopted, what was rejected and why, which names collided with
a local tool, and whether the discovery cap truncated the list. The tool view is then
held: tools() returns the same set for the life of the client, so a server that changes
its mind mid-run cannot widen an in-flight agent's surface. refresh() is how that view
changes, and it is always something the consumer chose to do.
An allow list, where one is set, is the only tools considered; everything else is a
rejection with its reason. max_tools then caps what survives, so a server advertising
hundreds of tools cannot flood a model's context.
What arrives, and what is refused¶
Each adopted tool carries the server's own JSON Schema, and arguments are checked against
it locally, so a call that would have failed at the server fails at validation with a
ToolArgumentValidationError instead. A root object that says nothing about extra fields
is read as forbidding them, and is advertised that way, so the schema shown to the model
and the schema enforced are the same document.
A schema the kit cannot hold a call to is refused rather than guessed at. That is an
McpSchemaError naming the server, the tool and the offending construct — a non-object
root, a non-local $ref, nesting past 16 levels, a schema over 32 KiB, or one the
validator cannot compile. The remaining tools still load; the client never registers a
tool it cannot validate arguments for.
Results and failures¶
A result is normalised — text as text, an image as [image image/png, N bytes elided], a
resource as [resource <uri>], structured content appended as sorted JSON — truncated at
max_result_bytes, and sealed with Origin.MCP_RESULT and the source server/tool.
Instruction-like text in a server's response is therefore inert for exactly the reason it
is inert from a local tool: it arrives inside an envelope it cannot close.
| What happened | What the caller gets |
|---|---|
The call outlived timeout_seconds |
ToolTimedOutError |
| The server could not be reached | ToolFailure(code="mcp_unavailable"), retryable |
| The server reported a tool error | ToolFailure(code="mcp_tool_error"), not retryable |
| The server signalled a decline | ToolRefusal carrying the server's refusal code |
The server's own words never enter an error message; a message a model reads outside the envelope is a message an untrusted server would like to write. MCP tools are treated as effectful, so nothing retries one on the consumer's behalf, and the run id and the idempotency key travel as call metadata for a server that wants to deduplicate.
Known limitations¶
- A tool's
descriptionand its schema prose are the server's text and are shown to the model as the tool declaration. Adopt servers you would let write a prompt, or wait for the allowlist and namespacing story that narrows this further. - Names are adopted as advertised. A collision with a local tool is reported through
discovery.conflictsand withheld, not renamed — namespacing is a separate story. - The client carries no authority of its own: auth pass-through and tenant propagation are owned by their own story, and only the run id and idempotency key are sent as metadata today.
For a network-free executable composition, run
uv run --extra mcp python examples/mcp_client.py.