All articles
System DesignAIProtocols14 min read

How MCP Works: The Model Context Protocol, Explained by Design

A systems-level breakdown of the Model Context Protocol — hosts, clients and servers, tools vs resources vs prompts, the JSON-RPC data layer, stdio and Streamable HTTP transports, the handshake, and the security model.

The problem MCP actually solves

A language model on its own is a text function. It has no filesystem, no database, no ticket tracker and no memory of your codebase. Everything useful an assistant does comes from context someone put in front of it, and from actions someone let it take.

Before the Model Context Protocol, every AI application wired those integrations by hand. A GitHub integration written for one editor did not work in another. If you had N clients and M tools, the ecosystem paid for N × M bespoke adapters. MCP, released by Anthropic in late 2024 and now maintained as an open specification, collapses that into N + M: each client implements the protocol once, each tool exposes a server once, and any client can talk to any server.

Before: N × M custom integrationsAfter: N + M protocol implementationsclientstoolsMCPspec
MCP is an integration-count argument before it is anything else.
The single most important fact
MCP is a specification, not a runtime. It does not execute your tools, host your servers, or decide how the model uses context. It only defines how the two sides talk. Everything else is your architecture.

Hosts, clients and servers

MCP uses a client–server architecture with three named roles. People conflate the first two constantly, and that confusion is the source of most bad diagrams.

RoleWhat it isResponsibilityExamples
HostThe AI application itselfOwns the model loop, the UI, user consent, and the lifecycle of every client it spawnsClaude Code, Claude Desktop, VS Code, your own agent
ClientA protocol connector inside the hostMaintains exactly one stateful connection to one server: handshake, requests, responses, errorsThe MCP client object VS Code instantiates per configured server
ServerA separate program exposing capabilitiesDeclares tools, resources and prompts; executes requests against a real systemFilesystem server, GitHub server, Sentry server, your internal Postgres server

The one-client-per-server rule matters. A host with three configured servers holds three independent, stateful sessions. One server crashing does not tear down the others, and each connection negotiates its own capabilities and its own protocol version.

MCP Host (AI application)Claude Code · VS Code · your agentMCP Client AMCP Client BMCP Client Cstdio · JSON-RPC 2.0Streamable HTTP · JSON-RPC 2.0Streamable HTTP · JSON-RPC 2.0Filesystem serverlocal subprocessGitHub serverremote servicePostgres serverremote servicediskREST APIdatabaseOne client per server. Connections are stateful and independent.
A host multiplexes several single-server clients; servers front real systems.

Two layers: data and transport

The spec splits cleanly into a data layer and a transport layer. The data layer defines what messages mean. The transport layer defines only how bytes are framed and delivered. Protocol semantics are identical on every transport, which is why a server written against the SDK can be moved from a local subprocess to a remote service without changing a single tool implementation.

Data layer — JSON-RPC 2.0initialize / capabilitiestools/list · tools/callresources/list · resources/readprompts/list · prompts/getTransport layer — message framing onlystdio — newline-delimited, local subprocessStreamable HTTP — POST + optional SSE stream
Semantics live in the data layer; transports are interchangeable bindings.

The data layer is JSON-RPC 2.0, UTF-8 encoded. Three message shapes exist: requests (carry an id, expect a response), responses (result or error), and notifications (fire-and-forget, no id). The direction rules are strict — clients send requests and notifications, servers send responses and notifications.

Tools vs resources vs prompts

Servers expose three kinds of capability. The distinction is not about data format — it is about who is in control, and that is the part interviewers probe.

PrimitiveControlled bySemanticsTypical example
ToolsThe modelCallable functions with a JSON Schema for inputs. May have side effects. The model decides when to invoke one.createIssue, searchFlights, runQuery
ResourcesThe applicationRead-only data addressed by URI, with a MIME type. The host decides what to expose and when to attach it.file:///repo/README.md, db://schema/public
PromptsThe userParameterised instruction templates the user explicitly picks, often wiring specific tools and resources together./review-pr, /summarise-incident

Each primitive has a symmetric pair of methods: tools/list and tools/call, resources/list and resources/read, prompts/list and prompts/get. Discovery first, then invocation. Servers that support dynamic capabilities emit a notifications/*_list_changed notification so the client can re-list instead of polling.

A tool definition is just a schema

{
  "name": "searchIssues",
  "title": "Search issues",
  "description": "Search the issue tracker for open issues matching a query.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "query":  { "type": "string", "description": "Full-text search string" },
      "state":  { "type": "string", "enum": ["open", "closed", "all"] },
      "limit":  { "type": "integer", "minimum": 1, "maximum": 50 }
    },
    "required": ["query"]
  }
}
What the model actually sees: a name, a natural-language description, and a typed input contract.
Descriptions are part of your API surface
The model routes on the description string, not on your code. A vague description is a functional bug: it produces wrong tool selection far more often than a wrong schema does. Write descriptions the way you would write a docstring for a junior engineer who cannot read the implementation.

The lifecycle of one MCP session

Every connection follows the same arc: initialise, discover, then operate. Nothing happens before the handshake completes.

UserHost + LLMMCP ClientMCP Serverinitialize (version, capabilities)result: capabilities + serverInfotools/listtool schemas (JSON Schema)“What changed in prod yesterday?”model picks searchIssues(...)tools/callresult content blocksgrounded answer + citationshandshakeruntime
Handshake once, discover once, then call tools for as long as the session lives.
  1. initialize. The client sends its supported protocol version and its own capabilities (sampling, elicitation, roots). The server replies with its version, its capabilities, and serverInfo. Version mismatch is negotiated here or the connection fails fast.
  2. initialized notification. The client confirms it is ready. Only now may normal traffic flow.
  3. Discovery. The client calls tools/list (and the resource and prompt equivalents), then hands the tool schemas to the model as available functions.
  4. Invocation. The model emits a tool call. The host applies its consent policy, the client sends tools/call, the server executes and returns content blocks — text, images, or structured data.
  5. Feedback loop. The result goes back into the model's context and the loop repeats until the model answers.
  6. Teardown. The client closes the stream or terminates the subprocess. In-flight work is abandoned via a cancellation signal.
--> {"jsonrpc":"2.0","id":7,"method":"tools/call",
     "params":{"name":"searchIssues","arguments":{"query":"timeout","state":"open"}}}

<-- {"jsonrpc":"2.0","id":7,
     "result":{"content":[{"type":"text","text":"3 open issues: #412, #418, #431"}],
               "isError":false}}
The wire is unremarkable — and that is the point.
Two kinds of failure, deliberately separated
A malformed request returns a JSON-RPC error object — the protocol failed. A tool that ran but could not do its job returns a normal result with isError: true — the model is supposed to see that text and adapt. Collapsing these two into one channel is the most common server-authoring mistake.

stdio vs Streamable HTTP

Two standard transport bindings exist. Choosing between them is a deployment decision, not a protocol one.

stdioStreamable HTTP
TopologyClient launches the server as a subprocessServer is a network service at one MCP endpoint
FramingNewline-delimited JSON on stdin/stdoutHTTP POST per message; reply is JSON or an SSE stream
Fan-outOne client per processMany clients, many sessions
AuthInherits the local user's OS permissionsOAuth 2.1 / bearer tokens, per-session identity
Cancellationnotifications/cancelledClose the request's response stream
Good forLocal files, git, editors, dev toolingSaaS integrations, shared internal servers

Streamable HTTP replaced the older HTTP+SSE pairing precisely because two endpoints made session resumption and load balancing awkward. With a single endpoint the server may answer a POST with a plain JSON body for a fast call, or upgrade that same request into an SSE stream when it needs to emit progress notifications before the final result. All protocol metadata still travels in the message body; HTTP headers only mirror it so proxies can route without parsing JSON.

A useful mental model
stdio is a Unix pipe with a schema. Streamable HTTP is an RPC endpoint with an optional server-push channel. The messages crossing them are byte-for-byte the same.

Writing a minimal MCP server

The SDKs hide the JSON-RPC plumbing entirely. A working server is mostly schema declaration plus the function you would have written anyway.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "issues", version: "1.0.0" });

server.registerTool(
  "searchIssues",
  {
    title: "Search issues",
    description: "Search the issue tracker for issues matching a full-text query.",
    inputSchema: { query: z.string(), state: z.enum(["open", "closed", "all"]).default("open") },
  },
  async ({ query, state }) => {
    const issues = await tracker.search(query, state);   // your existing code
    return { content: [{ type: "text", text: formatIssues(issues) }] };
  },
);

await server.connect(new StdioServerTransport());
A TypeScript server exposing one tool over stdio.

Design rules that separate a usable server from a demo:

  • Few, coarse tools beat many thin ones. Every tool schema consumes context tokens on every turn and widens the model's choice space. Twenty tools is already a lot.
  • Return tokens, not dumps. Paginate, truncate, and summarise. A tool that returns 200 KB of JSON will blow the context window and degrade the answer.
  • Make results self-describing. Include ids and URLs the model can cite or feed into the next call.
  • Keep tools idempotent where possible, and mark destructive ones clearly so the host can require confirmation.
  • Validate server-side. Schemas guide the model; they do not constrain it. Treat every argument as untrusted input.

Debug with the MCP Inspector, which connects to your server as a client and lets you list and call everything by hand before an LLM is anywhere near it.

Security and the trust boundary

MCP hands a language model the ability to execute code against your systems. The protocol deliberately pushes authorisation to the host and the server, which means the security work is yours.

  • Prompt injection is the headline risk. Tool output re-enters the model's context. A malicious issue body, web page or file comment can contain instructions the model then follows. Treat every tool result as untrusted data, never as instructions, and never let one server's output silently authorise another server's write.
  • Confused deputy. The server acts with its own credentials, not the user's. If your server holds an admin token, every user of that server is effectively an admin. Scope tokens per user or per session.
  • Consent belongs to the host. Destructive tool calls should surface a confirmation. Auto-approving everything is convenient and is how people delete production data.
  • Server supply chain. Installing a community MCP server is running someone else's code with your filesystem access. Pin versions and read the source for anything touching credentials.
  • Least privilege at the data source. A read-only database role for a query server costs nothing and removes an entire class of incident.

Where MCP struggles

  • Context economics. Tool definitions are always resident. Connect ten servers and a meaningful slice of the window is gone before the user types anything.
  • Discovery is not selection. The protocol tells the model what exists; it does nothing to help the model pick well among fifty similar tools. That remains a prompting and tool-design problem.
  • No built-in orchestration. Retries, sagas, partial failure across multiple servers — all yours to build.
  • Latency stacks. Each tool call is a full model round trip plus a network call. Multi-step agent flows feel slow, and streaming only masks part of it.
  • It is effort, not magic. The protocol describes how things should talk; it does not run anything. The value shows up only once an ecosystem of good servers exists.

How to discuss MCP in an interview

If you are asked to design an AI assistant that touches internal systems, MCP is a strong answer — but only if you frame it as a protocol choice with consequences.

  1. Open with the integration-count argument: N × M adapters become N + M implementations.
  2. Name the three participants precisely, and say one client per server, stateful.
  3. Split data layer from transport, and justify stdio for local tooling versus Streamable HTTP for shared services.
  4. Use the control axis for the primitives: tools are model-controlled, resources are application-controlled, prompts are user-controlled.
  5. Volunteer the failure modes — context bloat, prompt injection through tool output, the confused deputy — before the interviewer asks. That is the signal that you have run this in production, not just read about it.

Read the primary sources rather than summaries: the architecture overview, the specification, and the reference servers. The reference implementations are short, and reading three of them teaches you more about good tool design than any article can.

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free