Part III Chapter 11: Opening a Door for External Capability — MCP

Chapter guide: Part III · Build It extends Chapter 3. Part I · Concepts called MCP "a protocol for capability discovery and message exchange." This chapter works out on paper how mini-agent would attach to an external MCP server. The code does not go into
src; what has to be clear is the transport, the tool adapter, the failure semantics, and the trust boundary.
Xiao A's Agent could only read and write its own workspace. He wanted a "check the calendar" capability, but the calendar lived in another service. He asked Lao Z: "Can we let the Agent call that service's API directly?"
"Yes, but don't write the HTTP request straight into a tool," Lao Z said. "What you connect to is an MCP server. It exposes tools over the protocol, and your Agent calls them over the protocol. Let me say the ugly part up front: the protocol solves connectivity, not trust."
Xiao A pushed back: "Why not just write a calendar tool that fetches that API? Far simpler than adopting MCP."
"Simple now, debt later," Lao Z said. "Write fetch directly and the API address, the authentication, and the error handling are all welded into one tool. Swap the service and you rewrite it. MCP lifts all of that into the protocol layer: the server manages its own authentication and errors, and your Agent just calls a tool named calendar_query. More to the point, mini-agent's Tool.execute returns Promise<ToolResult> (see types.ts), which assumes local, synchronous semantics. Stuff a network request in there and you have to handle timeouts, dropped connections, and retries inside the tool. That is exactly where MCP earns its keep: it disguises a remote capability as a local tool, and the loop does not need to know there is a network behind it. This chapter works out what that disguise layer looks like. It does not go into src."
First, get the two directions straight
"MCP has three primitives. First, be clear about which one you are connecting to," Lao Z said:
- Tool: a callable action. This is what an Agent needs most.
- Resource: readable context (files, documents).
- Prompt: a reusable template.
"mini-agent's loop can only consume its own Tool interface (spec + execute). So the core question of an MCP integration is: how do you turn an MCP server's Tool into a Tool that mini-agent can call. That is where the adapter seam goes. You do not need the loop to understand MCP; you need a bridge that translates an MCP tool into a ToolSpec."
Run the three primitives through one question in turn: "does mini-agent's loop have a place that consumes it?"
| MCP primitive | What it is | Does the loop consume it? | How you would connect it |
|---|---|---|---|
| Tool | a callable action | Yes: options.tools | Adapt it into a Tool; usable as it is |
| Resource | readable context | No | Wrap it into a read_resource tool |
| Prompt | a reusable template | No | Change the system injection logic |
"The first two columns of this table belong to MCP's world; the third and fourth belong to mini-agent's," Lao Z said. "Only Tool has a consumption point by nature. Resource and Prompt both require changing the loop before they can plug in. Changing the loop is not bad in itself. What is bad is acting before you have answered 'why does this need to change.' Draw the consumption point first, and it is obvious at a glance where each primitive attaches."
Xiao A continued: "So we just don't connect Resource and Prompt?"
"Not now — the loop has nowhere to consume them," Lao Z said. "mini-agent's runAgent only accepts tools: Tool[] (see agent.ts); the model can call tools and nothing else. To really connect them, Resource has to be wrapped as a read_resource tool, and Prompt has to change the system injection logic — and mini-agent's system prompt is a hardcoded string right now (see main.ts). One rule: for every primitive you connect, first answer 'which place in the loop consumes it.' If the consumption point does not exist, don't connect the primitive yet."
Design a minimal McpClient
Lao Z sketched an interface following the ModelClient pattern:
// Design sketch: McpClient (not in src yet — confirm the requirement first)
export interface McpClient {
/** Optional: server/discover to confirm version and capabilities, then tools/list for the tool catalog */
connect(): Promise<{ tools: McpTool[] }>;
/** Call one tool and return a structured result */
call(name: string, input: unknown): Promise<{ content: unknown; isError: boolean }>;
close(): Promise<void>;
}
// Translate an MCP tool into a mini-agent Tool
function mcpToolToTool(mcpTool: McpTool): Tool {
return {
spec: { name: mcpTool.name, description: mcpTool.description, inputSchema: mcpTool.inputSchema },
execute: async (input, signal) => {
const result = await mcp.call(mcpTool.name, input);
return { isError: result.isError, content: JSON.stringify(result.content) };
},
};
}"Start with the name connect," Lao Z said. "In the MCP 2026-07-28 spec, the protocol itself is stateless: no handshake, no session. Every request carries the protocol version and the client capabilities in _meta. So connect here is only the host's own startup step: bring up the stdio child process, optionally call server/discover once to confirm the versions the peer supports, then call tools/list. The McpClient fills in the version and capability fields on every request, so neither the loop nor the adapter has to care."
"Now look at mcpToolToTool. It translates an MCP tool's shape into mini-agent's shape," Lao Z said. "The loop has no idea there is MCP behind it; it sees an ordinary Tool. That is the adapter: McpClient owns the protocol, mcpToolToTool owns the shape conversion, the loop owns the call. Three layers, one job each."
The call chain across the three layers, and the layer where each kind of error stops, looks like this:
runAgent (the loop)
│ knows only Tool / ToolSpec
▼
mcpToolToTool (adapter seam: translates McpTool into Tool)
│ does shape conversion only: the spec fields + JSON.stringify inside execute
▼
McpClient (protocol layer: connect / call / close)
│ knows only the MCP protocol, not the loop
▼
McpTransport (stdin/stdout or HTTP)
│ knows only byte streams, turns them into protocol messages
▼
remote MCP server"Every layer in this picture talks only to the layers directly above and below it," Lao Z said. "The loop asks execute, the adapter asks McpClient.call, the protocol layer asks transport.send. Whichever layer sees an error translates it into its own language right there. Don't let one error pass straight through all three layers. The argument coming up, protocol error versus tool error, is really an argument about which layer assigns blame."
Xiao A recognized something: "This adapter is the same pattern as mini-agent's existing AnthropicModelClient?"
"The same pattern, just in the opposite direction," Lao Z said. "AnthropicModelClient (see model-client.ts) translates Anthropic's streaming protocol into mini-agent's ModelEvent: it handles content_block_start and input_json_delta, and emits text, tool_call, and done. mcpToolToTool goes the other way: it translates an MCP tool description into a ToolSpec. Both adapters do the same kind of work — attach an external protocol to an internal interface. It is also why mini-agent's tests can play a full round with FakeClient (see agent.test.ts): the loop does not care whether what sits behind it is Anthropic or MCP. The adapter is therefore the unit of reuse in this architecture."
Xiao A pointed at a line inside execute: "Is JSON.stringify(result.content) there because the model can't read an object, so you turn it into a string first?"
"Right. mini-agent's ToolResult.content is a string (see types.ts), but the content an MCP tool returns may be a structured object," Lao Z said. "Look at mini-agent's own tools: read returns file contents, bash returns joined stdout. The internal interface uses strings uniformly, and the model can read them as they are. MCP returns structured data, so you have to stringify it to fit this shape. The loss happens exactly here. What you get in exchange is a loop that never has to understand MCP's return format."
Xiao A pressed: "Then stringify drops the type information. What if the model can't parse a structured result?"
"Let the model treat it as text, or add a formatting step in the adapter," Lao Z said. "This is the same thinking as the truncation in read: the internal interface is a string, so structured information either gets read as text by the model itself, or it gets formatted into readable text ahead of time inside the adapter. The choice is yours, not the loop's. If you want the model to see { title: 'Meeting', time: '10:00' } instead of a blob of JSON, do the formatting inside mcpToolToTool's execute. Don't add a new 'understand JSON' rule to the loop. Let one place — the adapter — understand both ends of the format."
transport: the boundary between stdio and HTTP
Xiao A thought about the runtime: "So where does the MCP server run? A local process or a remote service?"
"Two transports, two boundaries," Lao Z said:
- stdio: the server is a local child process, and you communicate through standard input and output. The advantage is process isolation; the disadvantage is one process per connection.
- Streamable HTTP: the server is a remote service. The advantage is no local process; the disadvantage is that you face network errors and authentication. Each request is one POST. If the response stream breaks midway, that request is lost — the spec requires re-issuing it as a new request with a new request ID.
"The transport decides the failure semantics," Lao Z stressed. "Failure in stdio is a process exit, so you handle a crashed child process. Failure in HTTP is a network error, so you handle timeouts and retries. When you design McpClient, isolate the transport behind a replaceable interface, so tests can use a fake transport: no real child process, no real request — exactly like ModelClient using a fake stream."
Put the two transports side by side in one table:
| Dimension | stdio | Streamable HTTP |
|---|---|---|
| Server form | local child process | remote service |
| Communication channel | standard input / output | HTTP requests |
| Failure modes | process exit, broken pipe | timeout, broken response stream, authentication failure |
| Lifecycle | one process per connection; an unexpected exit should be restarted | one POST per request; no session at the protocol layer |
| Authentication | usually none (local) | credentials must be handled |
"This table imposes exactly one requirement on the interface: design for the worst case," Lao Z said. "McpTransport's send is declared as Promise<unknown>; it never promises 'always succeeds.' Whichever implementation sits underneath, failure is expressed as a rejection. The difference between stdio and HTTP happens inside the implementations, not in the interface."
"This fake transport idea — which mini-agent test does it correspond to?" Xiao A asked.
"fakeStream in model-client.test.ts," Lao Z said. "It takes an array of RawEvent objects and feeds it to toModelEvents as if it were Anthropic's stream, with no real network request. The test constructs events like content_block_start and input_json_delta, then asserts the ModelEvents that come out are correct. A fake transport does the same thing: construct the messages a server would send, then assert the tool catalog McpClient parses is correct. All of mini-agent's protocol-translation logic lives in the pure function toModelEvents (see model-client.ts), and the tests never instantiate AnthropicModelClient. MCP should be split the same way: protocol parsing is a pure function, the transport is an injected dependency, and only then do the tests depend on neither a real process nor the network."
Xiao A followed up: "Then when the stdio child process crashes, how does the loop find out?"
"The transport layer translates it into a protocol error and raises it upward," Lao Z said. "Process exit, broken pipe — McpTransport.send should reject, and it is handled as a protocol error. The loop should not see 'child process exit code 1'; it should see a failed tool call. Treat it exactly like mini-agent's bash tool: when execFileAsync fails, failure(cause) turns it into { content, isError: true } (see tools.ts)."
"And if the child process is still alive but sends nothing but garbage messages?"
"That is a protocol error too. The message format is wrong; if McpTransport can't parse it, it should reject," Lao Z said. "Same idea as how toModelEvents handles things in model-client.ts: if an input_json_delta arrives with no matching content_block_start, it throws Anthropic stream protocol error outright — no guessing, no ignoring. When parsing a protocol, stopping beats guessing. Guess wrong once and every event after it is misaligned."
// Design sketch: the transport is replaceable
export interface McpTransport {
send(message: unknown): Promise<unknown>;
close(): Promise<void>;
}
export class StdioTransport implements McpTransport { /* child-process stdio */ }
export class HttpTransport implements McpTransport { /* fetch + authentication */ }Failure semantics: a tool error is not a protocol error
"What happens when an MCP call fails?" Xiao A brought the question back down to the most practical layer.
"Two kinds," Lao Z said:
- Protocol error: a failed connection, a malformed message, an unsupported protocol version (
UnsupportedProtocolVersionError), a missing client capability the server requires, or a call to a tool that does not exist. The server returns these as JSON-RPC errors. This is an error at theMcpClientlayer; it should propagate upward and let the host decide whether to retry or give up.- Tool error: the MCP tool itself returned
isError: true(for example "calendar query failed," or the date format in the arguments was wrong). This is a business result. It should becomeToolResult.isErrorand go back into the loop, so the model sees it and adjusts.
"The two paths must not mix. Treat a protocol error as a tool error and the loop will cheerfully retry a connection that is already dead. Treat a tool error as a protocol error and the model never sees why the call failed; it can only guess. Keeping them apart is the contract between McpClient and Tool."
Xiao A wanted an existing precedent: "Do these two paths have an analogue in mini-agent's current code?"
"Yes, right inside runAgent," Lao Z said. "Look at agent.ts. When tool execution throws, it calls recordFailedCallAndSkippedCalls, records the call as 'tool execution failed', and then rethrows to the caller as it is; the whole round stops. That corresponds to a protocol error. But when a tool returns { isError: true } — for instance read hitting a symlink escape — it only records the result into messages, the loop continues, and the model adjusts on its own. That corresponds to a tool error. Once MCP is plugged in: a server that returns isError: true takes the second path; a transport that throws takes the first. The contract lines up with the semantics you already have, so the loop needs no new rule."
Lao Z drew the two error paths as a state diagram, landing directly on mini-agent's real behavior:
An MCP tool call fails
│
result.isError === true? ← the server explicitly reported failure
│
┌────┴─────────────────┐
│ yes │ no
▼ ▼
business error protocol error (transport reject / bad format)
recorded in messages recordFailedCallAndSkippedCalls
loop continues → "tool execution failed"
model sees the cause → thrown to the caller as-is → main sets exit code 1"Note the right-hand path: the model never sees the original error content," Lao Z said. "What it sees is the stable marker 'tool execution failed'. This is the same point the security chapter made: host-side failure details do not enter the model's context. An MCP protocol error behaves the same way. What the transport throws is transparent to the loop; the loop only closes out the current call and passes the error upward."
Xiao A guessed: "If the server says the tool doesn't exist, that's probably a protocol error?"
"Half right this time," Lao Z said. "The Tools section of the MCP 2026-07-28 spec classifies an unknown tool as a protocol error; the server replies with a JSON-RPC error. Only an invalid argument value or a business failure counts as an execution error, returned with isError: true. But from the loop's point of view, 'the tool is gone' usually means the catalog is stale, and stopping the whole round may not be the right call. The adapter can translate this one kind of protocol error into a business error inside call, tell the model 'this tool is no longer available,' and have the host refresh with tools/list. Which class it belongs in depends on which layer can read it: the server understands the protocol, the loop understands tool results, and the translation happens in the adapter."
"There is one more new result type to think about early," Lao Z added. "When a newer server needs the user to confirm something or supply more information, it returns resultType: "input_required" and waits for the client to resend with inputResponses under a new ID. A minimal McpClient that does not support this should hand it to the host as an explicit failure, not hand it to the model as an empty result."
"What about a startup failure? If server/discover or the first tools/list fails, connect rejects right away — the loop never gets a chance to run?"
"Exactly, and that is how it should be," Lao Z said. "A failure at startup means the server is gone or the versions don't match; every tool call would fail anyway, so it is better to expose it before the loop begins. mini-agent's runAgent even throws directly when loading sessionPath fails (see await loadSession in agent.ts). When a startup-time dependency fails, let startup fail; don't drag it into the loop and retry forever. MCP's connect follows the same rule: it runs before runAgent, and on failure it throws straight to the caller, who decides whether to reconnect or give up."
Trust boundary: a working protocol is not safety
Xiao A relaxed: "Once it is connected, can I let the Agent use the calendar tool without worrying?"
"The protocol solves connectivity; security solves controllability. You need both," Lao Z said, opening the conclusion of Part I · Concepts, Chapter 12: Leaving a Door Open for External Tools — MCP. "MCP does not define who may call which tool or which resource may be read. Connect an external service and you have opened a back door with no guard. Three things are yours to manage:
- Review the tool catalog: for every tool the server declares, confirm that what it does is something you allow the Agent to do.
- Put results through approval: high-risk tools (delete, send-out) must carry
BeforeToolCallapproval, just like local tools.- Guard against injection: content returned by MCP can carry prompt injection, using tool results to talk the model into overstepping. It is only text, not trusted instruction."
Xiao A: "Is approval for an MCP tool the same as for a local tool?"
"Exactly the same, and it has to be," Lao Z said. "mcpToolToTool translates an MCP tool into an ordinary Tool, so it enters runAgent through options.tools. Before executing any tool, the loop runs beforeToolCall?.(call) (see agent.ts), looking only at call.name, with no distinction between local and MCP. So the if (call.name === 'bash') check in denyBashByDefault (see approval.ts) applies to MCP tools as well. Read it the other way, though: with no approval hook attached, everything is allowed by default — ?? { approved: true } (see agent.ts). The adapter makes an MCP tool look like a local tool, so it inherits the local default-allow policy too. The risk and the benefit come as one package."
Run the exercise on your own calendar service, and the trust list looks like this:
Trust checklist for the calendar MCP server (design exercise)
calendar_query: read-only, returns schedule text. Allow it, but review whether it also returns other people's appointments.calendar_create: creates an appointment; the side effect is a write. Attach approval and deny by name unless a policy says otherwise.calendar_delete: deletes an appointment; irreversible. Deny by default, confirm each call by hand.- Treat the returned content of every tool as prompt injection. An appointment description is external text, and it could say "ignore all previous instructions."
"Item 4 is the one people miss," Lao Z said. "Calendar entries come from someone else's hand and carry a stranger's intent into your context. The malicious README in the security chapter could fool the model; a malicious appointment description can fool it just the same."
Xiao A raised another question: "And if the connection is down and the audit data never got sent, how do you make up for that?"
"That has to be designed inside the trust boundary as well," Lao Z said. "If you require 'every call is auditable' and the transport reports fire-and-forget, then you have to guarantee delivery yourself: either the caller learns when the transport fails, or auditing goes over a separate channel. Your trust model needs an answer for a lost audit record, otherwise you think you are auditing while nothing is being recorded."
Comparison with pi-mono
"pi-mono's protocol layer is the more complete reference," Lao Z said. "Its pi-protocol turns messages into reliable bytes through four layers — schema → CBOR → frame — and pi-server handles the handshake, request routing, and session attachment. That is a full remote protocol stack. Your minimal McpClient needs only a sliver of it: able to connect, able to list tools, able to call one. Don't write the whole protocol stack first just to attach a calendar tool."
Xiao A was thinking ahead: "Will mini-agent ever use pi-mono's layering idea?"
"It depends on the transport," Lao Z said. "For a local stdio child process, JSON is enough. A frame protocol and message validation only start to pay off once you connect Streamable HTTP and face real network unreliability. pi-mono's protocol stack is its own remote-communication infrastructure, a separate thing from MCP. Take the layering idea — isolate the transport, classify the failures — but don't carry over the byte format."
Lay pi-mono's full protocol stack next to the minimal slice from this chapter and compare:
| pi-mono protocol layer | What it does | Does the minimal McpClient need it? |
|---|---|---|
| schema | message type definitions | Partially: tool catalog, calls, results |
| CBOR serialization | binary encoding | No: stdio uses JSON |
| frame | framing, checksums | No: the local connection is stable enough |
| transport | reading and writing byte streams | Yes: isolated as McpTransport |
| handshake / subscription snapshot | session state sync | No: the MCP 2026-07-28 spec has no handshake and no session; version and capabilities travel with every request |
"pi-mono needs the first three rows, because its server may run remotely and its messages cross an unreliable network. If your calendar service only ever runs over local stdio, JSON plus newlines is enough," Lao Z said. "The test for whether a layer is worth having: without it, which failure path can you not describe? CBOR and frames solve network validation. Your stdio has no use for them, so cut them. Don't carry debt you can't spend."
"But what if we really do add HTTP later?"
"Then you add frames, add retries, add a heartbeat. All of that lands in the implementation, and the interface stays exactly as it is," Lao Z said. "The McpTransport signature doesn't change; what changes is the failure handling inside HttpTransport. Isolating the transport behind an interface was done precisely so that complexity you don't need today can stay inside the implementation."
Summary
The key to an MCP integration is the adapter seam: McpClient owns the protocol, mcpToolToTool owns the shape conversion, and the loop just calls ordinary Tools. The transport decides the failure semantics, so isolate it behind a replaceable interface; keep protocol parsing in a pure function and hand the connection details to the injected transport. Tests need only a fake — no real process and no real network. A tool error is a business result and must go back into the loop (isError: true travels as a ToolResult); a protocol error is a connection-layer fault and must propagate upward (a transport rejection surfaces as a throw from tool.execute). Keep the two paths apart, aligned with the tool-failure semantics mini-agent already has. A working protocol is not safety: adapted MCP tools inherit the local default-allow policy, so high-risk ones must carry BeforeToolCall approval keyed by name, and their returned content must be treated as prompt injection. mini-agent does not implement MCP; this chapter is a design exercise. What it settles is what the adapter's shape, the transport's boundary, and the contract between the two error paths should look like.
This bridge is the unit of reuse in the architecture, and it runs in two directions. AnthropicModelClient translates a provider's streaming protocol into ModelEvents; mcpToolToTool translates an MCP tool into a ToolSpec. One faces outward, one faces inward; both are bridges that attach an external protocol to an internal interface. Because the loop knows only the internal interface, tests can play a full round with FakeClient; adding a second MCP server later means writing one more transport without changing a line of the loop. Judge whether an adapter is well written by one test: when you swap out its implementation, does the loop have to know?
Both the primitives and the layers get connected only where there is a consumption point. Tool has one, so adapt it directly. Resource and Prompt have none, so either wrap a tool first or change the system prompt first; don't connect for the sake of connecting. The protocol layer follows the same rule: pi-mono's schema, CBOR, and frames were designed for remote communication, and a local stdio connection gets by with JSON. Isolate the transport behind an interface, and add what HTTP needs later. What is worth learning is the layering; the byte format stays with pi-mono.
Last, the lesson about trust. A connectivity protocol has already settled connectivity for you; controllability is still on your list. MCP does not decide who may call which tool, and it does not decide whether returned content can be trusted. Review the tool catalog, put high-risk tools through approval, treat returned content as prompt injection — miss any one of the three and the adapter makes the risk as invisible as the benefit. Write down the calendar service's trust list: which tools are allowed, which are denied by default, and why. Once that is written, this chapter's deliverable is complete: a boundary you can give reasons for.
Stage Acceptance
Do not modify src. Write design notes in examples/mini-agent:
- Draw the call chain
McpClient→mcpToolToTool→runAgent, and mark the propagation path of a protocol error and of a tool error. - Write a draft test for
McpClientusing a fake transport: for connection failure, a tool returningisError, and a transport breaking mid-call, state what each case asserts. - Write a trust checklist for your MCP calendar tool: which operations need
BeforeToolCallapproval, and why.
