feat(skill-mcp): add HTTP transport support for remote MCP servers

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
This commit is contained in:
stranger2904 2026-01-14 00:39:31 -05:00 committed by Aleksey Bragin
parent 22619d137e
commit 570b51d07b
2 changed files with 472 additions and 34 deletions

View File

@ -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/
)
})
})
})

View File

@ -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<Client> {
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<Client> {
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<Client> {
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
}