From 570b51d07bb0614838c991731caa954fc5c8dff7 Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 00:39:31 -0500 Subject: [PATCH] feat(skill-mcp): add HTTP transport support for remote MCP servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for connecting to remote MCP servers via HTTP in addition to the existing stdio (local process) connections. This enables skills to use cloud-hosted MCP servers and aggregated MCP gateways. ## Changes - Extend SkillMcpManager to detect connection type from config: - Explicit `type: "http"` or `type: "sse"` → HTTP connection - Explicit `type: "stdio"` → stdio connection - Infer from `url` field → HTTP connection - Infer from `command` field → stdio connection - Add StreamableHTTPClientTransport from MCP SDK for HTTP connections - Supports custom headers for authentication (e.g., API keys) - Proper error handling with helpful hints - Maintain full backward compatibility with existing stdio configurations ## Usage ```yaml # HTTP connection (new) mcp: remote-server: url: https://mcp.example.com/mcp headers: Authorization: Bearer ${API_KEY} # stdio connection (existing, unchanged) mcp: local-server: command: npx args: [-y, @some/mcp-server] ``` ## Tests Added comprehensive tests for: - Connection type detection (explicit type vs inferred) - HTTP URL validation and error messages - Headers configuration - Backward compatibility with stdio configs --- .../skill-mcp-manager/manager.test.ts | 328 ++++++++++++++++-- src/features/skill-mcp-manager/manager.ts | 178 +++++++++- 2 files changed, 472 insertions(+), 34 deletions(-) diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index 2313e22e..4f43247a 100644 --- a/src/features/skill-mcp-manager/manager.test.ts +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -15,34 +15,272 @@ describe("SkillMcpManager", () => { }) describe("getOrCreateClient", () => { - it("throws error when command is missing", async () => { - // #given - const info: SkillMcpClientInfo = { - serverName: "test-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: ClaudeCodeMcpServer = {} + describe("configuration validation", () => { + it("throws error when neither url nor command is provided", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "test-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = {} - // #when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /missing required 'command' field/ - ) + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /no valid connection configuration/ + ) + }) + + it("includes both HTTP and stdio examples in error message", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "my-mcp", + skillName: "data-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = {} + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /HTTP[\s\S]*Stdio/ + ) + }) + + it("includes server and skill names in error message", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "custom-server", + skillName: "custom-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = {} + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /custom-server[\s\S]*custom-skill/ + ) + }) }) - it("includes helpful error message with example when command is missing", async () => { - // #given - const info: SkillMcpClientInfo = { - serverName: "my-mcp", - skillName: "data-skill", - sessionID: "session-1", - } - const config: ClaudeCodeMcpServer = {} + describe("connection type detection", () => { + it("detects HTTP connection from explicit type='http'", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "http-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "http", + url: "https://example.com/mcp", + } - // #when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /my-mcp[\s\S]*data-skill[\s\S]*Example/ - ) + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect/ + ) + }) + + it("detects HTTP connection from explicit type='sse'", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "sse-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "sse", + url: "https://example.com/mcp", + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect/ + ) + }) + + it("detects HTTP connection from url field when type is not specified", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "inferred-http", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://example.com/mcp", + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect[\s\S]*URL/ + ) + }) + + it("detects stdio connection from explicit type='stdio'", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "stdio-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "stdio", + command: "node", + args: ["-e", "process.exit(0)"], + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect[\s\S]*Command/ + ) + }) + + it("detects stdio connection from command field when type is not specified", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "inferred-stdio", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + command: "node", + args: ["-e", "process.exit(0)"], + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect[\s\S]*Command/ + ) + }) + + it("prefers explicit type over inferred type", async () => { + // #given - has both url and command, but type is explicitly stdio + const info: SkillMcpClientInfo = { + serverName: "mixed-config", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "stdio", + url: "https://example.com/mcp", // should be ignored + command: "node", + args: ["-e", "process.exit(0)"], + } + + // #when / #then - should use stdio (show Command in error, not URL) + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Command: node/ + ) + }) + }) + + describe("HTTP connection", () => { + it("throws error for invalid URL", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "bad-url-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "http", + url: "not-a-valid-url", + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /invalid URL/ + ) + }) + + it("includes URL in HTTP connection error", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "http-error-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://nonexistent.example.com/mcp", + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /https:\/\/nonexistent\.example\.com\/mcp/ + ) + }) + + it("includes helpful hints for HTTP connection failures", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "hint-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://nonexistent.example.com/mcp", + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Hints[\s\S]*Verify the URL[\s\S]*authentication headers[\s\S]*MCP over HTTP/ + ) + }) + }) + + describe("stdio connection (backward compatibility)", () => { + it("throws error when command is missing for stdio type", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "missing-command", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "stdio", + // command is missing + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /missing 'command' field/ + ) + }) + + it("includes command in stdio connection error", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "test-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + command: "nonexistent-command-xyz", + args: ["--foo"], + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /nonexistent-command-xyz --foo/ + ) + }) + + it("includes helpful hints for stdio connection failures", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "test-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + command: "nonexistent-command", + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Hints[\s\S]*PATH[\s\S]*package exists/ + ) + }) }) }) @@ -156,4 +394,46 @@ describe("SkillMcpManager", () => { } }) }) + + describe("HTTP headers handling", () => { + it("accepts configuration with headers", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "auth-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }, + } + + // #when / #then - should fail at connection, not config validation + // Headers are passed through to the transport + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect/ + ) + }) + + it("works without headers (optional)", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "no-auth-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://example.com/mcp", + // no headers + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect/ + ) + }) + }) }) diff --git a/src/features/skill-mcp-manager/manager.ts b/src/features/skill-mcp-manager/manager.ts index 089e186e..7741b8ee 100644 --- a/src/features/skill-mcp-manager/manager.ts +++ b/src/features/skill-mcp-manager/manager.ts @@ -1,16 +1,60 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" import { createCleanMcpEnvironment } from "./env-cleaner" import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types" -interface ManagedClient { +/** + * Connection type for a managed MCP client. + * - "stdio": Local process via stdin/stdout + * - "http": Remote server via HTTP (Streamable HTTP transport) + */ +type ConnectionType = "stdio" | "http" + +interface ManagedClientBase { client: Client - transport: StdioClientTransport skillName: string lastUsedAt: number + connectionType: ConnectionType +} + +interface ManagedStdioClient extends ManagedClientBase { + connectionType: "stdio" + transport: StdioClientTransport +} + +interface ManagedHttpClient extends ManagedClientBase { + connectionType: "http" + transport: StreamableHTTPClientTransport +} + +type ManagedClient = ManagedStdioClient | ManagedHttpClient + +/** + * Determines connection type from MCP server configuration. + * Priority: explicit type field > url presence > command presence + */ +function getConnectionType(config: ClaudeCodeMcpServer): ConnectionType | null { + // Explicit type takes priority + if (config.type === "http" || config.type === "sse") { + return "http" + } + if (config.type === "stdio") { + return "stdio" + } + + // Infer from available fields + if (config.url) { + return "http" + } + if (config.command) { + return "stdio" + } + + return null } export class SkillMcpManager { @@ -98,18 +142,125 @@ export class SkillMcpManager { private async createClient( info: SkillMcpClientInfo, config: ClaudeCodeMcpServer + ): Promise { + const connectionType = getConnectionType(config) + + if (!connectionType) { + throw new Error( + `MCP server "${info.serverName}" has no valid connection configuration.\n\n` + + `The MCP configuration in skill "${info.skillName}" must specify either:\n` + + ` - A URL for HTTP connection (remote MCP server)\n` + + ` - A command for stdio connection (local MCP process)\n\n` + + `Examples:\n` + + ` HTTP:\n` + + ` mcp:\n` + + ` ${info.serverName}:\n` + + ` url: https://mcp.example.com/mcp\n` + + ` headers:\n` + + ` Authorization: Bearer \${API_KEY}\n\n` + + ` Stdio:\n` + + ` mcp:\n` + + ` ${info.serverName}:\n` + + ` command: npx\n` + + ` args: [-y, @some/mcp-server]` + ) + } + + if (connectionType === "http") { + return this.createHttpClient(info, config) + } else { + return this.createStdioClient(info, config) + } + } + + /** + * Create an HTTP-based MCP client using StreamableHTTPClientTransport. + * Supports remote MCP servers with optional authentication headers. + */ + private async createHttpClient( + info: SkillMcpClientInfo, + config: ClaudeCodeMcpServer + ): Promise { + const key = this.getClientKey(info) + + if (!config.url) { + throw new Error( + `MCP server "${info.serverName}" is configured for HTTP but missing 'url' field.` + ) + } + + let url: URL + try { + url = new URL(config.url) + } catch { + throw new Error( + `MCP server "${info.serverName}" has invalid URL: ${config.url}\n\n` + + `Expected a valid URL like: https://mcp.example.com/mcp` + ) + } + + this.registerProcessCleanup() + + // Build request init with headers if provided + const requestInit: RequestInit = {} + if (config.headers && Object.keys(config.headers).length > 0) { + requestInit.headers = config.headers + } + + const transport = new StreamableHTTPClientTransport(url, { + requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined, + }) + + const client = new Client( + { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" }, + { capabilities: {} } + ) + + try { + await client.connect(transport) + } catch (error) { + try { + await transport.close() + } catch { + // Transport may already be closed + } + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to connect to MCP server "${info.serverName}".\n\n` + + `URL: ${config.url}\n` + + `Reason: ${errorMessage}\n\n` + + `Hints:\n` + + ` - Verify the URL is correct and the server is running\n` + + ` - Check if authentication headers are required\n` + + ` - Ensure the server supports MCP over HTTP` + ) + } + + const managedClient: ManagedHttpClient = { + client, + transport, + skillName: info.skillName, + lastUsedAt: Date.now(), + connectionType: "http", + } + this.clients.set(key, managedClient) + this.startCleanupTimer() + return client + } + + /** + * Create a stdio-based MCP client using StdioClientTransport. + * Spawns a local process and communicates via stdin/stdout. + */ + private async createStdioClient( + info: SkillMcpClientInfo, + config: ClaudeCodeMcpServer ): Promise { const key = this.getClientKey(info) if (!config.command) { throw new Error( - `MCP server "${info.serverName}" is missing required 'command' field.\n\n` + - `The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` + - `Example:\n` + - ` mcp:\n` + - ` ${info.serverName}:\n` + - ` command: npx\n` + - ` args: [-y, @some/mcp-server]` + `MCP server "${info.serverName}" is configured for stdio but missing 'command' field.` ) } @@ -153,7 +304,14 @@ export class SkillMcpManager { ) } - this.clients.set(key, { client, transport, skillName: info.skillName, lastUsedAt: Date.now() }) + const managedClient: ManagedStdioClient = { + client, + transport, + skillName: info.skillName, + lastUsedAt: Date.now(), + connectionType: "stdio", + } + this.clients.set(key, managedClient) this.startCleanupTimer() return client }