Guide · MCP Protocol

MCP server roots

MCP roots give your server access to the client's workspace context — the list of filesystem paths or URIs the user has opened or designated as in-scope. Rather than requiring users to pass file paths as tool arguments, roots let your server discover what's relevant automatically, scope tool operations to the correct directories, and populate resources from the client's active workspace.

TL;DR

Declare roots: { listChanged: true } in your server capabilities to receive change notifications. On connect, call client.listRoots() (or use the low-level server to call roots/list) to get the client's current root list. Subscribe to notifications/roots/list_changed to refresh whenever the user opens or closes a workspace. Each root has a uri (typically a file:// URI) and an optional human-readable name. Not all clients provide roots — check the capability before relying on it.

What roots represent

Roots are the top-level filesystem entry points the user has designated as their working context. In a code editor like VS Code or Cursor using an MCP server:

Roots are not always filesystem paths — a client could return other URI schemes (https://, custom schemes). Your server should handle whatever the client sends and skip roots with schemes it does not understand.

Declaring roots capability

To receive notifications/roots/list_changed notifications, declare the roots capability in your server definition. The listChanged flag tells clients your server can handle dynamic root updates.

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

// Using the high-level McpServer
const server = new McpServer(
  { name: 'workspace-server', version: '1.0.0' },
  {
    capabilities: {
      roots: { listChanged: true },
    },
  }
);

Reading roots on connect

Call roots/list after the initialize handshake to fetch the current workspace roots. The SDK exposes this via the underlying client connection from within tool handlers or server event handlers.

// Track current roots in server state
let currentRoots: Array<{ uri: string; name?: string }> = [];

// After connect, fetch initial roots
server.server.oninitialized = async () => {
  const caps = server.server.getClientCapabilities();
  if (caps?.roots) {
    try {
      const result = await server.server.sendRequest(
        { method: 'roots/list', params: {} },
        z.object({
          roots: z.array(z.object({
            uri: z.string(),
            name: z.string().optional(),
          })),
        })
      );
      currentRoots = result.roots;
    } catch {
      // Client doesn't support roots/list — proceed without roots
    }
  }
};

Responding to roots/list_changed

When the user opens or closes a workspace folder, the client sends notifications/roots/list_changed. Re-fetch the root list to stay current.

server.server.setNotificationHandler(
  { method: 'notifications/roots/list_changed' },
  async () => {
    try {
      const result = await server.server.sendRequest(
        { method: 'roots/list', params: {} },
        z.object({
          roots: z.array(z.object({
            uri: z.string(),
            name: z.string().optional(),
          })),
        })
      );
      currentRoots = result.roots;
      // If you expose resources derived from roots, notify clients
      server.sendResourceListChanged();
    } catch {
      currentRoots = [];
    }
  }
);

Using roots in tool handlers

Roots let tools operate on the user's actual workspace without requiring explicit path arguments. A find_files tool, for example, can search across all roots automatically.

import path from 'path';
import { fileURLToPath } from 'url';

server.tool(
  'find-files',
  'Find files matching a pattern across the current workspace',
  { pattern: z.string().describe('Glob pattern to match, e.g. **/*.ts') },
  async ({ pattern }) => {
    const fileRoots = currentRoots.filter(r => r.uri.startsWith('file://'));

    if (fileRoots.length === 0) {
      return {
        content: [{ type: 'text', text: 'No workspace roots available. Open a folder in your editor first.' }],
      };
    }

    const results: string[] = [];
    for (const root of fileRoots) {
      const dirPath = fileURLToPath(root.uri);
      const files = await globFiles(dirPath, pattern);
      results.push(...files.map(f => path.relative(dirPath, f)));
    }

    return {
      content: [{
        type: 'text',
        text: results.length > 0
          ? `Found ${results.length} files:\n${results.join('\n')}`
          : `No files matching ${pattern} found in workspace.`,
      }],
    };
  }
);

Populating resources from roots

Combine roots with the resources API to expose workspace files as readable resources. When roots change, rebuild the resource catalog.

// Expose workspace files as resources, scoped to current roots
server.resource(
  'workspace-files',
  new ResourceTemplate('workspace://{relativePath}', {
    list: async () => {
      const resources = [];
      for (const root of currentRoots.filter(r => r.uri.startsWith('file://'))) {
        const dir = fileURLToPath(root.uri);
        const files = await globFiles(dir, '**/*.{ts,js,json,md}');
        for (const file of files) {
          const rel = path.relative(dir, file);
          resources.push({
            uri: `workspace://${rel}`,
            name: rel,
            mimeType: mimeTypeForFile(file),
          });
        }
      }
      return { resources };
    },
  }),
  { name: 'Workspace File', description: 'A file from the current workspace' },
  async (uri, { relativePath }) => {
    // Find the file across all roots
    for (const root of currentRoots.filter(r => r.uri.startsWith('file://'))) {
      const fullPath = path.join(fileURLToPath(root.uri), relativePath);
      try {
        const text = await fs.readFile(fullPath, 'utf8');
        return {
          contents: [{
            uri: uri.href,
            mimeType: mimeTypeForFile(fullPath),
            text,
          }],
        };
      } catch { /* not in this root, try next */ }
    }
    throw new Error(`File not found in workspace: ${relativePath}`);
  }
);

Scoping tools to roots for safety

For file-writing tools, validate that the target path is inside a known root before proceeding. This prevents accidental writes outside the workspace and is a key security boundary when your server has filesystem access.

function isPathInRoots(targetPath: string): boolean {
  return currentRoots
    .filter(r => r.uri.startsWith('file://'))
    .some(root => {
      const rootPath = fileURLToPath(root.uri);
      const rel = path.relative(rootPath, targetPath);
      return !rel.startsWith('..') && !path.isAbsolute(rel);
    });
}

server.tool(
  'write-file',
  'Write content to a file in the workspace',
  { filePath: z.string(), content: z.string() },
  async ({ filePath, content }) => {
    const absPath = path.resolve(filePath);
    if (!isPathInRoots(absPath)) {
      return {
        content: [{ type: 'text', text: `Refused: ${absPath} is outside the workspace roots.` }],
        isError: true,
      };
    }
    await fs.writeFile(absPath, content, 'utf8');
    return { content: [{ type: 'text', text: `Written: ${absPath}` }] };
  }
);

Further reading