d5fbada13d
Move test coercion out of a hidden global and require each test to import the helper so review tools and runtime scripts can see the unsafe boundary. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
975 lines
30 KiB
TypeScript
975 lines
30 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, afterAll, mock, spyOn } from "bun:test"
|
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
|
|
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
|
import type { OAuthTokenData } from "../mcp-oauth/storage"
|
|
import { setHttpClientDependenciesForTesting } from "./http-client"
|
|
import { setStdioClientDependenciesForTesting } from "./stdio-client"
|
|
import { SkillMcpManager } from "./manager"
|
|
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
|
|
|
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
|
|
const mockHttpClose = mock(() => Promise.resolve())
|
|
let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {}
|
|
|
|
class MockHttpClient {
|
|
readonly close = mock(() => Promise.resolve())
|
|
readonly listTools = mock(async () => ({ tools: [] }))
|
|
readonly listResources = mock(async () => ({ resources: [] }))
|
|
readonly listPrompts = mock(async () => ({ prompts: [] }))
|
|
readonly callTool = mock(async () => ({ content: [] }))
|
|
readonly readResource = mock(async () => ({ contents: [] }))
|
|
readonly getPrompt = mock(async () => ({ messages: [] }))
|
|
|
|
constructor(
|
|
_clientInfo: { name: string; version: string },
|
|
_options: { capabilities: Record<string, never> }
|
|
) {}
|
|
|
|
async connect(transport: Transport): Promise<void> {
|
|
await transport.start()
|
|
}
|
|
}
|
|
|
|
class MockStreamableHTTPClientTransport {
|
|
constructor(public url: URL, public options?: { requestInit?: RequestInit }) {
|
|
lastTransportInstance = { url, options }
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
await mockHttpConnect()
|
|
}
|
|
|
|
async send(): Promise<void> {}
|
|
|
|
async close(): Promise<void> {
|
|
await mockHttpClose()
|
|
}
|
|
}
|
|
|
|
function getHeaderValue(headers: HeadersInit | undefined, name: string): string | undefined {
|
|
if (!headers) {
|
|
return undefined
|
|
}
|
|
|
|
if (headers instanceof Headers) {
|
|
return headers.get(name) ?? undefined
|
|
}
|
|
|
|
if (Array.isArray(headers)) {
|
|
const entry = headers.find(([headerName]) => headerName.toLowerCase() === name.toLowerCase())
|
|
return entry?.[1]
|
|
}
|
|
|
|
return headers[name]
|
|
}
|
|
|
|
const mockTokens = mock(() => null as OAuthTokenData | null)
|
|
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token" } satisfies OAuthTokenData))
|
|
const mockRefresh = mock((_: string) => Promise.resolve({ accessToken: "refreshed-token" } satisfies OAuthTokenData))
|
|
|
|
afterAll(() => { mock.restore() })
|
|
|
|
describe("SkillMcpManager", () => {
|
|
let manager: SkillMcpManager
|
|
|
|
beforeEach(() => {
|
|
setHttpClientDependenciesForTesting({
|
|
createClient: (clientInfo, options) => new MockHttpClient(clientInfo, options),
|
|
createTransport: (url, options) => new MockStreamableHTTPClientTransport(url, options),
|
|
})
|
|
setStdioClientDependenciesForTesting()
|
|
|
|
manager = new SkillMcpManager({
|
|
createOAuthProvider: () => ({
|
|
tokens: () => mockTokens(),
|
|
login: () => mockLogin(),
|
|
refresh: (refreshToken: string) => mockRefresh(refreshToken),
|
|
}),
|
|
})
|
|
mockHttpConnect.mockClear()
|
|
mockHttpClose.mockClear()
|
|
mockTokens.mockClear()
|
|
mockLogin.mockClear()
|
|
mockRefresh.mockClear()
|
|
lastTransportInstance = {}
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await manager.disconnectAll()
|
|
setHttpClientDependenciesForTesting()
|
|
setStdioClientDependenciesForTesting()
|
|
})
|
|
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
const session2Info: SkillMcpClientInfo = {
|
|
serverName: "server1",
|
|
skillName: "skill1",
|
|
sessionID: "session-2",
|
|
scope: "builtin",
|
|
}
|
|
|
|
// 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([])
|
|
})
|
|
|
|
it("unregisters signal handlers after disconnectAll", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "signal-server",
|
|
skillName: "signal-skill",
|
|
sessionID: "session-1",
|
|
scope: "builtin",
|
|
}
|
|
const config: ClaudeCodeMcpServer = {
|
|
url: "https://example.com/mcp",
|
|
}
|
|
|
|
const before = process.listenerCount("SIGINT")
|
|
|
|
// when
|
|
try {
|
|
await manager.getOrCreateClient(info, config)
|
|
} catch {
|
|
// Expected to fail connection, still registers cleanup handlers
|
|
}
|
|
const afterRegister = process.listenerCount("SIGINT")
|
|
|
|
await manager.disconnectAll()
|
|
const afterDisconnect = process.listenerCount("SIGINT")
|
|
|
|
// then
|
|
expect(afterRegister).toBe(before + 1)
|
|
expect(afterDisconnect).toBe(before)
|
|
})
|
|
})
|
|
|
|
describe("isConnected", () => {
|
|
it("returns false for unconnected server", () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "$1",
|
|
skillName: "$2",
|
|
sessionID: "$3",
|
|
scope: "builtin",
|
|
}
|
|
|
|
// 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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
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",
|
|
scope: "builtin",
|
|
}
|
|
const context: SkillMcpServerContext = {
|
|
config: {
|
|
url: "https://example.com/mcp",
|
|
},
|
|
skillName: "retry-skill",
|
|
}
|
|
|
|
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()),
|
|
}
|
|
|
|
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
|
|
getOrCreateSpy.mockResolvedValue(mockClient)
|
|
|
|
// when
|
|
const result = await manager.callTool(info, context, "test-tool", {})
|
|
|
|
// then
|
|
expect(callCount).toBe(2)
|
|
expect(result).toEqual([{ type: "text", text: "success" }])
|
|
expect(getOrCreateSpy).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it("should fail after 3 retry attempts", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "fail-server",
|
|
skillName: "fail-skill",
|
|
sessionID: "session-fail-1",
|
|
scope: "builtin",
|
|
}
|
|
const context: SkillMcpServerContext = {
|
|
config: {
|
|
url: "https://example.com/mcp",
|
|
},
|
|
skillName: "fail-skill",
|
|
}
|
|
|
|
const mockClient = {
|
|
callTool: mock(async () => {
|
|
throw new Error("Not connected")
|
|
}),
|
|
close: mock(() => Promise.resolve()),
|
|
}
|
|
|
|
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "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)
|
|
})
|
|
|
|
it("should not retry on non-connection errors", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "error-server",
|
|
skillName: "error-skill",
|
|
sessionID: "session-error-1",
|
|
scope: "builtin",
|
|
}
|
|
const context: SkillMcpServerContext = {
|
|
config: {
|
|
url: "https://example.com/mcp",
|
|
},
|
|
skillName: "error-skill",
|
|
}
|
|
|
|
const mockClient = {
|
|
callTool: mock(async () => {
|
|
throw new Error("Tool not found")
|
|
}),
|
|
close: mock(() => Promise.resolve()),
|
|
}
|
|
|
|
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
|
|
getOrCreateSpy.mockResolvedValue(mockClient)
|
|
|
|
// when / #then
|
|
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
|
"Tool not found"
|
|
)
|
|
expect(getOrCreateSpy).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe("OAuth integration", () => {
|
|
beforeEach(() => {
|
|
mockTokens.mockClear()
|
|
mockLogin.mockClear()
|
|
})
|
|
|
|
it("injects Authorization header when oauth config has stored tokens", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "oauth-server",
|
|
skillName: "oauth-skill",
|
|
sessionID: "session-oauth-1",
|
|
scope: "builtin",
|
|
}
|
|
const config: ClaudeCodeMcpServer = {
|
|
url: "https://mcp.example.com/mcp",
|
|
oauth: {
|
|
clientId: "my-client",
|
|
scopes: ["read", "write"],
|
|
},
|
|
}
|
|
mockTokens.mockReturnValue({ accessToken: "stored-access-token" })
|
|
|
|
// when
|
|
try {
|
|
await manager.getOrCreateClient(info, config)
|
|
} catch { /* connection fails in test */ }
|
|
|
|
// then
|
|
const headers = lastTransportInstance.options?.requestInit?.headers
|
|
expect(getHeaderValue(headers, "Authorization")).toBe("Bearer stored-access-token")
|
|
})
|
|
|
|
it("does not inject Authorization header when no stored tokens exist and login fails", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "oauth-no-token",
|
|
skillName: "oauth-skill",
|
|
sessionID: "session-oauth-2",
|
|
scope: "builtin",
|
|
}
|
|
const config: ClaudeCodeMcpServer = {
|
|
url: "https://mcp.example.com/mcp",
|
|
oauth: {
|
|
clientId: "my-client",
|
|
},
|
|
}
|
|
mockTokens.mockReturnValue(null)
|
|
mockLogin.mockRejectedValue(new Error("Login failed"))
|
|
|
|
// when
|
|
try {
|
|
await manager.getOrCreateClient(info, config)
|
|
} catch { /* connection fails in test */ }
|
|
|
|
// then
|
|
const headers = lastTransportInstance.options?.requestInit?.headers
|
|
expect(getHeaderValue(headers, "Authorization")).toBeUndefined()
|
|
})
|
|
|
|
it("preserves existing static headers alongside OAuth token", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "oauth-with-headers",
|
|
skillName: "oauth-skill",
|
|
sessionID: "session-oauth-3",
|
|
scope: "builtin",
|
|
}
|
|
const config: ClaudeCodeMcpServer = {
|
|
url: "https://mcp.example.com/mcp",
|
|
headers: {
|
|
"X-Custom": "custom-value",
|
|
},
|
|
oauth: {
|
|
clientId: "my-client",
|
|
},
|
|
}
|
|
mockTokens.mockReturnValue({ accessToken: "oauth-token" })
|
|
|
|
// when
|
|
try {
|
|
await manager.getOrCreateClient(info, config)
|
|
} catch { /* connection fails in test */ }
|
|
|
|
// then
|
|
const headers = lastTransportInstance.options?.requestInit?.headers
|
|
expect(getHeaderValue(headers, "X-Custom")).toBe("custom-value")
|
|
expect(getHeaderValue(headers, "Authorization")).toBe("Bearer oauth-token")
|
|
})
|
|
|
|
it("attempts silent refresh for expired stored tokens before login", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "oauth-refresh",
|
|
skillName: "oauth-skill",
|
|
sessionID: "session-oauth-refresh",
|
|
scope: "builtin",
|
|
}
|
|
const config: ClaudeCodeMcpServer = {
|
|
url: "https://mcp.example.com/mcp",
|
|
oauth: {
|
|
clientId: "my-client",
|
|
},
|
|
}
|
|
mockTokens.mockReturnValue({
|
|
accessToken: "expired-token",
|
|
refreshToken: "refresh-token",
|
|
expiresAt: Math.floor(Date.now() / 1000) - 60,
|
|
})
|
|
mockRefresh.mockResolvedValue({ accessToken: "refreshed-token" })
|
|
|
|
// when
|
|
try {
|
|
await manager.getOrCreateClient(info, config)
|
|
} catch { /* connection fails in test */ }
|
|
|
|
// then
|
|
const headers = lastTransportInstance.options?.requestInit?.headers
|
|
expect(getHeaderValue(headers, "Authorization")).toBe("Bearer refreshed-token")
|
|
expect(mockRefresh).toHaveBeenCalledWith("refresh-token")
|
|
expect(mockLogin).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("falls back to login when silent refresh fails", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "oauth-refresh-fallback",
|
|
skillName: "oauth-skill",
|
|
sessionID: "session-oauth-refresh-fallback",
|
|
scope: "builtin",
|
|
}
|
|
const config: ClaudeCodeMcpServer = {
|
|
url: "https://mcp.example.com/mcp",
|
|
oauth: {
|
|
clientId: "my-client",
|
|
},
|
|
}
|
|
mockTokens.mockReturnValue({
|
|
accessToken: "expired-token",
|
|
refreshToken: "refresh-token",
|
|
expiresAt: Math.floor(Date.now() / 1000) - 60,
|
|
})
|
|
mockRefresh.mockRejectedValue(new Error("Refresh failed"))
|
|
mockLogin.mockResolvedValue({ accessToken: "login-token" })
|
|
|
|
// when
|
|
try {
|
|
await manager.getOrCreateClient(info, config)
|
|
} catch { /* connection fails in test */ }
|
|
|
|
// then
|
|
const headers = lastTransportInstance.options?.requestInit?.headers
|
|
expect(getHeaderValue(headers, "Authorization")).toBe("Bearer login-token")
|
|
expect(mockRefresh).toHaveBeenCalledWith("refresh-token")
|
|
expect(mockLogin).toHaveBeenCalled()
|
|
})
|
|
|
|
it("does not create auth provider when oauth config is absent", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "no-oauth-server",
|
|
skillName: "test-skill",
|
|
sessionID: "session-no-oauth",
|
|
scope: "builtin",
|
|
}
|
|
const config: ClaudeCodeMcpServer = {
|
|
url: "https://mcp.example.com/mcp",
|
|
headers: {
|
|
Authorization: "Bearer static-token",
|
|
},
|
|
}
|
|
|
|
// when
|
|
try {
|
|
await manager.getOrCreateClient(info, config)
|
|
} catch { /* connection fails in test */ }
|
|
|
|
// then
|
|
const headers = lastTransportInstance.options?.requestInit?.headers
|
|
expect(getHeaderValue(headers, "Authorization")).toBe("Bearer static-token")
|
|
expect(mockTokens).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("handles step-up auth by triggering re-login on 403 with scope", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "stepup-server",
|
|
skillName: "stepup-skill",
|
|
sessionID: "session-stepup-1",
|
|
scope: "builtin",
|
|
}
|
|
const config: ClaudeCodeMcpServer = {
|
|
url: "https://mcp.example.com/mcp",
|
|
oauth: {
|
|
clientId: "my-client",
|
|
scopes: ["read"],
|
|
},
|
|
}
|
|
const context: SkillMcpServerContext = {
|
|
config,
|
|
skillName: "stepup-skill",
|
|
}
|
|
|
|
mockTokens.mockReturnValue({ accessToken: "initial-token" })
|
|
mockLogin.mockResolvedValue({ accessToken: "upgraded-token" })
|
|
|
|
let callCount = 0
|
|
const mockClient = {
|
|
callTool: mock(async () => {
|
|
callCount++
|
|
if (callCount === 1) {
|
|
throw new Error('403 WWW-Authenticate: Bearer scope="admin write"')
|
|
}
|
|
return { content: [{ type: "text", text: "success" }] }
|
|
}),
|
|
close: mock(() => Promise.resolve()),
|
|
}
|
|
|
|
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
|
|
getOrCreateSpy.mockResolvedValue(mockClient)
|
|
|
|
// when
|
|
const result = await manager.callTool(info, context, "test-tool", {})
|
|
|
|
// then
|
|
expect(result).toEqual([{ type: "text", text: "success" }])
|
|
expect(mockLogin).toHaveBeenCalled()
|
|
})
|
|
|
|
it("does not attempt step-up when oauth config is absent", async () => {
|
|
// given
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: "no-stepup-server",
|
|
skillName: "no-stepup-skill",
|
|
sessionID: "session-no-stepup",
|
|
scope: "builtin",
|
|
}
|
|
const context: SkillMcpServerContext = {
|
|
config: {
|
|
url: "https://mcp.example.com/mcp",
|
|
},
|
|
skillName: "no-stepup-skill",
|
|
}
|
|
|
|
const mockClient = {
|
|
callTool: mock(async () => {
|
|
throw new Error('403 WWW-Authenticate: Bearer scope="admin"')
|
|
}),
|
|
close: mock(() => Promise.resolve()),
|
|
}
|
|
|
|
const getOrCreateSpy = spyOn(unsafeTestValue(manager), "getOrCreateClientWithRetry")
|
|
getOrCreateSpy.mockResolvedValue(mockClient)
|
|
|
|
// when / #then
|
|
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(/403/)
|
|
expect(mockLogin).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|