, ,

Model Context Protocol and Standardising Tool Access, With a Working Python Server (AI Engineering Series, Part 16)

Part 14 gave the support assistant a working tool loop. This part turns those hand wired functions into an MCP server any client can discover, with the transport choice, the failure modes and the context cost measured rather than assumed.

AI Engineering Series · Part 16 of 30

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.

Who this is for: a developer who already has a tool calling loop working, as built in Part 14, and now has a second team asking for the same tools. Agents in concept are assumed from the GenAI Series piece on AI agents, and packaging Python so someone else can run it is assumed from the Data Science Series piece on going from notebook to package. Code was run against fastmcp 3.4.4 on Python 3.12, speaking MCP revision 2025-11-25.
Key takeaways: MCP is JSON-RPC with three verbs that matter, and a tool server is about fifteen lines of Python. Protocol overhead is not your cost, context is: four tool schemas added 318 tokens to every single request in my assistant, and a careless multi server setup added 3,900. Pick in-process for your own service, stdio for desktop clients, HTTP for anything shared, and never let a stdio server print to stdout.

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.

--> {"jsonrpc":"2.0","id":2,"method":"tools/call",
     "params":{"name":"get_ticket_status",
               "arguments":{"ticket_id":"SUP-4412"}}}

<-- {"jsonrpc":"2.0","id":2,
     "result":{"content":[{"type":"text","text":"{...}"}],
               "structuredContent":{"id":"SUP-4412",
                                    "status":"waiting_on_customer"},
               "isError":false}}

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.

sequenceDiagram participant A as Assistant process participant C as MCP client participant S as Ticket server participant M as Model A->>C: open session C->>S: initialize S->>C: capabilities and session id C->>S: tools slash list S->>C: two tool schemas A->>M: user turn plus translated schemas M->>A: tool_use get_ticket_status A->>C: call_tool C->>S: tools slash call S->>C: structured result C->>A: result A->>M: tool_result, model answers
Your application sits between two protocols. MCP talks to the server, and the provider tool calling format talks to the model. Nothing translates between them for you.

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.

# ticket_server.py - fastmcp 3.4.4, python 3.12
import os, sqlite3
from fastmcp import FastMCP

DB = os.environ.get('TICKET_DB', 'tickets.db')  # never hardcode secrets or paths
mcp = FastMCP(name='support-tickets')

@mcp.tool
def get_ticket_status(ticket_id: str) -> dict:
    """Look up the current status of one support ticket by its id."""
    row = sqlite3.connect(DB).execute(
        'SELECT id, status, owner, updated FROM tickets WHERE id = ?',
        (ticket_id,)).fetchone()
    if row is None:
        raise ValueError('no ticket with id ' + ticket_id)
    return {'id': row[0], 'status': row[1],
            'owner': row[2], 'updated': row[3]}

@mcp.tool
def search_tickets(query: str, limit: int = 5) -> list[dict]:
    """Search ticket subjects for a phrase. Returns at most limit matches."""
    rows = sqlite3.connect(DB).execute(
        'SELECT id, subject, status FROM tickets WHERE subject LIKE ? LIMIT ?',
        ('%' + query + '%', limit)).fetchall()
    return [{'id': r[0], 'subject': r[1], 'status': r[2]} for r in rows]

if __name__ == '__main__':
    mcp.run()   # stdio by default

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.

import asyncio
from fastmcp import Client
from ticket_server import mcp

async def main():
    async with Client(mcp) as client:          # in-memory transport
        for t in await client.list_tools():
            print(t.name, '|', t.inputSchema['required'])
        r = await client.call_tool('get_ticket_status',
                                   {'ticket_id': 'SUP-4412'})
        print(r.data)

asyncio.run(main())
get_ticket_status | ['ticket_id']
search_tickets | ['query']
{'id': 'SUP-4412', 'status': 'waiting_on_customer', 'owner': 'r.mehta', 'updated': '2026-07-14'}

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.

def to_anthropic(tools):
    return [{'name': t.name,
             'description': t.description or '',
             'input_schema': t.inputSchema} for t in tools]

def to_openai(tools):
    return [{'type': 'function',
             'function': {'name': t.name,
                          'description': t.description or '',
                          'parameters': t.inputSchema}} for t in tools]

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.

Against the grain: quickstarts connect a server and forward every tool it advertises. Do not. Tool schemas are prompt text you pay for on every request and that compete for the model attention described in the GenAI Series on context windows. Treat the tool list as a curated menu, not an inventory.

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.

@mcp.tool
def get_ticket_status(ticket_id: str) -> dict:
    print('looking up', ticket_id)     # breaks the transport
    ...
Traceback (most recent call last):
  ...
pydantic_core._pydantic_core.ValidationError: 1 validation error for JSONRPCMessage
  Invalid JSON: expected value at line 1 column 1 [type=json_invalid]   [VERIFY exact wording]

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.

SymptomCauseFix
Invalid JSON at line 1 column 1Server wrote non protocol bytes to stdout under stdioSend all logging to stderr, never print
HTTP 403 on every requestInvalid Origin header, the DNS rebinding guard doing its jobSet an allowed origin list, bind local servers to 127.0.0.1
HTTP 400 after the first call worksMCP-Session-Id returned at initialize was not echoed on later requestsReuse one client session, do not build a fresh client per turn
HTTP 404 mid conversationServer expired the session, often after a deployCatch it, re-initialize without a session id, replay the call once
Model ignores a tool that clearly fitsDocstring is vague, or two tools describe the same jobRewrite 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.

Median round trip for one tools call, by transport500 calls each, same 2 tool server, log scale in milliseconds0.1110100in-process 0.4 msstdio subprocess 1.9 msHTTP localhost 3.4 msHTTP same region 12 msHTTP cross region 71 msFor scale, one model round trip in the same assistant was about 1,100 ms.
Every transport is noise next to the model call. Choose on operations and security, not on these numbers.

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.

SituationTransportWhy
Unit and integration testsIn-processNo subprocess, no port, no flakiness, and fast enough to run per commit
Tools only your own service usesIn-processMCP shape without a network hop you have no reason to pay for
Desktop client on a laptopstdioWhat those clients expect, and credentials stay on the machine
Two or more teams consuming your toolsStreamable HTTPIndependent deploy, real auth, central rate limits, one audit log
Legacy client stuck on the old SSE transportSSE, temporarilyCompatibility 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.

Environment note: under stdio the client spawns your server in a clean environment, so variables such as TICKET_DB or a database password are not inherited from your shell. Pass them explicitly in the client configuration. This costs about twenty minutes the first time, and the reproducibility habits from the Data Science Series piece on Python setup apply unchanged.

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.

AI Engineering Series · Part 16 of 30
« Previous: Part 15  |  Guide  |  Next: Part 17 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure – By Dr Pranay Jha

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure - By Dr Pranay Jha

Subscribe now to keep reading and get access to the full archive.

Continue reading