feat: auto-infer streamable-http type for MCP servers with URL configuration

- Modified validateServerConfig to auto-infer streamable-http when URL is present without explicit type
- Updated ServerConfigSchema to prioritize streamable-http over SSE for URL configs
- SSE now requires explicit type specification
- Added comprehensive test coverage for URL auto-inference
- Maintains full backward compatibility with existing configs

Fixes #9118
This commit is contained in:
Roo Code 2025-11-08 08:02:46 +00:00
parent e98f4b9057
commit 8630a9601b
2 changed files with 106 additions and 21 deletions

View file

@ -77,7 +77,7 @@ const streamableHttpFieldsErrorMessage =
const mixedFieldsErrorMessage =
"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."
"Server configuration must include either 'command' (for stdio) or 'url' (for sse/streamable-http). When 'url' is present without 'type', 'streamable-http' will be inferred."
// Helper function to create a refined schema with better error messages
const createServerTypeSchema = () => {
@ -98,22 +98,7 @@ const createServerTypeSchema = () => {
type: "stdio" as const,
}))
.refine((data) => data.type === undefined || data.type === "stdio", { message: typeErrorMessage }),
// SSE config (has url field)
BaseConfigSchema.extend({
type: z.enum(["sse"]).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: "sse" as const,
}))
.refine((data) => data.type === undefined || data.type === "sse", { message: typeErrorMessage }),
// StreamableHTTP config (has url field)
// StreamableHTTP config (has url field, default for URL-based configs)
BaseConfigSchema.extend({
type: z.enum(["streamable-http"]).optional(),
url: z.string().url("URL must be a valid URL format"),
@ -130,6 +115,16 @@ const createServerTypeSchema = () => {
.refine((data) => data.type === undefined || data.type === "streamable-http", {
message: typeErrorMessage,
}),
// SSE config (requires explicit type="sse")
BaseConfigSchema.extend({
type: z.enum(["sse"]),
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(),
}).refine((data) => data.type === "sse", { message: typeErrorMessage }),
])
}
@ -209,9 +204,9 @@ export class McpHub {
config.type = "stdio"
}
// For url-based configs, type must be provided by the user
// For url-based configs, auto-infer type as 'streamable-http' if not provided
if (hasUrlFields && !config.type) {
throw new Error("Configuration with 'url' must explicitly specify 'type' as 'sse' or 'streamable-http'.")
config.type = "streamable-http"
}
// Validate type if provided
@ -1378,8 +1373,8 @@ export class McpHub {
await this.deleteConnection(serverName, serverSource)
// Re-add as a disabled connection
// Re-read config from file to get updated disabled state
const updatedConfig = await this.readServerConfigFromFile(serverName, serverSource)
await this.connectToServer(serverName, updatedConfig, serverSource)
const updatedConfig = await this.readServerConfigFromFile(serverName, serverSource)
await this.connectToServer(serverName, updatedConfig, serverSource)
} else if (!disabled && connection.server.status === "disconnected") {
// If enabling a disabled server, connect it
// Re-read config from file to get updated disabled state

View file

@ -2147,4 +2147,94 @@ describe("McpHub", () => {
)
})
})
describe("URL type auto-inference", () => {
it("should auto-infer 'streamable-http' type when URL is present without explicit type", () => {
const config = {
url: "https://api.example.com/mcp",
headers: {
Authorization: "Bearer token",
},
}
const result = ServerConfigSchema.parse(config)
expect(result.type).toBe("streamable-http")
})
it("should preserve explicit 'sse' type when specified with URL", () => {
const config = {
type: "sse",
url: "https://api.example.com/mcp",
headers: {
Authorization: "Bearer token",
},
}
const result = ServerConfigSchema.parse(config)
expect(result.type).toBe("sse")
})
it("should preserve explicit 'streamable-http' type when specified", () => {
const config = {
type: "streamable-http",
url: "https://api.example.com/mcp",
}
const result = ServerConfigSchema.parse(config)
expect(result.type).toBe("streamable-http")
})
it("should maintain backward compatibility for stdio type", () => {
const config = {
command: "node",
args: ["server.js"],
}
const result = ServerConfigSchema.parse(config)
expect(result.type).toBe("stdio")
})
it("should work with minimal URL config", () => {
const config = {
url: "https://docs.langchain.com/mcp",
}
const result = ServerConfigSchema.parse(config)
expect(result.type).toBe("streamable-http")
})
it("should auto-infer for GitHub Copilot-like config", () => {
const config = {
url: "https://api.githubcopilot.com/mcp",
headers: {
Authorization: "Bearer GITHUB_PAT",
},
alwaysAllow: ["SearchDocsByLangChain"],
}
const result = ServerConfigSchema.parse(config)
expect(result.type).toBe("streamable-http")
})
it("should reject invalid URL", () => {
const config = {
url: "not-a-url",
}
expect(() => ServerConfigSchema.parse(config)).toThrow("URL must be a valid URL format")
})
it("should reject mixed stdio and URL fields", () => {
const config = {
command: "node",
url: "https://api.example.com/mcp",
}
// This should be caught by the validateServerConfig function
const mcpHub = new McpHub(mockProvider as ClineProvider)
expect(() => mcpHub["validateServerConfig"](config)).toThrow(
"Cannot mix 'stdio' and ('sse' or 'streamable-http') fields",
)
})
})
})