9d815a5697
- Added withOperationRetry<T>() helper method that retries operations up to 3 times - Catches "Not connected" errors (case-insensitive) - Cleans up stale client before retry - Modified callTool, readResource, getPrompt to use retry logic - Added tests for retry behavior (3 new test cases) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
612 lines
18 KiB
TypeScript
612 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"
|
|
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 () => {
|
|
await manager.disconnectAll()
|
|
})
|
|
|
|
describe("getOrCreateClient", () => {
|
|
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(
|
|
/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/
|
|
)
|
|
})
|
|
})
|
|
|
|
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 - 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/
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("disconnectSession", () => {
|
|
it("removes all clients for a specific session", async () => {
|
|
// #given
|
|
const session1Info: SkillMcpClientInfo = {
|
|
serverName: "server1",
|
|
skillName: "skill1",
|
|
sessionID: "session-1",
|
|
}
|
|
const session2Info: SkillMcpClientInfo = {
|
|
serverName: "server1",
|
|
skillName: "skill1",
|
|
sessionID: "session-2",
|
|
}
|
|
|
|
// #when
|
|
await manager.disconnectSession("session-1")
|
|
|
|
// #then
|
|
expect(manager.isConnected(session1Info)).toBe(false)
|
|
expect(manager.isConnected(session2Info)).toBe(false)
|
|
})
|
|
|
|
it("does not throw when session has no clients", async () => {
|
|
// #given / #when / #then
|
|
await expect(manager.disconnectSession("nonexistent")).resolves.toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("disconnectAll", () => {
|
|
it("clears all clients", async () => {
|
|
// #given - no actual clients connected (would require real MCP server)
|
|
|
|
// #when
|
|
await manager.disconnectAll()
|
|
|
|
// #then
|
|
expect(manager.getConnectedServers()).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("isConnected", () => {
|
|
it("returns false for unconnected server", () => {
|
|
// #given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "unknown",
|
|
skillName: "test",
|
|
sessionID: "session-1",
|
|
}
|
|
|
|
// #when / #then
|
|
expect(manager.isConnected(info)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("getConnectedServers", () => {
|
|
it("returns empty array when no servers connected", () => {
|
|
// #given / #when / #then
|
|
expect(manager.getConnectedServers()).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("environment variable handling", () => {
|
|
it("always inherits process.env even when config.env is undefined", async () => {
|
|
// #given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "test-server",
|
|
skillName: "test-skill",
|
|
sessionID: "session-1",
|
|
}
|
|
const configWithoutEnv: ClaudeCodeMcpServer = {
|
|
command: "node",
|
|
args: ["-e", "process.exit(0)"],
|
|
}
|
|
|
|
// #when - attempt connection (will fail but exercises env merging code path)
|
|
// #then - should not throw "undefined" related errors for env
|
|
try {
|
|
await manager.getOrCreateClient(info, configWithoutEnv)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
expect(message).not.toContain("env")
|
|
expect(message).not.toContain("undefined")
|
|
}
|
|
})
|
|
|
|
it("overlays config.env on top of inherited process.env", async () => {
|
|
// #given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "test-server",
|
|
skillName: "test-skill",
|
|
sessionID: "session-2",
|
|
}
|
|
const configWithEnv: ClaudeCodeMcpServer = {
|
|
command: "node",
|
|
args: ["-e", "process.exit(0)"],
|
|
env: {
|
|
CUSTOM_VAR: "custom_value",
|
|
},
|
|
}
|
|
|
|
// #when - attempt connection
|
|
// #then - should not throw, env merging should work
|
|
try {
|
|
await manager.getOrCreateClient(info, configWithEnv)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
expect(message).toContain("Failed to connect")
|
|
}
|
|
})
|
|
})
|
|
|
|
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/
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("operation retry logic", () => {
|
|
it("should retry operation when 'Not connected' error occurs", async () => {
|
|
// #given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "retry-server",
|
|
skillName: "retry-skill",
|
|
sessionID: "session-retry-1",
|
|
}
|
|
const context: SkillMcpServerContext = {
|
|
config: {
|
|
url: "https://example.com/mcp",
|
|
},
|
|
skillName: "retry-skill",
|
|
}
|
|
|
|
// Mock client that fails first time with "Not connected", then succeeds
|
|
let callCount = 0
|
|
const mockClient = {
|
|
callTool: mock(async () => {
|
|
callCount++
|
|
if (callCount === 1) {
|
|
throw new Error("Not connected")
|
|
}
|
|
return { content: [{ type: "text", text: "success" }] }
|
|
}),
|
|
close: mock(() => Promise.resolve()),
|
|
}
|
|
|
|
// Spy on getOrCreateClientWithRetry to inject mock client
|
|
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
|
getOrCreateSpy.mockResolvedValue(mockClient)
|
|
|
|
// #when
|
|
const result = await manager.callTool(info, context, "test-tool", {})
|
|
|
|
// #then
|
|
expect(callCount).toBe(2) // First call fails, second succeeds
|
|
expect(result).toEqual([{ type: "text", text: "success" }])
|
|
expect(getOrCreateSpy).toHaveBeenCalledTimes(2) // Called twice due to retry
|
|
})
|
|
|
|
it("should fail after 3 retry attempts", async () => {
|
|
// #given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "fail-server",
|
|
skillName: "fail-skill",
|
|
sessionID: "session-fail-1",
|
|
}
|
|
const context: SkillMcpServerContext = {
|
|
config: {
|
|
url: "https://example.com/mcp",
|
|
},
|
|
skillName: "fail-skill",
|
|
}
|
|
|
|
// Mock client that always fails with "Not connected"
|
|
const mockClient = {
|
|
callTool: mock(async () => {
|
|
throw new Error("Not connected")
|
|
}),
|
|
close: mock(() => Promise.resolve()),
|
|
}
|
|
|
|
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
|
getOrCreateSpy.mockResolvedValue(mockClient)
|
|
|
|
// #when / #then
|
|
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
|
/Failed after 3 reconnection attempts/
|
|
)
|
|
expect(getOrCreateSpy).toHaveBeenCalledTimes(3) // Initial + 2 retries
|
|
})
|
|
|
|
it("should not retry on non-connection errors", async () => {
|
|
// #given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "error-server",
|
|
skillName: "error-skill",
|
|
sessionID: "session-error-1",
|
|
}
|
|
const context: SkillMcpServerContext = {
|
|
config: {
|
|
url: "https://example.com/mcp",
|
|
},
|
|
skillName: "error-skill",
|
|
}
|
|
|
|
// Mock client that fails with non-connection error
|
|
const mockClient = {
|
|
callTool: mock(async () => {
|
|
throw new Error("Tool not found")
|
|
}),
|
|
close: mock(() => Promise.resolve()),
|
|
}
|
|
|
|
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
|
getOrCreateSpy.mockResolvedValue(mockClient)
|
|
|
|
// #when / #then
|
|
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
|
"Tool not found"
|
|
)
|
|
expect(getOrCreateSpy).toHaveBeenCalledTimes(1) // No retry
|
|
})
|
|
})
|
|
})
|