Skip to main content

92 - Model Context Protocol: the Specification, and a Server Wired to an Agent

MCP standardises how an app discovers and uses external tools. The JSON-RPC exchange line by line, a server wired to an agent, and the supply-chain risks.

What do you think about this article?

92 - Model Context Protocol: the Specification, and a Server Wired to an Agent
Machine Learning Engineerfedericocalo.dev

At a Glance

MCP standardises how an app discovers and uses external tools. The JSON-RPC exchange line by line, a server wired to an agent, and the supply-chain risks.

The Model Context Protocol (MCP) is a protocol that standardises the way an application built on a language model discovers and uses external capabilities. In concrete terms: instead of writing a custom integration between your agent and your management system, you write just one between your management system and the protocol, and from that moment any application that speaks MCP can use it. It is the same idea as a standard physical connector: one protocol in place of N integrations. The specification is public, the messages are JSON-RPC 2.0, and the server primitives are three.

Versions tested. Python 3.14.4, mcp 2.3.0 and openai-agents 0.24.0, on 2026-11-12, with the fake model FakeModel, whose code is in the article on handoffs (here I import it from a fake module of your own). The revisions of the specification are dated: the most recent one I read is 2026-07-28, which follows 2025-11-25. The exchanges reported below are from revision 2025-06-18, which the installed SDK still speaks by default; what changes in the most recent one I say in the section on the exchange. In the 1.x series of the Python SDK the server object is called FastMCP (mcp.server.fastmcp); in 2.x it is called MCPServer (mcp.server.mcpserver), and the old import no longer exists.

The problem: N clients for M services

Before the analogy, the count that justifies the existence of the protocol. You have five applications that use models (an internal assistant, an agent that triages tickets, a code review tool, a support chat, a reporting script) and eight services you would like them to access: the management system, the repository, the ticket tracker, the database, the wiki, the calendar, the mail system, the monitoring.

Without a protocol every pair is an integration: up to forty pieces of code to write and maintain, each with its own authentication, its own error format, its own pagination logic, its own way of describing the capabilities to the model. And when the ticket tracker changes its API, five integrations break. With a protocol you write eight servers (one per service) and five clients (one per application, and in practice the library provides it): thirteen pieces instead of forty, and the tracker's API change touches a single server. It is the move from N×M to N+M, and the difference between an integration that gets maintained and one that gets abandoned.

The analogy: the standard connector

Before standardised connectors every device had its own cable and its own power supply: a drawer full of adapters, each compatible with one thing only. The standard connector did not make the devices more powerful, it made the cables interchangeable. MCP does this for the capabilities of a model: it does not make the agent smarter, it makes your management system pluggable into any application that speaks the protocol, without your knowing which applications exist. And it is also the limit of the analogy: a standard connector does not protect you from a faulty power supply. We talk about that in the section on the supply chain, because it is the main risk of adoption.

The architecture: host, client, server

The host - client - server chain of the Model Context Protocol HOST - your application the model + the loop decides which tools to call client 1 1 server = 1 client client 2 isolated session The host decides which servers to connect to and what to authorise: it is the control point. A server sees neither the other servers nor the full context. JSON-RPC 2.0 initialize tools/list tools/call resources/read ... transport: stdio (local process) or HTTP (remote) SERVER A - management system tools + resources + prompts SERVER B - repository tools + resources the real systems DB, API, files, services The model does NOT talk to the servers: it talks to the host's loop, which calls the clients, which speak JSON-RPC. Every extra link is also a point where you can filter, authorise and log.
Three roles: the host (your application, which decides), the client (one per server, manages the session), the server (encapsulates a real system).

The three roles must be kept distinct because that is where the responsibilities sit. The host is your application: it contains the model, the agent loop and the decision about which servers to connect to. It is the control point, the only place where it is decided what a model can reach. The client is the component that manages one connection with one server; in practice you do not write it, the library gives it to you. The server encapsulates a real system and exposes its capabilities in a standard form; it runs as a local process or as a remote service, and sees neither the other servers nor the full context of the conversation.

The separation has a security consequence: the model does not talk to the servers. It talks to the host's loop, which calls the clients. Every link in the chain is a place where you can filter which tools to expose, check an authorisation and record what passed through, and that is where the filter belongs (I do it in the section on integration).

The three server primitives

An MCP server can expose three kinds of thing, and the distinction changes who decides to use them.

Tool. Functions that the model decides to call. They have a name, a description and a JSON schema of the arguments, the same structure as in the article on tools, seen through a protocol. They are the "model-controlled" primitive.

Resource. Data identified by a URI, which the application decides to include in the context: a file, a table, a wiki document. The difference from a tool is the direction of the initiative: a resource is not "called" by the model, it is attached by the host because it is relevant.

Prompt. Parameterised interaction templates, which the user typically selects: "review this function", "write the commit message for this diff". The server provides the structure of the prompt and the host presents it as a command. It is the least used primitive and the most useful for making repeatable a good prompt built by someone who knows the domain.

On the client side the specification provides symmetrical capabilities: sampling (a server can ask the host to run a generation of the model), roots (the host declares to the server which filesystem roots are in scope) and elicitation (a server can ask the host to collect a piece of information from the user). They are optional, declared capabilities, so do not take for granted that a server or a host supports them.

Resource versus tool: the decision most often got wrong

Take a server that encapsulates the company wiki, with two ways of exposing the same content. As a tool: search_the_wiki(question: str). The model decides whether it needs it, formulates the question, receives the fragments: it costs one extra round and brings into the context only what is needed. It pays off when you do not know in advance whether the wiki will be relevant, and when it is large. As a resource: wiki://procedures/refunds. The host, knowing that the conversation is about a refund, attaches the document before the first round: it costs zero rounds and brings the whole document into the context. It pays off when you know for certain which context is needed and the document is small.

The criterion is a single question: who knows, at that moment, whether this content is relevant? If the application knows (the user clicked on a refund ticket, the request arrives from a dedicated channel, an upstream classifier has already decided the topic), it attaches the resource: you have eliminated a round and a probabilistic decision. If nobody knows in advance, you expose a tool. A recurring mistake is to expose large things as resources and always attach them "to be safe": the bloated context is paid for at every round (the mechanism of conversational memory). The symmetrical mistake is to expose as a tool something the application already knows: a tool read_current_user() makes you spend a round for a piece of data the host already holds, and it is a field to put in the run's context.

Why this protocol and not a REST API

It is a legitimate question, because an MCP server resembles a web service with a few endpoints. The differences are three, all oriented to a model that has to discover what it can do.

The description is part of the protocol, not of the documentation. In a REST API what an endpoint does lives in the OpenAPI file, in the README or in the head of whoever wrote it. In MCP tools/list returns name, description and schema in a form a model can read, at connection time: an interface designed for a consumer that discovers it at runtime.

The server can take the initiative. It can notify that the catalogue has changed, ask the host for a generation of the model (sampling), ask the user for a piece of information (elicitation). Over plain HTTP these are things done with effort. In revision 2025-06-18 the connection also has a handshake that negotiates version and capabilities; revision 2026-07-28 removed it, as we will see.

The outcome of a tool has a defined place. In REST you use a status code, with the known ambiguities: is a 404 "resource does not exist" or "wrong endpoint"? In MCP an execution error of the tool lives in the result with isError: true, which the model can read, while a JSON-RPC error is a protocol fault. The tools specification lists unknown tools, invalid arguments and server errors as protocol errors, and API failures, invalid data and business-logic errors as execution errors. For many cases an existing REST API is perfectly fine, and the sensible choice is a thin MCP server that encapsulates it, adding descriptions the model can read and the reduction of payloads.

The exchange, line by line

The whole protocol is JSON-RPC 2.0: messages with jsonrpc, id, method, params. To see a real session I launched the server of the next paragraph as a child process (stdio transport, no port) and talked to it by hand, one JSON line per message, without any client library. The server file is given further below; what matters here is the traffic, in particular the last part, where I tried the different ways in which a call can go wrong. Long answers are shortened in the descriptions.

import json
import subprocess
import sys

# Launches the server as a child process and talks JSON-RPC to it, one line per message.
server = subprocess.Popen([sys.executable, "warehouse_server.py"], stdin=subprocess.PIPE,
                          stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

def send(message: dict, wait_for_reply: bool = True) -> None:
    print("-->", json.dumps(message, ensure_ascii=False))
    server.stdin.write(json.dumps(message) + "\n")
    server.stdin.flush()
    if wait_for_reply:
        reply = json.loads(server.stdout.readline())
        for t in reply.get("result", {}).get("tools", []):       # shorten the descriptions
            t["description"] = t["description"].split("\n")[0]
            t["inputSchema"] = {"required": t["inputSchema"]["required"]}
        print("<--", json.dumps(reply, ensure_ascii=False))

send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
    "protocolVersion": "2025-06-18", "capabilities": {},
    "clientInfo": {"name": "warehouse-agent", "version": "1.0.0"}}})
send({"jsonrpc": "2.0", "method": "notifications/initialized"}, wait_for_reply=False)
send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
send({"jsonrpc": "2.0", "id": 3, "method": "tools/call",
      "params": {"name": "read_stock", "arguments": {"sku": "SKU-11"}}})
send({"jsonrpc": "2.0", "id": 4, "method": "tools/call",       # domain error
      "params": {"name": "read_stock", "arguments": {"sku": "SKU-999"}}})
send({"jsonrpc": "2.0", "id": 5, "method": "tools/call",       # arbitrary exception
      "params": {"name": "read_stock_fragile", "arguments": {"sku": "SKU-999"}}})
send({"jsonrpc": "2.0", "id": 6, "method": "tools/call",       # a tool that does not exist
      "params": {"name": "delete_everything", "arguments": {}}})
send({"jsonrpc": "2.0", "id": 7, "method": "method/nonexistent"})

server.stdin.close()
server.wait(timeout=10)
lines = server.stderr.read().strip().splitlines()
print("server stderr, first lines:", lines[:2], "... +", len(lines) - 2, "traceback lines")

# Output:
# --> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "warehouse-agent", "version": "1.0.0"}}}
# <-- {"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {"prompts": {"listChanged": false}, "resources": {"listChanged": false, "subscribe": false}, "tools": {"listChanged": false}}, "protocolVersion": "2025-06-18", "serverInfo": {"name": "warehouse-server", "version": ""}}}
# --> {"jsonrpc": "2.0", "method": "notifications/initialized"}
# --> {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
# <-- {"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"description": "Current stock and reorder threshold of a product code (SKU).", "inputSchema": {"required": ["sku"]}, "name": "read_stock"}, {"description": "Like read_stock, but raises an arbitrary exception (it is here to show the difference).", "inputSchema": {"required": ["sku"]}, "name": "read_stock_fragile"}]}}
# --> {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "read_stock", "arguments": {"sku": "SKU-11"}}}
# <-- {"jsonrpc": "2.0", "id": 3, "result": {"content": [{"text": "{\n  \"sku\": \"SKU-11\",\n  \"stock\": 4,\n  \"threshold\": 10\n}", "type": "text"}], "isError": false}}
# --> {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "read_stock", "arguments": {"sku": "SKU-999"}}}
# <-- {"jsonrpc": "2.0", "id": 4, "result": {"content": [{"text": "Error executing tool read_stock: unknown_sku: SKU-999. Known SKUs: ['SKU-11', 'SKU-42']", "type": "text"}], "isError": true}}
# --> {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "read_stock_fragile", "arguments": {"sku": "SKU-999"}}}
# <-- {"jsonrpc": "2.0", "id": 5, "result": {"content": [{"text": "Error executing tool read_stock_fragile", "type": "text"}], "isError": true}}
# --> {"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "delete_everything", "arguments": {}}}
# <-- {"jsonrpc": "2.0", "id": 6, "result": {"content": [{"text": "Unknown tool: delete_everything", "type": "text"}], "isError": true}}
# --> {"jsonrpc": "2.0", "id": 7, "method": "method/nonexistent"}
# <-- {"jsonrpc": "2.0", "id": 7, "error": {"code": -32601, "message": "Method not found", "data": "method/nonexistent"}}
# server stderr, first lines: ['warehouse server started', 'Tool \'read_stock\' failed: "Error executing tool read_stock: unknown_sku: SKU-999. Known SKUs: [\'SKU-11\', \'SKU-42\']"'] ... + 40 traceback lines

Five things to read. Step 1 is a proposal from the client and step 2 the server's answer, which repeats the version 2025-06-18 because it accepted it and declares the capabilities: tools, resources and prompts, none with listChanged or active subscriptions. The line without an id (notifications/initialized) is a notification: no answer is awaited. tools/list is the moment of discovery, and the client does not know the tools in advance: it asks for them.

The part I found most instructive is the handling of errors, because the SDK does not do everything the specification would lead you to think. A successful call has isError: false. A domain error raised with ToolError gives isError: true with my message ("unknown_sku… Known SKUs: …"), which the model can read and correct itself from. An arbitrary exception (ValueError in the second tool) gives isError: true but with only the text "Error executing tool read_stock_fragile": SDK 2.x treats whatever is not a ToolError as a crash, hides the detail from the client and writes the traceback on standard error (40 lines, in the last line of the output). An unknown tool gives a result with isError: true, whereas the 2025-06-18 specification lists it as a protocol error (-32602): it is a difference between the specification and the SDK, which is worth knowing today. Only a nonexistent method produces a real JSON-RPC error (-32601).

The practical rule for writing a server is therefore a single one: the error that must go back to the model is raised with ToolError (or a result with an explicit isError is returned), and every other exception is, for the SDK, a fault not to be read. Getting it wrong does not break the agent's loop: it takes away the information it needs to correct itself.

What changes in revision 2026-07-28

The changelog of the most recent revision lists among the main changes the removal of protocol-level sessions and of the initialize/notifications/initialized handshake: every request carries the protocol version and the client's capabilities in _meta, and a new method server/discover lets the client learn the server's versions, capabilities and identity. Subscriptions change (subscriptions/listen instead of resources/subscribe) and ping and logging/setLevel are removed. I could not run this flow: the server of SDK 2.3.0 in its default mode answers server/discover with "Method not found" and rejects a request with the new envelope ("this connection serves the handshake protocol era"). That is why the transcript above is that of 2025-06-18, and if you write a server today you must declare in the README which revision it supports.

The two transports, and when to use them

stdio. The server is a child process; the client writes to its standard input and reads from its standard output. No network, no port, no authentication: the security boundary is the process itself, and whoever launches it decides with what permissions it runs. It is the right transport for everything that lives on the same machine. The specification is clear: the server may write strings to standard error for logging, and must not write to standard output anything that is not a valid MCP message. HTTP. The server is a service reachable over the network, and it is needed when it is shared among several users or runs elsewhere; it brings with it authentication, authorisation, session management, TLS. Recent revisions define an HTTP transport with streaming, which replaces the SSE-based one of revision 2024-11-05; it is the part that has moved the most, and it is the one on which to read the exact revision before implementing.

The selection criterion is simple: stdio if the server can run next to the host, HTTP only if it has to be shared. An HTTP server is an exposed service, with all the security work that implies; a stdio one is a child process, and its perimeter is what you give it when you launch it. I did not test any HTTP server for this article (it would require a listening port), so of that transport I report only what the specification says.

A minimal server

Let us look at the written server. I use the official Python SDK, which offers a decorator-based abstraction very similar to that of tools: whoever has written a tool already knows how to write a server. The file has a tool, a resource and a prompt, plus a second tool, read_stock_fragile, which exists only to show in the transcript the difference between ToolError and an arbitrary exception.

import asyncio
import sys
from pathlib import Path
from agents.mcp import MCPServerStdio

# An identical server, with ONE debug print on standard output.
Path("server_with_print.py").write_text(
    Path("warehouse_server.py").read_text().replace(
        'print("warehouse server started", file=sys.stderr)', 'print("warehouse server started")'))

async def main():
    try:
        async with MCPServerStdio(name="warehouse", client_session_timeout_seconds=10,
                                  params={"command": sys.executable,
                                          "args": ["server_with_print.py"]}) as server:
            tools = await server.list_tools()
            print("connection succeeded, tools:", [t.name for t in tools])
    except BaseException as error:                  # the exact exception depends on the version
        print("type:", type(error).__name__)
        print("message:", str(error)[:200])

asyncio.run(main())

Three choices to read. The tool's docstring becomes its description in the protocol (you see it in tools/list), so what was said about tools applies: it says when to use it, not how it is implemented. The domain error with ToolError is the MCP translation of the rule "error on the data, not a fault": the outcome lives in the result and carries the message the model must read. And logging on standard error is the detail that the specification imposes. To see what happens if it is violated I made a line print on standard output.

import asyncio
import sys
from pathlib import Path
from agents.mcp import MCPServerStdio

# An identical server, with ONE debug print on standard output.
Path("server_with_print.py").write_text(
    Path("warehouse_server.py").read_text().replace(
        'print("warehouse server started", file=sys.stderr)', 'print("warehouse server started")'))

async def main():
    try:
        async with MCPServerStdio(name="warehouse", client_session_timeout_seconds=10,
                                  params={"command": sys.executable,
                                          "args": ["server_with_print.py"]}) as server:
            tools = await server.list_tools()
            print("connection succeeded, tools:", [t.name for t in tools])
    except BaseException as error:                  # the exact exception depends on the version
        print("type:", type(error).__name__)
        print("message:", str(error)[:200])

asyncio.run(main())

# Output:
# connection succeeded, tools: ['read_stock', 'read_stock_fragile']

The output says the connection succeeded: in the SDK version I tested the client discards the invalid line, reporting on its own error channel a validation error ("Invalid JSON"), and the session goes on. It is not a guarantee: the specification says the server must not write anything else on standard output, and another client might react differently, in particular with output printed in the middle of a response. That my SDK forgives does not make the print legitimate.

Connecting it to an agent

The block launches the server as a child process on entering the async with block and terminates it on leaving. The model is fake: it first calls read_stock with a valid SKU, then with a nonexistent one, then concludes. The block runs the test twice, the first time with all the tools and without cache, the second with an allowlist (tool_filter) and with cache_tools_list=True, and counts how many times the client really asks the server for tools/list.

import asyncio
import sys
from agents import Agent, Runner
from agents.mcp import MCPServerStdio
from fake import FakeModel, tool_call, say

ALLOWED = {"read_stock"}                                          # allowlist, not denylist

def tools_list_counter(server) -> list:
    """Counts how many times the client really asks the server for `tools/list`."""
    calls, original = [], server.session.list_tools
    async def count(*args, **kwargs):
        calls.append(1)
        return await original(*args, **kwargs)
    server.session.list_tools = count
    return calls

async def trial(cache: bool, filter_) -> None:
    async with MCPServerStdio(
        name="warehouse",
        params={"command": sys.executable, "args": ["warehouse_server.py"]},
        cache_tools_list=cache,         # avoids a tools/list at every round
        tool_filter=filter_,            # decides which of the server's tools reach the model
    ) as server:
        list_requests = tools_list_counter(server)
        seen = []
        agent = Agent(
            name="Warehouse assistant",
            instructions="Decide whether to reorder. Always read the data with the tools.",
            mcp_servers=[server],       # the server's tools enter the catalogue
            model=FakeModel([tool_call("read_stock", sku="SKU-11"),
                             tool_call("read_stock", sku="SKU-999"),
                             say("REORDER 6")], seen),
        )
        result = await Runner.run(agent, "Should SKU-11 be reordered?", max_turns=6)
        print(f"cache={cache!s:<5} filter={'yes' if filter_ else 'no'} | "
              f"tools seen by the model: {seen[0]['tools']} | tools/list requests: {len(list_requests)}")
    return seen, result

async def main():
    await trial(cache=False, filter_=None)
    seen, result = await trial(cache=True, filter_=lambda context, tool: tool.name in ALLOWED)
    read = [m["output"] for m in seen[2]["input"] if m.get("type") == "function_call_output"]
    print("the model read:", read)
    print("answer:", result.final_output)

asyncio.run(main())

# Output:
# cache=False filter=no | tools seen by the model: ['read_stock', 'read_stock_fragile'] | tools/list requests: 3
# cache=True  filter=yes | tools seen by the model: ['read_stock'] | tools/list requests: 1
# the model read: [[{'type': 'input_text', 'text': '{\n  "sku": "SKU-11",\n  "stock": 4,\n  "threshold": 10\n}'}], [{'type': 'input_text', 'text': "Error executing tool read_stock: unknown_sku: SKU-999. Known SKUs: ['SKU-11', 'SKU-42']"}]]
# answer: REORDER 6

The first line shows two facts together. Without a filter the model sees both the server's tools, including the one I would rather not have exposed; without cache the client asked for tools/list three times, once for each round of the agent. The second line corrects them: the filter lets only read_stock through and the cache reduces the requests to one. From the model's point of view there is no difference between a local tool and an MCP tool: the discovered tools enter the catalogue and, when it chooses one, the runner routes it to the client as tools/call. The third line shows what the model reads: the result in JSON for the valid SKU and the error message with the known codes for the wrong one.

The filter is mandatory, not optional: connecting a server and taking all its tools is what works in tutorials and must not be done in production, because every tool is a door and a third-party server decides for itself how many doors to open for you. It is an allowlist, not a denylist, for the usual reason: a denylist has to be updated every time the server adds a tool, and you do not control the server's updates. The cache, instead, has a price: you do not notice if the server changes the catalogue while the session is open, and for a server of your own that does not do so it is the right choice.

The supply-chain risk

It is the cost of adoption that presentations of the protocol tend to gloss over. Connecting a third-party MCP server means running third-party code with the permissions of your process, and giving a model the ability to invoke it. On the stdio transport, literally: you are launching a command. The concrete risks, in order of how underrated they are, are four.

The server runs code on your machine. A stdio server installed from a public registry runs with the permissions of the user who launches it, accesses their filesystem and their network: it is no different from any dependency, with the difference that a dependency you invoke yourself where you decide, whereas an MCP server is invoked by a model in response to text that may come from outside. Tool descriptions are prompts supplied by others. The description enters your model's context at every round: a hostile server can write instructions in it, and it is an injection delivered through the channel you considered configuration (the attack of the previous article, with a different vector).

The server can change after approval. If the catalogue can change, a server can expose a harmless tool during evaluation and a dangerous one afterwards: that is why the allowlist is on the names you approved and the server's version is pinned like that of a library. The combination of several servers. Two harmless servers taken separately can compose the lethal trifecta (private data, untrusted content, a channel to the outside): neither is the culprit, the combination is. Hence the practice I recommend: assess the permissions of the set of servers connected to an agent, not those of each one. The countermeasures are those of the article on traps: an allowlist of tools, an agent that reads external content without tools that act, the server launched with minimal permissions (dedicated user, limited filesystem and network), and reading the server's code before connecting it.

For the operation and testing of servers, the dedicated series goes deeper in MCP: testing and best practices for production; the introduction to the protocol and the primitives are in introduction to the Model Context Protocol and MCP primitives: tools, resources, prompts, and a complete TypeScript server in building your first MCP server in TypeScript. Here I concentrated on the exchange and on the integration with a Python agent.

Trade-offs

  • Protocol versus direct integration. The protocol pays off when you have several clients or several services, or want your service to be usable by applications you do not know. For an agent and a service written by the same person, a direct tool is less code, fewer processes and fewer points of failure.
  • One more link. Between the function and the model there are now a client, a transport and a server. You gain standardisation and replaceability; you pay a process to start and supervise and one more place where something can break silently.
  • Dynamic discovery versus known catalogue. Discovering the tools at runtime makes the model's prompt no longer constant across versions, so your evaluation cases measure a system that can change. The allowlist brings back determinism, at the price of authorising new things by hand.
  • stdio versus HTTP. stdio is simple and local, and cannot be shared. HTTP can be shared and requires authentication, authorisation and TLS: all the work of exposing a service.

The four pairs say the same thing from different angles: every ring of abstraction buys replaceability and pays for it in control. Adopt the protocol when the number of integrations justifies it, and start from the narrowest perimeter (stdio, allowlist, pinned version) widening it only for a written reason.

The traps people really fall into

  1. Writing to standard output in a stdio server. The specification forbids it; the log goes on standard error, always, even if a forgiving client does not notice the error.
  2. Raising an arbitrary exception for a domain error. In SDK 2.x the model reads only "Error executing tool …". To get the reason through you need ToolError.
  3. Connecting a server and taking all the tools. It works in the tutorial, and in production it is a delegation of attack surface to a third party. Allowlist, always.
  4. tools/list at every round. Measured: three requests over three rounds without cache. With a server of your own, turn on the cache.
  5. Not pinning the version of the server and of the SDK. A server is an executable dependency; and the Python SDK has already changed the name of the main object between the 1.x and 2.x series.
  6. Forgetting the life cycle of the process. Outside the async with block the server is terminated; a client used after closing gives errors that look like protocol errors and are resource-management errors.
  7. Taking optional capabilities for granted. Sampling, roots and elicitation are declared by the client. A server that demands them without checking works with one host and not with another.
  8. Trusting the tool descriptions of someone else's server. They enter your model's context at every round: they are prompts written by somebody else. Read them once, before connecting the server.

The eight traps fall into three families: server implementation errors (1, 2), integration errors in the host (3, 4, 6, 7) and dependency governance errors (5, 8). The first two are found in a few minutes with a transcript like the one above; the others require an explicit decision, written in your agent's README: which servers, which tools, which version, with what permissions.

What you can do now

You can read an MCP session line by line, tell an execution error from a protocol error, and you know that in Python SDK 2.x you need ToolError for the model to read the reason for an error. You can choose between tool, resource and prompt according to who has the initiative, and between stdio and HTTP according to who has to reach the server. You can connect a server to an agent with a closed-list filter and with the catalogue cache, and you have seen with a counter what changes. You also know that the most recent revision of the specification removed the handshake and that your server must be written against a declared revision. In the next article we change level: from the capabilities of the model to writing the prompt.

Sources

The listings were run twice with Python 3.14.4, mcp 2.3.0 and openai-agents 0.24.0 in a temporary virtual environment: the outputs are identical. The server runs as a child process on stdio (no port, no network) and terminates at the end of each block. The model's decisions come from the fake model. The HTTP transport and the flow of revision 2026-07-28 were not run. The scenarios (warehouse, SKUs) are invented for the example.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects