Chapter 2: Architecture Essentials
Most design mistakes happen because teams misplace the boundary. They treat the MCP server as just another API wrapper, or they treat it as the entire product. It is neither. It is a capability layer between a host application and systems that perform useful work.
The rule for this chapter: every guarantee the model depends on has exactly one owner, and it is written down in the schema, the description or the server’s validation, never in the model’s memory.
Five Actors, Four Owners
Every term in this chapter is defined, alphabetically, in the Glossary.
Keep five actors separate: the user, who has intent, permissions and risk tolerance; the host application, which owns the conversation; the MCP client inside the host; the MCP server; and the downstream systems behind it. In a healthy design, four of them own something distinct:
- The host owns approval UX and session context.
- The client owns MCP transport and protocol compliance.
- The server owns capability design, validation, and integration logic.
- Downstream systems own the real side effects and data authority.
Confusing them produces two classic bugs: the server assumes the host will ask the user before a destructive call when it won’t, and the host assumes the server enforced tenant boundaries when it didn’t.
The server I run for Atelier customers settles the second bug on the server side. The host never says which site to touch; the server derives it from the token. The Clerk access token yields an organization claim, the organization maps to a customer row, the row names a repository slug, the slug names one directory, and a policy decides which paths inside it are editable. The host still owns the approval prompt; each tool’s readOnlyHint and destructiveHint annotations tell it when to ask.
Local and Remote Are Different Products
Treat local and remote MCP servers as different deployment shapes with different user expectations.
| Shape | Best for | Strengths | Main risks | Who launches and restarts it | Where auth happens |
|---|---|---|---|---|---|
| Local via stdio | Desktop tools, coding workflows, local files, private machine context | Fast startup, low latency, simple trust boundary, no network transport to harden | Process management, path handling, desktop-specific debugging | The host, as a child process; it dies with the host | The OS user account; secrets come from the environment the host passes in |
| Remote via HTTP | Shared services, SaaS APIs, team tools, centrally managed integrations | Centralized auth, multi-user support, easier rollout and monitoring | Network security, latency, availability, cross-tenant mistakes | You: a process manager, a container platform or a service unit | OAuth on every request; the server validates the bearer token (Chapter 19) |
The wrong instinct is to start remote because it feels more “real.” Start local: the trust boundary is small and the debug loop short. Go remote when the value clearly comes from centralization, shared access, or managed policy.
Transport Choices
For local servers, stdio is the default for a reason. It is simple, direct, and easy for desktop hosts to launch. The most important operational rule is equally simple: do not write protocol noise to stdout. Use stderr or a logging sink for diagnostics.
For remote servers, modern MCP uses Streamable HTTP rather than the older HTTP plus SSE pattern. If you need legacy compatibility, you may still encounter SSE in the wild, but it should feel like compatibility baggage, not the design center.
JSON-RPC Matters More Than It Looks
MCP is JSON-RPC: named operations with typed arguments, not endpoints to browse. Here is one exchange with the book’s tickets server. The client lists tools, the model calls search_tickets with values outside the schema, and the server answers with a tool error.
// → client
{"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}}}}
// ← server
{"jsonrpc": "2.0", "id": 1, "result": {"resultType": "complete", "tools": [{
"name": "search_tickets",
"description": "Search support tickets by keyword. Filters: status (open|closed|pending). Returns id, title, status and age in days.",
"inputSchema": {"type": "object", "required": ["query"], "properties": {
"query": {"type": "string"},
"status": {"type": "string", "enum": ["open", "closed", "pending"]},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}}}}]}}
// → client (every request carries the same _meta; omitted from here on)
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "search_tickets",
"arguments": {"query": "login timeout", "status": "urgent", "limit": 500}}}
// ← server
{"jsonrpc": "2.0", "id": 2, "result": {"resultType": "complete", "isError": true,
"content": [{"type": "text", "text":
"2 problems: status 'urgent' is not a status; use open, closed or pending (for urgency, search by keyword). limit 500 is above the maximum of 100; use 100 and page with a narrower query."}]}}
Three design decisions sit in that trace. The error is a result with isError: true, not a JSON-RPC error, because the call was well formed and the model can fix it; clients are told to pass tool errors to the model, not protocol errors. The text lists every problem, so one retry fixes both. And each problem names the valid values, so the retry is a correction rather than a guess. A bare 400 Bad Request would be correct and useless. Note too that search_tickets is one tool shaped around an intent. Expose your REST API one tool per endpoint and the model gets a catalog it can’t route through.
Who Decides: Tools, Resources, Prompts
The three primitives differ less in what they carry than in who decides to use them: the model picks tools, the host application picks resources, the user picks prompts.
- Tools are model-controlled actions: write a record, call an API, run a query, send a message, compute a result. Use a tool when the model should decide whether to invoke the operation.
- Resources are readable artifacts identified by URIs: files, reference documents, schemas, logs, snapshots of state. The host decides what to attach as context. If the model must fetch something on its own, give it a read tool as well.
- Prompts are reusable instruction templates the user invokes, typically as a slash command or menu item. Use a prompt when a person should be able to start a workflow with consistent framing, not when the model should.
A common beginner mistake is trying to turn everything into a tool. That produces noisy approvals, unnecessary latency, and clumsy interfaces. Another is exposing everything as resources and then wondering why nothing actionable happens.
How Models Actually Use Your Server
Models infer intent from names, descriptions, schemas, resource titles and prior context, not from your team’s understanding. So:
- Naming is interface design.
- Descriptions are routing hints.
- Flat, concrete parameter schemas outperform abstract elegance.
- Error messages should teach recovery, not just record failure.
The model’s memory of prior turns is incomplete. If your server requires the model to remember hidden invariants, you have already lost. Put the invariants in the schema, descriptions, validation, and resource structure.
My Atelier server’s policy check works that way. When an assistant tries to edit a template, the refusal names the rule: “safe tier may only edit: content/, static/ (got ‘templates/’)”. The model never has to remember which folders are editable.
Build on the Stable Core
Build on tools, resources, prompts and structured output. Treat roots and sampling as deprecated as of 2026-07-28, and don’t start new designs on them. Use the tasks extension (io.modelcontextprotocol/tasks) only for work that genuinely outlives one request, and only if your target clients support it (Chapter 10). A server that depends on a feature no deployed client honors is broken, however elegant. Appendix A tabulates every feature, its status and what negotiates it.
Versioning and Compatibility
Your server needs two kinds of compatibility discipline:
- Protocol discipline: know which transport and features your target clients support.
- Product discipline: change tool names, parameters, and descriptions carefully because they form part of your interface contract.
Once a server is in use, interface churn becomes operational churn.
For the protocol side, Appendix A lists every change from 2025-11-25 to 2026-07-28 as a checklist.
Failure Modes
- Protocol noise on stdout. A stray
printcorrupts the stdio stream and the host drops the server. - Hidden invariants. The model is expected to remember an ordering, a format or a forbidden value that nothing in the schema or description states.
- One tool per REST endpoint. The catalog mirrors the API, and the model can’t route between forty near-identical tools.
- Depending on a feature no target client honors. The server works in the Inspector and nowhere else.
Implementation Checklist
- Each of the four owners (host, client, server, downstream) is named for approval, validation, tenancy and side effects.
- Every tool declares
readOnlyHintanddestructiveHint, so the host knows when to ask. - Expected failures return
isError: truewith every problem and the valid values listed. - Every primitive is chosen by who should decide to use it: model, host or user.
- Tool names and parameters are treated as a contract; changes go through a dated deprecation.
What Comes Next
Every guarantee now has an owner and a place in the contract. All of which assumes you should be building a server at all. Chapter 3 is the test.