AI Engineering · Draft

11 — A standard plug for tools

The assistant from chapter 9 and chapter 10 has four tools, and every one of them is wired in by hand. Each tool exists three times: as a Python function, as a JSON schema written out for Ollama’s tools field, and as a branch in the code that runs a call and turns the result, or the error, into a string for the model. The schema and the function can drift apart, and nothing checks that they agree. Moving the search to another program, or letting a second application use it, means writing all of that again in the new place.

That is the ordinary state of affairs without a standard. An editor, a chat application and a command-line agent that all want the same search each carry their own adapter for it, and the author of the search has to support each one. The Model Context Protocol (MCP) is an answer to that: a single protocol between applications that use language models and the programs that give those models tools and data. The tool is written once, as a server, and any application that speaks the protocol can use it.

This chapter moves the site search out of the assistant and into an MCP server, connects to it from a client, and looks at what actually crosses the wire.

Hosts, clients and servers

The specification names three roles. A host is the application the user works in: the chat window, the editor, or here, the assistant script. A client is a connector inside the host that talks to exactly one server. A server is a program that offers capabilities. A host that uses three servers holds three clients. The architecture page of the specification is explicit that servers should not be able to read the whole conversation or see into each other: the host keeps the conversation and decides what each server is sent.

The model is not one of the parties. It never talks to an MCP server. The host asks the model what to do, the model emits a tool call in whatever format its runtime uses (Ollama’s tool_calls here), and the host turns that into a protocol request through the right client. MCP standardizes the second half of that path, between the host and the tool, and leaves the model side alone. That is why the same server works with llama3.1 in Ollama, with a hosted model, or with no model at all.

Servers offer three kinds of things, and the difference is who decides when they’re used:

PrimitiveControlled byWhat it isIn this project
ToolsThe modelFunctions the model can call with argumentssearch_site, list_chapters
ResourcesThe applicationData identified by a URI that the host can read and add to the contextthe markdown of one chapter, chapter://dsa/09-heaps
PromptsThe userTemplates the user picks, often as a slash command“answer from the site, with citations”

The specification uses those words: tools are “model-controlled”, resources “application-driven”, prompts “user-controlled”. It also has features that run the other way, from server to client, such as asking the user for input (elicitation) or asking the host’s model for a completion (sampling). The site search doesn’t need them.

JSON-RPC underneath

Every message is JSON-RPC 2.0. A request has an id, a method and params. A response carries the same id and either a result or an error with an integer code and a message. A notification has no id and gets no response. MCP adds a few rules on top: request ids must not be null, and must not repeat while a request with the same id is still waiting.

JSON-RPC itself says nothing about how the bytes travel. MCP defines two standard transports. With stdio, the host starts the server as a child process and they exchange messages over its standard input and output, one JSON object per line, with no embedded newlines. The server may log to standard error, and must not write anything to standard output that isn’t a protocol message. With Streamable HTTP, the server is an independent process with one endpoint, every message from the client is its own HTTP POST, and each response comes back either as a single JSON body or as a Server-Sent Events stream scoped to that request.

The protocol changed this year

Most explanations of MCP describe an initialize handshake that opens a session. That was true up to the revision dated 2025-11-25. The current revision, 2026-07-28, removed it. Its changelog lists the change first among the major ones: there is no handshake and no session, and “every request now carries its protocol version and client capabilities in _meta”. The specification calls revisions with per-request metadata modern and the handshake revisions legacy.

In the modern revision, a server must implement one new method, server/discover, which returns the versions it supports, its capabilities and its name. A client may call it first, but doesn’t have to. If a request asks for a version the server doesn’t support, the server answers with error -32022 and lists what it does support, and the client retries. On HTTP, the protocol-level session and the Mcp-Session-Id header are gone, and so is the long-lived GET stream; change notifications now arrive on a subscriptions/listen request instead. Server-to-client requests became “multi round-trip requests”: instead of the server sending its own request mid-call, it returns a result with resultType: "input_required", and the client retries the original request with the answers attached. Every result now carries a resultType.

The reason given in the specification is statelessness: “all the information needed to process a request is contained in the request itself.” A server behind a load balancer can then handle any request on any replica, without sticky sessions, which is the same property the System Design track argues for in ordinary web services.

The code in this chapter uses the official Python SDK, mcp 2.3.0 from PyPI, on Python 3.14. Version 2 of the SDK speaks both eras. Its server class, which version 1 called FastMCP, is now MCPServer.

The server

The server reuses the search and chapter listing from chapter 9 (tools.py and the baseline index: about 800-character chunks with 100 characters of overlap, nomic-embed-text embeddings, cosine top 5, 5,653 chunks across the six tracks). The MCP layer is thin:

"""An MCP server that exposes the site search, chapter listing and chapter text."""
import sys
from typing import Literal
from mcp.server.mcpserver import MCPServer
import tools as t

Track = Literal["blog", "kernel", "gpu", "design", "dsa", "essays"]

server = MCPServer(
    name="site-search",
    version="0.1.0",
    instructions="Search and read the chapters of ashokg.ai. Cite sources as /track/id.",
)


@server.tool()
def search_site(query: str, track: Track | None = None) -> list[dict]:
    """Search the text of the site's chapters and return the 5 most relevant passages with their source."""
    return t.search_site(query, track)


@server.tool()
def list_chapters(track: Track) -> list[dict]:
    """List every chapter in one track, in order, with its id and title."""
    return t.list_chapters(track)


@server.resource("chapter://{track}/{chapter_id}", mime_type="text/markdown")
def chapter(track: str, chapter_id: str) -> str:
    """The full markdown of one chapter."""
    for d in t.DOCS:
        if d["track"] == track and d["id"] == chapter_id:
            return d["body"]
    raise ValueError(f"no chapter {track}/{chapter_id}")


@server.prompt()
def answer_from_site(question: str) -> str:
    """Ask a question that must be answered from the site's chapters, with citations."""
    return (f"Answer using only passages returned by search_site. Cite each source as /track/id. "
            f"If the passages don't contain the answer, say so.\n\nQuestion: {question}")


if __name__ == "__main__":
    if sys.argv[1:] == ["http"]:
        server.run("streamable-http", host="127.0.0.1", port=8765, json_response=True)
    else:
        server.run()  # stdio

The schema problem from chapter 9 goes away here. The SDK builds each tool’s inputSchema from the Python signature, so the Literal type becomes an enum of the six tracks and the optional track becomes an anyOf with null. The docstring becomes the description the model will read. Since the schema is generated from the code that runs, they can’t disagree.

The chapter resource is a URI template, chapter://{track}/{chapter_id}, so it doesn’t appear in resources/list. Templates are listed separately with resources/templates/list, and the client fills in the variables.

What crosses the wire

To see the real messages, a small relay sits between the client and the server. The client launches the relay instead of the server; the relay launches the server, copies every line in both directions and writes each to a log, marked -> for client to server and <- for server to client.

"""Sit between an MCP client and a stdio server and log every line in both directions."""
import subprocess, sys, threading

log = open(sys.argv[1], "a", buffering=1)
child = subprocess.Popen(sys.argv[2:], stdin=subprocess.PIPE, stdout=subprocess.PIPE)


def pump(src, dst, tag):
    for line in iter(src.readline, b""):
        log.write(f"{tag} {line.decode().rstrip()}\n")
        dst.write(line); dst.flush()
    if tag == "->":
        child.stdin.close()


threading.Thread(target=pump, args=(sys.stdin.buffer, child.stdin, "->"), daemon=True).start()
pump(child.stdout, sys.stdout.buffer, "<-")
child.wait()

The client uses each primitive once:

import asyncio, sys
from mcp import Client, StdioServerParameters, Implementation

PY = sys.executable
HERE = "/path/to/scratch"
mode = sys.argv[1] if len(sys.argv) > 1 else "auto"

params = StdioServerParameters(
    command=PY, args=[f"{HERE}/wiretap.py", f"{HERE}/wire_{mode}.log", PY, f"{HERE}/site_server.py"],
    cwd=HERE)


async def main():
    async with Client(params, mode=mode,
                      client_info=Implementation(name="site-assistant", version="0.1.0")) as client:
        print("protocol:", client.protocol_version)
        tools = await client.list_tools()
        for tl in tools.tools:
            print("tool:", tl.name, "-", tl.description)
        res = await client.call_tool("list_chapters", {"track": "gpu"})
        print("isError:", res.is_error, "| first block:", res.content[0].text[:120])
        bad = await client.call_tool("list_chapters", {"track": "cooking"})
        print("isError:", bad.is_error, "|", bad.content[0].text[:200])
        r = await client.read_resource("chapter://dsa/09-heaps")
        print("resource chars:", len(r.contents[0].text))
        p = await client.get_prompt("answer_from_site", {"question": "What is a heap?"})
        print("prompt:", p.messages[0].content.text[:80])


asyncio.run(main())

With the default mode="auto", the client printed protocol: 2026-07-28, the two tools, a successful call, an error for the made-up track, 47,043 characters for the heaps chapter, and the prompt text. The log holds twelve lines, six requests and six responses. These are the lines exactly as they were sent, except that long results are cut where marked with ….

The first request is the discovery probe. Its only parameters are the _meta block that every modern request carries:

-> {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"site-assistant","version":"0.1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
<- {"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"instructions":"Search and read the chapters of ashokg.ai. Cite sources as /track/id.","resultType":"complete","supportedVersions":["2026-07-28"],"ttlMs":0,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"site-search","version":"0.1.0"}}}}

The client capabilities are an empty object, because this client offers nothing back to the server: no sampling, no elicitation, no roots. The server says what it has (tools, resources, prompts) and which version it speaks. ttlMs and cacheScope are hints for caching the answer; the 2026 revision requires them on list and read results so that a client doesn’t have to ask again on every turn.

The tool list comes next. The full response is 1,309 bytes; this is the list_chapters entry from it:

-> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{…same three fields…}}}
<- {"jsonrpc":"2.0","id":2,"result":{"cacheScope":"private","resultType":"complete","tools":[…,{"description":"List every chapter in one track, in order, with its id and title.","inputSchema":{"type":"object","properties":{"track":{"enum":["blog","kernel","gpu","design","dsa","essays"],"title":"Track","type":"string"}},"required":["track"],"title":"list_chaptersArguments"},"name":"list_chapters","outputSchema":{"properties":{"result":{"items":{"additionalProperties":true,"type":"object"},"title":"Result","type":"array"}},"required":["result"],"title":"list_chaptersOutput","type":"object"}}],"ttlMs":0,"_meta":{…serverInfo…}}}

The inputSchema is plain JSON Schema, which is why it can go almost unchanged into Ollama’s tools field later. The SDK also generated an outputSchema from the return type, and so it also returns the result as structuredContent, a JSON value that a program can use without parsing text.

A successful call:

-> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_chapters","arguments":{"track":"gpu"},"_meta":{…}}}
<- {"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"{\n  \"id\": \"01-from-pixels-to-compute\",\n  \"title\": \"1 — From pixels to compute\"\n}","type":"text"},…six more blocks…],"isError":false,"resultType":"complete","structuredContent":{"result":[…]},"_meta":{…}}}

The SDK turned each item of the returned list into its own text block, seven in all, and also put the whole list in structuredContent. A model will see the text blocks.

A call with an argument outside the enum:

-> {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_chapters","arguments":{"track":"cooking"},"_meta":{…}}}
<- {"jsonrpc":"2.0","id":4,"result":{"content":[{"text":"Error executing tool list_chapters: 1 validation error for list_chaptersArguments\ntrack\n  Input should be 'blog', 'kernel', 'gpu', 'design', 'dsa' or 'essays' [type=literal_error, input_value='cooking', input_type=str]\n    For further information visit https://errors.pydantic.dev/2.14/v/literal_error","type":"text"}],"isError":true,"resultType":"complete","_meta":{…}}}

This is not a JSON-RPC error. It’s a normal result with isError: true, and that distinction is deliberate. The tools page of the specification separates protocol errors (a malformed request, a server failure), which are JSON-RPC errors, from tool execution errors (bad input, a failing API), which go in the result so the model can read them and try again. The specification says clients should pass tool execution errors to the model “to enable self-correction”. Chapter 9 reached the same rule by measurement: an error returned as text gives the model a chance to recover (qwen2.5 did in both of its cases, llama3.1 in 1 of 8), while an exception that ends the loop gives it none. Here it’s part of the protocol.

The resource read and the prompt:

-> {"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"chapter://dsa/09-heaps","_meta":{…}}}
<- {"jsonrpc":"2.0","id":5,"result":{"cacheScope":"private","contents":[{"mimeType":"text/markdown","text":"# 9 — Heaps and priority queues\n\nA priority queue is a container that always hands back its smallest (or largest) item next, …"}],…}}
-> {"jsonrpc":"2.0","id":6,"method":"prompts/get","params":{"name":"answer_from_site","arguments":{"question":"What is a heap?"},"_meta":{…}}}
<- {"jsonrpc":"2.0","id":6,"result":{"description":"Ask a question that must be answered from the site's chapters, with citations.","messages":[{"content":{"text":"Answer using only passages returned by search_site. Cite each source as /track/id. If the passages don't contain the answer, say so.\n\nQuestion: What is a heap?","type":"text"},"role":"user"}],"resultType":"complete",…}}

The prompt comes back as messages, ready to put into a conversation. The server wrote them; the user chose to use them.

The same exchange in the old protocol

Run with mode="legacy", the same client opens with the 2025-11-25 handshake instead, and the SDK’s server, which supports both eras, answers it:

-> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"site-assistant","version":"0.1.0"}}}
<- {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"prompts":{"listChanged":false},"resources":{"listChanged":false,"subscribe":false},"tools":{"listChanged":false}},"instructions":"Search and read the chapters of ashokg.ai. Cite sources as /track/id.","protocolVersion":"2025-11-25","serverInfo":{"name":"site-search","version":"0.1.0"}}}
-> {"jsonrpc":"2.0","method":"notifications/initialized"}
-> {"jsonrpc":"2.0","id":2,"method":"tools/list"}

The version and capabilities are sent once, the client confirms with a notification that has no id, and later requests such as tools/list carry no _meta at all, because the server is expected to remember. That memory is what the 2026 revision removed. The log has thirteen lines in this mode, one more than the modern run, for the initialized notification.

No SDK on the client side

Because stdio is just lines of JSON, a client needs nothing but a pipe. This one talks to the same server with the standard library:

import json, subprocess, sys

proc = subprocess.Popen([sys.executable, "site_server.py"], text=True,
                        stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
META = {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientCapabilities": {}}


def rpc(id, method, **params):
    msg = {"jsonrpc": "2.0", "id": id, "method": method, "params": {**params, "_meta": META}}
    proc.stdin.write(json.dumps(msg) + "\n"); proc.stdin.flush()
    return json.loads(proc.stdout.readline())


print(json.dumps(rpc(1, "server/discover")["result"]["supportedVersions"]))
res = rpc(2, "tools/call", name="search_site",
          arguments={"query": "token bucket refill rate", "track": "design"})["result"]
print("isError:", res["isError"], "| blocks:", len(res["content"]))
for block in res["content"][:2]:
    hit = json.loads(block["text"])
    print(hit["source"], "|", hit["text"][:150].replace("\n", " "))
print(json.dumps(rpc(3, "tools/call", name="no_such_tool", arguments={})))
print(json.dumps(rpc(4, "initialize", protocolVersion="2026-07-28", capabilities={},
                     clientInfo={"name": "x", "version": "0"})))
proc.stdin.close(); proc.wait()

Reading one line per response only works because this client sends one request at a time and the server sends nothing unasked. A real client reads in a loop and matches responses to requests by id. The output:

["2026-07-28"]
isError: False | blocks: 5
/design/22-rate-limiter | ataclasses import dataclass  @dataclass class _BucketState:     tokens: float     last_refill: float  class TokenBucketRateLimiter:     # Thread-safe 
/design/27-notifications | wastes quota and can look like abuse to the provider, so dead tokens have to be cleaned up promptly.  ### Simulating a burst against a rate-limited pr
{"jsonrpc": "2.0", "id": 3, "result": {"content": [{"text": "Unknown tool: no_such_tool", "type": "text"}], "isError": true, "resultType": "complete", "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "site-search", "version": "0.1.0"}}}}
{"jsonrpc": "2.0", "id": 4, "error": {"code": -32022, "message": "connection is serving the 2026-07-28 protocol; the initialize handshake is not accepted", "data": {"supported": ["2026-07-28"], "requested": "2026-07-28"}}}

Two details are worth noticing. The first search hit starts mid-word (“ataclasses”), because the baseline chunker from chapter 6 cuts oversized paragraphs at fixed character offsets, and the overlap carried the tail of a word into the next chunk. MCP carries whatever the tool returns, rough edges included.

The second is a small disagreement between the SDK and the specification. The specification’s tools page lists an unknown tool as a protocol error and shows it as a JSON-RPC error with code -32602. The SDK returned it as a tool result with isError: true. For a model-facing client, the difference is mostly harmless, since the text reaches the model either way. For a client that treats JSON-RPC errors and tool errors differently, it matters. The initialize sent after the server had already been spoken to in the modern protocol was refused with -32022, the unsupported-version code: once this server process is serving the stateless protocol, it doesn’t switch.

The same server over HTTP

Started with python site_server.py http, the server listens on 127.0.0.1:8765/mcp. Streamable HTTP in the 2026 revision mirrors parts of the body into headers so that proxies and load balancers can route without parsing JSON: MCP-Protocol-Version on every request, Mcp-Method always, and Mcp-Name for tools/call, resources/read and prompts/get. The client must accept both application/json and text/event-stream.

curl -s -i -X POST http://127.0.0.1:8765/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' -H 'Mcp-Name: list_chapters' \
  -d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"list_chapters","arguments":{"track":"gpu"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
HTTP/1.1 200 OK
server: uvicorn
content-length: 1523
content-type: application/json

{"jsonrpc":"2.0","id":7,"result":{"content":[{"text":"{\n  \"id\": \"01-from-pixels-to-compute\", …

Changing one thing at a time shows the server’s checks, each of which the specification requires:

What was changedStatusBody
Nothing (request above)200the result
_meta removed from the body400-32602: “params._meta must be an object carrying the required ‘io.modelcontextprotocol/protocolVersion’ and ‘io.modelcontextprotocol/clientCapabilities’ envelope keys”
Mcp-Name: search_site while the body calls list_chapters400-32020: “mcp-name header does not match the request body’s ‘name’ parameter”
Origin: http://evil.example added403“Invalid Origin header”

The last check deserves a word. A server bound to localhost can still be reached by a web page in the user’s browser, through DNS rebinding: a hostile domain that resolves first to its own server and then to 127.0.0.1. The specification says servers “MUST validate the Origin header” for that reason, and should bind to 127.0.0.1 rather than every interface when they run locally. The SDK refused the foreign origin without any configuration.

A request with the header MCP-Protocol-Version: 2025-11-25 and no session was answered 400 with “Missing session ID”: the server took the header as a sign of a legacy client, which in that revision has to open a session first.

The agent, now an MCP host

The chapter 9 loop needs one change to become an MCP host: the tool list and the tool runner come from the client instead of from a dictionary. An MCP tool definition maps onto Ollama’s tool format almost field for field.

def to_ollama(tool):
    """An MCP tool definition maps almost field for field onto Ollama's tools format."""
    return {"type": "function", "function": {
        "name": tool.name, "description": tool.description or "", "parameters": tool.input_schema}}


async def ask(question, model="llama3.1:8b", max_steps=6):
    server = StdioServerParameters(command=PY, args=[f"{HERE}/site_server.py"], cwd=HERE)
    async with Client(server, client_info=Implementation(name="site-assistant", version="0.1.0")) as mcp:
        tools = [to_ollama(t) for t in (await mcp.list_tools()).tools]
        messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": question}]
        for _ in range(max_steps):
            r = requests.post("http://localhost:11434/api/chat", json={
                "model": model, "messages": messages, "tools": tools, "stream": False,
                "options": {"num_predict": 1024}}).json()
            msg = r["message"]
            messages.append(msg)
            if not msg.get("tool_calls"):
                return msg["content"]
            for call in msg["tool_calls"]:
                fn = call["function"]
                print(f"  -> {fn['name']}({json.dumps(fn['arguments'])})")
                result = await mcp.call_tool(fn["name"], fn["arguments"])
                text = "\n".join(c.text for c in result.content if c.type == "text")
                if result.is_error:
                    text = "ERROR: " + text
                messages.append({"role": "tool", "tool_name": fn["name"], "content": text})
        return None

The host starts the server as a subprocess, asks it for its tools, and hands the generated schemas to the model unchanged. When the model calls a tool, the host forwards the call over MCP and passes the text blocks back as a tool message, marking errors so the model can see them. Nothing in the loop knows what the tools do.

With qwen2.5:7b (7.6B parameters, Q4_K_M, served by Ollama 0.40 with its default 4,096-token context and replies capped at 1,024 tokens, on one Mac with an Apple M3 Pro and 18 GB), two questions:

Q: How many chapters does the GPU track have, and which one covers the CUDA execution model?
  -> list_chapters({"track": "gpu"})
The GPU track has 7 chapters. The chapter that covers the CUDA execution model is titled
"3 — The CUDA execution model" with the ID 03-the-cuda-model.

Q: In the rate limiter chapter, how many requests did the fixed window and the token bucket allow in the boundary test?
  -> search_site({"track": "design", "query": "rate limiter fixed window token bucket boundary test requests"})
In the boundary test, the fixed window rate limiter allowed 200 requests, while the token bucket allowed
100 requests. This information is from the chapter titled "22 — A rate limiter, end to end" (/design/22-rate-limiter).

Both answers are right, and both took one call. The same two questions sent to llama3.1:8b through the same host failed in the ways chapter 10 describes: for the first, it wrote the two calls it intended as JSON in its reply instead of making them, and for the second it made no call and answered “would likely allow 1 request”, citing a chapter that doesn’t exist. Moving the tools behind a protocol changed where they run, not how well a given model uses them.

The host still makes every decision that matters. It chooses which servers to start, which of their tools to show the model, whether to ask the user before a call, and what to do with the result. MCP moved the tool out of the host’s code, not out of its responsibility.

What the protocol doesn’t do

MCP standardizes the plug. It doesn’t make a tool good. The tool-design points from chapter 9 (clear names, tight schemas, errors the model can act on) apply unchanged, because the model reads the same description and inputSchema, now sent over a pipe. A server with forty vaguely described tools will be used as badly as forty vaguely described Python functions, and loading several servers at once makes that worse: the specification warns that two servers can both expose a tool called search, and that hosts should disambiguate.

It also doesn’t make a tool trustworthy. The specification’s own security section says tools “represent arbitrary code execution”, that hosts must get the user’s consent before invoking them, and that tool descriptions and annotations “should be considered untrusted, unless obtained from a trusted server”. A stdio server runs with the user’s own permissions. Installing one is installing a program.

Where this leaves things

The site search now lives in its own process behind a standard interface. Any MCP host can start it, list its tools, call them and read chapters as resources, over a pipe or over HTTP, and the assistant’s loop got shorter in the move. The wire format is plain JSON-RPC, simple enough to drive from twenty lines of standard-library Python, and in its 2026 revision every request is self-contained.

Look again at what the tools return, though. search_site hands the model five passages of text taken from documents, and the host appends them to the conversation as a tool message, right next to the system prompt and the user’s question. The model doesn’t see a boundary between “instructions from the person using me” and “text that some document happened to contain”. So far the documents have been this site’s own chapters, written by one person. An MCP server can just as easily search a mailbox, a ticket queue or the web, where anyone can write the text. A passage that says “ignore the question and do something else” arrives through exactly the same channel as a passage about token buckets. Chapter 12 plants such a passage in a document and measures how often the agent follows it.