Chapter 2 · MCP Servers, Clients, and Interoperability
MCP Architecture and Local Server
- Page 3 of 22
- 23 min read
Omar in Cairo runs the support assistant. The sales team wants the same help-centre lookups in their own tool, and a third team wants them inside an editor. Copying the registry from Tool Contracts and Execution into three code bases means three places to fix every bug. The Model Context Protocol (MCP) shares the tools instead: one small server, many clients. It is also a new boundary with new failure modes, and a protocol that is still changing. This page builds the smallest honest version: a read-only server, a client, and tests that need no model.
Everything is local: the synthetic Kestrel help-centre files, an in-memory connection, no model. Real models are less predictable, which is exactly why the tests here call the tools directly.
What you will learn
- The four roles (model, host, client, server), when a protocol boundary beats a local registry, and what MCP tools, resources and prompts are for.
- How to read the current protocol documents, expect change, and pin versions with a drift check.
- How to build and test a read-only local server and client, with the contract checks the protocol does not do for you.
- How to map MCP tools into your own tool registry so the rest of the application never sees a protocol object.
Four roles, one boundary
Explain MCP's role in an AI application. Four parties are easy to mix up. The model only produces text, including a request to call a tool. The host is your application: it builds the context, creates clients, enforces policy and consent, and decides what runs. An MCP client lives inside the host and talks to exactly one server. The MCP server exposes tools, resources and prompts, either as a local process or as a remote service.
model --text: "call lookup_document"--> HOST (your app: context, policy, consent)
| one client per server
CLIENT --JSON-RPC over stdio / Streamable HTTP--> SERVER
tools, resources, promptsThe model never talks to the server. The host turns the model's request into a client call, exactly as the registry in Tool Contracts and Execution turned a request into an action. MCP changes where the tool lives, not who is responsible for checking the request.
MCP or direct function calling?
Compare MCP with direct function calling. Direct calling means a registry in your own process (ToolRegistry, with the allowlist, limits and audit events of Tool Permissions and Failure Handling). MCP puts a process or network boundary between the host and the tool.
| Question | Local registry | Over MCP |
|---|---|---|
| Who can reuse the tool? | One code base | Any host that speaks the protocol |
| Language and release cycle | Same as the app | Independent: another team, another language |
| New failure modes | Few: your own exceptions | Disconnects, timeouts, version mismatch, malformed or hostile replies |
| Who validates arguments and results? | Your Pydantic models | Both sides; you cannot assume the other side did |
| Trust | Your code | Someone else's code and text (descriptions, results) |
| Cost to run | None extra | A process or service to deploy, monitor and patch |
Rule of thumb: use the local registry while one application owns the tool. Move to a protocol boundary when several hosts must share it, when another team owns it, or when you want to isolate it in its own process. The decision is about ownership and reuse, not about which is newer. Further down, the same five calls run through both paths so you can see what the boundary adds; the next page returns to the maintenance cost.
Tools, resources and prompts
Inspect MCP tools, resources, and prompts. The three server features differ in who decides to use them:
| Feature | Controlled by | What it is | In our server |
|---|---|---|---|
| Tool | The model (through the host) | A function that does something and returns a result | lookup_document, search_help |
| Resource | The application | Context data identified by a URI | kestrel://index, kestrel://docs/{doc_id} |
| Prompt | The user | A reusable template, like a slash command | answer_from_docs |
A client discovers all three with list calls (list_tools, list_resources, list_resource_templates, list_prompts) and never needs to know the server's source code. Discovery output is data from another party: read it, do not trust it.
Read the current protocol documentation
Identify protocol-version negotiation and compatibility requirements. Most articles and many examples still describe the 2024 and 2025 protocol: an initialize handshake, sessions, sampling and roots. Do not copy them. Read the specification itself, and expect it to move.
Tool note (as of 2026-10-12): The current specification revision is 2026-07-28; version ids are dates. In this revision there is no
initializehandshake and no protocol-level session: every request carries its protocol version (in the request_meta, and in theMCP-Protocol-Versionheader over HTTP). A server that does not support the version replies with anUnsupportedProtocolVersionError(code -32022) listing the versions it does support, and the client may retry with a shared one. Servers must implementserver/discover(versions, capabilities, identity);initializesurvives only for older ("legacy") revisions. Sampling, roots and logging are marked Deprecated, and the old HTTP+SSE transport is Deprecated; the two standard transports are stdio and Streamable HTTP. The Python SDKmcpwas at 2.3.0 on 2026-10-02 (1.x was still patched); its 2.x server class isMCPServer(FastMCPin 1.x) and 2.x has aClientclass. Its pages disagree on the minimum Python version: use 3.11 or newer (this course uses 3.12). VERIFY — checked 2026-10-12; check the current documentation.
Security note (as of 2026-10-12): The SDK repository listed ten security advisories published in the previous 12 months (HTTP transports, token handling, redirects, sessions and more). The newest fixes shipped in 2.2.0 and 1.30.0; this page uses only stdio and in-memory connections. The habit matters more than the list: pin a fixed version, read advisories and release notes before every upgrade, and re-check at release time. VERIFY — checked 2026-10-12; check the current documentation.
Turn that habit into code. versions.py records the SDK and protocol versions we tested and reports any difference. Create the files below inside kit/evidencedesk/mcp_local/ (the package marker __init__.py is a docstring only and is in the kit).
# not run here: installs a package (already present in the author's environment)
pip install "mcp==2.3.0"# file: kit/evidencedesk/mcp_local/versions.py
"""Pinned versions and a drift check. Re-read the changelog, then change the pins on purpose."""
from __future__ import annotations
from importlib import metadata
EXPECTED_SDK = "2.3.0" # VERIFY — checked 2026-10-12: python-sdk releases and advisories change often
EXPECTED_PROTOCOL = "2026-07-28" # VERIFY — checked 2026-10-12: the spec version this course was written against
def installed_sdk() -> str:
return metadata.version("mcp")
def drift(sdk: str, protocol: str) -> list[str]:
"""What differs from the pins. An empty list means: same versions as the ones we tested."""
found = []
if sdk != EXPECTED_SDK:
found.append(f"sdk {sdk} != pinned {EXPECTED_SDK}")
if protocol != EXPECTED_PROTOCOL:
found.append(f"protocol {protocol} != pinned {EXPECTED_PROTOCOL}")
return foundA test calls drift() with the installed SDK and the version a live client reports, and fails on purpose when either moves: read the changelog and advisories, then change the pins deliberately.
Build a minimal local MCP server
Expose harmless read-only tools with fixture data. The server wraps the registry you already have. lookup_document is the Beginner tool unchanged; search_help is one new read-only tool over the toy index from Embeddings and Similarity. Both call ToolRegistry.execute, so argument validation, error codes and the "no internals in errors" rule stay in one place. Design notes:
- Least privilege by construction.
build_server(tools=...)registers only the names you ask for, and only names fromTOOL_NAMES. There is no write tool in the file to enable, so the model has no path to one. Resources are limited to ids found in the corpus folder, never a path built from user text. - Annotations are hints, not security. The specification says clients must treat annotations as untrusted unless they come from a trusted server; the SDK documentation calls them hints. What keeps this server read-only is that its code never writes.
- Errors carry a safe code and message. The SDK hides unexpected exception text; we raise its tool error only with our
code: message.
# file: kit/evidencedesk/mcp_local/server.py
"""A minimal, read-only MCP server over the synthetic Kestrel help centre. Its tools wrap the Beginner ToolRegistry."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated, Iterable
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceError, ToolError as McpToolError
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
from evidencedesk.embeddings import LocalSearch, load_corpus
from evidencedesk.tools import Tool, ToolArgs, ToolCall, ToolRegistry, default_registry, read_front_matter
KIT = Path(__file__).resolve().parents[2]
CORPUS = KIT / "corpus"
SERVER_NAME = "kestrel-help"
TOOL_NAMES = ("lookup_document", "search_help") # all read-only; there is no write tool to enable
DOC_ID = r"^[a-z0-9-]{3,40}$"
INSTRUCTIONS = "Read-only Kestrel Cycles help centre (synthetic). No tool changes any data."
HINTS = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False) # hints, not security
class DocInfo(BaseModel):
id: str
title: str
version: str
status: str
lang: str
class Hit(BaseModel):
passage_id: str
doc_id: str
status: str
score: float
class SearchResult(BaseModel):
query: str
hits: list[Hit]
class SearchArgs(ToolArgs):
query: str = Field(min_length=1, max_length=200)
k: int = Field(default=3, ge=1, le=5)
def help_registry(corpus_dir: Path = CORPUS) -> ToolRegistry:
"""The internal registry: the Beginner lookup tool plus one search tool."""
registry = default_registry(corpus_dir)
index = LocalSearch(load_corpus(corpus_dir))
def search(args: SearchArgs) -> dict:
hits = [Hit(passage_id=h.passage_id, doc_id=index.passages[h.passage_id].doc_id,
status=index.passages[h.passage_id].status, score=round(float(h.score), 3))
for h in index.search(args.query, args.k)]
return SearchResult(query=args.query, hits=hits).model_dump()
registry.register(Tool("search_help", "Search the help centre; returns passage ids with scores, not text.", SearchArgs, search))
return registry
def build_server(corpus_dir: Path = CORPUS, *, tools: Iterable[str] = TOOL_NAMES) -> MCPServer:
"""Least privilege: register only the tools asked for, and only names from TOOL_NAMES."""
wanted = set(tools)
if wanted - set(TOOL_NAMES):
raise ValueError(f"unknown tool names: {sorted(wanted - set(TOOL_NAMES))}")
registry = help_registry(corpus_dir)
ids = {read_front_matter(p)["id"]: p for p in sorted(corpus_dir.glob("*.md"))}
server = MCPServer(SERVER_NAME, instructions=INSTRUCTIONS)
def run(name: str, arguments: dict) -> dict:
result = registry.execute(ToolCall("mcp", name, arguments))
if not result.ok:
raise McpToolError(f"{result.error['code']}: {result.error['message']}") # safe text only; the SDK hides other exceptions
return result.data
if "lookup_document" in wanted:
@server.tool(annotations=HINTS)
def lookup_document(doc_id: Annotated[str, Field(pattern=DOC_ID)]) -> DocInfo:
"""Look up a help-centre document by its id."""
return DocInfo(**run("lookup_document", {"doc_id": doc_id}))
if "search_help" in wanted:
@server.tool(annotations=HINTS)
def search_help(query: Annotated[str, Field(min_length=1, max_length=200)], k: Annotated[int, Field(ge=1, le=5)] = 3) -> SearchResult:
"""Search the help centre; returns passage ids with scores, not text."""
return SearchResult(**run("search_help", {"query": query, "k": k}))
@server.resource("kestrel://index", mime_type="application/json")
def index() -> str:
"""Ids and titles of every document."""
return json.dumps([{"id": i, "title": read_front_matter(p)["title"]} for i, p in sorted(ids.items())], ensure_ascii=False)
@server.resource("kestrel://docs/{doc_id}", mime_type="text/markdown")
def document(doc_id: str) -> str:
"""One document. Only ids found in the corpus folder are readable."""
if doc_id not in ids:
raise ResourceError("no document has that id")
return ids[doc_id].read_text(encoding="utf-8")
@server.prompt()
def answer_from_docs(question: str) -> str:
"""A template the user picks."""
return ("Answer the question using only the Kestrel help-centre documents. Cite document ids. "
f"If they do not answer it, say so.\n\nQuestion: {question}")
return serverThis entry point runs it as a stdio server. Standard output is the wire, so a stray print() would corrupt the protocol: log to standard error. Stdio is also not a sandbox: the child runs with the privileges of whoever started it.
# file: kit/evidencedesk/mcp_local/__main__.py
"""Run the server over stdio: python -m evidencedesk.mcp_local (stdout is the wire: never print here)."""
from evidencedesk.mcp_local.server import build_server
if __name__ == "__main__":
build_server().run() # no argument = stdioexport PYTHONPATH=kitNow discover what it offers. The loop connects in both modes and prints the version each reports; the rest prints what the list calls return. (The SDK logs failed calls to standard error; the block silences that.)
import logging
import anyio
from mcp import Client
from evidencedesk.mcp_local.server import build_server
logging.getLogger("mcp").setLevel(logging.CRITICAL) # the SDK logs failed tool calls to stderr
async def main() -> None:
for mode in ("auto", "legacy"):
async with Client(build_server(), mode=mode) as client:
print(f"mode={mode:6} protocol={client.protocol_version} server={client.server_info.name}")
async with Client(build_server()) as client:
caps = client.server_capabilities
print("capabilities:", [n for n in ("tools", "resources", "prompts", "completions") if getattr(caps, n)])
tools = (await client.list_tools()).tools
print("tools:", [(t.name, t.annotations.read_only_hint) for t in tools])
print("resources:", [str(r.uri) for r in (await client.list_resources()).resources])
print("templates:", [t.uri_template for t in (await client.list_resource_templates()).resource_templates])
print("prompts:", [p.name for p in (await client.list_prompts()).prompts])
anyio.run(main)mode=auto protocol=2026-07-28 server=kestrel-help
mode=legacy protocol=2025-11-25 server=kestrel-help
capabilities: ['tools', 'resources', 'prompts']
tools: [('lookup_document', True), ('search_help', True)]
resources: ['kestrel://index']
templates: ['kestrel://docs/{doc_id}']
prompts: ['answer_from_docs']What to notice. mode="auto" (the default) used the 2026-07-28 revision. mode="legacy" negotiated 2025-11-25, so the older handshake is still available for old servers. Both strings are what this SDK version printed and may change in others. Next, one call of each kind:
import json
import anyio
from mcp import Client
from evidencedesk.mcp_local.server import build_server
async def main() -> None:
async with Client(build_server()) as client:
called = await client.call_tool("lookup_document", {"doc_id": "kc-recalls-v1"})
print("tool :", called.structured_content)
index = json.loads((await client.read_resource("kestrel://index")).contents[0].text)
print("resource:", len(index), "documents, first:", index[0])
doc = (await client.read_resource("kestrel://docs/kc-recalls-v1")).contents[0].text
print("resource:", doc.splitlines()[0:2], "...", len(doc), "characters")
got = await client.get_prompt("answer_from_docs", {"question": "Is my bike recalled?"})
print("prompt :", got.messages[0].role, "|", got.messages[0].content.text.splitlines()[-1])
anyio.run(main)tool : {'id': 'kc-recalls-v1', 'title': 'Safety recalls', 'version': '1', 'status': 'current', 'lang': 'en'}
resource: 13 documents, first: {'id': 'kc-assembly-v1', 'title': 'Assembling your bike'}
resource: ['---', 'id: kc-recalls-v1'] ... 467 characters
prompt : user | Question: Is my bike recalled?The tool returned a structured dictionary, the resources returned application context, and the prompt returned a ready-made user message. The model sees none of this unless the host puts it in the context, under the trust rules of Context Assembly and Trust Boundaries.
Validate tool inputs and outputs
Treat schemas as contracts that still require runtime checks. The server publishes a JSON schema for each tool, and the SDK does check types and patterns. But a schema is only a promise. The raw SDK call accepts a field the schema never mentioned, and a drifted server can return a result that breaks the schema it advertised. The bridge below defends our side twice: it validates arguments with the Beginner Pydantic models (which forbid extra fields) before sending, and it validates the structured result against a Contract that lives in our code, not in the server's description.
Build a minimal MCP client and map it into the registry
Discover and invoke the local tool through the documented SDK, and map MCP tools into the internal tool registry. McpRegistry has the same surface as ToolRegistry (names, specs, execute), but it is async, only tools with a contract are callable, and every outcome is a ToolResult from Tool Contracts and Execution. The application never sees a protocol result object. Failures get codes of our own: invalid_arguments, unknown_tool, tool_error (the server said so; text clipped, because it is untrusted), bad_result, timeout, protocol_error. Server tools without a contract are listed in ignored and can never be called.
# file: kit/evidencedesk/mcp_local/bridge.py
"""Map MCP tools into the internal tool registry: the application sees ToolCall and ToolResult, never an MCP result object."""
from __future__ import annotations
import sys
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, AsyncIterator
import anyio
from mcp import Client, StdioServerParameters
from mcp.shared.exceptions import MCPError
from pydantic import BaseModel, ValidationError
from evidencedesk.mcp_local.server import KIT, DocInfo, SearchArgs, SearchResult
from evidencedesk.permissions import fail
from evidencedesk.tools import LookupArgs, ToolArgs, ToolCall, ToolResult
CLIP = 200 # server error text is untrusted: clip it
@dataclass(frozen=True)
class Contract:
"""What we expect of a remote tool, written in our code, not taken from the server."""
args: type[ToolArgs]
result: type[BaseModel]
CONTRACTS = {"lookup_document": Contract(LookupArgs, DocInfo), "search_help": Contract(SearchArgs, SearchResult)}
def problems_of(exc: ValidationError) -> list[dict[str, str]]:
return [{"loc": ".".join(str(p) for p in e["loc"]), "msg": e["msg"]} for e in exc.errors()]
class McpRegistry:
"""Like ToolRegistry (names, specs, execute) but async, with the work behind a protocol boundary."""
def __init__(self, client: Client, contracts: dict[str, Contract] = CONTRACTS, timeout: float = 2.0) -> None:
self.client, self.contracts, self.timeout = client, contracts, timeout
self._specs: dict[str, dict[str, Any]] = {}
self.ignored: list[str] = [] # server tools without a contract: listed, never callable
async def refresh(self) -> list[str]:
listing = await self.client.list_tools()
self._specs = {t.name: {"name": t.name, "description": t.description or "", "parameters": t.input_schema}
for t in listing.tools if t.name in self.contracts}
self.ignored = sorted(t.name for t in listing.tools if t.name not in self.contracts)
return self.names()
def names(self) -> list[str]:
return sorted(self._specs)
def specs(self) -> list[dict[str, Any]]:
return [self._specs[n] for n in self.names()]
async def execute(self, call: ToolCall) -> ToolResult:
contract = self.contracts.get(call.name)
if contract is None or call.name not in self._specs:
return fail(call, "unknown_tool", "no such tool is registered")
try:
args = contract.args.model_validate(call.arguments) # our check first: the SDK ignores extra fields
except ValidationError as exc:
return fail(call, "invalid_arguments", "arguments rejected", problems=problems_of(exc))
try:
with anyio.fail_after(self.timeout):
reply = await self.client.call_tool(call.name, args.model_dump())
except TimeoutError:
return fail(call, "timeout", f"tool did not finish within {self.timeout} s")
except MCPError:
return fail(call, "protocol_error", "the server answered with a protocol error")
except Exception: # never leak the text
return fail(call, "internal_error", "the call failed")
if reply.is_error:
text = next((c.text for c in reply.content if getattr(c, "type", "") == "text"), "tool error")
return fail(call, "tool_error", text.splitlines()[0][:CLIP] if text else "tool error")
try:
data = contract.result.model_validate(reply.structured_content).model_dump()
except ValidationError:
return fail(call, "bad_result", "the result does not match the contract")
return ToolResult(call.id, call.name, True, data=data)
def stdio_params() -> StdioServerParameters:
"""Launch `python -m evidencedesk.mcp_local` with this interpreter."""
return StdioServerParameters(command=sys.executable, args=["-m", "evidencedesk.mcp_local"],
cwd=str(KIT), env={"PYTHONPATH": str(KIT)})
@asynccontextmanager
async def open_registry(target: Any, **options: Any) -> AsyncIterator[McpRegistry]:
"""Connect to a server object (in memory) or StdioServerParameters, discover its tools, yield the registry."""
async with Client(target) as client:
registry = McpRegistry(client, **options)
await registry.refresh()
yield registryRun the three gaps against the real SDK. The second server is a deliberately drifted stand-in that returns a number where we expect a string.
import logging
import anyio
from mcp import Client
from mcp.server import MCPServer
from evidencedesk.mcp_local.bridge import open_registry
from evidencedesk.mcp_local.server import build_server
from evidencedesk.tools import ToolCall
logging.getLogger("mcp").setLevel(logging.CRITICAL)
sneaky = {"doc_id": "kc-returns-v3", "role": "admin"}
rogue = MCPServer("rogue") # a server whose result drifted away from our contract
@rogue.tool()
def lookup_document(doc_id: str) -> dict[str, int]:
return {"id": 7}
async def main() -> None:
async with Client(build_server()) as client: # the raw SDK call
raw = await client.call_tool("lookup_document", sneaky)
print("raw SDK, extra field : is_error =", raw.is_error)
async with open_registry(build_server()) as registry: # our bridge
result = await registry.execute(ToolCall("c1", "lookup_document", sneaky))
print("bridge, extra field :", result.error["code"], result.error["problems"][0])
async with open_registry(rogue) as registry:
result = await registry.execute(ToolCall("c2", "lookup_document", {"doc_id": "kc-returns-v3"}))
print("bridge, drifted result :", result.error)
anyio.run(main)raw SDK, extra field : is_error = False
bridge, extra field : invalid_arguments {'loc': 'role', 'msg': 'Extra inputs are not permitted'}
bridge, drifted result : {'code': 'bad_result', 'message': 'the result does not match the contract'}The raw SDK accepted the extra role field. Our bridge refused it before sending, and turned the drifted result into a clean bad_result instead of letting a wrong shape travel on. MCP does not replace authentication, authorisation or validation.
Test the MCP flow without an LLM
Use a normal Python test to call the server and assert the result. No model is needed to test a tool. In the SDK, passing the server object to Client connects in memory: no subprocess, no port. The kit holds three test files (test_mcp_local_server.py, test_mcp_local_bridge.py, test_mcp_local_roundtrip.py) that check, among other things:
- the tool list is exactly the two read-only tools; a subset works and an unknown name is refused; the server schema matches the registry schema (the wrapper declares its own signature, so drift needs a test);
- hostile resource ids are refused and errors contain no paths;
- against hostile in-memory servers: invalid arguments never reach the server, a tool without a contract cannot run, an 8,500-character error is clipped, a broken result is
bad_result, a slow tool times out; - one test starts the server as a real stdio child process and requires the same answer as the in-memory run (one local process, no network, deterministic). The other 19 tests are in memory only.
The test files import mcp and anyio by name with importlib and skip themselves when mcp is not installed. Reason: the suite from Packaging, Configuration, and Reproducibility checks that tests import only declared packages, and mcp is optional.
Compare the two paths
Five calls go through the direct registry, the bridge in memory, and the bridge over a stdio child process; the last asks for an unregistered tool. (The stdio child logs one line to standard error; I ran the block with 2>/dev/null, so only standard output is shown.)
import logging
import anyio
from evidencedesk.mcp_local.bridge import open_registry, stdio_params
from evidencedesk.mcp_local.server import CORPUS, build_server, help_registry
from evidencedesk.tools import ToolCall
logging.getLogger("mcp").setLevel(logging.CRITICAL)
CALLS = [ToolCall("a", "lookup_document", {"doc_id": "kc-returns-v3"}),
ToolCall("b", "search_help", {"query": "battery charging", "k": 1}),
ToolCall("c", "lookup_document", {"doc_id": "no-such-doc"}),
ToolCall("d", "search_help", {"query": ""}),
ToolCall("e", "send_email", {"to": "a@example.org"})]
def show(label: str, results) -> None:
print(f"{label:10}", " ".join(f"{r.call_id}={'ok' if r.ok else r.error['code']}" for r in results))
async def main() -> None:
direct = help_registry(CORPUS)
show("direct", [direct.execute(c) for c in CALLS])
async with open_registry(build_server()) as registry:
in_memory = [await registry.execute(c) for c in CALLS]
show("in-memory", in_memory)
async with open_registry(stdio_params()) as registry:
over_stdio = [await registry.execute(c) for c in CALLS]
show("stdio", over_stdio)
print("same results in memory and over stdio:", in_memory == over_stdio)
print("same data as direct for ok calls:", all(d.data == m.data for d, m in zip((direct.execute(c) for c in CALLS), in_memory) if d.ok))
anyio.run(main)direct a=ok b=ok c=not_found d=invalid_arguments e=unknown_tool
in-memory a=ok b=ok c=tool_error d=invalid_arguments e=unknown_tool
stdio a=ok b=ok c=tool_error d=invalid_arguments e=unknown_tool
same results in memory and over stdio: True
same data as direct for ok calls: TrueThe data of the successful calls is identical on all three paths. But call c shows the price of the boundary: the direct registry says not_found, while over MCP the same failure arrives as the generic tool_error, with the specific code only inside the message text. We could parse that text back into a code, but it comes from another party, so we keep it as clipped text and decide on our own codes. The boundary also added failures that cannot happen in-process (bad_result, timeout, protocol_error): the "new failure modes" row of the table, in running code.
Exercise: a local MCP round trip
Start a fixture server, call it from a client, and verify deterministic output. This small file is the exercise; run it together with the other two test files.
# file: kit/tests/test_mcp_local_roundtrip.py
"""Exercise: a local MCP round trip. Start the fixture server in memory, call it through the bridge, check deterministic output."""
import importlib
import pytest
pytest.importorskip("mcp") # skipped when the optional `mcp` package is not installed
anyio = importlib.import_module("anyio") # loaded by name: test_api scans tests/*.py for undeclared third-party imports
from evidencedesk.mcp_local.bridge import open_registry # noqa: E402
from evidencedesk.mcp_local.server import build_server # noqa: E402
from evidencedesk.tools import ToolCall # noqa: E402
RETURNS = {"id": "kc-returns-v3", "title": "Returns policy", "version": "3", "status": "current", "lang": "en"}
def round_trip(name, arguments):
async def main():
async with open_registry(build_server()) as registry:
return registry.names(), await registry.execute(ToolCall("t1", name, arguments))
return anyio.run(main)
def test_lookup_round_trip_is_deterministic():
_, first = round_trip("lookup_document", {"doc_id": "kc-returns-v3"})
assert first.ok and first.data == RETURNS
assert round_trip("lookup_document", {"doc_id": "kc-returns-v3"})[1] == first # same call, same answer
def test_an_unknown_document_is_a_clean_failure():
_, result = round_trip("lookup_document", {"doc_id": "no-such-doc"})
assert not result.ok and result.error["code"] == "tool_error" and "not_found" in result.error["message"]
def test_the_model_can_reach_no_write_tool():
names, result = round_trip("send_email", {"to": "a@example.org"})
assert names == ["lookup_document", "search_help"]
assert result.error["code"] == "unknown_tool"cd kit
python -m pytest tests/test_mcp_local_server.py tests/test_mcp_local_bridge.py tests/test_mcp_local_roundtrip.py.................... [100%]
20 passed(The elapsed seconds that pytest prints after the count are left out here.) Remember the third test: the model cannot reach a write tool because none exists; asking for send_email returns unknown_tool.
Where this shows up at work
Platform teams publish one internal tools server (ticket lookup, product catalogue, runbook search) so the support bot, the sales assistant and the engineers' editor use the same audited code. The review question is always: what can this server do, who can call it, and what happens when it changes under us? A pinned version, a contract in the caller's repository and a test that needs no model make those answers short.
Common mistakes
- Copying a 2025 tutorial. It teaches the handshake and sessions. Read the current specification, pin the SDK version, and add the drift test.
- Using
print()in a stdio server. Standard output is the protocol channel, so one stray line breaks every client. Log to standard error. - Believing the schema or the annotations. The SDK ignored an unknown field, and
read_only_hintis a hint. Validate on both sides and keep read-only true in code. - Passing protocol objects through the application. Every caller then needs to know MCP. Convert at the boundary into your own result type, and test with plain calls (no model) including hostile-server cases.
Try it yourself
- Easy. For each of tools, resources and prompts, name who controls it and give the example from our server. Which Beginner page's registry does the tool wrapper reuse?
- Medium. Build the server with only
lookup_documentand ask the client to callsearch_help. Which code comes back, and which idea from Tool Permissions and Failure Handling is this? - Hard. Write a test that reads four hostile resource URIs (path traversal, a null byte, a wrong-case id, a
file:URI) and requires a short refusal for each.
Answers
Easy. Tools: the model, through the host (lookup_document). Resources: the application (kestrel://docs/{doc_id}). Prompts: the user (answer_from_docs). The wrapper reuses the registry of Tool Contracts and Execution, so the Pydantic validation and error codes are the same. Checklist: three controllers named correctly; one example each; the right Beginner title.
Medium. Run:
import anyio
from evidencedesk.mcp_local.bridge import open_registry
from evidencedesk.mcp_local.server import build_server
from evidencedesk.tools import ToolCall
async def main() -> None:
async with open_registry(build_server(tools=["lookup_document"])) as registry:
print(registry.names())
result = await registry.execute(ToolCall("m1", "search_help", {"query": "returns"}))
print(result.error["code"])
anyio.run(main)['lookup_document']
unknown_toolIt is an allowlist, enforced at server build time (and again by the bridge, since a tool the server does not list cannot be called). Checklist: search_help missing from the names; unknown_tool, not an exception; you can say why the server and the host should each restrict tools.
Hard. Save this file and run python -m pytest tests/test_mcp_local_hostile_uris.py from kit/.
# file: kit/tests/test_mcp_local_hostile_uris.py
import importlib
import pytest
pytest.importorskip("mcp")
mcp = importlib.import_module("mcp") # loaded by name: test_api scans tests/*.py for undeclared third-party imports
anyio = importlib.import_module("anyio")
MCPError = importlib.import_module("mcp.shared.exceptions").MCPError
from evidencedesk.mcp_local.server import build_server # noqa: E402
HOSTILE = ["kestrel://docs/..%2F..%2Fetc%2Fpasswd", "kestrel://docs/kc-returns-v3%00.md", "kestrel://docs/KC-RETURNS-V3", "file:///etc/passwd"]
def test_hostile_resource_uris_are_refused():
async def main():
out = []
async with mcp.Client(build_server()) as client:
for uri in HOSTILE:
with pytest.raises(MCPError) as caught:
await client.read_resource(uri)
out.append(str(caught.value))
return out
for message in anyio.run(main):
assert message.startswith(("Unknown resource", "no document")) # a short refusal; the error may echo the URI, never file contents. [100%]
1 passedChecklist: each URI raised the SDK's protocol error; none returned file contents; the refusals are short; you saw that unknown URIs fail in the SDK's routing, while the wrong-case id reaches our code and is refused by the corpus-id allowlist.
Summary
- The host owns policy; the client talks to one server; the server offers tools (model-controlled), resources (application-controlled) and prompts (user-controlled).
- Use a local registry while one application owns the tool. A protocol boundary buys reuse and isolation, and costs new failure modes and a service to maintain.
- The current specification (as of 2026-10-12) has no handshake; read it, pin the SDK and protocol versions, test for drift, and re-check advisories before upgrades.
- Schemas and annotations are promises, not checks: validate arguments before sending and results after receiving, with contracts in your own code.
- Map MCP tools into your registry so the application only sees
ToolCallandToolResult, and test the whole flow without a model.
Next: MCP Security and Interoperability takes this server from a trusted local process toward the real world: transports, connection errors, authentication, hostile tool text, version drift, limits and audit events.