Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/angular/cli/src/commands/mcp/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export interface Host {
* Finds an available TCP port on the system.
*/
getAvailablePort(): Promise<number>;

/**
* Checks whether a TCP port is available on the system.
*/
isPortAvailable(port: number): Promise<boolean>;
}

/**
Expand Down Expand Up @@ -236,4 +241,16 @@ export const LocalWorkspaceHost: Host = {
});
});
},

isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer();
server.once('error', () => resolve(false));
server.listen(port, () => {
server.close(() => {
resolve(true);
});
});
});
},
};
1 change: 1 addition & 0 deletions packages/angular/cli/src/commands/mcp/testing/mock-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
3 changes: 3 additions & 0 deletions packages/angular/cli/src/commands/mcp/testing/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export function createMockHost(): MockHost {
getAvailablePort: jasmine
.createSpy<Host['getAvailablePort']>('getAvailablePort')
.and.resolveTo(0),
isPortAvailable: jasmine
.createSpy<Host['isPortAvailable']>('isPortAvailable')
.and.resolveTo(true),
} as unknown as MockHost;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof devserverStartToolInputSchema>;
Expand Down Expand Up @@ -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();
}
Comment on lines +63 to +73
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Consider moving this into a function to reduce mutations.

const port = getPort(input, context);


devserver = new LocalDevserver({
host: context.host,
Expand Down Expand Up @@ -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.
</Use Cases>
<Operational Notes>
* 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.
</Operational Notes>
`,
isReadOnly: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider: The agent can also just pick a different point. Maybe "Try using a different port or omitting the port parameter to auto-assign a free port."

);
}
});

it('should wait for a build to complete', async () => {
await startDevserver({}, mockContext);

Expand Down
Loading