Guide · MCP Protocol

MCP server resources API

The MCP Resources protocol lets your server expose structured data — files, database records, API responses, configuration snapshots — that LLM clients can read and reference in context. Unlike tools (which execute actions), resources are readable artifacts with stable URIs. This guide covers resources/list, resources/read, URI schemes, MIME types, dynamic resource generation, and subscriptions for real-time data changes.

TL;DR

Register resources with server.resource() using a URI template and a read handler. Return a contents array with uri, mimeType, and either text (for text content) or blob (for base64-encoded binary). Prefix URIs with a custom scheme that describes the data domain (db://, config://, git://). To push updates when underlying data changes, call server.sendResourceUpdated(uri) after subscribing clients have called resources/subscribe. Resources are passive reads — for operations that modify state, use tools instead.

Resources vs tools: when to use each

Resources and tools serve different purposes in the MCP protocol:

DimensionResourcesTools
IntentExpose data for readingExecute actions
Side effectsNone expectedExpected and explicit
Client invocationresources/readtools/call
URI-basedYes — each resource has a stable URINo — tools are named functions
Real-time updatesYes — subscriptions + notificationsNo
Typical use casesFiles, DB records, configs, logsWrite operations, computations, external API calls

A database table that a user queries read-only is a good resource candidate. An insert or update operation is a tool. A file the LLM should read for context is a resource; a tool that writes a file is a tool.

Registering a static resource

The simplest form: a fixed URI that returns fixed or computed content.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

const server = new McpServer({ name: 'my-server', version: '1.0.0' });

// Static text resource
server.resource(
  'app-config',                    // internal name
  'config://app/settings',         // URI clients use to read this
  {
    name: 'Application Settings',
    description: 'Current application configuration as JSON',
    mimeType: 'application/json',
  },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: 'application/json',
      text: JSON.stringify({ maxConnections: 10, timeout: 30 }, null, 2),
    }],
  })
);

The read handler receives the parsed URI object. Return a contents array — most resources return a single item, but you can return multiple chunks for paginated or multi-part data.

URI templates for dynamic resources

Use URI templates to expose parameterized resources — a single registration that covers many URIs:

import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';

// Expose individual database rows as resources
server.resource(
  'user-record',
  new ResourceTemplate('db://users/{userId}', { list: undefined }),
  {
    name: 'User Record',
    description: 'A single user object from the database',
    mimeType: 'application/json',
  },
  async (uri, { userId }) => {
    const user = await db.users.findById(userId);
    if (!user) {
      throw new Error(`User not found: ${userId}`);
    }
    return {
      contents: [{
        uri: uri.href,
        mimeType: 'application/json',
        text: JSON.stringify(user, null, 2),
      }],
    };
  }
);

The list property on the template controls whether clients can call resources/list to enumerate all matching URIs. Set it to a handler function to enable listing, or undefined to disable it (clients can read resources if they know the URI but cannot enumerate them). This is useful for large collections where enumeration is impractical.

Enabling resources/list

To let clients enumerate available resources, provide a list handler. Clients call resources/list to discover what your server exposes before deciding what to read.

server.resource(
  'log-files',
  new ResourceTemplate('logs://{filename}', {
    list: async () => {
      const files = await fs.readdir('./logs');
      return {
        resources: files
          .filter(f => f.endsWith('.log'))
          .map(f => ({
            uri: `logs://${f}`,
            name: f,
            description: `Log file: ${f}`,
            mimeType: 'text/plain',
          })),
      };
    },
  }),
  { name: 'Log Files', mimeType: 'text/plain' },
  async (uri, { filename }) => {
    const content = await fs.readFile(`./logs/${filename}`, 'utf8');
    return {
      contents: [{ uri: uri.href, mimeType: 'text/plain', text: content }],
    };
  }
);

MIME types and binary resources

Choose the MIME type that accurately describes the content format. Clients and LLMs use this to decide how to process or display the resource.

Content typeMIME typeField to use
Plain text, logstext/plaintext
Markdown documentationtext/markdowntext
JSON data structuresapplication/jsontext
HTML pagestext/htmltext
CSV tablestext/csvtext
Images (PNG, JPEG)image/png, image/jpegblob (base64)
PDF documentsapplication/pdfblob (base64)

For binary content, base64-encode the buffer and use the blob field:

server.resource(
  'screenshot',
  'screenshots://current',
  { name: 'Current Screenshot', mimeType: 'image/png' },
  async (uri) => {
    const buffer = await captureScreenshot();
    return {
      contents: [{
        uri: uri.href,
        mimeType: 'image/png',
        blob: buffer.toString('base64'),
      }],
    };
  }
);

Resource subscriptions and real-time updates

Clients can subscribe to individual resources with resources/subscribe. When data changes, send a notifications/resources/updated notification. The client re-reads the resource after receiving the notification.

// When your data source changes, notify subscribed clients
async function onDatabaseRowUpdated(userId: string) {
  // The SDK tracks which clients have subscribed to which URIs.
  // Call sendResourceUpdated and the SDK delivers the notification.
  await server.sendResourceUpdated(`db://users/${userId}`);
}

// Hook this into your data layer
db.users.on('updated', (user) => {
  onDatabaseRowUpdated(user.id);
});

Not all clients implement subscriptions. Check client.capabilities?.resources?.subscribe before assuming notifications are delivered. The canonical flow is:

  1. Client calls resources/subscribe with a URI
  2. Server receives the subscription (SDK handles registration automatically)
  3. Data changes; server calls server.sendResourceUpdated(uri)
  4. Client receives notifications/resources/updated notification
  5. Client calls resources/read again to fetch the new data

Notifying clients when the resource list changes

If resources are created or removed dynamically (a new log file, a deleted database row), notify clients with sendResourceListChanged(). This triggers a fresh resources/list call from the client.

// When a new log file appears
watcher.on('add', (filename) => {
  if (filename.endsWith('.log')) {
    server.sendResourceListChanged();
  }
});

URI scheme design

Choose a URI scheme that clearly identifies the data domain. Use custom schemes rather than file:// for server-managed data — this avoids ambiguity with the client's local filesystem.

DomainExample URIs
Database rowsdb://users/123, db://orders/456
Application configconfig://app/settings, config://feature-flags
Git repositorygit://HEAD/src/main.ts, git://branches
Application logslogs://app.log, logs://errors-2026-06-09.log
External APIsapi://weather/london, api://github/repos/myorg
Memory/cache statecache://sessions, cache://rate-limits

Resources and external monitoring

If your MCP server exposes a resources endpoint over HTTP (via Streamable HTTP or SSE transport), that endpoint needs the same uptime monitoring as your tools endpoint. A resources/list failure — a crashed handler, a database connection drop, a memory limit hit — silently breaks LLM workflows that depend on that data. AliveMCP probes your HTTP MCP endpoint on a 60-second interval using the full MCP initialize handshake, catching both transport-level failures and protocol-level handler errors before your users do.

Further reading