Skip to content

[mcp-server] Introduce a plugin system for custom MCP tools #5217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
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
19 changes: 19 additions & 0 deletions apps/rush-mcp-server/config/api-extractor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",

"mainEntryPointFilePath": "<projectFolder>/lib/index.d.ts",

"apiReport": {
"enabled": true,
"reportFolder": "../../../common/reviews/api"
},

"docModel": {
"enabled": true,
"apiJsonFilePath": "../../../common/temp/api/<unscopedPackageName>.api.json"
},

"dtsRollup": {
"enabled": true
}
}
2 changes: 2 additions & 0 deletions apps/rush-mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"name": "@rushstack/mcp-server",
"version": "0.1.4",
"description": "A Model Context Protocol server implementation for Rush",
"main": "lib/index.js",
"typings": "dist/mcp-server.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/microsoft/rushstack.git",
Expand Down
11 changes: 9 additions & 2 deletions apps/rush-mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
// 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';
/**
* API for use by MCP plugins.
* @packageDocumentation
*/

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

import type { RushMcpPluginSession } from './RushMcpPluginSession';

/**
* MCP plugins should implement this interface.
* @public
*/
export interface IRushMcpPlugin {
onInitializeAsync(): Promise<void>;
}

/**
* The plugin's entry point should return this function as its default export.
* @public
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type RushMcpPluginFactory<TConfigFile = {}> = (
session: RushMcpPluginSession,
configFile: TConfigFile | undefined
) => IRushMcpPlugin;
15 changes: 15 additions & 0 deletions apps/rush-mcp-server/src/pluginFramework/IRushMcpTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type * as zod from 'zod';

import type { CallToolResult } from './zodTypes';

/**
* MCP plugins should implement this interface.
* @public
*/
export interface IRushMcpTool<TSchema extends zod.ZodTypeAny = zod.ZodTypeAny> {
readonly schema: TSchema;
executeAsync(input: zod.infer<TSchema>): Promise<CallToolResult>;
}
171 changes: 171 additions & 0 deletions apps/rush-mcp-server/src/pluginFramework/RushMcpPluginLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'path';
import { FileSystem, Import, JsonFile, JsonSchema } from '@rushstack/node-core-library';
import { Autoinstaller } from '@rushstack/rush-sdk/lib/logic/Autoinstaller';
import { RushGlobalFolder } from '@rushstack/rush-sdk/lib/api/RushGlobalFolder';
import { RushConfiguration } from '@rushstack/rush-sdk/lib/api/RushConfiguration';

import type { IRushMcpPlugin, RushMcpPluginFactory } from './IRushMcpPlugin';
import { RushMcpPluginSessionInternal } from './RushMcpPluginSession';

import rushMcpJsonSchemaObject from '../schemas/rush-mcp.schema.json';
import rushMcpPluginSchemaObject from '../schemas/rush-mcp-plugin.schema.json';

/**
* Configuration for @rushstack/mcp-server in a monorepo.
* Corresponds to the contents of common/config/rush-mcp/rush-mcp.json
*/
export interface IJsonRushMcpConfig {
/**
* The list of plugins that @rushstack/mcp-server should load when processing this monorepo.
*/
mcpPlugins: IJsonRushMcpPlugin[];
}

/**
* Describes a single MCP plugin entry.
*/
export interface IJsonRushMcpPlugin {
/**
* The name of an NPM package that appears in the package.json "dependencies" for the autoinstaller.
*/
packageName: string;

/**
* The name of a Rush autoinstaller with this package as its dependency.
* @rushstack/mcp-server will ensure this folder is installed before loading the plugin.
*/
autoinstaller: string;
}

/**
* Manifest file for a Rush MCP plugin.
* Every plugin package must contain a "rush-mcp-plugin.json" manifest in the top-level folder.
*/
export interface IJsonRushMcpPluginManifest {
/**
* 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.
*/
pluginName: string;

/**
* 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`.
*/
configFileSchema?: string;

/**
* The module path to the plugin's entry point.
* Its default export must be a class implementing the MCP plugin interface.
*/
entryPoint: string;
}

export class RushMcpPluginLoader {
private static readonly _rushMcpJsonSchema: JsonSchema =
JsonSchema.fromLoadedObject(rushMcpJsonSchemaObject);
private static readonly _rushMcpPluginSchemaObject: JsonSchema =
JsonSchema.fromLoadedObject(rushMcpPluginSchemaObject);

private readonly _rushWorkspacePath: string;

public constructor(rushWorkspacePath: string) {
this._rushWorkspacePath = rushWorkspacePath;
}

public async loadAsync(): Promise<void> {
const rushMcpFilePath: string = path.join(
this._rushWorkspacePath,
'common/config/rush-mcp/rush-mcp.json'
);

if (!(await FileSystem.existsAsync(rushMcpFilePath))) {
// Should we report an error here?
return;
}

const rushConfiguration: RushConfiguration = RushConfiguration.loadFromDefaultLocation({
startingFolder: this._rushWorkspacePath
});

const jsonRushMcpConfig: IJsonRushMcpConfig = await JsonFile.loadAndValidateAsync(
rushMcpFilePath,
RushMcpPluginLoader._rushMcpJsonSchema
);

if (jsonRushMcpConfig.mcpPlugins.length === 0) {
return;
}

const rushGlobalFolder: RushGlobalFolder = new RushGlobalFolder();

for (const jsonMcpPlugin of jsonRushMcpConfig.mcpPlugins) {
// Ensure the autoinstaller is installed
const autoinstaller: Autoinstaller = new Autoinstaller({
autoinstallerName: jsonMcpPlugin.autoinstaller,
rushConfiguration,
rushGlobalFolder,
restrictConsoleOutput: false
});
await autoinstaller.prepareAsync();

// Load the manifest

// Suppose the autoinstaller is "my-autoinstaller" and the package is "rush-mcp-example-plugin".
// Then the folder will be:
// "/path/to/my-repo/common/autoinstallers/my-autoinstaller/node_modules/rush-mcp-example-plugin"
const installedPluginPackageFolder: string = await Import.resolvePackageAsync({
baseFolderPath: autoinstaller.folderFullPath,
packageName: jsonMcpPlugin.packageName
});

const manifestFilePath: string = path.join(installedPluginPackageFolder, 'rush-mcp-plugin.json');
if (!(await FileSystem.existsAsync(manifestFilePath))) {
throw new Error(
'The "rush-mcp-plugin.json" manifest file was not found under ' + installedPluginPackageFolder
);
}

const jsonManifest: IJsonRushMcpPluginManifest = await JsonFile.loadAndValidateAsync(
manifestFilePath,
RushMcpPluginLoader._rushMcpPluginSchemaObject
);

// TODO: Load and validate config file if defined by the manifest

const fullEntryPointPath: string = path.join(installedPluginPackageFolder, jsonManifest.entryPoint);
let pluginFactory: RushMcpPluginFactory;
try {
const entryPointModule: { default?: RushMcpPluginFactory } = require(fullEntryPointPath);
if (entryPointModule.default === undefined) {
throw new Error('The commonJS "default" export is missing');
}
pluginFactory = entryPointModule.default;
} catch (e) {
throw new Error(`Unable to load plugin entry point at ${fullEntryPointPath}: ` + e.toString());
}

const session: RushMcpPluginSessionInternal = new RushMcpPluginSessionInternal();

let plugin: IRushMcpPlugin;
try {
// TODO: Replace "{}" with the plugin's parsed config file JSON
plugin = pluginFactory(session, {});
} catch (e) {
throw new Error(`Error invoking entry point for plugin ${jsonManifest.pluginName}:` + e.toString());
}

try {
await plugin.onInitializeAsync();
} catch (e) {
throw new Error(
`Error occurred in onInitializeAsync() for plugin ${jsonManifest.pluginName}:` + e.toString()
);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { IRushMcpTool } from './IRushMcpTool';
import * as zod from 'zod';
import type { zodModule } from './zodTypes';

/**
* Each plugin gets its own session.
*
* @public
*/
export interface IRegisterToolOptions {
toolName: string;
description?: string;
}

/**
* Each plugin gets its own session.
*
* @public
*/
export abstract class RushMcpPluginSession {
public readonly zod: typeof zodModule = zod;
public abstract registerTool(options: IRegisterToolOptions, tool: IRushMcpTool): void;
}

export class RushMcpPluginSessionInternal extends RushMcpPluginSession {
public constructor() {
super();
}

public override registerTool(options: IRegisterToolOptions, tool: IRushMcpTool): void {
// TODO: Register the tool
}
}
14 changes: 14 additions & 0 deletions apps/rush-mcp-server/src/pluginFramework/zodTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type * as zod from 'zod';
export type { zod as zodModule };

import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types';

export { CallToolResultSchema };

/**
* @public
*/
export type CallToolResult = zod.infer<typeof CallToolResultSchema>;
21 changes: 21 additions & 0 deletions apps/rush-mcp-server/src/schemas/rush-mcp-plugin.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Rush MCP Plugin Manifest",
"type": "object",
"properties": {
"pluginName": {
"type": "string",
"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."
},
"configFileSchema": {
"type": "string",
"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`."
},
"entryPoint": {
"type": "string",
"description": "The module path to the plugin's entry point. Its default export must be a class implementing the MCP plugin interface."
}
},
"required": ["pluginName", "entryPoint"],
"additionalProperties": false
}
28 changes: 28 additions & 0 deletions apps/rush-mcp-server/src/schemas/rush-mcp.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "rush-mcp.json Configuration Schema",
"type": "object",
"properties": {
"mcpPlugins": {
"type": "array",
"description": "The list of plugins that `@rushstack/mcp-server` should load when processing this monorepo.",
"items": {
"type": "object",
"properties": {
"packageName": {
"type": "string",
"description": "The name of an NPM package that appears in the package.json \"dependencies\" for the autoinstaller."
},
"autoinstaller": {
"type": "string",
"description": "The name of a Rush autoinstaller with this package as its dependency."
}
},
"required": ["packageName", "autoinstaller"],
"additionalProperties": false
}
}
},
"required": ["mcpPlugins"],
"additionalProperties": false
}
7 changes: 7 additions & 0 deletions apps/rush-mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
RushProjectDetailsTool,
RushDocsTool
} from './tools';
import { RushMcpPluginLoader } from './pluginFramework/RushMcpPluginLoader';

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

public constructor(rushWorkspacePath: string) {
super({
Expand All @@ -23,9 +25,14 @@ export class RushMCPServer extends McpServer {
});

this._rushWorkspacePath = rushWorkspacePath;
this._pluginLoader = new RushMcpPluginLoader(this._rushWorkspacePath);
}

public async startAsync(): Promise<void> {
this._initializeTools();
this._registerTools();

await this._pluginLoader.loadAsync();
}

private _initializeTools(): void {
Expand Down
1 change: 1 addition & 0 deletions apps/rush-mcp-server/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const main = async (): Promise<void> => {
}

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

Expand Down
Loading