diff --git a/apps/rush-mcp-server/package.json b/apps/rush-mcp-server/package.json index 738c2ccf087..8a562753653 100644 --- a/apps/rush-mcp-server/package.json +++ b/apps/rush-mcp-server/package.json @@ -59,6 +59,7 @@ "@rushstack/rush-sdk": "workspace:*", "@rushstack/ts-command-line": "workspace:*", "@modelcontextprotocol/sdk": "~1.10.2", + "ws": "~8.14.1", "zod": "~3.25.76" }, "devDependencies": { @@ -66,7 +67,8 @@ "eslint": "~9.37.0", "local-node-rig": "workspace:*", "typescript": "~5.8.2", - "@types/node": "20.17.19" + "@types/node": "20.17.19", + "@types/ws": "8.5.5" }, "sideEffects": [ "lib-commonjs/start.js", diff --git a/apps/rush-mcp-server/src/server.ts b/apps/rush-mcp-server/src/server.ts index 5eceb116012..1f7cd861d1a 100644 --- a/apps/rush-mcp-server/src/server.ts +++ b/apps/rush-mcp-server/src/server.ts @@ -5,6 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { type BaseTool, + RushBuildStatusTool, RushConflictResolverTool, RushMigrateProjectTool, RushCommandValidatorTool, @@ -36,6 +37,7 @@ export class RushMCPServer extends McpServer { } private _initializeTools(): void { + this._tools.push(new RushBuildStatusTool()); this._tools.push(new RushConflictResolverTool()); this._tools.push(new RushMigrateProjectTool(this._rushWorkspacePath)); this._tools.push(new RushCommandValidatorTool()); diff --git a/apps/rush-mcp-server/src/tools/build-status.tool.ts b/apps/rush-mcp-server/src/tools/build-status.tool.ts new file mode 100644 index 00000000000..a42742aca09 --- /dev/null +++ b/apps/rush-mcp-server/src/tools/build-status.tool.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { z } from 'zod'; + +import { + fetchBuildStatusAsync, + formatBuildStatusSnapshot, + type IBuildStatusSnapshot +} from '../utilities/build-status-client'; +import { BaseTool, type CallToolResult } from './base.tool'; + +export class RushBuildStatusTool extends BaseTool { + public constructor() { + super({ + name: 'rush_get_build_status', + description: + 'Returns the current build status from a running `rush start` session. ' + + 'Connects to the rush-serve-plugin WebSocket endpoint and returns a snapshot ' + + 'of all operation statuses. Requires `rush start` to be running.', + schema: { + port: z.number().describe('The port number where `rush start` is serving'), + host: z.string().optional().describe('The hostname (default: localhost)') + } + }); + } + + public async executeAsync({ port, host }: { port: number; host?: string }): Promise { + const snapshot: IBuildStatusSnapshot = await fetchBuildStatusAsync({ port, host }); + + return { + content: [ + { + type: 'text', + text: formatBuildStatusSnapshot(snapshot) + } + ] + }; + } +} diff --git a/apps/rush-mcp-server/src/tools/index.ts b/apps/rush-mcp-server/src/tools/index.ts index 7fdc2dbd2b1..09f6f209551 100644 --- a/apps/rush-mcp-server/src/tools/index.ts +++ b/apps/rush-mcp-server/src/tools/index.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. export { BaseTool, type IBaseToolOptions, type CallToolResult } from './base.tool'; +export { RushBuildStatusTool } from './build-status.tool'; export { RushMigrateProjectTool } from './migrate-project.tool'; export { RushProjectDetailsTool } from './project-details.tool'; export { RushCommandValidatorTool } from './rush-command-validator.tool'; diff --git a/apps/rush-mcp-server/src/utilities/build-status-client.ts b/apps/rush-mcp-server/src/utilities/build-status-client.ts new file mode 100644 index 00000000000..8af6bd35c00 --- /dev/null +++ b/apps/rush-mcp-server/src/utilities/build-status-client.ts @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import WebSocket from 'ws'; + +/** + * URLs for an operation's log files, served by the rush-serve-plugin. + */ +export interface ILogFileURLs { + text: string; + error: string; + jsonl: string; +} + +/** + * Minimal subset of operation info from the rush-serve-plugin WebSocket protocol. + */ +export interface IOperationSummary { + name: string; + packageName: string; + phaseName: string; + status: string; + startTime: number | undefined; + endTime: number | undefined; + logFileURLs: ILogFileURLs | undefined; +} + +/** + * Session information from the rush-serve-plugin WebSocket protocol. + */ +export interface IRushSessionInfo { + actionName: string; + repositoryIdentifier: string; +} + +/** + * A snapshot of the current build status, returned by the WebSocket utility functions. + */ +export interface IBuildStatusSnapshot { + status: string; + operations: IOperationSummary[]; + sessionInfo?: IRushSessionInfo; +} + +/** + * Options for connecting to the rush-serve-plugin WebSocket server. + */ +export interface IBuildStatusClientOptions { + port: number; + host?: string; +} + +/** + * WebSocket event message types matching the rush-serve-plugin wire format. + * Duplicated here to avoid a runtime dependency on rush-serve-plugin. + */ +interface IWebSocketSyncEventMessage { + event: 'sync'; + operations: IOperationSummary[]; + sessionInfo: IRushSessionInfo; + status: string; +} + +type IWebSocketEventMessage = + | IWebSocketSyncEventMessage + | { event: 'before-execute' | 'status-change' | 'after-execute'; operations: IOperationSummary[] }; + +function buildWebSocketUrl(options: IBuildStatusClientOptions): string { + const host: string = options.host ?? '127.0.0.1'; + return `wss://${host}:${options.port}/ws`; +} + +function toSnapshot(message: IWebSocketSyncEventMessage): IBuildStatusSnapshot { + return { + status: message.status, + operations: message.operations, + sessionInfo: message.sessionInfo + }; +} + +/** + * Formats a build status snapshot into a human-readable string for LLM consumption. + */ +export function formatBuildStatusSnapshot(snapshot: IBuildStatusSnapshot): string { + const lines: string[] = []; + + lines.push(`Build Status: ${snapshot.status}`); + + if (snapshot.sessionInfo) { + lines.push(`Command: ${snapshot.sessionInfo.actionName}`); + lines.push(`Repository: ${snapshot.sessionInfo.repositoryIdentifier}`); + } + + // Summarize operation statuses + const statusCounts: Map = new Map(); + for (const op of snapshot.operations) { + statusCounts.set(op.status, (statusCounts.get(op.status) ?? 0) + 1); + } + + lines.push(''); + const total: number = snapshot.operations.length; + const summaryParts: string[] = []; + for (const [status, count] of statusCounts) { + summaryParts.push(`${status}: ${count}`); + } + lines.push(`Operation Summary: ${total} total`); + if (summaryParts.length > 0) { + lines.push(` ${summaryParts.join(', ')}`); + } + + // List failed operations + const failedOps: IOperationSummary[] = snapshot.operations.filter((op) => op.status === 'Failure'); + if (failedOps.length > 0) { + lines.push(''); + lines.push('Failed Operations:'); + for (const op of failedOps) { + lines.push(` - ${op.packageName} (${op.phaseName})`); + } + } + + // List blocked operations + const blockedOps: IOperationSummary[] = snapshot.operations.filter((op) => op.status === 'Blocked'); + if (blockedOps.length > 0) { + lines.push(''); + lines.push('Blocked Operations:'); + for (const op of blockedOps) { + lines.push(` - ${op.packageName} (${op.phaseName})`); + } + } + + return lines.join('\n'); +} + +/** + * Connects to the rush-serve-plugin WebSocket, receives the initial sync message, + * and returns a snapshot of the current build status. + */ +export async function fetchBuildStatusAsync( + options: IBuildStatusClientOptions +): Promise { + const url: string = buildWebSocketUrl(options); + + return new Promise((resolve, reject) => { + const ws: WebSocket = new WebSocket(url, { rejectUnauthorized: false }); + let settled: boolean = false; + + const connectionTimeout: NodeJS.Timeout = setTimeout(() => { + if (!settled) { + settled = true; + ws.close(); + reject(new Error(`Connection to rush start timed out after 10000ms.`)); + } + }, 10000); + + function settle(action: () => void): void { + if (!settled) { + settled = true; + clearTimeout(connectionTimeout); + action(); + } + } + + ws.on('error', (err: Error) => { + settle(() => + reject( + new Error( + `Cannot connect to rush start on port ${options.port}. Ensure \`rush start\` is running. (${err.message})` + ) + ) + ); + }); + + ws.on('close', () => { + settle(() => + reject( + new Error(`Connection to rush start on port ${options.port} closed before receiving build status.`) + ) + ); + }); + + ws.on('message', (data: WebSocket.Data) => { + try { + const message: IWebSocketEventMessage = JSON.parse(data.toString()); + if (message.event === 'sync') { + settle(() => resolve(toSnapshot(message))); + ws.close(); + } + } catch (parseError: unknown) { + ws.close(); + settle(() => reject(new Error(`Failed to parse WebSocket message: ${parseError}`))); + } + }); + }); +} diff --git a/common/changes/@rushstack/mcp-server/user-frags51-start-mcp_2026-03-21-00-14.json b/common/changes/@rushstack/mcp-server/user-frags51-start-mcp_2026-03-21-00-14.json new file mode 100644 index 00000000000..3cfe487ef25 --- /dev/null +++ b/common/changes/@rushstack/mcp-server/user-frags51-start-mcp_2026-03-21-00-14.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/mcp-server", + "comment": "Build tool for rush start", + "type": "minor" + } + ], + "packageName": "@rushstack/mcp-server" +} \ No newline at end of file diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index dd1378b412d..4032b37513f 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: '@rushstack/ts-command-line': specifier: workspace:* version: link:../../libraries/ts-command-line + ws: + specifier: ~8.14.1 + version: 8.14.2 zod: specifier: ~3.25.76 version: 3.25.76 @@ -457,6 +460,9 @@ importers: '@types/node': specifier: 20.17.19 version: 20.17.19 + '@types/ws': + specifier: 8.5.5 + version: 8.5.5 eslint: specifier: ~9.37.0 version: 9.37.0