MCP
MCP is a boring protocol, and that's exactly why it works
The first time someone explained the Model Context Protocol to me it sounded like a much bigger deal than it is. Then I read the spec and it turned out to be JSON-RPC over a transport, a discovery handshake, and three or four core concepts. That's it. No inference, no orchestration, no opinions about your model.
I mean that as a compliment. The boring part is the value.
The problem it solved for us
We had three things that needed to read the same operational data: a chat assistant, an agent flow doing first-line triage, and an internal tool that drafted incident summaries. Each one had its own copy of the integration code for inventory lookups.
Three copies means three auth implementations, three retry policies, three different ideas of what a "circuit" record looks like, and three places to patch when the upstream API adds a required field. We found out about the last one the hard way — one client kept working, two started returning empty results, and the behaviour difference took an afternoon to trace.
MCP let us invert that. The integration becomes a server. It owns the auth, the schema, the retries, the rate limits. Any client that speaks the protocol discovers the available tools at runtime and calls them. One implementation, three consumers, and adding a fourth consumer is configuration rather than code.
If you've been in platform engineering for a while, this is a very familiar shape. It's the same argument as a shared service versus a copy-pasted client library, and it has the same payoff.
The parts of the spec that matter in practice
A server can expose three kinds of thing, and the distinction is worth getting right:
- Tools — functions the model may decide to call. Model-controlled. This is where nearly all of our surface area lives.
- Resources — data the client can read and attach to context, like a document or a config file. Application-controlled: the host decides what to include, not the model.
- Prompts — parameterised templates a user can deliberately invoke. Useful for "run the standard triage checklist on circuit X".
Teams tend to make everything a tool. Resources are the better fit whenever the host knows in advance what context is relevant, because you skip a round trip and you remove a decision the model can get wrong.
Tool descriptions are prompt engineering
This is the thing I'd most like to convince people of. Your tool's description field is not documentation for humans. It is the entire basis on which a model decides whether to call your tool, and with what arguments. It's prompt engineering in a JSON field, and it deserves the same iteration.
Early on, we shipped something like:
{
"name": "get_status",
"description": "Gets the status",
"inputSchema": {
"type": "object",
"properties": { "id": { "type": "string" } }
}
}
The model called it with customer account numbers, with postcodes, with a circuit reference
that had the wrong prefix, and — my favourite — with the string "id". None of
that is the model being stupid. There's nothing in that schema that says what an
id is.
The version that behaved:
{
"name": "get_circuit_status",
"description": "Look up the current operational status of a single network circuit by its circuit reference. Use this when the user asks whether a circuit is up, down, or degraded. Returns status, last state change timestamp, and any open alarms. Does NOT cover customer account or billing status - use get_account_summary for those.",
"inputSchema": {
"type": "object",
"properties": {
"circuit_ref": {
"type": "string",
"description": "Circuit reference in the form BT-CCT-000000 (case sensitive, 6 digits).",
"pattern": "^BT-CCT-[0-9]{6}$"
}
},
"required": ["circuit_ref"]
}
}
Three things changed. The name says what it does. The description says when to use it and when not to — that negative clause cut a whole class of wrong-tool calls. And the schema constrains the argument, so a malformed reference fails at validation with a message the model can actually act on rather than producing a confusing empty result.
Rule of thumb: if two of your tools could plausibly answer the same question, the model will pick between them at random. Either say explicitly in each description which is which, or merge them.
Error messages are also prompt engineering
Related, and less obvious. When a tool call fails, the error text goes back into the model's
context and the model decides what to do next. 500 Internal Server Error gives it
nothing, so it typically retries the identical call, fails again, and then apologises to the
user.
Circuit BT-CCT-9931 not found. Circuit references are 6 digits; you supplied 4. Check
the reference and retry. gets corrected on the next turn, usually successfully. Write
errors for the reader, and remember your reader is a model that will try again.
What we deliberately did not expose
Our servers are read-heavy by design. Nothing that changes production state is callable directly by a model.
Where a workflow needs a change, the tool returns a proposed change — the diff, the blast radius, the rollback — and a human approves it through the normal change process. The assistant compresses twenty minutes of gathering evidence into twenty seconds. It does not get to press the button. On critical national infrastructure that isn't a close call, and honestly I'd make the same choice on a normal production system until the audit story is a lot more mature.
The other things I'd consider mandatory rather than nice-to-have:
- Per-tool authorisation carrying the end user's identity. A shared service account that can read everything is a data-exfiltration primitive with a chat interface attached. The server enforces what this user may see.
- An audit record of every invocation — who, which tool, what arguments, what came back. This is the first question every assurance reviewer asks, and it's much easier to add on day one than month six.
- Rate limits on the server, not the client. An agent in a bad loop can generate a remarkable amount of traffic, and you cannot rely on the caller to be well-behaved.
- Treat tool output as untrusted input. If a document your tool returns contains text telling the model to ignore its instructions, that's a live prompt-injection path. Don't hand back raw content you haven't thought about.
Where I've landed
MCP isn't going to make a mediocre assistant good. What it does is stop the tool layer from being re-invented per application, which means the effort you spend on getting one integration genuinely right — good descriptions, tight schemas, real authorisation, useful errors — pays off across every client you build later.
The protocol is the boring, settled part. The design of what you expose through it is the actual engineering, and it's worth more of your time than picking a model.
The natural follow-on: once you have a clean tool layer, how do you organise the agents that use it? That's the Google ADK post.