Skip to content

Commit 2cf2442

Browse files
committed
feat(@angular/cli): support custom port in MCP devserver start tool
1 parent a712a82 commit 2cf2442

File tree

5 files changed

+71
-4
lines changed

5 files changed

+71
-4
lines changed

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ export interface Host {
118118
* Finds an available TCP port on the system.
119119
*/
120120
getAvailablePort(): Promise<number>;
121+
122+
/**
123+
* Checks whether a TCP port is available on the system.
124+
*/
125+
isPortAvailable(port: number): Promise<boolean>;
121126
}
122127

123128
/**
@@ -236,4 +241,15 @@ export const LocalWorkspaceHost: Host = {
236241
});
237242
});
238243
},
244+
245+
isPortAvailable(port: number): Promise<boolean> {
246+
return new Promise((resolve) => {
247+
const server = createServer();
248+
server.once('error', () => resolve(false));
249+
server.listen(port, () => {
250+
server.close();
251+
resolve(true);
252+
});
253+
});
254+
},
239255
};

packages/angular/cli/src/commands/mcp/testing/mock-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ export class MockHost implements Host {
2121
resolveModule = jasmine.createSpy('resolveRequest').and.returnValue('/dev/null');
2222
spawn = jasmine.createSpy('spawn');
2323
getAvailablePort = jasmine.createSpy('getAvailablePort');
24+
isPortAvailable = jasmine.createSpy('isPortAvailable').and.resolveTo(true);
2425
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export function createMockHost(): MockHost {
2727
getAvailablePort: jasmine
2828
.createSpy<Host['getAvailablePort']>('getAvailablePort')
2929
.and.resolveTo(0),
30+
isPortAvailable: jasmine
31+
.createSpy<Host['isPortAvailable']>('isPortAvailable')
32+
.and.resolveTo(true),
3033
} as unknown as MockHost;
3134
}
3235

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ import { type McpToolContext, type McpToolDeclaration, declareTool } from '../to
1515

1616
const devserverStartToolInputSchema = z.object({
1717
...workspaceAndProjectOptions,
18+
port: z
19+
.number()
20+
.optional()
21+
.describe(
22+
'The port number to run the server on. If not provided, a random available port will be chosen. ' +
23+
'It is recommended to reuse port numbers across calls within the same workspace to maintain consistency.',
24+
),
1825
});
1926

2027
export type DevserverStartToolInput = z.infer<typeof devserverStartToolInputSchema>;
@@ -53,7 +60,17 @@ export async function startDevserver(input: DevserverStartToolInput, context: Mc
5360
});
5461
}
5562

56-
const port = await context.host.getAvailablePort();
63+
let port: number;
64+
if (input.port) {
65+
if (!(await context.host.isPortAvailable(input.port))) {
66+
throw new Error(
67+
`Port ${input.port} is unavailable. Try calling this tool again without the 'port' parameter to auto-assign a free port.`,
68+
);
69+
}
70+
port = input.port;
71+
} else {
72+
port = await context.host.getAvailablePort();
73+
}
5774

5875
devserver = new LocalDevserver({
5976
host: context.host,
@@ -87,14 +104,18 @@ the first build completes.
87104
background.
88105
* **Get Initial Build Logs:** Once a dev server has started, use the "devserver.wait_for_build" tool to ensure it's alive. If there are any
89106
build errors, "devserver.wait_for_build" would provide them back and you can give them to the user or rely on them to propose a fix.
90-
* **Get Updated Build Logs:** Important: as long as a devserver is alive (i.e. "devserver.stop" wasn't called), after every time you make a
91-
change to the workspace, re-run "devserver.wait_for_build" to see whether the change was successfully built and wait for the devserver to
92-
be updated.
107+
* **Get Updated Build Logs:** Important: as long as a devserver is alive (i.e. "devserver.stop" wasn't called), after every time you
108+
make a change to the workspace, re-run "devserver.wait_for_build" to see whether the change was successfully built and wait for the
109+
devserver to be updated.
93110
</Use Cases>
94111
<Operational Notes>
95112
* This tool manages development servers by itself. It maintains at most a single dev server instance for each project in the monorepo.
96113
* This is an asynchronous operation. Subsequent commands can be ran while the server is active.
97114
* Use 'devserver.stop' to gracefully shut down the server and access the full log output.
115+
* **Keeping the Server Alive**: It is often better to keep the server alive between tool calls if you expect the user to request more
116+
changes or run more tests, as it saves time on restarts and maintains the file watcher state. You must still call
117+
'devserver.wait_for_build' after every change to see whether the change was successfully built and be sure that that app was updated.
118+
* **Consistent Ports**: If making multiple calls, it is recommended to reuse the port you got from the first call for subsequent ones.
98119
</Operational Notes>
99120
`,
100121
isReadOnly: true,

packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { EventEmitter } from 'events';
1010
import type { ChildProcess } from 'node:child_process';
11+
import { createServer } from 'node:net';
1112
import type { MockHost } from '../../testing/mock-host';
1213
import {
1314
MockMcpToolContext,
@@ -64,6 +65,31 @@ describe('Serve Tools', () => {
6465
expect(mockProcess.kill).toHaveBeenCalled();
6566
});
6667

68+
it('should use the provided port number', async () => {
69+
const startResult = await startDevserver({ port: 54321 }, mockContext);
70+
expect(startResult.structuredContent.message).toBe(
71+
`Development server for project 'my-app' started and watching for workspace changes.`,
72+
);
73+
expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'my-app', '--port=54321'], {
74+
stdio: 'pipe',
75+
cwd: '/test',
76+
});
77+
expect(mockHost.getAvailablePort).not.toHaveBeenCalled();
78+
});
79+
80+
it('should throw an error if the provided port is taken', async () => {
81+
mockHost.isPortAvailable.and.resolveTo(false);
82+
83+
try {
84+
await startDevserver({ port: 55555 }, mockContext);
85+
fail('Should have thrown an error');
86+
} catch (e) {
87+
expect((e as Error).message).toContain(
88+
"Port 55555 is unavailable. Try calling this tool again without the 'port' parameter to auto-assign a free port.",
89+
);
90+
}
91+
});
92+
6793
it('should wait for a build to complete', async () => {
6894
await startDevserver({}, mockContext);
6995

0 commit comments

Comments
 (0)