PROJECT · 90 minutes
Capstone: build the complete de-identified support-triage MCP server — KB resources, ticket history, and three triage tools — then test it end-to-end with Claude.
Across the previous four modules, I built each piece of the support-triage MCP server in isolation: module 02 scaffolded the server, module 03 added the knowledge-base and ticket-history resources, and module 04 wired up the classify, route, and escalate tools. Each piece worked on its own. The triage system became genuinely useful only when resources and tools operated together in a single server.
Think of it like a reference library and a set of action buttons inside the same room. A support agent who can only read the manual is a search engine. One who can only press buttons will escalate every ticket blindly. The integrated server gives Claude access to both at once: it can look up the refund policy, read the recent open tickets, classify the issue, and route it — all in a single conversation turn.
That combination also changes how the AI reasons. When Claude has both the KB and the ticket queue available, it can match a ticket's subject against known issue patterns in the knowledge base before deciding how to classify it. The resources and tools are not independent — the resources inform how the tools are used.
Before I built the MCP server, the support-automation system used separate integrations for each data source: one HTTP client to query the KB, another to pull tickets from the queue, and a third to post routing decisions. Each integration had its own auth, its own error handling, and its own schema. Adding a new AI model meant rewiring all three.
MCP replaced that custom wiring with a single protocol. The AI connects to one server and discovers every resource and tool through a standard listing endpoint. Adding a new model — or swapping to a different one — requires no changes to the server at all.
The deeper benefit was maintainability. The KB client, the ticket client, and the routing client each needed separate testing and separate monitoring. The MCP server consolidates all of that into one place: one set of tests, one log stream, one place to add auth. The protocol does not just simplify the AI integration — it simplifies the whole data-access layer.
In this capstone I assemble the complete server: both resources, all three tools, and an end-to-end test that connects Claude to the finished system. By the end, you will have a working triage server you can extend — not a pedagogical sketch, but a real starting point.
Scope: this is the smallest complete server that demonstrates the full resource-plus-tool workflow. Authentication, rate limiting, and observability are addressed briefly in the "Going Further" section — not as full implementations. A real system adds those layers around the same MCP surface shown here.
The server exposes two resources — knowledge-base://articles/{id} (KB policy documents) and tickets://recent (live open ticket queue) — and three tools: classify_ticket, route_ticket, and escalate_ticket. Each piece does one job. Together they handle a complete triage cycle.
The foundation is unchanged from module 02: create the server with typed capabilities, wire a transport, and connect. The key difference here is that the capabilities object must declare both resources and tools. The SDK uses that declaration to determine which typed request schemas to accept from MCP clients.
This matters more than it might appear. The capabilities object is the server's public contract. If you declare only resources, a client checking the contract will know not to call tool endpoints — it will not even try. Declaring both resources and tools up front is an honest statement about what your server can do.
TypeScript reinforces this. If you register a CallToolRequestSchema handler but forget to declare tools: {}, the server compiles without error but rejects tool calls at runtime. Declaring capabilities in code makes the intent visible to both the SDK and your teammates.
All four schema imports appear here together for the first time. In the isolated modules, only the resource schemas or only the tool schemas were needed. In the capstone, all four are required because this single server handles the complete cycle: listing and reading resources, listing and calling tools.
The import path @modelcontextprotocol/sdk/types.js is where the MCP SDK exports the typed schemas. Using the typed schemas rather than plain strings is what gives TypeScript the information it needs to type-check your handler arguments. If you ever see a handler whose argument is typed as unknown, that is usually a sign the wrong form of setRequestHandler was called.
TypeScript strict mode: if your project uses strict: true in tsconfig.json, you may need to add type assertions when accessing args properties inside tool handlers, as the SDK types arguments as unknown. The as { subject: string } assertions in the tool handler below are the standard pattern for this.
Capabilities matter: omit resources: {} and MCP clients will not discover your resources even though the handlers are registered. Declare everything you intend to expose before registering any handlers.
Resources are the read-only shelf of the server. Claude picks articles and ticket data off the shelf to inform its decisions but cannot change what is on it. That read-only guarantee matters when the support team shares ticket data with the AI: it can read and recommend, but it cannot modify records directly. The boundary between reading and acting is enforced by the protocol, not just by convention.
The two resources the triage server exposes are fundamentally different in character:
tickets://recent), fetched fresh on each read. The AI gets the latest open tickets every time it asks.This difference — stable individual items vs. live aggregate — is a useful design pattern. Stable data maps naturally to individual-item URIs; live aggregates map naturally to a single endpoint.
Start by defining the data layer. In production this would be database calls; here in-memory arrays keep the focus on the MCP surface rather than persistence details.
The interface definitions are more than type hints. They document the shape that the AI can expect to receive. When Claude reads tickets://recent, it receives a JSON array of SupportTicket objects — the id, subject, status, and priority fields are what it works with.
Keeping the data types explicit also makes future refactoring safer. If the ticket schema changes — say, you add a customerId field — TypeScript will surface every place in the server that needs to be updated. That compile-time feedback is especially valuable in an MCP server where the handler code is the contract between the AI and your data.
Now register the ListResourcesRequestSchema handler so Claude can discover what data is available. Both resources appear in a single listing. The AI requests specific ones from the same menu, choosing based on the task at hand.
The mimeType field tells Claude what to expect. A ticket-triage task would prompt it to fetch tickets://recent (JSON array); a question about the refund timeline would prompt it to read knowledge-base://articles/kb-001 (plain text). Claude uses the name and MIME type together to decide which resources are relevant.
When Claude lists resources and then reads one, the conversation looks like two sequential MCP calls: resources/list to discover what is available, then resources/read with the specific URI. The AI drives both calls — the server just responds. This pull model (AI requests data) is the opposite of a push model (server sends data as events), and it is why resources are described as a "shelf" rather than a "feed."
The ReadResourceRequestSchema handler routes to the correct data source based on the URI. One handler manages both resources — a startsWith check for KB articles, an exact match for the ticket queue:
The final throw is not a catch-all — it is an intentional rejection of URIs the server does not recognize. If Claude requests a URI that was never listed, the server says so clearly rather than returning empty content. Failing loudly on a bad URI prevents the AI from producing an answer grounded in nothing.
Before moving on, predict what happens when Claude requests knowledge-base://articles/kb-999. Trace through the handler: the startsWith check passes, articleId becomes "kb-999", knowledgeBase.find returns undefined, and the handler throws "Article not found: kb-999". The MCP client surfaces that as an error — which is the right behavior. Silent empty-content returns are harder to debug than explicit errors.
Tools are the power outlets of the server. When Claude plugs into a tool, it triggers a real-world side effect: assigning a ticket to a queue or paging a human agent. Unlike resources — which are safe to call repeatedly without consequence — tool calls change state. Routing a ticket notifies the queue; escalating it alerts a human. Those effects persist after the conversation ends.
That asymmetry is why the resource/tool distinction exists in the protocol, not just as a naming convention. The AI can freely browse the knowledge base. It cannot freely fire escalations. The structure of the protocol reflects the structure of the responsibility.
Each tool handles one transition in the ticket lifecycle:
classify_ticket: takes a ticket subject, returns a category and urgency level. This is a decision point, not a side-effect step — it produces information used to make the routing decision.route_ticket: takes a ticket ID and a queue name, assigns the ticket. This changes state in the support system.escalate_ticket: takes a ticket ID and a reason, pages a human agent. This is the highest-consequence action — it interrupts a person.Register all three in a single ListToolsRequestSchema handler so Claude sees the complete menu at once. The tool descriptions are the most important part of this registration — Claude reads them to decide which tool to call and when:
The enum constraint on queue is deliberate. By listing valid queue names in the schema, Claude is constrained to choose from the options you have defined — it cannot invent a queue name that does not exist in your system. Input schema constraints are your first line of validation before any business logic runs.
The CallToolRequestSchema handler switches on the tool name and executes the appropriate logic. Each branch returns a content array with a text item — the standard MCP tool response shape:
The text responses from each tool become part of Claude's context for the next step. When classify_ticket returns {"category":"account","urgency":"medium"}, Claude reads that result and uses it to decide what to pass to route_ticket. The tools are a chain, and the response from each step feeds the next.
Stub classification logic: the keyword matching in classify_ticket demonstrates the MCP surface, not a production classifier. A real implementation would call a model or a structured service. The value here is the tool handler shape, not the business logic inside it.
The escalate_ticket tool fires with no guardrails — whatever the AI decides to pass as the reason goes straight through. What would you add to prevent false escalations? Consider two levels: immediate validation (is this ticket in a state where escalation makes sense?), and longer-term audit (how would you trace which AI invocation triggered each escalation in production?).
This is not a hypothetical concern. A tool that pages a human at 3 AM has real cost if it fires incorrectly. The responsibility for sensible guardrails sits with the tool author, not with the AI. The AI does not know what "too many escalations" looks like for your organization — you do. Design tools accordingly.
With all handlers registered, connect the transport to make the server live. Any MCP-compatible client can now discover and use both the resources and the tools:
The server is now running, waiting on stdin for MCP protocol messages. A client — Claude Desktop, your own test harness, or any MCP-compatible tool — can connect and send requests.
To verify the complete flow, the following test script uses the Anthropic SDK to call Claude directly with the tool definitions. Claude reads the schemas, decides which tools to call and in what order, and returns structured tool-use content. This mirrors what any MCP client does under the hood.
In a full MCP deployment, the AI client (Claude Desktop, for example) connects to the server process via stdio and calls resources/list and tools/list at startup. Our test script skips the MCP transport layer and calls Claude directly with the tool schemas — a useful way to test the schema design and description quality before wiring up the full protocol stack.
The test provides only the two most relevant tools — classify_ticket and route_ticket. When you give Claude a narrower tool set, it can reason about the task more efficiently. Expose what the task needs, not the full server inventory. Including escalate_ticket when the task does not call for escalation introduces unnecessary ambiguity.
When you run this, Claude reads the tool schemas and calls classify_ticket with the subject text first. It receives {"category":"account","urgency":"medium"}, then calls route_ticket with t-001 and an appropriate queue. The full triage cycle — classify then route — happens in one conversation turn.
You did not tell Claude to classify before routing. It inferred that order from the task description and the tool schemas. That emergent tool orchestration is what MCP enables: the intelligence is in the AI; the structure is in the protocol.
What this demonstrates: Claude reasons about which tools to call and in what sequence based on the tool descriptions alone. The server exposes capabilities; the AI decides how to use them. That division is why MCP scales — adding a new tool does not require changing the AI or the client.
The capstone brought together everything from the previous four modules. Before looking forward to what comes next, it is worth reviewing the structure of what you have assembled — not just the code, but the design decisions embedded in it.
Looking back at the five modules in this path, you have built a complete MCP server from first principles:
The key design choice that runs through all five modules is the resource/tool distinction. It is not a naming convention — it is a structural decision about what the AI can do and what consequences flow from its actions. Resources are read-only data stores. Tools are operations with side effects. Keeping those categories separate makes MCP servers easier to reason about, test, and audit.
The server above is complete but not hardened. Three areas to address before putting it in front of real traffic:
Authentication: MCP over stdio runs inside a trusted process boundary, which is often sufficient for internal tooling. For HTTP transports, add bearer-token validation in a middleware layer before the request reaches your handlers. The handlers themselves do not change — auth wraps around the protocol layer.
Rate limiting: tool calls that trigger external actions — routing, escalating — should be rate-limited per client to prevent runaway automation. A simple in-memory counter keyed on client identity is a reasonable starting point for low-traffic deployments. Track call counts per time window, return an error when the limit is exceeded.
Observability: log every tool call with its input, output, and latency. Resources that return large payloads are worth timing separately. When something goes wrong in production, you want the full call trace — which tool was called, with what arguments, and what it returned — not just an exception message. Assign a request ID to each MCP session so you can correlate traces across calls.
None of these changes alter the MCP surface or the handler logic. They wrap around it. That clean separation between protocol (handlers) and infrastructure (auth, limits, logging) is one of the design strengths of the SDK.
One more area worth considering as you extend this server: error messages. The errors thrown in the current handlers ("Article not found: kb-001", "Unknown tool: xyz") are developer-facing strings. In a production server, you would want error codes alongside messages, so clients can handle them programmatically rather than parsing string content. The MCP spec leaves error representation to the implementation, so this is a design choice you own.
Finally, test your tools with adversarial inputs before deploying. What happens if the subject passed to classify_ticket is an empty string? What if route_ticket receives a queue value that passes schema validation but does not match any real queue in your system? The schema constraints catch many invalid inputs, but not all. Layering explicit checks inside the handler is defense-in-depth.
Apply what you have built to reason about MCP resource and tool design.
1. Your team wants to add a 'get customer contact details by customer ID' feature. Should it be a resource or a tool?
Correct Answer: B
Resources are read-only data stores identified by URIs; they can be parameterized via URI (e.g., customers://contacts/cust-001). Tools are operations with side effects. Fetching read-only data from a store is a resource, not a tool.
2. In the capstone server, both KB articles and tickets://recent appear in the same ListResourcesRequestSchema response. Why does a single handler work for both?
Correct Answer: B
ListResourcesRequestSchema is registered once and its handler returns a single resources array. That array can describe any number of resources from any source — KB articles, ticket queues, agents, or anything else the server knows about.
3. Which of the following is the clearest reason route_ticket is implemented as a tool rather than a resource?
Correct Answer: C
The resource/tool distinction is about side effects, not parameter count or return type. Route_ticket changes the system by assigning a ticket — an irreversible action. That is the defining characteristic of a tool.
4. Why does the ReadResourceRequestSchema handler throw an Error when a KB article is not found, rather than returning an empty string?
Correct Answer: B
Fail-loud is a deliberate design choice. If the handler returned empty content, Claude might proceed with an answer grounded in nothing — and the user would get a confident but wrong response. A visible error is easier to diagnose and fix.
5. What happens if you omit `resources: {}` from the capabilities object when creating the Server?
Correct Answer: B
The capabilities object is the server's public declaration of what it supports. Clients check it before sending resource discovery requests. Omitting resources: {} means the client never sends ListResourcesRequestSchema, so your handlers never run.
6. The end-to-end test provides both classify_ticket and route_ticket as tools. What determines which tool Claude calls first?
Correct Answer: C
Claude reads the tool descriptions and the user message to decide which tools to call and in what sequence. It inferred that classification should precede routing from the task description — not from any ordering in the tools array.
7. A team member suggests a `tickets://summary/t-001` resource that returns a Claude-generated summary. What is the strongest objection?
Correct Answer: B
Resources are meant to return stored or efficiently computable data. A resource that calls a model on every read blurs the read/action boundary, makes caching unreliable, and hides a non-trivial cost behind a simple URI. A tool makes the intent explicit.
Extend the triage server with a tickets://detail/{id} resource that returns a single ticket by ID. This is the individual-item counterpart to tickets://recent — just as KB articles are accessed individually via their IDs, tickets should be too.
Steps:
ListResourcesRequestSchema, spread recentTickets.map(...) to expose each ticket as an individual URItickets://detail/ URI branch to ReadResourceRequestSchema that extracts the ticket IDrecentTickets; throw a clear error if not foundmimeType: "application/json"Hint: Extract the ID with uri.replace("tickets://detail/", ""). The list spread looks like ...recentTickets.map(t => ({uri: "tickets://detail/" + t.id, ...})).
Worked Solution — expose individual ticket URIs in the resources list
<CodeExample title="ListResourcesRequestSchema spread" language="typescript"> // Add alongside tickets://recent in your resources array: ...recentTickets.map((t) => ({ uri: "tickets://detail/" + t.id, name: "Ticket " + t.id, description: t.subject, mimeType: "application/json", })), </CodeExample>Nice work. Here's what's next.
Back to this pathOr book the diagnostic call