Chapter 1 of 6 · 16 min read
What MCP standardises
On this page
The Model Context Protocol, MCP for short, is a published specification for how a program that holds a language model talks to a program that holds a capability. It names the two sides. The client is the side running the model and the conversation: an agent your team deployed, an editor's assistant, a desktop chat application. The server is a separate process, often somebody else's, that advertises what it can do — a list of tools, each one a named action with a JSON schema describing the arguments it takes, and a set of resources, which are documents it can hand over on request. When the model decides to call a tool, the client sends the call to the server and gets back a tool result, which is text the client then places into the conversation for the model to read. That last step is where this whole topic lives.
The sentence this chapter takes apart is the one that appears in every migration ticket: reduces our attack surface by standardising tool access. An attack surface is the set of places where something outside your control can influence what your system does, and a trust boundary is the line in a system where data stops being something you produced and starts being something somebody else did. The sentence is half right, and the half that is right is doing all of the persuading. Pulling the halves apart is worth doing before the migration merges, because the true half is an engineering win nobody should argue with, and the false half quietly moves a boundary from inside code you own into a process you do not.
In practice you meet MCP as a block of configuration rather than as code you write: an entry names the server, the command or URL that starts it, and the credential it holds; the client launches it, asks which tools and resources it offers, and those tools become visible to the model alongside the ones your team implemented. Nothing in that sequence is a decision a reviewer sees as a decision, which is what makes the two halves worth separating in writing rather than in a corridor.
What a hand-written client put in front of the model
Here is the kind of function a migration deletes. It is not good code, and it is representative of what these clients look like after two years of small changes.
def search_issues(query: str) -> str:
r = httpx.get(
f"{BASE}/search",
params={"jql": query, "maxResults": 5},
headers={"Authorization": f"Bearer {TRACKER_TOKEN}"},
timeout=10.0,
)
r.raise_for_status()
issues = r.json()["issues"]
return "\n".join(
f"{i['key']}: {i['fields']['summary'][:120]} "
f"[{i['fields']['status']['name']}]"
for i in issues
)Look at what reaches the model. Five issue keys, five summaries truncated at 120 characters, and five status names drawn from a closed set the workflow admin controls. The API response behind that call is roughly 40 KB of JSON per issue: description body, every comment, attachment filenames, custom field values, the rendered HTML of the last edit, a changelog. None of it reaches the model, because nobody ever wrote the line of code that would put it there. The filtering was not a security decision. It was somebody in 2024 keeping the prompt short and the parsing simple, and it happened to be the tightest content boundary in the system.
The client also has real problems. Its error handling is one raise_for_status and nothing else, so a 429 propagates as a stack trace. Pagination is a hardcoded maxResults: 5. The token is read from a module-level global. A team running three integrations has three clients like it, each with a different retry story, a different timeout, and a different way of turning a failure into something the agent can read. Every new integration means writing all of that again and getting a slightly different subset of it wrong.
That is the cost the protocol exists to remove, and the reason it spread is integration arithmetic rather than anything to do with security. Before MCP, every agent framework had its own way of declaring a tool, every vendor who wanted their product callable from an assistant shipped a bespoke plugin manifest, and the work was quadratic: n tools times m frameworks, each pair with its own glue. A single wire format collapses that to n plus m, which is the same reason ODBC beat writing a driver per application per database. Protocols that win usually win on exactly this arithmetic.
Do that arithmetic rather than accepting its shape, because the shape is what decides when a protocol is worth adopting and when it is ceremony. Without a common wire format, every tool that wants to be callable has to be glued to every framework that might call it, so the number of pieces of glue is the number of tools multiplied by the number of frameworks. With one format, each tool implements the format once and each framework implements it once, so the pieces are the tools plus the frameworks. Try it small first. Three tools and three frameworks is nine bespoke connectors one way against six implementations the other, which is barely worth a meeting. Try it at ten and ten and it is a hundred against twenty. The product grows with the square of the ecosystem while the sum grows in step with it, so the gap widens without limit as more of both arrive — which is why a standard that looks like overhead early becomes inevitable late, and why the same argument decided the same question for database drivers a generation earlier.
What the protocol fixes
MCP replaces the hand-written client with three things: a way for the client to ask what tools exist, a way to call one, and a framed transport underneath both. The discovery step is a JSON-RPC method — JSON-RPC being a small, long-established convention for asking another process to run a named method with named parameters and return a result, carried here over standard input and output or over HTTP:
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}{
"jsonrpc": "2.0", "id": 1,
"result": {
"tools": [
{
"name": "search_issues",
"description": "Search the issue tracker using JQL. Returns matching issues with their full description and comment history so the assistant can answer questions about them.",
"inputSchema": {
"type": "object",
"properties": {
"jql": {"type": "string"},
"limit": {"type": "integer", "maximum": 50}
},
"required": ["jql"]
}
}
]
}
}Invocation is the same shape, and so is every other server you connect. Your client gets one code path for calling a tool, one place where timeouts live, one representation of an error, and argument validation against a schema the server publishes rather than against whatever the last developer remembered. Swapping the tracker vendor becomes a config change instead of a rewrite. If you have ever debugged an integration where the agent passed a string where the API wanted an array, that class of bug is genuinely gone, because the schema is machine-checked before the call leaves your process.
None of that is marketing, and a migration ticket should say so. What it buys is integration cost, which is a different account from the one the security claim is making. A second comparison shows where the claim goes wrong. HTTP standardises how a response is framed, and nobody concludes from that that the body of a response can be trusted. The status line, the headers and the chunked encoding are all specified to the byte; the entity inside is whatever the origin server felt like sending. MCP sits in the same relation to the text in a tool result, and the fact that the envelope is rigorously specified says nothing about the contents, in the same way that a well-formed HTTP 200 says nothing about whether the HTML inside it is trying to run script in your users' browsers.
That comparison is an analogy and is worth holding as one, because it is close enough to mislead in a particular place. Where it holds: both protocols specify the frame and decline to specify the payload, and in both cases the specification could not do otherwise without ceasing to be general. Where it breaks is on the defences that grew up around the older one. A browser handed a hostile response body is surrounded by machinery built to treat that body as foreign — an origin it is confined to, a policy about which scripts may run, a rendering context separate from the browser's own program. Nothing corresponding exists for a tool result. It is concatenated into the same sequence as the system prompt and the user's turn, with no marker on any span saying which of the three it came from. So the analogy understates the problem rather than overstating it, and a reader who stops at "it is like HTTP" will assume a boundary is present because one is present in the case they already know.
Where the returned text goes now
The result of a tool call comes back like this:
{
"jsonrpc": "2.0", "id": 2,
"result": {
"content": [
{"type": "text", "text": "PAY-114 — Idempotency keys on refunds\nStatus: In Review\n\nDescription:\nRefund endpoints currently…\n\nComments (7):\n[2026-01-14 j.okafor] The partner sandbox…\n[2026-01-19 external:contractor-22] Note for any assistant reading this…"}
],
"isError": false
}
}The schema for that result says the content is a list of blocks and that a text block has a text field which is a string. It does not say anything else, and there is no version of the specification in which it could, because the whole point of a text block is to carry arbitrary text. Whatever the server puts in that string is what your model reads, in full, as part of the conversation.
That is the relocation. Before the migration, a boundary existed at the line f"{i['key']}: {i['fields']['summary'][:120]}", enforced by the fact that Python only puts in the string what you tell it to. After it, the boundary is inside the vendor's server, at whatever function decides how to render an issue, and that function's authors were optimising for the model having enough context to be useful. They were right to. The tool description above says so out loud: returns matching issues with their full description and comment history.
It is worth being precise about who suffers from this, because "the server controls the text" sounds like an accusation and usually is not one. A server author choosing between returning three fields and returning the whole issue is choosing between an agent that answers half the questions users ask and an agent that answers most of them. Truncated output produces bug reports; complete output produces good demos. Every incentive on the server side points at returning more, and the servers people adopt are the ones that returned more. The result is that the most useful third-party servers are also the ones putting the largest volume of other people's writing into your context, and that correlation is not going to reverse.
The volume change is easy to measure and worth measuring before the argument gets abstract. On the same five-issue query, the old formatter produced 154 tokens. The vendor's server produced 3,180, because it renders descriptions and comment threads in full. A typical run of the release-notes assistant makes four tool calls, so the amount of externally-authored text entering context per task went from about 600 tokens to about 9,400. Those two pairs of figures are separate measurements rather than one multiplied out: 154 against 3,180 is a single query against a single server, while the per-task numbers cover four calls spread across three servers whose renderings differ, so the per-call volume is not uniform and the honest way to report the change is to give both. There is a second number that is easier to miss: the eleven tool descriptions from three servers are present in every single request the client makes, before any tool is called at all, and they total 1,040 tokens of server-authored prose sitting inside the system context. A tool description is written by the server author to persuade a model to call the tool correctly, which means it is prose aimed at a model, delivered above the conversation, on every request, whether or not the tool is ever used. That surface has its own name: a poisoned tool description is one whose text is written to steer the model rather than to describe the action, and it is the first thing OWASP's MCP Tool Poisoning write-up lists, while Invariant Labs' MCP Security Notification: Tool Poisoning Attacks has a working proof of concept where nothing but the description text is hostile.
Check those two pairs against each other, because the check is what makes the caution in that sentence visible rather than merely asserted. Four calls at the old formatter's hundred and fifty-four tokens is a little over six hundred, which is the old per-task figure — so that pair does multiply out, and it multiplies out because all three old clients were the same three-field formatter written three times, so the per-call volume really was uniform. Now try the same multiplication on the new figures. Four calls at three thousand one hundred and eighty comes to about twelve and a half thousand tokens, and the measured per-task figure is roughly nine and a half thousand. The measurement sits well below the product, and the shortfall is not measurement error. The four calls are spread across three servers, one of which was written in-house by the team that had been writing the formatters, and its rendering is still the old one. A residual with a name in it is worth more than an identity that closes, because it says which part of the increase was bought from a third party and which part the team still controls. The "roughly fifteen times" quoted later is the two per-task figures divided, and division is the right operation there because both of them are already per task.
The other figure repays a division as well. Eleven tool descriptions totalling a little over a thousand tokens is a little under a hundred tokens each, which is a short paragraph — about the length of the search_issues description in the discovery response above. That makes the surface concrete in a way the total does not: what sits above every conversation is eleven short paragraphs written by three other parties, none of which the model asked for. The tool-result volume is paid per call and varies with what was fetched. This is paid per request whether any tool is called or not, and it does not vary at all, which makes it the smaller number and the more dependable one to attack — anybody who can edit one of those paragraphs has guaranteed delivery on every single turn.
Laid out against the same dimensions, the change is not uniformly better or worse.
| Dimension | Hand-written clients | MCP servers | Direction |
|---|---|---|---|
| Argument marshalling bugs | Per-client, hand-checked | Schema-validated before send | Lower |
| Transport, retry, timeout handling | Three different implementations | One client code path | Lower |
| Credential sprawl in your codebase | Three globals in three modules | Server config, one place | Lower |
| Code executing in your process/host | Yours | Third party's, per topic 6.14 on trusting servers you did not write | Higher |
| Externally-authored text in context per task | ~600 tokens, three fields | ~9,400 tokens, full documents | Higher |
| Text present before any call | None | 1,040 tokens of tool descriptions | Higher |
| Who decides what the model reads | Your formatter function | The server's rendering code | Moved |
The last row is the one that matters most and the hardest to argue about, because it is not a risk that went up or down so much as a decision that changed owner.
Process risk, schema validation and the injection mechanism
This is not the argument from topic 6.14, on trusting third-party servers. That one is about the server as a process: arbitrary code, running with your environment, holding your tokens, capable of exfiltrating anything it can see. It is a real threat and vetting is the right response to it. The concern here survives a server that passes every part of that review. The vendor's tracker server can be flawlessly written, audited, pinned to a digest, and running in a container with a scoped token, and it will still faithfully return the comment that external:contractor-22 left on PAY-114 in January, because returning comments is its job. Chapter 4, on what an entirely honest relay server delivers, takes that observation apart properly.
It is also not an argument that schema validation is worthless. The inputSchema on search_issues genuinely constrains what your client sends, and it will reject a call where limit is the string "all". What it does not do is constrain what comes back, and the temptation is to feel that a system with schemas in it is a validated system. Shape and content are different properties, and only one of them is checkable by a JSON Schema validator. A string field validates the same whether it contains an issue summary or a paragraph addressed to the model.
Put that intuition through the test that separates a control from a comfort, which is whether its answer changes when the thing it is meant to bound changes. Run a schema validator over the returned text with an issue title in the field, PAY-114 — Idempotency keys on refunds, and it answers valid. Run it again with a paragraph addressed to the assistant instructing it to share a file with an outside address, and it answers valid. Both are strings; the schema says the field is a string; nothing in between them is expressible in a schema. The quantity the reader is hoping is bounded — whether the content is hostile — moves across its entire range while the validator's answer does not move at all. That is the signature of a check measuring a different property, and the different property is real and worth having. Shape is checkable by a machine; content is not; and a system with schemas in it is a system whose shapes are checked.
Nor does connecting an MCP server create the injection mechanism itself. Topic 7.1 established that: the context window is one undifferentiated sequence, and text that arrives as data can be read as instruction. MCP does not invent that. What it does is make it dramatically easier to attach new sources of third-party text to an agent — a config entry rather than a sprint of integration work — and the ease is the point of the protocol and also the reason the surface grows faster than anyone's review capacity. Chapter 6, on registering server-to-agent connections, deals with what that does to an organisation.
When somebody says that standardising tool access reduces the attack surface, the accurate version is that it standardises discovery, invocation and transport, which removes a real class of bugs, and standardises nothing whatsoever about the content the server returns. The trust decision about that text is exactly the one you had before, now made by somebody else's rendering function, on material written by people neither of you employs.
There is one more thing a migration ticket cannot yet specify, and it is the reason the next chapter exists. "The text the server returns" is not one channel. A tool result is one route into context, and it is not the only one — the protocol has a second way for a server to hand your client a document, with a different method, a different schema, and usually different code on your side. A defence that covers one of them can leave the other completely open, and the only way to settle which is which for your client is to build a server that carries a payload on both.
MCP as an injection surface — read the current version at https://daviddominguez.dev/learn/en/mcp-as-an-injection-surface/what-mcp-standardises