Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/angular/cli/src/commands/mcp/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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,
);
Expand Down
15 changes: 13 additions & 2 deletions packages/angular/cli/src/commands/mcp/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -332,7 +343,7 @@ export function createRootRestrictedHost(
return {
...baseHost,
setRoots(newRoots: string[]) {
roots = newRoots;
roots = newRoots.length > 0 ? resolveRoots(newRoots) : defaultRoots;
},
Comment thread
clydin marked this conversation as resolved.
stat(path: string) {
checkPath(path);
Expand Down
60 changes: 60 additions & 0 deletions packages/angular/cli/src/commands/mcp/host_spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
11 changes: 9 additions & 2 deletions packages/angular/cli/src/commands/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +66,7 @@ export async function createMcpServer(
readOnly?: boolean;
localOnly?: boolean;
experimentalTools?: string[];
roots?: string[];
},
logger: { warn(text: string): void },
): Promise<McpServer> {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -163,6 +169,7 @@ for equivalent actions.
exampleDatabasePath: join(__dirname, '../../../lib/code-examples.db'),
devservers: new Map<string, Devserver>(),
host: restrictedHost,
roots: resolvedRoots,
},
toolDeclarations,
);
Expand Down
4 changes: 4 additions & 0 deletions packages/angular/cli/src/commands/mcp/testing/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export interface MockContextOptions {

/** Initial set of projects to populate the mock workspace with. */
projects?: Record<string, workspaces.ProjectDefinition>;

/** Optional roots to configure in the mock context. */
roots?: string[];
}

/**
Expand Down Expand Up @@ -75,6 +78,7 @@ export function createMockContext(options: MockContextOptions = {}): {
logger: { warn: () => {} },
devservers: new Map<string, Devserver>(),
host,
roots: options.roots,
};

return { host, context, projects };
Expand Down
20 changes: 10 additions & 10 deletions packages/angular/cli/src/commands/mcp/tools/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,11 +386,9 @@ async function getProjectStyleLanguage(
fullSourceRoot: string,
): Promise<StyleLanguage> {
const projectSchematics = project.extensions.schematics as
| Record<string, Record<string, unknown>>
| undefined;
Record<string, Record<string, unknown>> | undefined;
const workspaceSchematics = workspace.extensions.schematics as
| Record<string, Record<string, unknown>>
| undefined;
Record<string, Record<string, unknown>> | undefined;

// 1. Check for a project-specific schematic setting.
let style = projectSchematics?.['@schematics/angular:component']?.['style'];
Expand Down Expand Up @@ -566,22 +564,24 @@ 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[] = [];
const versioningErrors: z.infer<typeof listProjectsOutputSchema.versioningErrors> = [];
const seenPaths = new Set<string>();
const versionCache = new Map<string, string | undefined>();

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()];
}
Comment thread
clydin marked this conversation as resolved.

searchRoots = deduplicateSearchRoots(searchRoots);
Expand Down
39 changes: 39 additions & 0 deletions packages/angular/cli/src/commands/mcp/tools/projects_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<typeof LIST_PROJECTS_TOOL.factory>[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<Parameters<typeof LIST_PROJECTS_TOOL.factory>[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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface McpToolContext {
exampleDatabasePath?: string;
devservers: Map<string, Devserver>;
host: Host;
roots?: string[];
}

export type McpToolCallback<TInput extends ZodRawShape = ZodRawShape> = (
Expand Down