Skip to content

Commit 7e125f8

Browse files
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
1 parent 7cd89ed commit 7e125f8

File tree

3 files changed

+121
-22
lines changed

3 files changed

+121
-22
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class StreamableHTTPClientTransport {
2+
constructor(url, options = {}) {
3+
this.url = url
4+
this.options = options
5+
this.onerror = null
6+
this.onclose = null
7+
this.connect = jest.fn().mockResolvedValue()
8+
this.close = jest.fn().mockResolvedValue()
9+
this.start = jest.fn().mockResolvedValue()
10+
}
11+
}
12+
13+
module.exports = {
14+
StreamableHTTPClientTransport,
15+
}

src/core/tools/__tests__/writeToFileTool.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,25 @@ describe("writeToFileTool", () => {
141141
finalContent: "final content",
142142
}),
143143
scrollToFirstDiff: jest.fn(),
144+
pushToolWriteResult: jest.fn().mockImplementation(async function (
145+
this: any,
146+
task: any,
147+
cwd: string,
148+
isNewFile: boolean,
149+
) {
150+
// Simulate the behavior of pushToolWriteResult
151+
if (this.userEdits) {
152+
await task.say(
153+
"user_feedback_diff",
154+
JSON.stringify({
155+
tool: isNewFile ? "newFileCreated" : "editedExistingFile",
156+
path: "test/path.txt",
157+
diff: this.userEdits,
158+
}),
159+
)
160+
}
161+
return "Tool result message"
162+
}),
144163
}
145164
mockCline.api = {
146165
getModel: jest.fn().mockReturnValue({ id: "claude-3" }),
@@ -343,11 +362,14 @@ describe("writeToFileTool", () => {
343362
})
344363

345364
it("reports user edits with diff feedback", async () => {
365+
const userEditsValue = "- old line\n+ new line"
346366
mockCline.diffViewProvider.saveChanges.mockResolvedValue({
347367
newProblemsMessage: " with warnings",
348-
userEdits: "- old line\n+ new line",
368+
userEdits: userEditsValue,
349369
finalContent: "modified content",
350370
})
371+
// Manually set the property on the mock instance because the original saveChanges is not called
372+
mockCline.diffViewProvider.userEdits = userEditsValue
351373

352374
await executeWriteFileTool({}, { fileExists: true })
353375

src/services/mcp/McpHub.ts

Lines changed: 83 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
22
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
33
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
4+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
45
import ReconnectingEventSource from "reconnecting-eventsource"
56
import {
67
CallToolResultSchema,
@@ -35,7 +36,7 @@ import { injectEnv } from "../../utils/config"
3536
export type McpConnection = {
3637
server: McpServer
3738
client: Client
38-
transport: StdioClientTransport | SSEClientTransport
39+
transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
3940
}
4041

4142
// Base configuration schema for common settings
@@ -47,14 +48,17 @@ const BaseConfigSchema = z.object({
4748
})
4849

4950
// Custom error messages for better user feedback
50-
const typeErrorMessage = "Server type must be either 'stdio' or 'sse'"
51+
const typeErrorMessage = "Server type must be 'stdio', 'sse', or 'streamable-http'"
5152
const stdioFieldsErrorMessage =
5253
"For 'stdio' type servers, you must provide a 'command' field and can optionally include 'args' and 'env'"
5354
const sseFieldsErrorMessage =
5455
"For 'sse' type servers, you must provide a 'url' field and can optionally include 'headers'"
56+
const streamableHttpFieldsErrorMessage =
57+
"For 'streamable-http' type servers, you must provide a 'url' field and can optionally include 'headers'"
5558
const mixedFieldsErrorMessage =
56-
"Cannot mix 'stdio' and 'sse' fields. For 'stdio' use 'command', 'args', and 'env'. For 'sse' use 'url' and 'headers'"
57-
const missingFieldsErrorMessage = "Server configuration must include either 'command' (for stdio) or 'url' (for sse)"
59+
"Cannot mix 'stdio' and ('sse' or 'streamable-http') fields. For 'stdio' use 'command', 'args', and 'env'. For 'sse'/'streamable-http' use 'url' and 'headers'"
60+
const missingFieldsErrorMessage =
61+
"Server configuration must include either 'command' (for stdio) or 'url' (for sse/streamable-http) and a corresponding 'type' if 'url' is used."
5862

5963
// Helper function to create a refined schema with better error messages
6064
const createServerTypeSchema = () => {
@@ -90,6 +94,23 @@ const createServerTypeSchema = () => {
9094
type: "sse" as const,
9195
}))
9296
.refine((data) => data.type === undefined || data.type === "sse", { message: typeErrorMessage }),
97+
// StreamableHTTP config (has url field)
98+
BaseConfigSchema.extend({
99+
type: z.enum(["streamable-http"]).optional(),
100+
url: z.string().url("URL must be a valid URL format"),
101+
headers: z.record(z.string()).optional(),
102+
// Ensure no stdio fields are present
103+
command: z.undefined().optional(),
104+
args: z.undefined().optional(),
105+
env: z.undefined().optional(),
106+
})
107+
.transform((data) => ({
108+
...data,
109+
type: "streamable-http" as const,
110+
}))
111+
.refine((data) => data.type === undefined || data.type === "streamable-http", {
112+
message: typeErrorMessage,
113+
}),
93114
])
94115
}
95116

@@ -152,33 +173,43 @@ export class McpHub {
152173
private validateServerConfig(config: any, serverName?: string): z.infer<typeof ServerConfigSchema> {
153174
// Detect configuration issues before validation
154175
const hasStdioFields = config.command !== undefined
155-
const hasSseFields = config.url !== undefined
176+
const hasUrlFields = config.url !== undefined // Covers sse and streamable-http
156177

157-
// Check for mixed fields
158-
if (hasStdioFields && hasSseFields) {
178+
// Check for mixed fields (stdio vs url-based)
179+
if (hasStdioFields && hasUrlFields) {
159180
throw new Error(mixedFieldsErrorMessage)
160181
}
161182

162-
// Check if it's a stdio or SSE config and add type if missing
163-
if (!config.type) {
164-
if (hasStdioFields) {
165-
config.type = "stdio"
166-
} else if (hasSseFields) {
167-
config.type = "sse"
168-
} else {
169-
throw new Error(missingFieldsErrorMessage)
170-
}
171-
} else if (config.type !== "stdio" && config.type !== "sse") {
183+
// Infer type for stdio if not provided
184+
if (!config.type && hasStdioFields) {
185+
config.type = "stdio"
186+
}
187+
188+
// For url-based configs, type must be provided by the user
189+
if (hasUrlFields && !config.type) {
190+
throw new Error("Configuration with 'url' must explicitly specify 'type' as 'sse' or 'streamable-http'.")
191+
}
192+
193+
// Validate type if provided
194+
if (config.type && !["stdio", "sse", "streamable-http"].includes(config.type)) {
172195
throw new Error(typeErrorMessage)
173196
}
174197

175198
// Check for type/field mismatch
176199
if (config.type === "stdio" && !hasStdioFields) {
177200
throw new Error(stdioFieldsErrorMessage)
178201
}
179-
if (config.type === "sse" && !hasSseFields) {
202+
if (config.type === "sse" && !hasUrlFields) {
180203
throw new Error(sseFieldsErrorMessage)
181204
}
205+
if (config.type === "streamable-http" && !hasUrlFields) {
206+
throw new Error(streamableHttpFieldsErrorMessage)
207+
}
208+
209+
// If neither command nor url is present (type alone is not enough)
210+
if (!hasStdioFields && !hasUrlFields) {
211+
throw new Error(missingFieldsErrorMessage)
212+
}
182213

183214
// Validate the config against the schema
184215
try {
@@ -441,7 +472,7 @@ export class McpHub {
441472
},
442473
)
443474

444-
let transport: StdioClientTransport | SSEClientTransport
475+
let transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
445476

446477
// Inject environment variables to the config
447478
const configInjected = (await injectEnv(config)) as typeof config
@@ -506,8 +537,33 @@ export class McpHub {
506537
} else {
507538
console.error(`No stderr stream for ${name}`)
508539
}
509-
transport.start = async () => {} // No-op now, .connect() won't fail
510-
} else {
540+
} else if (configInjected.type === "streamable-http") {
541+
// Streamable HTTP connection
542+
transport = new StreamableHTTPClientTransport(new URL(configInjected.url), {
543+
requestInit: {
544+
headers: configInjected.headers,
545+
},
546+
})
547+
548+
// Set up Streamable HTTP specific error handling
549+
transport.onerror = async (error) => {
550+
console.error(`Transport error for "${name}" (streamable-http):`, error)
551+
const connection = this.findConnection(name, source)
552+
if (connection) {
553+
connection.server.status = "disconnected"
554+
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
555+
}
556+
await this.notifyWebviewOfServerChanges()
557+
}
558+
559+
transport.onclose = async () => {
560+
const connection = this.findConnection(name, source)
561+
if (connection) {
562+
connection.server.status = "disconnected"
563+
}
564+
await this.notifyWebviewOfServerChanges()
565+
}
566+
} else if (configInjected.type === "sse") {
511567
// SSE connection
512568
const sseOptions = {
513569
requestInit: {
@@ -542,7 +598,13 @@ export class McpHub {
542598
}
543599
await this.notifyWebviewOfServerChanges()
544600
}
601+
} else {
602+
// Correctly placed "unsupported type" else block
603+
// Should not happen if validateServerConfig is correct
604+
throw new Error(`Unsupported MCP server type: ${(configInjected as any).type}`)
545605
}
606+
// transport.start assignment moved after all type-specific initializations
607+
transport.start = async () => {} // No-op now, .connect() won't fail
546608

547609
const connection: McpConnection = {
548610
server: {

0 commit comments

Comments
 (0)