Introduction
Every generation of software has a moment where a messy, ad-hoc integration pattern gets replaced by a standard. REST replaced bespoke RPC formats. OpenAPI replaced hand-written API docs. In agentic AI systems, that moment belongs to the Model Context Protocol (MCP), an open standard introduced by Anthropic in November 2024 for connecting AI applications to external tools, data sources, and systems. Instead of every team writing a one-off integration between a language model and a database, a CRM, or an internal API, MCP defines a common wire format and a common set of primitives that any client and any server can speak.
For engineers, the interesting part isn't just "there's a new protocol." It's that MCP forces you to think like an API architect again, but with a twist: your consumer is not a human developer reading documentation, and not even a deterministic client calling fixed endpoints - it's a language model deciding, at runtime, which capabilities to invoke and with what arguments. That changes the calculus for naming, schema design, error handling, and security in ways that traditional API design doesn't fully prepare you for. This article walks through what an MCP server actually is, how to architect one properly, and where teams commonly get it wrong.
Context: Why MCP Exists and What Problem It Solves
Before MCP, "giving a model access to tools" meant writing custom function-calling glue for every combination of model provider and backend system. A team supporting three LLM providers and ten internal systems could easily end up maintaining thirty bespoke integrations, each with its own schema conventions, authentication handling, and error semantics. This is the same fragmentation problem that plagued application integration before REST and OpenAPI standardized how services describe and expose themselves. The cost isn't just development time; it's the ongoing maintenance burden of keeping thirty slightly different integrations in sync as models and backends evolve independently.
MCP addresses this with a client-server architecture and a JSON-RPC 2.0 wire format. An MCP host - the AI application, such as an IDE, a chat client, or an autonomous agent runtime - embeds an MCP client that manages a connection to one or more MCP servers. Each server exposes a bounded set of capabilities: tools the model can invoke, resources it can read, and prompt templates it can use. The host is responsible for orchestrating multiple servers and presenting a unified capability set to the model; the server's job is narrower and more disciplined - wrap one system (a database, a ticketing tool, a filesystem) and expose it safely and predictably.
What makes this genuinely useful rather than just "another SDK" is the decoupling it creates. A server built to expose a PostgreSQL database doesn't need to know anything about Claude, GPT, or Gemini - it just needs to speak MCP correctly. A host application doesn't need custom adapters for every backend system it wants to connect to - it just needs an MCP client. This is precisely the value proposition that made REST and, later, gRPC durable choices in distributed systems: a stable contract at the boundary lets both sides evolve independently. The ecosystem effect is already visible - by mid-2026, the MCP registry and community catalogs list servers for source control platforms, cloud providers, observability stacks, and countless SaaS tools, most of which never had to coordinate with the model vendors that consume them.
Deep Technical Explanation: Architecture, Primitives, and Transports
An MCP server is built around a small number of well-defined primitives, and understanding each one precisely is the foundation of good architecture. Tools are functions the model can decide to call - they take structured arguments, execute an action or computation, and return a result. This is the primitive most people associate with "function calling," but MCP standardizes the discovery (tools/list) and invocation (tools/call) mechanics so every client can enumerate and invoke tools the same way, and every server describes its tools with a JSON Schema so arguments are validated consistently. Resources represent readable context - files, database rows, API responses - that the host application can attach to a conversation, typically under application or user control rather than the model's own discretion. Prompts are reusable, parameterized templates that a server exposes so hosts can standardize common interaction patterns, such as a "summarize this ticket" prompt maintained by the issue-tracker server rather than duplicated across every client that talks to it.
The distinction between tools and resources is one of the most frequently misunderstood parts of the spec, and it has real architectural consequences. Tools are model-invoked and can have side effects; resources are meant to be more passive, host-mediated context. If you expose a "read file" operation as a tool, the model decides when to fetch it, mixed in with everything else it's reasoning about. If you expose it as a resource, the host application controls when and how it enters context - which is often what you want for large or sensitive reference data, and often not what you want for something the model needs to fetch conditionally based on its own reasoning.
On the wire, everything is JSON-RPC 2.0: requests, responses, and notifications, each identified by method names like tools/call or resources/read. Two transports matter in practice. stdio runs the server as a local subprocess and exchanges newline-delimited JSON-RPC messages over standard input and output - this is the right choice for local developer tools, IDE integrations, and anything running on the same machine as the host. Streamable HTTP, introduced in the 2025-03-26 revision of the spec to replace an earlier HTTP+SSE design, lets a server run as an independent, potentially remote process serving multiple clients over a single HTTP endpoint that supports both POST and GET, with optional Server-Sent Events for server-to-client streaming. This is the transport you reach for when a server needs to be shared across teams, deployed centrally, or scaled horizontally.
It's worth being explicit about spec versioning because the protocol has moved quickly. The 2025-06-18 revision - the version most production SDKs and servers implement as of this writing - added structured tool output, an "elicitation" mechanism for servers to request additional input from users mid-call, resource links in tool results, and stricter OAuth-based authorization requirements for remote servers. A 2026-07-28 release candidate is now in progress and represents the largest revision since launch: it moves the transport toward a stateless core (dropping the Mcp-Session-Id session model in favor of explicit state handles passed as tool arguments), introduces an extensions framework, and formally deprecates the older Roots, Sampling, and Logging primitives in favor of newer replacements. When you architect a server today, pin an explicit protocol version, negotiate it during initialization, and treat the spec as something to track deliberately rather than assume is frozen.
Implementation: Building an MCP Server in Practice
The cleanest way to internalize the architecture is to build a small but realistic server. Below is a TypeScript example using the official @modelcontextprotocol/sdk, exposing a tool that queries an internal order-management system. Notice that the schema validation, error surface, and tool description are doing as much work as the business logic itself - this is not incidental. A model chooses which tool to call and how to fill in its arguments based almost entirely on the name, description, and schema you provide, so treat these as the primary interface, not documentation you write after the fact.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { OrdersClient } from "./orders-client.js";
const server = new McpServer({
name: "orders-mcp-server",
version: "1.2.0",
});
const orders = new OrdersClient(process.env.ORDERS_API_URL!);
server.registerTool(
"get_order_status",
{
title: "Get order status",
description:
"Look up the current status of a customer order by order ID. " +
"Returns fulfillment stage, carrier, and expected delivery date. " +
"Use this only when the user references a specific order.",
inputSchema: {
orderId: z
.string()
.regex(/^ORD-\d{6,10}$/)
.describe("Order identifier, formatted like ORD-123456"),
},
},
async ({ orderId }) => {
try {
const order = await orders.fetchStatus(orderId);
if (!order) {
return {
content: [
{ type: "text", text: `No order found with ID ${orderId}.` },
],
isError: false,
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
status: order.status,
carrier: order.carrier,
estimatedDelivery: order.eta,
},
null,
2
),
},
],
};
} catch (err) {
// Surface a model-readable error rather than throwing raw exceptions
return {
content: [
{ type: "text", text: `Order lookup failed: ${(err as Error).message}` },
],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Two details in this example matter more than they look. First, the input is validated with a Zod schema and a regex constraint, so malformed order IDs never reach the downstream client - the model gets a schema validation error it can recover from, rather than your service throwing an unhandled exception. Second, failures are returned as structured tool results with isError: true rather than thrown as exceptions that terminate the connection; the model needs to see the failure to reason about it, and a crashed transport gives it nothing to work with.
The same pattern in Python, using the official mcp SDK's high-level FastMCP interface, looks similarly disciplined but leans on Python type hints and docstrings instead of a schema library:
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
import httpx
mcp = FastMCP("orders-mcp-server")
class OrderStatusResult(BaseModel):
status: str
carrier: str | None
estimated_delivery: str | None
@mcp.tool()
async def get_order_status(
order_id: str = Field(
...,
pattern=r"^ORD-\d{6,10}$",
description="Order identifier, formatted like ORD-123456",
)
) -> OrderStatusResult | str:
"""Look up the current status of a customer order by order ID.
Returns fulfillment stage, carrier, and expected delivery date.
Use this only when the user references a specific order.
"""
async with httpx.AsyncClient(timeout=5.0) as client:
try:
resp = await client.get(f"https://orders.internal/api/{order_id}")
except httpx.RequestError as exc:
return f"Order lookup failed: could not reach orders service ({exc})"
if resp.status_code == 404:
return f"No order found with ID {order_id}."
resp.raise_for_status()
data = resp.json()
return OrderStatusResult(
status=data["status"],
carrier=data.get("carrier"),
estimated_delivery=data.get("eta"),
)
if __name__ == "__main__":
mcp.run(transport="stdio")
Both examples deliberately expose a single, narrow tool rather than a generic "call the orders API" passthrough. This is a design choice, not a limitation of the SDKs - a model given a broad, unstructured tool will produce broad, unstructured calls, and you lose the ability to validate, rate-limit, or audit behavior at a meaningful granularity. Narrow, purpose-built tools with tight schemas are the MCP equivalent of designing a good REST resource: fewer surprises, better observability, and a much smaller blast radius when something goes wrong.
Trade-offs and Common Pitfalls
The biggest architectural trade-off in MCP server design is granularity: how many tools, how narrowly scoped, and how much logic lives in the server versus is left for the model to orchestrate across multiple calls. Too few, overly broad tools (a single execute_query that accepts arbitrary SQL) hand the model enormous flexibility at the cost of safety and predictability - you're essentially building a text-to-SQL system with no guardrails and calling it a tool. Too many narrow tools, on the other hand, bloats the tool list presented to the model at every turn, increasing token overhead and, more importantly, increasing the chance the model picks the wrong tool among many similar-sounding options. There's no universal answer here; it depends on how much you trust the model's judgment for a given class of operation and how expensive a wrong call would be. A read-only reporting tool can tolerate more flexibility than a tool that issues refunds.
A second, less obvious pitfall is treating MCP servers as stateless the way REST handlers usually are. Under the 2025-06-18 spec, Streamable HTTP servers commonly track session state via an Mcp-Session-Id header, and it's tempting to stash conversation-scoped state there - but that state now lives at the protocol layer, invisible to the model, and becomes a source of bugs when a client reconnects, a load balancer routes a request to a different server instance, or a session expires mid-task. The direction the specification itself is moving - toward a stateless protocol core with explicit state handles passed back to the server as ordinary tool arguments - is a strong signal that hidden transport-level state is an anti-pattern worth avoiding even before it's formally deprecated. Design your server so any state it needs to recall is either fully re-derivable from arguments or explicitly threaded through as a handle the model can see and pass along.
Best Practices for Production MCP Servers
Security has to be treated as a first-class concern rather than an afterthought, because an MCP server sits at the exact boundary where a model's output becomes a real-world side effect. The 2025-06-18 revision formalized this by classifying MCP servers exposed over HTTP as OAuth 2.0 Resource Servers and requiring Resource Indicators (RFC 8707) to prevent a token issued for one server from being replayed against another. In practice this means remote servers should validate bearer tokens on every request, scope tokens as narrowly as the operations they authorize, and never accept a token whose audience doesn't match the server itself. For local stdio servers, the equivalent discipline is running with the least filesystem and network access the tool actually needs - a filesystem server should be sandboxed to a specific directory tree, not handed the user's full home directory by default.
Observability deserves the same attention you'd give any other production service boundary. Every tool call should be logged with enough context to reconstruct what the model asked for, what arguments it supplied, and what the server returned - not for the model's benefit, but for yours, when something goes wrong three tool calls downstream and you need to trace back through the sequence of decisions. The emerging convention, formalized as W3C Trace Context propagation inside the protocol's _meta field, lets a distributed trace follow a single logical operation from the host application, through the MCP client, into the server, and out to whatever backend it calls - adopt this early rather than bolting on custom correlation IDs later.
Schema and description quality is the most underrated lever available to you. Because the model reads your tool descriptions and JSON Schemas the same way a new engineer reads sparse documentation, ambiguity there translates directly into wrong or wasted calls. Write descriptions that state not just what a tool does but when it should and shouldn't be used, constrain arguments with enums and patterns wherever the domain allows it, and return errors as informative text the model can act on rather than opaque codes. Treat your tool surface with the same rigor you'd apply to a public API that thousands of unfamiliar developers will integrate against - because functionally, that's what it is; the "developer" just happens to be a language model reasoning from your descriptions at inference time.
Finally, version your server's capabilities deliberately. Tool schemas will change as your backend evolves, and a model mid-conversation with a stale understanding of a tool's arguments is a worse failure mode than an API client that simply gets a 400. Where possible, evolve tools additively - new optional fields rather than renamed required ones - and use the protocol's capability negotiation during initialize to let older clients continue working against a server that has since gained new tools they simply won't see listed.
Analogies and Mental Models
The most useful mental model for an MCP server is to think of it as a well-designed public API with an unusually literal-minded consumer. A human developer integrating against your REST API will infer intent from surrounding documentation, tribal knowledge, and trial and error; a model calling your MCP tools has only what's in the schema and description in front of it at that moment. Every ambiguity you'd normally leave for a support ticket to resolve becomes, in this context, a wrong tool call executed with full confidence. This reframes tool design from "expose the capability" to "expose the capability in a way that's unambiguous to a reader with zero context beyond what you give it right now."
A second useful analogy is the Unix philosophy of small, composable tools that do one thing well, connected through a predictable interface. An MCP server that exposes ten narrow, well-named tools - each with a tight contract - is architecturally closer to a well-factored set of Unix utilities than to a monolithic CLI with forty flags. The model, like a shell script author, composes these primitives to accomplish tasks you never explicitly anticipated, which only works reliably if each primitive behaves predictably in isolation.
Key Takeaways
- Design tools like public API endpoints, not internal functions - the model has no context beyond the name, description, and schema you give it, so treat those as your primary interface contract.
- Choose the right transport deliberately: stdio for local, single-user tools; Streamable HTTP for shared or remote servers that need to scale and be reachable over a network.
- Keep tools narrow and side-effect-aware; avoid one broad "do anything" tool, and mark clearly which operations are read-only versus mutating.
- Treat authorization as non-optional for remote servers - validate tokens, scope them tightly, and follow the OAuth Resource Server model the spec now mandates.
- Version deliberately and log everything - pin a protocol version, propagate trace context, and evolve schemas additively so neither models nor clients get stranded by silent breaking changes.
Conclusion
Architecting an MCP server well is less about mastering a new SDK and more about applying API design discipline to a consumer that behaves differently from anything most engineers have designed for before. The protocol itself gives you a clean, well-specified foundation - JSON-RPC framing, explicit primitives for tools, resources, and prompts, and a maturing security model - but the foundation only pays off if the server built on top of it treats schema clarity, narrow scoping, and observability as core requirements rather than polish applied at the end.
MCP is still evolving quickly, and that's worth internalizing as a design constraint rather than a footnote: the gap between the 2025-06-18 spec most production servers run today and the stateless, extension-based direction of the 2026-07-28 release candidate is substantial. Building servers around explicit, model-visible state, additive schema evolution, and standard observability hooks today will make that transition far less painful than building around session-hidden state and ad-hoc logging will. The engineering fundamentals here are not new - they're the same discipline that made REST and gRPC durable - but MCP applies them to a genuinely new kind of consumer, and that's precisely what makes getting the architecture right worth the effort.
References
- Anthropic - "Introducing the Model Context Protocol," Anthropic News, November 2024. https://www.anthropic.com/news/model-context-protocol
- Model Context Protocol - Official Specification. https://modelcontextprotocol.io/specification/
- Model Context Protocol - Transports specification (stdio and Streamable HTTP). https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
- Model Context Protocol Blog - "The 2026 MCP Roadmap," March 9, 2026. https://blog.modelcontextprotocol.io/posts/2026-mcp-roadmap/
- Model Context Protocol Blog - "The 2026-07-28 MCP Specification Release Candidate," May 2026. https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
- Model Context Protocol Blog - "The 2026-07-28 Specification," July 28, 2026. https://blog.modelcontextprotocol.io/posts/2026-07-28/
- Model Context Protocol - TypeScript SDK. https://github.com/modelcontextprotocol/typescript-sdk
- Model Context Protocol - Python SDK. https://github.com/modelcontextprotocol/python-sdk
- IETF RFC 8707 - "Resource Indicators for OAuth 2.0." https://datatracker.ietf.org/doc/html/rfc8707
- W3C - "Trace Context" Recommendation. https://www.w3.org/TR/trace-context/