You can stand up an MCP server that returns data in an afternoon. Standing up one that an agent hammers 85,000 times a day, across tenants, without leaking stack traces, OOM-ing on a big query, or dropping calls on deploy — that's a service, not a demo. This part builds the real thing.
This is Part 3 of a 15-part deep dive on Model Context Protocol (MCP). We build one of the three Mattrx servers — mattrx-analytics, our .NET 9 read server — end to end.
TL;DR
| Concern | Demo server (before) | Production server (after) |
|---|---|---|
| Bootstrap | hand-rolled JSON-RPC | MCP SDK + DI + transport |
| Tools | stringly-typed blob args | described, typed params → real schema |
| Errors | exceptions leak / 500 | tool errors vs protocol errors |
| Data | everything is a "tool" | read data as resources (by URI) |
| Results | return everything | paginated, capped, cancellable |
| Ops | no health / telemetry | /healthz, /readyz, OTel per call |
-
mattrx-analyticsserves ~85k tool calls/day at read p95 120 ms. - Typed tool schemas + typed errors are a big part of why the agent tool-call error rate is 0.8%.
- Cap + paginate every result (page ≤ 200, opaque cursor); honor
CancellationToken.
The one mental shift: an MCP server is a service, not a script. Everything you'd demand of a production API — schemas, typed errors, pagination, cancellation, health, telemetry — an MCP server needs too, because an agent is a more demanding, less forgiving client than a human.
1. Bootstrap: use the SDK, not hand-rolled JSON-RPC
The MCP SDK gives you the server; you register capabilities through DI, reusing the same domain services as the rest of the app.
// Program.cs — the mattrx-analytics MCP server.
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMcpServer(o => o.ServerInfo = new() { Name = "mattrx-analytics", Version = "2.4.0" })
.WithHttpTransport(o => o.Stateless = false) // Streamable HTTP + SSE; keep session for streams
.WithTools<AnalyticsTools>()
.WithResources<CampaignResources>()
.WithPrompts<AnalyticsPrompts>();
builder.Services.AddScoped<ICampaignQueries, CampaignQueries>();
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource("Mattrx.Mcp"));
builder.Services.AddHealthChecks().AddCheck<AzureSqlHealthCheck>("sql", tags: ["ready"]);
var app = builder.Build();
app.MapMcp("/mcp"); // the MCP endpoint
app.MapHealthChecks("/healthz"); // liveness
app.MapHealthChecks("/readyz", new() { Predicate = c => c.Tags.Contains("ready") });
app.Run();
The SDK owns the protocol (framing, initialize, tools/list, schema emission) so you own only your capabilities. Hand-rolling JSON-RPC is effort spent re-creating a solved problem, with new bugs.
2. Tools done right — typed params, real schemas
A precise name and described, typed parameters (the SDK turns these into a JSON Schema the model reads), returning a structured type:
[McpServerToolType]
public sealed class AnalyticsTools(ICampaignQueries campaigns, AiPrincipal principal)
{
[McpServerTool(Name = "get_campaign_kpis")]
[Description("Return the KPI time-series for one campaign in the caller's tenant.")]
public async Task<CampaignKpis> GetCampaignKpis(
[Description("Campaign id (GUID) within the caller's tenant.")] string campaignId,
[Description("ISO-8601 date range, e.g. 2026-06-01/2026-06-30.")] string range,
CancellationToken ct)
{
var window = DateRange.Parse(range);
return await campaigns.GetKpisAsync(principal.TenantId, campaignId, window, ct);
}
}
The model calls a tool from its schema. A described, typed signature is the schema — the model fills it correctly. A string args blob makes the model guess, and every guess is a failed call.
3. Error handling — tool errors vs protocol errors
A tool error is a result (isError: true) the agent can read and recover from. A protocol error is for a malformed request. Unexpected exceptions are logged server-side and returned as a safe, generic tool error — never a stack trace.
public async Task<CallToolResult> GetCampaignKpis(GetKpisArgs args, CancellationToken ct)
{
if (!Guid.TryParse(args.CampaignId, out var id))
return ToolResults.Error("invalid_campaign_id", "campaignId must be a GUID.");
var campaign = await campaigns.FindAsync(principal.TenantId, id, ct);
if (campaign is null)
return ToolResults.Error("not_found", $"No campaign {id} in this tenant.");
try
{
var kpis = await campaigns.GetKpisAsync(principal.TenantId, id, args.Range, ct);
return ToolResults.Structured(kpis); // success: structured content
}
catch (Exception ex)
{
logger.ToolFailed(ex, "get_campaign_kpis"); // full detail stays server-side
return ToolResults.Error("internal", "The tool failed; try again shortly."); // safe surface
}
}
If the agent gets a dead connection instead of a readable not_found, it can't adapt — and if it gets your SQL exception text, you've leaked your schema.
4. Resources — not everything is a tool
Expose read data as a resource with a URI template and a content type; the host attaches it by URI, the model reads it.
[McpServerResource(
UriTemplate = "mattrx://analytics/campaigns/{campaignId}",
MimeType = "application/json")]
[Description("A campaign record (name, status, budget, audience) in the caller's tenant.")]
public async Task<ReadResourceResult> GetCampaign(string campaignId, CancellationToken ct)
{
if (!Guid.TryParse(campaignId, out var id))
return ResourceResults.NotFound(campaignId);
var c = await campaigns.FindAsync(principal.TenantId, id, ct);
return c is null
? ResourceResults.NotFound(campaignId)
: ResourceResults.Json($"mattrx://analytics/campaigns/{campaignId}", c);
}
Model-controlled actions are tools; application-controlled data is a resource. Serving "the record the user is viewing" as a resource instead of a tool call is part of how we hold context tokens at 3.5k (down from 14k).
5. Pagination, caps, and cancellation
Cap the page size, return an opaque cursor (never OFFSET on a huge table), and honor cancellation:
[McpServerTool(Name = "query_events")]
[Description("Query a campaign's events, newest first. Returns one page; pass `cursor` to continue.")]
public async Task<EventPage> QueryEvents(
[Description("Campaign id (GUID).")] string campaignId,
[Description("Opaque cursor from a previous page, or null for the first page.")] string? cursor,
[Description("Page size, 1-200 (default 50).")] int pageSize = 50,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 200); // the model does NOT get to ask for 180M rows
var page = await events.QueryAsync(principal.TenantId, campaignId, cursor, pageSize, ct);
return new EventPage(page.Items, page.NextCursor); // cursor-based, not OFFSET
}
An unbounded tool result is a double foot-gun — memory on the server, and cost + confusion in the model's context. Capping pages at 200 with cursors keeps query_events at read p95 120 ms even against the 180M-row Events table.
6. Health, readiness, and telemetry
Expose /healthz (liveness) and /readyz (readiness gated on dependencies), and emit one OpenTelemetry span per tool call. Readiness lets Azure Container Apps roll deploys without dropping traffic.
using var activity = ActivitySource.StartActivity("mcp.tool_call");
activity?.SetTag("mcp.tool", toolName);
activity?.SetTag("mattrx.tenant", principal.TenantId);
var result = await next(ct);
activity?.SetTag("mcp.outcome", result.IsError ? "error" : "ok");
An agent won't tell you it's getting errors — it'll just quietly perform worse. Per-call spans turn "the assistant feels off" into a chart of p95 and error rate per tool and per tenant.
When the full build is overkill
- A local stdio dev tool. A few tools over stdio needs none of the HTTP/readiness/scaling machinery.
- Everything-as-a-tool. Reads that just fetch a record are resources.
- Skipping result caps. The single most common production MCP mistake — cap and paginate from line one.
- Leaking internals in errors. Log server-side; return safe strings.
- Hand-rolling the protocol. The SDK does framing, negotiation, schema generation.
- In-memory session state. Keep the server stateless; let the transport/gateway own session.
The model to carry forward
An MCP server is a service, not a script. An agent is a relentless, literal, unforgiving client: it calls from your schemas, it retries on your errors, it will happily ask for a billion rows.
- Type your tools. Described, typed parameters are the schema the model depends on.
- Make errors data, not exceptions. Return a tool error the agent can reason about; log the detail, never leak it.
- Cap and paginate everything. Treat every tool as if it could match a billion rows.
Originally published on PrepStack. Building an MCP server and want a second pair of eyes on your tool contracts or error handling? Reach me at randhir.jassal[at]gmail.com.
Top comments (0)