URI Templates, Subscriptions & Live Data — how MCP Resources give AI context without burning tool budgets, with real-time push notifications via chokidar and PostgreSQL LISTEN/NOTIFY.
MCP has two primary primitives for giving an AI access to the outside world: Tools and Resources. Confusing them leads to poorly designed servers. Understanding the distinction leads to clean, efficient APIs that hosts can render beautifully.
Think of Tools as verbs — callable functions with side effects or computation. send_email, run_query, create_issue. Each tool invocation is an action; hosts often confirm with users before executing. Resources are nouns — addressable data identified by a URI that clients can read. config://app, db://public/orders/rows, github://octocat/hello-world/issues/1. Reading a resource is semantically safe: it never changes state.
This distinction matters for three practical reasons. First, many host applications count tool calls against a session budget or show a confirmation dialog before each one. Reading a resource is free of that overhead — the AI can pull context quietly. Second, hosts can render resources in specialized UI: a Markdown resource gets rendered as formatted text, an image resource gets displayed inline, a JSON resource gets pretty-printed in a collapsible viewer. Third, the subscription system only exists for resources, not tools — so live data (stock prices, log streams, DB row counts) is naturally modelled as a resource.
A useful heuristic: if the data has a stable identity that could be bookmarked (a specific GitHub issue, a database table, a configuration file), make it a resource. If the operation is a computation, transformation, or write, make it a tool. When in doubt, ask: "Would a REST API expose this as a GET endpoint with a URL?" If yes, it belongs as a resource.
Resources also enable the AI to read context without "consuming" an action budget that may be limited per turn. In agentic workflows where the AI iterates over dozens of steps, being able to read a resource for free context — rather than burning a tool call — meaningfully improves efficiency and reduces latency.
The MCP spec defines four flavours of resource. Static resources have a fixed URI and rarely-changing content — perfect for config files, README docs, and schema definitions. Dynamic resources use URI templates (RFC 6570) so one registration handles thousands of URIs parameterized by owner, table name, or ID. List resources appear in resources/list and can be paginated when there are many of them. Subscription resources support push notifications — the server calls back to the client when content changes, so the AI always has fresh data without polling.
Static resources are the simplest and most common type. A fixed URI maps to content that either never changes or changes so infrequently that a client can safely cache it. They are the MCP equivalent of a GET endpoint that returns the same document every time.
The server.resource() call on McpServer takes four arguments: a human-readable name (used in the resource listing), the URI string, optional metadata (primarily mimeType), and an async handler that returns the resource contents. The handler is called every time a client does resources/read with that exact URI, so you can make it dynamic if you want — "static" refers to the URI being fixed, not to the content being hardcoded.
Resource contents come in two flavours controlled by the field name on the content object: text for all textual data (strings, JSON serialized as a string, Markdown, CSV, SVG, HTML), and blob for binary data encoded as a base64 string (PNG, PDF, WASM, etc.). You must use exactly one of these two fields; using both or neither is a protocol error. The mimeType field guides the host in rendering: a text/markdown resource will be rendered with formatting, an image/png blob will be displayed as an image, and an application/json resource may get a collapsible tree view.
Static resources always appear in resources/list automatically — the SDK registers them when you call server.resource(). This means clients can discover them without knowing the URI in advance, which is ideal for configuration or documentation resources that an AI should be able to find through browsing rather than by constructing a URI.
Note the distinction between the resource name (first argument — shown in listings, e.g. "server-readme"), the resource URI (second argument — the addressable identifier, e.g. "readme://server"), and the metadata object (third argument — currently only mimeType and optional description). Some hosts display the name in their resource browser UI, while the URI is what the AI uses to request the content.
TypeScript — three static resource patternsimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as fs from 'fs/promises';
const server = new McpServer({ name: 'demo', version: '1.0.0' });
// 1. Plain text / Markdown static resource
server.resource(
'server-readme',
'readme://server',
{ mimeType: 'text/markdown' },
async () => ({
contents: [{
uri: 'readme://server',
mimeType: 'text/markdown',
text: `# Demo MCP Server\n\nThis server provides access to...`
}]
})
);
// 2. JSON config resource
server.resource(
'app-config',
'config://app',
{ mimeType: 'application/json' },
async () => ({
contents: [{
uri: 'config://app',
mimeType: 'application/json',
text: JSON.stringify({
version: '1.0.0',
features: { darkMode: true, analytics: false },
limits: { maxQueryRows: 1000 }
}, null, 2)
}]
})
);
// 3. Binary resource (image) — uses blob, not text
server.resource(
'company-logo',
'assets://logo.png',
{ mimeType: 'image/png' },
async () => {
const imageData = await fs.readFile('./assets/logo.png');
return {
contents: [{
uri: 'assets://logo.png',
mimeType: 'image/png',
blob: imageData.toString('base64') // base64-encoded binary
}]
};
}
);
github://, db://, config://, metrics://, docs://. These schemes are not resolved by any browser — they are identifiers within your server's namespace. Choose them to be self-documenting so an AI can infer what a URI contains just from its shape.When your data is parameterized — by owner, repo, table, ID, date — registering one static resource per entity is impractical. URI templates (RFC 6570) solve this: a single ResourceTemplate registration handles all URIs that match the pattern, extracting the variable segments and passing them to your handler.
RFC 6570 defines a compact template language for URIs. The most common form is simple expansion: {var} matches a single path segment without slashes. You can also use {+path} for a path variable that allows slashes (useful for file paths), and {?query*} for query string expansion. The MCP SDK's ResourceTemplate class accepts a template string and an options object with an optional list callback. The list callback, if provided, populates resources/list — if you omit it or return an empty array, the resource is "dark": clients must construct the URI themselves.
The resource handler for a template receives two arguments: the full parsed URL object (so you can access uri.href, uri.pathname, etc.) and a record of the extracted template variables as strings. Always parse numeric variables explicitly — template variables are always strings regardless of what you put in the URI.
A critical design choice with templates: when does the list callback enumerate resources vs. leaving it empty? If the set of matching resources is small and enumerable (e.g., all tables in a database, all files in a directory), implement list so clients can browse them. If the set is unbounded or client-constructed (e.g., any GitHub repo by any owner), leave list returning an empty array and document the URI pattern so clients know how to construct valid URIs.
Templates shine in database-backed servers. Rather than registering a resource per table at startup, you register one db://{schema}/{table}/rows template, and the list callback queries information_schema.tables to enumerate all available tables dynamically. This means your resource catalogue automatically tracks schema changes without restarting the server.
Be careful with injection in dynamic resources. The extracted template variables come from the URI — treat them as untrusted input. In the database example below, use parameterised queries or identifier quoting (like PostgreSQL's quote_ident) rather than string interpolation to prevent SQL injection through a malicious URI.
TypeScript — URI template resourcesimport { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
// Template: github://{owner}/{repo}/issues/{issue_number}
server.resource(
'github-issue',
new ResourceTemplate('github://{owner}/{repo}/issues/{issue_number}', {
list: async () => ({
resources: [] // dynamic — client must construct URIs
})
}),
{ mimeType: 'application/json' },
async (uri, { owner, repo, issue_number }) => {
const issue = await octokit.issues.get({
owner,
repo,
issue_number: parseInt(issue_number)
});
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify({
number: issue.data.number,
title: issue.data.title,
state: issue.data.state,
body: issue.data.body,
labels: issue.data.labels.map(l => (typeof l === 'string' ? l : l.name)),
assignees: issue.data.assignees?.map(a => a.login),
created_at: issue.data.created_at,
updated_at: issue.data.updated_at
}, null, 2)
}]
};
}
);
// Template: db://{schema}/{table}/rows — list callback enumerates all tables
server.resource(
'db-table-rows',
new ResourceTemplate('db://{schema}/{table}/rows', {
list: async () => {
const tables = await pool.query(
`SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog','information_schema')`
);
return {
resources: tables.rows.map(t => ({
uri: `db://${t.table_schema}/${t.table_name}/rows`,
name: `${t.table_schema}.${t.table_name}`,
description: `All rows from ${t.table_schema}.${t.table_name}`,
mimeType: 'application/json'
}))
};
}
}),
{ mimeType: 'application/json' },
async (uri, { schema, table }) => {
// Use JSON.stringify for identifier quoting (PostgreSQL accepts this)
const rows = await pool.query(
`SELECT * FROM ${JSON.stringify(schema)}.${JSON.stringify(table)} LIMIT 100`
);
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify({ count: rows.rowCount, rows: rows.rows }, null, 2)
}]
};
}
);
| Template Syntax | Matches | Example |
|---|---|---|
{var} | Single path segment (no slashes) | db://{schema}/{table} |
{+path} | Full path including slashes | file://{+filepath} |
{?query*} | Query string expansion | search://{q}{?limit,offset} |
{#fragment} | Fragment identifier | docs://{page}{#section} |
A server that exposes a filesystem with 10,000 Markdown files, or a database with 500 tables, cannot return everything in a single resources/list response. MCP defines a cursor-based pagination mechanism for exactly this scenario.
Pagination in MCP uses an opaque cursor string. The server returns a nextCursor field in the response alongside the current page of resources. A client that wants all resources calls resources/list in a loop, passing the previous response's nextCursor as the cursor parameter, until it receives a response with no nextCursor. The cursor is opaque to the client — it must not assume it is a number, an offset, or a page index. It's just a token the server understands.
On the server side you can encode any state you need into the cursor. A simple approach is base64-encoding an integer offset, as shown below. More sophisticated servers might encode a composite key (table name + row ID for stable pagination even if rows are inserted between pages) or a signed JWT with an expiry to prevent stale cursor replay. The only requirement is that the cursor must be a string that round-trips through base64url safely.
The high-level McpServer class handles pagination for static resources automatically — it will never return more than a reasonable page size. But when you use URI templates or a low-level Server instance with a custom ListResourcesRequestSchema handler, you are responsible for implementing pagination yourself. The example below uses the low-level Server for full control:
TypeScript — cursor-based paginationimport { ListResourcesRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import * as path from 'path';
const PAGE_SIZE = 50;
server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
const cursor = request.params?.cursor;
const offset = cursor
? parseInt(Buffer.from(cursor, 'base64url').toString(), 10)
: 0;
const allFiles = await getFilePaths('./docs'); // could be thousands
const page = allFiles.slice(offset, offset + PAGE_SIZE);
const nextCursor = offset + PAGE_SIZE < allFiles.length
? Buffer.from(String(offset + PAGE_SIZE)).toString('base64url')
: undefined;
return {
resources: page.map(f => ({
uri: `docs://${f}`,
name: path.basename(f),
description: `Documentation file: ${f}`,
mimeType: 'text/markdown'
})),
nextCursor // undefined means "no more pages"
};
});
// ── Client-side: consume all pages ────────────────────────────
async function listAllResources(client: Client) {
const all: Resource[] = [];
let cursor: string | undefined;
do {
const resp = await client.listResources(cursor ? { cursor } : undefined);
all.push(...resp.resources);
cursor = resp.nextCursor;
} while (cursor !== undefined);
return all;
}
Polling a resource every few seconds to detect changes is wasteful. MCP's subscription system lets clients register interest in a resource and receive a push notification the moment its content changes, after which they re-read the resource to get the new content.
The subscription lifecycle involves five distinct messages. A client sends a resources/subscribe request with the target URI. The server acknowledges with an empty result object — it stores the subscription internally, keyed by URI and session. At some later point, when the server detects that the resource's content has changed (via a filesystem watcher, a database trigger, a timer, or any other mechanism), it proactively sends a notifications/resources/updated notification containing the URI of the changed resource. The client receives this notification, calls resources/read to fetch the new content, and processes it. When the client is done monitoring the resource, it sends resources/unsubscribe to clean up the server-side state.
An important protocol constraint: the notifications/resources/updated message contains only the URI, not the new content. This is intentional — the server does not need to know whether the client actually wants to re-read, and the client can decide to batch updates, apply back-pressure, or ignore an update if it is already processing one. It also means binary and large resources are not pushed over the wire unnecessarily.
On the server side, the key challenge is tracking subscriptions per session in multi-client scenarios. An HTTP server that serves many concurrent MCP clients needs to associate each subscription with the specific client connection that requested it. The example below shows the pattern using a Set for single-client servers, with a note on the multi-client extension.
TypeScript — subscription handlersimport { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
SubscribeRequestSchema,
UnsubscribeRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
// For single-client (stdio) servers: a simple Set is sufficient
const subscriptions = new Set<string>();
server.setRequestHandler(SubscribeRequestSchema, async (request) => {
const { uri } = request.params;
subscriptions.add(uri);
console.error(`[subscribe] ${uri} — total: ${subscriptions.size}`);
return {};
});
server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
const { uri } = request.params;
subscriptions.delete(uri);
console.error(`[unsubscribe] ${uri}`);
return {};
});
// Call this whenever a resource changes
async function notifyResourceChanged(uri: string) {
if (subscriptions.has(uri)) {
await server.sendResourceUpdated({ uri });
}
}
// Call this when the resource catalogue changes (items added/removed)
async function notifyListChanged() {
await server.sendResourceListChanged();
}
Map<sessionId, Set<string>> pattern keyed on the session identifier, so one client's subscription does not trigger notifications to a different client.One of the most common real-world use cases for resource subscriptions is watching a directory for file changes and pushing updates to connected AI clients. The chokidar library provides cross-platform file watching (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows) with a clean, event-based API.
Chokidar normalizes filesystem events across platforms, debounces rapid changes (so a text editor that writes a file in multiple chunks triggers only one event), and handles the quirks of atomic save operations where some editors write to a temporary file then rename it. Install it with npm install chokidar — it is a pure JavaScript package with no native bindings, so it works in any Node.js environment.
The integration pattern is straightforward: register a URI template resource that reads file content on demand, then set up a chokidar watcher on the same directory. When chokidar fires a change event, compute the file's MCP URI and call server.server.sendResourceUpdated({ uri }). When files are added or removed, call server.server.sendResourceListChanged() because the resource catalogue itself has changed — clients may need to re-fetch the resource list to discover new files.
There is an important distinction between sendResourceUpdated and sendResourceListChanged. The first targets a specific URI — it tells clients subscribed to that exact resource that its content has changed. The second is a broadcast that says "the set of resources I expose has changed" — clients should call resources/list again to discover additions or removals. Adding a file triggers sendResourceListChanged; modifying an existing file triggers sendResourceUpdated for that file's URI.
For high-frequency change scenarios (e.g., a log file written to thousands of times per second), debounce your notifications. Do not call sendResourceUpdated on every write event — instead, coalesce rapid changes using a debounce function and send one notification per "quiet period." Clients that receive a flood of update notifications may not be able to process them fast enough, and most AI hosts will discard or queue notifications during active inference anyway.
TypeScript — chokidar file watcher integrationimport { watch } from 'chokidar';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as path from 'path';
import * as fs from 'fs/promises';
import { glob } from 'glob';
const WATCH_DIR = process.env.WATCH_DIR ?? './docs';
const server = new McpServer({ name: 'fs-watcher', version: '1.0.0' });
// Register file resources with URI template
server.resource(
'watched-file',
new ResourceTemplate('file://{+filePath}', {
list: async () => {
const files = await glob('**/*.md', { cwd: WATCH_DIR });
return {
resources: files.map(f => ({
uri: `file://${f}`,
name: path.basename(f),
description: `Watched Markdown file: ${f}`,
mimeType: 'text/markdown'
}))
};
}
}),
{ mimeType: 'text/markdown' },
async (uri, { filePath }) => {
const content = await fs.readFile(
path.join(WATCH_DIR, filePath as string), 'utf-8'
);
return {
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: content }]
};
}
);
// Simple debounce utility
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let timer: NodeJS.Timeout;
return ((...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
}) as T;
}
// Watch for changes and notify subscribers
const watcher = watch(WATCH_DIR, { ignoreInitial: true, persistent: true });
const notifyChange = debounce(async (filePath: string) => {
const rel = path.relative(WATCH_DIR, filePath).replace(/\\/g, '/');
const uri = `file://${rel}`;
console.error(`[watcher] changed: ${uri}`);
await server.server.sendResourceUpdated({ uri });
}, 200); // coalesce changes within 200 ms
watcher.on('change', notifyChange);
watcher.on('add', async () => {
console.error('[watcher] file added — notifying list change');
await server.server.sendResourceListChanged();
});
watcher.on('unlink', async () => {
console.error('[watcher] file removed — notifying list change');
await server.server.sendResourceListChanged();
});
Relational databases generate the most common category of live data in enterprise applications. PostgreSQL's built-in LISTEN/NOTIFY mechanism lets your server receive push notifications from the database the moment a row is inserted, updated, or deleted — no polling required.
LISTEN/NOTIFY works at the PostgreSQL protocol level. A client issues LISTEN channel_name, and the database engine sends a notification to all listening clients whenever any session calls NOTIFY channel_name, 'payload' or the payload form pg_notify('channel_name', payload_string). PostgreSQL guarantees delivery within the same transaction boundary — a notification is sent to listeners only after the transaction that triggered it commits, so listeners never see notifications for rolled-back changes.
The critical detail when using LISTEN from Node.js with the pg library is that you must use a dedicated non-pooled connection. Connection pools recycle connections between queries — if your LISTEN connection gets recycled and given to a different logical caller, the listener is lost and you silently miss notifications. Create a separate pg.Client (not from the pool) for the sole purpose of listening, and reconnect it on errors.
The typical pattern is to create a PostgreSQL trigger that calls pg_notify with a JSON payload encoding the table name, operation (INSERT/UPDATE/DELETE), and the affected row's ID. Your MCP server receives the notification, parses the payload, maps it to the corresponding resource URI, and calls sendResourceUpdated. This creates a clean separation: the database manages change detection, the MCP server manages notification routing.
TypeScript — PostgreSQL LISTEN/NOTIFY integrationimport pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// Dedicated notification client — MUST NOT come from the pool
const notifyClient = new pg.Client({
connectionString: process.env.DATABASE_URL
});
await notifyClient.connect();
// Listen for PostgreSQL notifications on our channel
await notifyClient.query('LISTEN data_changed');
notifyClient.on('notification', async (msg) => {
if (!msg.payload) return;
const payload = JSON.parse(msg.payload) as {
table: string;
operation: 'INSERT' | 'UPDATE' | 'DELETE';
id: string | number;
};
// Map the table change to a resource URI
const tableUri = `db://public/${payload.table}/rows`;
await server.server.sendResourceUpdated({ uri: tableUri });
// If a specific row resource exists, notify that too
if (payload.id) {
const rowUri = `db://public/${payload.table}/${payload.id}`;
await server.server.sendResourceUpdated({ uri: rowUri });
}
});
// Reconnect on error (pg.Client does not auto-reconnect)
notifyClient.on('error', async (err) => {
console.error('[pg-notify] connection error:', err.message);
// In production: implement exponential backoff reconnect
});
/* ── Run this SQL once to create the trigger ─────────────────────
CREATE OR REPLACE FUNCTION notify_data_change() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify(
'data_changed',
json_build_object(
'table', TG_TABLE_NAME,
'operation', TG_OP,
'id', NEW.id
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_changed
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION notify_data_change();
──────────────────────────────────────────────────────────────── */
NOTIFY payloads to 8000 bytes. For large row changes, send only the identifier in the payload and let the MCP client re-read the full resource via resources/read. Never put the full row in the notification payload.The subscription protocol described in Section 5 is only useful if clients implement the receiving end correctly. This section shows how to subscribe to resources, handle update notifications, batch-subscribe to multiple resources, and ensure clean unsubscription on shutdown.
From the client SDK, client.subscribeResource({ uri }) sends the subscribe request and waits for acknowledgement. The actual update events arrive as notifications, not as responses to any request — so you must register a notification handler before you need it. Use client.setNotificationHandler(ResourceUpdatedNotificationSchema, handler) to register a callback that fires whenever notifications/resources/updated arrives.
The notification handler receives the notification object with a params.uri field identifying which resource changed. Your handler typically re-reads the resource, updates a local cache, and triggers any downstream UI refresh. Keep the handler fast — it runs in the event loop. If processing the update is expensive, push the URI to a queue and process it in a worker.
For multiple subscriptions, it is clean to maintain a set of subscribed URIs so you can unsubscribe from all of them on shutdown. The SIGTERM handler should iterate this set, call client.unsubscribeResource for each, then close the client. Failing to unsubscribe leaks server-side subscription state until the session drops.
There is also a second notification type to handle: ResourceListChangedNotificationSchema. This fires when the server's resource catalogue changes — new resources were added or existing ones were removed. Your client should re-fetch the resource list and update its own catalogue view. This notification has no payload — it is simply a signal to refresh.
TypeScript — client-side subscription managementimport { Client } from '@modelcontextprotocol/sdk/client/index.js';
import {
ResourceUpdatedNotificationSchema,
ResourceListChangedNotificationSchema
} from '@modelcontextprotocol/sdk/types.js';
const subscribedUris = new Set<string>();
// Register handlers BEFORE subscribing
client.setNotificationHandler(
ResourceUpdatedNotificationSchema,
async (notification) => {
const { uri } = notification.params;
console.log(`Resource updated: ${uri}`);
// Re-read to get current content
const updated = await client.readResource({ uri });
const content = updated.contents[0];
if (content.mimeType === 'application/json' && content.text) {
const data = JSON.parse(content.text);
await updateLocalCache(uri, data);
await notifyUI(uri, data);
}
}
);
client.setNotificationHandler(
ResourceListChangedNotificationSchema,
async () => {
console.log('Resource list changed — refreshing catalogue');
const { resources } = await client.listResources();
await updateResourceCatalogue(resources);
}
);
// Subscribe to a single resource
async function subscribe(uri: string) {
await client.subscribeResource({ uri });
subscribedUris.add(uri);
}
// Subscribe to all critical resources
const criticalResources = [
'db://public/orders/rows',
'config://app',
'metrics://cpu'
];
for (const uri of criticalResources) {
await subscribe(uri);
}
// Clean up all subscriptions on shutdown
async function shutdown() {
for (const uri of subscribedUris) {
await client.unsubscribeResource({ uri });
}
subscribedUris.clear();
await client.close();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
You do not always need a separate resources/read call to get resource content into a conversation. A tool can return a resource inline as part of its result using the type: "resource" content type — combining the action of a tool with the richness of a resource.
This pattern was introduced in the MCP content type system covered in Day 15. When a tool returns a content item of type "resource", the host receives the full resource content (URI, mimeType, and either text or blob) without needing to make a separate read request. The host can render it using the same MIME-type-aware rendering pipeline it would use for a regular resource read.
The key design question is when to embed vs. when to reference. Embed when: the tool's caller will definitely consume the content immediately; the resource is small enough to fit in context; the content is one-time (computed fresh each time) and caching by the client doesn't make sense. Reference (return the URI and let the client call resources/read) when: the resource is large and might exceed context limits; the client should cache it and re-use it across turns; the resource is separately subscribable and you want the client to track it.
A common use case is a search tool that returns matching documents as embedded resources. Instead of returning a list of URIs that the AI must follow up with individual read calls, the tool fetches the content immediately and embeds it all in a single response. The AI gets everything it needs in one round-trip, reducing latency and cognitive load.
Another idiom is a read_config tool that accepts an optional key filter and returns a config resource inline. This lets the AI ask for a specific subset of the configuration without knowing the full URI or the structure of the config store. The tool acts as an adapter layer between the AI's natural language request and the underlying resource system.
TypeScript — embedded resources in tool resultsserver.tool(
'search_documentation',
{
query: z.string().describe('Search query'),
limit: z.number().int().min(1).max(10).default(5)
},
async ({ query, limit }) => {
const matches = await searchDocs(query, limit);
return {
content: [
{
type: 'text',
text: `Found ${matches.length} documents matching "${query}":`
},
// Embed each matching document as a resource inline
...matches.map(doc => ({
type: 'resource' as const,
resource: {
uri: `docs://${doc.slug}`,
mimeType: 'text/markdown',
text: doc.content // full content, no separate read needed
}
}))
]
};
}
);
// Tool that returns a config subset as an embedded resource
server.tool(
'read_config',
{ key: z.string().optional().describe('Specific config key to retrieve') },
async ({ key }) => {
const config = await loadConfig();
const subset = key ? { [key]: config[key] } : config;
return {
content: [{
type: 'resource' as const,
resource: {
uri: 'config://app',
mimeType: 'application/json',
text: JSON.stringify(subset, null, 2)
}
}]
};
}
);
resources/read if it decides it needs the content). Between 20–100 KB, consider the conversation's remaining context window. A tool that always embeds large documents will fill the context window rapidly in long agentic sessions.Some resources are expensive to generate — a report that requires a multi-table SQL join, metrics that involve pinging external APIs, or a rendered document that needs compilation. Caching the result and returning it on repeated reads can dramatically reduce latency and external API usage.
The simplest caching strategy is time-to-live (TTL) caching: store the computed result alongside a timestamp, and serve the cached copy until the TTL expires. The cache class below also computes an ETag (a hash of the content) so that if you later want to implement conditional reads, you have the mechanism ready. ETags let clients detect whether the content they last read is still current without fetching the full content again.
Cache invalidation should happen on two triggers: expiry (TTL elapsed) and explicit invalidation triggered by a known data change. When a data source signals a change (a PostgreSQL notification arrives, a file watcher fires, a webhook is received), immediately invalidate the cache entry and send a sendResourceUpdated notification. This way, clients always get fresh content after a change, and the TTL acts as a safety net for changes the server did not detect.
For template resources where one pattern matches many URIs, you may want to invalidate by pattern rather than by exact URI. The invalidatePattern method below accepts a regular expression so you can efficiently sweep all cached entries for a given schema or table prefix. Be careful with overly broad patterns — invalidating the entire cache on every change defeats the purpose of caching.
In multi-process deployments (multiple instances of the same MCP server behind a load balancer), a local in-process cache creates inconsistency: a resource might be cached as fresh on one instance while the other has already invalidated it. In that scenario, move to a shared external cache (Redis, Memcached) so all instances share the same cache state and invalidations propagate globally.
TypeScript — resource cache with TTL and pattern invalidationimport * as crypto from 'crypto';
interface CacheEntry {
contents: any[];
etag: string;
cachedAt: number;
ttl: number; // milliseconds
}
class ResourceCache {
private cache = new Map<string, CacheEntry>();
isValid(uri: string): boolean {
const entry = this.cache.get(uri);
if (!entry) return false;
return Date.now() - entry.cachedAt < entry.ttl;
}
get(uri: string): CacheEntry | null {
return this.isValid(uri) ? (this.cache.get(uri) ?? null) : null;
}
set(uri: string, contents: any[], ttl: number): string {
const etag = crypto.createHash('sha256')
.update(JSON.stringify(contents))
.digest('hex')
.slice(0, 16);
this.cache.set(uri, { contents, etag, cachedAt: Date.now(), ttl });
return etag;
}
invalidate(uri: string) { this.cache.delete(uri); }
invalidatePattern(pattern: RegExp) {
for (const key of this.cache.keys()) {
if (pattern.test(key)) this.cache.delete(key);
}
}
}
const cache = new ResourceCache();
// Resource handler with transparent caching
server.resource('metrics', 'metrics://system', async () => {
const hit = cache.get('metrics://system');
if (hit) return { contents: hit.contents };
const metrics = await gatherSystemMetrics(); // expensive call
const contents = [{
uri: 'metrics://system',
mimeType: 'application/json',
text: JSON.stringify(metrics, null, 2)
}];
cache.set('metrics://system', contents, 10_000); // 10 s TTL
return { contents };
});
// When data changes: invalidate cache AND notify subscribers
async function onDataChange(uri: string) {
cache.invalidate(uri);
await server.server.sendResourceUpdated({ uri });
}
// Invalidate all db://public/* entries when the schema changes
async function onSchemaChange() {
cache.invalidatePattern(/^db:\/\/public\//);
await server.server.sendResourceListChanged();
}
The mimeType field in a resource content object is not cosmetic — hosts and clients use it to decide how to render the content, whether to parse it as JSON, whether to display it as an image, and whether to pass it to a specialized viewer. Choosing the right MIME type is as important as the content itself.
For textual resources, the choice is usually between text/plain (raw text, no formatting), text/markdown (Markdown that the host can render with formatting), text/html (HTML that some hosts will render in an embedded webview), and application/json (structured data that hosts may present as a collapsible tree). Use application/json for all structured data — never return JSON as text/plain, because the host cannot offer structured rendering.
For binary resources, the only valid content field is blob (base64-encoded). Common binary MIME types: image/png, image/jpeg, image/svg+xml (SVG can also use text since it is XML-based), application/pdf, application/wasm. For image/svg+xml specifically, you can use either the text field (since SVG is valid XML text) or the blob field — using text is simpler and avoids the base64 overhead.
Some servers expose the same underlying data in multiple formats by registering multiple resource URIs with different extensions: reports://monthly.html, reports://monthly.json, reports://monthly.csv. This is a form of content negotiation through the URI rather than through HTTP Accept headers. It is explicit and simple — the AI chooses the format by picking the right URI, guided by your resource descriptions.
Hosts that do not support a given MIME type will fall back to rendering the content as raw text. This is safe for all text-based MIME types, but binary blobs displayed as raw text are meaningless. Always document the MIME types your server uses in its resource descriptions so host developers know what rendering capabilities are needed.
| MIME Type | Content Field | Use For | Host Rendering |
|---|---|---|---|
| text/plain | text | Raw text, logs, plain output | Monospaced text |
| text/markdown | text | Documentation, README, notes | Formatted Markdown |
| application/json | text | Structured data, API responses | Collapsible JSON tree |
| text/html | text | Reports, rendered content | Embedded webview (if supported) |
| text/csv | text | Tabular data exports | Table view or raw text |
| application/xml | text | XML configs, SOAP, sitemap | Syntax-highlighted XML |
| image/png | blob | Screenshots, charts, logos | Inline image |
| image/svg+xml | text or blob | Diagrams, icons, charts | Rendered SVG or inline image |
| application/pdf | blob | Reports, documents | PDF viewer embed |
TypeScript — multi-format report resources// Same underlying data exposed in three formats
server.resource(
'report-html',
'reports://monthly.html',
{ mimeType: 'text/html' },
async () => ({
contents: [{
uri: 'reports://monthly.html',
mimeType: 'text/html',
text: await generateHTMLReport()
}]
})
);
server.resource(
'report-json',
'reports://monthly.json',
{ mimeType: 'application/json' },
async () => ({
contents: [{
uri: 'reports://monthly.json',
mimeType: 'application/json',
text: JSON.stringify(await generateReportData(), null, 2)
}]
})
);
server.resource(
'report-csv',
'reports://monthly.csv',
{ mimeType: 'text/csv' },
async () => ({
contents: [{
uri: 'reports://monthly.csv',
mimeType: 'text/csv',
text: await generateCSVReport()
}]
})
);
Resources have more surface area to test than tools: you need to verify the resource list, individual reads, URI template variable extraction, pagination, subscription notifications, and cache behaviour. The in-process transport pattern makes all of this testable without spinning up external processes.
The connectInProcess helper (or the in-process transport available in the MCP SDK's test utilities) connects a client and server in the same process, using in-memory message passing instead of stdio pipes or HTTP. This makes tests fast, deterministic, and parallelisable. Combine it with vitest's vi.waitFor for testing async subscription notifications — it polls a condition until it becomes true or a timeout is reached, which is exactly the right model for "wait until a notification arrives."
The most common resource test failures are: forgetting to set mimeType (test by asserting it matches the expected value), returning invalid JSON in a application/json resource (test by parsing the text field and asserting it does not throw), and URI template variables not being extracted correctly (test by reading a specific template URI and asserting the response reflects the expected variable values).
For subscription tests, the pattern is: subscribe, trigger a change, wait for the notification using vi.waitFor, then assert on the notification content. Always unsubscribe at the end of the test to keep server state clean between test runs. Use afterEach hooks to clean up subscriptions even if the test throws.
TypeScript — vitest resource test suiteimport { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ResourceUpdatedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
import { vi } from 'vitest';
describe('Resources', () => {
let client: Client;
beforeAll(async () => {
client = await connectInProcess(createServer());
});
afterAll(async () => { await client.close(); });
it('lists resources with correct metadata', async () => {
const { resources } = await client.listResources();
expect(resources.length).toBeGreaterThan(0);
resources.forEach(r => {
expect(r.uri).toMatch(/^[a-z][a-z0-9+.-]*:\/\//); // valid URI scheme
expect(r.name).toBeTruthy();
expect(r.mimeType).toMatch(/^\w+\/[\w.+-]+$/); // valid MIME type
});
});
it('reads static resource with correct mimeType', async () => {
const result = await client.readResource({ uri: 'config://app' });
expect(result.contents).toHaveLength(1);
expect(result.contents[0].mimeType).toBe('application/json');
expect(() => JSON.parse(result.contents[0].text!)).not.toThrow();
});
it('resolves URI template variables', async () => {
const result = await client.readResource({
uri: 'db://public/users/rows'
});
const data = JSON.parse(result.contents[0].text!);
expect(data).toHaveProperty('rows');
expect(Array.isArray(data.rows)).toBe(true);
expect(data).toHaveProperty('count');
});
it('receives subscription notification', async () => {
const updates: string[] = [];
client.setNotificationHandler(
ResourceUpdatedNotificationSchema,
async (n) => { updates.push(n.params.uri); }
);
await client.subscribeResource({ uri: 'metrics://system' });
// Trigger a server-side change
await triggerMetricsChange();
// Wait up to 2 s for the notification to arrive
await vi.waitFor(
() => expect(updates).toContain('metrics://system'),
{ timeout: 2000 }
);
await client.unsubscribeResource({ uri: 'metrics://system' });
});
it('paginates resource list correctly', async () => {
const page1 = await client.listResources();
if (!page1.nextCursor) return; // skip if server has < 1 page
const page2 = await client.listResources({ cursor: page1.nextCursor });
// No duplicates across pages
const allUris = [
...page1.resources.map(r => r.uri),
...page2.resources.map(r => r.uri)
];
const uniqueUris = new Set(allUris);
expect(uniqueUris.size).toBe(allUris.length);
});
});
Before deploying a resource-heavy MCP server into production, run through these ten items:
github://, db://, config://. Self-documenting URIs help the AI pick the right resource.
mimeType. Omitting it causes hosts to fall back to plain text rendering.
ResourceTemplate for any parameterized pattern.
resources/list when your catalogue exceeds 50 items.
sendResourceListChanged() whenever resources are added or removed, not just when content changes.
blob (base64) for images and PDFs. Use text for everything human-readable. Never mix them on the same content item.
vi.waitFor() with a reasonable timeout rather than arbitrary setTimeout sleeps.
ResourceTemplate?notifications/resources/updated notification to get the new content?sendResourceListChanged instead of sendResourceUpdated?