Add McpHub and sync with McpView

This commit is contained in:
Saoud Rizwan
2024-12-05 19:00:55 -08:00
parent fa62548b01
commit 17d481d4d1
10 changed files with 577 additions and 215 deletions

106
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@anthropic-ai/sdk": "^0.26.0", "@anthropic-ai/sdk": "^0.26.0",
"@anthropic-ai/vertex-sdk": "^0.4.1", "@anthropic-ai/vertex-sdk": "^0.4.1",
"@google/generative-ai": "^0.18.0", "@google/generative-ai": "^0.18.0",
"@modelcontextprotocol/sdk": "^1.0.1",
"@types/clone-deep": "^4.0.4", "@types/clone-deep": "^4.0.4",
"@types/pdf-parse": "^1.1.4", "@types/pdf-parse": "^1.1.4",
"@types/turndown": "^5.0.5", "@types/turndown": "^5.0.5",
@@ -38,7 +39,8 @@
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11", "tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"web-tree-sitter": "^0.22.6" "web-tree-sitter": "^0.22.6",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/diff": "^5.2.1", "@types/diff": "^5.2.1",
@@ -2777,6 +2779,17 @@
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause" "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "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", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" "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": { "node_modules/c8": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz",
@@ -5667,6 +5689,15 @@
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -5903,6 +5934,15 @@
"node": ">=0.4.0" "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": { "node_modules/devtools-protocol": {
"version": "0.0.1342118", "version": "0.0.1342118",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1342118.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1342118.tgz",
@@ -7366,6 +7406,22 @@
"entities": "^4.5.0" "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": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -9614,6 +9670,21 @@
"safe-buffer": "^5.1.0" "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": { "node_modules/read-pkg": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", "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==", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT" "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": { "node_modules/shallow-clone": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
@@ -10164,6 +10241,15 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause" "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": { "node_modules/stdin-discarder": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz",
@@ -10553,6 +10639,15 @@
"node": ">=8.0" "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": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -10805,6 +10900,15 @@
"node": ">= 4.0.0" "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": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

View File

@@ -166,6 +166,7 @@
"@anthropic-ai/sdk": "^0.26.0", "@anthropic-ai/sdk": "^0.26.0",
"@anthropic-ai/vertex-sdk": "^0.4.1", "@anthropic-ai/vertex-sdk": "^0.4.1",
"@google/generative-ai": "^0.18.0", "@google/generative-ai": "^0.18.0",
"@modelcontextprotocol/sdk": "^1.0.1",
"@types/clone-deep": "^4.0.4", "@types/clone-deep": "^4.0.4",
"@types/pdf-parse": "^1.1.4", "@types/pdf-parse": "^1.1.4",
"@types/turndown": "^5.0.5", "@types/turndown": "^5.0.5",
@@ -191,6 +192,7 @@
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11", "tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"web-tree-sitter": "^0.22.6" "web-tree-sitter": "^0.22.6",
"zod": "^3.23.8"
} }
} }

View File

@@ -751,6 +751,12 @@ export class Cline {
} }
async *attemptApiRequest(previousApiReqIndex: number): ApiStream { 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) let systemPrompt = await SYSTEM_PROMPT(cwd, this.api.getModel().info.supportsComputerUse ?? false)
if (this.customInstructions && this.customInstructions.trim()) { 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 <potentially relevant details> // 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 <potentially relevant details>

View File

@@ -20,6 +20,7 @@ import { Cline } from "../Cline"
import { openMention } from "../mentions" import { openMention } from "../mentions"
import { getNonce } from "./getNonce" import { getNonce } from "./getNonce"
import { getUri } from "./getUri" 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 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", apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json", uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json", openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
} }
export class ClineProvider implements vscode.WebviewViewProvider { export class ClineProvider implements vscode.WebviewViewProvider {
@@ -72,6 +74,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private view?: vscode.WebviewView | vscode.WebviewPanel private view?: vscode.WebviewView | vscode.WebviewPanel
private cline?: Cline private cline?: Cline
private workspaceTracker?: WorkspaceTracker private workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
private latestAnnouncementId = "oct-28-2024" // update to some unique identifier when we add a new announcement private latestAnnouncementId = "oct-28-2024" // update to some unique identifier when we add a new announcement
constructor( constructor(
@@ -81,6 +84,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine("ClineProvider instantiated") this.outputChannel.appendLine("ClineProvider instantiated")
ClineProvider.activeInstances.add(this) ClineProvider.activeInstances.add(this)
this.workspaceTracker = new WorkspaceTracker(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?.dispose()
this.workspaceTracker = undefined this.workspaceTracker = undefined
this.mcpHub?.dispose()
this.mcpHub = undefined
this.outputChannel.appendLine("Disposed all disposables") this.outputChannel.appendLine("Disposed all disposables")
ClineProvider.activeInstances.delete(this) ClineProvider.activeInstances.delete(this)
} }
@@ -485,6 +491,21 @@ export class ClineProvider implements vscode.WebviewViewProvider {
} }
break 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 // Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js) // 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 // await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
} }
private async ensureCacheDirectoryExists(): Promise<string> { async ensureCacheDirectoryExists(): Promise<string> {
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache") const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true }) await fs.mkdir(cacheDir, { recursive: true })
return cacheDir return cacheDir

314
src/services/mcp/McpHub.ts Normal file
View File

@@ -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<ClineProvider>
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<string> {
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<void> {
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<void> {
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<void> {
// 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<McpTool[]> {
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<McpResource[]> {
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<void> {
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<string, any>): Promise<void> {
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<void> {
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<void> {
await this.providerRef.deref()?.postMessageToWebview({
type: "mcpServers",
mcpServers: this.connections.map((connection) => connection.server),
})
}
async dispose(): Promise<void> {
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())
}
}

View File

@@ -2,6 +2,7 @@
import { ApiConfiguration, ModelInfo } from "./api" import { ApiConfiguration, ModelInfo } from "./api"
import { HistoryItem } from "./HistoryItem" import { HistoryItem } from "./HistoryItem"
import { McpServer } from "./mcp"
// webview will hold state // webview will hold state
export interface ExtensionMessage { export interface ExtensionMessage {
@@ -16,6 +17,7 @@ export interface ExtensionMessage {
| "invoke" | "invoke"
| "partialMessage" | "partialMessage"
| "openRouterModels" | "openRouterModels"
| "mcpServers"
text?: string text?: string
action?: action?:
| "chatButtonClicked" | "chatButtonClicked"
@@ -31,6 +33,7 @@ export interface ExtensionMessage {
filePaths?: string[] filePaths?: string[]
partialMessage?: ClineMessage partialMessage?: ClineMessage
openRouterModels?: Record<string, ModelInfo> openRouterModels?: Record<string, ModelInfo>
mcpServers?: McpServer[]
} }
export interface ExtensionState { export interface ExtensionState {

View File

@@ -23,6 +23,8 @@ export interface WebviewMessage {
| "openMention" | "openMention"
| "cancelTask" | "cancelTask"
| "refreshOpenRouterModels" | "refreshOpenRouterModels"
| "openMcpSettings"
| "retryMcpServer"
text?: string text?: string
askResponse?: ClineAskResponse askResponse?: ClineAskResponse
apiConfiguration?: ApiConfiguration apiConfiguration?: ApiConfiguration

21
src/shared/mcp.ts Normal file
View File

@@ -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
}

View File

@@ -1,119 +1,75 @@
import { import { VSCodeButton, VSCodePanels, VSCodePanelTab, VSCodePanelView } from "@vscode/webview-ui-toolkit/react"
VSCodeButton,
VSCodeDivider,
VSCodeTextArea,
VSCodeTextField,
VSCodeTag,
VSCodePanelTab,
VSCodePanelView,
VSCodeDataGrid,
VSCodeDataGridRow,
VSCodeDataGridCell,
VSCodePanels,
} from "@vscode/webview-ui-toolkit/react"
import { useState } from "react" import { useState } from "react"
import { vscode } from "../../utils/vscode"
type McpServer = { import { useExtensionState } from "../../context/ExtensionStateContext"
name: string import { McpServer } from "../../../../src/shared/mcp"
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
}
type McpViewProps = { type McpViewProps = {
onDone: () => void onDone: () => void
} }
const McpView = ({ onDone }: McpViewProps) => { const McpView = ({ onDone }: McpViewProps) => {
const [isAdding, setIsAdding] = useState(false) const { mcpServers: servers } = useExtensionState()
const [servers, setServers] = useState<McpServer[]>([ // const [servers, setServers] = useState<McpServer[]>([
// Add some mock servers for testing // // Add some mock servers for testing
{ // {
name: "local-tools", // name: "local-tools",
config: JSON.stringify({ // config: JSON.stringify({
mcpServers: { // mcpServers: {
"local-tools": { // "local-tools": {
command: "npx", // command: "npx",
args: ["-y", "@modelcontextprotocol/server-tools"], // args: ["-y", "@modelcontextprotocol/server-tools"],
}, // },
}, // },
}), // }),
status: "connected", // status: "connected",
tools: [ // tools: [
{ // {
name: "execute_command", // name: "execute_command",
description: "Run a shell command on the local system", // description: "Run a shell command on the local system",
}, // },
{ // {
name: "read_file", // name: "read_file",
description: "Read contents of a file from the filesystem", // description: "Read contents of a file from the filesystem",
}, // },
], // ],
}, // },
{ // {
name: "postgres-db", // name: "postgres-db",
config: JSON.stringify({ // config: JSON.stringify({
mcpServers: { // mcpServers: {
"postgres-db": { // "postgres-db": {
command: "npx", // command: "npx",
args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"], // args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
}, // },
}, // },
}), // }),
status: "disconnected", // status: "disconnected",
error: "Failed to connect to database: Connection refused", // error: "Failed to connect to database: Connection refused",
}, // },
{ // {
name: "github-tools", // name: "github-tools",
config: JSON.stringify({ // config: JSON.stringify({
mcpServers: { // mcpServers: {
"github-tools": { // "github-tools": {
command: "npx", // command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"], // args: ["-y", "@modelcontextprotocol/server-github"],
}, // },
}, // },
}), // }),
status: "connecting", // status: "connecting",
resources: [ // resources: [
{ // {
uri: "github://repo/issues", // uri: "github://repo/issues",
name: "Repository Issues", // name: "Repository Issues",
}, // },
{ // {
uri: "github://repo/pulls", // uri: "github://repo/pulls",
name: "Pull Requests", // 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)
}
}
return ( return (
<div <div
@@ -126,7 +82,6 @@ const McpView = ({ onDone }: McpViewProps) => {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}}> }}>
{/* Fixed Header */}
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -138,11 +93,10 @@ const McpView = ({ onDone }: McpViewProps) => {
<VSCodeButton onClick={onDone}>Done</VSCodeButton> <VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div> </div>
{/* Scrollable Content */}
<div style={{ flex: 1, overflow: "auto", padding: "0 20px" }}> <div style={{ flex: 1, overflow: "auto", padding: "0 20px" }}>
<p style={{ color: "var(--vscode-foreground)", fontSize: "13px" }}> <p style={{ color: "var(--vscode-foreground)", fontSize: "13px" }}>
MCP (Model Context Protocol) enables AI models to access external tools and data through 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. and real-time data access.
</p> </p>
@@ -151,51 +105,22 @@ const McpView = ({ onDone }: McpViewProps) => {
{servers.map((server) => ( {servers.map((server) => (
<ServerRow key={server.name} server={server} /> <ServerRow key={server.name} server={server} />
))} ))}
</div>
{/* Add Server UI as a row */} {/* Edit Settings Button */}
{isAdding ? ( <div style={{ marginTop: "10px", width: "100%" }}>
<div
style={{
padding: "12px",
background: "var(--vscode-list-hoverBackground)",
borderRadius: "4px",
display: "flex",
flexDirection: "column",
gap: "10px",
}}>
<b style={{ color: "var(--vscode-foreground)" }}>New MCP Server</b>
<p style={{ color: "var(--vscode-descriptionForeground)", fontSize: "13px", margin: "0" }}>
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.
</p>
<VSCodeTextArea
rows={4}
placeholder='{"mcpServers": {"server-name": {"command": "...", "args": [...]}}}'
value={configInput}
onChange={(e) => setConfigInput((e.target as HTMLTextAreaElement).value)}
/>
<div style={{ display: "flex", gap: "10px" }}>
<VSCodeButton style={{ flex: 1 }} onClick={handleAddServer}>
Save
</VSCodeButton>
<VSCodeButton <VSCodeButton
style={{ flex: 1 }}
appearance="secondary" appearance="secondary"
onClick={() => setIsAdding(false)}> style={{ width: "100%" }}
Cancel onClick={() => {
vscode.postMessage({ type: "openMcpSettings" })
}}>
<span className="codicon codicon-edit" style={{ marginRight: "6px" }}></span>
Edit MCP Settings
</VSCodeButton> </VSCodeButton>
</div> </div>
</div>
) : (
<VSCodeButton style={{ width: "100%" }} onClick={() => setIsAdding(true)}>
<span className="codicon codicon-add" style={{ marginRight: "6px" }}></span>
Add MCP Server
</VSCodeButton>
)}
</div>
{/* Add bottom padding for scrolling */} {/* Bottom padding */}
<div style={{ height: "20px" }} /> <div style={{ height: "20px" }} />
</div> </div>
</div> </div>
@@ -205,8 +130,6 @@ const McpView = ({ onDone }: McpViewProps) => {
// Server Row Component // Server Row Component
const ServerRow = ({ server }: { server: McpServer }) => { const ServerRow = ({ server }: { server: McpServer }) => {
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editConfig, setEditConfig] = useState(server.config)
const getStatusColor = () => { const getStatusColor = () => {
switch (server.status) { 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 = () => { const handleRowClick = () => {
if (!server.error) { if (!server.error) {
setIsExpanded(!isExpanded) setIsExpanded(!isExpanded)
} }
} }
const handleRetry = () => {
vscode.postMessage({
type: "retryMcpServer",
text: server.name,
})
}
return ( return (
<div style={{ marginBottom: "10px" }}> <div style={{ marginBottom: "10px" }}>
<div <div
@@ -271,18 +190,21 @@ const ServerRow = ({ server }: { server: McpServer }) => {
style={{ style={{
padding: "8px", padding: "8px",
fontSize: "13px", fontSize: "13px",
color: "var(--vscode-testing-iconFailed)",
background: "var(--vscode-list-hoverBackground)", background: "var(--vscode-list-hoverBackground)",
borderRadius: "0 0 4px 4px", borderRadius: "0 0 4px 4px",
}}> }}>
{server.error} <div style={{ color: "var(--vscode-testing-iconFailed)", marginBottom: "8px" }}>{server.error}</div>
<VSCodeButton appearance="secondary" onClick={handleRetry}>
<span className="codicon codicon-debug-restart" style={{ marginRight: "6px" }}></span>
Retry Connection
</VSCodeButton>
</div> </div>
) : ( ) : (
isExpanded && ( isExpanded && (
<div <div
style={{ style={{
background: "var(--vscode-list-hoverBackground)", background: "var(--vscode-list-hoverBackground)",
padding: "0 12px 12px 12px", padding: "0 12px 0 12px",
fontSize: "13px", fontSize: "13px",
borderRadius: "0 0 4px 4px", borderRadius: "0 0 4px 4px",
}}> }}>
@@ -361,47 +283,6 @@ const ServerRow = ({ server }: { server: McpServer }) => {
)} )}
</VSCodePanelView> </VSCodePanelView>
</VSCodePanels> </VSCodePanels>
{/* Edit/Remove Buttons */}
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "0px" }}>
{isEditing ? (
<>
<VSCodeTextArea
rows={4}
value={editConfig}
onChange={(e) => setEditConfig((e.target as HTMLTextAreaElement).value)}
style={{ width: "100%" }}
/>
<div style={{ display: "flex", gap: "8px" }}>
<VSCodeButton onClick={handleSaveConfig} style={{ flex: 1 }}>
Save
</VSCodeButton>
<VSCodeButton
appearance="secondary"
onClick={() => setIsEditing(false)}
style={{ flex: 1 }}>
Cancel
</VSCodeButton>
</div>
</>
) : (
<div style={{ display: "flex", gap: "8px" }}>
<VSCodeButton
appearance="secondary"
onClick={() => setIsEditing(true)}
style={{ flex: 1 }}>
Edit
</VSCodeButton>
<VSCodeButton
appearance="secondary"
style={{
flex: 1,
}}>
Remove
</VSCodeButton>
</div>
)}
</div>
</div> </div>
) )
)} )}

View File

@@ -10,12 +10,14 @@ import {
import { vscode } from "../utils/vscode" import { vscode } from "../utils/vscode"
import { convertTextMateToHljs } from "../utils/textMateToHljs" import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { findLastIndex } from "../../../src/shared/array" import { findLastIndex } from "../../../src/shared/array"
import { McpServer } from "../../../src/shared/mcp"
interface ExtensionStateContextType extends ExtensionState { interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean didHydrateState: boolean
showWelcome: boolean showWelcome: boolean
theme: any theme: any
openRouterModels: Record<string, ModelInfo> openRouterModels: Record<string, ModelInfo>
mcpServers: McpServer[]
filePaths: string[] filePaths: string[]
setApiConfiguration: (config: ApiConfiguration) => void setApiConfiguration: (config: ApiConfiguration) => void
setCustomInstructions: (value?: string) => void setCustomInstructions: (value?: string) => void
@@ -39,6 +41,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({ const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo, [openRouterDefaultModelId]: openRouterDefaultModelInfo,
}) })
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const handleMessage = useCallback((event: MessageEvent) => { const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data const message: ExtensionMessage = event.data
@@ -95,6 +98,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
}) })
break break
} }
case "mcpServers": {
setMcpServers(message.mcpServers ?? [])
break
}
} }
}, []) }, [])
@@ -110,6 +117,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
showWelcome, showWelcome,
theme, theme,
openRouterModels, openRouterModels,
mcpServers,
filePaths, filePaths,
setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })), setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })),
setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })), setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })),