diff --git a/examples/browser/package.json b/examples/browser/package.json index 2fdea15ac3e78..1c5b1ba3bfad9 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -29,6 +29,7 @@ "@theia/ai-history": "1.56.0", "@theia/ai-huggingface": "1.56.0", "@theia/ai-llamafile": "1.56.0", + "@theia/ai-mcp": "1.56.0", "@theia/ai-ollama": "1.56.0", "@theia/ai-openai": "1.56.0", "@theia/ai-terminal": "1.56.0", diff --git a/examples/browser/tsconfig.json b/examples/browser/tsconfig.json index 5c7e5e2b56ab6..ec73ed6072340 100644 --- a/examples/browser/tsconfig.json +++ b/examples/browser/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../packages/ai-llamafile" }, + { + "path": "../../packages/ai-mcp" + }, { "path": "../../packages/ai-ollama" }, diff --git a/packages/ai-core/src/browser/prompttemplate-contribution.ts b/packages/ai-core/src/browser/prompttemplate-contribution.ts index c8735daa2402b..9465ced70b5ef 100644 --- a/packages/ai-core/src/browser/prompttemplate-contribution.ts +++ b/packages/ai-core/src/browser/prompttemplate-contribution.ts @@ -14,15 +14,15 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { GrammarDefinition, GrammarDefinitionProvider, LanguageGrammarDefinitionContribution, TextmateRegistry } from '@theia/monaco/lib/browser/textmate'; import * as monaco from '@theia/monaco-editor-core'; -import { Command, CommandContribution, CommandRegistry, ContributionProvider, MessageService } from '@theia/core'; +import { Command, CommandContribution, CommandRegistry, MessageService } from '@theia/core'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { codicon, Widget } from '@theia/core/lib/browser'; import { EditorWidget, ReplaceOperation } from '@theia/editor/lib/browser'; -import { PromptCustomizationService, PromptService, ToolProvider } from '../common'; +import { PromptCustomizationService, PromptService, ToolInvocationRegistry } from '../common'; import { ProviderResult } from '@theia/monaco-editor-core/esm/vs/editor/common/languages'; const PROMPT_TEMPLATE_LANGUAGE_ID = 'theia-ai-prompt-template'; @@ -56,9 +56,8 @@ export class PromptTemplateContribution implements LanguageGrammarDefinitionCont @inject(PromptCustomizationService) protected readonly customizationService: PromptCustomizationService; - @inject(ContributionProvider) - @named(ToolProvider) - private toolProviders: ContributionProvider; + @inject(ToolInvocationRegistry) + protected readonly toolInvocationRegistry: ToolInvocationRegistry; readonly config: monaco.languages.LanguageConfiguration = { @@ -115,7 +114,7 @@ export class PromptTemplateContribution implements LanguageGrammarDefinitionCont model, position, '~{', - this.toolProviders.getContributions().map(provider => provider.getTool()), + this.toolInvocationRegistry.getAllFunctions(), monaco.languages.CompletionItemKind.Function, tool => tool.id, tool => tool.name, diff --git a/packages/ai-core/src/common/language-model.ts b/packages/ai-core/src/common/language-model.ts index 4c507b74bdfcc..ab0c3e104595c 100644 --- a/packages/ai-core/src/common/language-model.ts +++ b/packages/ai-core/src/common/language-model.ts @@ -38,6 +38,7 @@ export interface ToolRequest { parameters?: { type?: 'object', properties: Record }; description?: string; handler: (arg_string: string) => Promise; + providerName?: string; } export interface LanguageModelRequest { messages: LanguageModelRequestMessage[], diff --git a/packages/ai-core/src/common/tool-invocation-registry.ts b/packages/ai-core/src/common/tool-invocation-registry.ts index 2ebde1921103a..baeac82f725b4 100644 --- a/packages/ai-core/src/common/tool-invocation-registry.ts +++ b/packages/ai-core/src/common/tool-invocation-registry.ts @@ -24,11 +24,44 @@ export const ToolInvocationRegistry = Symbol('ToolInvocationRegistry'); * Registry for all the function calls available to Agents. */ export interface ToolInvocationRegistry { + /** + * Registers a tool into the registry. + * + * @param tool - The `ToolRequest` object representing the tool to be registered. + */ registerTool(tool: ToolRequest): void; + /** + * Retrieves a specific `ToolRequest` from the registry. + * + * @param toolId - The unique identifier of the tool to retrieve. + * @returns The `ToolRequest` object corresponding to the provided tool ID, + * or `undefined` if the tool is not found in the registry. + */ getFunction(toolId: string): ToolRequest | undefined; + /** + * Retrieves multiple `ToolRequest`s configurations from the registry. + * + * @param toolIds - A list of tool IDs to retrieve. + * @returns An array of `ToolRequest` objects for the specified tool IDs. + * If a tool ID is not found, it is skipped in the returned array. + */ getFunctions(...toolIds: string[]): ToolRequest[]; + + /** + * Retrieves all `ToolRequest`s currently registered in the registry. + * + * @returns An array of all `ToolRequest` objects in the registry. + */ + getAllFunctions(): ToolRequest[]; + + /** + * Unregisters all tools provided by a specific tool provider. + * + * @param providerName - The name of the tool provider whose tools should be removed (as specificed in the `ToolRequest`). + */ + unregisterAllTools(providerName: string): void; } export const ToolProvider = Symbol('ToolProvider'); @@ -52,6 +85,19 @@ export class ToolInvocationRegistryImpl implements ToolInvocationRegistry { }); } + unregisterAllTools(providerName: string): void { + const toolsToRemove: string[] = []; + for (const [id, tool] of this.tools.entries()) { + if (tool.providerName === providerName) { + toolsToRemove.push(id); + } + } + toolsToRemove.forEach(id => this.tools.delete(id)); + } + getAllFunctions(): ToolRequest[] { + return Array.from(this.tools.values()); + } + registerTool(tool: ToolRequest): void { if (this.tools.has(tool.id)) { console.warn(`Function with id ${tool.id} is already registered.`); diff --git a/packages/ai-mcp/.eslintrc.js b/packages/ai-mcp/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-mcp/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-mcp/README.md b/packages/ai-mcp/README.md new file mode 100644 index 0000000000000..271efd4a45050 --- /dev/null +++ b/packages/ai-mcp/README.md @@ -0,0 +1,91 @@ +# Model Context Server (MCP) Integration + +The AI MCP package provides an integration that allows users to start and use MCP Servers to provide additional tool functions to LLMs, e.g. search or file access (outside of the workspace). + +## Features +- Add MCP Servers via settings.json +- Start and stop MCP servers. +- Use tool functions provided by MCP servers in prompt templates + +## Commands + +### Start MCP Server + +- **Command ID:** `mcp.startserver` +- **Label:** `MCP: Start MCP Server` +- **Functionality:** Allows you to start a MCP server by selecting from a list of configured servers. + +### Stop MCP Server + +- **Command ID:** `mcp.stopserver` +- **Label:** `MCP: Stop MCP Server` +- **Functionality:** Allows you to stop a running MCP server by selecting from a list of currently running servers. + +## Usage + +1. **Starting a Llamafile Language Server:** + + - Use the command palette to invoke `MCP: Start MCP Server`. + - A quick pick menu will appear with a list of configured MCP Servers. + - Select a server to start. + +2. **Stopping a Llamafile Language Server:** + - Use the command palette to invoke `MCP: Stop MCP Server`. + - A quick pick menu will display a list of currently running MCP Servers. + - Select a server to stop. + +3. **Using provided tool functions** + - Only functions of started MCP servers can be used + - Open a prompt template and add the added tool functions + - Type '~{' to open the auto completion + +## Configuration + +Make sure to configure your MCP Servers properly within the preference settings. + +Example Configuration: + +```json +{ + "ai-features.mcp.mcpServers": { + "memory": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-memory" + ] + }, + "brave-search": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-brave-search" + ], + "env": { + "BRAVE_API_KEY": "YOUR_API_KEY" + } + }, + "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "ABSOLUTE_PATH_TO_ALLOWED_DIRECTORY", + ] + }, + } +} +``` + +Example prompt (for search) +```md +~{mcp_brave-search_brave_web_search} +``` + +Example User query +```md +Search the internet for XYZ +``` + +## More Information +[List of available MCP servers](https://github.com/modelcontextprotocol/servers) diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json new file mode 100644 index 0000000000000..e430ae3aba109 --- /dev/null +++ b/packages/ai-mcp/package.json @@ -0,0 +1,49 @@ +{ + "name": "@theia/ai-mcp", + "version": "1.56.0", + "description": "Theia - MCP Integration", + "dependencies": { + "@theia/core": "1.56.0", + "@theia/ai-core": "1.56.0", + "@modelcontextprotocol/sdk": "1.0.1" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/ai-mcp-frontend-module", + "backend": "lib/node/ai-mcp-backend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.56.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-mcp/src/browser/mcp-command-contribution.ts b/packages/ai-mcp/src/browser/mcp-command-contribution.ts new file mode 100644 index 0000000000000..0eb6aa333e1d5 --- /dev/null +++ b/packages/ai-mcp/src/browser/mcp-command-contribution.ts @@ -0,0 +1,121 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { AICommandHandlerFactory } from '@theia/ai-core/lib/browser/ai-command-handler-factory'; +import { CommandContribution, CommandRegistry, MessageService } from '@theia/core'; +import { QuickInputService } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { MCPServerManager } from '../common/mcp-server-manager'; +import { ToolInvocationRegistry, ToolRequest } from '@theia/ai-core'; + +export const StartMCPServer = { + id: 'mcp.startserver', + label: 'MCP: Start MCP Server', +}; +export const StopMCPServer = { + id: 'mcp.stopserver', + label: 'MCP: Stop MCP Server', +}; + +@injectable() +export class MCPCommandContribution implements CommandContribution { + @inject(AICommandHandlerFactory) + protected readonly commandHandlerFactory: AICommandHandlerFactory; + + @inject(QuickInputService) + protected readonly quickInputService: QuickInputService; + + @inject(MessageService) + protected messageService: MessageService; + + @inject(MCPServerManager) + protected readonly mcpServerManager: MCPServerManager; + + @inject(ToolInvocationRegistry) + protected readonly toolInvocationRegistry: ToolInvocationRegistry; + + private async getMCPServerSelection(serverNames: String[]): Promise { + if (!serverNames || serverNames.length === 0) { + this.messageService.error('No MCP Servers configured.'); + return undefined; + } + const options = serverNames.map(mcpServerName => ({ label: mcpServerName as string })); + const result = await this.quickInputService.showQuickPick(options); + if (!result) { + return undefined; + } + return result.label; + } + + registerCommands(commandRegistry: CommandRegistry): void { + commandRegistry.registerCommand(StopMCPServer, this.commandHandlerFactory({ + execute: async () => { + try { + const selection = await this.getMCPServerSelection(await this.mcpServerManager.getStartedServers()); + if (!selection) { + return; + } + this.toolInvocationRegistry.unregisterAllTools(`mcp_${selection}`); + this.mcpServerManager.stopServer(selection); + } catch (error) { + console.error('Error while stopping MCP server:', error); + } + } + })); + + commandRegistry.registerCommand(StartMCPServer, this.commandHandlerFactory({ + execute: async () => { + try { + const selection = await this.getMCPServerSelection(await this.mcpServerManager.getServerNames()); + if (!selection) { + return; + } + this.mcpServerManager.startServer(selection); + const { tools } = await this.mcpServerManager.getTools(selection); + const toolRequests: ToolRequest[] = tools.map((tool: any) => this.convertToToolRequest(tool, selection)); + + for (const toolRequest of toolRequests) { + this.toolInvocationRegistry.registerTool(toolRequest); + } + } catch (error) { + console.error('Error while starting MCP server:', error); + } + } + })); + } + + convertToToolRequest(tool: any, serverName: string): ToolRequest { + const id = `mcp_${serverName}_${tool.name}`; + return { + id: id, + name: id, + providerName: `mcp_${serverName}`, + parameters: tool.inputSchema ? { + type: tool.inputSchema.type, + properties: tool.inputSchema.properties, + } : undefined, + description: tool.description, + handler: async (arg_string: string) => { + try { + return await this.mcpServerManager.callTool(serverName, tool.name, arg_string); + } catch (error) { + console.error(`Error in tool handler for ${tool.name} on server ${serverName}:`, error); + throw error; + } + }, + }; + } + +} diff --git a/packages/ai-mcp/src/browser/mcp-frontend-application-contribution.ts b/packages/ai-mcp/src/browser/mcp-frontend-application-contribution.ts new file mode 100644 index 0000000000000..a082947b11721 --- /dev/null +++ b/packages/ai-mcp/src/browser/mcp-frontend-application-contribution.ts @@ -0,0 +1,104 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { FrontendApplicationContribution, PreferenceService } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { MCPServerDescription, MCPServerManager } from '../common'; +import { MCP_SERVERS_PREF } from './mcp-preferences'; // Preference constant for MCP servers + +type MCPServerEntry = { + [name: string]: { + command: string; + args: string[]; + env?: { [key: string]: string }; + }; +}; + +@injectable() +export class McpFrontendApplicationContribution implements FrontendApplicationContribution { + + @inject(PreferenceService) + protected preferenceService: PreferenceService; + + @inject(MCPServerManager) + protected manager: MCPServerManager; + + protected prevServers: Map = new Map(); + + onStart(): void { + this.preferenceService.ready.then(() => { + const servers = this.preferenceService.get( + MCP_SERVERS_PREF, + {} + ); + this.syncServers(servers); + this.prevServers = this.convertToMap(servers); + + this.preferenceService.onPreferenceChanged(event => { + if (event.preferenceName === MCP_SERVERS_PREF) { + this.handleServerChanges(event.newValue as MCPServerEntry); + } + }); + }); + } + + protected handleServerChanges(newServers: MCPServerEntry): void { + const oldServers = this.prevServers; + const updatedServers = this.convertToMap(newServers); + + for (const [name] of oldServers) { + if (!updatedServers.has(name)) { + this.manager.removeServer(name); + } + } + + for (const [name, description] of updatedServers) { + const oldDescription = oldServers.get(name); + if (!oldDescription || JSON.stringify(oldDescription) !== JSON.stringify(description)) { + this.manager.addOrUpdateServer(description); + } + } + + this.prevServers = updatedServers; + } + + protected syncServers(servers: MCPServerEntry): void { + const updatedServers = this.convertToMap(servers); + + for (const [, description] of updatedServers) { + this.manager.addOrUpdateServer(description); + } + + for (const [name] of this.prevServers) { + if (!updatedServers.has(name)) { + this.manager.removeServer(name); + } + } + + this.prevServers = updatedServers; + } + + protected convertToMap(servers: MCPServerEntry): Map { + const map = new Map(); + Object.entries(servers).forEach(([name, description]) => { + map.set(name, { + name, + ...description, + env: description.env || undefined + }); + }); + return map; + } +} diff --git a/packages/ai-mcp/src/browser/mcp-frontend-module.ts b/packages/ai-mcp/src/browser/mcp-frontend-module.ts new file mode 100644 index 0000000000000..d040de0844580 --- /dev/null +++ b/packages/ai-mcp/src/browser/mcp-frontend-module.ts @@ -0,0 +1,33 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { CommandContribution } from '@theia/core'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { MCPCommandContribution } from './mcp-command-contribution'; +import { FrontendApplicationContribution, PreferenceContribution, RemoteConnectionProvider, ServiceConnectionProvider } from '@theia/core/lib/browser'; +import { MCPServerManager, MCPServerManagerPath } from '../common/mcp-server-manager'; +import { McpServersPreferenceSchema } from './mcp-preferences'; +import { McpFrontendApplicationContribution } from './mcp-frontend-application-contribution'; + +export default new ContainerModule(bind => { + bind(PreferenceContribution).toConstantValue({ schema: McpServersPreferenceSchema }); + bind(FrontendApplicationContribution).to(McpFrontendApplicationContribution).inSingletonScope(); + bind(CommandContribution).to(MCPCommandContribution); + bind(MCPServerManager).toDynamicValue(ctx => { + const connection = ctx.container.get(RemoteConnectionProvider); + return connection.createProxy(MCPServerManagerPath); + }).inSingletonScope(); +}); diff --git a/packages/ai-mcp/src/browser/mcp-preferences.ts b/packages/ai-mcp/src/browser/mcp-preferences.ts new file mode 100644 index 0000000000000..ef708e7db24e7 --- /dev/null +++ b/packages/ai-mcp/src/browser/mcp-preferences.ts @@ -0,0 +1,65 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { PreferenceSchema } from '@theia/core/lib/browser/preferences/preference-contribution'; + +export const MCP_SERVERS_PREF = 'ai-features.mcp.mcpServers'; + +export const McpServersPreferenceSchema: PreferenceSchema = { + type: 'object', + properties: { + [MCP_SERVERS_PREF]: { + type: 'object', + title: 'MCP Servers Configuration', + markdownDescription: 'Configure MCP servers with command and arguments. Each server is identified by a unique key, such as "sqlite" or "filesystem".\ + To start a server, use the "MCP: Start MCP Server" command, which enables you to select the desired server.\ + To stop a server, use the "MCP: Stop MCP Server" command.\ + \n\ + Example configuration:\n\ + ```\ + {\n\ + "sqlite": {\n\ + "command": "uvx",\n\ + "args": ["mcp-server-sqlite", "--db-path", "/Users/YOUR_USERNAME/test.db"]\n\ + },\n\ + "filesystem": {\n\ + "command": "npx",\n\ + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/YOUR_USERNAME/Desktop"]\n\ + }\n\ + }\ + ```', + additionalProperties: { + type: 'object', + properties: { + command: { + type: 'string', + title: 'Command to execute the MCP server', + markdownDescription: 'The command used to start the MCP server, e.g., "uvx" or "npx".' + }, + args: { + type: 'array', + title: 'Arguments for the command', + markdownDescription: 'An array of arguments to pass to the command.', + items: { + type: 'string' + } + } + }, + required: ['command', 'args'] + } + } + } +}; diff --git a/packages/ai-mcp/src/common/index.ts b/packages/ai-mcp/src/common/index.ts new file mode 100644 index 0000000000000..a282b9a44c96f --- /dev/null +++ b/packages/ai-mcp/src/common/index.ts @@ -0,0 +1,16 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +export * from './mcp-server-manager'; diff --git a/packages/ai-mcp/src/common/mcp-server-manager.ts b/packages/ai-mcp/src/common/mcp-server-manager.ts new file mode 100644 index 0000000000000..0cd2abc02296d --- /dev/null +++ b/packages/ai-mcp/src/common/mcp-server-manager.ts @@ -0,0 +1,49 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +export interface MCPServerManager { + callTool(serverName: string, toolName: any, arg_string: string): unknown; + removeServer(name: string): unknown; + addOrUpdateServer(description: MCPServerDescription): unknown; + getTools(serverName: string): Promise; + getServerNames(): Promise; + startServer(serverName: string): Promise; + stopServer(serverName: string): Promise; + getStartedServers(): Promise; +} +export interface MCPServerDescription { + /** + * The unique name of the MCP server. + */ + name: string; + + /** + * The command to execute the MCP server. + */ + command: string; + + /** + * An array of arguments to pass to the command. + */ + args: string[]; + /** + * Optional environment variables to set when starting the server. + */ + env?: { [key: string]: string }; +} + +export const MCPServerManager = Symbol('MCPServerManager'); + +export const MCPServerManagerPath = '/services/mcpservermanager'; diff --git a/packages/ai-mcp/src/node/mcp-backend-module.ts b/packages/ai-mcp/src/node/mcp-backend-module.ts new file mode 100644 index 0000000000000..5ea32d4fc240c --- /dev/null +++ b/packages/ai-mcp/src/node/mcp-backend-module.ts @@ -0,0 +1,31 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ContainerModule } from '@theia/core/shared/inversify'; +import { ConnectionHandler, RpcConnectionHandler } from '@theia/core'; +import { MCPServerManagerImpl } from './mcp-server-manager-impl'; +import { MCPServerManager, MCPServerManagerPath } from '../common/mcp-server-manager'; + +export default new ContainerModule(bind => { + bind(MCPServerManager).to(MCPServerManagerImpl).inSingletonScope(); + bind(ConnectionHandler).toDynamicValue(ctx => new RpcConnectionHandler( + MCPServerManagerPath, + () => { + const service = ctx.container.get(MCPServerManager); + return service; + } + )).inSingletonScope(); +}); diff --git a/packages/ai-mcp/src/node/mcp-server-manager-impl.ts b/packages/ai-mcp/src/node/mcp-server-manager-impl.ts new file mode 100644 index 0000000000000..a7798c2aa7f10 --- /dev/null +++ b/packages/ai-mcp/src/node/mcp-server-manager-impl.ts @@ -0,0 +1,90 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { injectable } from '@theia/core/shared/inversify'; +import { MCPServerDescription, MCPServerManager } from '../common/mcp-server-manager'; +import { MCPServer } from './mcp-server'; + +@injectable() +export class MCPServerManagerImpl implements MCPServerManager { + async stopServer(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`Server "${serverName}" not found.`); + } + server.stop(); + console.log(`Server "${serverName}" stopped.`); + } + + async getStartedServers(): Promise { + const startedServers: string[] = []; + for (const [name, server] of this.servers.entries()) { + if (server.isStarted()) { + startedServers.push(name); + } + } + return startedServers; + } + + callTool(serverName: string, toolName: string, arg_string: string): unknown { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`Server "${toolName}" not found.`); + } + return server.callTool(toolName, arg_string); + } + + async startServer(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`Server "${serverName}" not found.`); + } + await server.start(); + } + async getServerNames(): Promise { + return Array.from(this.servers.keys()); + } + + public async getTools(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`Server "${serverName}" not found.`); + } + return server.getTools(); + + } + private servers: Map = new Map(); + addOrUpdateServer(description: MCPServerDescription): void { + const { name, command, args, env } = description; + const existingServer = this.servers.get(name); + + if (existingServer) { + existingServer.update(command, args, env); + } else { + const newServer = new MCPServer(name, command, args, env); + this.servers.set(name, newServer); + } + } + + removeServer(name: string): void { + const server = this.servers.get(name); + if (server) { + server.stop(); + this.servers.delete(name); + } else { + console.warn(`Server "${name}" not found.`); + } + } +} diff --git a/packages/ai-mcp/src/node/mcp-server.ts b/packages/ai-mcp/src/node/mcp-server.ts new file mode 100644 index 0000000000000..f77a9ca2f39b2 --- /dev/null +++ b/packages/ai-mcp/src/node/mcp-server.ts @@ -0,0 +1,96 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +export class MCPServer { + private name: string; + private command: string; + private args: string[]; + private client: Client; + private env?: { [key: string]: string }; + private started: boolean = false; + + constructor(name: string, command: string, args: string[], env?: Record) { + this.name = name; + this.command = command; + this.args = args; + this.env = env; + } + + isStarted(): boolean { + return this.started; + } + + async start(): Promise { + console.log(`Starting server "${this.name}" with command: ${this.command} and args: ${this.args.join(' ')} and env: ${JSON.stringify(this.env)}`); + // Filter process.env to exclude undefined values + const sanitizedEnv: Record = Object.fromEntries( + Object.entries(process.env).filter(([_, value]) => value !== undefined) as [string, string][] + ); + + const mergedEnv: Record = { + ...sanitizedEnv, + ...(this.env || {}) + }; + const transport = new StdioClientTransport({ + command: this.command, + args: this.args, + env: mergedEnv, + }); + transport.onerror = error => { + console.log('Error: ' + error); + }; + + this.client = new Client({ + name: 'example-client', + version: '1.0.0', + }, { + capabilities: {} + }); + this.client.onerror = error => { + console.log('Error Client: ' + error); + }; + + await this.client.connect(transport); + this.started = true; + } + + async callTool(toolName: string, arg_string: string): Promise { + const args = JSON.parse(arg_string); + const params = { + name: toolName, + arguments: args, + }; + return this.client.callTool(params); + } + + async getTools(): Promise { + return this.client.listTools(); + } + + update(command: string, args: string[], env?: { [key: string]: string }): void { + console.log(`Updating server "${this.name}" with new command: ${command} and args: ${args.join(' ')}`); + this.command = command; + this.args = args; + this.env = env; + } + + stop(): void { + console.log(`Stopping server "${this.name}"`); + this.client.close(); + this.started = false; + } +} diff --git a/packages/ai-mcp/src/package.spec.ts b/packages/ai-mcp/src/package.spec.ts new file mode 100644 index 0000000000000..0285e7159b30b --- /dev/null +++ b/packages/ai-mcp/src/package.spec.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +/* note: this bogus test file is required so that + we are able to run mocha unit tests on this + package, without having any actual unit tests in it. + This way a coverage report will be generated, + showing 0% coverage, instead of no report. + This file can be removed once we have real unit + tests in place. */ + +describe('ai-mcp package', () => { + + it('support code coverage statistics', () => true); +}); diff --git a/packages/ai-mcp/tsconfig.json b/packages/ai-mcp/tsconfig.json new file mode 100644 index 0000000000000..420367fccbfb3 --- /dev/null +++ b/packages/ai-mcp/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-core" + }, + { + "path": "../core" + } + ] +} diff --git a/tsconfig.json b/tsconfig.json index 72e54f2e87b78..2afcd04b2d53c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -75,6 +75,9 @@ { "path": "packages/ai-llamafile" }, + { + "path": "packages/ai-mcp" + }, { "path": "packages/ai-ollama" }, diff --git a/yarn.lock b/yarn.lock index 21d2692e9f2aa..386740b9bff39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1234,6 +1234,15 @@ dependencies: cross-spawn "^7.0.1" +"@modelcontextprotocol/sdk@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.0.1.tgz#810684e7c11b7e056adab895edf3c4a1bc796205" + integrity sha512-slLdFaxQJ9AlRg+hw28iiTtGvShAOgOKXcD0F91nUcRYiOMuS9ZBYjcdNZRXW9G5JQ511GRTdUy1zQVZDpJ+4w== + dependencies: + content-type "^1.0.5" + raw-body "^3.0.0" + zod "^3.23.8" + "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz#44d752c1a2dc113f15f781b7cc4f53a307e3fa38" @@ -4385,7 +4394,7 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4, content-type@~1.0.5: +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -10349,6 +10358,16 @@ raw-body@2.5.2, raw-body@^2.3.0: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" + integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.6.3" + unpipe "1.0.0" + rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"