MCP Architecture Explained: The Full Request Path | MewCP | MewCP
Inside an MCP Architecture: What Actually Happens Between Your Agent and a Tool
by Rohit Gite, Founder CTO @MewCP··16 min read
Most MCP diagrams draw two boxes. There are four participants, and the one people skip explains the transports, the fan-out and half the confusion. Here is the whole path.
Ask ten engineers to sketch an MCP architecture and nine of them draw two boxes: the AI app on the left, the MCP server on the right, one arrow in between. That picture is not wrong so much as incomplete, and the box it leaves out is the one that explains connection lifecycle, transports, tool name collisions and why your server has no idea who is talking to it.
There are four participants. Each owns exactly one job. Once you can name all four and say what each one is responsible for, the rest of the protocol stops feeling arbitrary.
This post covers the full request path, the two layers the protocol is built from, the three server primitives and the control model that separates them, and a working server you can run. Authentication and deployment are deliberately out of scope here. They are large enough to deserve their own treatment, and they come later in the series.
The four participants in an MCP architecture
The spec calls this a client-host-server architecture. In practice you are looking at four things, because the external service on the far end matters to your design even though the protocol says nothing about it.
The host is the AI application. Claude Desktop, an IDE, your own agent runtime. It owns the model, the conversation and every decision about what the model is allowed to see. It creates client instances, controls their connection permissions and lifecycle, enforces consent, and aggregates context across all of them.
The client is a protocol component inside the host. This is the missing box. The spec is blunt about the relationship: each client is created by the host and communicates with exactly one server, and a host application manages multiple clients, with each client having a 1:1 relationship with a particular server. Three servers means three clients, three connections, three independent capability sets.
The server is a program that exposes one focused domain of capability. Filesystem access. Your issue tracker. A payments API. It receives JSON-RPC, does ordinary backend work, and answers.
The external service is whatever the server actually talks to. A REST API, a database, a SaaS product, the local disk. The protocol has nothing to say about this hop, which is exactly right, because that hop is your normal engineering.
Three design principles fall out of that arrangement, and they are load-bearing:
Servers are meant to be easy to build. The host carries the orchestration complexity so a server can be a few functions.
Servers are meant to compose. Each one is focused and isolated, and combining them is the host's job.
Servers should not be able to read the whole conversation, nor see into other servers. Full history stays with the host, and cross-server interaction is mediated, never direct.
That third one is why your server gets a tool call and nothing else. No chat history, no user profile, no sight of the other five servers on the same host. If your design assumes context it was not handed, the architecture will not give it to you.
Two layers: data and transport
MCP splits into a data layer and a transport layer, and holding those apart is what lets the same server code run as a local subprocess today and a network service later.
The data layer is JSON-RPC 2.0. It defines discovery, the server primitives, the result envelope, notifications and the exact shape of every message. All the semantics live here.
The transport layer defines how those bytes move and how the caller is authenticated. Two standard transports: stdio, where the host launches the server as a subprocess and speaks newline-delimited JSON over stdin and stdout, and Streamable HTTP, where the server exposes one HTTP endpoint that clients POST to.
Your tool functions never learn which transport carried the call. That independence is the entire point of the split, and it is why "should this be local or remote" is a deployment question rather than an architecture question.
One thing worth internalizing before we trace a request: the current revision of the protocol, 2026-07-28, is stateless. There is no initialize handshake and no session identifier. Every request is self-contained and carries its own protocol version, client identity and client capabilities in _meta. Servers must implement server/discover, which returns supported versions, capabilities and identity in a single round trip, but a client is free to skip it and call any method inline.
One request, traced end to end
Here is what happens when a user types "summarize the open bugs in checkout" into an agent that has an issue tracker server connected.
1. The user asks. The host owns the conversation. Nothing has crossed the protocol yet.
2. The client already knows the tool surface. At some point after connecting, the client sent tools/list. The result is not a list of names, it is a list of contracts:
{ "jsonrpc": "2.0", "id": 1, "result": { "resultType": "complete", "tools": [ { "name": "search_issues", "title": "Search issues", "description": "Search the issue tracker by text, state and area.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "Free text to match in title or body." }, "state": { "type": "string", "enum": ["open", "closed", "all"], "default": "open" }, "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 } }, "required": ["query"] } } ], "ttlMs": 300000, "cacheScope": "public" }}
inputSchema is plain JSON Schema 2020-12, the same dialect the major tool-calling APIs already consume, so the client can hand it to a model with almost no translation. ttlMs and cacheScope tell the client how long that list stays fresh and whether a shared intermediary may cache it.
3. The host assembles the model's tool surface. A host job, not a server job. It collects tools from every connected client and decides which ones go into this turn. Tool names are only unique within one server, so a host or proxy aggregating several servers needs a disambiguation strategy, usually a server prefix.
4. The model picks. It reads names, titles, descriptions and schemas and emits a tool call. Everything it knows about your server at this moment came from strings you wrote.
5. The host routes to the right client. Because each client owns exactly one connection, routing is just picking the client that advertised that tool.
6. The client sends tools/call. Every request carries its metadata:
On Streamable HTTP this arrives as one HTTP POST, with Mcp-Method: tools/call and Mcp-Name: search_issues mirrored into headers so gateways can route and meter without parsing the body. Headers that disagree with the body are a hard error, -32020.
7. The server does the actual work. It validates arguments, checks whatever authorization applies, calls the tracker API, and formats a result. This is the hop where all your existing engineering lives.
8. The result comes back and the host decides what to do with it.
Note where isError sits. Inside a successful JSON-RPC result. That split is deliberate and it is the single most useful thing to get right in a server. Protocol errors are JSON-RPC error responses for malformed or unroutable requests, and clients may show them to the model but recovery is unlikely. Tool execution errors are normal results with isError: true and a readable message, and clients should hand those to the model precisely so it can self-correct. Return "issue not found" as a -32603 and you have hidden the only actionable information from the only participant that could act on it.
The server never sees step 1, step 3, step 4 or step 8. It sees step 6 and nothing else.
Tools, resources and prompts: three primitives, three owners
Servers expose three things. They are not three formats for the same idea. They are separated by who is allowed to pull the trigger.
Primitive
Methods
Controlled by
Reach for it when
Tools
tools/list, tools/call
The model
The agent should decide at runtime whether to do the thing
Context should be attached, not fetched by a decision
Prompts
prompts/list, prompts/get
The user
A person deliberately picks a workflow
Tools are model-controlled
The model discovers and invokes them automatically based on context. A tool definition carries name, an optional title for display, a description, an inputSchema, an optional outputSchema, and optional annotations describing behaviour such as whether the tool is read-only or destructive. Those annotations are hints, not guarantees. Clients must treat them as untrusted unless the server is trusted, so they help a host build a sensible confirmation UI and enforce nothing.
Two rules about the tool set itself catch people out.
It must not vary per connection or as a side effect of another request. It may vary by the authorization presented on the request, since credentials are per-request input rather than connection state. Giving a read-only caller a smaller surface is supported. Giving caller A a different surface because of something A did earlier on the same socket is not.
Servers should also return tools in a deterministic order. Stable ordering lets clients cache the list and improves prompt cache hit rates when tools are serialized into model context, which becomes a real cost line once your surface is more than a handful.
Resources are application-controlled
A resource is data identified by a URI, read with no side effects. file:///project/README.md, repo://checkout/labels, calendar://events/2026. The host decides how to surface them: a tree view, a search box, automatic inclusion by heuristic, or the model's own selection inside the host's UI.
There are two discovery patterns and the second one is underused. Direct resources have fixed URIs and appear in resources/list. Resource templates are parameterized URIs following RFC 6570 and appear in resources/templates/list, so repo://{repo}/labels covers every repository without you enumerating them. Template arguments support completion, which is how a client suggests values while a user types.
A read returns a contents array, because one URI may legitimately expand to several pieces of content, such as a directory. Each entry carries a uri, a mimeType, and either text or a base64 blob. A resource that does not exist is an error, specifically -32602. Returning an empty contents array is not allowed, because it is ambiguous between "no content" and "no such thing".
Resources can also carry annotations: an intended audience of user or assistant, a priority from 0 to 1, and a lastModified timestamp. Hosts use these to decide what to pull into context and in what order, so leaving them unset means leaving the host to guess. And keep reads cheap. The host may pull a resource on every turn.
Prompts are user-controlled
Prompts are parameterized templates the user explicitly invokes, which is why almost every host renders them as slash commands. The spec is careful about the wording: user-controlled refers to who decides when the prompt is used, not who authors its content. The server writes the content. The user picks the moment.
prompts/get returns a messages array, not a string. Each message has a role of user or assistant and a content block that can be text, an image, a resource link, or an embedded resource. That means a prompt can seed a multi-turn opening and pull server-side documents into the conversation in one move, which is a much stronger tool than most server authors use it as.
The decision rule
Three questions, in this order:
Does something need to happen, with a side effect or a live query? That is a tool.
Is it data the application should be able to attach without the model deciding? That is a resource.
Is it a workflow a human would deliberately start? That is a prompt.
The common failure is shipping read-only lookups as tools by default. It works, so nobody notices. The cost shows up as an agent that burns a turn and a round trip fetching a schema the host could have attached for free, on every conversation, forever.
A server that exposes all three
Install the SDK. Version 2.x supports 2026-07-28 and every earlier revision.
uv add "mcp[cli]" # or: pip install "mcp[cli]"
Now server.py. One tool, one templated resource, one prompt. This runs as written.
from typing import Annotated, Literalfrom pydantic import Fieldfrom mcp.server import MCPServerfrom mcp.server.mcpserver.exceptions import ToolErrorfrom mcp.server.mcpserver.prompts.base import Message, UserMessagefrom mcp.types import ToolAnnotationsmcp = MCPServer( "Tracker", instructions="Search issues before summarizing anything about a bug.",)ISSUES = [ {"id": 812, "title": "card declined on retry", "area": "checkout", "state": "open", "priority": "P1"}, {"id": 819, "title": "promo code strips whitespace", "area": "checkout", "state": "open", "priority": "P3"}, {"id": 764, "title": "receipt email missing tax line", "area": "billing", "state": "closed", "priority": "P2"},]LABELS = { "checkout": ["payments", "cart", "regression"], "billing": ["invoices", "tax"],}@mcp.tool( title="Search issues", annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False),)def search_issues( query: Annotated[str, Field(description="Free text matched against issue titles. Case insensitive.")], state: Literal["open", "closed", "all"] = "open", limit: Annotated[int, Field(ge=1, le=50, description="Maximum issues to return.")] = 10,) -> str: """Search the issue tracker by text and state. Use this before answering anything about bugs, counts or priorities. Do not use it to look up which labels exist for an area, read the repo://{area}/labels resource for that. Returns one line per issue. """ needle = query.lower() hits = [ issue for issue in ISSUES if needle in issue["title"].lower() or needle in issue["area"].lower() if state == "all" or issue["state"] == state ] if not hits: raise ToolError( f"No issues match {query!r} with state={state!r}. " "Try a shorter query, or state='all'." ) label = "" if state == "all" else f"{state} " shown = hits[:limit] lines = [f"{len(shown)} of {len(hits)} {label}issues matching {query!r}:"] lines += [f"- #{i['id']} {i['title']} ({i['priority']}, {i['area']})" for i in shown] return "\n".join(lines)@mcp.resource("repo://{area}/labels", mime_type="application/json")def area_labels(area: str) -> dict[str, str | list[str]]: """Labels currently in use for one area of the codebase.""" return {"area": area, "labels": LABELS.get(area, [])}@mcp.prompt(title="Triage open bugs")def triage_bugs( area: Annotated[str, Field(description="Area of the product, for example checkout.")],) -> list[Message]: """Open a structured triage session for one area.""" return [ UserMessage( f"Triage the open bugs in {area}. " "Search the tracker first, then group by priority, " "then propose one owner per group and flag anything that looks like a regression." ), ]if __name__ == "__main__": mcp.run()
Three things to notice.
There is no JSON Schema in that file. The type hints are the schema. Annotated[str, Field(description=...)] becomes a described property, Literal[...] becomes an enum, ge and le become minimum and maximum, and anything with a default stays out of required.
repo://{area}/labels is a template, so it shows up under resources/templates/list as a pattern rather than under resources/list as a concrete URI. The placeholder name has to match the function parameter exactly or the SDK raises at import time, which is the right moment to find out.
The prompt returns messages, not a string. Returning a plain string works and becomes a single user message, but the list form is what lets you seed a real opening.
Run it in the Inspector and call all three by hand:
uv run mcp dev server.py
Or serve it over HTTP:
uv run mcp run server.py --transport streamable-http
Nothing in the file changed between those two commands. That is the data layer and transport layer split doing its job.
What the client can offer back
The relationship is not one-directional. Clients can expose features to servers, and in the current revision they arrive through a single mechanism called Multi Round-Trip Requests.
Elicitation lets a server ask the user for information mid-request. Instead of holding a stream open, the server answers tools/call with a result whose resultType is input_required:
The client collects the answer, then retries the original request with inputResponses and the same requestState, using a new JSON-RPC id. Because everything needed to resume travels in that state blob, the retry can land on a completely different server instance. Elicitation has two modes: form mode for structured data, and URL mode for sensitive flows, where the interaction happens out of band and the data never passes through the client. Servers must not ask for passwords, API keys or payment details in form mode.
Roots, sampling and logging are deprecated as of 2026-07-28. They keep working for at least twelve months under the deprecation policy, but do not build new work on them. The suggested replacements are concrete: pass directories through tool parameters or resource URIs instead of roots, call an LLM provider API directly instead of sampling, and write to stderr or OpenTelemetry instead of protocol logging.
Six ways this architecture breaks in production
1. Tool list bloat from fan-out. One client per server means the host is the aggregation point. Connect six servers with fifteen tools each and the model is choosing from ninety definitions before the user has said anything. Selection accuracy drops, cost rises, latency rises. Fix: treat tool exposure as a per-task decision at the host or gateway layer, not a property of what happens to be connected. Design your own server so its whole surface can be read out loud in under thirty seconds.
2. Confusing resources with tools. Everything becomes a tool because tools are the path of least resistance. Fix: apply the three questions. If the host could attach it without the model deciding, it is a resource, and making it one removes a round trip from every conversation that needs it.
3. Assuming a persistent session. There is no initialize, no session id, and any instance may serve any request. State stored against a connection is state you will lose. Fix: return an explicit handle from the creating tool and take it back as an ordinary argument. Validate the caller's authorization against that handle on every call rather than treating it as a capability, keep it opaque, give it a bounded lifetime, state that lifetime in the tool description so the model can see it, and return expiry as a tool error the model can recover from.
4. Treating notifications as guaranteed. A server cannot push a tool list change to a client that has not asked for one. Notifications flow on a subscriptions/listen stream the client opens with an explicit filter, and the server must not send types the client did not request. The server acknowledges with the subset it will actually honour, so what you asked for and what you get can differ. On stdio, a client that reconnects has to send subscriptions/listen again, because no subscription state survives the reconnect. Fix: check the acknowledgment against your request, and build refresh on the client side rather than assuming a push will arrive.
5. Ignoring the fan-out when you name things. Tool name uniqueness is scoped to a single server, so two servers both exposing search is a normal Tuesday for a host. Fix: name for intent and domain, search_issues rather than search, and do not rely on the server's self-reported name for disambiguation.
6. Letting the transport choose the architecture. Teams start on stdio because it is easy, wire local paths and environment variables through their tool logic, then find that moving to a hosted service is a rewrite rather than a config change. Fix: keep the data layer clean. No transport assumptions in tool functions, no credentials resolved at import time, no state that only makes sense for a single-user subprocess.
Before you ship a server
Every tool description says what it does, when to use it, when not to, and what comes back
Read-only lookups that the host could attach are resources, not tools
Templated resources are actually templates, so the host does not need one entry per object
Prompts return messages, and the arguments are described well enough to render a form
Tool ordering is deterministic and cacheScope is honest about whether the list varies by authorization
Nothing varies per connection, and any workflow state is an explicit handle
Recoverable failures raise a tool error with a message written for the model, not a raw exception
Missing resources return -32602, never an empty contents array
The server behaves identically over stdio and Streamable HTTP
You have opened it in the Inspector and called every tool, read every resource and rendered every prompt by hand
Where this leaves you
An MCP server is not an API wrapper with a new coat of paint. It is a capability boundary, and the three primitives are how you draw it: what the model may call, what the app may read, what the user may run. Getting that split right is most of the design work, and it is the part no framework does for you.
What the protocol does not hand you is the operational middle. Running servers so they stay up for callers you do not control, keeping per-user credentials separate so tenant A never acts as tenant B, and putting a gateway in front so an agent sees the eight tools that matter instead of ninety. That middle is what we work on at MewCP. The architecture above stays the same either way.
Next in the series: authentication, and where the credential actually lives in that four-box picture.