Productionizing MCP Part 1: Architecture & Design
Dean Jain
Senior Staff Software Engineer · Enterprise AI, Data & Cloud Architect
· 7 min read
Part 1 of 2. The mental model, DDD-aligned MCP architecture, and intent-shaped tool design for production MCP Understanding MCP with REST analogy
📚 Two-part series. You’re reading Part 1 Architecture & Design (the mental model, server topology, tool design with REST analogy). Part 2 Security, Observability & Governance → covers semantic-attack defense, identity & token handling, observability, governance, and testing.
TL;DR
- MCP gives you a protocol, not a platform. Everything between
localhostand production is the same gap we spent 15 years closing for REST and most of those lessons port directly. - A tool call is an RPC where the caller is a non-deterministic LLM that consumes untrusted text and can be steered by it. That single fact reshapes every cross-cutting concern downstream.
- The architecture answer is not “monolith vs microservice.” It’s DDD-aligned, single-purpose servers behind a gateway + registry. Carve servers by bounded context, expose task-shaped tools, federate centrally.
- Stop 1:1 wrapping CRUD endpoints. Design coarse, intent-based tools. Treat tool descriptions and JSON schemas as binding contracts the model reasons over and as adversarial prompt surface.
- Design for statelessness now. The old spec pins clients to a session/instance; the newer spec makes requests self-contained so any instance can serve any call. Build as if you’re already there and horizontal scaling is free.
- ~70% of production MCP is API engineering you already know; ~30% is genuinely new. This part maps both halves. The new 30% semantic attacks, descriptions-as-behavior, token-cost SLOs, and evals as a release gate is covered in Part 2 →.
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart LR
START(["You already ship REST at scale"]):::good --> OWN["≈70% of production MCP<br/>is yours already"]:::good
START --> GAP["≈30% is genuinely new"]:::warn
GAP --> N1["Semantic attacks"]:::danger
GAP --> N2["Descriptions are behavior"]:::gate
GAP --> N3["Token cost as an SLO"]:::obs
GAP --> N4["Evals as a release gate"]:::server
classDef good fill:#BFEFC8,stroke:#3FA34D,stroke-width:2px,color:#0F172A
classDef warn fill:#FFE6A8,stroke:#E0A106,stroke-width:2px,color:#0F172A
classDef danger fill:#FFB3B3,stroke:#D14545,stroke-width:2px,color:#0F172A
classDef gate fill:#D7C3F2,stroke:#8E5BD0,stroke-width:2px,color:#0F172A
classDef obs fill:#AED6F1,stroke:#2E86C1,stroke-width:2px,color:#0F172A
classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
Figure 1: REST vs MCP: ~70% of production work transfers, ~30% is genuinely new.
Spend your attention on the 30%. The rest of this article is a map of both halves.
1. The mental model: a tool call is an RPC where the caller is an LLM
MCP is, fairly, “HTTP for agents.” It standardizes how a host/client discovers and invokes capabilities on a server over JSON-RPC. The transport, the primitives, the auth handshake all of it rhymes with the API world.
The one fact that reshapes every cross-cutting concern:
The end caller is a non-deterministic model that consumes untrusted text (documents, web pages, prior tool outputs) and can be steered by it into making tool calls the user never intended.
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart TB
subgraph A["🧱 REST world deterministic"]
direction LR
C1["Client code"]:::neutral -->|"known request"| E1["Endpoint"]:::good
end
subgraph B["🤖 MCP world non-deterministic"]
direction LR
U2["Untrusted text:<br/>docs · web · prior output"]:::danger -.->|"can steer"| M2["LLM caller"]:::agent
M2 -->|"inferred intent"| T2["Tool"]:::server
end
classDef neutral fill:#ECECEC,stroke:#8A8A8A,stroke-width:2px,color:#0F172A
classDef good fill:#BFEFC8,stroke:#3FA34D,stroke-width:2px,color:#0F172A
classDef danger fill:#FFB3B3,stroke:#D14545,stroke-width:2px,color:#0F172A
classDef agent fill:#FFE08A,stroke:#E8A33D,stroke-width:2px,color:#0F172A
classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
Figure 2: A tool call is an RPC where the caller is a non-deterministic LLM.
| Concern | REST world | MCP world | What actually changes |
|---|---|---|---|
| Caller | Deterministic client code | Non-deterministic LLM | Call patterns are unpredictable; intent is inferred, not coded |
| Input trust | Validate the request | Validate the request and treat tool descriptions/outputs as adversarial prompt surface | New injection vectors inside “metadata” |
| AuthZ | User/service identity → RBAC | Same, plus “did the model decide this, or did a poisoned doc decide it?” | Audit gains an origin dimension |
| Rate limiting | Requests/sec | Requests/sec and token spend | Cost becomes a first-class SLO |
| Testing | Contract + integration | Same, plus evals (does the agent select the tool correctly?) | Descriptions are behavior, not docs |
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart LR
TOOL["🔧 Tool"]:::server -->|"≈"| POST["POST endpoint /<br/>RPC method (side-effecting)"]:::neutral
RES["📄 Resource"]:::obs -->|"≈"| GET["GET / read model"]:::neutral
PR["💬 Prompt"]:::good -->|"≈"| TPL["Server-supplied template /<br/>stored procedure"]:::neutral
classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
classDef obs fill:#AED6F1,stroke:#2E86C1,stroke-width:2px,color:#0F172A
classDef good fill:#BFEFC8,stroke:#3FA34D,stroke-width:2px,color:#0F172A
classDef gate fill:#D7C3F2,stroke:#8E5BD0,stroke-width:2px,color:#0F172A
classDef neutral fill:#ECECEC,stroke:#8A8A8A,stroke-width:2px,color:#0F172A
Figure 3: MCP primitives mapped to familiar API concepts.
Transports (know which era you’re in)
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
timeline
title 🚌 MCP Transport Evolution
Nov 2024 : stdio (local only)
Original remote : HTTP + SSE : now legacy / deprecated
Mar 2025 : Streamable HTTP : remote, but session-pinned
Nov 2025 : 2025-11-25 stable
Jul 2026 (RC) : Stateless Streamable HTTP : any instance serves any call
Figure 4: MCP transport evolution: stdio to Streamable HTTP to stateless.
Design implication: the 2026-07-28 work removes the mandatory initialize handshake + Mcp-Session-Id pinning in favor of self-describing requests. That turns MCP scaling into ordinary stateless-HTTP scaling no sticky sessions, trivial autoscaling and rollout. Even on today’s spec, keep your server logic stateless so you inherit this for free.
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart TB
subgraph SF["😖 Stateful (2025-11-25) sticky sessions"]
direction TB
CA["Client A"]:::agent -->|"Mcp-Session-Id pins to instance"| LB1["Load balancer<br/>needs session affinity"]:::warn
LB1 --> I1["Instance 1"]:::server
LB1 -.->|"can't reroute"| I2["Instance 2"]:::neutral
end
subgraph SL["😎 Stateless (2026-07-28) any instance"]
direction TB
CB["Client B"]:::agent -->|"self-contained request"| LB2["Load balancer<br/>plain round-robin"]:::good
LB2 --> J1["Instance 1"]:::server
LB2 --> J2["Instance 2"]:::server
LB2 --> J3["Instance 3"]:::server
end
classDef agent fill:#FFE08A,stroke:#E8A33D,stroke-width:2px,color:#0F172A
classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
classDef warn fill:#FFE6A8,stroke:#E0A106,stroke-width:2px,color:#0F172A
classDef good fill:#BFEFC8,stroke:#3FA34D,stroke-width:2px,color:#0F172A
classDef neutral fill:#ECECEC,stroke:#8A8A8A,stroke-width:2px,color:#0F172A
Figure 5: Stateful (session-pinned) vs stateless horizontal scaling.
2. Architecture: stop asking “monolith or microservice”
That’s the wrong axis. The right question is the one DDD already answered for services: where are the bounded contexts, and who owns them?
The two failure modes at the extremes
One mega-server (“the MCP monolith”)
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart TB
MEGA["🗿 One mega-server<br/>(60+ tools)"]:::danger
MEGA --> X1["❌ Tool explosion<br/>model picks wrong, tokens burn"]:::warn
MEGA --> X2["❌ Blast radius<br/>one bug kills everything"]:::warn
MEGA --> X3["❌ Ownership smear<br/>5 teams, 1 repo"]:::warn
MEGA --> X4["❌ Deploy coupling<br/>Slack waits on Billing"]:::warn
classDef danger fill:#FFB3B3,stroke:#D14545,stroke-width:2px,color:#0F172A
classDef warn fill:#FFE6A8,stroke:#E0A106,stroke-width:2px,color:#0F172A
Figure 6: The MCP monolith failure mode.
Fifty servers wired directly into every client (“the N×M mess”)
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
lineColor: "#C0504D"
---
flowchart LR
A1["Client 1"]:::agent --> S1["Server A"]:::server
A1 --> S2["Server B"]:::server
A1 --> S3["Server C"]:::server
A2["Client 2"]:::agent --> S1
A2 --> S2
A2 --> S3
A3["Client 3"]:::agent --> S1
A3 --> S2
A3 --> S3
NOTE["⚠️ N×M wiring · config sprawl ·<br/>no central policy or discovery"]:::danger
classDef agent fill:#FFE08A,stroke:#E8A33D,stroke-width:2px,color:#0F172A
classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
classDef danger fill:#FFB3B3,stroke:#D14545,stroke-width:2px,color:#0F172A
Figure 7: The N x M direct-wiring mess.
The synthesis: DDD servers + a gateway + a registry
This is the pattern converging across the ecosystem (variously “Macro-MCP,” “MCP gateway,” “virtual MCP servers”):
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart TB
A1["🤖 Agent / Host A"]:::agent
A2["🤖 Agent / Host B"]:::agent
A3["🤖 Agent / Host C"]:::agent
GW["🛡️ MCP GATEWAY single virtual endpoint<br/>authN/Z · routing · policy<br/>rate + cost limits · audit · OTel"]:::gate
S1["⚙️ personal-assistant MCP server"]:::server
S2["⚙️ Benefits MCP server"]:::server
S3["⚙️ Search MCP server"]:::server
D1[("🗄️ Core DB")]:::down
D2["☁️ SaaS API"]:::down
D3["🏢 Internal API"]:::down
REG["📚 REGISTRY discovery + governance<br/>central IT ⇄ LOB (federated)"]:::gov
A1 --> GW
A2 --> GW
A3 --> GW
GW --> S1
GW --> S2
GW --> S3
S1 -->|"token exchange (RFC 8693)"| D1
S2 --> D2
S3 --> D3
REG -.->|"feeds approved tools"| GW
classDef agent fill:#FFE08A,stroke:#E8A33D,stroke-width:2px,color:#0F172A
classDef gate fill:#D7C3F2,stroke:#8E5BD0,stroke-width:2px,color:#0F172A
classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
classDef down fill:#FFC2B4,stroke:#E8694A,stroke-width:2px,color:#0F172A
classDef gov fill:#E0D6F5,stroke:#9B7EDE,stroke-width:2px,color:#0F172A
Figure 8: Reference architecture: agents to gateway to servers to downstream, plus registry.
| Layer | Responsibility | REST analog |
|---|---|---|
| Single-purpose server | One bounded context’s tools; team-owned; independently deployable/scalable | A domain microservice |
| Gateway | Single endpoint; centralizes authN/Z, routing, rate/cost limits, audit, telemetry, allowlists; can fan-out and merge tool lists into a virtual server | API gateway (Kong/Apigee/Envoy) |
| Registry | Discovery + governance: which servers exist, who approved them, which agents see which tools | API catalog / developer portal |
A single well-scoped server fronted by your existing API gateway is a perfectly good starting point. Don’t build the control plane before you have the servers to control. But design each server so it can slot behind a gateway later (stateless, pure RS, OTel-instrumented).
Frameworks (architecture/runtime)
- Clients / Orchestrators: LangChain and LlamaIndex (which act as the client calling your MCP servers).
- Servers: official SDKs (TypeScript, Python, Go, Java); FastMCP (Python) for ergonomics.
- Gateways: GCP API Gateway, Cloudflare Workers MCP, AWS MCP Gateway & Registry.
- Pick a gateway when: heterogeneous teams, centralized policy/audit, or aggregating many servers. Roll your own thin proxy when: one or two servers and an existing gateway you can extend.
3. Domain design: carving servers and shaping tools
The discipline is the same as service design; the new wrinkles are that the model reads your descriptions and the model picks your tools.
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart TD
BC["🗂️ Bounded context (domain)"]:::gov --> SRV["⚙️ One MCP server (team-owned)"]:::server
SRV --> T1["🔧 Tool: capability A"]:::agent
SRV --> T2["🔧 Tool: capability B"]:::agent
SRV --> R1["📄 Resource: read model"]:::obs
SRV --> P1["💬 Prompt: shared template"]:::good
classDef gov fill:#E0D6F5,stroke:#9B7EDE,stroke-width:2px,color:#0F172A
classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
classDef agent fill:#FFE08A,stroke:#E8A33D,stroke-width:2px,color:#0F172A
classDef obs fill:#AED6F1,stroke:#2E86C1,stroke-width:2px,color:#0F172A
classDef good fill:#BFEFC8,stroke:#3FA34D,stroke-width:2px,color:#0F172A
Figure 9: DDD: bounded context to one server to tools, resources, prompts.
Tool granularity: design for intent, not CRUD
The single most common anti-pattern is 1:1 wrapping every REST endpoint as a tool. It re-creates tool explosion and forces the model to orchestrate low-level calls it will get wrong.
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart TB
subgraph BAD["❌ 1:1 CRUD wrapping model must chain 4 calls"]
direction LR
m1["🤖 LLM"]:::agent --> g1["getCustomer"]:::danger --> g2["getCustomerOrders"]:::danger --> g3["getOrderItems"]:::danger --> g4["getItemDetails"]:::danger
end
subgraph GOOD["✅ Task-shaped tool one intent, server orchestrates"]
direction LR
m2["🤖 LLM"]:::agent --> t1["get_customer_order_history(id)"]:::good
end
classDef agent fill:#FFE08A,stroke:#E8A33D,stroke-width:2px,color:#0F172A
classDef danger fill:#FFB3B3,stroke:#D14545,stroke-width:2px,color:#0F172A
classDef good fill:#BFEFC8,stroke:#3FA34D,stroke-width:2px,color:#0F172A
Figure 10: 1:1 CRUD wrapping vs a single task-shaped tool.
Rule of thumb: a tool should map to a unit of user/agent intent, not a database operation. Coarser, task-oriented tools mean fewer tokens, better selection accuracy, and less room for the model to assemble something dangerous.
Descriptions are a first-class (and adversarial) interface
- The description and JSON schema are the contract the model reasons over. Treat them with the rigor of an API spec and the suspicion of untrusted input (see Part 2 tool poisoning).
- Be explicit about side effects, idempotency, and when not to call the tool.
- Keep parameter schemas tight (types, enums, length/format) they double as input validation and model guardrails.
Versioning & change management (port the API playbook directly)
---
config:
theme: dark
fontSize: 17
themeVariables:
fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart LR
V1["🔧 v1 tool"]:::server -->|"additive change"| V2["🔧 v2 tool<br/>backward compatible"]:::good
V2 -->|"mark deprecated"| DEP["⏳ Deprecation window<br/>watch usage telemetry"]:::warn
DEP -->|"usage ≈ 0"| REM["🗑️ Remove"]:::neutral
CHG["⚠️ Silent definition change"]:::danger -.->|"= RUG PULL"| RECON["🔒 Force re-review<br/>+ re-consent"]:::gate
classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
classDef good fill:#BFEFC8,stroke:#3FA34D,stroke-width:2px,color:#0F172A
classDef warn fill:#FFE6A8,stroke:#E0A106,stroke-width:2px,color:#0F172A
classDef neutral fill:#ECECEC,stroke:#8A8A8A,stroke-width:2px,color:#0F172A
classDef danger fill:#FFB3B3,stroke:#D14545,stroke-width:2px,color:#0F172A
classDef gate fill:#D7C3F2,stroke:#8E5BD0,stroke-width:2px,color:#0F172A
Figure 11: Tool lifecycle, versioning, and the rug-pull risk.
- Additive, backward-compatible changes by default; new tools rather than breaking signatures.
- Deprecation windows with usage telemetry before removal the MCP project itself is moving to deprecation-window + extension discipline; mirror it.
- Treat a changed tool definition as a contract change requiring re-review and (for clients that approved it) potential re-consent silent changes are the “rug pull” attack (covered in Part 2).
Resources vs tools
- Resource when the model should read data with no side effect. Application decides what to surface.
- Tool when the model should act or run a parameterized query. Side-effecting or compute-bearing.
- Don’t expose mutation as a “resource,” and don’t force read-only context through tool calls.
Continue to Part 2
You now have the architecture and design foundation the mental model, where to draw server boundaries, and how to shape tools. Part 2 covers the cross-cutting concerns that decide whether MCP survives contact with production: defending semantic attacks, identity & token handling, observability with token-cost and audit-origin, governance via the registry, and testing with evals.