Integrations¶
An integration lets an agent use software that was never written for agentkit — and, where the arrow reverses, lets other software use agentkit.
Today there is exactly one: the Model Context Protocol (MCP), under
agentkit.integrations.mcp.
Is this page for you?
Reach for it when you want your agent to use MCP servers, or you want to expose your own tools to something else that speaks MCP.
Skip it for now if your tools are ordinary Python functions and nothing outside your process needs to call them.
The problem it solves¶
MCP is a small wire protocol for the thing every agent framework reinvents: "here are some tools you can call, some documents you can read, and some prompts you can use." A server implements that protocol; a client — your agent — talks to it. The server can be a subprocess on the same machine or an HTTP endpoint on another continent, and it can be written in any language.
The reason this matters is arithmetic. There are thousands of published MCP servers — filesystem, git, GitHub, Postgres, Slack, search, ticket systems — plus whatever internal ones your company already runs. Without a protocol, using any of them from Python means writing and maintaining an adapter per server: a schema by hand, an argument-marshalling layer, an error path, a test. That work does not compose and it does not stop. With a protocol, one adapter serves all of them.
So agentkit.integrations.mcp is a translation layer with three
directions and two reversals:
| MCP concept | becomes an agentkit | via |
|---|---|---|
| tool | Tool |
mcp_tools(client) |
| resource | MemorySource |
mcp_resources(client) |
| prompt | Prompt |
mcp_prompts(client) |
(reversed) your Asker |
an MCP server that answers permission prompts | ApprovalServer |
(reversed) your ToolRegistry |
an MCP server that runs your tools | serve_registry |
Each integration is opt-in and gated behind a pyproject extra so the
core stays dependency-free:
Importing agentkit.integrations.mcp without the extra raises
ImportError immediately, with the install line in the message, rather
than failing somewhere deep in a transport later.
There is a recipe for the common case
Consume MCP tools from an agent is the copy-paste task page. This page is the layer above it: what the pieces are, which transport applies, what is cached and for how long, and what the server side is for.
The smallest thing that works¶
This spawns a real MCP server as a subprocess, adapts its tools, and
calls one. It needs no API key, no network, and no third-party server —
the server is six lines of mcp and runs under the same interpreter.
"""Spawn a tiny MCP server and call its tool as an agentkit Tool."""
import asyncio
import logging
import sys
from agentkit.integrations.mcp import MCPClient, StdioServer, mcp_tools
from agentkit.core.testing import make_test_ctx
logging.disable(logging.INFO) # the mcp SDK narrates every request at INFO
# A complete MCP server, run via `python -c` so this file stands alone.
# In real use `command` is `uvx`, `npx`, or a compiled binary.
SERVER = """
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("demo", log_level="ERROR")
@mcp.tool()
def add(a: int, b: int) -> int:
"Add two integers."
return a + b
mcp.run()
"""
async def main() -> None:
server = StdioServer(command=sys.executable, args=("-c", SERVER))
async with MCPClient(server) as mcp:
tools = await mcp_tools(mcp, prefix="demo_")
tool = tools[0]
print(tool.name, "|", tool.description)
print("params:", tool.schema.parameters["properties"])
print("side_effecting:", tool.side_effecting)
ctx = make_test_ctx()
print("result:", await tool.run({"a": 2, "b": 3}, ctx))
asyncio.run(main())
demo_add | Add two integers.
params: {'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'integer'}}
side_effecting: True
result: 5
The returned objects satisfy the Tool Protocol structurally — no
inheritance, no registration — so they drop into a cognition unchanged:
"""The same server, driven by a scripted FakeLLM instead of a real one."""
import asyncio
import logging
import sys
from agentkit.agents import Agent
from agentkit.agents.cognition import ReActCognition
from agentkit.integrations.mcp import MCPClient, StdioServer, mcp_tools
from agentkit.core.contracts.types import ToolCall
from agentkit.core.testing import FakeLLM, Turn, make_test_ctx
logging.disable(logging.INFO)
SERVER = """
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("demo", log_level="ERROR")
@mcp.tool()
def add(a: int, b: int) -> int:
"Add two integers."
return a + b
mcp.run()
"""
async def main() -> None:
llm = FakeLLM.script([
Turn(tool_calls=(ToolCall(id="1", name="demo_add", arguments={"a": 2, "b": 3}),)),
Turn(content="2 + 3 is 5."),
])
ctx = make_test_ctx(llm=llm)
async with MCPClient(StdioServer(command=sys.executable, args=("-c", SERVER))) as mcp:
agent = Agent(
name="calc",
model="fake",
prompt="Use the tools.",
cognition=ReActCognition(tools=await mcp_tools(mcp, prefix="demo_")),
)
result = await agent.run("What is 2 + 3?", ctx)
print("output:", result.output, "| stop_reason:", result.stop_reason)
asyncio.run(main())
Swap FakeLLM for a real provider and nothing about the MCP wiring
changes.
How it works¶
MCPClient owns the connection¶
The raw MCP SDK gives you a two-level context stack: open a transport,
then open and initialize a session on top of it. MCPClient collapses
that into one async with, using an AsyncExitStack so a failure
half-way through entering unwinds what was already opened — a spawned
subprocess or an HTTP client does not leak because initialize()
raised.
After entry, client.session is the live mcp.ClientSession. Before
entry — or after exit — reading it raises RuntimeError with the
message "MCPClient not entered — use async with MCPClient(...) as c:".
On exit the client drops its caches as well as its session, so a torn-down client can never hand back tool metadata for a server it is no longer connected to.
Two transports¶
from agentkit.integrations.mcp import StdioServer, StreamableHttpServer
local = StdioServer(
command="uvx",
args=("mcp-server-time", "--local-timezone", "UTC"),
env={"TZ": "UTC"}, # overlays on os.environ in the subprocess
cwd="/srv/workdir",
)
remote = StreamableHttpServer(
url="https://mcp.example.com/mcp",
headers={"Authorization": "Bearer …"},
timeout_s=30.0,
)
StdioServer |
StreamableHttpServer |
|
|---|---|---|
| Shape | spawns a subprocess, speaks over its stdin/stdout | HTTP POST with SSE responses |
| Use when | the server is a CLI you can run locally (uvx, npx, a binary) |
the server is hosted, shared, or behind auth |
| Lifetime | one process per MCPClient context |
one HTTP client per MCPClient context |
| Auth | environment variables via env |
headers |
Most published community servers are stdio. Hosted and multi-tenant servers are streamable HTTP.
Both configs are frozen dataclasses, and both deep-freeze their one
mutable field in __post_init__ — env for stdio, headers for HTTP.
Both fields carry the same risk, so both get the same treatment.
env is handed to a subprocess. Edit it in place later and the next
spawn inherits something different, while the config object still reads
as the one that was reviewed.
headers can carry credentials. As the source puts it: "a value that can
be edited after review is one that can be edited after the review that
approved it."
Neither field participates in __hash__. Identity is the process
(command, args, cwd) or the endpoint (url, timeout_s).
The HTTP transport moved underneath this adapter
Upstream renamed and re-signatured the streamable-HTTP transport:
streamablehttp_client(url, headers=…, timeout=…) became
streamable_http_client(url, http_client=…), moving ownership of
the httpx.AsyncClient to the caller. The old spelling still works
but emits a DeprecationWarning — which any caller running under
-W error (this project's own test suite included) turns into a
hard failure raised from inside the transport, where it reads as a
connection problem rather than a deprecation.
client.py prefers the new spelling and keeps the old as the floor
of the supported mcp>=1.28,<2 range. On the new path it builds the
httpx.AsyncClient itself and enters it on the exit stack above
the transport, so unwinding tears the transport down first and it
never writes into a closed client.
Caching, and its 30-second TTL¶
list_tools() and list_resources() are cached client-side for
_DEFAULT_TTL_S = 30.0 seconds. The MCP spec allows a server to
advertise its own cache TTL on list responses, but the current Python
client does not surface that field, so the adapter applies a fixed
client-side value instead of inventing one per server.
Two ways out of the cache:
async def refresh(client):
# A server that just registered a new tool won't be visible for up
# to 30s otherwise.
return await client.list_tools(force_refresh=True)
…and exiting the client context, which clears both caches.
list_prompts() is not cached — prompts are fetched fresh every call.
There is a second, independent 30-second cache inside mcp_resources():
the returned MemorySource caches its own resource list so a burst of
query() calls does not re-list on every one. It is a separate layer, so
a force_refresh on the client does not reach through it.
Resources become memory¶
MCP resources are named documents the server exposes for reading.
mcp_resources() presents them as a queryable MemorySource: on
query() it lists resources, filters by substring match against
name / description / URI, reads the top-k matches, and returns
MemoryItems.
The ranking is deliberately coarse in v1 — substring match, ties broken
by first-seen order. If you want vector-quality ranking, wrap the source
in CachedMemory / CompactedMemory or put it in a CompositeMemory
next to a VectorMemory.
Writes raise NotImplementedError. MCP resources are server-authored;
a client-side write path would be a protocol violation.
Prompts become Prompt objects¶
MCP prompts come in two flavours and only one survives adaptation:
- Static (no arguments, or all optional) — rendered eagerly at
adaptation time and stored as
Prompt.template, withversion="mcp:1". - Argumented (any argument with
required=True) — dropped with a logged warning, because there is no template to materialise without the arguments. Reach forMCPClient.get_prompt(name, args)directly instead.
A prompt whose render fails is also skipped with a warning rather than sinking the whole batch.
"""Resources and prompts from the same server."""
import asyncio
import logging
import sys
from agentkit.integrations.mcp import MCPClient, StdioServer, mcp_prompts, mcp_resources
from agentkit.core.testing import make_test_ctx
logging.disable(logging.INFO)
SERVER = """
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("demo", log_level="ERROR")
@mcp.resource("docs://runbook")
def runbook() -> str:
"On-call runbook."
return "Restart the ingest worker, then check the queue depth."
@mcp.prompt()
def triage() -> str:
"Static triage prompt."
return "You triage incidents. Be terse."
@mcp.prompt()
def review(diff: str) -> str:
"Needs an argument."
return f"Review this diff: {diff}"
mcp.run()
"""
async def main() -> None:
ctx = make_test_ctx()
async with MCPClient(StdioServer(command=sys.executable, args=("-c", SERVER))) as mcp:
memory = mcp_resources(mcp, name="runbooks")
items = await memory.query("runbook", k=1, ctx=ctx)
print("memory:", items[0].content)
print("metadata:", items[0].metadata)
prompts = await mcp_prompts(mcp)
print("static:", {k: (v.version, v.template) for k, v in prompts.items()})
print("argumented, fetched directly:", await mcp.get_prompt("review", {"diff": "-a +b"}))
asyncio.run(main())
mcp_prompts: dropping 'review' — MCP prompt requires arguments, cannot render statically. Use MCPClient.get_prompt(name, args) directly.
memory: Restart the ingest worker, then check the queue depth.
metadata: {'uri': 'docs://runbook', 'name': 'runbook', 'mimeType': 'text/plain'}
static: {'triage': ('mcp:1', 'You triage incidents. Be terse.')}
argumented, fetched directly: Review this diff: -a +b
The warning on the first line is the drop, working as designed.
The other direction: agentkit as an MCP server¶
Two things here are servers, and both exist for one reason.
ClaudeCliCognition hands the whole agent loop to the Claude CLI, and
the CLI owns that loop: you cannot step into it, wrap a tool call, or
answer a prompt from inside. Anything you want it to do differently has
to be handed to it on the way in — and the seams it offers are MCP
servers.
| what you want | the CLI's seam | agentkit's server |
|---|---|---|
| a human to approve each action | --permission-prompt-tool |
ApprovalServer |
| the loop to call your tools | --mcp-config |
serve_registry |
Both run on the same LoopbackMcpTransport: one bind-and-wait loop,
one shutdown that genuinely releases the port, one
mcp__<server>__<tool> spelling. That is worth a sentence because the
second server was written when the first already had all of it, and two
copies of a bind-and-wait loop is two places to fix "handed the CLI a
URL that was not listening yet" — only one of which would have been
fixed.
ApprovalServer is the narrower of the two, and it came first.
The problem it solves is specific. The CLI owns its own permissions,
which left a service two options, both bad:
bypassPermissions (the agent may do anything, unattended) or
dontAsk (anything not pre-approved is denied outright and the run just
fails). agentkit already had the missing middle — Asker, the injected
human transport behind its own human-in-the-loop path — but nothing
connected the two.
The CLI's seam is --permission-prompt-tool: name an MCP tool and the
CLI calls it instead of prompting a terminal, sending the tool name and
arguments and expecting an allow/deny back. So ApprovalServer is an
MCP server. Each prompt becomes an Elicitation, the application's
Asker answers it, and the answer maps back onto the CLI's wire shape.
The returned CLI configuration also disables ambient user, project, and
local setting sources while leaving subscription authentication intact. This
is part of the approval guarantee: a global permissions.allow = ["Write"]
would otherwise settle the call before the permission-prompt tool could see it.
Because an Asker may await a person for as long as it likes, the CLI
turn parks in place — the same behaviour agentkit's own tool loop gets
from the same protocol.
"""agentkit as the MCP server — driven here by agentkit's own MCP client."""
import asyncio
import logging
from agentkit.agents.control.elicitation import Decision, Elicitation
from agentkit.integrations.mcp import ApprovalServer, MCPClient, StreamableHttpServer
logging.disable(logging.INFO)
class AutoReviewer:
"""A stand-in Asker. A real one awaits a person over a socket."""
async def ask(self, request: Elicitation) -> Decision:
path = (request.tool_call or {}).get("arguments", {}).get("file_path", "")
if path.startswith("/etc/"):
return Decision(kind="reject", actor="ci", note="/etc is out of scope")
return Decision(kind="approve", actor="ci")
async def main() -> None:
async with ApprovalServer(
asker=AutoReviewer(), timeout_s=30.0, auto_allow=("Read",)
) as approvals:
print("tool_name: ", approvals.tool_name)
print("cli_kwargs:", approvals.cli_kwargs())
# Ask it the questions the CLI would ask.
async with MCPClient(
StreamableHttpServer(url=approvals.url, headers=approvals.auth_headers)
) as client:
print("tools: ", [t.name for t in await client.list_tools()])
for args in (
{"tool_name": "Read", "input": {"file_path": "/etc/passwd"}},
{"tool_name": "Write", "input": {"file_path": "/etc/passwd"}},
{"tool_name": "Write", "input": {"file_path": "out.txt"}},
):
res = await client.call_tool("approve", args)
print(f" {args['tool_name']:6} -> {res.content[0].text}")
print("prompts_seen:", approvals.prompts_seen)
for d in approvals.decisions:
print(f" {d.tool:6} allowed={d.allowed!s:5} "
f"source={d.source:10} asked={d.asked}")
asyncio.run(main())
tool_name: mcp__agentkit_approvals__approve
cli_kwargs: {'mcp_config': ('{"mcpServers": {"agentkit_approvals": {"type": "http", "url": "http://127.0.0.1:65162/mcp", "headers": {"Authorization": "Bearer <43-char token>"}}}}',), 'strict_mcp_config': True, 'permission_prompt_tool': 'mcp__agentkit_approvals__approve'}
tools: ['approve']
Read -> {"behavior": "allow", "updatedInput": {"file_path": "/etc/passwd"}}
Write -> {"behavior": "deny", "message": "/etc is out of scope"}
Write -> {"behavior": "allow", "updatedInput": {"file_path": "out.txt"}}
prompts_seen: 3
Read allowed=True source=auto_allow asked=False
Write allowed=False source=asker asked=True
Write allowed=True source=asker asked=True
Those last three lines are the point of decisions, and they are the
reason a count could not do the job. The Read was allowed because it
was on auto_allow — nobody was consulted, asked=False. The two
Writes reached the reviewer. prompts_seen is 3 for all of them
and says which is which about none of them.
source is a closed set for the same reason:
source |
what happened |
|---|---|
asker |
a person was consulted and answered |
auto_allow |
the tool was pre-approved; no Asker was invoked |
autonomy |
the run's tier did not gate this call |
timeout |
nobody answered in time — denied, and not a refusal |
error |
the Asker raised; denied, and not a policy decision |
The two that matter most are the ones a boolean collapses. Allowed
because a person said so and allowed because the tier does not gate
are the facts an auditor most needs apart. And a timeout that degraded
to a deny must not read afterwards as a human who said no.
asked is separate from source == "asker" on purpose: a prompt can
reach a human and then expire. Whether somebody was interrupted is a
fact about what the run cost a person, and it is not recoverable from
the verdict.
Every decision is also emitted on ctx.emit as gate.check as it
happens, best-effort — the in-memory append comes first, because a
refusal that happened matters more than a record of it that did not.
arguments is held on the record but deliberately kept off the
broadcast.
The port is ephemeral — ApprovalServer asks the OS for a free loopback
port at start(), so you will see a different one. Picking a fixed port
collides the moment two agents run on one host.
What this example does and does not prove
Every line above ran: the server bound a loopback port, served a
real MCP session, and answered three real tool calls. What it does
not exercise is the Claude CLI itself — the binary that would
normally be the client. The wire values (tool_name, mcp_config,
cli_kwargs()) are the real ones the CLI is handed; that it accepts
them is verified by this project's own tests against the binary, not
by this snippet.
In real use you never call approve yourself. You hand
cli_kwargs() straight to the cognition:
from agentkit.agents import Agent
from agentkit.models.providers.cli.claude import ClaudeCliCognition
from agentkit.integrations.mcp import ApprovalServer
async def run(asker, task, ctx):
async with ApprovalServer(asker=asker, timeout_s=120.0) as approvals:
cognition = ClaudeCliCognition(model="claude-sonnet-4-6", **approvals.cli_kwargs())
return await Agent(name="dev", cognition=cognition).run(task, ctx)
The decision table¶
ApprovalServer._decide never raises. It runs inside an MCP request
handler, and an exception there reaches the CLI as a malformed result
— which it reports as a broken permission system rather than a denial,
leaving the model free to retry a call nobody approved. So a transport
failure is a deny with the reason attached.
Decision.kind |
CLI sees | Note |
|---|---|---|
approve, value |
allow with the original arguments |
|
modify (dict value) |
allow with the edited arguments |
approve-with-changes; the model is not told they changed |
reject (or anything else) |
deny with note |
|
expired |
deny, message names the deadline |
fires from timeout_s, enforced by the server |
Two details worth internalising:
- The default has to be deny. An approval gate that fails open is not a gate. But the message says which denial it was, because "the reviewer said no" and "nobody answered in 60s" call for different fixes.
timeout_sis enforced by the server, not trusted to theAsker. TheAskerprotocol allows an implementation to wait forever, and a queue worker holding a CLI subprocess open indefinitely is a resource leak with a model attached.timeout_s=Nonewaits forever — right for an interactive UI, wrong for a worker.
auto_allow, and why it exists¶
auto_allow names tools approved without asking. It is not a
convenience knob. The CLI prompts for reads too, and a person clicking
"yes" on forty Read calls is not oversight — it is habituation, the
thing that makes the fortieth prompt, the one that mattered, get the
same reflexive yes.
It is also blunt: the match is on tool name only. In the run above,
Read /etc/passwd was allowed without reaching the reviewer, while
Write /etc/passwd was denied — because Read is in auto_allow and
arguments are not consulted. Put only tools you would allow with any
arguments in that tuple.
That is worth stating plainly because the mistake is easy to make in the
safe-sounding direction: it is tempting to read auto_allow=("Read",) as
"reading is fine", when it means "reading anything is fine, including
~/.ssh/id_rsa".
Narrowing it by argument¶
auto_allow_when(tool_name, arguments) -> bool is the opt-in filter for
when a tool is mostly safe:
from agentkit.agents.control.elicitation import Decision, Elicitation
from agentkit.integrations.mcp.approvals import ApprovalServer
class Reviewer:
async def ask(self, request: Elicitation) -> Decision:
return Decision(kind="approve", actor="ops")
server = ApprovalServer(
asker=Reviewer(),
auto_allow=("Read",),
auto_allow_when=lambda tool, args: not str(
args.get("file_path", "")
).startswith("/etc/"),
)
Read README.md auto-allowed: True
Read /etc/passwd auto-allowed: False # falls through to the reviewer
Two properties make this safe to put on an approval seam:
- It can only subtract. The gate is
tool_name in auto_allowand the predicate, so the predicate filters an already-approved set. A predicate returningTruefor a tool that is not inauto_allowcannot let it through — it is never a second way in. - A raising predicate falls through to the reviewer. A bug in your filter costs a prompt, not an approval.
Leaving it unset (the default) keeps the old behaviour exactly.
Serving your own tools: serve_registry¶
ApprovalServer answers questions about what the CLI wants to do.
serve_registry gives it something to do that it did not ship with.
The gap here was narrow and total. Everything needed to describe an
agentkit tool to a model was already present and already correct:
FunctionTool derives a ToolSchema from a signature and a docstring,
ToolRegistry holds them and refuses a duplicate name, and
ToolArgumentError refuses a bad call with a message naming the tool,
the offending arguments and the accepted set. ClaudeCliCognition
already accepted mcp_config. Only the wire was missing — nothing in
agentkit could produce the document that flag reads. So a service that
wanted the CLI to run its own deploy() wrote MCP JSON by hand, stood
a server up beside it, and then owned the job of keeping a hand-written
schema in step with the Python signature it claimed to describe.
serve_registry takes the registry you already have and hands back an
McpServerSpec: the config file, the qualified tool names, the safety
declarations, and — for the HTTP transport — the listener.
"""agentkit as the MCP server: a ToolRegistry the Claude CLI can call.
Driven here by agentkit's own MCP client over a real loopback socket,
because that is the only way to show the error contract holding.
"""
import asyncio
import json
import logging
from agentkit.integrations.mcp import MCPClient, StreamableHttpServer, serve_registry
from agentkit.core.testing import make_test_ctx
from agentkit.tools import ToolRegistry, tool
logging.disable(logging.INFO)
@tool(side_effecting=False)
def run_check(name: str, strict: bool = False) -> str:
"""Run the named check and report whether it passed."""
return f"{name}:{'strict' if strict else 'lax'}:ok"
@tool(side_effecting=True, requires_approval=True, caps=("egress",))
def deploy(target: str) -> str:
"""Deploy the current build to the named target environment."""
return f"deployed to {target}"
async def main() -> None:
ctx = make_test_ctx()
spec = serve_registry(ToolRegistry.from_tools([run_check, deploy]), name="engine", ctx=ctx)
print("tool_names: ", spec.tool_names)
print("requires_approval:", spec.requires_approval)
print("auto_approve: ", spec.auto_approve)
print("caps: ", spec.caps)
print("cli_kwargs keys: ", sorted(spec.cli_kwargs()))
async with spec:
async with MCPClient(
StreamableHttpServer(
url=spec.url, headers=spec.auth_headers, timeout_s=10.0
)
) as client:
listed = {t.name: t for t in await client.list_tools()}
print("advertised: ", sorted(listed))
assert run_check.schema is not None
print(
"schema unchanged: ",
json.dumps(listed["run_check"].inputSchema)
== json.dumps(dict(run_check.schema.parameters)),
)
print("deploy _meta: ", listed["deploy"].meta["agentkit"])
print("deploy read-only: ", listed["deploy"].annotations.readOnlyHint)
ok = await client.call_tool("run_check", {"name": "lint"})
print("ok: ", ok.content[0].text)
bad = await client.call_tool("run_check", {"name": "lint", "verbose": True})
print("bad isError: ", bad.isError)
print("bad text: ", bad.content[0].text)
after = await client.call_tool("run_check", {"name": "after"})
print("session survived: ", after.content[0].text)
print("calls_seen: ", spec.calls_seen)
asyncio.run(main())
tool_names: ('mcp__engine__deploy', 'mcp__engine__run_check')
requires_approval: ('mcp__engine__deploy',)
auto_approve: ('mcp__engine__run_check',)
caps: ('egress',)
cli_kwargs keys: ['mcp_config', 'strict_mcp_config']
advertised: ['deploy', 'run_check']
schema unchanged: True
deploy _meta: {'side_effecting': True, 'requires_approval': True, 'caps': ['egress']}
deploy read-only: False
ok: lint:lax:ok
bad isError: True
bad text: tool 'run_check' call rejected: unexpected argument(s) ['verbose']. Accepted arguments: ['name', 'strict']
session survived: after:lax:ok
calls_seen: 3
Three lines in that output are the whole design, and each has its own
subsection below: schema unchanged: True, the pair
bad isError: True / session survived, and deploy _meta. The last
line is the humbler one — calls_seen exists because a session that
reports zero either never called a tool or never reached the server,
and those are different bugs to go looking for.
What this example does and does not prove
Every line above ran: a real loopback listener, a real MCP session
over a real socket, three real tool calls. What it does not
exercise is the Claude CLI — the binary that would normally be the
client. The config file and cli_kwargs() are the real values it
is handed; that it accepts them, connects, and runs the tool body
back in this process is covered by this project's own tests against
the binary, not by this snippet.
In real use you never call the tools yourself. You hand cli_kwargs()
to the cognition, exactly as with ApprovalServer:
"""The shape a real caller writes."""
from agentkit.agents import Agent
from agentkit.models.providers.cli.claude import ClaudeCliCognition
from agentkit.integrations.mcp import serve_registry
async def run(registry, task, ctx):
spec = serve_registry(registry, name="engine", ctx=ctx, timeout_s=120.0)
async with spec:
cognition = ClaudeCliCognition(
model="claude-sonnet-4-6",
allowed_tools=spec.auto_approve, # the rest still prompt
**spec.cli_kwargs(builtin_tools=False), # only OUR tools
)
return await Agent(name="dev", cognition=cognition).run(task, ctx)
strict_mcp_config=True is always in there. Without it the CLI also
loads whatever MCP servers the working directory or the user's home
configuration happen to define — the difference between "these tools"
and "these tools plus a teammate's .mcp.json". builtin_tools=False
is a separate decision and is off by default, because it disables the
CLI's own Read, Grep and Bash, and that is a statement about what the
session can do at all rather than about MCP wiring. A flag named
strict_mcp_config should not quietly make it.
The schema is not translated¶
ToolSchema.parameters goes out as MCP inputSchema unchanged — one
dict() unwrap of the FrozenDict and nothing else. No key renaming,
no shape massaging, no defaults filled in. That is what the
schema unchanged: True line above is asserting, and the project's own
test asserts it as byte equality rather than ==, because two dicts
can compare equal with different key order and the bytes are what the
model is prompted with.
Both sides are already JSON Schema, so a translation step would be a second description of one thing — and second descriptions drift. The drift is not cosmetic. It surfaces as the model being shown a schema the tool does not validate against: it writes a call that matches the documentation it was given, gets rejected, and has nothing to work with.
This is also why the module builds a lowlevel MCP Server rather than
a FastMCP one. FastMCP derives inputSchema from a Python
signature via pydantic and validates arguments against that derived
model before the handler runs. Both halves are wrong here: the schema
already exists, and pydantic's rejection would replace
ToolArgumentError's message — which names the tool, the offending
arguments and the accepted set — with a generic validation dump. The
handler is registered validate_input=False for the same reason.
agentkit's tools already check their own calls and their diagnosis is
the better one.
Two consequences worth knowing before they surprise you:
- A tool whose
schemaisNoneis dropped, not advertised. TheToolProtocol calls that loop-invisible, and MCP has no way to say "callable but undescribed" —inputSchemais required. Inventing an empty schema would advertise something the model would then call blind. - Tool names are sanitised, server names are not. MCP's own
grammar would allow
run.check, but the CLI addresses a served tool asmcp__engine__run.checkand callers then paste that into--allowed-tools, shell arguments and log greps — so agentkit applies a narrower rule than MCP's and replaces anything outside[A-Za-z0-9_-]with_, keeping the qualified name one glob-free, quote-free token. The model only ever sees the sanitised form, so the rename is invisible to it; you write the qualified name intoallowed_toolsby hand, which is whyspec.mcp_namespublishes the mapping rather than leaving it implicit. The server name is refused rather than rewritten, because you typed that one yourself and are holding it in a string somewhere else. Two tool names that collide after sanitising (run.checkandrun check) raise instead of shadowing:ToolRegistryrefuses a duplicate name for a reason, and sanitising would otherwise re-open that hole one layer down.
A bad call is a tool error, not a transport error¶
This is the distinction the module exists to preserve, so it is worth being concrete about what the alternative costs.
An exception raised out of an MCP request handler is a transport
error. It ends the session — and the session is the whole registry. One
tool raising RuntimeError("the database is on fire") would take every
other tool down with it mid-run, and what reaches the model is "the
tool system is broken", which is not something it can route around. A
ToolArgumentError arriving that way is worse still: the model
authored the bad call and is the only party that can fix it, and it
never gets told what was wrong.
So the dispatch handler never raises. Every failure comes back as an
isError result on the call, which is reflected into the
transcript:
| what happened | what the model reads |
|---|---|
ToolArgumentError |
the message verbatim — tool, bad arguments, accepted set |
| any other exception | tool 'x' failed: RuntimeError: the database is on fire |
| no such tool | the name, plus the sorted list of ones that do exist |
| the run was cancelled before dispatch | the run was cancelled; 'x' was not run |
| the run was cancelled mid-call | the run was cancelled while 'x' was running |
The two cancellation rows are not bookkeeping. ctx.check_cancelled()
runs before the tool body, because the CLI knows nothing about
agentkit's cancellation seam and will keep driving tools for a run
somebody stopped — a side-effecting tool firing after someone pressed
stop is the failure that check exists to prevent. And Cancelled gets
its own clause because it subclasses RuntimeError: without one it
would fall into the generic row above and be reported as an ordinary
failure, which is an invitation to retry work that was deliberately
abandoned.
The declarations travel, so the Rule of Two still applies¶
side_effecting, requires_approval and caps go out twice: once as
MCP ToolAnnotations, and once verbatim in _meta under the
agentkit key — META_KEY in agentkit.integrations.mcp.serve,
namespaced because _meta is a shared bag and an unqualified caps
would collide with whatever the next middleware wants to attach. Twice,
because annotations are hints, and the MCP spec tells clients not to
make decisions on hints from a server they do not trust. _meta is
where a cooperating
agentkit-side reader — a RunPolicy check, an audit trail — gets the
declarations back without re-inferring them from three booleans that
were lossy on the way out.
readOnlyHint is not (side_effecting or requires_approval), not the
obvious not side_effecting. readOnlyHint is precisely the hint a
client consults to decide it may run something without asking, so a
read-only tool that nonetheless declares requires_approval — reading
a customer record, say — must not advertise itself as free. That is the
exact case where an approval requirement matters most and is least
visible, which is why deploy read-only: False above is a computed
answer rather than a copy.
Because the tags survive the crossing, the lethal-trifecta check still sees the set it needs to see:
"""The Rule-of-Two check still applies to tools that moved behind MCP."""
import asyncio
from agentkit.agents.control.safety import RunPolicy
from agentkit.integrations.mcp import serve_registry, stdio_command
from agentkit.core.testing import make_test_ctx
from agentkit.tools import ToolRegistry, tool
@tool(side_effecting=False, caps=("private_data",))
def read_record(customer: str) -> str:
"""Read one customer record out of the private store."""
return customer
@tool(side_effecting=False, caps=("untrusted_content",))
def fetch_page(url: str) -> str:
"""Fetch a page of untrusted web content and return its text."""
return url
@tool(side_effecting=True, caps=("egress",))
def notify(channel: str, text: str) -> str:
"""Post a message to the named chat channel — the egress leg."""
return f"sent to {channel}"
async def main() -> None:
registry = ToolRegistry.from_tools([read_record, fetch_page, notify])
spec = serve_registry(
registry,
name="engine",
ctx=make_test_ctx(),
transport="stdio",
command=stdio_command("myapp.mcp_server"),
)
async with spec:
verdict = RunPolicy().check(registry.tools())
print("spec.caps:", spec.caps)
print("allowed: ", verdict.allowed)
print("reason: ", verdict.reason)
asyncio.run(main())
spec.caps: ('egress', 'private_data', 'untrusted_content')
allowed: False
reason: lethal trifecta: this tool set combines private-data access, untrusted-content ingestion, and egress in one run — require a human gate or split the run
Be honest about where that check runs: on your side, before you start
the CLI. Nothing on the wire enforces it, and serve_registry will
serve a trifecta happily if you ask it to. What it guarantees is that
the tags are still there to be checked — which is the part that used
to vanish at the boundary, silently, exactly when the tools are
furthest from the caller.
spec.requires_approval and spec.auto_approve are the same
declarations in the shape allowed_tools wants: qualified names, split
by whether the tool asked for a human. Splatting auto_approve into
allowed_tools is what actually makes the CLI prompt for the others —
requires_approval=True is a statement the CLI reads, not a check this
server performs.
Loopback, plus a generated bearer token
The HTTP transport binds 127.0.0.1 and, by default, requires a
bearer token that the listener generates. cli_kwargs() puts it
in the config document's headers; spec.auth_headers is the same
credential for a non-CLI client. Nothing accepts a caller-supplied
token — one that a caller can set is one that ends up a constant in
somebody's config file.
Loopback alone used to be the containment, and that argument holds
exactly as long as nothing untrusted shares the host. It is the
wrong assumption for the case this exists to serve: the point of
ClaudeCliCognition is that the CLI runs Bash — a build, a
package install, a script out of a repository the agent was pointed
at — inside the same network namespace as the server holding the
agent's own tools. The trust boundary loopback assumes is precisely
the boundary the tool set is designed to cross.
A token is a weaker fence than a 0700 directory, and the honest
statement is that it turns anything on the host into anything
that can read this process's temp directory. That is why the config
file is 0600 and why stop() removes it together with the
listener — the file is now a live credential, so a stale one
outliving its server would be worse than untidy.
auth="none" restores the old unauthenticated behaviour. It is
still supported, and now something a caller asks for by name rather
than gets by default.
Which transport, and when¶
transport="http" (the default) keeps the tools in this process.
The spec reserves a loopback port, writes a config naming it, and
async with starts a uvicorn listener. Your registry, its closures and
its ctx are all right here, so a tool can hold a database handle, an
open connection, a half-built object — anything that would not survive
being reconstructed elsewhere.
transport="stdio" is a different shape, and the difference is not
cosmetic: the CLI spawns a stdio server itself. The tools are
rebuilt in a fresh interpreter, and this process's registry, closures
and ctx do not cross that boundary. So command= is required, and
passing transport="stdio" without it raises immediately with that
explanation rather than writing a config the CLI would fail on a minute
later.
"""transport="stdio": the parent half writes a config naming a command."""
import asyncio
import json
from agentkit.integrations.mcp import serve_registry, stdio_command
from agentkit.core.testing import make_test_ctx
from agentkit.tools import tool
@tool(side_effecting=False)
def run_check(name: str) -> str:
"""Run the named check and report whether it passed."""
return f"{name}:ok"
async def main() -> None:
spec = serve_registry(
[run_check], # a bare sequence works; it is registered for you
name="engine",
ctx=make_test_ctx(),
transport="stdio",
command=stdio_command("myapp.mcp_server"),
env={"PYTHONPATH": "/srv/app"},
)
async with spec:
entry = json.loads(spec.config_path.read_text())["mcpServers"]["engine"]
print("type:", entry["type"])
print("args:", entry["args"], "(command is sys.executable)")
print("env: ", entry["env"])
print("url: ", spec.url)
asyncio.run(main())
type: stdio
args: ['-m', 'myapp.mcp_server'] (command is sys.executable)
env: {'PYTHONPATH': '/srv/app'}
url: None
The command's main is the other half, and it calls
serve_registry_stdio — the same advertising, the same error contract,
differing only in which pipe carries it:
"""myapp/mcp_server.py — the child the CLI spawns."""
import asyncio
from agentkit.integrations.mcp import serve_registry_stdio
from agentkit.core.testing import make_test_ctx
from agentkit.tools import tool
@tool(side_effecting=False)
def run_check(name: str) -> str:
"""Run the named check and report whether it passed."""
return f"{name}:ok"
async def serve() -> None:
# Serves on this process's stdin/stdout until the peer closes it.
# A real child builds its own ctx; make_test_ctx keeps this runnable.
await serve_registry_stdio([run_check], name="engine", ctx=make_test_ctx())
def main() -> None:
"""What `python -m myapp.mcp_server` calls."""
asyncio.run(serve())
No config file is written on that side, and that is not an oversight: a process that will be spawned by the config cannot also be the process that authors it.
stdio_command("myapp.mcp_server") builds (sys.executable, "-m",
"myapp.mcp_server") rather than starting from "python", because the
CLI spawns the child with the caller's PATH, not the virtualenv's,
and a bare python there is whatever the system ships — which will not
have agentkit installed. env= is often the only way that fresh
interpreter can import the package your tools live in.
spec.url is None for stdio. There is no endpoint to have, and
returning a plausible-looking one would be a lie a caller could paste
somewhere.
Reach for HTTP unless the tools genuinely have no in-process state. The process boundary is real work — everything the tools need has to be reachable from a fresh interpreter running under the CLI's environment — and it buys you nothing that loopback does not already give you.
Pointing Codex at the same server¶
spec.cli_kwargs() writes Claude Code's --mcp-config shape. Codex reads
MCP servers out of config.toml instead, addressed as
mcp_servers.<name>.<key>, so the same spec has a second projection:
from agentkit.models.providers.cli.codex import CodexCliCognition
from agentkit.models.providers.cli.codex import as_codex_mcp
async def wire(spec):
async with spec:
return CodexCliCognition(model="gpt-5-codex", **as_codex_mcp(spec))
spec.codex_kwargs() is the same thing as a method, next to cli_kwargs().
One part is not a rename. Codex has no header field for a bearer token — it
has bearer_token_env_var, naming an environment variable it reads at connect
time. So the projection also returns an env= entry, and the credential
travels in the child's environment rather than in the argv, which is
world-readable in ps output on most systems.
There is no builtin_tools= switch on the Codex side, because Codex has no
tool allow-list: every session has shell whatever you serve it. What contains
that is sandbox=, covered on The Codex CLI.
The lifecycle, and re-entering it¶
serve_registry is synchronous on purpose. It reserves the port and
writes the config file, so the returned spec is complete enough to
build a ClaudeCliCognition before anything is serving.
async with spec then starts the listener; leaving the block stops it
and deletes the config.
The port is reserved by binding a socket and holding it, not by
asking the OS for a free port and closing it again. --mcp-config
wants a URL written to a file before the CLI is spawned, so the port
has to be known before anything serves on it, and the ask-then-rebind
version leaves a window that a second agent on the same host wins often
enough to matter. It also keeps uvicorn's own bind-failure path out of
the picture, which is worth knowing about: that path is
logger.error(...); sys.exit(3) raised inside the serve task, so a
port collision propagates a SystemExit out of asyncio.run — a
library taking down its host process over a busy port, while the
awaiting caller gets a bare CancelledError with the port named
nowhere. Binding early raises the real
OSError: [Errno 48] Address already in use in your own frame, with
the endpoint appended to the message.
start() and stop() are both idempotent, and a spec is re-enterable
— a retry loop can async with spec a second time. That took a fix to
actually be true. stop() deletes the config file, and nothing used to
put it back, so a second entry brought the listener up on the same port
and left cli_kwargs() naming a path that no longer existed: a live
server the CLI could not be pointed at, reported as success. start()
now rewrites the document.
Deleting the config on the way out is deliberate. A stale one outlives
the port it names, and the next reader gets a URL pointing at nothing —
or worse, at whatever bound that port next. Only a directory the spec
created is removed; a config_path= you supplied is yours to keep.
Two timeouts that do not read the same¶
"""A tool's own TimeoutError is not this server's deadline firing."""
import asyncio
import logging
from agentkit.integrations.mcp import MCPClient, StreamableHttpServer, serve_registry
from agentkit.core.testing import make_test_ctx
from agentkit.tools import tool
logging.disable(logging.INFO)
@tool(side_effecting=False)
async def slow_build() -> str:
"""Wait far past the server's deadline, so only the deadline can end it."""
await asyncio.sleep(3600)
return "never"
@tool(side_effecting=False)
def call_upstream(url: str) -> str:
"""Read the named upstream, failing the way a real client fails."""
raise TimeoutError("upstream read timed out after 3s")
async def main() -> None:
spec = serve_registry(
[slow_build, call_upstream], name="engine", ctx=make_test_ctx(), timeout_s=0.05
)
async with spec:
async with MCPClient(
StreamableHttpServer(
url=spec.url, headers=spec.auth_headers, timeout_s=10.0
)
) as client:
ours = await client.call_tool("slow_build", {})
theirs = await client.call_tool("call_upstream", {"url": "http://x"})
print("our deadline:", ours.content[0].text)
print("their error: ", theirs.content[0].text)
asyncio.run(main())
our deadline: tool 'slow_build' did not return within 0.05s and was abandoned
their error: tool 'call_upstream' failed: TimeoutError: upstream read timed out after 3s
Those are deliberately different sentences, and the difference is a
bug fix rather than a flourish. The handler used to use
asyncio.wait_for, which collapses "our deadline fired" and "the tool
raised a TimeoutError of its own" into one indistinguishable
exception — so every one of them was reported as the deadline. With the
default timeout_s=None, a tool whose upstream read timed out came
back to the model as "did not return within Nones and was
abandoned": false twice over — nothing was abandoned, and there was no
deadline — with the one message naming what actually failed thrown
away. asyncio.timeout keeps them apart, because deadline.expired()
is true only when that scope is what did the cancelling. A model cannot
repair a call it has been lied to about.
What bites people¶
- Every MCP tool is
side_effecting=True. MCP has no standard read-only marker, so the adapter assumes the worst.ReActCognitionpassesside_effectingtoshould_gateaskey_step, so underautonomy="gated"every MCP tool call pauses for a human — even a pure lookup. Under the default"auto"none of them do. If you know a server is read-only, wrap its tools and flip the flag. requires_approvalisFalseon every adapted tool. The adapter will not force a gate the run's autonomy tier did not ask for; a tool author'srequires_approval=Trueis honoured even under"auto", and claiming that on someone else's server would be a guess.- Name collisions are yours to avoid. Two servers both exposing
searchwill shadow each other in one cognition. That is whatprefix=is for:mcp_tools(fs, prefix="fs_"),mcp_tools(gh, prefix="gh_"). The prefix lands onnameand onschema.name, so the model sees the prefixed name too. - Cancellation is pre-dispatch, not mid-call.
call_tool(..., ctx=ctx)callsctx.check_cancelled()before it hits the transport, so a run cancelled while queued never reaches the server. To stop a call already in flight you must exit theMCPClientcontext, which tears down the transport — and the session is not reusable afterwards. - Non-text tool output is dropped with a marker. MCP tools return a
list of text / image / audio / resource blocks. Text is concatenated;
anything else becomes
[<type> content omitted]so the agent sees something attributable rather than a silent hole.isError=Trueresults come back as"ERROR: …"strings, not exceptions. read_resource()drops binary blobs for the same reason — it returns the human/model-facing text.- One session per context. Servers that keep per-session state (open
file handles, a DB transaction) lose it if you split calls across
independent
MCPClientcontexts. - Both servers require a generated bearer token, and the token is
the fence rather than loopback. Without it, anything able to reach
ApprovalServer's port could answer permission prompts on the agent's behalf, and anything able to reach aserve_registryport could run your tools,requires_approvalones included — that flag is a declaration the CLI reads, not a check the server performs. The credential is checked on HTTP requests and every other ASGI scope is refused outright. Still do not givehost=a routable address: the token narrows who can call, not who can reach. ApprovalDecision.reasonis unredacted text from outside this library — a reviewer's free-text note, or the message of an exception raised by your ownAsker— and unlikeargumentsit IS broadcast onctx.emit. Plan retention for it. Nothing here redacts anything, deliberately: a library that half-redacted would hand you an audit trail nobody can trust to be complete.serve_registry(timeout_s=None)is the default, and it means forever. A tool that never returns parks the CLI turn with no signal anywhere: the CLI waits on the MCP call, agentkit waits on the tool, nothing times out. A default deadline would be the worse mistake — it would kill a legitimately slow tool, a long build or a human-in-the-loop approval, and read as a flake — so the caller has to say which of those they have.- A served tool with no
schemais silently absent. It is dropped rather than advertised with an invented emptyinputSchema, so a tool you registered can simply not be there.spec.tool_namesis the authoritative list of what the CLI will actually see. - The
mcpextra bringspydantic(and, transitively,uvicornandstarlette, which is whyApprovalServeradds no new dependency). If your install was pydantic-free by design, the extra ends that.
Related¶
- Consume MCP tools from an agent — the task-shaped version of the client half.
- Human-in-the-loop tool approval —
agentkit's own gate, the one
ApprovalServerborrows theAskerfrom. - Elicit a value from a human
—
Elicitation/Decisionin full. - Plug the claude CLI into FastAPI code-gen
—
ClaudeCliCognitionend to end. - The Codex CLI —
as_codex_mcp, and the two Claude-side adapters (hook_settings,as_cli_agents) that have no Codex counterpart and why. - Concepts · Agents — where
Tool,MemorySourceandPromptplug in. - The protocol itself: https://modelcontextprotocol.io