Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 initialize handshake, protocol sessions, and Mcp-Session-Id are gone, replaced by per-request _meta plus a mandatory server/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

  1. 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.
  2. Transport — a local tool is a function call, a remote tool a network service with auth, retries, and cancellation. Where is that line drawn?
  3. Capability negotiation — who decides which optional features are in play? Without an explicit step, versions drift silently.
  4. 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

PrimitiveWhat it isWho drives itMethods
ToolsFunctions the model can executeThe modeltools/list, tools/call
ResourcesContext and data to readThe applicationresources/list, resources/read, resources/templates/list
PromptsTemplated messages, surfaced as commandsThe userprompts/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 \$schema field is present”, and 2026 loosens it to “any JSON Schema 2020-12 keywords”.
  • outputSchema — optional; it is what makes structuredContent meaningful 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-28 and later). Legacy: protocol versions that establish a session with an initialize handshake (2025-11-25 and earlier). Dual-era: an implementation that supports both.”

ConcernLegacy (≤ 2025-11-25)Modern (2026-07-28)
Version negotiationinitialize + notifications/initializedPer-request _meta.protocolVersion; optional server/discover
IdentityclientInfo/serverInfo in the handshake_meta on every request / every result
SessionsMcp-Session-Id, connection-scoped stateRemoved. Cross-call state becomes an explicit server-minted handle passed as a tool argument
Server to clientServer-initiated requestsMRTR: InputRequiredResult + client retry
Change notificationsHTTP GET stream; resources/subscribesubscriptions/listen: one long-lived POST-response stream
Result shapeFree formRequired resultType: "complete" or "input_required"
Stream recoverySSE Last-Event-ID resumabilityRemoved. Re-issue with a new request id
Removed outright—ping, logging/setLevel, notifications/roots/list_changed

Four consequences to state without notes:

  1. There is no negotiation handshake. “Every request declares the protocol version it is using in its _meta field.” The server “accepts or rejects each request independently”; a mismatch returns UnsupportedProtocolVersionError (JSON-RPC -32022) and the client retries with a mutually supported version.
  2. server/discover is mandatory for servers, and optional for clients, which may invoke any RPC inline and handle the version error.
  3. Servers initiate nothing. They can no longer ask for a directory listing; they ask, and the client retries.
  4. 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 Origin header 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) plus extensions. Servers: tools, resources, prompts, subscriptions, plus extensions. extensions was added to both in this revision, so optional behaviour lives outside the core protocol.
  • How. In _meta.io.modelcontextprotocol/clientCapabilities on every request, and in the server/discover response.
  • 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. annotations are 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 _meta key that every request MUST carry. Rejecting with -32022 rather than a generic error is what lets a client retry with a version you do support.
  • Error codes: -32602 malformed request, -32601 unknown method, -32022 version mismatch, -32603 internal. 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" with inputRequests.
  • ttlMs / cacheScope — the freshness contract. "public" permits a shared intermediary to cache; "private" forbids it for user-scoped data.
  • isError: true inside a successful result — the in-band error channel, the most useful distinction in the tool layer.
  • structuredContent alongside content — 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.

KnobRealistic rangeWhat moving it does
ttlMs0 - 24hHigher = fewer tools/list round trips and a warmer prompt cache; too high serves a stale inventory
cacheScopepublic / privateprivate forbids shared-intermediary caching; use it for user-scoped data
Tools per server1-20Past ~20, selection accuracy falls and schema tokens dominate
Transportstdio / Streamable HTTPstdio = host privileges, local, no auth; HTTP = auth, multi-tenant, network-exposed
protocolVersionpin, do not floatPinning 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 with additionalProperties: false and full required lists 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.