CONCEPT · 45 minutes
Expose read-only data to the AI using the TypeScript MCP SDK — knowledge-base articles and ticket history for the support-triage server
When I built the support-automation system, the AI needed constant access to two data sources: knowledge-base articles that spelled out how to classify tickets by priority and type, and a running history of recent tickets it could use as context. My first instinct was to make both into tool calls — get_kb_article(id) and get_recent_tickets().
But tool calls are for actions that change state. The AI should never be able to rewrite a KB article or modify ticket history — those are authoritative records. Giving read-only data the same interface as state-changing actions made the system harder to reason about and harder to audit.
Resources solve this at the design level. Think of a resource as a read-only shelf. The AI can pick up any article from the shelf and use what it learns — but it cannot put things back, relabel them, or change what's on the shelf. It can browse the shelf without supervision, safely.
The core rule: if the AI should never be able to modify something, expose it as a resource. If it needs to cause a state change — route a ticket, escalate an issue — that belongs as a tool. This module builds the read side; the next module builds the action side.
In this module we implement two resources for the support-triage server:
knowledge-base://articles/{id} — KB articles the AI reads to understand classification rulestickets://recent — a live feed of the last 20 tickets that updates as new tickets arriveBefore the AI can read a resource, it needs to discover what resources exist. The ListResourcesRequestSchema handler answers that question. Notice the import: we are using typed schemas from the SDK, not string-based handler names like 'resources/list'.
The capabilities: { resources: {} } declaration tells any connecting client that this server provides resources. Without it, compliant clients will not attempt resource discovery. Now register the list handler:
The uri is the shelf location — a unique address the AI uses to request the actual content. The mimeType tells the AI what kind of content to expect before it reads it. We return a flat array; pagination is possible but unnecessary for two resources.
Pause here before you add the read handler. You have defined what resources exist — now think about how the AI will use them.
tickets://recent returns different data on every read as new tickets arrive. Does that make it a tool?
The answer is no. The data is dynamic, but the AI's access is still read-only. The AI can observe new tickets arriving, but it cannot create, delete, or modify a ticket through this resource. Dynamic content, read-only access — still a shelf, even if someone keeps adding new books to it.
Common confusion: "Dynamic" describes how the content changes, not who can change it. A resource can return fresh data on every call and still be read-only. The test is always: can the AI modify the underlying data through this interface? If not, it is a resource.
The ReadResourceRequestSchema handler fires when the AI requests a resource by URI. Here is the implementation for KB articles — notice the same typed schema pattern:
Two things to notice: uri.startsWith() routes requests to the right handler branch by URI prefix, and throw new Error(...) is how you return a resource-not-found error — the MCP SDK converts thrown errors into the correct error response for the client. The final throw acts as a catch-all for any URI the handler does not recognize.
Think of the Map as the shelf — each article ID is its address, and throwing an error when a key is missing is the equivalent of saying "nothing at that address is on this shelf."
The data store here is a simple in-memory Map — perfectly adequate for a small set of static articles. In production you would replace it with a query to your KB system, but the handler structure stays identical. The MCP layer does not care where the data comes from; it only cares that you return the right shape.
Ticket history is dynamic — fetchRecentTickets hits a live backend. We extend the same ReadResourceRequestSchema handler to cover both resources:
fetchRecentTickets(20) calls whatever backend holds the ticket data — a database, an internal API. The handler's job is to translate that data into the MCP response format. The AI sees a clean JSON snapshot and never touches the ticket store directly.
What you have built: The AI can now read KB classification rules and scan recent ticket history — the two read-only data sources it needs to triage accurately. Neither resource lets the AI change a ticket or edit the KB. That power belongs to the tools we add in the next module.
Now that you have both resources working, what is your decision rule for future capabilities? If you needed to add "ticket search" to the server, would it be a resource or a tool?
Search is tricky: it reads data, which sounds like a resource. But a URI like tickets://search?q=billing does not fit the MCP resource model — resources have stable, addressable URIs, not query parameters. Search also typically involves backend side effects (logging, ranking updates). The right answer is a tool. The decision rule: if a capability has a stable URI and returns data the AI should not modify, it is a resource. If it requires variable parameters per call or triggers any backend side effect, it is a tool.
This module covers the two resources the support-triage server needs. Real production servers sometimes add resource subscriptions — the server pushes a notification to the client when a resource's content changes, rather than waiting for the client to poll. The MCP SDK supports this pattern via server.sendResourceUpdated(). Subscriptions are worth considering when the AI needs to react to data changes in near-real-time — for example, immediately re-triaging when a high-priority ticket arrives and the tickets://recent feed updates.
For most support-triage workloads, polling on demand is simpler and works well. The AI requests tickets://recent when it needs context; there is no need to push updates unless latency becomes a problem. Add subscriptions only when you have measured that polling is too slow — premature push introduces real complexity around subscription state, reconnect handling, and back-pressure that polling avoids entirely.
Application-level questions on MCP resources and the support-triage server
1. Your ReadResourceRequestSchema handler receives uri = 'knowledge-base://articles/99'. Article 99 does not exist in the Map. What is the correct handler behavior?
2. Which import is required to register a resource listing handler in the TypeScript MCP SDK?
3. tickets://recent returns different data on every read because the ticket database changes. This makes it a tool, not a resource.
True or False question
Correct Answer: FALSE
False. Dynamic content does not change the resource/tool classification. Resources are read-only regardless of how often their content changes. The AI cannot create, modify, or delete tickets through tickets://recent — it can only read the current snapshot.
4. You register ReadResourceRequestSchema but forget to register ListResourcesRequestSchema. What can the AI still do with resources?
5. What does capabilities: { resources: {} } in the Server constructor do?
6. Which field in a resource content object carries JSON data returned to the AI?
7. A resource URI like tickets://search?q=billing is a good way to add ticket search to the support-triage server.
True or False question
Correct Answer: FALSE
False. MCP resource URIs represent stable, addressable data — not parameterized queries. A search-by-query capability has a URI that changes with every search term and typically involves backend side effects (logging, ranking). Search belongs as a tool, not a resource.
8. You want to add configs://thresholds (a static JSON object of SLA thresholds) to the server. Which two changes are required?
The support-triage server routes tickets to available agents. Before routing, the AI needs to know which agents currently have capacity. Add a third resource — agents://active — that lists agents available for new tickets.
Steps:
agents://active to the resources array in your ListResourcesRequestSchema handleragents://active case to your ReadResourceRequestSchema handlerapplication/json content with an array of agent objectsid (string), name (string), and openTickets (number)fetchActiveAgents() function for nowHint: The mimeType for the new resource is "application/json". The return shape is {contents: [{uri, mimeType: "application/json", text: JSON.stringify(data)}]}.
// Add to resources array in ListResourcesRequestSchema handler: // { // uri: "agents://active", // name: "Active Support Agents", // description: "Agents currently available for ticket assignment", // mimeType: "application/json", // } </CodeExample>
Worked Solution
<CodeExample title="Solution: ReadResourceRequestSchema branch" language="typescript"> if (uri === "agents://active") { const agents = await fetchActiveAgents(); return { contents: [ { uri: "agents://active", mimeType: "application/json", text: JSON.stringify(agents), }, ], }; } </CodeExample>