Resources and Prompts
Tools let a model do things, but a capable MCP server also needs to offer context and guide interactions. That is what the other two primitives are for. Resources expose read-only data — files, database rows, API responses — that a client can pull into the model's context. Prompts are reusable, parameterised message templates that surface in the client UI (often as slash commands) so users trigger well-crafted interactions without retyping them. Getting these right is what turns a toy server into something a team actually integrates into Claude Desktop, an IDE, or a custom agent.
- Distinguish tools, resources, and prompts by who controls them
- Register static resources and read their contents
- Build dynamic resources with URI templates
- Notify clients when resource data changes
- Define prompt templates with typed arguments
Three Primitives, Three Controllers
MCP defines three server primitives. The key mental model is who decides when each one is used:
| Primitive | Controlled by | Analogous to | Side effects |
|---|---|---|---|
| Tools | The model | POST endpoints | Yes — actions |
| Resources | The application | GET endpoints | No — read-only |
| Prompts | The user | Slash commands / macros | No — templates |
Warning
Resources must be side-effect free. A client may read a resource eagerly, cache it, or fetch it repeatedly to refresh context. If reading a resource mutates state, model that operation as a tool instead.
Before a client will request any of these, declare the capabilities during construction. listChanged tells the client you will emit list-update notifications; subscribe enables per-resource change subscriptions.
typescriptimport { Server } from "@modelcontextprotocol/sdk/server/index.js" const server = new Server( { name: "docs-server", version: "1.0.0" }, { capabilities: { resources: { subscribe: true, listChanged: true }, prompts: { listChanged: true }, }, }, )
Exposing Static Resources
A resource is identified by a URI and described with a name, optional description, and a mimeType. Listing resources advertises what exists; reading one returns its contents. The high-level McpServer API wires both handlers for you, while the low-level Server gives you full control over each JSON-RPC method.
typescriptimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { readFile } from "node:fs/promises" const server = new McpServer({ name: "docs-server", version: "1.0.0" }) server.registerResource( "api-docs", // internal id "file:///docs/api.md", // stable URI { title: "API Reference", description: "REST endpoint docs", mimeType: "text/markdown" }, async (uri) => ({ contents: [ { uri: uri.href, mimeType: "text/markdown", text: await readFile("docs/api.md", "utf8") }, ], }), )
The contents array can hold either text or binary entries. For binary payloads, drop text and send a base64 blob alongside its mimeType:
typescriptreturn { contents: [ { uri: "file:///logo.png", mimeType: "image/png", blob: pngBuffer.toString("base64") }, ], }
Dynamic Resources with URI Templates
Enumerating every possible resource is impractical when data is keyed by an id or date. Resource templates advertise a parameterised URI using RFC 6570 syntax, and the SDK extracts the parameters for you on read.
typescriptimport { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js" server.registerResource( "user-profile", new ResourceTemplate("users://{userId}/profile", { list: undefined }), { title: "User Profile", description: "Public profile as JSON" }, async (uri, { userId }) => ({ contents: [ { uri: uri.href, mimeType: "application/json", text: JSON.stringify(await getUser(userId)) }, ], }), )
With the low-level API you expose the template through its own list handler so clients can discover the pattern:
typescriptimport { ListResourceTemplatesRequestSchema } from "@modelcontextprotocol/sdk/types.js" server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [ { uriTemplate: "users://{userId}/profile", name: "User profile", mimeType: "application/json" }, ], }))
Pro Tip
Pick a URI scheme that communicates the source: file:// for disk, https:// for remote docs, or a custom scheme like db://orders/{id} or users://{userId}/profile. Keep URIs stable — clients cache and reference them, so a changed URI reads as a different resource.
Notifying Clients When Data Changes
Static context goes stale. When the underlying data updates, emit a notification so clients can re-read. There are two levels: notifications/resources/list_changed (the set of resources changed) and notifications/resources/updated (one resource's contents changed). The latter requires subscribe capability and a subscribe handler.
typescriptimport { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextprotocol/sdk/types.js" const subscribed = new Set<string>() server.setRequestHandler(SubscribeRequestSchema, async (req) => { subscribed.add(req.params.uri) return {} }) server.setRequestHandler(UnsubscribeRequestSchema, async (req) => { subscribed.delete(req.params.uri) return {} }) // Later, when a watched file changes on disk: async function onFileChanged(uri: string) { if (!subscribed.has(uri)) return await server.notification({ method: "notifications/resources/updated", params: { uri } }) }
Defining Prompt Templates
Prompts are user-invoked templates. Each has a name, an optional description, and typed arguments. prompts/list advertises them; prompts/get fills in the arguments and returns concrete messages the client sends to the model.
typescriptimport { z } from "zod" server.registerPrompt( "code-review", { title: "Code Review", description: "Review a snippet for bugs and style", argsSchema: { code: z.string(), language: z.string().optional() }, }, ({ code, language }) => ({ messages: [ { role: "user", content: { type: "text", text: `Review this ${language ?? "code"} for bugs and style:\n\n${code}` }, }, ], }), )
Embedding Resources in Prompts
A prompt message is not limited to plain text. It can embed a resource, letting one template pull live data into the conversation. Use content type resource with the same contents shape returned by resources/read.
typescriptserver.setRequestHandler(GetPromptRequestSchema, async (req) => ({ messages: [ { role: "user", content: { type: "text", text: "Summarise the current API docs." } }, { role: "user", content: { type: "resource", resource: { uri: "file:///docs/api.md", mimeType: "text/markdown", text: await readFile("docs/api.md", "utf8") }, }, }, ], }))
Best Practices & Common Pitfalls
- Keep resources read-only. If it writes, it is a tool, not a resource.
- Always set an accurate
mimeType. Clients rely on it to render or parse contents; useapplication/json,text/markdown, etc. - Validate template parameters. A
users://{userId}read is untrusted input — reject unknown ids instead of leaking errors. - Only advertise capabilities you implement. Declaring
subscribe: truewithout a subscribe handler makes clients wait for updates that never arrive. - Mark required prompt arguments. Omitting
requiredlets clients callprompts/getwith missing data, forcing defensive fallbacks.
Warning
Never interpolate a resource URI straight into a filesystem path. fileURLToPath plus an allow-list of served directories prevents file:///../../etc/passwd style path traversal. Treat every incoming URI as hostile input.
Next Steps
Next Lesson
Multi-Tool Orchestration