flowchart TD
accTitle: Choosing between a protocol error and a tool execution error
accDescr: If the tool name is unknown or the request is malformed, return a protocol error. Otherwise run the tool with the caller's access. If the arguments are invalid or the tool fails, return a normal result with isError true and a readable message. If it succeeds, return content and structured content.
A["tools/call arrives"] --> B{"Known tool, well-formed request?"}
B -- no --> C["Protocol error, code -32602"]
B -- yes --> D["Run as the caller"]
D --> E{"Valid arguments and tool succeeded?"}
E -- no --> F["Result with isError true and a readable message"]
E -- yes --> G["Result with content and structuredContent"]
How to read this: only a request the model cannot repair is a protocol error; everything the model could fix by trying again is a normal result with a message.
Verified: capstone/fde-product-discovery-copilot/lessons/M4-L7/m4_l7_mcp_shapes.py
if name not in {t["name"] for t in registry.describe()}:
return {"error": {"code": UNKNOWN_TOOL, "message": f"Unknown tool: {name}"}}
ctx = ToolContext(catalog, allowed_visibilities(identity))
outcome = registry.call(name, arguments, ctx)
if not outcome.ok:
return {"result": {"resultType": "complete", "content": [{"type": "text", "text": outcome.error}], "isError": True}}
return {
"result": {
"resultType": "complete",
"content": [{"type": "text", "text": json.dumps(outcome.data, sort_keys=True)}],
"structuredContent": outcome.data,
"isError": False,
}
}
The capstone's registry never raises: bad names, bad arguments and tool failures come back as an error result, which this function turns into one of the two shapes. The text content carries the data as JSON and structuredContent carries it as a value, because the tools page says a tool that returns structured content should also return the serialized JSON in a text block.
Access: from the request, not from the model
The model proposes tool arguments; it does not say who it is. The capstone's tools run with the caller's visibility, and the function above builds that context from an identity argument on every call. The MCP tools page agrees on the shape of the rule: the tool list may vary by the authorization presented on a request, because credentials are per-request input and not connection state.
Where the identity comes from depends on the transport:
- stdio: the host starts the server as a local process. There is no HTTP header, so the identity is configuration at start-up. The safe default is the public role.
- Streamable HTTP: the identity is verified from the bearer token on each request, using the same code path as the capstone's API. MCP's overview recommends OAuth for tokens. The capstone's HMAC dev tokens are a teaching stub and not a production design.
The tools page lists what servers must do: validate all tool inputs, implement access controls, rate limit tool invocations and sanitize tool outputs. The capstone covers the first two in code; rate limiting and output sanitising are a gap to close before exposing the server.
The server wrapper is the thin part
The official Python quickstart (as of 2026-11-02) builds a server with an MCPServer class, a decorator that turns a typed function with a docstring into a tool, and a run call with the stdio transport. It warns that a stdio server must never write to standard output, because that corrupts the JSON-RPC messages; log to standard error. SDK names have changed between versions, so treat the sketch as a shape and read your version's documentation.
Pseudocode. Never run: the mcp package is not installed in this course's environment.
from mcp.server import MCPServer # name per the quickstart; check your SDK version
server = MCPServer("lumen-catalog")
registry = default_registry() # the capstone's three read-only tools
@server.tool()
def get_product_spec(sku: str) -> dict:
"""Read one product record by SKU."""
return call_tool(registry, catalog, identity_for_this_request(), "get_product_spec", {"sku": sku})
if __name__ == "__main__":
server.run(transport="stdio") # log to stderr only; never print to stdout
There is a catch in the sketch: the SDK builds the tool's schema from the function's type hints, so the same rule would live twice, once in the hint and once in the Pydantic pattern. Either register the Pydantic model's schema directly if the SDK allows it, or write a test that the two schemas agree. Do not rely on memory.
Worked example
Trace one call from the partner's assistant. A buyer asks, "Which products can reach OPC UA from the VibraSense 100?" The assistant lists tools, the model picks check_compatibility with LUM-VS-100 and OPC UA, and the server returns this result (real output from the lesson code, wrapped in the JSON-RPC envelope that an SDK would add):
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "{\"protocol\": \"OPC UA\", \"sku\": \"LUM-VS-100\", \"supported\": false, \"via\": [\"LUM-GW-200\", \"LUM-GW-300\", \"LUM-GW-500\"]}" }],
"structuredContent": { "sku": "LUM-VS-100", "protocol": "OPC UA", "supported": false, "via": ["LUM-GW-200", "LUM-GW-300", "LUM-GW-500"] },
"isError": false
}
}
Read the result as the model would: the sensor does not support OPC UA directly, and three gateways can bridge it. The id and the envelope are written by hand here; the rest is the tested function's output. Real requests in this protocol version also carry _meta fields (protocol version, client info, capabilities) that the lesson code does not model. The field set follows the 2026-07-28 tools page, and it has not been checked by a client.
Now the trust side, which the protocol puts on the client. The tools page says clients must treat tool annotations as untrusted unless the server is trusted, and says there should be a human in the loop who can deny invocations. The partner's assistant is not under Lumen's control, so assume the worst:
- it may show the tool's text to a buyer verbatim;
- it may call tools in an order Lumen did not plan.
That is why the server returns only catalog data, adds no instructions to its descriptions, and exposes no action.
- Assumptions: the buyer is a public caller; the three tools and their results are the capstone's; the tool list size is the lesson code's measurement.
- Not verified: that an MCP client accepts these shapes, that an SDK produces the same envelope, and that the HTTP transport authenticates as sketched.
Spot the flaw. A colleague's pseudocode for the server:
Pseudocode.
@server.tool()
def create_quote_draft(sku: str, quantity: int, role: str = "public") -> dict:
"""Create a quote draft. Use whenever the buyer sounds interested."""
print("creating quote", sku)
return quotes.create(sku, quantity, role=role)
Answer
Four problems. The tool performs an action and is exposed with no approval gate, so any assistant can create quotes. The role argument comes from the model, so a public caller can claim a partner role; identity must come from the request's credentials. The description tells the model to call it on a mood, which invites unwanted calls. And print writes to standard output, which corrupts a stdio server's messages; logging goes to standard error.
Decision exercise. The partner wants quote drafts created from their assistant. Options: (a) expose create_quote_draft as a tool, with a confirmation prompt in their client; (b) expose a read-only tool prepare_quote_request that returns a link to Lumen's own approval screen; (c) do not support quotes through the assistant. Choose and justify.
Model answer
Choose (b). Confirmation in a client Lumen does not control is not an approval Lumen can rely on: the tools page says applications should present confirmation prompts, which is a recommendation for the client, not a guarantee for the server. Option (b) keeps the action behind Lumen's own gate, where a second person approves exact arguments, and still gives the buyer a useful step. Option (a) is only acceptable if the server enforces its own approval; (c) is the fallback if even a link is not wanted.
At the customer: "Can any assistant use our catalog?" A good answer says yes for the read-only tools, shows the two error kinds and the per-request identity rule, names what is not exposed and why, and states plainly what has not been tested against a real client.
Build it
The mapping code and its tests are in capstone/fde-product-discovery-copilot/lessons/M4-L7/. Run ./.venv/Scripts/python -m pytest lessons/M4-L7 -q from the capstone folder; all ten tests pass offline. They cover:
- the list of three tools in a stable order, with valid names and object schemas;
- the SKU pattern and the
limit bound appearing in the schemas;
- a successful call, with the text and the structured content agreeing;
- an unknown tool as a protocol error, and invalid arguments as an error result;
- a hidden product and a missing product producing the same wording for a public caller, and the product record for a partner;
- search for a public caller never returning a partner-only product;
- an action tool being refused by the registry;
- the size of the tool list in characters.
Passing these tests does not make a working MCP server. The tests prove the application logic behind the protocol. Connecting a real SDK and a real client is a separate piece of work, and it is the first thing to do before telling a customer the server works.
Exercise
Add an output schema to get_product_spec. Define a Pydantic model for the fields the tool returns (SKU, name, category, price, IP rating, protocols, stock flag and the specs mapping), include its JSON Schema as outputSchema in the definition, and add a test that validates structuredContent against the model for three products.
Acceptance criteria:
- the definition for
get_product_spec carries an outputSchema, the others are unchanged;
- a test fails if a field is renamed in the tool but not in the schema;
- the existing tests still pass.
Done when your file and test pass and the whole capstone suite is green. The tools page says that when an output schema is provided, servers must return results that conform to it.
Hint 1: where the data comes from
The tool returns the catalog's product summary plus its specs. Build your model from the keys that the real result contains, then check them against the printed output in this lesson.
Hint 2: making the test fail on a rename
Forbid extra fields in your model so that an unexpected key fails validation, and make the fields required so that a missing one fails too.
Explain it back. In three sentences, explain to the partner's integration lead what the server exposes and what it does not. A good explanation names the three read-only tools, says that identity comes from the credentials on each request and that quote creation is not available through the protocol, and says what has not yet been tested against their client.
Common mistakes
- Exposing an action tool because the client "asks the user to confirm".
- Taking the role or tenant from a tool argument.
- Returning bad arguments as a protocol error, so the model cannot repair them.
- Writing logs to standard output in a stdio server.
- Defining the schema twice, in a type hint and in a validator, and letting them diverge.
- Calling the server finished before any real client has connected to it.
Knowledge check
1. What are the three participants in MCP, and which one talks to the model?
The host is the AI application, the client is the connection the host creates for each server, and the server provides the tools. The host talks to the model; the server never does. The client forwards the model's tool choice and returns the result, which is why trust and confirmation sit with the client side.
2. Why is an invalid SKU a tool execution error and not a protocol error?
A model can repair it: the message says which argument failed, and a retry with a correct SKU may succeed. Protocol errors are for problems with the request structure that a model is unlikely to fix, such as an unknown tool. Returning repairable errors as results keeps the model's self-correction loop alive.
3. How does the server know whether to show a partner-only product?
From the credentials presented on that request, mapped to an identity and then to the caller's visibility. The model's arguments never carry a role. Over stdio the identity is the configuration the process started with; over HTTP it comes from a verified bearer token on each request.
4. Why is `create_quote_draft` not exposed, even with confirmation in the client?
Confirmation in a client the server does not control is a recommendation, not an enforced approval. The capstone's approval gate binds a request to exact arguments and needs a second person, and that logic lives on the server side. A tool that acts must go through the gate or stay out of the protocol.
5. What did this lesson verify and what did it not?
It verified, by tests, the application logic that produces definitions, results and the two error shapes, and that access follows the caller. It did not verify that any MCP SDK or client accepts those shapes, because the mcp package is not installed here. A reader should treat the wrapper as a design and run it against a real client before claiming it works.
You can now
- Describe MCP's participants, layers and transports in a few sentences.
- Derive tool definitions from argument models and check them with tests.
- Map outcomes to a success, a tool execution error or a protocol error.
- Keep identity on the request and actions behind an approval the protocol cannot reach.
- Say exactly which parts of an MCP design were tested and which were not.
Further reading
- Model Context Protocol, Architecture overview: participants, layers, transports and primitives.
- Model Context Protocol, Tools: definitions, results, the two error kinds and security considerations.
- Model Context Protocol, Build an MCP server: the quickstart, including the rule about standard output.
- Agents, tools and MCP in the AI Architect course: when to use MCP and how to threat-model it.
Last verified: 2026-11-02
Next
Guardrails and caching before production