Skip to content

Commit 08b674d

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 08b674d

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

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

Lines changed: 67 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,65 @@ 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(
302+
baseHost: Host,
303+
initialRoots: string[] = [process.cwd()],
304+
): Host {
305+
let roots = initialRoots;
306+
307+
function checkPath(path: string) {
308+
const resolvedPath = resolve(path);
309+
const isAllowed = roots.some((root) => {
310+
const rel = relative(root, resolvedPath);
311+
312+
return !rel.startsWith('..') && !isAbsolute(rel);
313+
});
314+
315+
if (!isAllowed) {
316+
throw new Error(`Access denied: path '${path}' is outside allowed roots.`);
317+
}
318+
}
319+
320+
return {
321+
...baseHost,
322+
setRoots(newRoots: string[]) {
323+
roots = newRoots;
324+
},
325+
stat(path: string) {
326+
checkPath(path);
327+
328+
return baseHost.stat(path);
329+
},
330+
existsSync(path: string) {
331+
checkPath(path);
332+
333+
return baseHost.existsSync(path);
334+
},
335+
readFile(path: string, encoding: 'utf-8') {
336+
checkPath(path);
337+
338+
return baseHost.readFile(path, encoding);
339+
},
340+
glob(pattern: string, options: { cwd: string }) {
341+
checkPath(options.cwd);
342+
343+
return baseHost.glob(pattern, options);
344+
},
345+
runCommand(command: string, args: readonly string[], options: { cwd?: string } = {}) {
346+
checkPath(options.cwd ?? process.cwd());
347+
348+
return baseHost.runCommand(command, args, options);
349+
},
350+
spawn(command: string, args: readonly string[], options: { cwd?: string } = {}) {
351+
checkPath(options.cwd ?? process.cwd());
352+
353+
return baseHost.spawn(command, args, options);
354+
},
355+
};
356+
}

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

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

99
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10-
import { join } from 'node:path';
10+
import { RootsListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
11+
import { join, normalize } from 'node:path';
12+
import { fileURLToPath } from 'node:url';
1113
import type { AngularWorkspace } from '../../utilities/config';
1214
import { VERSION } from '../../utilities/version';
1315
import type { Devserver } from './devserver';
14-
import { LocalWorkspaceHost } from './host';
16+
import { LocalWorkspaceHost, createRootRestrictedHost } from './host';
1517
import { registerInstructionsResource } from './resources/instructions';
1618
import { AI_TUTOR_TOOL } from './tools/ai-tutor';
1719
import { BEST_PRACTICES_TOOL } from './tools/best-practices';
@@ -124,14 +126,48 @@ equivalent actions.
124126
logger,
125127
});
126128

129+
const restrictedHost = createRootRestrictedHost(LocalWorkspaceHost);
130+
131+
server.server.oninitialized = () => {
132+
void (async () => {
133+
try {
134+
const clientCapabilities = server.server.getClientCapabilities();
135+
if (clientCapabilities?.roots) {
136+
const { roots } = await server.server.listRoots();
137+
const searchRoots = roots?.map((r) => normalize(fileURLToPath(r.uri))) ?? [];
138+
restrictedHost.setRoots(searchRoots);
139+
140+
if (clientCapabilities.roots.listChanged) {
141+
server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
142+
try {
143+
const { roots: updatedRoots } = await server.server.listRoots();
144+
const updatedSearchRoots =
145+
updatedRoots?.map((r) => normalize(fileURLToPath(r.uri))) ?? [];
146+
restrictedHost.setRoots(updatedSearchRoots);
147+
} catch (e) {
148+
logger.warn(
149+
`Failed to update roots on notification: ${e instanceof Error ? e.message : e}`,
150+
);
151+
}
152+
});
153+
}
154+
}
155+
} catch (e) {
156+
logger.warn(
157+
`Failed to initialize roots on connection: ${e instanceof Error ? e.message : e}`,
158+
);
159+
}
160+
})();
161+
};
162+
127163
await registerTools(
128164
server,
129165
{
130166
workspace: options.workspace,
131167
logger,
132168
exampleDatabasePath: join(__dirname, '../../../lib/code-examples.db'),
133169
devservers: new Map<string, Devserver>(),
134-
host: LocalWorkspaceHost,
170+
host: restrictedHost,
135171
},
136172
toolDeclarations,
137173
);

packages/angular/cli/src/commands/mcp/testing/mock-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ export class MockHost implements Host {
2222
spawn = jasmine.createSpy('spawn');
2323
getAvailablePort = jasmine.createSpy('getAvailablePort');
2424
isPortAvailable = jasmine.createSpy('isPortAvailable').and.resolveTo(true);
25+
setRoots = jasmine.createSpy('setRoots');
2526
}

0 commit comments

Comments
 (0)