From a9fc743ab4afa57e461ba199047791e404a2457c Mon Sep 17 00:00:00 2001 From: Alon Mishne Date: Thu, 26 Mar 2026 15:02:23 -0700 Subject: [PATCH] feat(@angular/cli): support custom port in MCP devserver start tool --- packages/angular/cli/src/commands/mcp/host.ts | 17 +++++++++++ .../cli/src/commands/mcp/testing/mock-host.ts | 1 + .../src/commands/mcp/testing/test-utils.ts | 3 ++ .../mcp/tools/devserver/devserver-start.ts | 29 ++++++++++++++++--- .../mcp/tools/devserver/devserver_spec.ts | 25 ++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/host.ts b/packages/angular/cli/src/commands/mcp/host.ts index 1ff0bb9724b3..94ac449fe356 100644 --- a/packages/angular/cli/src/commands/mcp/host.ts +++ b/packages/angular/cli/src/commands/mcp/host.ts @@ -118,6 +118,11 @@ export interface Host { * Finds an available TCP port on the system. */ getAvailablePort(): Promise; + + /** + * Checks whether a TCP port is available on the system. + */ + isPortAvailable(port: number): Promise; } /** @@ -236,4 +241,16 @@ export const LocalWorkspaceHost: Host = { }); }); }, + + isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once('error', () => resolve(false)); + server.listen(port, () => { + server.close(() => { + resolve(true); + }); + }); + }); + }, }; diff --git a/packages/angular/cli/src/commands/mcp/testing/mock-host.ts b/packages/angular/cli/src/commands/mcp/testing/mock-host.ts index 29f41c24e101..ce2e5177ffab 100644 --- a/packages/angular/cli/src/commands/mcp/testing/mock-host.ts +++ b/packages/angular/cli/src/commands/mcp/testing/mock-host.ts @@ -21,4 +21,5 @@ export class MockHost implements Host { resolveModule = jasmine.createSpy('resolveRequest').and.returnValue('/dev/null'); spawn = jasmine.createSpy('spawn'); getAvailablePort = jasmine.createSpy('getAvailablePort'); + isPortAvailable = jasmine.createSpy('isPortAvailable').and.resolveTo(true); } diff --git a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts index 7afcd695dd7d..1bdf2ef416a5 100644 --- a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts +++ b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts @@ -27,6 +27,9 @@ export function createMockHost(): MockHost { getAvailablePort: jasmine .createSpy('getAvailablePort') .and.resolveTo(0), + isPortAvailable: jasmine + .createSpy('isPortAvailable') + .and.resolveTo(true), } as unknown as MockHost; } diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts index 272bf6800300..f5f413cfad30 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts @@ -15,6 +15,13 @@ import { type McpToolContext, type McpToolDeclaration, declareTool } from '../to const devserverStartToolInputSchema = z.object({ ...workspaceAndProjectOptions, + port: z + .number() + .optional() + .describe( + 'The port number to run the server on. If not provided, a random available port will be chosen. ' + + 'It is recommended to reuse port numbers across calls within the same workspace to maintain consistency.', + ), }); export type DevserverStartToolInput = z.infer; @@ -53,7 +60,17 @@ export async function startDevserver(input: DevserverStartToolInput, context: Mc }); } - const port = await context.host.getAvailablePort(); + let port: number; + if (input.port) { + if (!(await context.host.isPortAvailable(input.port))) { + throw new Error( + `Port ${input.port} is unavailable. Try calling this tool again without the 'port' parameter to auto-assign a free port.`, + ); + } + port = input.port; + } else { + port = await context.host.getAvailablePort(); + } devserver = new LocalDevserver({ host: context.host, @@ -87,14 +104,18 @@ the first build completes. background. * **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 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. -* **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 - change to the workspace, re-run "devserver.wait_for_build" to see whether the change was successfully built and wait for the devserver to - be updated. +* **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 change to the workspace, re-run "devserver.wait_for_build" to see whether the change was successfully built and wait for the + devserver to be updated. * This tool manages development servers by itself. It maintains at most a single dev server instance for each project in the monorepo. * This is an asynchronous operation. Subsequent commands can be ran while the server is active. * Use 'devserver.stop' to gracefully shut down the server and access the full log output. +* **Keeping the Server Alive**: It is often better to keep the server alive between tool calls if you expect the user to request more + changes or run more tests, as it saves time on restarts and maintains the file watcher state. You must still call + 'devserver.wait_for_build' after every change to see whether the change was successfully built and be sure that that app was updated. +* **Consistent Ports**: If making multiple calls, it is recommended to reuse the port you got from the first call for subsequent ones. `, isReadOnly: true, diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts index 93c6b367cb70..52a66902e2ef 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts @@ -64,6 +64,31 @@ describe('Serve Tools', () => { expect(mockProcess.kill).toHaveBeenCalled(); }); + it('should use the provided port number', async () => { + const startResult = await startDevserver({ port: 54321 }, mockContext); + expect(startResult.structuredContent.message).toBe( + `Development server for project 'my-app' started and watching for workspace changes.`, + ); + expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'my-app', '--port=54321'], { + stdio: 'pipe', + cwd: '/test', + }); + expect(mockHost.getAvailablePort).not.toHaveBeenCalled(); + }); + + it('should throw an error if the provided port is taken', async () => { + mockHost.isPortAvailable.and.resolveTo(false); + + try { + await startDevserver({ port: 55555 }, mockContext); + fail('Should have thrown an error'); + } catch (e) { + expect((e as Error).message).toContain( + "Port 55555 is unavailable. Try calling this tool again without the 'port' parameter to auto-assign a free port.", + ); + } + }); + it('should wait for a build to complete', async () => { await startDevserver({}, mockContext);