Guide · MCP Protocol Primitives
MCP Server Resources — URI templates, subscriptions, and resource change notifications
Resources are one of MCP's three core primitives alongside tools and prompts. Where tools perform actions and prompts define reusable message templates, resources expose read-only data from your server as URI-addressable content. A file system server exposes file:///workspace/src/auth.ts; a database server exposes db://customers/customer-42; a monitoring server exposes metrics://uptime/alivemcp.com?window=7d. Resources are the substrate LLM clients read before deciding what tools to call. This guide covers URI template design, the ListResources and ReadResource handlers, resource subscriptions and change notifications, static versus dynamic resources, and how to monitor a resource layer that silently breaks.
TL;DR
Register resources with server.resource() using URI templates (RFC 6570) or literal URIs. Implement a ListResources handler that returns your resource catalog — keep it under 50 items for good client UX. Implement a ReadResource handler that returns text, blob, or embedded resource content. For live data, implement resource subscriptions: emit notifications/resources/updated when content changes, and notifications/resources/list_changed when the catalog changes. Wire AliveMCP to your resource health endpoint — a resource layer that returns stale data silently is indistinguishable from one that is working correctly.
Resources vs tools vs prompts
Understanding where resources fit requires a clear picture of all three primitives:
| Primitive | Direction | Nature | Typical use |
|---|---|---|---|
| Tool | Client → Server | Imperative action | Create, update, delete, search, call external API |
| Resource | Client → Server | Declarative read | Read file content, fetch row, get metric snapshot |
| Prompt | Client → Server | Reusable message template | Expand a multi-turn conversation scaffold with arguments |
The key distinction between resources and tools: resources are read-only by convention. The MCP spec does not prevent a resource handler from writing data, but clients treat resources as safe to read freely — they may pre-fetch them, cache them, and display them in a resource browser without user confirmation. Write operations belong in tools, not resources.
URI design for MCP resources
MCP resource URIs follow RFC 3986. They must be absolute URIs. The scheme communicates the resource type to clients and shapes client-side rendering decisions:
| Scheme | Typical use | Example URI |
|---|---|---|
file:// |
Filesystem content | file:///workspace/src/index.ts |
Custom (db://, docs://) |
Application data | db://orders/order-8821 |
https:// |
Fetched external content | https://api.example.com/v1/status |
Custom (metrics://) |
Time-series / monitoring | metrics://uptime/alivemcp.com?window=7d |
URI templates (RFC 6570) let you describe a family of resources with a pattern. Use {param} for required path segments and {?query} for optional query parameters. The MCP SDK resolves templates against the incoming URI at read time:
// Static resource — single known URI
server.resource(
'server-config',
'config://mcp/server',
{ description: 'Current server configuration' },
async () => ({
contents: [{
uri: 'config://mcp/server',
mimeType: 'application/json',
text: JSON.stringify(serverConfig, null, 2)
}]
})
);
// Template resource — URI pattern covers a family of resources
server.resource(
'customer-record',
new ResourceTemplate('db://customers/{customerId}', { list: undefined }),
{ description: 'Customer record by ID' },
async (uri, { customerId }) => {
const customer = await db.customers.findUnique({ where: { id: customerId } });
if (!customer) throw new Error(`Customer ${customerId} not found`);
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(customer, null, 2)
}]
};
}
);
Implementing ListResources
The ListResources handler returns the catalog of resources the client can read. Clients use this to populate resource browsers and to decide which resources to include in context before making tool calls. Keep the list actionable — aim for fewer than 50 items; larger catalogs degrade client UI and force LLMs to sift through irrelevant entries.
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { ListResourcesRequestSchema } from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{ name: 'my-mcp-server', version: '1.0.0' },
{ capabilities: { resources: { subscribe: true, listChanged: true } } }
);
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const customers = await db.customers.findMany({
select: { id: true, name: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 20
});
return {
resources: [
// Static resource — always present
{
uri: 'config://mcp/server',
name: 'Server configuration',
description: 'Current server runtime configuration and feature flags',
mimeType: 'application/json'
},
// Dynamic resources — one per recent customer
...customers.map(c => ({
uri: `db://customers/${c.id}`,
name: `Customer: ${c.name}`,
description: `Customer record last updated ${c.updatedAt.toISOString()}`,
mimeType: 'application/json'
}))
]
};
});
The name, description, and mimeType fields are metadata the client uses to select resources. Write clear descriptions — the LLM uses these to decide whether a resource is relevant to include in context before calling a tool.
Implementing ReadResource
The ReadResource handler responds to a specific URI. Return text for UTF-8 string content or blob for base64-encoded binary data. A single resource read can return multiple content items if the URI resolves to a composite view:
import { ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
server.setRequestHandler(ReadResourceRequestSchema, async ({ params }) => {
const uri = new URL(params.uri);
// Route by scheme
if (uri.protocol === 'config:') {
return {
contents: [{
uri: params.uri,
mimeType: 'application/json',
text: JSON.stringify(getServerConfig(), null, 2)
}]
};
}
if (uri.protocol === 'db:' && uri.hostname === 'customers') {
const customerId = uri.pathname.slice(1); // remove leading /
const customer = await db.customers.findUnique({ where: { id: customerId } });
if (!customer) {
throw new Error(`Resource not found: ${params.uri}`);
}
return {
contents: [{
uri: params.uri,
mimeType: 'application/json',
text: JSON.stringify(customer, null, 2)
}]
};
}
if (uri.protocol === 'file:') {
const filePath = uri.pathname;
// Validate against declared roots before reading
if (!isWithinAllowedRoots(filePath)) {
throw new Error(`Access denied: ${filePath} is outside declared workspace roots`);
}
const content = await fs.readFile(filePath, 'utf-8');
const mimeType = detectMimeType(filePath);
return {
contents: [{
uri: params.uri,
mimeType,
text: content
}]
};
}
throw new Error(`Unsupported resource scheme: ${uri.protocol}`);
});
Always throw a descriptive error for unrecognised URIs rather than returning empty content. Clients surface these errors to the LLM, which uses them to avoid re-requesting the same broken resource.
Resource subscriptions and change notifications
Resources can be subscribed to. When a client subscribes to a URI, the server should notify it when that resource's content changes. This is how a file editor client keeps the LLM's context in sync with file edits without polling on an interval.
import {
SubscribeRequestSchema,
UnsubscribeRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
// Track which client sessions have subscribed to which URIs
const subscriptions = new Map<string, Set<string>>(); // uri → Set of sessionIds
server.setRequestHandler(SubscribeRequestSchema, async ({ params }, extra) => {
const sessionId = extra.sessionId;
if (!subscriptions.has(params.uri)) {
subscriptions.set(params.uri, new Set());
}
subscriptions.get(params.uri)!.add(sessionId);
return {};
});
server.setRequestHandler(UnsubscribeRequestSchema, async ({ params }, extra) => {
subscriptions.get(params.uri)?.delete(extra.sessionId);
return {};
});
// Emit notifications when a resource changes
async function notifyResourceUpdated(uri: string): Promise<void> {
const subscribers = subscriptions.get(uri);
if (!subscribers || subscribers.size === 0) return;
for (const sessionId of subscribers) {
await server.sendResourceUpdated({ uri }, sessionId);
}
}
// Wire to your data change events
db.on('customer:updated', async ({ id }) => {
await notifyResourceUpdated(`db://customers/${id}`);
});
When the catalog itself changes — resources added or removed — emit a notifications/resources/list_changed notification. Clients re-issue a ListResources request when they receive this, refreshing their resource browser:
async function notifyResourceListChanged(): Promise<void> {
await server.sendResourceListChanged();
}
// Trigger when a new customer is created or deleted
db.on('customer:created', notifyResourceListChanged);
db.on('customer:deleted', notifyResourceListChanged);
Static versus dynamic resources
Resources split into two operational categories with different monitoring requirements:
| Type | Content changes? | Catalog changes? | Monitoring target |
|---|---|---|---|
| Static (config, schema) | Only on deploy | No | ReadResource latency; 404s after deploy |
| Dynamic (DB rows) | On data writes | On insert/delete | Staleness gap; subscription delivery latency |
| File-backed | On disk write | On file create/delete | Filesystem watcher health; read errors |
| Remote (fetched URL) | External source | Rarely | Upstream latency; cache staleness; 5xx from origin |
Static resources are low-risk. Dynamic resources — especially those backed by database rows or file watchers — require active monitoring. A resource that returns a 200 with stale data is the hardest failure class to detect: the protocol layer looks healthy while the LLM receives outdated context that leads to wrong tool calls.
Resource health endpoint
Expose a /health/resources endpoint that validates the resource layer is operational. Check the three common failure modes: the backing data store is unreachable, the file watcher is dead, and the subscription fanout is accumulating stale sessions.
app.get('/health/resources', async (req, res) => {
const checks: Record<string, 'ok' | 'degraded' | 'down'> = {};
const details: Record<string, unknown> = {};
// 1. Database reachability — can we serve db:// resources?
try {
await db.$queryRaw`SELECT 1`;
checks.db = 'ok';
} catch (e) {
checks.db = 'down';
details.db_error = (e as Error).message;
}
// 2. File watcher alive — can we serve file:// resources?
const watcherAge = Date.now() - lastWatcherHeartbeatMs;
checks.file_watcher = watcherAge < 30_000 ? 'ok' : 'degraded';
details.watcher_age_ms = watcherAge;
// 3. Subscription map size — guard against leaked sessions
const totalSubscriptions = [...subscriptions.values()]
.reduce((sum, set) => sum + set.size, 0);
checks.subscriptions = totalSubscriptions < 1000 ? 'ok' : 'degraded';
details.subscription_count = totalSubscriptions;
const overall = Object.values(checks).includes('down') ? 'down'
: Object.values(checks).includes('degraded') ? 'degraded'
: 'ok';
res.status(overall === 'down' ? 503 : 200).json({
status: overall,
checks,
details
});
});
Wire AliveMCP to this endpoint with a 60-second probe interval. A resource layer failure is silent at the MCP protocol level — ReadResource may continue returning cached data, or may start throwing errors that the LLM interprets as a missing resource rather than a server outage.
Common mistakes
- Returning resource content from tools. If data is read-only and doesn't need user confirmation, it belongs in a resource. Tools that return read-only data cause unnecessary friction and dilute the tool list that affects selection accuracy.
- Listing all rows in a database table. A resource catalog with 10,000 entries is unusable. Apply filters (recent, relevant, owned-by-session) and paginate using
nextCursorin theListResourcesresponse. - Not declaring mimeType. Without
mimeType, clients cannot render resource content correctly. At minimum, distinguishtext/plain,application/json, and programming language types (text/typescript,text/python). - Forgetting to validate file paths against roots. If your server exposes
file://resources, validate each requested path against the client-declared roots before reading. See MCP Server Roots for the path validation pattern. - Subscribing without session cleanup. When a client disconnects, clean up its subscriptions. Leaked sessions in the subscription map cause
sendResourceUpdatedcalls to throw silently for dead connections, inflating error log noise.
Frequently asked questions
Should I expose the same data as both a resource and a tool?
Yes, in some cases. Expose the data as a resource for context-building (the LLM reads it before deciding what to do) and as a tool for parameterised lookup with richer filtering. For example: a db://customers/customer-42 resource for reading a single known customer, and a search_customers tool for finding customers by query. The resource is for direct access; the tool is for discovery. They serve different query shapes at the prompt level.
Can a resource return binary content?
Yes. Return a blob content item with the data base64-encoded instead of a text item. Set mimeType accordingly — image/png, application/pdf, application/octet-stream. Clients that support blob rendering (image viewers, PDF previews) will render inline; others will offer download. Most current LLM clients handle base64-encoded images natively.
What is the right way to paginate a large resource list?
Use the nextCursor field in the ListResources response. When a client sends a cursor parameter, decode it as an offset or keyset cursor and return the next page. Opaque base64-encoded cursors are the recommended format — they let you change the pagination implementation without changing the API surface. The client will keep re-issuing ListResources with the cursor until nextCursor is absent, meaning the last page was reached.
How do I handle resources that require authentication per user?
Resources are served in the context of an authenticated MCP session. The session carries the user identity (from OAuth scopes, API keys, or JWT claims depending on your authentication scheme). In the ReadResource handler, check whether the authenticated session has access to the requested URI before returning content. Return an error for unauthorised access — throw new McpError(ErrorCode.InvalidRequest, 'Not authorised to read this resource') — rather than an empty response, so the client can distinguish missing from forbidden.
Further reading
- MCP Server Prompts — templated interactions and dynamic argument expansion
- MCP Server Roots — filesystem boundary assertions for file:// resources
- MCP Capabilities Negotiation — advertising resource and subscription support
- MCP Server Authentication — per-session identity for resource access control
- MCP Server Tool Discovery — the complementary primitive for write operations