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..067e915bba4e4 100644 --- a/packages/ai-core/src/common/language-model.ts +++ b/packages/ai-core/src/common/language-model.ts @@ -32,13 +32,40 @@ export const isLanguageModelRequestMessage = (obj: unknown): obj is LanguageMode 'query' in obj && typeof (obj as { query: unknown }).query === 'string' ); +export type ToolRequestParametersProperties = Record; +export interface ToolRequestParameters { + type?: 'object'; + properties: ToolRequestParametersProperties +} export interface ToolRequest { id: string; name: string; - parameters?: { type?: 'object', properties: Record }; + parameters?: ToolRequestParameters description?: string; handler: (arg_string: string) => Promise; + providerName?: string; } + +export namespace ToolRequest { + export function isToolRequestParametersProperties(obj: unknown): obj is ToolRequestParametersProperties { + if (!obj || typeof obj !== 'object') { return false; }; + + return Object.entries(obj).every(([key, value]) => + typeof key === 'string' && + value && + typeof value === 'object' && + 'type' in value && + typeof value.type === 'string' && + Object.keys(value).every(k => typeof k === 'string') + ); + } + export function isToolRequestParameters(obj: unknown): obj is ToolRequestParameters { + return !!obj && typeof obj === 'object' && + (!('type' in obj) || obj.type === 'object') && + 'properties' in obj && isToolRequestParametersProperties(obj.properties); + } +} + export interface LanguageModelRequest { messages: LanguageModelRequestMessage[], tools?: ToolRequest[]; diff --git a/packages/ai-core/src/common/tool-invocation-registry.ts b/packages/ai-core/src/common/tool-invocation-registry.ts index 2ebde1921103a..0c11938e7d5eb 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 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..5ba0d1aa63923 --- /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 MCP 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 MCP 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..d33981e2ef8e5 --- /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/mcp-frontend-module", + "backend": "lib/node/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..ae1921d35b4bd --- /dev/null +++ b/packages/ai-mcp/src/browser/mcp-command-contribution.ts @@ -0,0 +1,142 @@ +// ***************************************************************************** +// 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'; + +type MCPTool = Awaited>['tools'][number]; + +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) { + return undefined; + } + const options = serverNames.map(mcpServerName => ({ label: mcpServerName })); + const result = await this.quickInputService.showQuickPick(options); + return result?.label; + } + + registerCommands(commandRegistry: CommandRegistry): void { + commandRegistry.registerCommand(StopMCPServer, this.commandHandlerFactory({ + execute: async () => { + try { + const startedServers = await this.mcpServerManager.getStartedServers(); + if (!startedServers || startedServers.length === 0) { + this.messageService.error('No MCP servers running.'); + return; + } + const selection = await this.getMCPServerSelection(startedServers); + 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 servers = await this.mcpServerManager.getServerNames(); + const startedServers = await this.mcpServerManager.getStartedServers(); + const startableServers = servers.filter(server => !startedServers.includes(server)); + if (!startableServers || startableServers.length === 0) { + if (startedServers && startedServers.length > 0) { + this.messageService.error('All MCP servers are already running.'); + } else { + this.messageService.error('No MCP servers configured.'); + } + return; + } + + const selection = await this.getMCPServerSelection(startableServers); + if (!selection) { + return; + } + this.mcpServerManager.startServer(selection); + const { tools } = await this.mcpServerManager.getTools(selection); + const toolRequests: ToolRequest[] = tools.map(tool => this.convertToToolRequest(tool, selection)); + + for (const toolRequest of toolRequests) { + this.toolInvocationRegistry.registerTool(toolRequest); + } + const toolNames = tools.map(tool => tool.name || 'Unnamed Tool').join(', '); + this.messageService.info( + `MCP server "${selection}" successfully started. Registered tools: ${toolNames || 'No tools available.'}` + ); + } catch (error) { + this.messageService.error('An error occurred while starting the MCP server.'); + console.error('Error while starting MCP server:', error); + } + } + })); + } + + convertToToolRequest(tool: MCPTool, serverName: string): ToolRequest { + const id = `mcp_${serverName}_${tool.name}`; + + return { + id: id, + name: id, + providerName: `mcp_${serverName}`, + parameters: ToolRequest.isToolRequestParameters(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 MCP 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..f972ffa7e8f40 --- /dev/null +++ b/packages/ai-mcp/src/browser/mcp-frontend-application-contribution.ts @@ -0,0 +1,141 @@ +// ***************************************************************************** +// 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, PreferenceProvider, 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'; +import { JSONObject } from '@theia/core/shared/@phosphor/coreutils'; + +interface MCPServersPreferenceValue { + command: string; + args?: string[]; + env?: { [key: string]: string }; +}; + +interface MCPServersPreference { + [name: string]: MCPServersPreferenceValue +}; + +namespace MCPServersPreference { + export function isValue(obj: unknown): obj is MCPServersPreferenceValue { + return !!obj && typeof obj === 'object' && + 'command' in obj && typeof obj.command === 'string' && + (!('args' in obj) || Array.isArray(obj.args) && obj.args.every(arg => typeof arg === 'string')) && + (!('env' in obj) || !!obj.env && typeof obj.env === 'object' && Object.values(obj.env).every(value => typeof value === 'string')); + } +} + +function filterValidValues(servers: unknown): MCPServersPreference { + const result: MCPServersPreference = {}; + if (!servers || typeof servers !== 'object') { + return result; + } + for (const [name, value] of Object.entries(servers)) { + if (typeof name === 'string' && MCPServersPreference.isValue(value)) { + result[name] = value; + } + } + return result; +} + +@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 = filterValidValues(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(filterValidValues(event.newValue)); + } + }); + }); + } + + protected handleServerChanges(newServers: MCPServersPreference): 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); + let diff = false; + try { + // We know that that the descriptions are actual JSONObjects as we construct them ourselves + if (!oldDescription || !PreferenceProvider.deepEqual(oldDescription as unknown as JSONObject, description as unknown as JSONObject)) { + diff = true; + } + } catch (e) { + // In some cases the deepEqual function throws an error, so we fall back to assuming that there is a difference + // This seems to happen in cases where the objects are structured differently, e.g. whole sub-objects are missing + console.debug('Failed to compare MCP server descriptions, assuming a difference', e); + diff = true; + } + if (diff) { + this.manager.addOrUpdateServer(description); + } + } + + this.prevServers = updatedServers; + } + + protected syncServers(servers: MCPServersPreference): 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: MCPServersPreference): 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..b3ff702defbf9 --- /dev/null +++ b/packages/ai-mcp/src/browser/mcp-preferences.ts @@ -0,0 +1,83 @@ +// ***************************************************************************** +// 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, arguments and optionally environment variables. Each server is identified by a unique key, such as\ + "brave-search" 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\ + "brave-search": {\n\ + "command": "npx",\n\ + "args": [\n\ + "-y",\n\ + "@modelcontextprotocol/server-brave-search"\n\ + ],\n\ + "env": {\n\ + "BRAVE_API_KEY": "YOUR_API_KEY"\n\ + }\n\ + },\n\ + "filesystem": {\n\ + "command": "npx",\n\ + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/YOUR_USERNAME/Desktop"],\n\ + "env": {\n\ + "CUSTOM_ENV_VAR": "custom-value"\n\ + }\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' + } + }, + env: { + type: 'object', + title: 'Environment variables', + markdownDescription: 'Optional environment variables to set for the server, such as an API key.', + additionalProperties: { + 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..e316a644a27c6 --- /dev/null +++ b/packages/ai-mcp/src/common/mcp-server-manager.ts @@ -0,0 +1,58 @@ +// ***************************************************************************** +// 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 type { Client } from '@modelcontextprotocol/sdk/client/index'; + +export interface MCPServer { + callTool(toolName: string, arg_string: string): ReturnType; + getTools(): ReturnType; +} + +export interface MCPServerManager { + callTool(serverName: string, toolName: string, arg_string: string): ReturnType; + removeServer(name: string): void; + addOrUpdateServer(description: MCPServerDescription): void; + getTools(serverName: string): ReturnType + 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..06f869f93be73 --- /dev/null +++ b/packages/ai-mcp/src/node/mcp-server-manager-impl.ts @@ -0,0 +1,93 @@ +// ***************************************************************************** +// 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 { + + protected servers: Map = new Map(); + + async stopServer(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`MCP server "${serverName}" not found.`); + } + server.stop(); + console.log(`MCP 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): ReturnType { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`MCP 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(`MCP server "${serverName}" not found.`); + } + await server.start(); + } + async getServerNames(): Promise { + return Array.from(this.servers.keys()); + } + + public async getTools(serverName: string): ReturnType { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`MCP server "${serverName}" not found.`); + } + return server.getTools(); + + } + + 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(`MCP 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..52a7932762df7 --- /dev/null +++ b/packages/ai-mcp/src/node/mcp-server.ts @@ -0,0 +1,111 @@ +// ***************************************************************************** +// 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 { + if (this.started) { + return; + } + 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((entry): entry is [string, string] => entry[1] !== undefined) + ); + + const mergedEnv: Record = { + ...sanitizedEnv, + ...(this.env || {}) + }; + const transport = new StdioClientTransport({ + command: this.command, + args: this.args, + env: mergedEnv, + }); + transport.onerror = error => { + console.error('Error: ' + error); + }; + + this.client = new Client({ + name: 'theia-client', + version: '1.0.0', + }, { + capabilities: {} + }); + this.client.onerror = error => { + console.error('Error in MCP client: ' + error); + }; + + await this.client.connect(transport); + this.started = true; + } + + async callTool(toolName: string, arg_string: string): ReturnType { + let args; + try { + args = JSON.parse(arg_string); + } catch (error) { + console.error( + `Failed to parse arguments for calling tool "${toolName}" in MCP server "${this.name}" with command "${this.command}". + Invalid JSON: ${arg_string}`, + error + ); + } + const params = { + name: toolName, + arguments: args, + }; + return this.client.callTool(params); + } + + async getTools(): ReturnType { + return this.client.listTools(); + } + + update(command: string, args?: string[], env?: { [key: string]: string }): void { + this.command = command; + this.args = args; + this.env = env; + } + + stop(): void { + if (!this.started || !this.client) { + return; + } + console.log(`Stopping MCP 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 87977acf8516c..722c0fc50dc08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1344,6 +1344,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.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz#9edec61b22c3082018a79f6d1c30289ddf3d9d11" @@ -4535,7 +4544,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== @@ -10603,6 +10612,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"