stranger2904 570b51d07b 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
2026-01-14 11:35:32 -05:00

475 lines
14 KiB
TypeScript

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"
/**
* 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
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 {
private clients: Map<string, ManagedClient> = new Map()
private pendingConnections: Map<string, Promise<Client>> = new Map()
private cleanupRegistered = false
private cleanupInterval: ReturnType<typeof setInterval> | null = null
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
private getClientKey(info: SkillMcpClientInfo): string {
return `${info.sessionID}:${info.skillName}:${info.serverName}`
}
private registerProcessCleanup(): void {
if (this.cleanupRegistered) return
this.cleanupRegistered = true
const cleanup = async () => {
for (const [, managed] of this.clients) {
try {
await managed.client.close()
} catch {
// Ignore errors during cleanup
}
try {
await managed.transport.close()
} catch {
// Transport may already be terminated
}
}
this.clients.clear()
this.pendingConnections.clear()
}
// Note: 'exit' event is synchronous-only in Node.js, so we use 'beforeExit' for async cleanup
// However, 'beforeExit' is not emitted on explicit process.exit() calls
// Signal handlers are made async to properly await cleanup
process.on("SIGINT", async () => {
await cleanup()
process.exit(0)
})
process.on("SIGTERM", async () => {
await cleanup()
process.exit(0)
})
if (process.platform === "win32") {
process.on("SIGBREAK", async () => {
await cleanup()
process.exit(0)
})
}
}
async getOrCreateClient(
info: SkillMcpClientInfo,
config: ClaudeCodeMcpServer
): Promise<Client> {
const key = this.getClientKey(info)
const existing = this.clients.get(key)
if (existing) {
existing.lastUsedAt = Date.now()
return existing.client
}
// Prevent race condition: if a connection is already in progress, wait for it
const pending = this.pendingConnections.get(key)
if (pending) {
return pending
}
const expandedConfig = expandEnvVarsInObject(config)
const connectionPromise = this.createClient(info, expandedConfig)
this.pendingConnections.set(key, connectionPromise)
try {
const client = await connectionPromise
return client
} finally {
this.pendingConnections.delete(key)
}
}
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 configured for stdio but missing 'command' field.`
)
}
const command = config.command
const args = config.args || []
const mergedEnv = createCleanMcpEnvironment(config.env)
this.registerProcessCleanup()
const transport = new StdioClientTransport({
command,
args,
env: mergedEnv,
stderr: "ignore",
})
const client = new Client(
{ name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
{ capabilities: {} }
)
try {
await client.connect(transport)
} catch (error) {
// Close transport to prevent orphaned MCP process on connection failure
try {
await transport.close()
} catch {
// Process may already be terminated
}
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to connect to MCP server "${info.serverName}".\n\n` +
`Command: ${command} ${args.join(" ")}\n` +
`Reason: ${errorMessage}\n\n` +
`Hints:\n` +
` - Ensure the command is installed and available in PATH\n` +
` - Check if the MCP server package exists\n` +
` - Verify the args are correct for this server`
)
}
const managedClient: ManagedStdioClient = {
client,
transport,
skillName: info.skillName,
lastUsedAt: Date.now(),
connectionType: "stdio",
}
this.clients.set(key, managedClient)
this.startCleanupTimer()
return client
}
async disconnectSession(sessionID: string): Promise<void> {
const keysToRemove: string[] = []
for (const [key, managed] of this.clients.entries()) {
if (key.startsWith(`${sessionID}:`)) {
keysToRemove.push(key)
// Delete from map first to prevent re-entrancy during async close
this.clients.delete(key)
try {
await managed.client.close()
} catch {
// Ignore close errors - process may already be terminated
}
try {
await managed.transport.close()
} catch {
// Transport may already be terminated
}
}
}
}
async disconnectAll(): Promise<void> {
this.stopCleanupTimer()
const clients = Array.from(this.clients.values())
this.clients.clear()
for (const managed of clients) {
try {
await managed.client.close()
} catch { /* process may already be terminated */ }
try {
await managed.transport.close()
} catch { /* transport may already be terminated */ }
}
}
private startCleanupTimer(): void {
if (this.cleanupInterval) return
this.cleanupInterval = setInterval(() => {
this.cleanupIdleClients()
}, 60_000)
this.cleanupInterval.unref()
}
private stopCleanupTimer(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
private async cleanupIdleClients(): Promise<void> {
const now = Date.now()
for (const [key, managed] of this.clients) {
if (now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
this.clients.delete(key)
try {
await managed.client.close()
} catch { /* process may already be terminated */ }
try {
await managed.transport.close()
} catch { /* transport may already be terminated */ }
}
}
}
async listTools(
info: SkillMcpClientInfo,
context: SkillMcpServerContext
): Promise<Tool[]> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.listTools()
return result.tools
}
async listResources(
info: SkillMcpClientInfo,
context: SkillMcpServerContext
): Promise<Resource[]> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.listResources()
return result.resources
}
async listPrompts(
info: SkillMcpClientInfo,
context: SkillMcpServerContext
): Promise<Prompt[]> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.listPrompts()
return result.prompts
}
async callTool(
info: SkillMcpClientInfo,
context: SkillMcpServerContext,
name: string,
args: Record<string, unknown>
): Promise<unknown> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.callTool({ name, arguments: args })
return result.content
}
async readResource(
info: SkillMcpClientInfo,
context: SkillMcpServerContext,
uri: string
): Promise<unknown> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.readResource({ uri })
return result.contents
}
async getPrompt(
info: SkillMcpClientInfo,
context: SkillMcpServerContext,
name: string,
args: Record<string, string>
): Promise<unknown> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.getPrompt({ name, arguments: args })
return result.messages
}
private async getOrCreateClientWithRetry(
info: SkillMcpClientInfo,
config: ClaudeCodeMcpServer
): Promise<Client> {
try {
return await this.getOrCreateClient(info, config)
} catch (error) {
const key = this.getClientKey(info)
const existing = this.clients.get(key)
if (existing) {
this.clients.delete(key)
try {
await existing.client.close()
} catch { /* process may already be terminated */ }
try {
await existing.transport.close()
} catch { /* transport may already be terminated */ }
return await this.getOrCreateClient(info, config)
}
throw error
}
}
getConnectedServers(): string[] {
return Array.from(this.clients.keys())
}
isConnected(info: SkillMcpClientInfo): boolean {
return this.clients.has(this.getClientKey(info))
}
}