CONCEPT · 50 minutes
Implement the three support-triage tools — classify, route, and escalate — and understand how MCP tools trigger real-world state changes
In Module 3, I exposed the support-automation system's knowledge base and ticket history as MCP resources. The AI could read every KB article and browse recent tickets without any intervention from me. That was the right call, because reading carries no risk: if the AI reads the wrong article, nothing breaks. The KB is still intact. The ticket queue is undisturbed.
But classifying a ticket as high-severity, routing it to the payments queue, or paging an on-call engineer? Those are actions. They change state, notify people, and cannot be undone by reversing the call. They belong in tools, not resources.
In the support-automation system, I discovered this distinction the hard way. An early prototype exposed "route ticket" as a resource URI (tickets://route/{id}) just because it felt symmetric. The problem: AI clients read resources speculatively to understand what is available. The first time an AI listed available resources to orient itself, it silently routed three live tickets to the wrong queue. After that, routing became a tool — something the AI must deliberately invoke, not passively discover.
The line is simple:
The core distinction: if the operation would appear in your system's audit log, it is a tool. If the AI is only observing, it is a resource.
Think of a tool like a power outlet. Plugging in triggers something real — a fan spins, a heater warms, a machine starts. Unplugging does not reverse what already happened; it just stops future current. MCP tools work the same way: calling escalate_ticket sends a page. There is no "un-escalate" endpoint on the pager system.
This analogy matters for design. Because tools have side effects, they need three things:
In the support-triage server, classify_ticket is idempotent: classifying the same ticket twice produces the same category. route_ticket is not: routing a ticket that is already routed could double-assign it.
Before you build each tool: decide whether it is idempotent and document that in the tool description. The AI can then reason about whether retrying on failure is safe.
The MCP TypeScript SDK provides ListToolsRequestSchema for the tool-listing handler. The AI calls this first, reads the schemas, then decides which tool to invoke. Notice the import: this is the typed schema form, not the string "tools/list".
The other two tools follow the identical shape. route_ticket takes { ticket_id, category } — where category comes from a prior classify_ticket call. escalate_ticket takes { ticket_id, reason } — a freeform explanation of why escalation is needed.
A note on tool descriptions: the text in description is what the AI reads to decide whether to call the tool. Write it as a verb clause ("Classify a support ticket...") that tells the AI what the tool does and when to use it. Vague descriptions produce unpredictable tool selection — the AI picks based on semantics, not on your intentions.
Before writing the handlers, predict the answers to these:
classify_ticket or route_ticket? What happens if it calls them out of order?escalate_ticket fails because the pager API is down, should the handler return isError: true or throw an uncaught exception? What is the difference from the AI's perspective?Keep those answers in mind as you read each implementation below.
CallToolRequestSchema is the handler for tool invocations. The AI supplies the tool name and arguments; the handler dispatches on name, validates inputs, and runs the logic.
First, the classifier helper — a simple keyword-based function that maps ticket content to a category:
The classify function is intentionally simple — it uses keyword matching, which is fast but brittle. In a production support system, you would replace it with an embedding-based classifier or an LLM call that understands context ("my payment didn't go through" maps to billing even without the word "charge"). The MCP handler does not care which approach you use; it calls classify(content) and moves on.
Now the CallToolRequestSchema handler. The classify_ticket branch uses that helper and returns a JSON string the AI can parse for the next tool call:
Good pattern: return JSON strings as the text content. The AI parses the JSON and can chain the category value directly into the next route_ticket call — no manual extraction required.
Routing takes the category from classification and assigns the ticket to the right support queue. Add this branch inside the existing CallToolRequestSchema handler, before the final throw:
The ?? fallback handles unknown categories gracefully: if classify_ticket somehow returns a value not in queueMap, the ticket lands in tier-1-support rather than causing an unhandled error.
When the AI uses the full triage server, the typical call sequence looks like this: first classify_ticket runs on the incoming ticket text, then route_ticket takes the returned category and assigns the queue. Escalation is optional — it only happens when the AI (or a downstream rule) determines the situation is urgent enough to page a human.
Escalation is the highest-stakes tool in the server — it pages an on-call engineer, which has real cost and interruption impact. The handler must validate the reason before proceeding and return a structured error if escalation is not warranted.
Three patterns to note here. First, validate before acting: check the reason length before touching any external system. Second, isError: true: setting this flag signals failure to the AI so it can decide whether to retry, apologize to the user, or try a different approach. Third, catch external errors: any real pager API call belongs in try/catch, and the error surfaces as a structured response — never as an uncaught exception that crashes the handler.
Reflective prompt: What guardrails would you add to escalate_ticket to prevent false escalations — for example, if the AI misclassifies a low-severity billing question as a critical outage? Think about rate limits, severity thresholds, or a confirmation step before you move on.
The three tools above cover the core support-triage loop. In a production deployment, you would also add:
These patterns live outside the MCP protocol itself — they are server-side concerns implemented inside handler functions. MCP gives you the hooks; you supply the guardrails.
Application-level questions about MCP tools, the power outlet analogy, and the support-triage server implementation.
1. What determines whether a support-system operation should be a tool rather than a resource?
Correct Answer: B
Resources are read-only — the AI reads without leaving a trace in the system. Tools trigger state changes: assigning a ticket, paging an engineer, updating a record. If the operation would appear in an audit log, it is a tool.
2. What does setRequestHandler(ListToolsRequestSchema, ...) give you that setRequestHandler("tools/list", ...) does not?
Correct Answer: B
The typed schema form lets TypeScript infer the exact shape of request.params and the expected return type. String-based registration is untyped — the compiler cannot catch shape mismatches between your handler and the MCP protocol.
3. Which of the following should be a tool, not a resource, in the support-triage server?
Correct Answer: C
Reading KB articles, browsing ticket history, and checking schedules are all read-only — perfect resources. Sending a pager notification changes state in an external system and cannot be undone, making it a tool.
4. The escalate_ticket handler checks reason.trim().length < 10 before escalating. A teammate suggests removing this check because the AI "always provides good reasons." What is the problem with this reasoning?
Correct Answer: B
Tool handlers are the last line of defense before touching external systems. The AI client might be misconfigured, upgraded, or replaced. Validating inputs in the handler ensures safe behavior regardless of who calls it.
5. route_ticket uses queueMap[category] ?? "tier-1-support". If the AI provides category: "uncategorized" (not in queueMap), what happens?
Correct Answer: B
The ?? operator returns the right-hand value when the left side is null or undefined. queueMap["uncategorized"] is undefined, so the fallback "tier-1-support" is used. This is intentional safe behavior — unknown categories land in the general queue rather than crashing.
6. You call escalate_ticket and the pager API times out. The handler is in a try/catch block. Which return value is correct?
Correct Answer: B
Returning isError: true signals the failure to the AI so it can inform the user or decide to retry. Bubbling the exception or re-throwing crashes the handler and leaves the AI without structured feedback. Always return a structured error response from tool handlers.
7. The classify helper returns "billing" if a ticket mentions "charge", even if it also mentions "error". A teammate wants to make this priority order configurable rather than hardcoded. Is this a good idea, and why?
Correct Answer: C
Hardcoding "billing beats technical" hides a business rule that will need to change as the product evolves. Externalizing priority order to a configuration object lets the team update classification behavior without touching the handler code or redeploying the server.
Extend the support-triage server with a summarize_ticket tool that formats a ticket into a structured summary for the routing dashboard.
Steps:
summarize_ticket to the ListToolsRequestSchema handler. Required inputs: ticket_id (string), content (string), category (string).summarize_ticket branch to the CallToolRequestSchema handler.summary field should be the first sentence of content (split on . or newline).priority field should be 'high' if content contains 'urgent' or 'critical' (case-insensitive), else 'normal'.JSON.stringify of { ticket_id, category, summary, priority, generated_at }.Hint: First sentence: content.split(/[.\n]/)[0].trim(). Priority flag: /urgent|critical/i.test(content). The generated_at field is new Date().toISOString().
Worked Solution
<CodeExample title="CallToolRequestSchema branch" language="typescript"> if (name === "summarize_ticket") { const { ticket_id, content, category } = args as { ticket_id: string; content: string; category: string; }; const summary = content.split(/[.\n]/)[0].trim(); const priority = /urgent|critical/i.test(content) ? "high" : "normal"; return { content: [ { type: "text", text: JSON.stringify({ ticket_id, category, summary, priority, generated_at: new Date().toISOString(), }), }, ], }; } </CodeExample>