P225 min

Adding Resources

Expose data as resources — static files, dynamic system info, URI templates, and change subscriptions.

On This Page

Key Concepts

  • Resources expose data the AI can READ
  • Tools perform ACTIONS
  • URI scheme for resource identification
  • Static vs dynamic resources
  • Resource templates with URI patterns
  • Subscriptions for change notification
  • MIME types for content format

Tools vs Resources

So far you have only used tools — things the AI model can call to perform an action and get a result. But MCP has another primitive: resources.

Here is the distinction:

  • Tools are actions. They do something: calculate, fetch, transform, write. The AI decides when to call them based on the user's request.
  • Resources are data. They expose information that the AI can read. Think of them like files or documents the AI can access. The AI (or the user) can read a resource to load context into the conversation.
Tool: "calculate" — I do math when you ask me to.
Resource: "config://settings" — Here is my configuration data. Read it anytime.

Tool: "search_users" — I search the database when called.
Resource: "db://schema" — Here is the database schema. Read it to understand the data model.

Resources are identified by URIs, like file:///path/to/config.json or system://info. The client can list all available resources and read any of them.

You will add resources to the same server you have been building. Open src/index.ts in your my-first-mcp-server project.


Step 1: Add a Static Resource

A static resource exposes fixed content — like a configuration file, a README, or server metadata. The content does not change between reads.

Add this after your tool registrations, before the transport/connect code:

// --- Resource: Server configuration ---
server.resource(
  // 1. Resource name — displayed in the client UI
  "server-config",

  // 2. URI — unique identifier for this resource
  "config://server",

  // 3. Options — metadata about the resource
  {
    description: "Current server configuration and metadata",
    mimeType: "application/json",
  },

  // 4. Handler — returns the resource content
  async () => {
    const config = {
      serverName: "my-first-server",
      version: "1.0.0",
      toolCount: 6,
      transport: "stdio",
      startedAt: new Date().toISOString(),
    };

    return {
      contents: [
        {
          uri: "config://server",
          text: JSON.stringify(config, null, 2),
          mimeType: "application/json",
        },
      ],
    };
  }
);

What Just Happened?

The server.resource() method takes four arguments:

  1. Resource name ("server-config") — A human-readable name displayed in the client UI. Unlike tool names, this is for display, not for programmatic invocation.
  2. URI ("config://server") — The unique identifier for this resource. Clients use this URI to read the resource. You can use any URI scheme that makes sense: config://, file://, system://, db://. The scheme is up to you.
  3. Options — An object with metadata. description tells the AI what this resource contains. mimeType tells the client how to interpret the content (JSON, plain text, markdown, etc.).
  4. Handler — An async function that returns the resource content. It must return an object with a contents array. Each item has a uri (must match the resource URI), text (the actual content), and optionally a mimeType.

Note: The return shape for resources is contents (plural), not content like tools. Resources use contents with items that have uri and text fields. Tools use content with items that have type and text fields. This is a common source of confusion.


Step 2: Add a Dynamic Resource

Dynamic resources return different data each time they are read. This is useful for system information, current status, live metrics, or anything that changes over time.

Add import os from "node:os"; at the top of your file, then add this resource:

import os from "node:os";
// --- Resource: System information ---
server.resource(
  "system-info",
  "system://info",
  {
    description: "Current system information including hostname, platform, memory, and uptime",
    mimeType: "application/json",
  },
  async () => {
    const info = {
      hostname: os.hostname(),
      platform: os.platform(),
      arch: os.arch(),
      nodeVersion: process.version,
      uptime: `${Math.floor(os.uptime() / 3600)} hours, ${Math.floor((os.uptime() % 3600) / 60)} minutes`,
      totalMemory: `${Math.round(os.totalmem() / 1024 / 1024 / 1024 * 100) / 100} GB`,
      freeMemory: `${Math.round(os.freemem() / 1024 / 1024 / 1024 * 100) / 100} GB`,
      cpus: os.cpus().length,
      loadAverage: os.loadavg().map((l) => Math.round(l * 100) / 100),
    };

    return {
      contents: [
        {
          uri: "system://info",
          text: JSON.stringify(info, null, 2),
          mimeType: "application/json",
        },
      ],
    };
  }
);

What Just Happened?

This resource looks identical to the static one in structure, but the handler reads live system data every time it is called. Each read returns the current values for uptime, free memory, load average, etc.

The AI model can read this resource to understand the environment it is running in. For example, if a user asks "How much memory does my server have?", the AI can read system://info and answer directly.

Key points about dynamic resources:

  • The handler runs fresh every time a client reads the resource. There is no caching unless you implement it yourself.
  • Keep handlers fast. Resources are meant for reading data, not for long-running operations. If something takes more than a few hundred milliseconds, it should probably be a tool instead.
  • Return JSON for structured data and plain text or markdown for human-readable content. Set the mimeType accordingly.

Step 3: Resource Templates

Sometimes you want to expose a family of resources that follow a pattern. For example, you might want to read environment variables by name: env://PATH, env://HOME, env://USER.

Instead of registering each one individually, you can use a resource template. Templates define a URI pattern with placeholders, and the handler receives the matched values.

// --- Resource Template: Environment variables ---
server.resource(
  "environment-variable",

  // URI template — {name} is a placeholder
  "env:///{name}",

  {
    description: "Read the value of a specific environment variable by name",
    mimeType: "text/plain",
  },

  // Handler receives the matched URI parameters
  async (uri, { name }) => {
    const value = process.env[name];

    if (value === undefined) {
      return {
        contents: [
          {
            uri: uri.href,
            text: `Environment variable "${name}" is not set.`,
            mimeType: "text/plain",
          },
        ],
      };
    }

    return {
      contents: [
        {
          uri: uri.href,
          text: value,
          mimeType: "text/plain",
        },
      ],
    };
  }
);

What Just Happened?

The URI env:///{name} contains a placeholder in curly braces. When a client requests env:///HOME, the SDK matches the pattern, extracts name = "HOME", and passes it to your handler.

Key points about resource templates:

  • URI format — Placeholders go in curly braces: {name}, {id}, {path}. These follow the RFC 6570 URI template specification.
  • Handler signature — The handler receives two arguments: the full parsed URI object, and an object with the extracted template variables. Use uri.href to get the full URI string for the response.
  • Client discovery — When a client lists resources, templates appear with their pattern. The client (or AI model) knows it can request any URI matching the pattern.

Security note: Be careful what you expose through resource templates. Environment variables can contain secrets like API keys and database passwords. In a production server, you would filter or mask sensitive variables. For this learning exercise, it is fine.


Step 4: Resource Subscriptions

Resources can notify clients when their data changes. This is called a subscription. The client subscribes to a resource, and the server sends a notification whenever the resource is updated.

The notification mechanism works through the server's notification system. Here is how you would notify clients that a resource has changed:

// To notify clients that a resource has changed, you call:
// server.notification({
//   method: "notifications/resources/updated",
//   params: { uri: "system://info" },
// });
//
// You would trigger this from wherever the change happens:
// - A file watcher detecting changes
// - A timer updating metrics
// - An external event updating state

For a concrete example, imagine you have a resource tracking a counter. You could set up an interval that increments it and notifies subscribers:

// This is a conceptual example — in practice, you would only set up
// subscriptions for data that actually changes from external events.

let requestCount = 0;

server.resource(
  "request-counter",
  "stats://requests",
  {
    description: "Number of tool requests processed by this server",
    mimeType: "text/plain",
  },
  async () => {
    return {
      contents: [
        {
          uri: "stats://requests",
          text: String(requestCount),
          mimeType: "text/plain",
        },
      ],
    };
  }
);

// In a real server, you would increment the counter in your tool handlers
// and send a notification after each change.

Subscriptions are an advanced feature. Most MCP servers start without them and add them later when real-time updates become necessary. The important thing to know now is that the mechanism exists and how it works conceptually.


How Clients Discover Resources

When a client connects to your server, it can send a resources/list request to discover all available resources. The server responds with a list containing each resource's name, URI, description, and MIME type.

Client sends:
  { "method": "resources/list" }

Server responds:
  {
    "resources": [
      {
        "name": "server-config",
        "uri": "config://server",
        "description": "Current server configuration and metadata",
        "mimeType": "application/json"
      },
      {
        "name": "system-info",
        "uri": "system://info",
        "description": "Current system information...",
        "mimeType": "application/json"
      }
    ],
    "resourceTemplates": [
      {
        "name": "environment-variable",
        "uriTemplate": "env:///{name}",
        "description": "Read the value of a specific environment variable by name",
        "mimeType": "text/plain"
      }
    ]
  }

Notice that regular resources and resource templates are listed separately. The client knows that static URIs like config://server can be read directly, while templates like env:///{name} require a value for the placeholder.

To read a specific resource, the client sends a resources/read request with the URI:

Client sends:
  { "method": "resources/read", "params": { "uri": "system://info" } }

Server responds with the resource contents.

You do not implement the listing or reading logic yourself — the SDK handles it based on the resources you registered with server.resource().


Try It Yourself

Add a resource that lists files in a directory. Here is the specification:

  • Resource name: directory-listing
  • URI template: file:///{path}
  • Description: "Lists files and directories at the given path"
  • MIME type: application/json
  • Behavior: Use fs.readdir with the withFileTypes option to list entries. For each entry, include the name and whether it is a file or directory. Return the list as JSON.

Hints:

  1. Import fs from node:fs/promises if you have not already.
  2. Use await fs.readdir(path, { withFileTypes: true }) to get entries with type information.
  3. Map entries to objects with name and type fields.
  4. Wrap in try/catch for paths that do not exist.
Stuck? Here is the solution
import fs from "node:fs/promises";

server.resource(
  "directory-listing",
  "file:///{path}",
  {
    description: "Lists files and directories at the given path",
    mimeType: "application/json",
  },
  async (uri, { path }) => {
    try {
      const entries = await fs.readdir(`/${path}`, { withFileTypes: true });
      const listing = entries.map((entry) => ({
        name: entry.name,
        type: entry.isDirectory() ? "directory" : "file",
      }));

      return {
        contents: [
          {
            uri: uri.href,
            text: JSON.stringify(listing, null, 2),
            mimeType: "application/json",
          },
        ],
      };
    } catch (error) {
      return {
        contents: [
          {
            uri: uri.href,
            text: JSON.stringify({
              error: `Cannot read directory: ${error instanceof Error ? error.message : "Unknown error"}`,
            }),
            mimeType: "application/json",
          },
        ],
      };
    }
  }
);

Troubleshooting

Resource does not show up in the resources list

Rebuild with npm run build and restart the Inspector connection. Resources are registered at server startup, so the client only sees them after reconnecting.

Confusion between content and contents

This is the most common mistake. Remember:

  • Tools return { content: [{ type, text }] }
  • Resources return { contents: [{ uri, text }] }

If you use the wrong shape, you will get a runtime error or empty results.

Resource template URI does not match

Make sure the URI scheme is consistent. If the template is env:///{name} (three slashes), requests must use three slashes too: env:///HOME. Two slashes would be a different URI.

Handler receives undefined for template variables

The handler's second argument is an object with the extracted variables. Make sure you are destructuring correctly:

// Correct:
async (uri, { name }) => { ... }

// Wrong — missing the uri parameter:
async ({ name }) => { ... }

Full Code Reference

Here are the resource additions for src/index.ts. Add these after your tool registrations and before the transport/connect code:

import os from "node:os";

// --- Resource: Server configuration ---
server.resource(
  "server-config",
  "config://server",
  {
    description: "Current server configuration and metadata",
    mimeType: "application/json",
  },
  async () => {
    const config = {
      serverName: "my-first-server",
      version: "1.0.0",
      toolCount: 6,
      transport: "stdio",
      startedAt: new Date().toISOString(),
    };
    return {
      contents: [
        {
          uri: "config://server",
          text: JSON.stringify(config, null, 2),
          mimeType: "application/json",
        },
      ],
    };
  }
);

// --- Resource: System information ---
server.resource(
  "system-info",
  "system://info",
  {
    description: "Current system information including hostname, platform, memory, and uptime",
    mimeType: "application/json",
  },
  async () => {
    const info = {
      hostname: os.hostname(),
      platform: os.platform(),
      arch: os.arch(),
      nodeVersion: process.version,
      uptime: `${Math.floor(os.uptime() / 3600)}h ${Math.floor((os.uptime() % 3600) / 60)}m`,
      totalMemory: `${Math.round(os.totalmem() / 1024 / 1024 / 1024 * 100) / 100} GB`,
      freeMemory: `${Math.round(os.freemem() / 1024 / 1024 / 1024 * 100) / 100} GB`,
      cpus: os.cpus().length,
    };
    return {
      contents: [
        {
          uri: "system://info",
          text: JSON.stringify(info, null, 2),
          mimeType: "application/json",
        },
      ],
    };
  }
);

// --- Resource Template: Environment variables ---
server.resource(
  "environment-variable",
  "env:///{name}",
  {
    description: "Read the value of a specific environment variable by name",
    mimeType: "text/plain",
  },
  async (uri, { name }) => {
    const value = process.env[name];
    return {
      contents: [
        {
          uri: uri.href,
          text: value ?? `Environment variable "${name}" is not set.`,
          mimeType: "text/plain",
        },
      ],
    };
  }
);

Your server now exposes both tools and resources. The full project structure is unchanged — all code is in src/index.ts.


What You Accomplished

  • Understood the difference between tools and resources
  • Added a static resource exposing server configuration
  • Added a dynamic resource exposing live system information
  • Used resource templates with URI parameters
  • Learned how resource subscriptions work conceptually
  • Understood how clients discover and read resources

In the next module, you will use MCP Inspector to thoroughly test all the tools and resources you have built so far.