Module 4: MCP Server in TypeScript

Implement Resources with URIs and Templates

Implement Resources with URIs and Templates

Capsule description

In the previous capsule you implemented tools — actions the model executes. Now you move to the natural complement: resources — data the model can read. If tools are the model's hands, resources are its eyes.

In module 3 you saw resources conceptually and with a minimal example. Now you're going to implement complete resources in TypeScript: static resources with fixed URIs, dynamic resources with URI templates, resources that read from the filesystem, resources that aggregate data, and resources with subscriptions.

The key difference between a tool and a resource is the intent: a resource exposes data without modifying anything. It's pull-based — the client asks and the server responds. There are no side effects. This restriction isn't a limitation — it's what makes resources predictable and safe to use.


Quick review: Resources in the SDK

The server.resource() API

// Static resource (fixed URI)
server.resource(
  name,           // string — internal identifier
  uri,            // string — the resource's URI (e.g., "config://app/settings")
  metadata,       // { description, mimeType } — metadata for the client
  handler         // async (uri) => ResourceResult
);

// Dynamic resource (URI template)
server.resource(
  name,           // string — internal identifier
  template,       // ResourceTemplate — URI with placeholders
  metadata,       // { description, mimeType }
  handler         // async (uri, params) => ResourceResult
);

Result structure

return {
  contents: [
    {
      uri: uri.href,       // the requested URI
      mimeType: "...",     // content type
      text: "...",         // text content
    },
  ],
};

Static resources: fixed URIs

A static resource has a URI that doesn't change. It always points to the same data, though the content can vary (because it reads fresh data each time).

Example 1: System status

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as os from "os";

const server = new McpServer({
  name: "system-monitor",
  version: "1.0.0",
});

server.resource(
  "system-status",
  "system://status",
  {
    description: "Current system status: CPU, memory, uptime, platform",
    mimeType: "application/json",
  },
  async (uri) => {
    const totalMem = os.totalmem();
    const freeMem = os.freemem();
    const usedMem = totalMem - freeMem;

    const status = {
      platform: os.platform(),
      arch: os.arch(),
      hostname: os.hostname(),
      uptime: {
        seconds: os.uptime(),
        formatted: formatUptime(os.uptime()),
      },
      memory: {
        total: formatBytes(totalMem),
        used: formatBytes(usedMem),
        free: formatBytes(freeMem),
        usagePercent: ((usedMem / totalMem) * 100).toFixed(1) + "%",
      },
      cpus: os.cpus().length,
      loadAverage: os.loadavg(),
      nodeVersion: process.version,
      timestamp: new Date().toISOString(),
    };

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

function formatBytes(bytes: number): string {
  const gb = bytes / (1024 * 1024 * 1024);
  return `${gb.toFixed(2)} GB`;
}

function formatUptime(seconds: number): string {
  const days = Math.floor(seconds / 86400);
  const hours = Math.floor((seconds % 86400) / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  return `${days}d ${hours}h ${minutes}m`;
}

When to use: Data that always exists and has a natural URI. You don't need parameters to request it.

Example 2: Application configuration

import * as fs from "fs/promises";
import * as path from "path";

server.resource(
  "app-config",
  "config://app/current",
  {
    description: "Current application configuration from package.json and config files",
    mimeType: "application/json",
  },
  async (uri) => {
    const projectDir = process.cwd();
    const config: Record<string, unknown> = {};

    try {
      const pkgJson = await fs.readFile(path.join(projectDir, "package.json"), "utf-8");
      const pkg = JSON.parse(pkgJson);
      config.package = {
        name: pkg.name,
        version: pkg.version,
        description: pkg.description,
        dependencies: Object.keys(pkg.dependencies || {}),
        devDependencies: Object.keys(pkg.devDependencies || {}),
      };
    } catch {
      config.package = { error: "package.json not found" };
    }

    try {
      const tsConfigJson = await fs.readFile(path.join(projectDir, "tsconfig.json"), "utf-8");
      const tsConfig = JSON.parse(tsConfigJson);
      config.typescript = {
        target: tsConfig.compilerOptions?.target,
        module: tsConfig.compilerOptions?.module,
        strict: tsConfig.compilerOptions?.strict,
        outDir: tsConfig.compilerOptions?.outDir,
      };
    } catch {
      config.typescript = { error: "tsconfig.json not found" };
    }

    const envVars: Record<string, string> = {};
    for (const [key, value] of Object.entries(process.env)) {
      if (key.startsWith("APP_") || key.startsWith("MCP_")) {
        envVars[key] = value || "";
      }
    }
    config.env = Object.keys(envVars).length > 0 ? envVars : { note: "No APP_* or MCP_* env vars found" };

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

Example 3: Project structure

server.resource(
  "project-tree",
  "project://structure",
  {
    description: "Tree of files and folders of the current project with sizes",
    mimeType: "application/json",
  },
  async (uri) => {
    const projectDir = process.cwd();

    interface FileNode {
      name: string;
      type: "file" | "directory";
      size?: string;
      children?: FileNode[];
    }

    async function buildTree(dir: string, depth: number = 0): Promise<FileNode[]> {
      if (depth > 4) return [];

      const entries = await fs.readdir(dir, { withFileTypes: true });
      const nodes: FileNode[] = [];

      const sorted = entries
        .filter(e => !e.name.startsWith(".") && e.name !== "node_modules" && e.name !== "build")
        .sort((a, b) => {
          if (a.isDirectory() && !b.isDirectory()) return -1;
          if (!a.isDirectory() && b.isDirectory()) return 1;
          return a.name.localeCompare(b.name);
        });

      for (const entry of sorted) {
        const fullPath = path.join(dir, entry.name);

        if (entry.isDirectory()) {
          const children = await buildTree(fullPath, depth + 1);
          nodes.push({ name: entry.name, type: "directory", children });
        } else {
          const stats = await fs.stat(fullPath);
          nodes.push({
            name: entry.name,
            type: "file",
            size: `${(stats.size / 1024).toFixed(1)} KB`,
          });
        }
      }

      return nodes;
    }

    const tree = await buildTree(projectDir);

    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({
          root: projectDir,
          tree,
          generatedAt: new Date().toISOString(),
        }, null, 2),
      }],
    };
  }
);

Dynamic resources: URI Templates

Resource templates let the same resource serve different data depending on the URI's parameters. They're the solution for parameterizable data where the parameters are simple.

How ResourceTemplate works

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

server.resource(
  "internal-name",
  new ResourceTemplate("scheme://type/{parameter}", {
    list: async () => {
      // Returns the list of concrete URIs the client can request
      return [
        { uri: "scheme://type/value1", name: "Value 1" },
        { uri: "scheme://type/value2", name: "Value 2" },
      ];
    },
  }),
  { description: "...", mimeType: "..." },
  async (uri, params) => {
    // params.parameter contains the placeholder's value
    // ...
  }
);

The list callback is what tells the client which concrete instances of the template exist. It's optional — if you don't provide it, the client can construct URIs based on the template.

Example 4: Files by path

server.resource(
  "file-content",
  new ResourceTemplate("file:///{filePath}", {
    list: async () => {
      const projectDir = process.cwd();
      const files: Array<{ uri: string; name: string; description: string }> = [];

      async function collectFiles(dir: string): Promise<void> {
        const entries = await fs.readdir(dir, { withFileTypes: true });
        for (const entry of entries) {
          if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "build") continue;
          const fullPath = path.join(dir, entry.name);
          const relativePath = path.relative(projectDir, fullPath);

          if (entry.isDirectory()) {
            await collectFiles(fullPath);
          } else {
            files.push({
              uri: `file:///${relativePath}`,
              name: entry.name,
              description: `Content of ${relativePath}`,
            });
          }
        }
      }

      await collectFiles(projectDir);
      return files;
    },
  }),
  {
    description: "Content of a project file by its relative path",
    mimeType: "text/plain",
  },
  async (uri, params) => {
    const filePath = params.filePath as string;
    const projectDir = process.cwd();
    const absolutePath = path.resolve(projectDir, filePath);

    if (!absolutePath.startsWith(projectDir)) {
      throw new Error("Access denied: the path is outside the project");
    }

    try {
      const content = await fs.readFile(absolutePath, "utf-8");
      const ext = path.extname(filePath);
      const mimeTypes: Record<string, string> = {
        ".ts": "text/typescript",
        ".js": "text/javascript",
        ".json": "application/json",
        ".md": "text/markdown",
        ".html": "text/html",
        ".css": "text/css",
      };

      return {
        contents: [{
          uri: uri.href,
          mimeType: mimeTypes[ext] || "text/plain",
          text: content,
        }],
      };
    } catch {
      throw new Error(`File not found: ${filePath}`);
    }
  }
);

Example 5: Logs by date

server.resource(
  "logs-by-date",
  new ResourceTemplate("logs://app/{date}", {
    list: async () => {
      const logsDir = path.join(process.cwd(), "logs");
      try {
        const files = await fs.readdir(logsDir);
        return files
          .filter(f => f.endsWith(".log"))
          .map(f => {
            const date = f.replace(".log", "");
            return {
              uri: `logs://app/${date}`,
              name: `Logs for ${date}`,
              description: `Log file for the day ${date}`,
            };
          });
      } catch {
        return [];
      }
    },
  }),
  {
    description: "Application logs by date (format: YYYY-MM-DD)",
    mimeType: "text/plain",
  },
  async (uri, params) => {
    const date = params.date as string;
    const dateRegex = /^\d{4}-\d{2}-\d{2}$/;

    if (!dateRegex.test(date)) {
      throw new Error(`Invalid date format: ${date}. Use YYYY-MM-DD`);
    }

    const logPath = path.join(process.cwd(), "logs", `${date}.log`);

    try {
      const content = await fs.readFile(logPath, "utf-8");
      const lines = content.split("\n");

      return {
        contents: [{
          uri: uri.href,
          mimeType: "text/plain",
          text: `=== Logs for ${date} ===\nTotal lines: ${lines.length}\n\n${content}`,
        }],
      };
    } catch {
      throw new Error(`No logs for the date ${date}`);
    }
  }
);

Example 6: Resource template with multiple parameters

server.resource(
  "git-diff",
  new ResourceTemplate("git://diff/{branch1}/{branch2}", {
    list: undefined,
  }),
  {
    description: "Diff between two Git branches",
    mimeType: "text/plain",
  },
  async (uri, params) => {
    const branch1 = params.branch1 as string;
    const branch2 = params.branch2 as string;
    const { exec } = await import("child_process");
    const { promisify } = await import("util");
    const execAsync = promisify(exec);

    try {
      const { stdout } = await execAsync(`git diff ${branch1}...${branch2}`, {
        cwd: process.cwd(),
        maxBuffer: 1024 * 1024 * 5,
      });

      return {
        contents: [{
          uri: uri.href,
          mimeType: "text/plain",
          text: stdout || `No differences between ${branch1} and ${branch2}`,
        }],
      };
    } catch (error) {
      throw new Error(`Error getting the diff: ${error instanceof Error ? error.message : "unknown"}`);
    }
  }
);

How the client discovers resources

The MCP protocol defines two methods for discovery:

resources/list

The client sends resources/list and receives the list of available resources:

{
  "resources": [
    {
      "uri": "system://status",
      "name": "system-status",
      "description": "Current system status",
      "mimeType": "application/json"
    },
    {
      "uri": "config://app/current",
      "name": "app-config",
      "description": "Current configuration"
    }
  ]
}

For resource templates, the list callback is what generates this list. Without list, the template doesn't appear in the static list but the client can still construct valid URIs.

resources/read

The client sends resources/read with a specific URI:

{
  "uri": "system://status"
}

And receives the content:

{
  "contents": [
    {
      "uri": "system://status",
      "mimeType": "application/json",
      "text": "{\"platform\": \"darwin\", ...}"
    }
  ]
}

Discovery in practice

When you connect your server to Claude Code, this is what happens:

1. Claude Code (host) sends resources/list
2. Your server responds with the list of available resources
3. Claude Code presents the resources to the model as available context
4. When the model needs data, it requests resources/read with a specific URI
5. Your server runs the handler and returns the data

In MCP Inspector, you can see this flow in the Resources tab: the list of available resources and the ability to read each one.


Resource vs Tool: when to use which

You're going to make this decision constantly. Here's an expanded guide:

Use a Resource when:

✅ The data is read-only
✅ The parameters fit in a URI (string, number, date)
✅ You want the data to be discoverable (appears in resources/list)
✅ The data is contextual — the model needs it as background
✅ There are no side effects of any kind

Use a Tool when:

✅ The operation modifies state
✅ The parameters are complex (nested objects, arrays, combined booleans)
✅ You need detailed input validation with Zod
✅ The operation can fail and you need granular error handling
✅ You want the model to decide when to invoke (not just expose data)

Gray cases

Read a file by path → Resource template (file:///{path})
Search files with complex filters → Tool (search_files)
System configuration → Static resource (config://app)
Change configuration → Tool (update_config)
Last 10 logs → Static resource (logs://recent)
Search logs with regex → Tool (search_logs)

Practical rule: If the parameters fit in a URI and there are no side effects, use a Resource. If you need Zod validation or there are side effects, use a Tool.


Advanced patterns

Pattern 1: Resource with aggregated data

server.resource(
  "project-stats",
  "stats://project/summary",
  {
    description: "Project statistics: files by type, total size, lines of code",
    mimeType: "application/json",
  },
  async (uri) => {
    const projectDir = process.cwd();
    const stats: Record<string, { count: number; totalSize: number; totalLines: number }> = {};
    let totalFiles = 0;

    async function scanDir(dir: string): Promise<void> {
      const entries = await fs.readdir(dir, { withFileTypes: true });
      for (const entry of entries) {
        if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "build") continue;
        const fullPath = path.join(dir, entry.name);

        if (entry.isDirectory()) {
          await scanDir(fullPath);
        } else {
          totalFiles++;
          const ext = path.extname(entry.name) || "(no extension)";
          if (!stats[ext]) stats[ext] = { count: 0, totalSize: 0, totalLines: 0 };

          const fileStat = await fs.stat(fullPath);
          stats[ext].count++;
          stats[ext].totalSize += fileStat.size;

          try {
            const content = await fs.readFile(fullPath, "utf-8");
            stats[ext].totalLines += content.split("\n").length;
          } catch {
            // binary file
          }
        }
      }
    }

    await scanDir(projectDir);

    const formatted = Object.entries(stats)
      .sort((a, b) => b[1].count - a[1].count)
      .map(([ext, data]) => ({
        extension: ext,
        files: data.count,
        totalSize: `${(data.totalSize / 1024).toFixed(1)} KB`,
        totalLines: data.totalLines,
      }));

    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({
          projectDir,
          totalFiles,
          byExtension: formatted,
          generatedAt: new Date().toISOString(),
        }, null, 2),
      }],
    };
  }
);

Pattern 2: Resource with markdown content

Not all resources return JSON. Markdown is ideal for documentation and reports:

server.resource(
  "api-docs",
  "docs://api/endpoints",
  {
    description: "Documentation of the API's available endpoints",
    mimeType: "text/markdown",
  },
  async (uri) => {
    const docs = `# API Endpoints

## Authentication

### POST /auth/login
Authenticates a user and returns a JWT token.

**Body:**
\`\`\`json
{
  "email": "user@example.com",
  "password": "secret"
}
\`\`\`

**Response 200:**
\`\`\`json
{
  "token": "eyJ...",
  "expiresIn": 3600
}
\`\`\`

## Users

### GET /users
Returns the paginated list of users.

**Query params:** \`page\`, \`limit\`, \`sort\`

### GET /users/:id
Returns a user by their ID.

### POST /users
Creates a new user. Requires authentication.

---
*Documentation generated automatically*
`;

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

Pattern 3: Resource that combines multiple sources

server.resource(
  "project-health",
  "health://project/overview",
  {
    description: "Complete view of the project's health: dependencies, security, code quality",
    mimeType: "application/json",
  },
  async (uri) => {
    const projectDir = process.cwd();
    const health: Record<string, unknown> = {};

    try {
      const lockfile = await fs.readFile(path.join(projectDir, "package-lock.json"), "utf-8");
      const lock = JSON.parse(lockfile);
      const depCount = Object.keys(lock.packages || {}).length;
      health.dependencies = {
        total: depCount,
        lockfileExists: true,
      };
    } catch {
      health.dependencies = { lockfileExists: false };
    }

    try {
      const gitignore = await fs.readFile(path.join(projectDir, ".gitignore"), "utf-8");
      health.git = {
        gitignoreExists: true,
        ignoresNodeModules: gitignore.includes("node_modules"),
        ignoresBuild: gitignore.includes("build") || gitignore.includes("dist"),
        ignoresEnv: gitignore.includes(".env"),
      };
    } catch {
      health.git = { gitignoreExists: false };
    }

    try {
      const tsconfig = await fs.readFile(path.join(projectDir, "tsconfig.json"), "utf-8");
      const config = JSON.parse(tsconfig);
      health.typescript = {
        strictMode: config.compilerOptions?.strict === true,
        target: config.compilerOptions?.target,
      };
    } catch {
      health.typescript = { configured: false };
    }

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

Troubleshooting

"Resource doesn't appear in MCP Inspector"

Cause: The resource was registered after server.connect().

Solution: Register all the resources before connecting the transport:

server.resource("...", "...", {}, async () => { ... });  // ← first
await server.connect(transport);                          // ← after

"Resource template doesn't resolve parameters"

Cause: The placeholder in the URI template doesn't match the name used in params.

Solution:

// The {date} placeholder maps to params.date
new ResourceTemplate("logs://app/{date}", { list: undefined })
// In the handler:
async (uri, params) => {
  const date = params.date as string;  // ← same name as the placeholder
}

"Error: Cannot read property 'href' of undefined"

Cause: The handler receives uri as a URL, not as a string.

Solution:

async (uri) => {
  return {
    contents: [{
      uri: uri.href,  // ← use .href to convert to a string
      text: "...",
    }],
  };
}

"Resource returns but the data is old"

Cause: The data is computed at registration time, not at read time.

Solution:

// ❌ Data computed only once
const data = await getExpensiveData();
server.resource("...", "...", {}, async (uri) => {
  return { contents: [{ uri: uri.href, text: JSON.stringify(data) }] };
});

// ✅ Data computed on each read
server.resource("...", "...", {}, async (uri) => {
  const data = await getExpensiveData();  // ← fresh each time
  return { contents: [{ uri: uri.href, text: JSON.stringify(data) }] };
});

"MIME type causes the content not to display well"

Cause: The declared mimeType doesn't match the actual format of the content.

Solution:

// If you return JSON → "application/json"
// If you return plain text → "text/plain"
// If you return markdown → "text/markdown"
// When in doubt → "text/plain" always works

Exercises

Exercise 1: Static environment resource (Easy)

Implement a static resource with URI env://app/info that returns environment information: Node.js version, platform, working directory, and environment variables starting with APP_.

See solution
server.resource(
  "env-info",
  "env://app/info",
  {
    description: "Information about the execution environment",
    mimeType: "application/json",
  },
  async (uri) => {
    const appEnv: Record<string, string> = {};
    for (const [key, value] of Object.entries(process.env)) {
      if (key.startsWith("APP_") && value) {
        appEnv[key] = value;
      }
    }

    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({
          nodeVersion: process.version,
          platform: process.platform,
          arch: process.arch,
          cwd: process.cwd(),
          pid: process.pid,
          appEnvVars: appEnv,
          timestamp: new Date().toISOString(),
        }, null, 2),
      }],
    };
  }
);

Exercise 2: Resource template for dependencies (Medium)

Implement a resource template with URI deps://package/{name} that returns information about an installed npm package (version, description) by reading its package.json from node_modules.

See solution
server.resource(
  "dependency-info",
  new ResourceTemplate("deps://package/{name}", {
    list: async () => {
      try {
        const pkgJson = await fs.readFile(
          path.join(process.cwd(), "package.json"), "utf-8"
        );
        const pkg = JSON.parse(pkgJson);
        const allDeps = {
          ...pkg.dependencies,
          ...pkg.devDependencies,
        };
        return Object.keys(allDeps).map(name => ({
          uri: `deps://package/${name}`,
          name: `${name} (${allDeps[name]})`,
          description: `Information about the ${name} package`,
        }));
      } catch {
        return [];
      }
    },
  }),
  {
    description: "Detailed information about an installed npm package",
    mimeType: "application/json",
  },
  async (uri, params) => {
    const name = params.name as string;
    const pkgPath = path.join(process.cwd(), "node_modules", name, "package.json");

    try {
      const content = await fs.readFile(pkgPath, "utf-8");
      const pkg = JSON.parse(content);

      return {
        contents: [{
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify({
            name: pkg.name,
            version: pkg.version,
            description: pkg.description,
            license: pkg.license,
            homepage: pkg.homepage,
            repository: pkg.repository,
            main: pkg.main,
            dependencies: Object.keys(pkg.dependencies || {}).length,
          }, null, 2),
        }],
      };
    } catch {
      throw new Error(`Package '${name}' not found in node_modules`);
    }
  }
);

Exercise 3: Resource with multiple contents (Medium)

Implement a resource that returns the content of the 3 most recent files in a directory. Use the contents array to return multiple files in a single resource.

See solution
server.resource(
  "recent-files",
  "files://recent/top3",
  {
    description: "The 3 most recently modified files in the project",
    mimeType: "text/plain",
  },
  async (uri) => {
    const projectDir = process.cwd();
    const allFiles: Array<{ path: string; mtime: Date }> = [];

    async function collectFiles(dir: string): Promise<void> {
      const entries = await fs.readdir(dir, { withFileTypes: true });
      for (const entry of entries) {
        if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "build") continue;
        const fullPath = path.join(dir, entry.name);
        if (entry.isDirectory()) {
          await collectFiles(fullPath);
        } else {
          const stats = await fs.stat(fullPath);
          allFiles.push({ path: fullPath, mtime: stats.mtime });
        }
      }
    }

    await collectFiles(projectDir);
    const top3 = allFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()).slice(0, 3);

    const contents = await Promise.all(
      top3.map(async (file) => {
        const content = await fs.readFile(file.path, "utf-8").catch(() => "(binary file)");
        const relativePath = path.relative(projectDir, file.path);
        return {
          uri: `file:///${relativePath}`,
          mimeType: "text/plain",
          text: `// === ${relativePath} (modified: ${file.mtime.toISOString()}) ===\n${content}`,
        };
      })
    );

    return { contents };
  }
);

Exercise 4: Resource template with validation (Hard)

Implement a resource template metrics://cpu/{period} where period can be "1min", "5min", or "15min". Return the corresponding system load average.

See solution
server.resource(
  "cpu-metrics",
  new ResourceTemplate("metrics://cpu/{period}", {
    list: async () => [
      { uri: "metrics://cpu/1min", name: "CPU Load (1 min)", description: "Load average of the last minute" },
      { uri: "metrics://cpu/5min", name: "CPU Load (5 min)", description: "Load average of 5 minutes" },
      { uri: "metrics://cpu/15min", name: "CPU Load (15 min)", description: "Load average of 15 minutes" },
    ],
  }),
  {
    description: "CPU load average by period: 1min, 5min, or 15min",
    mimeType: "application/json",
  },
  async (uri, params) => {
    const period = params.period as string;
    const loadAvg = os.loadavg();
    const periodMap: Record<string, { index: number; label: string }> = {
      "1min": { index: 0, label: "1 minute" },
      "5min": { index: 1, label: "5 minutes" },
      "15min": { index: 2, label: "15 minutes" },
    };

    const config = periodMap[period];
    if (!config) {
      throw new Error(`Invalid period: ${period}. Use: 1min, 5min, or 15min`);
    }

    const cpuCount = os.cpus().length;
    const load = loadAvg[config.index];

    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({
          period: config.label,
          loadAverage: load.toFixed(2),
          cpuCores: cpuCount,
          utilizationPercent: ((load / cpuCount) * 100).toFixed(1) + "%",
          status: load / cpuCount > 0.8 ? "HIGH" : load / cpuCount > 0.5 ? "MODERATE" : "NORMAL",
          timestamp: new Date().toISOString(),
        }, null, 2),
      }],
    };
  }
);

Summary

In this capsule you learned:

  • Static resources have fixed URIs and are ideal for data that always exists (system status, configuration)
  • Resource templates (ResourceTemplate) allow URIs with dynamic parameters ({id}, {date}, {path})
  • The list callback in resource templates lets the client discover which concrete instances exist
  • resources/list and resources/read are the two protocol methods for discovery and reading
  • Resources return data with a declared mimeType — application/json, text/plain, text/markdown
  • The decision rule: simple parameters + read-only → Resource; complex parameters or side effects → Tool
  • Resources are registered before connecting the transport

Additional resources

  1. MCP Specification — Resources - Official specification
  2. URI Template RFC 6570 - URI templates standard
  3. MCP TypeScript SDK — ResourceTemplate - ResourceTemplate API
  4. MIME Types Reference - List of MIME types
  5. Node.js os module - API of the os module used in the examples
  6. MCP Inspector - To test your resources visually

Next capsule: Transports — how your server communicates with the host. stdio for local development, HTTP/SSE for remote servers, and Streamable HTTP as the future of the protocol.