Compare commits

...
7 changed files with 243 additions and 250 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
adding support for streamable mcp server
+15
View File
@@ -64,6 +64,7 @@
"posthog-node": "^4.8.1",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"reconnecting-eventsource": "^1.6.4",
"serialize-error": "^11.0.3",
"simple-git": "^3.27.0",
"strip-ansi": "^7.1.0",
@@ -23228,6 +23229,15 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/reconnecting-eventsource": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/reconnecting-eventsource/-/reconnecting-eventsource-1.6.4.tgz",
"integrity": "sha512-0L3IS3wxcNFApTPPHkcbY8Aya7XZIpYDzhxa8j6QSufVkUN018XJKfh2ZaThLBGP/iN5UTz2yweMhkqr0PKa7A==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/refractor": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-4.9.0.tgz",
@@ -43577,6 +43587,11 @@
"vfile": "^6.0.0"
}
},
"reconnecting-eventsource": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/reconnecting-eventsource/-/reconnecting-eventsource-1.6.4.tgz",
"integrity": "sha512-0L3IS3wxcNFApTPPHkcbY8Aya7XZIpYDzhxa8j6QSufVkUN018XJKfh2ZaThLBGP/iN5UTz2yweMhkqr0PKa7A=="
},
"refractor": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-4.9.0.tgz",
+1
View File
@@ -395,6 +395,7 @@
"posthog-node": "^4.8.1",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"reconnecting-eventsource": "^1.6.4",
"serialize-error": "^11.0.3",
"simple-git": "^3.27.0",
"strip-ansi": "^7.1.0",
+122 -250
View File
@@ -1,5 +1,8 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import ReconnectingEventSource from "reconnecting-eventsource"
import {
CallToolResultSchema,
ListResourcesResultSchema,
@@ -29,61 +32,10 @@ import { fileExistsAtPath } from "@utils/fs"
import { arePathsEqual } from "@utils/path"
import { secondsToMs } from "@utils/time"
import { GlobalFileNames } from "@core/storage/disk"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { ExtensionMessage } from "@shared/ExtensionMessage"
// Default timeout for internal MCP data requests in milliseconds; is not the same as the user facing timeout stored as DEFAULT_MCP_TIMEOUT_SECONDS
const DEFAULT_REQUEST_TIMEOUT_MS = 5000
export type McpConnection = {
server: McpServer
client: Client
transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
}
export type McpTransportType = "stdio" | "sse" | "http"
export type McpServerConfig = z.infer<typeof ServerConfigSchema>
const AutoApproveSchema = z.array(z.string()).default([])
const BaseConfigSchema = z.object({
autoApprove: AutoApproveSchema.optional(),
disabled: z.boolean().optional(),
timeout: z.number().min(MIN_MCP_TIMEOUT_SECONDS).optional().default(DEFAULT_MCP_TIMEOUT_SECONDS),
})
const SseConfigSchema = BaseConfigSchema.extend({
url: z.string().url(),
headers: z.record(z.string()).optional(), // headers of POST requests to the sse server
}).transform((config) => ({
...config,
transportType: "sse" as const,
}))
const StdioConfigSchema = BaseConfigSchema.extend({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
}).transform((config) => ({
...config,
transportType: "stdio" as const,
}))
const StreamableHTTPConfigSchema = BaseConfigSchema.extend({
transportType: z.literal("http"),
url: z.string().url(),
}).transform((config) => ({
...config,
transportType: "http" as const,
}))
const ServerConfigSchema = z.union([StdioConfigSchema, SseConfigSchema, StreamableHTTPConfigSchema])
const McpSettingsSchema = z.object({
mcpServers: z.record(ServerConfigSchema),
})
import { DEFAULT_REQUEST_TIMEOUT_MS } from "./constants"
import { Transport, McpConnection, McpTransportType, McpServerConfig } from "./types"
import { BaseConfigSchema, ServerConfigSchema, McpSettingsSchema } from "./schemas"
export class McpHub {
getMcpServersPath: () => Promise<string>
@@ -191,125 +143,14 @@ export class McpHub {
}
}
private async connectToServerRPC(
name: string,
config: z.infer<typeof StdioConfigSchema> | z.infer<typeof SseConfigSchema> | z.infer<typeof StreamableHTTPConfigSchema>,
): Promise<void> {
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
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.clientVersion,
},
{
capabilities: {},
},
)
let transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
if (config.transportType === "sse") {
transport = new SSEClientTransport(new URL(config.url), {})
} else if (config.transportType === "http") {
transport = new StreamableHTTPClientTransport(new URL(config.url), {})
} else {
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 } : {}),
},
stderr: "pipe", // necessary for stderr to be available
})
}
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error.message)
}
}
transport.onclose = async () => {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
}
}
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "connecting",
disabled: config.disabled,
},
client,
transport,
}
this.connections.push(connection)
if (config.transportType === "stdio") {
// transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process.
// As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again.
await transport.start()
const stderrStream = (transport as StdioClientTransport).stderr
if (stderrStream) {
stderrStream.on("data", async (data: Buffer) => {
const output = data.toString()
// Check if output contains INFO level log
const isInfoLog = !/\berror\b/i.test(output)
if (isInfoLog) {
// Log normal informational messages
console.info(`Server "${name}" info:`, output)
} else {
// Treat as error log
console.error(`Server "${name}" stderr:`, output)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
this.appendErrorMessage(connection, output)
}
}
})
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {} // No-op now, .connect() won't fail
}
// Connect
await client.connect(transport)
connection.server.status = "connected"
connection.server.error = ""
// Initial fetch of tools and resources
connection.server.tools = await this.fetchToolsList(name)
connection.server.resources = await this.fetchResourcesList(name)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
} catch (error) {
// Update status with error
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error))
}
throw error
}
private findConnection(name: string, source: "rpc" | "internal"): McpConnection | undefined {
return this.connections.find((conn) => conn.server.name === name)
}
private async connectToServer(
name: string,
config: z.infer<typeof StdioConfigSchema> | z.infer<typeof SseConfigSchema> | z.infer<typeof StreamableHTTPConfigSchema>,
config: z.infer<typeof ServerConfigSchema>,
source: "rpc" | "internal",
): Promise<void> {
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
this.connections = this.connections.filter((conn) => conn.server.name !== name)
@@ -328,46 +169,110 @@ export class McpHub {
let transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
if (config.transportType === "sse") {
// Set headers of POST requests to the sse server
const postRequestInit = {
headers: config.headers,
}
switch (config.type) {
case "stdio": {
transport = new StdioClientTransport({
command: config.command,
args: config.args,
cwd: config.cwd,
env: {
// ...(config.env ? await injectEnv(config.env) : {}), // Commented out as injectEnv is not found
...(config.env || {}), // Use config.env directly or an empty object
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
},
stderr: "pipe",
})
transport = new SSEClientTransport(new URL(config.url), {
requestInit: postRequestInit,
})
} else if (config.transportType === "http") {
transport = new StreamableHTTPClientTransport(new URL(config.url), {})
} else {
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 } : {}),
},
stderr: "pipe", // necessary for stderr to be available
})
}
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
}
await this.notifyWebviewOfServerChanges()
}
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error.message)
}
await this.notifyWebviewOfServerChanges()
}
transport.onclose = async () => {
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
}
await this.notifyWebviewOfServerChanges()
}
transport.onclose = async () => {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
await transport.start()
const stderrStream = transport.stderr
if (stderrStream) {
stderrStream.on("data", async (data: Buffer) => {
const output = data.toString()
const isInfoLog = /INFO/i.test(output)
if (isInfoLog) {
console.log(`Server "${name}" info:`, output)
} else {
console.error(`Server "${name}" stderr:`, output)
const connection = this.findConnection(name, source)
if (connection) {
this.appendErrorMessage(connection, output)
if (connection.server.status === "disconnected") {
await this.notifyWebviewOfServerChanges()
}
}
}
})
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {}
break
}
await this.notifyWebviewOfServerChanges()
case "sse": {
const sseOptions = {
requestInit: {
headers: config.headers,
},
}
const reconnectingEventSourceOptions = {
max_retry_time: 5000,
withCredentials: config.headers?.["Authorization"] ? true : false,
}
global.EventSource = ReconnectingEventSource
transport = new SSEClientTransport(new URL(config.url), {
...sseOptions,
eventSourceInit: reconnectingEventSourceOptions,
})
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
}
await this.notifyWebviewOfServerChanges()
}
break
}
case "streamableHttp": {
transport = new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: {
headers: config.headers,
},
})
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
}
await this.notifyWebviewOfServerChanges()
}
break
}
default:
throw new Error(`Unknown transport type: ${(config as any).type}`)
}
const connection: McpConnection = {
@@ -382,39 +287,6 @@ export class McpHub {
}
this.connections.push(connection)
if (config.transportType === "stdio") {
// transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process.
// As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again.
await transport.start()
const stderrStream = (transport as StdioClientTransport).stderr
if (stderrStream) {
stderrStream.on("data", async (data: Buffer) => {
const output = data.toString()
// Check if output contains INFO level log
const isInfoLog = !/\berror\b/i.test(output)
if (isInfoLog) {
// Log normal informational messages
console.info(`Server "${name}" info:`, output)
} else {
// Treat as error log
console.error(`Server "${name}" stderr:`, output)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
this.appendErrorMessage(connection, output)
// Only notify webview if server is already disconnected
if (connection.server.status === "disconnected") {
await this.notifyWebviewOfServerChanges()
}
}
}
})
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {} // No-op now, .connect() won't fail
}
// Connect
await client.connect(transport)
@@ -427,7 +299,7 @@ export class McpHub {
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
} catch (error) {
// Update status with error
const connection = this.connections.find((conn) => conn.server.name === name)
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error))
@@ -533,21 +405,21 @@ export class McpHub {
if (!currentConnection) {
// New server
try {
if (config.transportType === "stdio") {
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
await this.connectToServer(name, config)
await this.connectToServer(name, config, "rpc")
} 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
try {
if (config.transportType === "stdio") {
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
await this.deleteConnection(name)
await this.connectToServer(name, config)
await this.connectToServer(name, config, "rpc")
console.log(`Reconnected MCP server with updated config: ${name}`)
} catch (error) {
console.error(`Failed to reconnect MCP server ${name}:`, error)
@@ -580,21 +452,21 @@ export class McpHub {
if (!currentConnection) {
// New server
try {
if (config.transportType === "stdio") {
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
await this.connectToServer(name, config)
await this.connectToServer(name, config, "internal")
} 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
try {
if (config.transportType === "stdio") {
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
await this.deleteConnection(name)
await this.connectToServer(name, config)
await this.connectToServer(name, config, "internal")
console.log(`Reconnected MCP server with updated config: ${name}`)
} catch (error) {
console.error(`Failed to reconnect MCP server ${name}:`, error)
@@ -606,7 +478,7 @@ export class McpHub {
this.isConnecting = false
}
private setupFileWatcher(name: string, config: Extract<McpServerConfig, { transportType: "stdio" }>) {
private setupFileWatcher(name: string, config: Extract<McpServerConfig, { type: "stdio" }>) {
const filePath = config.args?.find((arg: string) => arg.includes("build/index.js"))
if (filePath) {
// we use chokidar instead of onDidSaveTextDocument because it doesn't require the file to be open in the editor. The settings config is better suited for onDidSave since that will be manually updated by the user or Cline (and we want to detect save events, not every file change)
@@ -643,7 +515,7 @@ export class McpHub {
try {
await this.deleteConnection(serverName)
// Try to connect again using existing config
await this.connectToServerRPC(serverName, JSON.parse(inMemoryConfig))
await this.connectToServer(serverName, JSON.parse(inMemoryConfig), "rpc")
} catch (error) {
console.error(`Failed to restart connection for ${serverName}:`, error)
}
@@ -675,7 +547,7 @@ export class McpHub {
try {
await this.deleteConnection(serverName)
// Try to connect again using existing config
await this.connectToServer(serverName, JSON.parse(config))
await this.connectToServer(serverName, JSON.parse(config), "internal")
vscode.window.showInformationMessage(`${serverName} MCP server connected`)
} catch (error) {
console.error(`Failed to restart connection for ${serverName}:`, error)
+10
View File
@@ -0,0 +1,10 @@
/**
* Default timeout for internal MCP data requests in milliseconds.
* This is not the same as the user facing timeout stored as DEFAULT_MCP_TIMEOUT_SECONDS.
*/
export const DEFAULT_REQUEST_TIMEOUT_MS = 5000
/**
* Custom error message for better user feedback when server type validation fails.
*/
export const TYPE_ERROR_MESSAGE = "Server type must be one of: 'stdio', 'sse', or 'streamableHttp'"
+71
View File
@@ -0,0 +1,71 @@
import { z } from "zod"
import { DEFAULT_MCP_TIMEOUT_SECONDS, MIN_MCP_TIMEOUT_SECONDS } from "@shared/mcp"
import { TYPE_ERROR_MESSAGE } from "./constants"
export const AutoApproveSchema = z.array(z.string()).default([])
export const BaseConfigSchema = z.object({
autoApprove: AutoApproveSchema.optional(),
disabled: z.boolean().optional(),
timeout: z.number().min(MIN_MCP_TIMEOUT_SECONDS).optional().default(DEFAULT_MCP_TIMEOUT_SECONDS),
})
// Helper function to create a refined schema with better error messages
const createServerTypeSchema = () => {
return z.union([
// Stdio config (has command field)
BaseConfigSchema.extend({
type: z.literal("stdio").optional(),
command: z.string(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
env: z.record(z.string()).optional(),
// Explicitly disallow other types' fields
url: z.undefined().optional(),
headers: z.undefined().optional(),
})
.transform((data) => ({
...data,
type: "stdio" as const,
}))
.refine((data) => data.type === undefined || data.type === "stdio", { message: TYPE_ERROR_MESSAGE }),
// SSE config (has url field)
BaseConfigSchema.extend({
type: z.literal("sse").optional(),
url: z.string().url("URL must be a valid URL format"),
headers: z.record(z.string()).optional(),
// Explicitly disallow other types' fields
command: z.undefined().optional(),
args: z.undefined().optional(),
env: z.undefined().optional(),
})
.transform((data) => ({
...data,
type: "sse" as const,
}))
.refine((data) => data.type === undefined || data.type === "sse", { message: TYPE_ERROR_MESSAGE }),
// Streamable HTTP config (has url field)
BaseConfigSchema.extend({
type: z.literal("streamableHttp").optional(),
url: z.string().url("URL must be a valid URL format"),
headers: z.record(z.string()).optional(),
// Explicitly disallow other types' fields
command: z.undefined().optional(),
args: z.undefined().optional(),
env: z.undefined().optional(),
})
.transform((data) => ({
...data,
type: "streamableHttp" as const,
}))
.refine((data) => data.type === undefined || data.type === "streamableHttp", {
message: TYPE_ERROR_MESSAGE,
}),
])
}
export const ServerConfigSchema = createServerTypeSchema()
export const McpSettingsSchema = z.object({
mcpServers: z.record(ServerConfigSchema),
})
+19
View File
@@ -0,0 +1,19 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { z } from "zod"
import { McpServer } from "@shared/mcp"
import { ServerConfigSchema } from "./schemas"
export type Transport = StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
export type McpConnection = {
server: McpServer
client: Client
transport: Transport
}
export type McpTransportType = "stdio" | "sse" | "http"
export type McpServerConfig = z.infer<typeof ServerConfigSchema>