Skip to content

Commit fcf625f

Browse files
committed
fix(@angular/cli): restrict MCP workspace access to allowed client roots during resolution
Introduces validation during workspace and project resolution to ensure the resolved workspace path falls within the allowed MCP roots defined by client capabilities. Throws an error if the workspace resolves outside the allowed roots. Closes #33077
1 parent c6dd57a commit fcf625f

8 files changed

Lines changed: 130 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
@@ -38,6 +38,7 @@ export type BuildToolOutput = z.infer<typeof buildToolOutputSchema>;
3838
export async function runBuild(input: BuildToolInput, context: McpToolContext) {
3939
const { workspacePath, projectName } = await resolveWorkspaceAndProject({
4040
host: context.host,
41+
server: context.server,
4142
workspacePathInput: input.workspace,
4243
projectNameInput: input.project,
4344
mcpWorkspace: context.workspace,

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
@@ -45,6 +45,7 @@ function localhostAddress(port: number) {
4545
export async function startDevserver(input: DevserverStartToolInput, context: McpToolContext) {
4646
const { workspacePath, projectName } = await resolveWorkspaceAndProject({
4747
host: context.host,
48+
server: context.server,
4849
workspacePathInput: input.workspace,
4950
projectNameInput: input.project,
5051
mcpWorkspace: context.workspace,

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
@@ -29,6 +29,7 @@ export type DevserverStopToolOutput = z.infer<typeof devserverStopToolOutputSche
2929
export async function stopDevserver(input: DevserverStopToolInput, context: McpToolContext) {
3030
const { workspacePath, projectName } = await resolveWorkspaceAndProject({
3131
host: context.host,
32+
server: context.server,
3233
workspacePathInput: input.workspace,
3334
projectNameInput: input.project,
3435
mcpWorkspace: context.workspace,

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
@@ -59,6 +59,7 @@ export async function waitForDevserverBuild(
5959
) {
6060
const { workspacePath, projectName } = await resolveWorkspaceAndProject({
6161
host: context.host,
62+
server: context.server,
6263
workspacePathInput: input.workspace,
6364
projectNameInput: input.project,
6465
mcpWorkspace: context.workspace,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type E2eToolOutput = z.infer<typeof e2eToolOutputSchema>;
3232
export async function runE2e(input: E2eToolInput, host: Host, context: McpToolContext) {
3333
const { workspacePath, workspace, projectName } = await resolveWorkspaceAndProject({
3434
host,
35+
server: context.server,
3536
workspacePathInput: input.workspace,
3637
projectNameInput: input.project,
3738
mcpWorkspace: context.workspace,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export async function runModernization(input: ModernizeInput, context: McpToolCo
108108

109109
const { workspacePath, projectName } = await resolveWorkspaceAndProject({
110110
host: context.host,
111+
server: context.server,
111112
workspacePathInput: input.workspace,
112113
projectNameInput: input.project,
113114
mcpWorkspace: context.workspace,

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

Lines changed: 60 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 { realpathSync } from 'node:fs';
11+
import { dirname, isAbsolute, join, normalize, relative } 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';
@@ -80,6 +82,44 @@ export function getDefaultProjectName(workspace: AngularWorkspace | undefined):
8082
return undefined;
8183
}
8284

85+
function isWithinAllowedRoot(root: string, targetPath: string): boolean {
86+
const rel = relative(root, targetPath);
87+
88+
return !rel.startsWith('..') && !isAbsolute(rel);
89+
}
90+
91+
async function getAllowedWorkspaceRoots(server: McpToolContext['server']): Promise<string[]> {
92+
let roots: string[];
93+
const clientCapabilities = server.server.getClientCapabilities();
94+
95+
if (clientCapabilities?.roots) {
96+
const { roots: clientRoots } = await server.server.listRoots();
97+
roots = clientRoots?.map((root) => fileURLToPath(root.uri)) ?? [];
98+
} else {
99+
roots = [process.cwd()];
100+
}
101+
102+
return roots
103+
.map((root) => {
104+
try {
105+
return realpathSync(root);
106+
} catch {
107+
return null;
108+
}
109+
})
110+
.filter((root): root is string => root !== null);
111+
}
112+
113+
async function isAllowedWorkspacePath(
114+
server: McpToolContext['server'],
115+
workspacePath: string,
116+
): Promise<boolean> {
117+
const allowedRoots = await getAllowedWorkspaceRoots(server);
118+
const resolvedWorkspacePath = realpathSync(workspacePath);
119+
120+
return allowedRoots.some((root) => isWithinAllowedRoot(root, resolvedWorkspacePath));
121+
}
122+
83123
/**
84124
* Resolves workspace and project for tools to operate on.
85125
*
@@ -89,11 +129,13 @@ export function getDefaultProjectName(workspace: AngularWorkspace | undefined):
89129
*/
90130
export async function resolveWorkspaceAndProject({
91131
host,
132+
server,
92133
workspacePathInput,
93134
projectNameInput,
94135
mcpWorkspace,
95136
}: {
96137
host: Host;
138+
server?: McpToolContext['server'];
97139
workspacePathInput?: string;
98140
projectNameInput?: string;
99141
mcpWorkspace?: AngularWorkspace;
@@ -118,6 +160,15 @@ export async function resolveWorkspaceAndProject({
118160
"You can use 'list_projects' to find available workspaces.",
119161
);
120162
}
163+
if (server) {
164+
if (!(await isAllowedWorkspacePath(server, workspacePathInput))) {
165+
throw new Error(
166+
`Workspace path is outside the allowed MCP roots: ${workspacePathInput}. ` +
167+
"You can use 'list_projects' to find available workspaces.",
168+
);
169+
}
170+
}
171+
121172
workspacePath = workspacePathInput;
122173
const configPath = join(workspacePath, 'angular.json');
123174
try {
@@ -137,6 +188,14 @@ export async function resolveWorkspaceAndProject({
137188
"You can use 'list_projects' to find available workspaces.",
138189
);
139190
}
191+
192+
if (server && !(await isAllowedWorkspacePath(server, found))) {
193+
throw new Error(
194+
`The current directory resolves to a workspace outside the allowed MCP roots: ${found}. ` +
195+
"You can use 'list_projects' to find available workspaces.",
196+
);
197+
}
198+
140199
workspacePath = found;
141200
const configPath = join(workspacePath, 'angular.json');
142201
try {

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

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

99
import { workspaces } from '@angular-devkit/core';
10+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11+
import { tmpdir } from 'node:os';
1012
import { join } from 'node:path';
13+
import { pathToFileURL } from 'node:url';
1114
import { AngularWorkspace } from '../../utilities/config';
1215
import { LocalWorkspaceHost } from './host';
1316
import { addProjectToWorkspace, createMockContext, createMockHost } from './testing/test-utils';
@@ -101,11 +104,24 @@ describe('MCP Workspace Utils', () => {
101104
describe('resolveWorkspaceAndProject', () => {
102105
let mockHost: ReturnType<typeof createMockHost>;
103106
let mockWorkspace: AngularWorkspace;
107+
let mockServer: NonNullable<Parameters<typeof resolveWorkspaceAndProject>[0]['server']>;
108+
let tempDir: string;
109+
let allowedRoot: string;
110+
let allowedWorkspace: string;
111+
let outsideWorkspace: string;
104112
const cwd = './';
105113

106114
beforeEach(() => {
107115
mockHost = createMockHost();
108116
spyOn(process, 'cwd').and.returnValue(cwd);
117+
tempDir = mkdtempSync(join(tmpdir(), 'mcp-workspace-utils-'));
118+
allowedRoot = join(tempDir, 'allowed-root');
119+
allowedWorkspace = join(allowedRoot, 'workspace');
120+
outsideWorkspace = join(tempDir, 'outside-workspace');
121+
mkdirSync(allowedWorkspace, { recursive: true });
122+
mkdirSync(outsideWorkspace, { recursive: true });
123+
writeFileSync(join(allowedWorkspace, 'angular.json'), '{}');
124+
writeFileSync(join(outsideWorkspace, 'angular.json'), '{}');
109125

110126
// Setup default mocks
111127
mockHost.existsSync.and.callFake((p) => {
@@ -120,6 +136,18 @@ describe('MCP Workspace Utils', () => {
120136
if (p === '/my/workspace/angular.json') {
121137
return true;
122138
}
139+
if (p === allowedWorkspace) {
140+
return true;
141+
}
142+
if (p === join(allowedWorkspace, 'angular.json')) {
143+
return true;
144+
}
145+
if (p === outsideWorkspace) {
146+
return true;
147+
}
148+
if (p === join(outsideWorkspace, 'angular.json')) {
149+
return true;
150+
}
123151

124152
return false;
125153
});
@@ -139,6 +167,21 @@ describe('MCP Workspace Utils', () => {
139167
} as unknown as AngularWorkspace;
140168

141169
spyOn(AngularWorkspace, 'load').and.resolveTo(mockWorkspace);
170+
171+
mockServer = {
172+
server: {
173+
getClientCapabilities: jasmine.createSpy('getClientCapabilities').and.returnValue({
174+
roots: { listChanged: false },
175+
}),
176+
listRoots: jasmine.createSpy('listRoots').and.resolveTo({
177+
roots: [{ uri: pathToFileURL(allowedRoot).href, name: 'allowed-root' }],
178+
}),
179+
},
180+
} as unknown as NonNullable<Parameters<typeof resolveWorkspaceAndProject>[0]['server']>;
181+
});
182+
183+
afterEach(() => {
184+
rmSync(tempDir, { recursive: true, force: true });
142185
});
143186

144187
it('should resolve workspace from CWD if not provided and mcpWorkspace is absent', async () => {
@@ -179,6 +222,27 @@ describe('MCP Workspace Utils', () => {
179222
expect(AngularWorkspace.load).toHaveBeenCalledWith('/my/workspace/angular.json');
180223
});
181224

225+
it('should allow provided workspace within allowed MCP roots', async () => {
226+
const result = await resolveWorkspaceAndProject({
227+
host: mockHost,
228+
server: mockServer,
229+
workspacePathInput: allowedWorkspace,
230+
});
231+
expect(result.workspacePath).toBe(allowedWorkspace);
232+
expect(AngularWorkspace.load).toHaveBeenCalledWith(join(allowedWorkspace, 'angular.json'));
233+
expect(mockServer.server.listRoots).toHaveBeenCalled();
234+
});
235+
236+
it('should reject provided workspace outside allowed MCP roots', async () => {
237+
await expectAsync(
238+
resolveWorkspaceAndProject({
239+
host: mockHost,
240+
server: mockServer,
241+
workspacePathInput: outsideWorkspace,
242+
}),
243+
).toBeRejectedWithError(/Workspace path is outside the allowed MCP roots/);
244+
});
245+
182246
it('should throw if provided workspace does not exist', async () => {
183247
mockHost.existsSync.and.returnValue(false);
184248
await expectAsync(

0 commit comments

Comments
 (0)