A platform lead asked me a fair question in a design review: why does the billing team have to rewrite our ticket lookup inside their own codebase when we already built it, tested it and put it behind a rate limit. I did not have a good answer that day. Model Context Protocol is the answer I have now.
Problem MCP actually solves
Last part I argued about when an agent loop is the wrong shape for a problem. Where the documentation assistant stands now: it retrieves over the product docs, it runs a bounded tool loop, and it has four functions wired directly into the same Python process that calls the model. Those four functions are good code. They are also completely unreachable from anywhere except that one process.
Model Context Protocol, MCP for short, is an open protocol in which a server advertises what it can do and any compliant client discovers and invokes it. Three server side concepts exist: tools, which are functions the model may call; resources, which are data the client may read; and prompts, which are reusable message templates. This part only needs tools. Underneath, every message is JSON-RPC 2.0, which means the whole thing is a request with a method name and a params object, and a response with a result or an error.
What changes for a team is smaller than the hype suggests and more useful than the sceptics admit. You are not gaining a capability. Your model could already call functions. You are gaining an interface boundary: one team owns the ticket tools, versions them, rate limits them and puts an audit log behind them, and every other team consumes them by URL instead of by copy and paste.
Wire protocol beneath the decorator
Frameworks hide this and you should look at it once, because every confusing bug you hit later is a JSON-RPC bug wearing a Python costume. Two methods carry almost all the traffic. A client sends tools/list to discover what exists, and tools/call to run one.
Two fields in that response repay attention. structuredContent is the machine readable result, added so clients stop parsing prose out of a text block, and a tool that returns it should also serialise the same JSON into a text block for older clients. isError is not a protocol failure. It is a tool execution failure, and the specification is explicit that input validation problems belong here rather than in a JSON-RPC error, precisely so the model sees the message and corrects itself on the next turn. That distinction is the single most useful thing in the tools specification and almost nobody reads it.
Ticket server for the support assistant
Here are two of the assistant tools moved out of the application and into a server. FastMCP generates the JSON Schema from your type hints and the tool description from your docstring, so the docstring is now production surface, not a comment. Write it for a model, not for a colleague.
A raised ValueError is worth a comment. FastMCP turns an exception inside a tool into a tool execution error, so the model receives the message and can retry with a corrected id rather than the whole request collapsing. That is the isError path from the previous section, reached without you writing any protocol code.
Connecting to it is short. Passing the server object straight to the client gives you an in-process transport with no subprocess and no socket, which is how you should write every test you own.
Client side, and bridging MCP tools into a model call
Here is the gap that surprises people. MCP standardises how your application talks to a tool server. It does not standardise how your application talks to a model. You still translate, and the translation is about six lines per provider.
One clause of vendor difference, because this series stays neutral: Anthropic wants a flat object with input_schema, OpenAI wants the schema nested under function.parameters, and Gemini wants a function declarations list. Same JSON Schema, three envelopes. Write the adapter once and keep it in the boring part of your codebase.
Now the part I got wrong. When our assistant went from four local functions to three MCP servers, I connected all of them and passed every discovered tool into the model, because that is what every quickstart shows. Thirty one tools arrived. My tool schema block went from 318 tokens to roughly 3,900 tokens on every single request, cost per conversation rose about 34 percent, and worse, tool selection accuracy on my regression set fell from 0.94 to 0.71 because four of those tools had near identical descriptions of searching things. It took me eleven days and a confused finance question to connect the two facts. Fix was unglamorous: expose six tools to the model, keep the other twenty five reachable but hidden behind an explicit route, and prefix names by server so tickets.search and docs.search stop competing.
Failures you will hit in the first hour
My first stdio server died on a single line of debugging. Under stdio the client launches your server as a subprocess and reads JSON-RPC off its stdout, one message per line, and the specification states plainly that the server must not write anything else there. A stray print statement is therefore not a log line. It is a corrupted frame.
Fix is one import. Log to stderr, which the specification explicitly reserves for any kind of server logging including informational and debug messages, and which the client is told not to treat as failure. Use logging.basicConfig(stream=sys.stderr) and move on. Below is the lookup table I keep pinned, because four of these five are protocol behaviours you cannot debug by reading your own Python.
| Symptom | Cause | Fix |
|---|---|---|
| Invalid JSON at line 1 column 1 | Server wrote non protocol bytes to stdout under stdio | Send all logging to stderr, never print |
| HTTP 403 on every request | Invalid Origin header, the DNS rebinding guard doing its job | Set an allowed origin list, bind local servers to 127.0.0.1 |
| HTTP 400 after the first call works | MCP-Session-Id returned at initialize was not echoed on later requests | Reuse one client session, do not build a fresh client per turn |
| HTTP 404 mid conversation | Server expired the session, often after a deploy | Catch it, re-initialize without a session id, replay the call once |
| Model ignores a tool that clearly fits | Docstring is vague, or two tools describe the same job | Rewrite the docstring around when to call it, prefix names by server |
Transport choice, stdio versus HTTP
MCP defines two standard transports. Under stdio the client spawns your server as a child process and pipes JSON-RPC through it. Under Streamable HTTP your server is an ordinary web service on one endpoint that accepts POST and GET, optionally upgrading to Server-Sent Events when it wants to stream. Both are in the specification. Only one of them belongs in your backend.
Now a place where I disagree with the specification for server side work. Clients should support stdio whenever possible, says the transports document, and for desktop tools that is right. For a backend service it is the wrong default. A stdio server is one process per client, dies with its parent, cannot be scaled or patched independently, and the roughly 900 ms of interpreter cold start I measured lands on somebody first request. Reserve stdio for desktop clients and local development. Ship HTTP.
| Situation | Transport | Why |
|---|---|---|
| Unit and integration tests | In-process | No subprocess, no port, no flakiness, and fast enough to run per commit |
| Tools only your own service uses | In-process | MCP shape without a network hop you have no reason to pay for |
| Desktop client on a laptop | stdio | What those clients expect, and credentials stay on the machine |
| Two or more teams consuming your tools | Streamable HTTP | Independent deploy, real auth, central rate limits, one audit log |
| Legacy client stuck on the old SSE transport | SSE, temporarily | Compatibility only, superseded by Streamable HTTP since 2025-03-26 |
Security model of a shared tool server
Standardising tool access also standardises a route into your systems, and two properties of MCP deserve caution before you connect a server you did not write. Tool descriptions are model visible text supplied by whoever runs the server, which makes them an injection surface; the specification says outright that clients must treat tool annotations as untrusted unless the server is trusted. Tool lists can also change at runtime through a list changed notification, so a server that behaved yesterday can advertise something new today.
Three habits cover most of the risk without slowing anyone down. Pin the set of tool names your application will forward to the model, so a newly advertised tool is inert until a human adds it. Authorise on the server, per caller, and never rely on the client to decide who may escalate a ticket. Log every call with caller, tool, arguments and result size, because when a support agent asks why a customer received a refund note you will want the row, not a theory. Part 24 goes at prompt injection properly; this is only the perimeter.
Versioning deserves a paragraph of its own, because a shared tool server acquires the same compatibility obligations as any public API and acquires them faster than anyone expects. Nothing in MCP versions your tools for you. A client discovers whatever is advertised at the moment it connects, so renaming an argument or tightening a required field breaks every caller silently on their next session, and they will find out through a model that quietly stops calling your tool rather than through a failed build. My rule after breaking the billing team twice in one quarter: additive changes go out freely, breaking changes ship as a new tool name carrying a suffix such as search_tickets_v2, and the old name stays alive with a deprecation line in its docstring for one full quarter. Docstring deprecation notes work surprisingly well, because that text goes to the model and to the engineer reading tools slash list output, which are exactly the two readers who need to know.
Where MCP earns its keep in this assistant
My recommendation for this project, and the one I would defend in a review: adopt MCP as the shape of every tool you write, and adopt it as a network protocol only where a second consumer actually exists. Writing tools as an MCP server costs you nothing over writing them as plain functions, it makes them testable in-process, and it means the day billing asks, you change a transport rather than a codebase. Turning on HTTP before anyone has asked buys you a deployment, a session store, an auth story and a 12 ms hop in exchange for an integration nobody requested. Avoid the multi server land grab hardest of all; that is where the 3,900 token prompt came from.
One thing to do on Monday: count the tokens your tool schema block adds to a single request, then divide by your daily request volume and price it. If that number surprises you, you have found the real cost of MCP, and it is not the protocol. Next up is Part 17, on multi step workflows, state and memory, where these tool calls stop being independent and start needing to remember what happened three turns ago.
References
- MCP specification 2025-11-25, Tools
- MCP specification 2025-11-25, Transports
- FastMCP documentation, the FastMCP Client
- FastMCP documentation, running your server


DrJha