Model Context Protocol System Design: The Tool Boundary

MCP is not a framework you adopt. It is a boundary you design against, and treating it that way is useful preparation for system-design interviews at AI companies. The protocol fixes the primitives (tools, resources, prompt templates), the message format (JSON-RPC), and the transports (a stdio subprocess or Streamable HTTP). It deliberately leaves the hard decisions to you: where state lives, what the model is allowed to see and call, how a call gets authorized, and how many choices you put in front of a non-deterministic caller. Those are system-design questions in the ordinary sense.
The running example below is an internal MCP server that fronts code search and a ticketing system for an engineering-assistant agent.
The integration problem MCP solves
Without a common protocol, every agent framework that wants a data source may need its own connector. With M frameworks and N sources, that is up to M times N pieces of glue, each with its own auth handling, error shape, and schema drift. Expose each source once behind a shared interface and the connector count can approach M plus N. This is the same argument that justifies any protocol standard. The source-specific work does not disappear; permissions and semantics still need mapping at each boundary.
The obvious counterposition: why not just wrap the existing REST API? MCP does not replace it. Your code-search and ticketing server can call REST or gRPC behind the boundary. What the protocol adds is a discovery contract (tools/list, resources/list, prompts/list) that any compliant host can read, a fixed shape for describing operations to a model, and an optional authorization framework for HTTP deployments. If exactly one agent will ever call your API and you control both ends, a direct integration is fine. The protocol pays off when you do not control the host, or the number of hosts will grow.
Host, client, and server are the real boundary
MCP has three roles. The host is the application running the model (an IDE, a chat product, an agent runtime). It creates one client per server connection, and each client speaks to exactly one server. That one-to-one link organizes communication, not blanket permission. The server authorizes each request for the authenticated principal, requested operation, and target resource.
Transport affects the failure and isolation boundary. Over stdio the host launches the server as a subprocess and speaks JSON-RPC over stdin and stdout, with access determined by the launch credentials and sandbox. Over Streamable HTTP the server is a network service with its own deployment, scaling, and identity. The code-search-and-ticketing server belongs on HTTP: it serves many engineers, it needs its own credentials to the ticketing backend, and you want to scale and deploy it independently of any IDE.
Tools, resources, and prompt templates are different surfaces
A server can expose three kinds of things, and the specification assigns each a different controller. Tools are model-controlled: the model selects them based on the conversation, within host-mediated execution. Model control describes selection, not permission to execute. Resources are application-driven: the host decides which to fetch and place in context. Prompt templates are user-controlled: a person picks one explicitly, typically through a slash command or menu.
In the running example, search_code and create_ticket are tools. The contributing guide and on-call runbook are resources, addressable by URI and read-only. A "triage this stack trace" template that fills in the trace and the relevant runbook is a prompt.
The distinction helps the host apply policy, but does not enforce it. Resources are the read-oriented surface; tools may read or write. Keep operations such as creating a ticket as tools that the host can gate. Read-only tools such as search_code are valid and may carry readOnlyHint. Annotations are hints, not enforced guarantees, so the host must treat them as untrusted unless the server itself is trusted; the server still enforces its own rules.
What statelessness, routing, and caching change for deployment
For deployments using optional sessions in the earlier Streamable HTTP transport, the presence of a session identifier does not itself require sticky routing or a shared session store. Those become considerations when requests depend on instance-local session state: routing must reach that state, or the state must be accessible elsewhere.
The claimed July 2026 changes to the stateless core, routing headers, and caching are unverified here. Their deployment benefits should be treated conditionally, not as guarantees of a released MCP revision.
Supplied references, whose contents and claimed behavior have not been verified here: the tool-annotation post, July revision post, authorization security considerations, and revision changelog.
Removing protocol sessions would not remove application state. If a tool returns a handle for later calls, that handle must resolve to state accessible to the receiving instance, for example through an existing shared backend. Only then can requests depending on that state reach any instance; process-local state still constrains placement or routing. Validate each handle against the caller's identity and permissions on every use.
If a supported transport supplies operation-routing headers, a gateway could rate-limit create_ticket more tightly than search_code without parsing the body. That works only if a trusted component verifies that the headers match the executed request. Otherwise a gateway can meter a search while the server creates a ticket.
If tool listings carry documented cache lifetimes and scopes, hosts can avoid repeated discovery requests within those limits. Cache sharing must respect that scope and the caller's authorization context. A listing can outlast a permission change, so the server must still check authorization when a tool executes.
Designing a tool the model will pick correctly
Exposing a tool is API design with two twists. First, the description is part of the contract. A hand-written REST client does not choose endpoints from your OpenAPI summary; a model may choose tools from their descriptions. Second, the caller is non-deterministic. It can pass the wrong argument, choose a neighboring tool, or paste content from a document it just read into a parameter.
Both twists argue for typed, constrained parameters and descriptions written for a selector rather than a reader.
{
"name": "create_ticket",
"description": "Create a new ticket in the engineering tracker. Use only when the user has explicitly asked to file a ticket. Does not modify existing tickets; use update_ticket for that.",
"inputSchema": {
"type": "object",
"properties": {
"project": { "type": "string", "enum": ["PLATFORM", "INFRA", "MOBILE"] },
"title": { "type": "string", "maxLength": 120 },
"priority": { "type": "string", "enum": ["P0", "P1", "P2", "P3"] },
"body_markdown": { "type": "string", "maxLength": 4000 }
},
"required": ["project", "title", "priority"]
},
"annotations": { "readOnlyHint": false, "destructiveHint": false }
}
When enforced by the server, the enum constraints reject unknown project and priority values; they cannot stop the model from choosing the wrong allowed value. The maxLength bounds what a model can push through. The description says when to use the tool and names the sibling to use instead, helping distinguish create from update. Schema validation constrains argument shape and allowed values. It does not prevent prompt injection, prove user intent, or authorize an action. The description's instruction to wait for an explicit user request is guidance for selection, not proof that the user gave approval.
Free-form strings are still unavoidable for the title and body, so the server validates them on receipt and treats them as untrusted input, the same way it would treat a form post. Prompt injection can influence tool selection or arguments even when they pass validation; unsafe interpolation into a shell command or raw search DSL is a separate code or query injection risk.
Structured output matters for the same reason. If search_code returns typed results (path, line, snippet, repo) rather than a text blob, later tool calls can reference fields instead of re-parsing prose.
Tool count is a cost, a selection risk, and a permission surface
Every tool definition occupies context on every model request that includes the tool list, and every additional tool is another option the model can choose wrongly. My working heuristic is that a dozen well-scoped tools beat forty overlapping ones; test it by comparing tool-selection errors and successful task completion for the relevant model and workflow. More tools can be warranted when they cover distinct operations the workflow needs.
Three controls address this, and they compose rather than compete. Filtering is a host-side decision: expose only the tools relevant to the current task or user. Splitting is a server-side decision: rather than one server with forty tools spanning code search, ticketing, deploys, and pager operations, run separate servers so a host can connect to only what a workflow needs. Scoping is a per-tool decision: search_code takes a repo parameter and a result limit instead of exposing search_everything.
Tool visibility affects exposure, but discovering a tool does not grant permission to execute it. A model that sees trigger_deploy next to search_code can select a consequential operation by mistake; independent permission checks and applicable host confirmation policy must still gate execution.
Authorization lives at the boundary, not in the model
The model is never the authority; the tool verifies permission independently on every call. For HTTP deployments using an OAuth resource-server flow, RFC 8707 resource indicators identify the server for which a token is requested. Check the supported version's requirements rather than generalizing one revision's authorization changes to every transport. Do not pass a token intended for the MCP server through to a different upstream API.
For the example, the flow is: the host obtains a token whose audience is the code-search-and-ticketing server, the server validates that token, including its audience, and identifies the authenticated user. It then uses a separate credential to the ticketing backend while applying the user's permissions to the requested project and operation. Possessing that backend credential must not allow the server to act beyond the user's permissions. Keep credentials out of model context. If the user cannot file P0 tickets in INFRA, create_ticket returns a permission error regardless of what the model argues in the prompt.
If you are interviewing at an AI company soon, practice narrating these choices for a system you know.