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
8 changes: 6 additions & 2 deletions apps/rush-mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@
"dependencies": {
"@rushstack/node-core-library": "workspace:*",
"@rushstack/terminal": "workspace:*",
"@rushstack/ts-command-line": "workspace:*"
"@rushstack/rush-sdk": "workspace:*",
"@rushstack/ts-command-line": "workspace:*",
"@modelcontextprotocol/sdk": "~1.10.2",
"zod": "~3.24.3"
},
"devDependencies": {
"@rushstack/heft": "workspace:*",
"local-node-rig": "workspace:*",
"typescript": "~5.8.2"
"typescript": "~5.8.2",
"@types/node": "20.17.19"
}
}
5 changes: 5 additions & 0 deletions apps/rush-mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

export { log } from './utilities/log';
export * from './tools';
47 changes: 47 additions & 0 deletions apps/rush-mcp-server/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
type BaseTool,
RushConflictResolverTool,
RushMigrateProjectTool,
RushCommandValidatorTool,
RushWorkspaceDetailsTool,
RushProjectDetailsTool,
RushDocsTool
} from './tools';

export class RushMCPServer extends McpServer {
private _rushWorkspacePath: string;
private _tools: BaseTool[] = [];

public constructor(rushWorkspacePath: string) {
super({
name: 'rush',
version: '1.0.0'
});

this._rushWorkspacePath = rushWorkspacePath;

this._initializeTools();
this._registerTools();
}

private _initializeTools(): void {
this._tools.push(new RushConflictResolverTool());
this._tools.push(new RushMigrateProjectTool(this._rushWorkspacePath));
this._tools.push(new RushCommandValidatorTool());
this._tools.push(new RushWorkspaceDetailsTool());
this._tools.push(new RushProjectDetailsTool());
this._tools.push(new RushDocsTool());
}

private _registerTools(): void {
process.chdir(this._rushWorkspacePath);

for (const tool of this._tools) {
tool.register(this);
}
}
}
23 changes: 23 additions & 0 deletions apps/rush-mcp-server/src/start.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,25 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

import { log } from './utilities/log';
import { RushMCPServer } from './server';

const main = async (): Promise<void> => {
const rushWorkspacePath: string | undefined = process.argv[2];
if (!rushWorkspacePath) {
throw new Error('Please provide workspace root path as the first argument');
}

const server: RushMCPServer = new RushMCPServer(rushWorkspacePath);
const transport: StdioServerTransport = new StdioServerTransport();
await server.connect(transport);

log('Rush MCP Server running on stdio');
};

main().catch((error) => {
log('Fatal error running server:', error);
process.exit(1);
});
59 changes: 59 additions & 0 deletions apps/rush-mcp-server/src/tools/base.tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol';
import type {
CallToolResultSchema,
ServerNotification,
ServerRequest
} from '@modelcontextprotocol/sdk/types';
import type { z, ZodRawShape, ZodTypeAny } from 'zod';

export type CallToolResult = z.infer<typeof CallToolResultSchema>;

type ToolCallback<Args extends undefined | ZodRawShape = undefined> = Args extends ZodRawShape
? (
args: z.objectOutputType<Args, ZodTypeAny>,
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
) => CallToolResult | Promise<CallToolResult>
: (
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
) => CallToolResult | Promise<CallToolResult>;

export interface IBaseToolOptions<Args extends ZodRawShape = ZodRawShape> {
name: string;
description: string;
schema: Args;
}

export abstract class BaseTool<Args extends ZodRawShape = ZodRawShape> {
private _options: IBaseToolOptions<Args>;

protected constructor(options: IBaseToolOptions<Args>) {
this._options = options;
}

protected abstract executeAsync(...args: Parameters<ToolCallback<Args>>): ReturnType<ToolCallback<Args>>;

public register(server: McpServer): void {
// TODO: remove ts-ignore
// @ts-ignore
server.tool(this._options.name, this._options.description, this._options.schema, async (...args) => {
try {
const result: CallToolResult = await this.executeAsync(...(args as Parameters<ToolCallback<Args>>));
return result;
} catch (error: unknown) {
return {
isError: true,
content: [
{
type: 'text',
text: error instanceof Error ? error.message : error
}
]
};
}
});
}
}
63 changes: 63 additions & 0 deletions apps/rush-mcp-server/src/tools/conflict-resolver.tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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 type { RushConfiguration } from '@rushstack/rush-sdk';
import type { IExecutableSpawnSyncOptions } from '@rushstack/node-core-library';

import { CommandRunner } from '../utilities/command-runner';
import { getRushConfiguration } from '../utilities/common';
import { BaseTool, type CallToolResult } from './base.tool';

export class RushConflictResolverTool extends BaseTool {
public constructor() {
super({
name: 'rush_pnpm_lock_file_conflict_resolver',
description:
'If a user requests to resolve a pnpm-lock.yaml file conflict, use this tool to automatically fix the conflict directly.',
schema: {
lockfilePath: z.string().describe('The path to the pnpm-lock.yaml file, should pass absolute path')
}
});
}

private _tryGetSubspaceNameFromLockfilePath(
lockfilePath: string,
rushConfiguration: RushConfiguration
): string | null {
for (const subspace of rushConfiguration.subspaces) {
const folderPath: string = subspace.getSubspaceConfigFolderPath();
if (lockfilePath.startsWith(folderPath)) {
return subspace.subspaceName;
}
}
return null;
}

public async executeAsync({ lockfilePath }: { lockfilePath: string }): Promise<CallToolResult> {
const rushConfiguration: RushConfiguration = await getRushConfiguration();
const subspaceName: string | null = this._tryGetSubspaceNameFromLockfilePath(
lockfilePath,
rushConfiguration
);
if (!subspaceName) {
throw new Error('subspace name not found');
}

const options: IExecutableSpawnSyncOptions = {
stdio: 'inherit',
currentWorkingDirectory: rushConfiguration.rushJsonFolder
};
await CommandRunner.runGitCommandAsync(['checkout', '--theirs', lockfilePath], options);
await CommandRunner.runRushCommandAsync(['update', '--subspace', subspaceName], options);

return {
content: [
{
type: 'text',
text: 'Conflict resolved successfully'
}
]
};
}
}
51 changes: 51 additions & 0 deletions apps/rush-mcp-server/src/tools/docs.tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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 { BaseTool, type CallToolResult } from './base.tool';

interface IDocsResult {
query: string;
results: {
score: number;
text: string;
}[];
count: number;
searchTimeMs: number;
}

export class RushDocsTool extends BaseTool {
public constructor() {
super({
name: 'rush_docs',
description:
'Search and retrieve relevant sections from Rush official documentation based on user queries.',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
'Search and retrieve relevant sections from Rush official documentation based on user queries.',
'Search and retrieve relevant sections from the official Rush documentation based on user queries.',

schema: {
userQuery: z.string().describe('The user query to search for relevant documentation sections.')
}
});
}

public async executeAsync({ userQuery }: { userQuery: string }): Promise<CallToolResult> {
// An example of a knowledge base that can run, but needs to be replaced with Microsoft’s service.
const response: Response = await fetch('http://47.120.46.115/search', {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should change this demo so that it doesn't rely on a black box.

If the code behind 47.120.46.115 is not too complex, maybe we could make it open source, or host it on the rushjs.io server. Otherwise, what happens 3 years from now when someone tries the demo? Most likely that IP address won't be running any more. 😄

But for demo/example purposes, it probably would be sufficient to rewrite this function so that it replies with static responses, e.g. from a JSON file.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Another idea would be to use the duckduckgo.com web search:

const https = require('https');

const query = 'dogs';
const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json`;

https.get(url, (res) => {
  let data = '';

  res.on('data', (chunk) => {
    data += chunk;
  });

  res.on('end', () => {
    try {
      const json = JSON.parse(data);
      console.log('Heading:', json.Heading);
      console.log('Abstract:', json.Abstract);
      console.log('Abstract URL:', json.AbstractURL);

      if (json.RelatedTopics && json.RelatedTopics.length > 0) {
        console.log('\nRelated Topics:');
        json.RelatedTopics.forEach((topic, index) => {
          if (topic.Text && topic.FirstURL) {
            console.log(`${index + 1}. ${topic.Text}`);
            console.log(topic.FirstURL);
          }
        });
      }
    } catch (e) {
      console.error('Error parsing JSON:', e.message);
    }
  });
}).on('error', (err) => {
  console.error('Request failed:', err.message);
});

Output:

Heading: Dog
Abstract: 
Abstract URL: https://en.wikipedia.org/wiki/Dog_(disambiguation)

Related Topics:
1. Dog A domesticated descendant of the gray wolf.
https://duckduckgo.com/Dog
2. Diogenes of Sinope An ancient Greek philosopher and one of the founders of Cynicism.
https://duckduckgo.com/Diogenes
3. Hot dog A dish consisting of a grilled, steamed, or boiled sausage served in the slit of a partially...
https://duckduckgo.com/Hot_dog

method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: userQuery, topK: 10 })
});

const result: IDocsResult = (await response.json()) as IDocsResult;

return {
content: [
{
type: 'text',
text: result.results.map((item) => item.text).join('\n')
}
]
};
}
}
10 changes: 10 additions & 0 deletions apps/rush-mcp-server/src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

export * from './base.tool';
export * from './migrate-project.tool';
export * from './project-details.tool';
export * from './rush-command-validator.tool';
export * from './workspace-details';
export * from './conflict-resolver.tool';
export * from './docs.tool';
Loading