I run several MCP servers in production and I've written both sides of the wire. The protocol took an afternoon to learn. The failures took considerably longer, and none of them are in the quickstart. So this is the protocol on one page, followed by the five things that broke for me after it was supposedly working.

MCP architecture: one client per server, and no session between them

What MCP actually is

MCP is a client-host-server protocol where one host runs many clients, and each client talks to exactly one server. The architecture page is blunt about the ratio: "each client having a 1:1 relationship with a particular server." Your host app (Claude Code, Cursor, your own agent loop) holds the model. Connect five servers and you've got five clients inside one host.

That rule explains API design that otherwise looks like an omission. There's no routing layer, no server registry, no addressing scheme on the wire. A message on a connection is unambiguously for that server, so nothing needs to say which server it means.

One more principle worth knowing: servers can't read the whole conversation or see into each other. Isolation between servers is the host's job, not the protocol's.

What changed in the current revision

The current revision, 2026-07-28, deleted the initialize handshake and made MCP stateless. The architecture page states it directly: "MCP is a stateless protocol: every request is self-contained and carries its own protocol version and capabilities." This was well covered when it landed, so treat it as context rather than news. It also silently invalidates a lot of older tutorials.

There's no session to open. Every request carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in its _meta field, and the server accepts or rejects that request on its own. Send an unsupported version and you get back error -32022 listing the versions the server does support, so you retry with one of those. If you'd rather ask up front, servers MUST implement server/discover; calling it is optional.

The spec calls the old world "legacy" (2025-11-25 and earlier) and the new one "modern". Mixed eras fail unless one side implements both, so if you're staring at a dead connection, check that first.

The second breaking change got much less attention: servers can no longer initiate JSON-RPC requests at all. The spec is flat about it, "servers do not initiate JSON-RPC requests and clients do not send JSON-RPC responses." Anything a server used to ask the client for, sampling, elicitation, roots, now comes back inside a reply as an InputRequiredResult with resultType: "input_required". The client answers by retrying the original call with inputResponses and the server's opaque requestState, using a different JSON-RPC id. If you wrote a server that calls back into the model, it's broken, and it probably fails quietly.

Two transports, and only two

The spec defines exactly two standard transports. stdio launches the server as a subprocess and exchanges newline-delimited JSON-RPC over its standard streams. Streamable HTTP POSTs each message to a single endpoint, and replies arrive either as a JSON object or a request-scoped SSE stream.

The older two-endpoint HTTP+SSE transport has been deprecated since 2025-03-26. If a tutorial has you opening a long-lived GET for events, that tutorial is old.

Which transport you get comes down to which key you wrote. This shape is host-tooling convention, not protocol spec:

{
        "mcpServers": {
          "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"] },
          "analytics":  { "type": "http", "url": "https://mcp.example.com/mcp" }
        }
      }
      

A command key means a subprocess on your machine. A url key means an HTTP request to someone else's. That difference matters more than it looks, because only one of them can read your environment variables.

What a tool definition actually is

A tool is a name, a description, and a JSON Schema for its arguments, plus optional extras like title, outputSchema, icons, and annotations. In Python it's a decorator, and the docstring becomes the description:

@mcp.tool()
      def conversations_list(platform: Optional[str] = None, limit: int = 50) -> str:
          """List active messaging conversations across connected platforms.

          Args:
              platform: Filter by platform name (telegram, discord, slack, etc.)
              limit: Maximum number of conversations to return (default 50)
          """
      

Here's what took me too long to internalize: that description is prompt text. It isn't documentation for a human reading your repo. It's serialized into the model's context and it's the only basis the model has for picking your tool over another. A vague description is a tool the model never calls. A description carrying Example: "amoxicillin, ibuprofen" is a tool it calls correctly the first time.

The round trip of one tool call

The model picks a tool, the client sends tools/call, the server runs your code, and the result goes back into context. Two details in that last step surprise people.

A tool result carries both content and structuredContent. The first is what the model reads. The second is server-produced JSON for your code, validated against outputSchema if you defined one. The spec says a tool returning structured content SHOULD also serialize it into a text block. For a human-facing agent I usually don't, because paying context tokens for JSON syntax the model doesn't need adds up.

Errors come in two flavours that behave nothing alike. A malformed request or unknown tool is a protocol error, a normal JSON-RPC error. A failure inside your tool is a tool execution error, returned as a successful result with isError: true. The spec is explicit that clients SHOULD hand those to the model so it can self-correct, which means your error strings are prompt text too. "Invalid input" teaches the model nothing. "drug_names must be comma-separated, you sent an array" gets a correct retry.

The parts that only break in production

Five things broke for me after MCP was supposedly working: colliding tool names, schemas that aren't portable across model providers, a 2xx from something that isn't an MCP server, cached tokens that lie about their own expiry, and state that no longer survives between calls. Everything above works on the first try in a demo. These cost me real days.

One tool call, end to end, and where it actually breaks

Tool names aren't namespaced. Uniqueness is scoped to a single server, so two servers can both export search. The spec tells aggregating clients to "implement a disambiguation strategy such as prefixing tool names with a server identifier", and warns the server's own name isn't guaranteed unique either. Mine registers everything as mcp_{server}_{tool}.

JSON Schema isn't portable across model providers. One schema from one server doesn't just work everywhere. Some providers reject #/definitions/... and need #/$defs/.... Some return a 400 when required names a property missing from properties. Some reject nullable anyOf unions in tool inputs. My client runs a normalization pass over every incoming schema, and that pass exists entirely because of provider-specific rejections.

A 2xx doesn't mean you reached an MCP server. Point a url at a normal web app and it answers HTML with a 200. The SDK then waits out the whole connect timeout before surfacing an opaque cancellation. A content-type preflight turns a 60 second mystery into a one second error, so check for application/json or text/event-stream first.

Cached tokens lie after a restart. Load a token from disk and its expiry can come back unset, which reads as valid no matter how old it is. You ship a stale token, and the failure isn't always a clean 401. One provider of mine returned 200 with an application-level auth error in the body, invisible to the transport layer and indistinguishable from an empty result set.

Statelessness is now your problem. Since there's no session, a server can't relate one call to the next. Anything spanning calls, a cart, a browser context, a transaction, needs an explicit handle returned by one tool and passed as an argument to the next. The spec's design guidance is non-normative here but worth obeying: "a handle is a name, not a capability." Validate authorization against it on every call, or anyone who guesses a handle inherits that state.

Tool descriptions are attack surface

A server you didn't write controls text that lands in your model's context, and the model is expected to act on it. The spec's warning is unambiguous: clients MUST consider tool annotations untrusted unless they come from trusted servers.

My client scans incoming descriptions for the obvious shapes:

_MCP_INJECTION_PATTERNS = [
          r"ignore\s+(all\s+)?previous\s+instructions",
          r"you\s+are\s+now\s+a",
          r"<\s*system\s*>",
          r"do\s+not\s+(tell|reveal)",
      ]
      # WARNING-level only: we log but do not block, since false
      # positives would break legitimate MCP servers.
      

That comment is the honest state of the art. We log, we don't block, because blocking on a regex breaks real servers. Detection isn't a solution here.

What works is narrowing reach. A stdio server is a subprocess that inherits your environment by default, so every API key you hold is one os.environ away from someone else's code. I pass an explicit allowlist instead (PATH, HOME, USER, LANG, the XDG_* vars) and nothing more. I also strip credential-shaped patterns out of error text, since an error string is an easy way to leak a token into a transcript you later paste in public.

The takeaway

MCP is small: JSON-RPC, per-request metadata, and a list of tools whose descriptions are prompts. Most of what goes wrong comes from treating it as bigger and more magical than that.

Write tool descriptions as if the model is your only reader, because it is. Namespace your tool names yourself, since nothing else will. And treat every description from a server you didn't write as untrusted text that's about to enter your model's context, because that's exactly what it is.


I write these from real work at astraedus.dev, where I build apps and tools. Building something, or stuck on something like this? Reach me at [email protected].

Get the next one in your inbox → subscribe at astraedus.dev.