MCP Deep Dive, Part 2: Inside the Model Context Protocol Architecture (Hosts, Clients, Servers)
MCP has a host, clients, servers, three primitives, and a capability handshake. Here is the full architecture, mapped onto Mattrx's real Azure setup.
- Author
- Randhir Jassal
- Published
- Reading time
- 16 min read
- Views
- 30 views
Most teams meet MCP as "a way to give your model tools" and stop there. That framing will cost you. MCP is a small distributed system with three roles and three primitives, and once you see the architecture clearly, every later question — auth, scale, versioning, streaming — has an obvious home. This part draws the map.
This is Part 2 of a 15-part deep dive on Model Context Protocol (MCP). In Part 1 we made the case for adopting MCP at all: it turns N×M integration glue into N+M, and on Mattrx — our multi-tenant marketing-analytics SaaS — that meant 14 bespoke clients collapsing to 3 MCP servers. Now we open the box. By the end you will be able to draw the host/client/server topology for your own system and know exactly which piece owns what.
TL;DR
| Concept | Ad-hoc agent (before) | MCP architecture (after) |
|---|---|---|
| Roles | One process does everything | Host, Client, Server — separated |
| Connection | One shared client, mixed state | One client per server, isolated |
| Capabilities | Tools only, conflated | Tools, Resources, Prompts |
| Versioning | Assumed; breaks on change | Negotiated at initialize |
| Transport | Hardcoded | stdio (local) or Streamable HTTP+SSE |
| Mattrx topology | Tangled | 1 host → 3 servers on Azure |
- Three roles: the host owns the agent loop and its clients, a client owns exactly one server connection, a server owns capabilities.
- One client ↔ one server — each connection has its own lifecycle and its own negotiated capability set.
- Three server primitives: tools (model-controlled actions), resources (app-controlled data by URI), prompts (user-controlled templates).
- Capability negotiation at
initializelets servers ship new versions without breaking clients. - Two transports: stdio for local/co-located, Streamable HTTP + SSE for remote/multi-tenant.
- Mattrx runs one host fanning out to 3 servers (
mattrx-analytics,mattrx-reports,mattrx-admin) on Azure Container Apps. - Modeling reads as resources (URI-addressable) helped keep context tokens at 3.5k (down from 14k).
- The capability handshake is a big reason agent tool-call error rate sits at 0.8% — no version-mismatch breakage.
- The same server code runs over stdio in dev and HTTP+SSE in production.
- Production latencies: read-tool p95 120 ms, report-enqueue p95 90 ms, streaming first-token p95 ~300 ms.
The one mental shift: stop thinking "MCP = tool calling." Think "MCP = three roles and three primitives." Get the roles right and the hard parts (auth at the boundary, scaling the server, versioning the contract) stop being architecture problems and become configuration.
The running example: Mattrx's MCP topology
Mattrx is a real system — Angular 19 front end, .NET 9 / ASP.NET Core back end, Azure SQL, Azure App Service, Kafka ingestion, Service Bus for report commands. The AI surface is Mattrx Help (RAG support) and Mattrx Insights (an agentic analyst), with C# owning orchestration and a Python FastAPI service owning embeddings, retrieval, and agents.
In MCP terms, here is who plays which role:
HOST (Mattrx Insights / Help runtime)
owns: the agent loop + the model + N clients
|
+-----------------------------+-----------------------------+
| | |
CLIENT A CLIENT B CLIENT C
(1 connection) (1 connection) (1 connection)
| | |
v v v
mattrx-analytics mattrx-reports mattrx-admin
SERVER SERVER SERVER
tools: get_campaign_kpis tools: create_report tools: set_flag
query_events report_status export_audience
resources: campaign records (actions -> Service Bus) (locked, admin scope)
prompts: campaign_health ------------------------- -----------------------
review Azure Container App Azure Container App
------------------------- Streamable HTTP + SSE Streamable HTTP + SSE
Azure Container App OAuth (Entra) + scopes OAuth (Entra) + scopes
Streamable HTTP + SSE
One host, three clients, three servers — each server a different trust and capability domain. Let's build that up one architectural decision at a time, with the before that tangled and the after that holds.
1. The three roles: Host, Client, Server
Before
The first Insights agent was a single class that was host, client, and server all at once — it ran the model loop, held the data-access logic, and executed actions, all in one file.
// BEFORE: a god object. Orchestration, tool logic, and data access fused.
public sealed class InsightsAgent(IChatModel model, AppDbContext db, IReportService reports)
{
public async Task<string> AnswerAsync(string goal, CancellationToken ct)
{
var data = await db.Campaigns.Where(/* ... */).ToListAsync(ct); // it IS the data layer
var plan = await model.PlanAsync(goal, ct); // it IS the host
var report = await reports.CreateAsync(/* ... */, ct); // it IS the action layer
// Everything is welded together; nothing can be reused or secured independently.
}
}
Diagnostic: when one object owns the loop, the data, and the actions, you cannot secure, scale, or version any of them separately. There is no boundary to put auth on, no seam to test across.
After
MCP names three roles and keeps them apart. The host owns the agent loop and one client per server. Each server owns its capabilities and knows nothing about agents or models.
// AFTER: the host owns the loop and a client per server. That's all it owns.
public sealed class InsightsHost(IReadOnlyList<IMcpClient> clients, IChatModel model)
{
public async Task<AgentAnswer> RunAsync(AiPrincipal p, string goal, CancellationToken ct)
{
// Gather tools from every connected server (one client each).
var tools = new List<McpTool>();
foreach (var client in clients)
tools.AddRange(await client.ListToolsAsync(ct));
var plan = await model.PlanAsync(goal, tools, ct);
// Dispatch each call to the client that owns that tool.
foreach (var call in plan.ToolCalls)
{
var client = clients.First(c => c.Owns(call.ToolName));
call.Result = await client.CallToolAsync(call.ToolName, call.Arguments, ct);
}
return await model.SynthesizeAsync(goal, plan, ct);
}
}
The server is the mirror image — it declares capabilities and is deliberately ignorant of who calls it:
// AFTER: the server owns capabilities. No agent, no model, no idea who's calling.
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMcpServer(o => o.ServerInfo = new() { Name = "mattrx-analytics", Version = "2.4.0" })
.WithHttpTransport() // Streamable HTTP + SSE
.WithTools<AnalyticsTools>() // model-controlled actions
.WithResources<CampaignResources>() // app-controlled data
.WithPrompts<AnalyticsPrompts>(); // user-controlled templates
var app = builder.Build();
app.MapMcp("/mcp");
Diagnostic: the boundary between host and server is exactly where auth, rate-limiting, and audit belong — and now there is a boundary. The server can be deployed, scaled, and secured on its own.
Mattrx metric: this separation is what let 14 tangled integrations become 3 independently deployable servers, and it is where the single OAuth/Entra boundary from Part 1 lives.
2. The three primitives: Tools, Resources, Prompts
Before
Everything was a "function." Fetching a campaign, creating a report, and a reusable summarization template were all just methods, with no distinction between reading data, taking an action, and offering a template.
// BEFORE: one undifferentiated bag of functions.
string GetCampaign(string id); // read
string CreateReport(ReportSpec spec); // action
string SummarizePrompt = "Summarize..."; // a template, stringly-typed, buried
Diagnostic: collapsing reads, actions, and templates into one notion of "tool" throws away the control model. Who decides to invoke it — the model, the application, or the user? That answer should shape the design, and here it's lost.
After
MCP gives servers three primitives, each with a different controller:
- Tools — model-controlled. The model decides when to call them. Actions and queries it reasons about.
- Resources — application-controlled. The host attaches relevant data to context, addressed by URI. The model reads; it does not "call."
- Prompts — user-controlled. Reusable templates the user (or UI) selects deliberately.
// Resource: URI-addressable data the host attaches to context — not a tool call.
[McpServerResourceType]
public sealed class CampaignResources(ICampaignQueries campaigns, AiPrincipal principal)
{
[McpServerResource(UriTemplate = "mattrx://analytics/campaigns/{campaignId}")]
[Description("A campaign record (name, status, budget, audience) for the caller's tenant.")]
public async Task<ResourceContents> GetCampaign(string campaignId, CancellationToken ct)
{
var c = await campaigns.GetAsync(principal.TenantId, campaignId, ct);
return ResourceContents.Json(c); // tenant bound from the principal, as always
}
}
// Prompt: a versioned, user-selected template the server owns.
[McpServerPromptType]
public sealed class AnalyticsPrompts
{
[McpServerPrompt(Name = "campaign_health_review")]
[Description("Structured weekly health review for a campaign.")]
public IReadOnlyList<PromptMessage> CampaignHealthReview(string campaignId) =>
[
PromptMessage.User($"""
Review the health of campaign {campaignId}. Use get_campaign_kpis and
query_events. Flag CTR drops over 15%, budget pacing issues, and audience
fatigue. Return a short bulleted brief.
"""),
];
}
Diagnostic: the win is the control model, not the syntax. A resource lets the host attach exactly the campaign record the user is looking at — by URI — instead of the model guessing which tool to call and pulling back more than it needs. A prompt lets you ship a vetted analysis template instead of hoping the model reinvents it each time.
Mattrx metric: moving "the record the user is currently viewing" from a tool call to an attached resource is part of how we hold context tokens at 3.5k (down from 14k) — the host attaches the right data instead of the model fishing for it.
3. Capability negotiation: the initialize handshake
Before
Clients assumed what servers could do. When a server added or changed a feature, clients that hardcoded the old assumption broke — silently, in production.
// BEFORE: assume the server has what we need and hope.
var report = await client.CallToolAsync("create_report", args, ct); // throws if renamed/missing
Diagnostic: assumption is the enemy of independent deployment. If the client must already know the server's exact shape, you can never ship the server on its own schedule.
After
Every MCP session opens with initialize, where both sides exchange a protocol version and their capabilities. The client then branches on what was actually advertised.
// initialize (client -> server)
{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": { "roots": { "listChanged": true }, "sampling": {} },
"clientInfo": { "name": "mattrx-insights", "version": "3.1.0" }
}
}
// initialize result (server -> client)
{
"jsonrpc": "2.0", "id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true },
"prompts": {}
},
"serverInfo": { "name": "mattrx-analytics", "version": "2.4.0" }
}
}
// AFTER: adapt to negotiated capabilities — never hardcode them.
var session = await client.InitializeAsync(ct);
if (session.Server.Supports(ServerCapability.Resources))
await PreloadCampaignResourcesAsync(client, ct); // only if advertised
if (session.Server.Supports(ServerCapability.Prompts))
await RegisterPromptMenuAsync(client, ct);
Diagnostic: the handshake is small but it is the load-bearing wall of the whole architecture. It is what lets mattrx-analytics ship a v2.5 with a new tool while a v3.0 client and a v3.1 client both keep working — each adapts to what it sees.
Mattrx metric: independent versioning behind this handshake is a major reason the agent tool-call error rate fell to 0.8% — the class of "someone renamed a method and three agents broke" simply stopped happening.
4. Transports: stdio vs Streamable HTTP + SSE
Before
Transport was hardcoded. Everything went over HTTP — even a tool co-located in the same dev process — or everything was local and could never go remote and multi-tenant.
After
In MCP, transport is a property of the deployment, not the server. The same server code runs two ways:
- stdio — the server is a child process of the host. No network, no auth, lowest latency. Perfect for local dev and desktop/co-located tools.
- Streamable HTTP + SSE — a single HTTP endpoint that upgrades to Server-Sent Events for streaming. For anything that crosses a network or a trust boundary, where you also get TLS, OAuth, and horizontal scale.
// Local dev: stdio. The host launches the server as a subprocess.
builder.Services.AddMcpServer()
.WithStdioTransport()
.WithTools<AnalyticsTools>();
// Production: Streamable HTTP + SSE, behind the gateway, multi-tenant.
builder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = false) // keep session for SSE streams
.WithTools<AnalyticsTools>()
.WithResources<CampaignResources>();
var app = builder.Build();
app.MapMcp("/mcp"); // one endpoint; upgrades to SSE when a stream is needed
Diagnostic: choose transport by trust boundary. Inside one process, stdio is simpler and faster and needs no auth. Across a network — between tenants, between your service and a partner's assistant — you need HTTP with TLS and OAuth. Same AnalyticsTools, different wiring.
Mattrx metric: in production the 3 servers run as Azure Container Apps with the HTTP+SSE transport behind the gateway (read p95 120 ms, report-enqueue p95 90 ms, streaming first-token p95 ~300 ms). Locally, developers run the identical servers over stdio with zero auth setup.
The full message lifecycle
Host (mattrx-insights) mattrx-analytics (MCP server)
| initialize {version, capabilities} |
|------------------------------------------>|
| result {version, capabilities} |
|<------------------------------------------|
| notifications/initialized |
|------------------------------------------>|
| --- session established --- |
| tools/list |
|------------------------------------------>|
| [get_campaign_kpis, query_events] |
|<------------------------------------------|
| resources/list |
|------------------------------------------>|
| [mattrx://analytics/campaigns/{id}] |
|<------------------------------------------|
| tools/call get_campaign_kpis {args} |
|------------------------------------------>| -> authorize scope
| | -> bind tenant from token
| result {CampaignKpis} | -> append audit entry
|<------------------------------------------|
| (server) notifications/tools/list_changed |
|<------------------------------------------| server shipped a new tool
| close |
|------------------------------------------>|
Three phases: initialize (negotiate), operate (list/call/read), close. Notifications flow both ways during the session — note tools/list_changed, where the server tells the host it has new capabilities mid-session, and the host can re-discover without reconnecting.
The architecture map, in one place
| MCP concept | Mattrx | Azure / runtime |
|---|---|---|
| Host | Insights + Help runtimes, Python AI service | Azure Container Apps |
| Client | one per server connection | in-process connector |
mattrx-analytics | read tools + campaign resources + prompts | Container App · HTTP+SSE |
mattrx-reports | action tools (create_report) | Container App · → Service Bus |
mattrx-admin | locked tools (flags, exports) | Container App · restricted scopes |
| Transport (prod) | Streamable HTTP + SSE | behind gateway · TLS · OAuth |
| Transport (dev) | stdio | child process · no network |
| Observability | per-message tracing | OpenTelemetry → App Insights |
Architecture checklist
- Name your host, clients, and servers explicitly — one client per server.
- Keep the host ignorant of data access and the server ignorant of the model.
- Pick each capability's primitive by its controller: model → tool, app → resource, user → prompt.
- Expose URL-addressable data as resources, not as tools that return blobs.
- Implement
initializeand branch on advertised capabilities — never hardcode. - Choose transport by trust boundary: stdio in-process, HTTP+SSE across a network.
- Split servers by domain and trust (read vs action vs admin), not one-per-method.
- Put auth, rate-limit, and audit at the host↔server boundary.
The honest stuff: when the full architecture is overkill
You do not need every piece on day one. Skip what you are not using:
- A single co-located tool. Use stdio and tools only. Resources, prompts, and elaborate negotiation are ceremony for a one-tool dev helper.
- Data you always need anyway. If the host attaches the same small record every time, a tool (or just inlining it) can be simpler than a resource with a URI scheme.
- The prompts primitive. If your prompt templates already live in your app and the user never picks them, server-hosted prompts add a moving part you don't need yet.
- Subscriptions and
listChangedyou won't honor. Don't advertiseresources.subscribeortools.listChangedunless a client actually reacts to them — unused capabilities are lies in the handshake. - Over-splitting servers. Three servers by domain is right; fourteen micro-servers re-creates the N×M mess from Part 1 with extra deployment overhead.
- stdio across a trust boundary. stdio in production usually means you co-located a tool you should have isolated. Across tenants or partners, use HTTP + auth.
- Capability negotiation you ignore. If you call
initializeand then hardcode assumptions anyway, you have the cost of the handshake with none of the benefit.
We arrived at three servers and three primitives by need, not by completeness. mattrx-admin exists because some actions deserve their own locked trust domain; resources appeared when we noticed the model fishing for records the host already had. Add a piece when a real problem asks for it.
The model to carry forward
Host owns the loop. Client owns the connection. Server owns the capability. Three roles, three primitives (tool, resource, prompt), one handshake. That sentence is the entire architecture, and almost every MCP bug we have hit was a violation of it — a host reaching into data, a server assuming who called it, a client hardcoding a capability.
Three habits that keep the architecture clean:
- Draw the host/client/server boundary before writing code. Most MCP confusion is role confusion; the diagram prevents it.
- Pick the primitive by control model. Ask "who decides to invoke this — model, app, or user?" and the answer names the primitive.
- Choose transport by trust boundary. stdio inside a process, HTTP+SSE across a network — and put auth exactly where the network starts.
In Part 3 we stop diagramming and start building: a production-grade MCP server from scratch — mattrx-analytics, end to end — with tools, resources, error handling, and the Azure Container App it runs in.
Continue the series — MCP Deep Dive
- Why Model Context Protocol Kills Integration Glue Code for Good
- Inside the MCP Architecture: Hosts, Clients, and Servers (you are here)
- Build a Production-Grade MCP Server From Scratch
- Build an MCP Client That Connects to Any AI Tool
- Custom MCP Tools Your AI Agents Can Actually Trust
- MCP Authentication With OAuth and Entra ID, Done Right
- Scoped Authorization for MCP Tools (Least Privilege for Agents)
- Securing MCP Against Prompt Injection and Tool Abuse
- Streaming and Long-Running Tools Over MCP
- Debugging and Observability for MCP in Production
- Rolling MCP Out Across the Enterprise
- Building MCP Servers in C# and .NET 9
- Hosting MCP on Azure at Real Scale
- Wiring MCP Into OpenAI and Agent Frameworks
- Running MCP in Production — Lessons From Mattrx
Further reading
- MCP Deep Dive, Part 1: Why Model Context Protocol Kills Integration Glue Code for Good
- MCP Deep Dive, Part 3: Build a Production-Grade MCP Server From Scratch
- AI-Native Architecture: The 9-Layer Blueprint Every Enterprise Will Adopt by 2027
Mapping your own host/client/server boundaries and want a sanity check? I'm always happy to compare notes — reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.