MCP (Model Context Protocol)
TL;DR — MCP is an open protocol that standardises the boundary between an LLM application and its tools. The application is a host; it runs one client per server connection; servers expose tools, resources, and prompts over JSON-RPC 2.0 on stdio or Streamable HTTP. Ad-hoc tool code costs integrations for tools and hosts; MCP makes it . The current revision is 2026-07-28 and it is stateless: the
initializehandshake, protocol sessions, andMcp-Session-Idare gone, replaced by per-request_metaplus a mandatoryserver/discover. MCP standardises the interface; it does not make tools safe, and the spec says so itself.
1. Why this exists
You are building a coding assistant that needs to read GitHub issues. You write
github_tools.py: three functions, a JSON schema each, an argument validator, a
token-refresh path, an error-to-string converter. It works.
Six months later the same capability is wanted in your agent, a teammate’s IDE plugin, and a desktop chat client. Each has a different notion of what a tool is, so you now have three copies that have already drifted: one retries on 429, one does not; one paginates issues, one truncates at 30.
The problem is not that tool calling is hard. It is that the tool boundary has no standard, so every (tool, host) pair is bespoke.
The arithmetic
Let be the number of tool integrations (GitHub, Postgres, Slack, a filesystem) and the number of host applications that should be able to use them. Bespoke integration needs one adapter per pair; a protocol needs one per node:
- — your tool surface, growing with every capability the product asks for.
- — the number of hosts. This is the term people underestimate, because at the start it is 1. It stops being 1 the moment a second team or a plugin ecosystem appears.
- — the bespoke cost, and it is a product. That is why the pain arrives suddenly rather than gradually.
- — the protocol cost. It is a sum: adding a host costs one client no matter how many tools exist.
- — the saving factor, and the honest test of whether MCP is worth it. (example) At , : 100 adapters versus 25, a 4x saving. At , : 1,000 versus 70. At , : 3 versus 4 — the protocol loses, which section 6 takes seriously.
What bespoke tool code leaves unspecified
- Discovery — name, arguments, and when to use it. Without a protocol this is a Python import and a docstring, and neither crosses a process boundary.
- Transport — a local tool is a function call, a remote tool a network service with auth, retries, and cancellation. Where is that line drawn?
- Capability negotiation — who decides which optional features are in play? Without an explicit step, versions drift silently.
- The trust contract — which side is trusted, and what may a server ask for? A bespoke integration has no vocabulary for this, so it has no policy.
Where this matters for you. “What does MCP solve that a function call does not?” is answered by the arithmetic and the word boundary: a function call is in-process and trusted; MCP is out-of-process, untrusted, and identical for every host.
2. How it works
2.1 Host, client, server
- Host — the LLM application. It owns the model, the context window, the conversation, and the user’s consent decisions.
- Client — a connector inside the host, one per server connection, speaking to exactly one server.
- Server — the service that provides context and capabilities.
Collapsing host and client is the classic mistake. One host process contains one client per server, so capabilities and consent are per-client — which is what makes “this server may read files, that one may only read a public API” expressible. Collapse them and you are back to one global tool namespace with one global permission set: the bespoke model, with more ceremony.
flowchart TB
U["User"] --> H["Host application<br/>(model, context, consent UI)"]
H --> C1["Client A"]
H --> C2["Client B"]
C1 -- "JSON-RPC / stdio" --> S1["Server A<br/>local filesystem"]
C2 -- "JSON-RPC / Streamable HTTP" --> S2["Server B<br/>remote SaaS API"]
S1 --> D1["Local resources<br/>(host privileges)"]
S2 --> D2["Remote resources<br/>(OAuth scope)"]
2.2 The wire format
MCP is JSON-RPC 2.0, the envelope used by the Language Server Protocol, from which MCP “takes some inspiration”:
{"jsonrpc":"2.0","id":"discover-1","method":"server/discover","params":{"_meta":{}}}
{"jsonrpc":"2.0","id":"discover-1","result":{"resultType":"complete","supportedVersions":["2026-07-28"]}}
{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}
Three consequences follow. The envelope is trivial to parse, so servers exist in a
dozen languages. Correlation is by id, which is what makes concurrent calls on
one connection safe. And params._meta is the extension point — load-bearing
in 2026, because it carries the version and capabilities the deleted handshake
used to carry.
2.3 Server primitives: tools, resources, prompts
| Primitive | What it is | Who drives it | Methods |
|---|---|---|---|
| Tools | Functions the model can execute | The model | tools/list, tools/call |
| Resources | Context and data to read | The application | resources/list, resources/read, resources/templates/list |
| Prompts | Templated messages, surfaced as commands | The user | prompts/list, prompts/get |
The control question is the design rule. A tool is something the model may decide to invoke; a resource is something the host decides to place in context; a prompt is something the user triggers explicitly. Expose your documentation as a tool and you have asked the model to discover a page it cannot yet know it needs. Expose a destructive operation as a prompt and you have kept only the human path — occasionally exactly right.
Each tool carries name, description, inputSchema, and optionally
outputSchema and annotations:
name— “SHOULD be between 1 and 128 characters … and should be considered case-sensitive.”description— free text, and the highest-leverage thing you will write; it is the routing signal (see Tool / Function Calling).inputSchema— “JSON Schema defining expected parameters”; “Defaults to 2020-12 if no\$schemafield is present”, and 2026 loosens it to “any JSON Schema 2020-12 keywords”.outputSchema— optional; it is what makesstructuredContentmeaningful rather than a string the caller must re-parse.annotations— hints such as read-only or destructive. The spec: “clients MUST consider tool annotations to be untrusted unless they come from trusted servers.” An annotation is data from the server, and servers are not trusted.
2.4 Client primitives, and how 2026 replaced them
Historically a server could make requests of the client: roots/list (which
directories may I touch), sampling/createMessage (run an LLM completion for me),
elicitation/create (ask the user something). All three are server-initiated,
which is the wrong shape for a stateless protocol. The 2026 revision replaces them
with Multi Round-Trip Requests: a server needing more information returns an
InputRequiredResult whose inputRequests field carries the questions, and the
client retries the original request with inputResponses.
resultType: "complete"— the ordinary result.resultType: "input_required"— the server needs input; retry with answers.- Clients “MUST treat results from earlier-protocol servers that omit the
field as
"complete".”
The trade: one extra round trip and a retry the caller must handle, for a server
that is a pure function of its request. Roots, Sampling, and Logging are
deprecated in the same revision — pass paths via tool parameters or resource
URIs instead of Roots, integrate with provider APIs directly instead of Sampling,
and log to stderr (stdio) or OpenTelemetry instead of Logging.
2.5 The 2026 delta, and why a 2024 description is wrong
The revision 2026-07-28 is labelled “latest” [as of 2026-10]. The spec’s own
terminology:
“Modern: protocol versions that convey version, identity, and capabilities as per-request metadata (revision
2026-07-28and later). Legacy: protocol versions that establish a session with aninitializehandshake (2025-11-25and earlier). Dual-era: an implementation that supports both.”
| Concern | Legacy (≤ 2025-11-25) | Modern (2026-07-28) |
|---|---|---|
| Version negotiation | initialize + notifications/initialized | Per-request _meta.protocolVersion; optional server/discover |
| Identity | clientInfo/serverInfo in the handshake | _meta on every request / every result |
| Sessions | Mcp-Session-Id, connection-scoped state | Removed. Cross-call state becomes an explicit server-minted handle passed as a tool argument |
| Server to client | Server-initiated requests | MRTR: InputRequiredResult + client retry |
| Change notifications | HTTP GET stream; resources/subscribe | subscriptions/listen: one long-lived POST-response stream |
| Result shape | Free form | Required resultType: "complete" or "input_required" |
| Stream recovery | SSE Last-Event-ID resumability | Removed. Re-issue with a new request id |
| Removed outright | — | ping, logging/setLevel, notifications/roots/list_changed |
Four consequences to state without notes:
- There is no negotiation handshake. “Every request declares the protocol
version it is using in its
_metafield.” The server “accepts or rejects each request independently”; a mismatch returnsUnsupportedProtocolVersionError(JSON-RPC-32022) and the client retries with a mutually supported version. server/discoveris mandatory for servers, and optional for clients, which may invoke any RPC inline and handle the version error.- Servers initiate nothing. They can no longer ask for a directory listing; they ask, and the client retries.
- A broken stream loses the request. With resumability gone, clients “MUST re-issue it as a new request with a new request ID” — so every tool call must be safe to retry or carry an idempotency key.
Two changes with outsized cost effects, both aimed at caching. Servers “SHOULD
return tools from tools/list in a deterministic order to enable client-side
caching and improve LLM prompt cache hit rates”, and every list/read result must
carry ttlMs (a freshness hint) and cacheScope ("public" or "private").
2.6 Transports
stdio — “newline-delimited messages over the standard streams of a client-launched subprocess.” Only the process-lifecycle rules are specific to standard streams; custom transports over “a reliable bidirectional byte stream (e.g., Unix domain sockets or TCP) SHOULD reuse the stdio framing.” The server is a local child process, which is also its sharpest security property: it runs with the host’s privileges and filesystem access.
Streamable HTTP — “each message is an HTTP POST to a single MCP endpoint;
replies arrive as a JSON object or a request-scoped SSE stream.” Introduced in
2025-03-26 to replace the now-deprecated 2024-11-05 HTTP+SSE transport. In the
2026 shape the reply is either a JSON object or an SSE stream scoped to that
request; server-to-client interaction is embedded as input requests per MRTR;
long-lived notifications arrive on the subscriptions/listen stream. The GET
stream endpoint and protocol-level sessions are gone. Required headers include
Mcp-Method, Mcp-Name, and custom headers declared via x-mcp-header; the
protocol version also rides in MCP-Protocol-Version.
Two server obligations stated as MUST, each aimed at a specific attack:
- Validate the
Originheader on all incoming connections “to prevent DNS rebinding attacks”; if it is present and invalid, respond with HTTP 403. - “servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)”.
Without those, “attackers could use DNS rebinding to interact with local MCP
servers from remote websites” — a page you visit reaches localhost from your
browser and drives a tool you installed. Cancellation also differs: on stdio the
client sends notifications/cancelled; on Streamable HTTP it closes the request’s
response stream.
2.7 Capability negotiation, precisely
- What each side declares. Clients:
roots,sampling,elicitation(all legacy/deprecated) plusextensions. Servers:tools,resources,prompts,subscriptions, plusextensions.extensionswas added to both in this revision, so optional behaviour lives outside the core protocol. - How. In
_meta.io.modelcontextprotocol/clientCapabilitieson every request, and in theserver/discoverresponse. - A mismatch fails that one request with
UnsupportedProtocolVersionError; the client retries with a mutually supported version. Failure is per-request, not per-connection, because there is no connection state. - Tool semantics are not negotiated at all. A server can advertise
capabilities.tools = {}and still return a destructive tool beside a read-only one.annotationsare hints, and the spec says to treat them as untrusted.
2.8 The security boundary MCP creates
MCP’s core value — mixing tools from many sources — is exactly its core risk:
“The problem with Model Context Protocol — MCP — is that it encourages users to mix and match tools from different sources that can do different things. Many of those tools provide access to your private data. Many more of them — often the same tools in fact — provide access to places that might host malicious instructions. And ways in which a tool might externally communicate in a way that could exfiltrate private data are almost limitless.” — Willison, The lethal trifecta
The spec’s own Security Best Practices page names the attacks:
- Confused Deputy Problem — the defining MCP attack. A server proxying to a third-party API with a static client ID can be tricked into exercising its own authority for a malicious client. The server has permission, the caller does not, and the server is confused about who it acts for.
- Token Passthrough — a server accepts a token not issued to it and forwards it downstream, inheriting its authority and becoming a laundering hop.
- Server-Side Request Forgery, including “SSRF Against Authorization Servers”.
- State Handle Hijacking — the 2026 replacement for Session Hijacking, and a direct consequence of removing sessions: cross-call state now lives in a server-minted handle passed as a tool argument, so the handle is the surface.
- Local MCP Server Compromise — a stdio server is a local process with the host’s privileges; payloads include exfiltration and privilege escalation.
The spec states four Key Principles — User Consent and Control, Data Privacy, Tool Safety, LLM Sampling Controls — and is honest that “MCP itself cannot enforce these security principles at the protocol level.” Tool Safety includes the sentence worth memorising: “Tools represent arbitrary code execution and must be treated with appropriate caution.”
flowchart LR
subgraph T1["Trusted: your application"]
H["Host + clients<br/>consent, policy, credentials"]
end
subgraph T2["Untrusted: the tool surface"]
S["MCP server<br/>(3rd-party code)"]
R["Descriptions, annotations,<br/>results"]
end
subgraph T3["Untrusted: the content"]
W["Web pages, issues,<br/>emails, PDFs"]
end
H -- "invokes" --> S
R -- "text that enters<br/>the context window" --> H
W -- "read by a tool,<br/>quoted into results" --> S
W -. "indirect prompt injection" .-> R
The dashed edge is the whole problem: untrusted content reaches the model through a tool result, and the model cannot distinguish it from an instruction. That is Guardrails, and MCP does not solve it — it widens the surface on which you have to solve it.
3. Implementation
3.1 A real server, over stdio, with no SDK
Runnable on the standard library, Python 3.11+.
# mcp_server.py — MCP revision 2026-07-28, stdlib only
import json
import sys
SUPPORTED = ["2026-07-28", "2025-11-25"] # dual-era: modern + one legacy
SERVER_INFO = {"name": "demo-arithmetic", "version": "0.1.0"}
CACHE_TTL_MS = 300_000 # tools/list is stable for 5 min
TOOLS = [{
"name": "add",
"description": "Add two integers and return their sum.",
# No "$schema" -> defaults to JSON Schema 2020-12 per the 2026 revision.
"inputSchema": {"type": "object",
"properties": {"a": {"type": "integer"},
"b": {"type": "integer"}},
"required": ["a", "b"], "additionalProperties": False},
"outputSchema": {"type": "object",
"properties": {"sum": {"type": "integer"}},
"required": ["sum"], "additionalProperties": False},
# A HINT. Clients MUST treat it as untrusted unless the server is trusted.
"annotations": {"readOnlyHint": True, "destructiveHint": False},
}]
class ProtocolError(Exception):
def __init__(self, code, message):
self.code, self.message = code, message
def check_meta(params):
"""The per-request metadata that replaced the initialize handshake."""
meta = (params or {}).get("_meta") or {}
version = meta.get("io.modelcontextprotocol/protocolVersion")
if version is None:
raise ProtocolError(-32602, "missing protocolVersion in params._meta")
if version not in SUPPORTED:
raise ProtocolError(-32022, f"unsupported protocol version {version}")
return meta
def handle(method, params):
check_meta(params)
if method == "server/discover": # servers MUST implement this
return {"resultType": "complete",
"supportedVersions": SUPPORTED,
"capabilities": {"tools": {"listChanged": True}},
"_meta": {"io.modelcontextprotocol/serverInfo": SERVER_INFO},
"instructions": "Demo server exposing integer addition only.",
"ttlMs": CACHE_TTL_MS, "cacheScope": "public"}
if method == "tools/list":
# Deterministic order (SHOULD) keeps the client's serialised inventory
# stable, which keeps the upstream LLM prompt cache warm.
return {"resultType": "complete",
"tools": sorted(TOOLS, key=lambda t: t["name"]),
"ttlMs": CACHE_TTL_MS, "cacheScope": "public"}
if method == "tools/call":
if params.get("name") != "add":
raise ProtocolError(-32602, f"unknown tool {params.get('name')!r}")
args = params.get("arguments") or {}
a, b = args.get("a"), args.get("b")
if not isinstance(a, int) or not isinstance(b, int):
# In-band tool error: the model can READ this and fix its arguments.
return {"resultType": "complete", "isError": True,
"content": [{"type": "text",
"text": f"add expects two integers, got {a!r}, {b!r}"}]}
# structuredContent is the typed payload the caller can trust without
# re-parsing the prose in content.
return {"resultType": "complete", "isError": False,
"content": [{"type": "text", "text": f"{a} + {b} = {a + b}"}],
"structuredContent": {"sum": a + b}}
raise ProtocolError(-32601, f"method not found: {method}")
def main():
"""Newline-delimited JSON-RPC over stdio: one line in, one line out."""
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
continue # a notification we cannot parse
if "id" not in req: # a notification: no reply is sent
continue
try:
reply = {"jsonrpc": "2.0", "id": req["id"],
"result": handle(req["method"], req.get("params") or {})}
except ProtocolError as e:
reply = {"jsonrpc": "2.0", "id": req["id"],
"error": {"code": e.code, "message": e.message}}
except Exception as e: # never leak a traceback to the wire
reply = {"jsonrpc": "2.0", "id": req["id"],
"error": {"code": -32603, "message": str(e)}}
sys.stdout.write(json.dumps(reply) + "\n")
sys.stdout.flush() # stdio is unbuffered by contract
if __name__ == "__main__":
main()
The fields that are not self-evident:
io.modelcontextprotocol/protocolVersion— the reverse-DNS_metakey that every request MUST carry. Rejecting with-32022rather than a generic error is what lets a client retry with a version you do support.- Error codes:
-32602malformed request,-32601unknown method,-32022version mismatch,-32603internal. Clients branch on these; a generic internal error makes automatic recovery impossible. resultType— required on every result."complete"here; a server needing clarification returns"input_required"withinputRequests.ttlMs/cacheScope— the freshness contract."public"permits a shared intermediary to cache;"private"forbids it for user-scoped data.isError: trueinside a successful result — the in-band error channel, the most useful distinction in the tool layer.structuredContentalongsidecontent— the model reads text, your code reads the typed object.
3.2 A real client: the modern handshake
# mcp_client.py — talks to mcp_server.py over stdio. Python 3.11+, stdlib only.
import json
import subprocess
class MCPClient:
def __init__(self, command, client_capabilities=None):
self.proc = subprocess.Popen(command, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True)
self.protocol_version = "2026-07-28"
self.client_capabilities = client_capabilities or {}
self._next_id = 0
self.server_info = None
def _request(self, method, params):
self._next_id += 1
req_id = f"{method}-{self._next_id}"
self.proc.stdin.write(json.dumps({
"jsonrpc": "2.0", "id": req_id, "method": method,
"params": {
# The per-request metadata that replaced `initialize`.
"_meta": {
"io.modelcontextprotocol/protocolVersion": self.protocol_version,
"io.modelcontextprotocol/clientInfo":
{"name": "ExampleClient", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities":
self.client_capabilities,
},
**params,
},
}) + "\n")
self.proc.stdin.flush()
while True: # correlation is by id, not by ordering
reply = json.loads(self.proc.stdout.readline())
if reply.get("id") != req_id:
continue
if "error" in reply:
raise RuntimeError(f"{method}: {reply['error']}")
return reply["result"]
def discover(self):
"""Optional for clients, mandatory for servers. Call once at startup."""
result = self._request("server/discover", {})
if self.protocol_version not in result["supportedVersions"]:
raise RuntimeError(f"server supports {result['supportedVersions']}, "
f"client speaks {self.protocol_version}")
self.server_info = result["_meta"]["io.modelcontextprotocol/serverInfo"]
return result
def list_tools(self):
return self._request("tools/list", {})["tools"]
def call_tool(self, name, arguments):
result = self._request("tools/call", {"name": name, "arguments": arguments})
if result.get("isError"):
text = "".join(c["text"] for c in result["content"] if c["type"] == "text")
raise RuntimeError(f"tool {name} failed: {text}")
return result["structuredContent"]
Three details carry the design. discover() runs once at startup, so a
version mismatch fails fast rather than mid-task. _request skips replies whose
id does not match, which is what makes concurrent calls safe on one connection.
And call_tool separates the in-band isError (the tool ran and reported a
failure the model can read) from a JSON-RPC error (the call itself was
malformed). Conflating them is how a retry loop retries a semantic error forever.
3.3 The version you should actually ship
The official Python SDK (mcp 2.3.0, [as of 2026-10]) turns the above into a
decorator. Write the protocol by hand once; ship the SDK:
from mcp.server import MCPServer
mcp = MCPServer("demo-arithmetic")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers and return their sum."""
return a + b
The type hints are the schema, the docstring is the description, and the
transport is selected at launch (mcp run server.py --transport streamable-http).
What you gave up: explicit ttlMs/cacheScope control and visibility into which
RPCs your server answers — the two things section 4 measures.
| Knob | Realistic range | What moving it does |
|---|---|---|
ttlMs | 0 - 24h | Higher = fewer tools/list round trips and a warmer prompt cache; too high serves a stale inventory |
cacheScope | public / private | private forbids shared-intermediary caching; use it for user-scoped data |
| Tools per server | 1-20 | Past ~20, selection accuracy falls and schema tokens dominate |
| Transport | stdio / Streamable HTTP | stdio = host privileges, local, no auth; HTTP = auth, multi-tenant, network-exposed |
protocolVersion | pin, do not float | Pinning makes failures explicit; floating buys a silent behaviour change |
4. Numbers and trade-offs
The cost that is invisible in the transcript
Every advertised tool is charged to the context window on every request:
- — the number of tools visible to the model. With MCP this is the union across connected servers, which is precisely the composition risk the security page warns about.
- — per-tool fixed overhead: name, keys, separators. Roughly 10-20 tokens, paid times, and it never shrinks.
- — the description. The term you control, and the one that grows with good intentions. (example) A 60-word description is ~80 tokens; twenty of them is 1,600 tokens before any schema.
- — the serialised
inputSchema. Nested objects withadditionalProperties: falseand fullrequiredlists are verbose; a five-field nested schema can cost 200-400 tokens.
(example) Twenty tools at ~150 tokens each is 3,000 tokens of inventory per turn,
comparable to a short system prompt and never amortised; over a 30-turn task that
is 90,000 input tokens before the conversation starts. This is the arithmetic
behind “bloated tool sets”, and ttlMs exists so at least the listing is not
re-fetched. Topology is the other cost: one client per server means (example) five
connections, five capability sets, five consent decisions, and five failure
domains, and a hang in any one blocks the turn without a per-call timeout. The
trade in one line: you swap adapters for a protocol layer you must debug and
an untrusted execution boundary in your process tree — and the security posture of
every server you did not write becomes yours.
Anthropic reports that with MCP “agents encounter unseen tools with descriptions of wildly varying quality”, and that a tool-testing agent which rewrote a flawed MCP tool description produced “a 40% decrease in task completion time for future agents.” That is a vendor-internal evaluation, not a controlled benchmark — the transferable part is the mechanism: description quality is the ceiling on routing quality.
5. Pitfalls
Implementing the 2024 handshake, or omitting _meta. Symptom: your server
works against one host and fails against another; a host never calls anything
because it waits for a notifications/initialized that modern clients never send;
or every call fails with a parameter error pointing at params rather than the
missing key. Version and capabilities are required on every request.
Treating a tool error as an RPC error. Symptom: a model that could have fixed
a bad argument sees a harness-level failure instead, or a retry loop retries a
semantic error thirty times. Return isError: true inside a successful result for
anything the model can act on.
Returning tools in non-deterministic order, or trusting annotations. Symptom
(a): prompt-cache hit rate near zero, cost several times the estimate, nothing
visibly broken — sort by name. Symptom (b): a tool marked readOnlyHint: true
deletes something. A hint from an untrusted server is not a guarantee.
Running a stdio server with your own credentials. Symptom: the blast radius of an incident is your user account rather than the tool. A stdio server is a local child process with the host’s privileges; scope credentials per server and run it under a restricted user.
Binding a local HTTP server to 0.0.0.0. Symptom: an external host reaches
your MCP server. The spec requires Origin validation and recommends binding to
127.0.0.1; without both, DNS rebinding makes any web page you visit a client.
Letting a server’s static OAuth client ID stand in for the user. Symptom: actions attributed to the server’s identity rather than the user’s, and a privilege escalation you cannot reproduce from the user’s own account. That is the confused deputy problem.
Interpolating a tool result into a system-prompt position. Symptom: an assistant that obeys text found in a web page or an issue title. Tool results are attacker-controllable data; delimit and label them as data, and assume the model may obey them anyway.
No timeout, and blind retry after a broken stream. Symptom: one slow server stalls the turn, or duplicate side effects after a network blip. The stream loses the in-flight request, so anything non-idempotent needs an idempotency key before you enable retries.
6. Using it in a project
Reach for MCP when the tool must be shared across more than one host; it is owned by another team or a third party; you want a plugin ecosystem; or it is naturally a separate process (a browser, a database proxy, a code sandbox).
Use a plain function call when and are both small. Three tools in one agent is a dict of Python functions; MCP adds a subprocess, a JSON-RPC layer, an untrusted boundary, and a negotiation you must now get right. Use the saving factor as the test: below about 2, do not bother.
Use the provider’s native tool-calling API when you have one provider, one host, and no sharing requirement. MCP is a wire protocol; in-process calling needs no wire.
Use resources rather than tools when the host, not the model, decides what enters context. Documents exposed as tools make the model spend turns discovering what it needed; resources let the host pin the right ones.
Do not put authorisation in the description. If your permission model is prose the model reads rather than code that enforces, you have documentation, not a policy. Enforce in the client or the downstream system.
When NOT to adopt MCP at all. Do not adopt it to solve a security problem — it widens the boundary rather than narrowing it. Do not adopt it as a plugin marketplace without a policy for which servers may run and what they may reach. And do not adopt it for a single-consumer internal tool whose only host is your own agent. The decision reduces to three questions: how many hosts, who owns the tool, and does it need a process boundary. Any answer of “one”, “us”, and “no” means plain functions.
7. How you would prove it worked
MCP’s payoff is a cost and reliability claim, not an accuracy claim; all figures
below are (example).
1. Integration cost, counted in adapters. Baseline: the bespoke matrix — count the distinct (tool, host) adapters you maintain today. After: servers plus clients. Report the saving factor with the real and , and the number of adapters deleted. This is the one metric where MCP’s value is unarguable, because it is a count rather than an estimate.
2. Tool-selection accuracy under composition. This is where MCP can make things worse. Metric: the fraction of tasks where the agent calls the intended tool on the first attempt. Baseline: the same tools exposed natively, in-process, with the same descriptions. Ablation: connect servers one at a time and plot accuracy against visible-tool count. The knee where adding a server starts hurting is your practical tool budget.
3. Per-turn overhead, and the security inventory. Measure the token cost of
the serialised inventory (call the provider’s token counter with and without tools
attached) and the p50/p95 latency of tools/list with caching versus ttlMs = 0,
both against the native baseline. Then walk every tool across all connected
servers and record whether it can (a) read private data, (b) reach untrusted
content, (c) change state or communicate externally. Any tool satisfying all three
is a lethal-trifecta instance; the target count is zero.
(example) 14 bespoke adapters replaced by 3 servers and 2 clients (4.2x at 6 tools
and 3 hosts); first-attempt selection 91% to 83% with all servers connected,
recovering to 89% after two read-heavy capabilities moved from tools to
resources; inventory 2,400 tokens per turn, tools/list round trips 11 to 1.
8. Interview drill
“What problem does MCP solve that ad-hoc tool code does not?” Strong answer: the integration matrix between tools and hosts. MCP fixes discovery, transport, the capability contract, and the trust boundary, so each tool author writes one server and each host author writes one client. State explicitly what it does not solve: authorisation and prompt injection. Common wrong answer: “it lets LLMs call tools.” Models called tools before MCP via function calling; MCP standardises the boundary between processes.
“What is the current revision, and what changed?”
Strong answer: 2026-07-28. It removes protocol-level sessions and the
initialize handshake; every request carries protocolVersion and
clientCapabilities in _meta; servers MUST implement server/discover;
server-initiated requests became Multi Round-Trip Requests; resultType is
required on every result; SSE resumability is gone; subscriptions/listen
replaces the GET stream; Roots, Sampling, and Logging are deprecated.
Common wrong answer: describing initialize plus notifications/initialized as
current, or citing 2024-11-05. Both describe the legacy era.
“Tools, resources, prompts — what is the difference?” Strong answer: who drives. Tools are model-controlled invocations; resources are host-controlled context; prompts are user-triggered templates. Choosing wrongly is a design bug: a document exposed as a tool makes the model hunt for what the host already knew it needed. Common wrong answer: “resources are read-only tools.” Read-only is a hint, and the spec says hints from untrusted servers are untrusted.
“How is a tool error reported?”
Strong answer: in-band — a successful tools/call result with isError: true
and readable content, because the model can act on that text. JSON-RPC errors
are for protocol faults the model cannot fix.
Follow-up: “why does the distinction matter?” → a harness that conflates them
retries semantic errors forever, or surfaces an unfixable failure to the model.
“Where is the security boundary, and why does removing sessions matter?” Strong answer: host and clients are trusted; servers, their descriptions, their annotations, and their results are not. Name the attacks: confused deputy, token passthrough, SSRF, state handle hijacking, local stdio server compromise — the spec says MCP “cannot enforce these security principles at the protocol level.” Removing sessions means cross-call state must become an explicit server-minted handle passed as a tool argument, so the handle is the attack surface and every tool call must be retry-safe. Common wrong answer: “MCP is secure because servers declare their capabilities.” Declaration is not enforcement.
“When would you not use MCP?” Strong answer: one host, few tools, one team. At , the saving factor is below 1 and you have added a subprocess, a protocol, and an untrusted boundary for no interoperability. Adopt it when or grows.
Next: Guardrails — MCP standardises the tool boundary but deliberately does not secure it; guardrails are what you build on top, starting from the assumption that the model will be fooled.