diff --git a/packages/angular/cli/src/commands/mcp/cli.ts b/packages/angular/cli/src/commands/mcp/cli.ts index 690ee2c94927..cc496de26a4d 100644 --- a/packages/angular/cli/src/commands/mcp/cli.ts +++ b/packages/angular/cli/src/commands/mcp/cli.ts @@ -39,6 +39,12 @@ export default class McpCommandModule extends CommandModule implements CommandMo builder(localYargs: Argv): Argv { return localYargs + .option('root', { + type: 'string', + array: true, + describe: + 'Allowed root directory paths for filesystem access and workspace discovery. Can be specified multiple times.', + }) .option('read-only', { type: 'boolean', default: false, @@ -59,6 +65,7 @@ export default class McpCommandModule extends CommandModule implements CommandMo } async run(options: { + root: string[] | undefined; readOnly: boolean; localOnly: boolean; experimentalTool: string[] | undefined; @@ -75,6 +82,7 @@ export default class McpCommandModule extends CommandModule implements CommandMo readOnly: options.readOnly, localOnly: options.localOnly, experimentalTools: options.experimentalTool, + roots: options.root, }, this.context.logger, ); diff --git a/packages/angular/cli/src/commands/mcp/host.ts b/packages/angular/cli/src/commands/mcp/host.ts index 5dda0ade077f..0ec914727e41 100644 --- a/packages/angular/cli/src/commands/mcp/host.ts +++ b/packages/angular/cli/src/commands/mcp/host.ts @@ -282,11 +282,22 @@ export const LocalWorkspaceHost: Host = { }, }; +function resolveRoots(roots: string[]): string[] { + return roots.map((r) => { + try { + return realpathSync(resolve(r)); + } catch { + return resolve(r); + } + }); +} + export function createRootRestrictedHost( baseHost: Host, initialRoots: string[] = [process.cwd()], ): Host { - let roots = initialRoots; + const defaultRoots = resolveRoots(initialRoots); + let roots = defaultRoots; function checkPath(path: string) { const resolvedPath = resolve(path); @@ -332,7 +343,7 @@ export function createRootRestrictedHost( return { ...baseHost, setRoots(newRoots: string[]) { - roots = newRoots; + roots = newRoots.length > 0 ? resolveRoots(newRoots) : defaultRoots; }, stat(path: string) { checkPath(path); diff --git a/packages/angular/cli/src/commands/mcp/host_spec.ts b/packages/angular/cli/src/commands/mcp/host_spec.ts new file mode 100644 index 000000000000..2b2d3d9ea60a --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/host_spec.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { LocalWorkspaceHost, createRootRestrictedHost } from './host'; + +describe('createRootRestrictedHost', () => { + let root1: string; + let root2: string; + let outsideDir: string; + + beforeEach(() => { + root1 = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-root1-')); + root2 = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-root2-')); + outsideDir = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-outside-')); + + writeFileSync(join(root1, 'file1.txt'), 'root 1 content'); + writeFileSync(join(root2, 'file2.txt'), 'root 2 content'); + writeFileSync(join(outsideDir, 'outside.txt'), 'outside content'); + }); + + afterEach(() => { + rmSync(root1, { recursive: true, force: true }); + rmSync(root2, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + }); + + it('should allow file access inside any of the configured initial roots', () => { + const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]); + + expect(host.existsSync(join(root1, 'file1.txt'))).toBeTrue(); + expect(host.existsSync(join(root2, 'file2.txt'))).toBeTrue(); + }); + + it('should reject file access outside of the configured roots', () => { + const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]); + + expect(() => host.existsSync(join(outsideDir, 'outside.txt'))).toThrowError( + new RegExp( + `Access denied: path '${join(outsideDir, 'outside.txt')}' is outside allowed roots.`, + ), + ); + }); + + it('should fall back to initial roots when setRoots is called with an empty array', () => { + const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]); + + host.setRoots([]); + + expect(host.existsSync(join(root1, 'file1.txt'))).toBeTrue(); + expect(host.existsSync(join(root2, 'file2.txt'))).toBeTrue(); + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 82d3c75f21db..e6c7580c6ba9 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -7,7 +7,7 @@ */ import { McpServer } from '@modelcontextprotocol/server'; -import { join, normalize } from 'node:path'; +import { join, normalize, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { AngularWorkspace } from '../../utilities/config'; import { VERSION } from '../../utilities/version'; @@ -66,6 +66,7 @@ export async function createMcpServer( readOnly?: boolean; localOnly?: boolean; experimentalTools?: string[]; + roots?: string[]; }, logger: { warn(text: string): void }, ): Promise { @@ -121,7 +122,12 @@ for equivalent actions. logger, }); - const restrictedHost = createRootRestrictedHost(LocalWorkspaceHost); + const resolvedRoots = options.roots?.map((r) => resolve(r)); + + const restrictedHost = createRootRestrictedHost( + LocalWorkspaceHost, + resolvedRoots?.length ? resolvedRoots : [process.cwd()], + ); server.server.oninitialized = () => { void (async () => { @@ -163,6 +169,7 @@ for equivalent actions. exampleDatabasePath: join(__dirname, '../../../lib/code-examples.db'), devservers: new Map(), host: restrictedHost, + roots: resolvedRoots, }, toolDeclarations, ); diff --git a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts index 379a3cb0be8f..97a11ac863be 100644 --- a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts +++ b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts @@ -44,6 +44,9 @@ export interface MockContextOptions { /** Initial set of projects to populate the mock workspace with. */ projects?: Record; + + /** Optional roots to configure in the mock context. */ + roots?: string[]; } /** @@ -75,6 +78,7 @@ export function createMockContext(options: MockContextOptions = {}): { logger: { warn: () => {} }, devservers: new Map(), host, + roots: options.roots, }; return { host, context, projects }; diff --git a/packages/angular/cli/src/commands/mcp/tools/projects.ts b/packages/angular/cli/src/commands/mcp/tools/projects.ts index 57efa39c4aa2..5b9d15808b44 100644 --- a/packages/angular/cli/src/commands/mcp/tools/projects.ts +++ b/packages/angular/cli/src/commands/mcp/tools/projects.ts @@ -386,11 +386,9 @@ async function getProjectStyleLanguage( fullSourceRoot: string, ): Promise { const projectSchematics = project.extensions.schematics as - | Record> - | undefined; + Record> | undefined; const workspaceSchematics = workspace.extensions.schematics as - | Record> - | undefined; + Record> | undefined; // 1. Check for a project-specific schematic setting. let style = projectSchematics?.['@schematics/angular:component']?.['style']; @@ -566,7 +564,7 @@ function deduplicateSearchRoots(roots: string[]): string[] { return deduplicated; } -async function createListProjectsHandler({ server }: McpToolContext) { +async function createListProjectsHandler({ server, roots: configuredRoots }: McpToolContext) { return async () => { const workspaces: WorkspaceData[] = []; const parsingErrors: ParsingError[] = []; @@ -574,14 +572,16 @@ async function createListProjectsHandler({ server }: McpToolContext) { const seenPaths = new Set(); const versionCache = new Map(); - let searchRoots: string[]; + let searchRoots: string[] | undefined; const clientCapabilities = server.server.getClientCapabilities(); if (clientCapabilities?.roots) { const { roots } = await server.server.listRoots(); - searchRoots = roots?.map((r) => normalize(fileURLToPath(r.uri))) ?? []; - } else { - // Fallback to the current working directory if client does not support roots - searchRoots = [process.cwd()]; + searchRoots = roots?.map((r) => normalize(fileURLToPath(r.uri))); + } + + if (!searchRoots || searchRoots.length === 0) { + searchRoots = + configuredRoots && configuredRoots.length > 0 ? configuredRoots : [process.cwd()]; } searchRoots = deduplicateSearchRoots(searchRoots); diff --git a/packages/angular/cli/src/commands/mcp/tools/projects_spec.ts b/packages/angular/cli/src/commands/mcp/tools/projects_spec.ts index b45b1bbcb189..9c5e2d7d08f8 100644 --- a/packages/angular/cli/src/commands/mcp/tools/projects_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/projects_spec.ts @@ -89,4 +89,43 @@ describe('List Projects Tool', () => { expect(projects[0].targets).toEqual(['build', 'test', 'lint', 'e2e']); expect(projects[0].unitTestFramework).toBe('vitest'); }); + + it('should use configured roots when client roots capability is absent', async () => { + mockContext.server = { + server: { + getClientCapabilities: jasmine.createSpy('getClientCapabilities').and.returnValue({}), + }, + } as unknown as NonNullable[0]['server']>; + mockContext.roots = [allowedRoot]; + + const handler = await LIST_PROJECTS_TOOL.factory(mockContext); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (handler as any)({}); + + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent.workspaces.length).toBe(1); + expect(result.structuredContent.workspaces[0].projects[0].name).toBe('my-app'); + }); + + it('should fall back to configured roots when client supports roots but returns an empty list', async () => { + mockContext.server = { + server: { + getClientCapabilities: jasmine.createSpy('getClientCapabilities').and.returnValue({ + roots: { listChanged: false }, + }), + listRoots: jasmine.createSpy('listRoots').and.resolveTo({ + roots: [], + }), + }, + } as unknown as NonNullable[0]['server']>; + mockContext.roots = [allowedRoot]; + + const handler = await LIST_PROJECTS_TOOL.factory(mockContext); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (handler as any)({}); + + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent.workspaces.length).toBe(1); + expect(result.structuredContent.workspaces[0].projects[0].name).toBe('my-app'); + }); }); diff --git a/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts index 098a12520c88..2784798d6de4 100644 --- a/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts +++ b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts @@ -24,6 +24,7 @@ export interface McpToolContext { exampleDatabasePath?: string; devservers: Map; host: Host; + roots?: string[]; } export type McpToolCallback = (