diff --git a/package-lock.json b/package-lock.json index 165751d..6e51e64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@anthropic-ai/sdk": "^0.26.0", "@anthropic-ai/vertex-sdk": "^0.4.1", "@google/generative-ai": "^0.18.0", + "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", "@types/pdf-parse": "^1.1.4", "@types/turndown": "^5.0.5", @@ -38,7 +39,8 @@ "strip-ansi": "^7.1.0", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", - "web-tree-sitter": "^0.22.6" + "web-tree-sitter": "^0.22.6", + "zod": "^3.23.8" }, "devDependencies": { "@types/diff": "^5.2.1", @@ -2777,6 +2779,17 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "license": "BSD-2-Clause" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.1.tgz", + "integrity": "sha512-slLdFaxQJ9AlRg+hw28iiTtGvShAOgOKXcD0F91nUcRYiOMuS9ZBYjcdNZRXW9G5JQ511GRTdUy1zQVZDpJ+4w==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "raw-body": "^3.0.0", + "zod": "^3.23.8" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5322,6 +5335,15 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/c8": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", @@ -5667,6 +5689,15 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "license": "ISC" }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5903,6 +5934,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1342118", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1342118.tgz", @@ -7366,6 +7406,22 @@ "entities": "^4.5.0" } }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -9614,6 +9670,21 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -9990,6 +10061,12 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -10164,6 +10241,15 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stdin-discarder": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", @@ -10553,6 +10639,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -10805,6 +10900,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 6055389..967f723 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "@anthropic-ai/sdk": "^0.26.0", "@anthropic-ai/vertex-sdk": "^0.4.1", "@google/generative-ai": "^0.18.0", + "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", "@types/pdf-parse": "^1.1.4", "@types/turndown": "^5.0.5", @@ -191,6 +192,7 @@ "strip-ansi": "^7.1.0", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", - "web-tree-sitter": "^0.22.6" + "web-tree-sitter": "^0.22.6", + "zod": "^3.23.8" } } diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 229d950..e7ffd4b 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -751,6 +751,12 @@ export class Cline { } async *attemptApiRequest(previousApiReqIndex: number): ApiStream { + // Wait for MCP servers to be connected before generating system prompt + await pWaitFor(() => this.providerRef.deref()?.mcpHub?.isConnecting !== true, { timeout: 10_000 }).catch(() => { + console.error("MCP servers failed to connect in time") + }) + const mcpServers = this.providerRef.deref()?.mcpHub?.connections.map((conn) => conn.server) + console.log("mcpServers for system prompt:", JSON.stringify(mcpServers, null, 2)) let systemPrompt = await SYSTEM_PROMPT(cwd, this.api.getModel().info.supportsComputerUse ?? false) if (this.customInstructions && this.customInstructions.trim()) { // altering the system prompt mid-task will break the prompt cache, but in the grand scheme this will not change often so it's better to not pollute user messages with it the way we have to with diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 619298e..f51035a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -20,6 +20,7 @@ import { Cline } from "../Cline" import { openMention } from "../mentions" import { getNonce } from "./getNonce" import { getUri } from "./getUri" +import { McpHub } from "../../services/mcp/McpHub" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -62,6 +63,7 @@ export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", uiMessages: "ui_messages.json", openRouterModels: "openrouter_models.json", + mcpSettings: "cline_mcp_settings.json", } export class ClineProvider implements vscode.WebviewViewProvider { @@ -72,6 +74,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView | vscode.WebviewPanel private cline?: Cline private workspaceTracker?: WorkspaceTracker + mcpHub?: McpHub private latestAnnouncementId = "oct-28-2024" // update to some unique identifier when we add a new announcement constructor( @@ -81,6 +84,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.outputChannel.appendLine("ClineProvider instantiated") ClineProvider.activeInstances.add(this) this.workspaceTracker = new WorkspaceTracker(this) + this.mcpHub = new McpHub(this) } /* @@ -104,6 +108,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { } this.workspaceTracker?.dispose() this.workspaceTracker = undefined + this.mcpHub?.dispose() + this.mcpHub = undefined this.outputChannel.appendLine("Disposed all disposables") ClineProvider.activeInstances.delete(this) } @@ -485,6 +491,21 @@ export class ClineProvider implements vscode.WebviewViewProvider { } break + case "openMcpSettings": { + const mcpSettingsFilePath = await this.mcpHub?.getMcpSettingsFilePath() + if (mcpSettingsFilePath) { + openFile(mcpSettingsFilePath) + } + break + } + case "retryMcpServer": { + try { + await this.mcpHub?.retryConnection(message.text!) + } catch (error) { + console.error(`Failed to retry connection for ${message.text}:`, error) + } + break + } // Add more switch case statements here as more webview message commands // are created within the webview context (i.e. inside media/main.js) } @@ -567,7 +588,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { // await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome } - private async ensureCacheDirectoryExists(): Promise { + async ensureCacheDirectoryExists(): Promise { const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache") await fs.mkdir(cacheDir, { recursive: true }) return cacheDir diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts new file mode 100644 index 0000000..2f7257d --- /dev/null +++ b/src/services/mcp/McpHub.ts @@ -0,0 +1,314 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js" +import { ListResourcesResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js" +import deepEqual from "fast-deep-equal" +import * as fs from "fs/promises" +import * as path from "path" +import * as vscode from "vscode" +import { z } from "zod" +import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider" +import { McpResource, McpServer, McpTool } from "../../shared/mcp" +import { fileExistsAtPath } from "../../utils/fs" +import { arePathsEqual } from "../../utils/path" + +export type McpConnection = { + server: McpServer + client: Client + transport: StdioClientTransport +} + +// StdioServerParameters +const StdioConfigSchema = z.object({ + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), +}) + +const McpSettingsSchema = z.object({ + mcpServers: z.record(StdioConfigSchema), +}) + +export class McpHub { + private providerRef: WeakRef + private settingsWatcher?: vscode.FileSystemWatcher + private disposables: vscode.Disposable[] = [] + connections: McpConnection[] = [] + isConnecting: boolean = false + + constructor(provider: ClineProvider) { + this.providerRef = new WeakRef(provider) + this.watchMcpSettingsFile() + this.initializeMcpServers() + } + + async getMcpSettingsFilePath(): Promise { + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider not available") + } + const mcpSettingsFilePath = path.join(await provider.ensureCacheDirectoryExists(), GlobalFileNames.mcpSettings) + const fileExists = await fileExistsAtPath(mcpSettingsFilePath) + if (!fileExists) { + await fs.writeFile( + mcpSettingsFilePath, + `{ + "mcpServers": { + + } +}`, + ) + } + return mcpSettingsFilePath + } + + private async watchMcpSettingsFile(): Promise { + const settingsPath = await this.getMcpSettingsFilePath() + this.disposables.push( + vscode.workspace.onDidSaveTextDocument(async (document) => { + if (arePathsEqual(document.uri.fsPath, settingsPath)) { + const content = await fs.readFile(settingsPath, "utf-8") + const errorMessage = + "Invalid MCP settings format. Please ensure your settings follow the correct JSON format." + let config: any + try { + config = JSON.parse(content) + } catch (error) { + vscode.window.showErrorMessage(errorMessage) + return + } + const result = McpSettingsSchema.safeParse(config) + if (!result.success) { + vscode.window.showErrorMessage(errorMessage) + return + } + try { + vscode.window.showInformationMessage("Updating MCP servers...") + await this.updateServerConnections(result.data.mcpServers || {}) + vscode.window.showInformationMessage("MCP servers updated") + } catch (error) { + console.error("Failed to process MCP settings change:", error) + } + } + }), + ) + } + + private async initializeMcpServers(): Promise { + try { + const settingsPath = await this.getMcpSettingsFilePath() + const content = await fs.readFile(settingsPath, "utf-8") + const config = JSON.parse(content) + await this.updateServerConnections(config.mcpServers || {}) + } catch (error) { + console.error("Failed to initialize MCP servers:", error) + } + } + + private async connectToServer(name: string, config: StdioServerParameters): Promise { + // Remove existing connection if it exists + this.connections = this.connections.filter((conn) => conn.server.name !== name) + + try { + // Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection. + const client = new Client( + { + name: "Cline", + version: this.providerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0", + }, + { + capabilities: {}, + }, + ) + + const transport = new StdioClientTransport({ + command: config.command, + args: config.args, + env: { + ...config.env, + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + // ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}), + }, + }) + + transport.onerror = (error) => { + console.error(`Transport error for "${name}":`, error) + const connection = this.connections.find((conn) => conn.server.name === name) + if (connection) { + connection.server.status = "disconnected" + connection.server.error = error.message + } + } + + transport.onclose = () => { + const connection = this.connections.find((conn) => conn.server.name === name) + if (connection) { + connection.server.status = "disconnected" + } + } + + // If the config is invalid, show an error + if (!StdioConfigSchema.safeParse(config).success) { + console.error(`Invalid config for "${name}": missing or invalid parameters`) + const connection: McpConnection = { + server: { + name, + config: JSON.stringify(config), + status: "disconnected", + error: "Invalid config: missing or invalid parameters", + }, + client, + transport, + } + this.connections.push(connection) + await this.notifyWebviewOfServerChanges() + return + } + + await client.connect(transport) + const connection: McpConnection = { + server: { + name, + config: JSON.stringify(config), + status: "connecting", + }, + client, + transport, + } + this.connections.push(connection) + connection.server.status = "connected" + + // After successful connection, fetch tools and resources + connection.server.tools = await this.fetchTools(name) + connection.server.resources = await this.fetchResources(name) + + await this.notifyWebviewOfServerChanges() + } catch (error) { + // Update status with error + const connection = this.connections.find((conn) => conn.server.name === name) + if (connection) { + connection.server.status = "disconnected" + connection.server.error = error instanceof Error ? error.message : String(error) + } + await this.notifyWebviewOfServerChanges() + throw error + } + } + + private async fetchTools(serverName: string): Promise { + try { + const response = await this.connections + .find((conn) => conn.server.name === serverName) + ?.client.request({ method: "tools/list" }, ListToolsResultSchema) + return response?.tools || [] + } catch (error) { + console.error(`Failed to fetch tools for ${serverName}:`, error) + return [] + } + } + + private async fetchResources(serverName: string): Promise { + try { + const response = await this.connections + .find((conn) => conn.server.name === serverName) + ?.client.request({ method: "resources/list" }, ListResourcesResultSchema) + return response?.resources || [] + } catch (error) { + console.error(`Failed to fetch resources for ${serverName}:`, error) + return [] + } + } + + async deleteConnection(name: string): Promise { + const connection = this.connections.find((conn) => conn.server.name === name) + if (connection) { + try { + await connection.transport.close() + await connection.client.close() + } catch (error) { + console.error(`Failed to close transport for ${name}:`, error) + } + this.connections = this.connections.filter((conn) => conn.server.name !== name) + await this.notifyWebviewOfServerChanges() + } + } + + async updateServerConnections(newServers: Record): Promise { + this.isConnecting = true + const currentNames = new Set(this.connections.map((conn) => conn.server.name)) + const newNames = new Set(Object.keys(newServers)) + + // Delete removed servers + for (const name of currentNames) { + if (!newNames.has(name)) { + await this.deleteConnection(name) + console.log(`Deleted MCP server: ${name}`) + } + } + + // Update or add servers + for (const [name, config] of Object.entries(newServers)) { + const currentConnection = this.connections.find((conn) => conn.server.name === name) + + if (!currentConnection) { + // New server - connect + try { + await this.connectToServer(name, config) + } catch (error) { + console.error(`Failed to connect to new MCP server ${name}:`, error) + } + } else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) { + // Existing server with changed config - reconnect + try { + await this.deleteConnection(name) + await this.connectToServer(name, config) + console.log(`Reconnected MCP server with updated config: ${name}`) + } catch (error) { + console.error(`Failed to reconnect MCP server ${name}:`, error) + } + } + // If server exists with same config, do nothing + } + this.isConnecting = false + } + + async retryConnection(serverName: string): Promise { + this.isConnecting = true + const provider = this.providerRef.deref() + if (!provider) { + return + } + + // Get existing connection and update its status + const connection = this.connections.find((conn) => conn.server.name === serverName) + const config = connection?.server.config + if (config) { + // Try to connect again using existing config + await this.connectToServer(serverName, JSON.parse(config)) + } + + await this.notifyWebviewOfServerChanges() + this.isConnecting = false + } + + private async notifyWebviewOfServerChanges(): Promise { + await this.providerRef.deref()?.postMessageToWebview({ + type: "mcpServers", + mcpServers: this.connections.map((connection) => connection.server), + }) + } + + async dispose(): Promise { + for (const connection of this.connections) { + try { + await this.deleteConnection(connection.server.name) + } catch (error) { + console.error(`Failed to close connection for ${connection.server.name}:`, error) + } + } + this.connections = [] + if (this.settingsWatcher) { + this.settingsWatcher.dispose() + } + this.disposables.forEach((d) => d.dispose()) + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index e682e6d..51cb0a6 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -2,6 +2,7 @@ import { ApiConfiguration, ModelInfo } from "./api" import { HistoryItem } from "./HistoryItem" +import { McpServer } from "./mcp" // webview will hold state export interface ExtensionMessage { @@ -16,6 +17,7 @@ export interface ExtensionMessage { | "invoke" | "partialMessage" | "openRouterModels" + | "mcpServers" text?: string action?: | "chatButtonClicked" @@ -31,6 +33,7 @@ export interface ExtensionMessage { filePaths?: string[] partialMessage?: ClineMessage openRouterModels?: Record + mcpServers?: McpServer[] } export interface ExtensionState { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 203ef13..cfd7de0 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -23,6 +23,8 @@ export interface WebviewMessage { | "openMention" | "cancelTask" | "refreshOpenRouterModels" + | "openMcpSettings" + | "retryMcpServer" text?: string askResponse?: ClineAskResponse apiConfiguration?: ApiConfiguration diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts new file mode 100644 index 0000000..66eede2 --- /dev/null +++ b/src/shared/mcp.ts @@ -0,0 +1,21 @@ +export type McpServer = { + name: string + config: string + status: "connected" | "connecting" | "disconnected" + error?: string + tools?: McpTool[] + resources?: McpResource[] +} + +export type McpTool = { + name: string + description?: string + inputSchema?: object +} + +export type McpResource = { + uri: string + name: string + mimeType?: string + description?: string +} diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index b923fe7..8da3650 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -1,119 +1,75 @@ -import { - VSCodeButton, - VSCodeDivider, - VSCodeTextArea, - VSCodeTextField, - VSCodeTag, - VSCodePanelTab, - VSCodePanelView, - VSCodeDataGrid, - VSCodeDataGridRow, - VSCodeDataGridCell, - VSCodePanels, -} from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton, VSCodePanels, VSCodePanelTab, VSCodePanelView } from "@vscode/webview-ui-toolkit/react" import { useState } from "react" - -type McpServer = { - name: string - config: string // JSON config - status: "connected" | "connecting" | "disconnected" - error?: string - tools?: any[] // We'll type this properly later - resources?: any[] // We'll type this properly later -} +import { vscode } from "../../utils/vscode" +import { useExtensionState } from "../../context/ExtensionStateContext" +import { McpServer } from "../../../../src/shared/mcp" type McpViewProps = { onDone: () => void } const McpView = ({ onDone }: McpViewProps) => { - const [isAdding, setIsAdding] = useState(false) - const [servers, setServers] = useState([ - // Add some mock servers for testing - { - name: "local-tools", - config: JSON.stringify({ - mcpServers: { - "local-tools": { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-tools"], - }, - }, - }), - status: "connected", - tools: [ - { - name: "execute_command", - description: "Run a shell command on the local system", - }, - { - name: "read_file", - description: "Read contents of a file from the filesystem", - }, - ], - }, - { - name: "postgres-db", - config: JSON.stringify({ - mcpServers: { - "postgres-db": { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"], - }, - }, - }), - status: "disconnected", - error: "Failed to connect to database: Connection refused", - }, - { - name: "github-tools", - config: JSON.stringify({ - mcpServers: { - "github-tools": { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-github"], - }, - }, - }), - status: "connecting", - resources: [ - { - uri: "github://repo/issues", - name: "Repository Issues", - }, - { - uri: "github://repo/pulls", - name: "Pull Requests", - }, - ], - }, - ]) - const [configInput, setConfigInput] = useState("") - - const handleAddServer = () => { - try { - const config = JSON.parse(configInput) - const serverName = Object.keys(config.mcpServers)[0] - - setServers((prev) => [ - ...prev, - { - name: serverName, - config: configInput, - status: "connecting", - }, - ]) - - setIsAdding(false) - setConfigInput("") - - // Here you would trigger the actual server connection - // and update its status/tools/resources accordingly - } catch (e) { - // Handle invalid JSON - console.error("Invalid server configuration:", e) - } - } + const { mcpServers: servers } = useExtensionState() + // const [servers, setServers] = useState([ + // // Add some mock servers for testing + // { + // name: "local-tools", + // config: JSON.stringify({ + // mcpServers: { + // "local-tools": { + // command: "npx", + // args: ["-y", "@modelcontextprotocol/server-tools"], + // }, + // }, + // }), + // status: "connected", + // tools: [ + // { + // name: "execute_command", + // description: "Run a shell command on the local system", + // }, + // { + // name: "read_file", + // description: "Read contents of a file from the filesystem", + // }, + // ], + // }, + // { + // name: "postgres-db", + // config: JSON.stringify({ + // mcpServers: { + // "postgres-db": { + // command: "npx", + // args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"], + // }, + // }, + // }), + // status: "disconnected", + // error: "Failed to connect to database: Connection refused", + // }, + // { + // name: "github-tools", + // config: JSON.stringify({ + // mcpServers: { + // "github-tools": { + // command: "npx", + // args: ["-y", "@modelcontextprotocol/server-github"], + // }, + // }, + // }), + // status: "connecting", + // resources: [ + // { + // uri: "github://repo/issues", + // name: "Repository Issues", + // }, + // { + // uri: "github://repo/pulls", + // name: "Pull Requests", + // }, + // ], + // }, + // ]) return (
{ display: "flex", flexDirection: "column", }}> - {/* Fixed Header */}
{ Done
- {/* Scrollable Content */}

MCP (Model Context Protocol) enables AI models to access external tools and data through - standardized interfaces. Add MCP servers to extend Claude's capabilities with custom functionality + standardized interfaces. These MCP servers extend Claude's capabilities with custom functionality and real-time data access.

@@ -151,51 +105,22 @@ const McpView = ({ onDone }: McpViewProps) => { {servers.map((server) => ( ))} - - {/* Add Server UI as a row */} - {isAdding ? ( -
- New MCP Server -

- Enter the MCP server configuration in JSON format. You can find this configuration in - the setup instructions for your MCP server. The config defines how to start and connect - to the server, typically specifying a command and arguments. -

- setConfigInput((e.target as HTMLTextAreaElement).value)} - /> -
- - Save - - setIsAdding(false)}> - Cancel - -
-
- ) : ( - setIsAdding(true)}> - - Add MCP Server - - )}
- {/* Add bottom padding for scrolling */} + {/* Edit Settings Button */} +
+ { + vscode.postMessage({ type: "openMcpSettings" }) + }}> + + Edit MCP Settings + +
+ + {/* Bottom padding */}
@@ -205,8 +130,6 @@ const McpView = ({ onDone }: McpViewProps) => { // Server Row Component const ServerRow = ({ server }: { server: McpServer }) => { const [isExpanded, setIsExpanded] = useState(false) - const [isEditing, setIsEditing] = useState(false) - const [editConfig, setEditConfig] = useState(server.config) const getStatusColor = () => { switch (server.status) { @@ -219,23 +142,19 @@ const ServerRow = ({ server }: { server: McpServer }) => { } } - const handleSaveConfig = () => { - try { - JSON.parse(editConfig) // Validate JSON - // Here you would update the server config - setIsEditing(false) - } catch (e) { - console.error("Invalid JSON config:", e) - } - } - - // Don't allow expansion if server has error const handleRowClick = () => { if (!server.error) { setIsExpanded(!isExpanded) } } + const handleRetry = () => { + vscode.postMessage({ + type: "retryMcpServer", + text: server.name, + }) + } + return (
{ style={{ padding: "8px", fontSize: "13px", - color: "var(--vscode-testing-iconFailed)", background: "var(--vscode-list-hoverBackground)", borderRadius: "0 0 4px 4px", }}> - {server.error} +
{server.error}
+ + + Retry Connection +
) : ( isExpanded && (
@@ -361,47 +283,6 @@ const ServerRow = ({ server }: { server: McpServer }) => { )} - - {/* Edit/Remove Buttons */} -
- {isEditing ? ( - <> - setEditConfig((e.target as HTMLTextAreaElement).value)} - style={{ width: "100%" }} - /> -
- - Save - - setIsEditing(false)} - style={{ flex: 1 }}> - Cancel - -
- - ) : ( -
- setIsEditing(true)} - style={{ flex: 1 }}> - Edit - - - Remove - -
- )} -
) )} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index e60a265..c164bf6 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -10,12 +10,14 @@ import { import { vscode } from "../utils/vscode" import { convertTextMateToHljs } from "../utils/textMateToHljs" import { findLastIndex } from "../../../src/shared/array" +import { McpServer } from "../../../src/shared/mcp" interface ExtensionStateContextType extends ExtensionState { didHydrateState: boolean showWelcome: boolean theme: any openRouterModels: Record + mcpServers: McpServer[] filePaths: string[] setApiConfiguration: (config: ApiConfiguration) => void setCustomInstructions: (value?: string) => void @@ -39,6 +41,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [openRouterModels, setOpenRouterModels] = useState>({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, }) + const [mcpServers, setMcpServers] = useState([]) const handleMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -95,6 +98,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }) break } + case "mcpServers": { + setMcpServers(message.mcpServers ?? []) + break + } } }, []) @@ -110,6 +117,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode showWelcome, theme, openRouterModels, + mcpServers, filePaths, setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })), setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })),