Designing an MCP-Based System
A complete worked example — an internal developer-tools MCP server, reachable by engineers, a shared team config, and an on-call agent
Designing an MCP-Based System
TL;DR
This page takes one realistic brief — an internal developer-tools MCP server wrapping incident management, deploy status, and internal docs search — and makes every decision with a reason attached: which primitive each piece of functionality should be, why Streamable HTTP over stdio, what the OAuth scopes should look like, and how the server evolves without breaking clients already connected to it.
| Property | Value |
|---|---|
| Level | Advanced — brings together every previous page |
| Reading time | ~25 minutes |
| Prerequisites | Failure Modes & Debugging |
| You will understand | How to go from a vague internal-tooling brief to a sized, justified MCP system |
Part 1 — The Brief
Our engineers spend a lot of time context-switching between the incident tracker, the deploy pipeline dashboard, and internal docs search when something's on fire. We want one MCP server that exposes all three, so Claude Code (used locally by engineers), a shared Claude Desktop config (for the whole team), and a custom on-call agent can all reach the same underlying systems without three separate bespoke integrations.
Turning that into requirements:
| Requirement | Value |
|---|---|
| Data sources to wrap | Incident management system, deploy pipeline status, internal docs search |
| Clients | Individual engineers via Claude Code (many, concurrent), a shared team Claude Desktop config, one custom on-call agent |
| Sensitivity | Incident data is sensitive — access needs real control, not "anyone on the network" |
| Scale of responses | Incident logs can be large; docs search results need to stay bounded |
| Longevity | Internal systems evolve — the server needs a plan for changing without breaking connected clients mid-migration |
This is a multi-client, sensitive-data, long-lived server — the opposite of a quick local demo. Every design choice below follows from those three properties, not from what's simplest to stand up first.
Part 2 — Primitive Design
The first real decision: for each piece of functionality, is it a tool, a resource, or a prompt?
Mapping functionality to primitives
Incident data — read
RecommendedResource template: incidents://active and incidents://{id} — read-only, application-controlled context an engineer or agent pulls in to understand what's happening, not an action.
Deploy status — read
RecommendedResource template: deploys://{service}/status — same reasoning, passive context.
Docs search
RecommendedTool: search_docs(query: string) — this is model-controlled and action-shaped even though it's read-only, because the decision to search and what to search for is something the model actively chooses mid-task, unlike a resource the application decides to include upfront.
Trigger a rollback
Tool, with mandatory elicitation: trigger_rollback(service, target_version) — a real, consequential action. This is exactly the case Elicitation exists for: confirm with the human before it executes, every time.
On-call runbook
Prompt: /oncall-runbook — a reusable, user-invoked template that walks through the standard triage steps, referencing the resources and tools above. User-controlled, not something the model reaches for unprompted.
Notice the pattern: read-only, application-assembled context is a resource; anything the model actively decides to do is a tool; a reusable, user-invoked workflow is a prompt. Applying that test to each piece of functionality is most of the primitive-design work — the tricky cases (like docs search, which is read-only but model-initiated) are the ones worth thinking through explicitly rather than defaulting.
Part 3 — Transport and Authorization
Why Streamable HTTP, not stdio
Transport choice
stdio
Rules itself out immediately — it typically serves exactly one client, and this brief needs many engineers, a shared team config, and a service client, all connecting concurrently.
Streamable HTTP
RecommendedThe only transport that fits multiple concurrent clients and remote reachability, matching every client in the brief. This decision was effectively made the moment "many engineers plus a service client" appeared in the requirements.
Authorization: OAuth with real scopes
Incident data is sensitive, which per Authorization & Security means this server needs to be a real OAuth 2.1 resource server, not an unauthenticated endpoint behind network-level trust alone.
Scopes:
incidents:read — view active/historical incidents
deploys:read — view deploy status
docs:search — search internal documentation
deploys:write — trigger a rollback (the consequential one)deploys:write is the scope that matters most to get right. Per the step-up authorization flow from Authorization & Security, a client holding only the read scopes should hit a 403 insufficient_scope when attempting trigger_rollback, and go through step-up authorization to obtain it — not have it bundled into a broad default scope every client gets automatically.
Part 4 — Output Size and Reliability
Two production concerns from Production & Operations apply directly here:
Incident logs can be huge. incidents://{id} needs a bounded default — return a summary plus the most recent N log lines, with a resource template parameter (incidents://{id}?tail=200) for callers that genuinely need more, rather than dumping an entire incident's raw log history into every read.
"New incident" notifications can't be trusted alone. Per the best-effort delivery guarantee covered in Production & Operations, an on-call agent that relies solely on a resources/updated notification for incidents://active can miss a new incident if the notification is dropped across a reconnect. The agent needs to poll incidents://active periodically regardless, treating notifications as a latency optimization on top of that, not a replacement for it.
Part 5 — Versioning Plan
Internal systems this server wraps will change — the incident tracker's API evolves, a new deploy pipeline replaces the old one. The server needs to keep already-connected engineer clients working through that.
{
"result": {
"supportedVersions": ["2026-07-28", "2026-04-01"],
"capabilities": { "tools": {}, "resources": {} }
}
}A migration, end to end
Ship the new server version supporting both protocol versions
Old and new clients both keep working, unnoticed
Announce the deprecation window
Give engineers real time to update their local Claude Code / Desktop configs
Drop the old version from supportedVersions
Any client still requesting it now gets a clear UnsupportedProtocolVersionError, not a silent failure
The Resulting Design
Internal developer-tools MCP server, end to end
Primitives
Transport
Auth
Reliability
Versioning
Concept Checks
Check yourself
Why is docs search a tool rather than a resource, even though it's read-only like the incident and deploy data?
Because the distinguishing factor isn't read-vs-write, it's who decides when it happens. Resources are context the application assembles and includes; docs search is something the model actively decides to invoke, with a query it constructs, mid-task — that model-initiated, action-shaped quality is what makes it a tool despite being read-only.
Why does trigger_rollback need elicitation specifically, rather than just being a tool requiring the deploys:write scope?
Because the OAuth scope controls whether a client is authorized to attempt the action at all, while elicitation adds a human-in-the-loop confirmation at the moment of execution — two different, complementary controls. A client holding deploys:write could still fire an unintended rollback without a confirmation step; elicitation is what catches that specific case even when authorization already permits it.
Why can't the on-call agent rely solely on resources/updated notifications for new incidents?
Because MCP notification delivery is explicitly best-effort, with no guarantee every notification is sent or received, especially across a reconnect. An agent depending only on that notification could miss a genuinely new incident with no other signal; polling incidents://active periodically as a backstop closes that gap, with notifications only improving average latency on top of it.
Key Concepts Recap
| Concept | One-line summary |
|---|---|
| Primitive choice follows control, not just read/write | Application-assembled context is a resource; model-initiated action is a tool, even if read-only |
| Multi-client requirements decide the transport | Streamable HTTP was the only real option once "many clients" appeared in the brief |
| Scope consequential actions separately | deploys:write behind step-up auth, not bundled into a default read scope |
| Elicitation and authorization are complementary | A scope controls who's allowed; elicitation confirms the specific action in the moment |
| Bound anything that can be large | Incident logs get a default cap with a parameterized way to ask for more |
| Poll behind any notification you rely on | Best-effort delivery means notifications improve latency, not replace correctness |
| Version honestly for a real migration window | supportedVersions lets old and new clients coexist during a planned transition |
Next
That completes the worked example. Glossary closes out the track as a reference for every term used along the way.
Failure Modes & Debugging
Symptom to cause across discovery, schemas, transport, and auth — the MCP bugs that show up again and again, and the fix for each
Glossary
Every MCP term and abbreviation used across the fundamentals track, grouped by theme — architecture, primitives, deprecated features, transport, auth, and extensions