Article

MCP in Practice (2026-07-28 Spec)

The Model Context Protocol went stateless in the 2026-07-28 revision. This guide covers the architecture, the three server primitives, both transports, authorization, a small working server, the security model, production operations, and when not to use MCP at all.

Most of what has been written about the Model Context Protocol describes a protocol that no longer exists. MCP launched as a local-only standard with an initialize handshake, a connection-scoped session, and servers that could call back into the client mid-request. The 2026-07-28 revision removed all three. If your mental model of MCP includes a session ID, you are a revision behind, and the gap matters: it changes how you write a server, how you hold state, how you authorize a call, and what can go wrong in production.

This guide is the protocol companion to Designing Tools for Agents, which covers how to design a tool regardless of how it is exposed. Everything there still applies. This one is about the wire: what MCP gives you that plain function calling does not, what it costs, and when you should not reach for it.

TL;DR

MCP is a JSON-RPC protocol that lets one agent host talk to many tool servers without bespoke integrations, and as of the 2026-07-28 revision it is stateless: there is no handshake and no session, every request carries its own protocol version and capabilities in _meta, and a server that needs state across calls mints an explicit handle and receives it back as an ordinary tool argument.

Use MCP when you want portability across hosts or want to consume servers other people maintain. Do not use it to mechanically re-expose a REST API, do not blame it for context bloat that your own harness caused, and do not treat anything a server tells you — tool names, descriptions, annotations — as trustworthy.

What MCP Actually Is

MCP standardises the connection between an AI application and the systems it needs to reach. The specification names three roles: hosts are the LLM applications that initiate connections, clients are the connectors inside the host, and servers are the services that provide context and capabilities. Messages are JSON-RPC 2.0, and the spec describes requests as “stateless, self-contained” with “per-request capability negotiation”.

The design is explicitly modelled on the Language Server Protocol, and the origin story is worth knowing because it explains the shape. On the Latent Space episode with the protocol’s creators, David Soria Parra describes recognising an M×N problem — many applications, many integrations, each pair needing its own glue — while separately working on something LSP-related internally (The Creators of Model Context Protocol, ~22:00). LSP solved the same shape for editors and languages: write one language server, and every compatible editor benefits.

MCP architecture after the 2026-07-28 revision A host application on the left contains two MCP clients. Each client connects to a server on the right: one local server over the stdio transport, one remote server over Streamable HTTP. Between them, a panel notes that every request carries its own protocol version and capabilities in the _meta field, with no initialize handshake and no session identifier. HOST APPLICATION SERVERS MCP client A MCP client B Local server Remote server one connection per server one connection per server client-launched subprocess single HTTP endpoint path stdio Streamable HTTP every request carries its own protocol version and capabilities in _meta — no handshake, no session
One host, several clients, one connection per server, and two standard transports. The dashed panel is the 2026-07-28 change that reshapes everything else.

Servers offer three features — resources, prompts, and tools — and clients offer elicitation. Beyond that core, the spec defines opt-in extensions negotiated by both sides, currently including Tasks for long-running work, Skills over MCP for packaged procedural instructions, and MCP Apps for interactive UI rendered inline.

The 2026-07-28 Revision Changed the Shape of MCP

The changelog is the single most useful page in the specification right now, because the major changes are not refinements. Five of them will touch your code.

Sessions are gone. The revision removes protocol-level sessions and the Mcp-Session-Id header. List endpoints — tools/list, resources/list, prompts/list — “no longer vary per-connection”. A server that needs state spanning requests now mints an explicit handle, such as a workflow ID, and receives it back as an ordinary tool argument.

The handshake is gone. There is no initialize and no notifications/initialized. Every request carries its protocol version and client capabilities in _meta, under io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities. Clients should identify themselves per request with clientInfo, servers should identify themselves in each result’s _meta with serverInfo, and a version mismatch returns UnsupportedProtocolVersionError.

server/discover is mandatory. Servers MUST implement this RPC to advertise supported protocol versions, capabilities, and identity. Clients MAY call it before anything else to pick a version up front, or use it as a backward-compatibility probe on stdio.

Server-initiated requests are gone, replaced by a retry pattern. Previously a server mid-request could call back to the client for roots/list, sampling/createMessage, or elicitation/create. Now it returns an InputRequiredResult with resultType: "input_required", whose inputRequests field carries what it needs; the client retries the original request with inputResponses. Every result carries a required resultType, either "complete" or "input_required", and clients MUST treat a missing field from older servers as "complete".

Streams are no longer resumable. SSE resumability and message redelivery are removed along with the Last-Event-ID header. A broken response stream loses the in-flight request, and clients MUST re-issue it as a new request with a new request ID.

Smaller changes with real operational consequences: ping and logging/setLevel are removed, with log level set per request via io.modelcontextprotocol/logLevel; tasks moved out of the core protocol into the io.modelcontextprotocol/tasks extension with polling through tasks/get; subscriptions/listen replaces the old HTTP GET endpoint and resources/subscribe; and list results now require ttlMs and cacheScope fields so clients can cache instead of polling.

The practical summary is that MCP now looks much more like ordinary REST infrastructure. You can put a server behind a load balancer without sticky sessions, scale it horizontally, and let a request land on any instance. That was the point. On the Latent Space one-year retrospective, Soria Parra calls transport “the one discussion that has never stopped” and describes spending two days at Google’s offices with senior engineers from Google, Microsoft, AWS, Anthropic, and OpenAI working out what the transport needed to be (One Year of MCP, ~11:48).

Tools, Resources, and Prompts

The three server primitives are not three ways to do the same thing. They differ by who decides when they are used, and that is an architectural distinction rather than a stylistic one.

PrimitiveControlled byDiscovery and useReach for it when
ToolsThe modeltools/list, then tools/callThe model should decide whether and when the operation happens
ResourcesThe applicationresources/list, resources/read, URI-addressedThe host or user should choose what context gets attached
PromptsThe userprompts/list, then prompts/get with argumentsA person should explicitly invoke a reusable workflow

In the Anthropic workshop that remains the best single explanation of this split, Mahesh Murag puts the test for tools in one sentence: tools suit the case where the right moment to retrieve information is uncertain, and if a database call should happen deterministically every time, the application should just make that call directly (Building Agents with MCP, 2:59). Resources are application-controlled data that a host can surface as attachments or attach automatically (10:43), and prompts are user-controlled templates — his example is a slash command that expands into a fuller instruction (12:57). He is also direct about what follows: the point of three primitives is to coordinate the model, the application, and the user rather than route every decision through the model, and a tools-only agent may not need resources or prompts at all (14:04).

That last point deserves emphasis, because most servers in the wild are tools-only and that is a legitimate design. The creators are candid that the resource story is the muddiest of the three: asked when to expose a SQL query as a tool versus the data as a resource, they describe tools as always model-initiated and resources as “more flexible”, then admit the practical story “is a bit complicated” (The Creators of MCP, ~20:00). If you cannot articulate which of the three parties should own the decision, you want a tool.

The resources specification adds a constraint worth internalising now that sessions are gone: the resource set “MUST NOT vary per-connection or as a side effect of other requests”, though it MAY vary by the authorization presented on the request, “since credentials are per-request input, not connection state”. Resources are addressed by URI, with file://, git://, and https:// defined — and https:// is reserved for the case where the client can fetch the thing itself without going through your server.

Transports: stdio and Streamable HTTP

The spec defines exactly two standard transports and is explicit that “protocol semantics are identical on every transport”. A transport is a binding: it decides framing and delivery, not meaning.

stdio is newline-delimited JSON-RPC over the standard streams of a client-launched subprocess. The host starts your process; you read requests on stdin and write responses on stdout. Cancellation arrives as a notifications/cancelled notification.

Streamable HTTP is the remote binding, and the rules are tighter than most implementations assume. The server MUST expose a single HTTP endpoint path. Every client message MUST be a new HTTP POST whose body is a single JSON-RPC request or notification — clients MUST NOT send JSON-RPC responses, because servers never ask them anything. A notification the server accepts gets 202 Accepted with no body. For a request, the server MUST return either a JSON object or an SSE stream, and clients MUST support both, which is why the client MUST send an Accept header listing both application/json and text/event-stream.

Two headers are mandatory on every POST. MCP-Protocol-Version carries the revision, as in MCP-Protocol-Version: 2026-07-28, and MUST match the value in _meta or the server rejects the request; an unsupported version gets 400 Bad Request. The revision also requires Mcp-Method, mirroring the JSON-RPC method, and Mcp-Name, mirroring params.name or params.uri for tools/call, resources/read, and prompts/get. The reason is infrastructure: mirroring selected body fields into headers lets load balancers, proxies, and WAFs route and inspect requests without parsing the body, while “the body remains the source of truth”.

Put together, a conforming tool call over HTTP looks like this — and the shape is the whole statelessness story in one request:

POST /mcp HTTP/1.1
Host: incident-ops.example.com
Accept: application/json, text/event-stream
Content-Type: application/json
Authorization: Bearer <token issued by this server's authorization server>
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search_job_logs

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "search_job_logs",
    "arguments": { "run_id": "run_8f21ca40b7d9", "query": "connection refused" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "incident-console", "version": "1.4.0" },
      "io.modelcontextprotocol/clientCapabilities": {},
      "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
    }
  }
}

No session identifier, no prior handshake, and no earlier request this one depends on. Any instance behind the load balancer can serve it, which is precisely what the revision was for. Note that the MCP-Protocol-Version header and the _meta protocol version must agree, and that traceparent is the documented way to join this call to the rest of your trace.

Cancellation on HTTP is simply closing the response stream, which the server MUST treat as cancellation; it SHOULD stop work promptly and MUST NOT send further messages for that request.

Three security requirements live in the transport layer rather than the security appendix, and they are the ones local servers get wrong. Servers MUST validate the Origin header on all incoming connections and reject an invalid one. When running locally, servers SHOULD bind only to localhost. And servers SHOULD implement proper authentication for all connections. Those three lines are what stand between a convenient local server and a web page that reaches into your machine.

Build a Small Server

Here is a server for the incident-triage example used in Tool Use and Designing Tools for Agents. It uses the API from the official build-an-MCP-server guide: the SDK derives tool definitions from type hints and docstrings, so your signature and docstring are the tool schema the model sees.

# incident_ops.py — run with: uv run incident_ops.py
from typing import Literal

from mcp.server import MCPServer

mcp = MCPServer("incident-ops")


@mcp.tool()
async def get_job_failure_context(job_name: str) -> str:
    """Assemble everything needed to explain one job's latest failure.

    Returns the failing step, matching error lines, deploys in the window,
    and dependency health in a single call. Start an investigation here
    rather than reading raw logs.

    Args:
        job_name: Scheduler job name, e.g. 'nightly-billing'.
    """
    run = await scheduler.latest_failed_run(job_name)
    if run is None:
        return (
            f"No failed runs found for '{job_name}'. "
            "Check the job name with list_jobs; a healthy job has no failure context."
        )

    return render_failure_context(run)


@mcp.tool()
async def search_job_logs(
    run_id: str,
    query: str,
    level: Literal["debug", "info", "warn", "error", "fatal"] = "warn",
) -> str:
    """Search one run's log lines and return matches with surrounding context.

    Use after get_job_failure_context when you need detail about a specific
    error. Matching is case-insensitive substring matching, not regex.
    Returns at most 50 matches, newest first; narrow the query rather than
    paging.

    Args:
        run_id: Run identifier like 'run_8f21ca40b7d9', from
            get_job_failure_context. Not the job name.
        query: Substring to match in the log body, e.g. 'connection refused'.
        level: Minimum severity to include. Defaults to 'warn'.
    """
    if not RUN_ID_PATTERN.fullmatch(run_id):
        return (
            f"'{run_id}' is not a run identifier. run_id looks like "
            "'run_8f21ca40b7d9'. Call get_job_failure_context with "
            f"job_name='{run_id}' to get the current run_id, then retry."
        )

    return render_matches(await logs.search(run_id, query, min_level=level))


if __name__ == "__main__":
    mcp.run(transport="stdio")

Two things in there are protocol-aware rather than generic good practice. The validation failure returns a result, not an exception: the tools specification separates protocol errors, which models are unlikely to be able to fix, from tool execution errors, which carry “actionable feedback that language models can use to self-correct and retry with adjusted parameters” and travel in the result with isError: true. Clients SHOULD pass execution errors to the model. Returning a string the model can act on is the whole point.

The second is what is absent. There is no session, so if this server needed state across calls — a triage workflow spanning several tools — it would mint a handle and take it back as an argument:

@mcp.tool()
async def open_triage(job_name: str) -> str:
    """Start a triage workflow and return its handle."""
    handle = secrets.token_urlsafe(24)          # unguessable, not sequential
    await store.put(f"{caller_user_id()}:{handle}", {"job": job_name})
    return f"triage_handle={handle}"


@mcp.tool()
async def add_triage_finding(triage_handle: str, finding: str) -> str:
    """Record a finding against an open triage workflow."""
    # The handle is an argument, so it is attacker-controllable input.
    # Scope the lookup to the authenticated caller; never trust the handle alone.
    key = f"{caller_user_id()}:{triage_handle}"
    state = await store.get(key)
    if state is None:
        return "That triage handle is not open for your account. Call open_triage first."

    state["findings"].append(finding)
    await store.put(key, state)
    return f"Recorded. {len(state['findings'])} findings so far."

That composite key is not decoration. The specification’s security best practices name state handle hijacking as an attack class created by statelessness, and the mitigation is specific: servers MUST verify all inbound requests, MUST NOT treat possession of a state handle as authentication, SHOULD use non-deterministic handles from a secure random source, and SHOULD bind handles server-side to the authenticated user — the doc’s own example keys state as <user_id>:<handle> “where the user ID is derived from the verified token rather than supplied by the client”.

The resource and prompt registration APIs differ between SDKs, so rather than guess, here is what a prompt looks like on the wire. Prompts are “designed to be user-controlled”, typically triggered by a user-initiated command, and prompts/get takes arguments that MAY be auto-completed through the completion API:

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "prompts/get",
  "params": {
    "name": "triage_writeup",
    "arguments": { "run_id": "run_8f21ca40b7d9", "audience": "incident-review" }
  }
}

Authorization

Authorization applies to HTTP transports. For stdio the spec says implementations SHOULD NOT follow it and should retrieve credentials from the environment instead, which is the right call — there is no network boundary to defend, and a bearer token in a subprocess argument is worse than an environment variable.

For HTTP, the authorization spec is prescriptive and the requirements are testable:

That last rule is the one to tattoo somewhere. Accepting an upstream token and forwarding it — token passthrough — turns your server into a confused deputy, and the spec lists both as named attack classes.

Worth knowing where the standard stops. On the one-year retrospective, Soria Parra points out that OAuth is “for the most part a very human centric protocol”: it tells you how to obtain a token, and after that the protocol does not care. MCP is deliberately not prescriptive about agent-to-agent or on-behalf-of-agent authentication. Inside an enterprise you can bind a token to a workload identity and pass it along, but “if the client and the server don’t know each other, we just don’t have a good solution for now” (One Year of MCP, ~11:31). If your design depends on an answer there, you are building it yourself.

Security: Assume the Server Is Hostile

MCP’s security model has one load-bearing idea: everything a server says is untrusted input. The tools spec states it as a requirement — clients MUST consider tool annotations untrusted unless they come from a trusted server — and the local server security guide explains why that is not paranoia. At runtime the server sends your client its tool names and descriptions, “and the model reads them as instructions — an injection surface that install-time review does not cover”.

That is the prompt-injection story in one line. A description field is text from a third party that lands in your model’s context on every call, alongside your system prompt and the user’s data. It can instruct your agent. If the server also returns content it fetched from elsewhere, the injection surface extends to whatever that content contains. Agent Security covers what to do about that surface, including a tool-description pinning check that catches a server changing a description after you approved it.

The practical defences split by where the server runs.

For servers you install locally, the guide’s install-time checklist is unusually concrete. Identify the publisher and tie the package to a real source repository. Verify the exact package name, because “typosquatting is the cheapest attack available”. Pin a version rather than latest. And know what your pin covers: pinning with a launcher such as npx or uvx “fixes only the top-level package; its transitive dependencies are typically resolved fresh at install time, which is exactly where a compromised dependency arrives”, whereas pinning a container image by digest freezes the whole tree. The guide is also blunt that “a listing on a registry is not an endorsement”, and that curated catalogues describe their own vetting as best-effort.

For servers you expose over HTTP, the controls are the transport rules from earlier plus the authorization rules above: validate Origin, bind to localhost when local, authenticate every connection, validate the token audience, and never forward a token you were handed. Then the ordinary server-side obligations from the tools spec: validate all inputs, implement access controls, rate limit invocations, and sanitise outputs.

Nothing in the protocol decides what an approved tool call is allowed to do. That boundary is yours, and it is the subject of Structured Outputs, Guardrails, and Execution Boundaries; the human checkpoint in front of a consequential call belongs to Human-in-the-Loop Control Design, which the protocol now supports directly through the input-required retry pattern.

When Not to Use MCP

MCP is a connectivity standard, not a strategy. The clearest statement of the alternatives comes from Soria Parra’s keynote, which frames 2026 as the year agents need a full connectivity stack: Skills for reusable domain knowledge, MCP for protocol-level interoperability and richer semantics, and CLIs or computer use where those fit the environment, with no single method treated as a universal answer (The Future of MCP, 5:06). He is specific about the trade: CLIs compose naturally in bash and align with tools a model already knows, but assume a sandbox and code execution; MCP earns its keep when you need platform independence, authorization, governance, resources, or interfaces for long-running work (6:02).

So: skip MCP when the agent runs in a sandbox you control and a CLI would do, when the capability is a single deterministic call the application should make itself, or when the knowledge is procedural rather than operational and belongs in a skill.

Two failure modes are worth naming because they are so common.

Converting your REST API one-to-one. Soria Parra criticises mechanical API-to-MCP conversion directly: it produces poor agent interfaces. The sharpest field report on this is Reducto’s. Their first server used Claude Code to generate one MCP tool per API endpoint, all in one file; a well-chosen prompt made a great Slack demo, but once the whole team used it, people would hand the agent incomplete notes, it would confidently assemble an entire document pipeline, and neither the user nor the agent could explain what the pipeline actually did (Why We Deleted Our MCP Server and Rebuilt It, 3:42). The rebuild deleted it in one commit and replaced it with fewer tools carrying product knowledge — a single classify-to-extract tool instead of repeated generic step creation, plus a snapshot tool returning the live pipeline state and specific error codes so the agent could reference reality instead of reconstructing it from conversation (6:12). That is Designing Tools for Agents learned the expensive way, and it is the same lesson whether or not MCP is involved.

Blaming MCP for context bloat. This one is a misattribution. A protocol moves information between systems; the client decides what enters the model’s context. Early hosts loaded every tool definition from every connected server at startup, which scales badly for the obvious reason. The fix is progressive discovery: give the model a tool-search mechanism and load definitions only when they become relevant (The Future of MCP, 7:12 and 8:20). If your context is full of unused tools, that is a harness decision, not a protocol defect.

Operating MCP in Production

Four things in this revision exist specifically to make servers operable, and they are easy to miss.

Caching instead of polling. tools/list, prompts/list, resources/list, resources/read, and resources/templates/list now require ttlMs and cacheScope on their results. ttlMs is a freshness hint in milliseconds; cacheScope is "public" or "private" and controls whether shared intermediaries may cache the response. Set them deliberately — a server that returns a short TTL on a stable tool list is paying for discovery traffic it does not need.

Deterministic tool ordering. The changelog asks servers to return tools from tools/list in a deterministic order, “to enable client-side caching and improve LLM prompt cache hit rates”. If your list order comes from a dict iteration or a database without an ORDER BY, you are quietly invalidating prompt caches on the client side and paying for it in tokens and latency.

Tracing. The revision documents OpenTelemetry trace context propagation through _meta, using the traceparent, tracestate, and baggage keys. That means an MCP call can join the same trace as the rest of your request path, which is what makes tool latency and failure attributable in the way Tracing and Observability for Agent Systems argues for. Propagate it.

Versioning and failure. Because every request carries its version, version negotiation is per-request rather than per-connection, and server/discover exists so a client can establish support up front. Plan for the two failure paths the revision creates: a version mismatch returns UnsupportedProtocolVersionError, and a broken SSE stream now loses the in-flight request, with no redelivery, so clients MUST re-issue with a new request ID. Any work your tool did before the stream broke has already happened. This is exactly where idempotency keys stop being theoretical.

For distribution, the ecosystem’s answer is the registry. Murag described it as a layer above existing distribution mechanisms like npm and PyPI, describing where servers live, which transport they use, who created them, whether they are verified, and how capabilities change across versions, with room for private registries and self-hosting (Building Agents with MCP, ~1:21:42). The registry is now a real repository, and the reference servers remain the best source of implementation patterns to read before writing your own.

FAQ

Do I need MCP if I already have function calling?

Not for a single application. MCP pays off when the same capability has to be reachable from several hosts, when you want to consume servers maintained by other people, or when you need what the protocol adds around a tool call: URI-addressed resources, user-invoked prompts, authorization discovery, and extensions for long-running work. If one app calls your own functions, plain function calling is less machinery for the same result.

What breaks when I upgrade to 2026-07-28?

Anything that depended on connection state. Session IDs, the initialize handshake, server-initiated requests such as sampling and elicitation mid-call, SSE resumption via Last-Event-ID, ping, and logging/setLevel are all gone. In their place: _meta on every request, server/discover, the input-required retry pattern, and explicit state handles passed as tool arguments. The changelog lists each change with its SEP number, and the spec keeps a backward-compatibility path for older clients.

Where does my server keep state now that sessions are gone?

Wherever you already keep durable state — a database, a cache — keyed by a handle you mint and hand back in a tool result. Treat the returned handle as untrusted input on the way back in: bind it server-side to the authenticated caller, generate it from a secure random source rather than a sequence, and consider expiring it. Possession of a handle is not authentication.

Is stdio or HTTP the right transport?

stdio for a server that runs on the user’s machine as a subprocess of the host, HTTP for anything remote or multi-tenant. Protocol semantics are identical, so this is an operational choice, not a capability one. Statelessness makes the HTTP path much easier to run: no sticky sessions, any instance can serve any request.

How do I stop a malicious server from steering my agent?

Treat server-supplied text as data, not instructions, and constrain what an approved call can do rather than trying to sanitise descriptions. Practically: pin versions and verify publishers before installing, prefer servers whose source you can read, isolate credentials and filesystem access, control network egress, and put the consequential actions behind a runtime permission check or a human approval. The protocol gives you the untrusted-by-default posture; the enforcement is yours.

Does MCP handle agent-to-agent authentication?

Not yet, by design. OAuth answers how a human obtains a token, and MCP is deliberately unprescriptive after that point. Inside a closed enterprise ecosystem you can pass a token bound to a workload identity, but the protocol’s own maintainers describe the case where client and server do not know each other as unsolved.

Further watching

Each of these was transcript-checked for this guide; timestamps link to the moment the claim appears.

Primary Sources