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:
- A single open folder → one root:
file:///home/user/my-project - A multi-root workspace → multiple roots, one per folder
- A repository clone → the clone root plus any workspace root
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
- MCP server resources API — expose structured data to LLM clients
- MCP tool design — naming, argument schemas, and return shapes
- MCP tool annotations — hints for safe and destructive tool calls
- MCP server authentication — securing tool access
- MCP server secrets management — API keys and environment variables
- MCP server stdio transport — local process and filesystem access
- MCP server testing — InMemoryTransport and unit tests
- MCP server error handling — protocol and handler errors
- AliveMCP — uptime monitoring for HTTP-deployed MCP servers