Merge pull request #790 from stranger2904/feat/http-mcp-transport
feat(skill-mcp): add HTTP transport support for remote MCP servers
This commit is contained in:
commit
abd1ec1092
@ -3,11 +3,47 @@ import { SkillMcpManager } from "./manager"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
|
||||
|
||||
|
||||
// Mock the MCP SDK transports to avoid network calls
|
||||
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
|
||||
const mockHttpClose = mock(() => Promise.resolve())
|
||||
let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {}
|
||||
|
||||
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: class MockStreamableHTTPClientTransport {
|
||||
constructor(public url: URL, public options?: { requestInit?: RequestInit }) {
|
||||
lastTransportInstance = { url, options }
|
||||
}
|
||||
async start() {
|
||||
await mockHttpConnect()
|
||||
}
|
||||
async close() {
|
||||
await mockHttpClose()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
describe("SkillMcpManager", () => {
|
||||
let manager: SkillMcpManager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SkillMcpManager()
|
||||
mockHttpConnect.mockClear()
|
||||
mockHttpClose.mockClear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@ -15,34 +51,296 @@ 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/
|
||||
)
|
||||
})
|
||||
|
||||
it("calls mocked transport connect for HTTP connections", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "mock-test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// #then - verify mock was called (transport was instantiated)
|
||||
// The connection attempt happens through the Client.connect() which
|
||||
// internally calls transport.start()
|
||||
expect(mockHttpConnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
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 +454,52 @@ 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/
|
||||
)
|
||||
|
||||
// Verify headers were forwarded to transport
|
||||
expect(lastTransportInstance.options?.requestInit?.headers).toEqual({
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Custom-Header": "custom-value",
|
||||
})
|
||||
})
|
||||
|
||||
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/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user