Skip to content

Commit 66e5aeb

Browse files
L-Qunoctogonz
andauthored
[mcp-server] Introduce a plugin system for custom MCP tools (#5241)
* Add a new test project "rush-mcp-example-plugin" * Initial plugin design * Finish decoupling "zod" so that rush-mcp-example-plugin does not need a package.json dependency for "zod" or "@rushstack/mcp-server" * Initial sketch of RushMcpPluginLoader * Add note * feat: resolve the todos * rush change * fix ci issue --------- Co-authored-by: Pete Gonzalez <4673363+octogonz@users.noreply.github.com>
1 parent 1683527 commit 66e5aeb

30 files changed

Lines changed: 697 additions & 2 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ These GitHub repositories provide supplementary resources for Rush Stack:
195195
| [/build-tests/run-scenarios-helpers](./build-tests/run-scenarios-helpers/) | Helpers for the *-scenarios test projects. |
196196
| [/build-tests/rush-amazon-s3-build-cache-plugin-integration-test](./build-tests/rush-amazon-s3-build-cache-plugin-integration-test/) | Tests connecting to an amazon S3 endpoint |
197197
| [/build-tests/rush-lib-declaration-paths-test](./build-tests/rush-lib-declaration-paths-test/) | This project ensures all of the paths in rush-lib/lib/... have imports that resolve correctly. If this project builds, all `lib/**/*.d.ts` files in the `@microsoft/rush-lib` package are valid. |
198+
| [/build-tests/rush-mcp-example-plugin](./build-tests/rush-mcp-example-plugin/) | Example showing how to create a plugin for @rushstack/mcp-server |
198199
| [/build-tests/rush-project-change-analyzer-test](./build-tests/rush-project-change-analyzer-test/) | This is an example project that uses rush-lib's ProjectChangeAnalyzer to |
199200
| [/build-tests/rush-redis-cobuild-plugin-integration-test](./build-tests/rush-redis-cobuild-plugin-integration-test/) | Tests connecting to an redis server |
200201
| [/build-tests/set-webpack-public-path-plugin-test](./build-tests/set-webpack-public-path-plugin-test/) | Building this project tests the set-webpack-public-path-plugin |
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
3+
4+
"mainEntryPointFilePath": "<projectFolder>/lib/index.d.ts",
5+
6+
"apiReport": {
7+
"enabled": true,
8+
"reportFolder": "../../../common/reviews/api"
9+
},
10+
11+
"docModel": {
12+
"enabled": true,
13+
"apiJsonFilePath": "../../../common/temp/api/<unscopedPackageName>.api.json"
14+
},
15+
16+
"dtsRollup": {
17+
"enabled": true
18+
}
19+
}

apps/rush-mcp-server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
"monorepo",
1010
"server"
1111
],
12+
"main": "lib/index.js",
13+
"typings": "dist/mcp-server.d.ts",
1214
"repository": {
1315
"type": "git",
1416
"url": "https://github.com/microsoft/rushstack.git",

apps/rush-mcp-server/src/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4-
export { log } from './utilities/log';
5-
export * from './tools';
4+
/**
5+
* API for use by MCP plugins.
6+
* @packageDocumentation
7+
*/
8+
9+
export * from './pluginFramework/IRushMcpPlugin';
10+
export * from './pluginFramework/IRushMcpTool';
11+
export { type IRegisterToolOptions, RushMcpPluginSession } from './pluginFramework/RushMcpPluginSession';
12+
export * from './pluginFramework/zodTypes';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { RushMcpPluginSession } from './RushMcpPluginSession';
5+
6+
/**
7+
* MCP plugins should implement this interface.
8+
* @public
9+
*/
10+
export interface IRushMcpPlugin {
11+
onInitializeAsync(): Promise<void>;
12+
}
13+
14+
/**
15+
* The plugin's entry point should return this function as its default export.
16+
* @public
17+
*/
18+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
19+
export type RushMcpPluginFactory<TConfigFile = {}> = (
20+
session: RushMcpPluginSession,
21+
configFile: TConfigFile | undefined
22+
) => IRushMcpPlugin;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type * as zod from 'zod';
5+
6+
import type { CallToolResult } from './zodTypes';
7+
8+
/**
9+
* MCP plugins should implement this interface.
10+
* @public
11+
*/
12+
export interface IRushMcpTool<
13+
TSchema extends zod.ZodObject<zod.ZodRawShape> = zod.ZodObject<zod.ZodRawShape>
14+
> {
15+
readonly schema: TSchema;
16+
executeAsync(input: zod.infer<TSchema>): Promise<CallToolResult>;
17+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as path from 'path';
5+
import { FileSystem, Import, JsonFile, type JsonObject, JsonSchema } from '@rushstack/node-core-library';
6+
import { Autoinstaller } from '@rushstack/rush-sdk/lib/logic/Autoinstaller';
7+
import { RushGlobalFolder } from '@rushstack/rush-sdk/lib/api/RushGlobalFolder';
8+
import { RushConfiguration } from '@rushstack/rush-sdk/lib/api/RushConfiguration';
9+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
10+
11+
import type { IRushMcpPlugin, RushMcpPluginFactory } from './IRushMcpPlugin';
12+
import { RushMcpPluginSessionInternal } from './RushMcpPluginSession';
13+
14+
import rushMcpJsonSchemaObject from '../schemas/rush-mcp.schema.json';
15+
import rushMcpPluginSchemaObject from '../schemas/rush-mcp-plugin.schema.json';
16+
17+
/**
18+
* Configuration for @rushstack/mcp-server in a monorepo.
19+
* Corresponds to the contents of common/config/rush-mcp/rush-mcp.json
20+
*/
21+
export interface IJsonRushMcpConfig {
22+
/**
23+
* The list of plugins that @rushstack/mcp-server should load when processing this monorepo.
24+
*/
25+
mcpPlugins: IJsonRushMcpPlugin[];
26+
}
27+
28+
/**
29+
* Describes a single MCP plugin entry.
30+
*/
31+
export interface IJsonRushMcpPlugin {
32+
/**
33+
* The name of an NPM package that appears in the package.json "dependencies" for the autoinstaller.
34+
*/
35+
packageName: string;
36+
37+
/**
38+
* The name of a Rush autoinstaller with this package as its dependency.
39+
* @rushstack/mcp-server will ensure this folder is installed before loading the plugin.
40+
*/
41+
autoinstaller: string;
42+
43+
/**
44+
* The name of the plugin. This is used to identify the plugin in the MCP server.
45+
*/
46+
pluginName: string;
47+
}
48+
49+
/**
50+
* Manifest file for a Rush MCP plugin.
51+
* Every plugin package must contain a "rush-mcp-plugin.json" manifest in the top-level folder.
52+
*/
53+
export interface IJsonRushMcpPluginManifest {
54+
/**
55+
* A name that uniquely identifies your plugin.
56+
* Generally this should match the NPM package name; two plugins with the same pluginName cannot be loaded together.
57+
*/
58+
pluginName: string;
59+
60+
/**
61+
* Optional. Indicates that your plugin accepts a config file.
62+
* The MCP server will load this schema file and provide it to the plugin.
63+
* Path is typically `<rush-repo>/common/config/rush-mcp/<plugin-name>.json`.
64+
*/
65+
configFileSchema?: string;
66+
67+
/**
68+
* The module path to the plugin's entry point.
69+
* Its default export must be a class implementing the MCP plugin interface.
70+
*/
71+
entryPoint: string;
72+
}
73+
74+
export class RushMcpPluginLoader {
75+
private static readonly _rushMcpJsonSchema: JsonSchema =
76+
JsonSchema.fromLoadedObject(rushMcpJsonSchemaObject);
77+
private static readonly _rushMcpPluginSchemaObject: JsonSchema =
78+
JsonSchema.fromLoadedObject(rushMcpPluginSchemaObject);
79+
80+
private readonly _rushWorkspacePath: string;
81+
private readonly _mcpServer: McpServer;
82+
83+
public constructor(rushWorkspacePath: string, mcpServer: McpServer) {
84+
this._rushWorkspacePath = rushWorkspacePath;
85+
this._mcpServer = mcpServer;
86+
}
87+
88+
public async loadAsync(): Promise<void> {
89+
const rushMcpFilePath: string = path.join(
90+
this._rushWorkspacePath,
91+
'common/config/rush-mcp/rush-mcp.json'
92+
);
93+
94+
if (!(await FileSystem.existsAsync(rushMcpFilePath))) {
95+
return;
96+
}
97+
98+
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromDefaultLocation({
99+
startingFolder: this._rushWorkspacePath
100+
});
101+
102+
const jsonRushMcpConfig: IJsonRushMcpConfig = await JsonFile.loadAndValidateAsync(
103+
rushMcpFilePath,
104+
RushMcpPluginLoader._rushMcpJsonSchema
105+
);
106+
107+
if (jsonRushMcpConfig.mcpPlugins.length === 0) {
108+
return;
109+
}
110+
111+
const rushGlobalFolder: RushGlobalFolder = new RushGlobalFolder();
112+
113+
for (const jsonMcpPlugin of jsonRushMcpConfig.mcpPlugins) {
114+
// Ensure the autoinstaller is installed
115+
const autoinstaller: Autoinstaller = new Autoinstaller({
116+
autoinstallerName: jsonMcpPlugin.autoinstaller,
117+
rushConfiguration,
118+
rushGlobalFolder,
119+
restrictConsoleOutput: false
120+
});
121+
await autoinstaller.prepareAsync();
122+
123+
// Load the manifest
124+
125+
// Suppose the autoinstaller is "my-autoinstaller" and the package is "rush-mcp-example-plugin".
126+
// Then the folder will be:
127+
// "/path/to/my-repo/common/autoinstallers/my-autoinstaller/node_modules/rush-mcp-example-plugin"
128+
const installedPluginPackageFolder: string = await Import.resolvePackageAsync({
129+
baseFolderPath: autoinstaller.folderFullPath,
130+
packageName: jsonMcpPlugin.packageName
131+
});
132+
133+
const manifestFilePath: string = path.join(installedPluginPackageFolder, 'rush-mcp-plugin.json');
134+
if (!(await FileSystem.existsAsync(manifestFilePath))) {
135+
throw new Error(
136+
'The "rush-mcp-plugin.json" manifest file was not found under ' + installedPluginPackageFolder
137+
);
138+
}
139+
140+
const jsonManifest: IJsonRushMcpPluginManifest = await JsonFile.loadAndValidateAsync(
141+
manifestFilePath,
142+
RushMcpPluginLoader._rushMcpPluginSchemaObject
143+
);
144+
145+
let rushMcpPluginOptions: JsonObject = {};
146+
if (jsonManifest.configFileSchema) {
147+
const mcpPluginSchemaFilePath: string = path.resolve(
148+
installedPluginPackageFolder,
149+
jsonManifest.configFileSchema
150+
);
151+
const mcpPluginSchema: JsonSchema = await JsonSchema.fromFile(mcpPluginSchemaFilePath);
152+
const rushMcpPluginOptionsFilePath: string = path.resolve(
153+
this._rushWorkspacePath,
154+
`common/config/rush-mcp/${jsonMcpPlugin.pluginName}.json`
155+
);
156+
// Example: /path/to/my-repo/common/config/rush-mcp/rush-mcp-example-plugin.json
157+
rushMcpPluginOptions = await JsonFile.loadAndValidateAsync(
158+
rushMcpPluginOptionsFilePath,
159+
mcpPluginSchema
160+
);
161+
}
162+
163+
const fullEntryPointPath: string = path.join(installedPluginPackageFolder, jsonManifest.entryPoint);
164+
let pluginFactory: RushMcpPluginFactory;
165+
try {
166+
const entryPointModule: { default?: RushMcpPluginFactory } = require(fullEntryPointPath);
167+
if (entryPointModule.default === undefined) {
168+
throw new Error('The commonJS "default" export is missing');
169+
}
170+
pluginFactory = entryPointModule.default;
171+
} catch (e) {
172+
throw new Error(`Unable to load plugin entry point at ${fullEntryPointPath}: ` + e.toString());
173+
}
174+
175+
const session: RushMcpPluginSessionInternal = new RushMcpPluginSessionInternal(this._mcpServer);
176+
177+
let plugin: IRushMcpPlugin;
178+
try {
179+
plugin = pluginFactory(session, rushMcpPluginOptions);
180+
} catch (e) {
181+
throw new Error(`Error invoking entry point for plugin ${jsonManifest.pluginName}:` + e.toString());
182+
}
183+
184+
try {
185+
await plugin.onInitializeAsync();
186+
} catch (e) {
187+
throw new Error(
188+
`Error occurred in onInitializeAsync() for plugin ${jsonManifest.pluginName}:` + e.toString()
189+
);
190+
}
191+
}
192+
}
193+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as zod from 'zod';
5+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
6+
7+
import type { IRushMcpTool } from './IRushMcpTool';
8+
import type { zodModule } from './zodTypes';
9+
10+
/**
11+
* Each plugin gets its own session.
12+
*
13+
* @public
14+
*/
15+
export interface IRegisterToolOptions {
16+
toolName: string;
17+
description?: string;
18+
}
19+
20+
/**
21+
* Each plugin gets its own session.
22+
*
23+
* @public
24+
*/
25+
export abstract class RushMcpPluginSession {
26+
public readonly zod: typeof zodModule = zod;
27+
public abstract registerTool(options: IRegisterToolOptions, tool: IRushMcpTool): void;
28+
}
29+
30+
export class RushMcpPluginSessionInternal extends RushMcpPluginSession {
31+
private readonly _mcpServer: McpServer;
32+
33+
public constructor(mcpServer: McpServer) {
34+
super();
35+
this._mcpServer = mcpServer;
36+
}
37+
38+
public override registerTool(options: IRegisterToolOptions, tool: IRushMcpTool): void {
39+
if (options.description) {
40+
this._mcpServer.tool(
41+
options.toolName,
42+
options.description,
43+
tool.schema.shape,
44+
tool.executeAsync.bind(tool)
45+
);
46+
} else {
47+
this._mcpServer.tool(options.toolName, tool.schema.shape, tool.executeAsync.bind(tool));
48+
}
49+
}
50+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type * as zod from 'zod';
5+
export type { zod as zodModule };
6+
7+
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types';
8+
9+
export { CallToolResultSchema };
10+
11+
/**
12+
* @public
13+
*/
14+
export type CallToolResult = zod.infer<typeof CallToolResultSchema>;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Rush MCP Plugin Manifest",
4+
"type": "object",
5+
"properties": {
6+
"pluginName": {
7+
"type": "string",
8+
"description": "A name that uniquely identifies your plugin. Generally this should match the NPM package name; two plugins with the same pluginName cannot be loaded together."
9+
},
10+
"configFileSchema": {
11+
"type": "string",
12+
"description": "Optional. Indicates that your plugin accepts a config file. The MCP server will load this schema file and provide it to the plugin. Path is typically `<rush-repo>/common/config/rush-mcp/<plugin-name>.json`."
13+
},
14+
"entryPoint": {
15+
"type": "string",
16+
"description": "The module path to the plugin's entry point. Its default export must be a class implementing the MCP plugin interface."
17+
}
18+
},
19+
"required": ["pluginName", "entryPoint"],
20+
"additionalProperties": false
21+
}

0 commit comments

Comments
 (0)