MCP Architecture Explained: Tools, Resources, Prompts, and How They Fit Together
The MCP spec is dense but well-designed. This post breaks down the architecture most developers skip: capability negotiation, the primitive lifecycle, server-initiated notifications, and how clients actually decide what to ask your server for.
The MCP spec is well-written but dense. Most developers read enough to register a tool and move on. That gets you a demo. Understanding the full architecture (capability negotiation, the primitive lifecycle, server-initiated notifications, and how clients actually use server metadata) makes the difference between a demo server and a production one.
I’ve built four MCP servers (scry, tome, lore, flume) and each one taught me something about parts of the protocol I’d initially glossed over. This post is the architecture walkthrough I needed before building the first one.
The initialization handshake
Every MCP session starts with a single request/response pair that determines everything about the session. The client sends an initialize request containing three things: the protocol version it wants to speak, its own name and version (for the server’s benefit), and its client capabilities.
The server responds with its own protocol version, its name and version, and its server capabilities. This is where the entire session is scoped.
Protocol version negotiation is strict. The client proposes a version. If the server supports it, it echoes it back. If not, the server responds with the latest version it does support, and the client decides whether to proceed or disconnect. In practice, the protocol hasn’t had a breaking version change that causes real-world mismatches yet, but the mechanism exists because it will eventually.
What matters more is the capabilities exchange. The client declares things like whether it supports roots (filesystem root URIs it’s willing to share) and sampling (whether the server can ask the client to generate completions). The server declares what primitives it offers: tools, resources, prompts, and logging.
After the server responds to initialize, the client sends an initialized notification (a notification, not a request), so no response is expected. This signals that the client has processed the server’s capabilities and the session is live. Only after this notification should either side send further messages.
The whole thing takes one round trip plus a notification. It’s fast by design.
Capability negotiation
The capabilities object is a contract. The client uses it to decide what methods to call. If your server’s capabilities don’t include tools, the client won’t call tools/list. If you don’t declare resources, the client won’t call resources/list. Your server might handle those methods perfectly, but no client will ever hit them.
This is the first mistake I made with scry. Early versions declared capabilities correctly, but I’ve seen other servers that register tool handlers without declaring the tools capability. Everything works in the MCP Inspector (which is forgiving) and nothing works in Claude Code (which isn’t).
The server capabilities object has specific sub-fields that matter:
tools: can optionally includelistChanged: trueto indicate the server will sendnotifications/tools/list_changedwhen tools are added or removed at runtime.resources: can includesubscribe: true(the server supports resource subscriptions) andlistChanged: true.prompts: can includelistChanged: true.logging: declares the server can emit log messages via notifications.
The listChanged flag is the one people miss. If you’re building a server whose tools can change during a session (maybe you load plugins dynamically, or tools are tied to workspace state), you need to declare listChanged: true and actually send the notification when things change. Otherwise clients will cache the initial tool list forever and never see your new tools.
The tool lifecycle
Tools are the primitive most developers interact with first. The lifecycle has three parts: listing, calling, and change notification.
tools/list returns an array of tool definitions. Each tool has a name, a description (which the LLM reads to decide when to use it), and an inputSchema, a JSON Schema object defining the parameters. The quality of your descriptions and schemas directly determines how well agents use your tools. This isn’t documentation for humans. It’s documentation for a language model that needs to decide, in real-time, whether your tool is the right one to call and what arguments to pass.
tools/call takes a tool name and an arguments object. The server executes the tool and returns a result containing a content array (text, images, or embedded resources) and an optional isError boolean. More on that in the error handling section.
Change notifications are the dynamic part. If you declared listChanged: true in your capabilities, you can send notifications/tools/list_changed at any time. The client receives this and re-fetches tools/list. This is how servers with dynamic tool sets work: lore, for example, could theoretically add tools when new git repositories are indexed, and the client would pick them up automatically.
How do clients cache tool lists? Aggressively. Claude Code fetches tools/list once after initialization and holds that list for the entire session unless it receives a list_changed notification. This means if your server adds tools without sending the notification, those tools are invisible for the rest of the session. The MCP Inspector refreshes more frequently, which is why things “work in Inspector but not in production.”
Resource architecture
Resources are the primitive most developers skip, and they’re the one that enables the most powerful patterns.
A resource is identified by a URI. The URI scheme is up to the server: you can use file://, postgres://, git://, or any custom scheme that makes sense for your domain. tome uses URIs like tome://schema/users and tome://relations/orders. The URI is an identifier, not a network locator.
Resources come in two forms: direct resources (returned by resources/list, a fixed set of known resources) and resource templates (URI templates with placeholders that the client can fill in). Templates use RFC 6570 syntax: git://repo/{path} or db://table/{name}/column/{col}. This lets you expose potentially infinite resources without listing them all upfront.
The real power is resource subscriptions. If the server declares subscribe: true in its resource capabilities, clients can call resources/subscribe with a resource URI. After that, the server sends notifications/resources/updated whenever that resource changes. The client then re-reads the resource to get the new content.
This enables patterns like: an agent subscribes to a database schema resource, and when you run a migration, the server detects the schema change and notifies the agent. The agent’s understanding of your database stays current without polling. Or a file-watching server that notifies when config files change. Or flume notifying when new HTTP traffic arrives that matches a pattern.
Most MCP servers today don’t implement subscriptions. That’s a missed opportunity. The infrastructure is there in the protocol. Clients support it. The pattern of “notify me when this changes” is natural for agents that maintain context over long sessions.
Prompt architecture
Prompts are the least-used primitive, but the design is elegant. A prompt is a parameterized template that expands into one or more messages. It’s how servers can package complex workflows into a single invocation.
prompts/list returns available prompts, each with a name, description, and list of arguments (name, description, required flag). prompts/get takes a prompt name and argument values, and returns an array of messages, each with a role and content.
The key insight is that prompts can compose with tools. A prompt template might expand into a system message that instructs the agent to use specific tools in a specific order. “Analyze this codebase” could expand into messages that tell the agent to first call the file-listing tool, then the dependency-graph tool, then the test-coverage tool, and synthesize the results. The prompt is a workflow starter.
Prompts can also include embedded resource references in their content, pointing the agent at specific resources to read as part of the workflow. This ties all three primitives together: a prompt kicks off a workflow, references resources for context, and implies tool usage for actions.
Multi-message prompts are particularly useful for establishing context. A single prompts/get call can return a system message setting up the task, a user message with the specific question, and even an assistant message priming the response format. This is more structured than dumping instructions into a tool description.
Server-initiated notifications
MCP is bidirectional. This is the architectural decision that separates it from a simple request/response protocol.
The key server-to-client notifications:
notifications/tools/list_changed: tools were added, removed, or modified, so the client should re-fetch.notifications/resources/list_changed: same for resources.notifications/resources/updated: a specific subscribed resource changed, so the client should re-read it.notifications/message: a log message from the server (with level, logger name, and data).
The logging notification is underappreciated. Long-running tool calls can stream progress back to the client via log notifications. When scry is indexing a large codebase, it can emit progress updates that the client surfaces to the user. Without this, long tool calls are a black box. The user sees nothing until the result comes back or the timeout hits.
For transport, notifications only work over transports that support server-initiated messages. Stdio works fine because the server writes to stdout whenever it wants. HTTP+SSE (Streamable HTTP) works because SSE is server-push by design. Pure HTTP request/response without SSE doesn’t support it, which is one reason the protocol has specific transport requirements.
The bidirectional nature also means servers need to be careful about concurrency. A notification can be sent while the server is in the middle of handling a request. Your server architecture needs to handle this, typically with a dedicated notification channel or queue that’s independent of request processing.
Error handling
MCP has two distinct error paths, and conflating them is the most common mistake I see in server implementations.
Protocol errors are JSON-RPC errors. They use the standard JSON-RPC error response format with a numeric code and message. These are for situations where the request itself is invalid: unknown method, malformed parameters, internal server crash. The standard codes apply: -32700 (parse error), -32600 (invalid request), -32601 (method not found), -32602 (invalid params), -32603 (internal error).
Tool errors are tool results where the tool ran but failed. A database query that returns a connection error, a file read that hits a permission issue, a search that finds nothing. These return a normal tools/call response with isError: true and the error description in the content array.
The distinction matters because clients handle them differently. A protocol error might trigger a retry or a reconnection. A tool error gets shown to the LLM, which can reason about it and try a different approach. If your tool throws an unhandled exception and your framework converts it to a JSON-RPC internal error, the agent loses the ability to reason about the failure. The error message vanishes into protocol-level handling instead of being surfaced as something the model can work with.
The rule is simple: if the tool ran and failed, return a result with isError: true. If the request was malformed or the server itself is broken, return a JSON-RPC error. In practice, almost every failure should be the first kind. Reserve JSON-RPC errors for things the model can’t fix by trying different arguments.
Putting it together
The architecture becomes clear when you see how the pieces compose. Initialization establishes the contract. Discovery tells the client what’s available. Tools provide actions. Resources provide data. Prompts provide workflows. Notifications keep everything synchronized over time.
If you’re building your first MCP server, the complete guide covers the end-to-end picture. For the three primitive types in depth, see the primitives breakdown. And if you’re building in Go specifically, the Go implementation guide covers the SDK patterns and production concerns.
The protocol is simple enough to implement in a weekend. The architecture is deep enough that you’ll keep finding useful patterns six months in. That’s good design.
Frequently Asked Questions
How does MCP capability negotiation work?
During the initialize handshake, both client and server declare what they support. The server lists its capabilities (tools, resources, prompts, logging) and the client lists its own (roots, sampling). The client then only requests primitives the server declared support for: if your server doesn't declare tool support in its capabilities object, the client will never call tools/list. This negotiation happens once at session start and determines what the entire session can do.
What is the MCP protocol flow from connection to tool call?
The flow is: transport connection, initialize request/response (capability negotiation), initialized notification, then discovery (tools/list, resources/list, prompts/list), then usage (tools/call, resources/read, prompts/get). The client drives the flow: it sends initialize, waits for the server's capabilities, then decides what to discover based on what the server declared.
How does MCP handle errors differently from REST APIs?
MCP distinguishes between protocol errors and tool errors. A protocol error (invalid JSON-RPC, unknown method, malformed params) returns a JSON-RPC error response. A tool error (the tool ran but failed) returns a normal tools/call result with isError set to true and the error message in the content array. Conflating these two is the most common mistake in MCP server implementations.
What are MCP server-initiated notifications and why do they matter?
MCP is bidirectional. Servers can push notifications to clients without being asked. The key notifications are notifications/tools/list_changed and notifications/resources/list_changed, which tell the client to re-fetch the tool or resource list because something changed. There's also logging notifications for streaming log output. This matters for long-running servers whose capabilities evolve at runtime.