Skip to content

Commit 41555df

Browse files
clydinalan-agius4
authored andcommitted
feat(@angular/cli): add --root command line option to mcp command
Add explicit `--root` command line option to the `@angular/cli mcp` command to specify allowed filesystem root directories for sandboxing and workspace discovery. In protocol revision 2026-07-28, the server-initiated `listRoots()` query is deprecated. The `--root` option allows hosts to explicitly pass allowed filesystem roots at startup, supporting multi-root workspaces and non-root CLI invocations. For clients that support `listRoots()`, those provided roots take priority.
1 parent e1c7193 commit 41555df

8 files changed

Lines changed: 144 additions & 14 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ export default class McpCommandModule extends CommandModule implements CommandMo
3939

4040
builder(localYargs: Argv): Argv {
4141
return localYargs
42+
.option('root', {
43+
type: 'string',
44+
array: true,
45+
describe:
46+
'Allowed root directory paths for filesystem access and workspace discovery. Can be specified multiple times.',
47+
})
4248
.option('read-only', {
4349
type: 'boolean',
4450
default: false,
@@ -59,6 +65,7 @@ export default class McpCommandModule extends CommandModule implements CommandMo
5965
}
6066

6167
async run(options: {
68+
root: string[] | undefined;
6269
readOnly: boolean;
6370
localOnly: boolean;
6471
experimentalTool: string[] | undefined;
@@ -75,6 +82,7 @@ export default class McpCommandModule extends CommandModule implements CommandMo
7582
readOnly: options.readOnly,
7683
localOnly: options.localOnly,
7784
experimentalTools: options.experimentalTool,
85+
roots: options.root,
7886
},
7987
this.context.logger,
8088
);

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,22 @@ export const LocalWorkspaceHost: Host = {
282282
},
283283
};
284284

285+
function resolveRoots(roots: string[]): string[] {
286+
return roots.map((r) => {
287+
try {
288+
return realpathSync(resolve(r));
289+
} catch {
290+
return resolve(r);
291+
}
292+
});
293+
}
294+
285295
export function createRootRestrictedHost(
286296
baseHost: Host,
287297
initialRoots: string[] = [process.cwd()],
288298
): Host {
289-
let roots = initialRoots;
299+
const defaultRoots = resolveRoots(initialRoots);
300+
let roots = defaultRoots;
290301

291302
function checkPath(path: string) {
292303
const resolvedPath = resolve(path);
@@ -332,7 +343,7 @@ export function createRootRestrictedHost(
332343
return {
333344
...baseHost,
334345
setRoots(newRoots: string[]) {
335-
roots = newRoots;
346+
roots = newRoots.length > 0 ? resolveRoots(newRoots) : defaultRoots;
336347
},
337348
stat(path: string) {
338349
checkPath(path);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
10+
import { tmpdir } from 'node:os';
11+
import { join } from 'node:path';
12+
import { LocalWorkspaceHost, createRootRestrictedHost } from './host';
13+
14+
describe('createRootRestrictedHost', () => {
15+
let root1: string;
16+
let root2: string;
17+
let outsideDir: string;
18+
19+
beforeEach(() => {
20+
root1 = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-root1-'));
21+
root2 = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-root2-'));
22+
outsideDir = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-outside-'));
23+
24+
writeFileSync(join(root1, 'file1.txt'), 'root 1 content');
25+
writeFileSync(join(root2, 'file2.txt'), 'root 2 content');
26+
writeFileSync(join(outsideDir, 'outside.txt'), 'outside content');
27+
});
28+
29+
afterEach(() => {
30+
rmSync(root1, { recursive: true, force: true });
31+
rmSync(root2, { recursive: true, force: true });
32+
rmSync(outsideDir, { recursive: true, force: true });
33+
});
34+
35+
it('should allow file access inside any of the configured initial roots', () => {
36+
const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]);
37+
38+
expect(host.existsSync(join(root1, 'file1.txt'))).toBeTrue();
39+
expect(host.existsSync(join(root2, 'file2.txt'))).toBeTrue();
40+
});
41+
42+
it('should reject file access outside of the configured roots', () => {
43+
const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]);
44+
45+
expect(() => host.existsSync(join(outsideDir, 'outside.txt'))).toThrowError(
46+
new RegExp(
47+
`Access denied: path '${join(outsideDir, 'outside.txt')}' is outside allowed roots.`,
48+
),
49+
);
50+
});
51+
52+
it('should fall back to initial roots when setRoots is called with an empty array', () => {
53+
const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]);
54+
55+
host.setRoots([]);
56+
57+
expect(host.existsSync(join(root1, 'file1.txt'))).toBeTrue();
58+
expect(host.existsSync(join(root2, 'file2.txt'))).toBeTrue();
59+
});
60+
});

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { McpServer } from '@modelcontextprotocol/server';
10-
import { join, normalize } from 'node:path';
10+
import { join, normalize, resolve } from 'node:path';
1111
import { fileURLToPath } from 'node:url';
1212
import type { AngularWorkspace } from '../../utilities/config';
1313
import { VERSION } from '../../utilities/version';
@@ -66,6 +66,7 @@ export async function createMcpServer(
6666
readOnly?: boolean;
6767
localOnly?: boolean;
6868
experimentalTools?: string[];
69+
roots?: string[];
6970
},
7071
logger: { warn(text: string): void },
7172
): Promise<McpServer> {
@@ -121,7 +122,12 @@ for equivalent actions.
121122
logger,
122123
});
123124

124-
const restrictedHost = createRootRestrictedHost(LocalWorkspaceHost);
125+
const resolvedRoots = options.roots?.map((r) => resolve(r));
126+
127+
const restrictedHost = createRootRestrictedHost(
128+
LocalWorkspaceHost,
129+
resolvedRoots?.length ? resolvedRoots : [process.cwd()],
130+
);
125131

126132
server.server.oninitialized = () => {
127133
void (async () => {
@@ -163,6 +169,7 @@ for equivalent actions.
163169
exampleDatabasePath: join(__dirname, '../../../lib/code-examples.db'),
164170
devservers: new Map<string, Devserver>(),
165171
host: restrictedHost,
172+
roots: resolvedRoots,
166173
},
167174
toolDeclarations,
168175
);

packages/angular/cli/src/commands/mcp/testing/test-utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export interface MockContextOptions {
4444

4545
/** Initial set of projects to populate the mock workspace with. */
4646
projects?: Record<string, workspaces.ProjectDefinition>;
47+
48+
/** Optional roots to configure in the mock context. */
49+
roots?: string[];
4750
}
4851

4952
/**
@@ -75,6 +78,7 @@ export function createMockContext(options: MockContextOptions = {}): {
7578
logger: { warn: () => {} },
7679
devservers: new Map<string, Devserver>(),
7780
host,
81+
roots: options.roots,
7882
};
7983

8084
return { host, context, projects };

packages/angular/cli/src/commands/mcp/tools/projects.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -386,11 +386,9 @@ async function getProjectStyleLanguage(
386386
fullSourceRoot: string,
387387
): Promise<StyleLanguage> {
388388
const projectSchematics = project.extensions.schematics as
389-
| Record<string, Record<string, unknown>>
390-
| undefined;
389+
Record<string, Record<string, unknown>> | undefined;
391390
const workspaceSchematics = workspace.extensions.schematics as
392-
| Record<string, Record<string, unknown>>
393-
| undefined;
391+
Record<string, Record<string, unknown>> | undefined;
394392

395393
// 1. Check for a project-specific schematic setting.
396394
let style = projectSchematics?.['@schematics/angular:component']?.['style'];
@@ -566,22 +564,24 @@ function deduplicateSearchRoots(roots: string[]): string[] {
566564
return deduplicated;
567565
}
568566

569-
async function createListProjectsHandler({ server }: McpToolContext) {
567+
async function createListProjectsHandler({ server, roots: configuredRoots }: McpToolContext) {
570568
return async () => {
571569
const workspaces: WorkspaceData[] = [];
572570
const parsingErrors: ParsingError[] = [];
573571
const versioningErrors: z.infer<typeof listProjectsOutputSchema.versioningErrors> = [];
574572
const seenPaths = new Set<string>();
575573
const versionCache = new Map<string, string | undefined>();
576574

577-
let searchRoots: string[];
575+
let searchRoots: string[] | undefined;
578576
const clientCapabilities = server.server.getClientCapabilities();
579577
if (clientCapabilities?.roots) {
580578
const { roots } = await server.server.listRoots();
581-
searchRoots = roots?.map((r) => normalize(fileURLToPath(r.uri))) ?? [];
582-
} else {
583-
// Fallback to the current working directory if client does not support roots
584-
searchRoots = [process.cwd()];
579+
searchRoots = roots?.map((r) => normalize(fileURLToPath(r.uri)));
580+
}
581+
582+
if (!searchRoots || searchRoots.length === 0) {
583+
searchRoots =
584+
configuredRoots && configuredRoots.length > 0 ? configuredRoots : [process.cwd()];
585585
}
586586

587587
searchRoots = deduplicateSearchRoots(searchRoots);

packages/angular/cli/src/commands/mcp/tools/projects_spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,43 @@ describe('List Projects Tool', () => {
8989
expect(projects[0].targets).toEqual(['build', 'test', 'lint', 'e2e']);
9090
expect(projects[0].unitTestFramework).toBe('vitest');
9191
});
92+
93+
it('should use configured roots when client roots capability is absent', async () => {
94+
mockContext.server = {
95+
server: {
96+
getClientCapabilities: jasmine.createSpy('getClientCapabilities').and.returnValue({}),
97+
},
98+
} as unknown as NonNullable<Parameters<typeof LIST_PROJECTS_TOOL.factory>[0]['server']>;
99+
mockContext.roots = [allowedRoot];
100+
101+
const handler = await LIST_PROJECTS_TOOL.factory(mockContext);
102+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
103+
const result = await (handler as any)({});
104+
105+
expect(result.structuredContent).toBeDefined();
106+
expect(result.structuredContent.workspaces.length).toBe(1);
107+
expect(result.structuredContent.workspaces[0].projects[0].name).toBe('my-app');
108+
});
109+
110+
it('should fall back to configured roots when client supports roots but returns an empty list', async () => {
111+
mockContext.server = {
112+
server: {
113+
getClientCapabilities: jasmine.createSpy('getClientCapabilities').and.returnValue({
114+
roots: { listChanged: false },
115+
}),
116+
listRoots: jasmine.createSpy('listRoots').and.resolveTo({
117+
roots: [],
118+
}),
119+
},
120+
} as unknown as NonNullable<Parameters<typeof LIST_PROJECTS_TOOL.factory>[0]['server']>;
121+
mockContext.roots = [allowedRoot];
122+
123+
const handler = await LIST_PROJECTS_TOOL.factory(mockContext);
124+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
125+
const result = await (handler as any)({});
126+
127+
expect(result.structuredContent).toBeDefined();
128+
expect(result.structuredContent.workspaces.length).toBe(1);
129+
expect(result.structuredContent.workspaces[0].projects[0].name).toBe('my-app');
130+
});
92131
});

packages/angular/cli/src/commands/mcp/tools/tool-registry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface McpToolContext {
2424
exampleDatabasePath?: string;
2525
devservers: Map<string, Devserver>;
2626
host: Host;
27+
roots?: string[];
2728
}
2829

2930
export type McpToolCallback<TInput extends ZodRawShape = ZodRawShape> = (

0 commit comments

Comments
 (0)