feat: Add support for Streamable HTTP Transport MCP servers (#4210)

* Implement support for streamable-http transport type mcp servers

* add streamable-http mock in same fashion as sse - which does not seem to currently be actually leveraged

* rename mock to resolve kebabcase vs camelCase

* fix (seemingly unrelatd) test failure in writeToFileTool.test.ts

* fix tests
This commit is contained in:
Taylor Wilsdon 2025-06-01 21:54:38 -04:00 • committed by GitHub
parent 7cd89edaa4
commit 7e125f86c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 121 additions and 22 deletions

View file

@ -0,0 +1,15 @@
class StreamableHTTPClientTransport {
constructor(url, options = {}) {
this.url = url
this.options = options
this.onerror = null
this.onclose = null
this.connect = jest.fn().mockResolvedValue()
this.close = jest.fn().mockResolvedValue()
this.start = jest.fn().mockResolvedValue()
}
}
module.exports = {
StreamableHTTPClientTransport,
}

View file

@ -141,6 +141,25 @@ describe("writeToFileTool", () => {
finalContent: "final content",
}),
scrollToFirstDiff: jest.fn(),
pushToolWriteResult: jest.fn().mockImplementation(async function (
this: any,
task: any,
cwd: string,
isNewFile: boolean,
) {
// Simulate the behavior of pushToolWriteResult
if (this.userEdits) {
await task.say(
"user_feedback_diff",
JSON.stringify({
tool: isNewFile ? "newFileCreated" : "editedExistingFile",
path: "test/path.txt",
diff: this.userEdits,
}),
)
}
return "Tool result message"
}),
}
mockCline.api = {
getModel: jest.fn().mockReturnValue({ id: "claude-3" }),
@ -343,11 +362,14 @@ describe("writeToFileTool", () => {
})
it("reports user edits with diff feedback", async () => {
const userEditsValue = "- old line\n+ new line"
mockCline.diffViewProvider.saveChanges.mockResolvedValue({
newProblemsMessage: " with warnings",
userEdits: "- old line\n+ new line",
userEdits: userEditsValue,
finalContent: "modified content",
})
// Manually set the property on the mock instance because the original saveChanges is not called
mockCline.diffViewProvider.userEdits = userEditsValue
await executeWriteFileTool({}, { fileExists: true })

View file

@ -1,6 +1,7 @@
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,
@ -35,7 +36,7 @@ import { injectEnv } from "../../utils/config"
export type McpConnection = {
server: McpServer
client: Client
transport: StdioClientTransport | SSEClientTransport
transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
}
// Base configuration schema for common settings
@ -47,14 +48,17 @@ const BaseConfigSchema = z.object({
})
// Custom error messages for better user feedback
const typeErrorMessage = "Server type must be either 'stdio' or 'sse'"
const typeErrorMessage = "Server type must be 'stdio', 'sse', or 'streamable-http'"
const stdioFieldsErrorMessage =
"For 'stdio' type servers, you must provide a 'command' field and can optionally include 'args' and 'env'"
const sseFieldsErrorMessage =
"For 'sse' type servers, you must provide a 'url' field and can optionally include 'headers'"
const streamableHttpFieldsErrorMessage =
"For 'streamable-http' type servers, you must provide a 'url' field and can optionally include 'headers'"
const mixedFieldsErrorMessage =
"Cannot mix 'stdio' and 'sse' fields. For 'stdio' use 'command', 'args', and 'env'. For 'sse' use 'url' and 'headers'"
const missingFieldsErrorMessage = "Server configuration must include either 'command' (for stdio) or 'url' (for sse)"
"Cannot mix 'stdio' and ('sse' or 'streamable-http') fields. For 'stdio' use 'command', 'args', and 'env'. For 'sse'/'streamable-http' use 'url' and 'headers'"
const missingFieldsErrorMessage =
"Server configuration must include either 'command' (for stdio) or 'url' (for sse/streamable-http) and a corresponding 'type' if 'url' is used."
// Helper function to create a refined schema with better error messages
const createServerTypeSchema = () => {
@ -90,6 +94,23 @@ const createServerTypeSchema = () => {
type: "sse" as const,
}))
.refine((data) => data.type === undefined || data.type === "sse", { message: typeErrorMessage }),
// StreamableHTTP config (has url field)
BaseConfigSchema.extend({
type: z.enum(["streamable-http"]).optional(),
url: z.string().url("URL must be a valid URL format"),
headers: z.record(z.string()).optional(),
// Ensure no stdio fields are present
command: z.undefined().optional(),
args: z.undefined().optional(),
env: z.undefined().optional(),
})
.transform((data) => ({
...data,
type: "streamable-http" as const,
}))
.refine((data) => data.type === undefined || data.type === "streamable-http", {
message: typeErrorMessage,
}),
])
}
@ -152,23 +173,25 @@ export class McpHub {
private validateServerConfig(config: any, serverName?: string): z.infer<typeof ServerConfigSchema> {
// Detect configuration issues before validation
const hasStdioFields = config.command !== undefined
const hasSseFields = config.url !== undefined
const hasUrlFields = config.url !== undefined // Covers sse and streamable-http
// Check for mixed fields
if (hasStdioFields && hasSseFields) {
// Check for mixed fields (stdio vs url-based)
if (hasStdioFields && hasUrlFields) {
throw new Error(mixedFieldsErrorMessage)
}
// Check if it's a stdio or SSE config and add type if missing
if (!config.type) {
if (hasStdioFields) {
config.type = "stdio"
} else if (hasSseFields) {
config.type = "sse"
} else {
throw new Error(missingFieldsErrorMessage)
}
} else if (config.type !== "stdio" && config.type !== "sse") {
// Infer type for stdio if not provided
if (!config.type && hasStdioFields) {
config.type = "stdio"
}
// For url-based configs, type must be provided by the user
if (hasUrlFields && !config.type) {
throw new Error("Configuration with 'url' must explicitly specify 'type' as 'sse' or 'streamable-http'.")
}
// Validate type if provided
if (config.type && !["stdio", "sse", "streamable-http"].includes(config.type)) {
throw new Error(typeErrorMessage)
}
@ -176,9 +199,17 @@ export class McpHub {
if (config.type === "stdio" && !hasStdioFields) {
throw new Error(stdioFieldsErrorMessage)
}
if (config.type === "sse" && !hasSseFields) {
if (config.type === "sse" && !hasUrlFields) {
throw new Error(sseFieldsErrorMessage)
}
if (config.type === "streamable-http" && !hasUrlFields) {
throw new Error(streamableHttpFieldsErrorMessage)
}
// If neither command nor url is present (type alone is not enough)
if (!hasStdioFields && !hasUrlFields) {
throw new Error(missingFieldsErrorMessage)
}
// Validate the config against the schema
try {
@ -441,7 +472,7 @@ export class McpHub {
},
)
let transport: StdioClientTransport | SSEClientTransport
let transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
// Inject environment variables to the config
const configInjected = (await injectEnv(config)) as typeof config
@ -506,8 +537,33 @@ export class McpHub {
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {} // No-op now, .connect() won't fail
} else {
} else if (configInjected.type === "streamable-http") {
// Streamable HTTP connection
transport = new StreamableHTTPClientTransport(new URL(configInjected.url), {
requestInit: {
headers: configInjected.headers,
},
})
// Set up Streamable HTTP specific error handling
transport.onerror = async (error) => {
console.error(`Transport error for "${name}" (streamable-http):`, 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.onclose = async () => {
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
}
await this.notifyWebviewOfServerChanges()
}
} else if (configInjected.type === "sse") {
// SSE connection
const sseOptions = {
requestInit: {
@ -542,7 +598,13 @@ export class McpHub {
}
await this.notifyWebviewOfServerChanges()
}
} else {
// Correctly placed "unsupported type" else block
// Should not happen if validateServerConfig is correct
throw new Error(`Unsupported MCP server type: ${(configInjected as any).type}`)
}
// transport.start assignment moved after all type-specific initializations
transport.start = async () => {} // No-op now, .connect() won't fail
const connection: McpConnection = {
server: {