Skip to content
All articles

Productionizing MCP Part 1: Architecture & Design

Dean Jain

Dean Jain

Senior Staff Software Engineer · Enterprise AI, Data & Cloud Architect

· 7 min read

MCPAgentic AIArchitectureSecurityObservability

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 localhost and 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.

ConcernREST worldMCP worldWhat actually changes
CallerDeterministic client codeNon-deterministic LLMCall patterns are unpredictable; intent is inferred, not coded
Input trustValidate the requestValidate the request and treat tool descriptions/outputs as adversarial prompt surfaceNew injection vectors inside “metadata”
AuthZUser/service identity → RBACSame, plus “did the model decide this, or did a poisoned doc decide it?”Audit gains an origin dimension
Rate limitingRequests/secRequests/sec and token spendCost becomes a first-class SLO
TestingContract + integrationSame, 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.

LayerResponsibilityREST analog
Single-purpose serverOne bounded context’s tools; team-owned; independently deployable/scalableA domain microservice
GatewaySingle endpoint; centralizes authN/Z, routing, rate/cost limits, audit, telemetry, allowlists; can fan-out and merge tool lists into a virtual serverAPI gateway (Kong/Apigee/Envoy)
RegistryDiscovery + governance: which servers exist, who approved them, which agents see which toolsAPI 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.

→ Read Part 2: Security, Observability & Governance