Skip to content

Commit 87c581d

Browse files
committed
feat(@angular/cli): validate workspace paths against allowed MCP roots
1 parent 7da255d commit 87c581d

9 files changed

Lines changed: 71 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export async function runBuild(input: BuildToolInput, context: McpToolContext) {
4141
workspacePathInput: input.workspace,
4242
projectNameInput: input.project,
4343
mcpWorkspace: context.workspace,
44+
server: context.server,
4445
});
4546

4647
// Build "ng"'s command line.

packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export async function startDevserver(input: DevserverStartToolInput, context: Mc
4848
workspacePathInput: input.workspace,
4949
projectNameInput: input.project,
5050
mcpWorkspace: context.workspace,
51+
server: context.server,
5152
});
5253

5354
const key = getDevserverKey(workspacePath, projectName);

packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export async function stopDevserver(input: DevserverStopToolInput, context: McpT
3232
workspacePathInput: input.workspace,
3333
projectNameInput: input.project,
3434
mcpWorkspace: context.workspace,
35+
server: context.server,
3536
});
3637
const key = getDevserverKey(workspacePath, projectName);
3738
const devserver = context.devservers.get(key);

packages/angular/cli/src/commands/mcp/tools/devserver/devserver-wait-for-build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export async function waitForDevserverBuild(
6262
workspacePathInput: input.workspace,
6363
projectNameInput: input.project,
6464
mcpWorkspace: context.workspace,
65+
server: context.server,
6566
});
6667
const key = getDevserverKey(workspacePath, projectName);
6768
const devserver = context.devservers.get(key);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export async function runE2e(input: E2eToolInput, host: Host, context: McpToolCo
3535
workspacePathInput: input.workspace,
3636
projectNameInput: input.project,
3737
mcpWorkspace: context.workspace,
38+
server: context.server,
3839
});
3940

4041
if (workspace && projectName) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export async function runModernization(input: ModernizeInput, context: McpToolCo
111111
workspacePathInput: input.workspace,
112112
projectNameInput: input.project,
113113
mcpWorkspace: context.workspace,
114+
server: context.server,
114115
});
115116

116117
const instructions: string[] = [];

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export async function runTest(input: TestToolInput, context: McpToolContext) {
4343
workspacePathInput: input.workspace,
4444
projectNameInput: input.project,
4545
mcpWorkspace: context.workspace,
46+
server: context.server,
4647
});
4748

4849
// Build "ng"'s command line.

packages/angular/cli/src/commands/mcp/workspace-utils.ts

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

99
import { workspaces } from '@angular-devkit/core';
10-
import { dirname, join } from 'node:path';
10+
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11+
import { dirname, isAbsolute, join, relative as relativePath, resolve } from 'node:path';
12+
import { fileURLToPath } from 'node:url';
1113
import { AngularWorkspace } from '../../utilities/config';
1214
import { type Host, LocalWorkspaceHost } from './host';
1315
import { McpToolContext } from './tools/tool-registry';
@@ -92,11 +94,13 @@ export async function resolveWorkspaceAndProject({
9294
workspacePathInput,
9395
projectNameInput,
9496
mcpWorkspace,
97+
server,
9598
}: {
9699
host: Host;
97100
workspacePathInput?: string;
98101
projectNameInput?: string;
99102
mcpWorkspace?: AngularWorkspace;
103+
server?: McpServer;
100104
}): Promise<{
101105
workspace: AngularWorkspace;
102106
workspacePath: string;
@@ -106,6 +110,29 @@ export async function resolveWorkspaceAndProject({
106110
let workspace: AngularWorkspace;
107111

108112
if (workspacePathInput) {
113+
if (server) {
114+
// Validate that the provided workspace path is within the allowed MCP roots.
115+
// This prevents attackers from tricking the server into loading and executing code
116+
// from arbitrary locations on the filesystem.
117+
const rootsResponse = await server.server.listRoots();
118+
const roots = rootsResponse.roots;
119+
const normalizedInputPath = resolve(workspacePathInput);
120+
121+
const isAllowed = roots.some((root) => {
122+
const rootPath = resolve(fileURLToPath(root.uri));
123+
const relative = relativePath(rootPath, normalizedInputPath);
124+
125+
return !relative.startsWith('..') && !isAbsolute(relative);
126+
});
127+
128+
if (!isAllowed) {
129+
throw new Error(
130+
`Workspace path is outside the allowed MCP roots: ${workspacePathInput}. ` +
131+
"You can use 'list_projects' to find available workspaces.",
132+
);
133+
}
134+
}
135+
109136
if (!host.existsSync(workspacePathInput)) {
110137
throw new Error(
111138
`Workspace path does not exist: ${workspacePathInput}. ` +

packages/angular/cli/src/commands/mcp/workspace-utils_spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,42 @@ describe('MCP Workspace Utils', () => {
179179
expect(AngularWorkspace.load).toHaveBeenCalledWith('/my/workspace/angular.json');
180180
});
181181

182+
it('should allow provided workspace within allowed MCP roots', async () => {
183+
const mockServer = {
184+
server: {
185+
listRoots: jasmine.createSpy('listRoots').and.resolveTo({
186+
roots: [{ uri: 'file:///my/' }],
187+
}),
188+
},
189+
} as any;
190+
191+
const result = await resolveWorkspaceAndProject({
192+
host: mockHost,
193+
workspacePathInput: '/my/workspace',
194+
server: mockServer,
195+
});
196+
expect(result.workspacePath).toBe('/my/workspace');
197+
expect(AngularWorkspace.load).toHaveBeenCalledWith('/my/workspace/angular.json');
198+
});
199+
200+
it('should reject provided workspace outside allowed MCP roots', async () => {
201+
const mockServer = {
202+
server: {
203+
listRoots: jasmine.createSpy('listRoots').and.resolveTo({
204+
roots: [{ uri: 'file:///other/' }],
205+
}),
206+
},
207+
} as any;
208+
209+
await expectAsync(
210+
resolveWorkspaceAndProject({
211+
host: mockHost,
212+
workspacePathInput: '/my/workspace',
213+
server: mockServer,
214+
}),
215+
).toBeRejectedWithError(/Workspace path is outside the allowed MCP roots/);
216+
});
217+
182218
it('should throw if provided workspace does not exist', async () => {
183219
mockHost.existsSync.and.returnValue(false);
184220
await expectAsync(

0 commit comments

Comments
 (0)