Skip to content

Commit 87d99e9

Browse files
amishnealan-agius4
authored andcommitted
feat(@angular/cli): support custom port in MCP devserver start tool
1 parent e7e434c commit 87d99e9

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: 17 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,16 @@ 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+
});
255+
},
239256
};

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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,31 @@ describe('Serve Tools', () => {
6464
expect(mockProcess.kill).toHaveBeenCalled();
6565
});
6666

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

0 commit comments

Comments
 (0)