Skip to content

Commit 9883239

Browse files
committed
refactor(@angular/cli): integrate MCP roots into Host abstraction
Update the `Host` abstraction to be aware of allowed roots provided by the MCP client. A new `createRootRestrictedHost` wrapper enforces that file operations and command executions stay within these roots. The server is updated to initialize roots on connection and listen for changes.
1 parent e6d80aa commit 9883239

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

packages/angular/cli/src/commands/mcp/host.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { Stats } from 'node:fs';
1919
import { glob as nodeGlob, readFile as nodeReadFile, stat } from 'node:fs/promises';
2020
import { createRequire } from 'node:module';
2121
import { createServer } from 'node:net';
22-
import { dirname, join, resolve } from 'node:path';
22+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
2323

2424
/**
2525
* An error thrown when a command fails to execute.
@@ -124,6 +124,11 @@ export interface Host {
124124
* Checks whether a TCP port is available on the system.
125125
*/
126126
isPortAvailable(port: number): Promise<boolean>;
127+
128+
/**
129+
* Sets the allowed roots for this host.
130+
*/
131+
setRoots(roots: string[]): void;
127132
}
128133

129134
function resolveCommand(
@@ -287,4 +292,55 @@ export const LocalWorkspaceHost: Host = {
287292
});
288293
});
289294
},
295+
296+
setRoots(roots: string[]) {
297+
// LocalWorkspaceHost does not enforce roots, so this is a no-op.
298+
},
290299
};
300+
301+
export function createRootRestrictedHost(baseHost: Host, initialRoots: string[] = [process.cwd()]): Host {
302+
let roots = initialRoots;
303+
304+
function checkPath(path: string) {
305+
const resolvedPath = resolve(path);
306+
const isAllowed = roots.some((root) => {
307+
const rel = relative(root, resolvedPath);
308+
return !rel.startsWith('..') && !isAbsolute(rel);
309+
});
310+
311+
if (!isAllowed) {
312+
throw new Error(`Access denied: path '${path}' is outside allowed roots.`);
313+
}
314+
}
315+
316+
return {
317+
...baseHost,
318+
setRoots(newRoots: string[]) {
319+
roots = newRoots;
320+
},
321+
stat(path: string) {
322+
checkPath(path);
323+
return baseHost.stat(path);
324+
},
325+
existsSync(path: string) {
326+
checkPath(path);
327+
return baseHost.existsSync(path);
328+
},
329+
readFile(path: string, encoding: 'utf-8') {
330+
checkPath(path);
331+
return baseHost.readFile(path, encoding);
332+
},
333+
glob(pattern: string, options: { cwd: string }) {
334+
checkPath(options.cwd);
335+
return baseHost.glob(pattern, options);
336+
},
337+
runCommand(command: string, args: readonly string[], options: { cwd?: string } = {}) {
338+
checkPath(options.cwd ?? process.cwd());
339+
return baseHost.runCommand(command, args, options);
340+
},
341+
spawn(command: string, args: readonly string[], options: { cwd?: string } = {}) {
342+
checkPath(options.cwd ?? process.cwd());
343+
return baseHost.spawn(command, args, options);
344+
},
345+
};
346+
}

packages/angular/cli/src/commands/mcp/mcp-server.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
*/
88

99
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10-
import { join } from 'node:path';
10+
import { join, normalize } from 'node:path';
11+
import { fileURLToPath } from 'node:url';
1112
import type { AngularWorkspace } from '../../utilities/config';
1213
import { VERSION } from '../../utilities/version';
1314
import type { Devserver } from './devserver';
14-
import { LocalWorkspaceHost } from './host';
15+
import { LocalWorkspaceHost, createRootRestrictedHost } from './host';
1516
import { registerInstructionsResource } from './resources/instructions';
1617
import { AI_TUTOR_TOOL } from './tools/ai-tutor';
1718
import { BEST_PRACTICES_TOOL } from './tools/best-practices';
@@ -26,6 +27,7 @@ import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zonel
2627
import { LIST_PROJECTS_TOOL } from './tools/projects';
2728
import { TEST_TOOL } from './tools/test';
2829
import { type AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';
30+
import { RootsListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types';
2931

3032
/**
3133
* Tools to manage devservers. Should be bundled together, then added to experimental or stable as a group.
@@ -124,14 +126,41 @@ equivalent actions.
124126
logger,
125127
});
126128

129+
const restrictedHost = createRootRestrictedHost(LocalWorkspaceHost);
130+
131+
server.server.oninitialized = async () => {
132+
try {
133+
const clientCapabilities = server.server.getClientCapabilities();
134+
if (clientCapabilities?.roots) {
135+
const { roots } = await server.server.listRoots();
136+
const searchRoots = roots?.map((r) => normalize(fileURLToPath(r.uri))) ?? [];
137+
restrictedHost.setRoots(searchRoots);
138+
139+
if (clientCapabilities.roots.listChanged) {
140+
server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
141+
try {
142+
const { roots: updatedRoots } = await server.server.listRoots();
143+
const updatedSearchRoots = updatedRoots?.map((r) => normalize(fileURLToPath(r.uri))) ?? [];
144+
restrictedHost.setRoots(updatedSearchRoots);
145+
} catch (e) {
146+
logger.warn(`Failed to update roots on notification: ${e instanceof Error ? e.message : e}`);
147+
}
148+
});
149+
}
150+
}
151+
} catch (e) {
152+
logger.warn(`Failed to initialize roots on connection: ${e instanceof Error ? e.message : e}`);
153+
}
154+
};
155+
127156
await registerTools(
128157
server,
129158
{
130159
workspace: options.workspace,
131160
logger,
132161
exampleDatabasePath: join(__dirname, '../../../lib/code-examples.db'),
133162
devservers: new Map<string, Devserver>(),
134-
host: LocalWorkspaceHost,
163+
host: restrictedHost,
135164
},
136165
toolDeclarations,
137166
);

0 commit comments

Comments
 (0)