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

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

Know when your resource layer goes stale

AliveMCP probes your /health/resources endpoint every 60 seconds and pages you the moment the database or file watcher fails — before your LLM clients start receiving wrong context silently.

Start monitoring free