feat(skill-mcp-manager): improve connection handling and client implementations

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-10 15:53:02 +09:00
parent 046ab1060d
commit e9b3ef0328
8 changed files with 259 additions and 94 deletions
@@ -1,6 +1,10 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, mock, test } from "bun:test"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types"
import { setHttpClientDependenciesForTesting } from "./http-client"
import { setStdioClientDependenciesForTesting } from "./stdio-client"
const trackedStates: SkillMcpManagerState[] = []
const createdStdioTransports: MockStdioClientTransport[] = []
@@ -8,33 +12,62 @@ const createdHttpTransports: MockStreamableHTTPClientTransport[] = []
class MockClient {
readonly close = mock(async () => {})
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: unknown): Promise<void> {
async connect(_transport: Transport): Promise<void> {
// Successful connect, env-related assertions happen on transport constructor args
}
}
class MockStdioClientTransport {
readonly close = mock(async () => {})
readonly options: { command: string; args?: string[]; env?: Record<string, string>; stderr?: string }
readonly start = mock(async () => {})
readonly send = mock(async () => {})
readonly options: StdioServerParameters
constructor(options: { command: string; args?: string[]; env?: Record<string, string>; stderr?: string }) {
constructor(options: StdioServerParameters) {
this.options = options
createdStdioTransports.push(this)
}
}
interface MockHttpTransportOptions {
requestInit?: { headers?: Record<string, string> }
requestInit?: RequestInit
}
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]
}
class MockStreamableHTTPClientTransport {
readonly close = mock(async () => {})
readonly send = mock(async () => {})
readonly url: URL
readonly options?: MockHttpTransportOptions
@@ -47,18 +80,6 @@ class MockStreamableHTTPClientTransport {
async start() {}
}
mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: MockClient,
}))
mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
StdioClientTransport: MockStdioClientTransport,
}))
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: MockStreamableHTTPClientTransport,
}))
afterAll(() => {
mock.restore()
})
@@ -110,6 +131,14 @@ const ORIGINAL_ENV = { ...process.env }
beforeEach(() => {
createdStdioTransports.length = 0
createdHttpTransports.length = 0
setStdioClientDependenciesForTesting({
createClient: (clientInfo, options) => new MockClient(clientInfo, options),
createTransport: (options) => new MockStdioClientTransport(options),
})
setHttpClientDependenciesForTesting({
createClient: (clientInfo, options) => new MockClient(clientInfo, options),
createTransport: (url, options) => new MockStreamableHTTPClientTransport(url, options),
})
})
afterEach(async () => {
@@ -126,6 +155,9 @@ afterEach(async () => {
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
process.env[key] = value
}
setStdioClientDependenciesForTesting()
setHttpClientDependenciesForTesting()
})
describe("getOrCreateClient env var expansion", () => {
@@ -265,11 +297,11 @@ describe("getOrCreateClient env var expansion", () => {
// when
await getOrCreateClient({ state, clientKey, info, config })
// then
expect(createdHttpTransports).toHaveLength(1)
expect(createdHttpTransports[0]?.options?.requestInit?.headers?.Authorization).toBe(
"Bearer xoxp-http-secret"
)
})
// then
expect(createdHttpTransports).toHaveLength(1)
expect(getHeaderValue(createdHttpTransports[0]?.options?.requestInit?.headers, "Authorization")).toBe(
"Bearer xoxp-http-secret"
)
})
})
})
@@ -1,5 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, mock, afterAll } from "bun:test"
import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import { setStdioClientDependenciesForTesting } from "./stdio-client"
import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types"
type Deferred<TValue> = {
@@ -23,7 +26,14 @@ class MockClient {
createdClients.push(this)
}
async connect(_transport: MockStdioClientTransport): Promise<void> {
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: [] }))
async connect(_transport: Transport): Promise<void> {
const pendingConnect = pendingConnects.shift()
if (pendingConnect) {
await pendingConnect.promise
@@ -33,20 +43,14 @@ class MockClient {
class MockStdioClientTransport {
readonly close = mock(async () => {})
readonly start = mock(async () => {})
readonly send = mock(async () => {})
constructor(_options: { command: string; args?: string[]; env?: Record<string, string>; stderr?: string }) {
constructor(_options: StdioServerParameters) {
createdTransports.push(this)
}
}
mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: MockClient,
}))
mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
StdioClientTransport: MockStdioClientTransport,
}))
afterAll(() => { mock.restore() })
const { disconnectAll, disconnectSession } = await import("./cleanup")
@@ -84,6 +88,11 @@ function createState(): SkillMcpManagerState {
shutdownGeneration: 0,
inFlightConnections: new Map(),
disposed: false,
createOAuthProvider: () => ({
tokens: () => null,
login: async () => ({ accessToken: "test-token" }),
refresh: async () => ({ accessToken: "test-token" }),
}),
}
trackedStates.push(state)
@@ -111,6 +120,10 @@ beforeEach(() => {
pendingConnects.length = 0
createdClients.length = 0
createdTransports.length = 0
setStdioClientDependenciesForTesting({
createClient: (clientInfo, options) => new MockClient(clientInfo, options),
createTransport: (options) => new MockStdioClientTransport(options),
})
})
afterEach(async () => {
@@ -122,6 +135,7 @@ afterEach(async () => {
pendingConnects.length = 0
createdClients.length = 0
createdTransports.length = 0
setStdioClientDependenciesForTesting()
})
describe("getOrCreateClient disconnect race", () => {
+6 -7
View File
@@ -1,13 +1,12 @@
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
import { forceReconnect } from "./cleanup"
import { getConnectionType } from "./connection-type"
import { createHttpClient } from "./http-client"
import { createStdioClient } from "./stdio-client"
import type { SkillMcpClientConnectionParams, SkillMcpClientInfo, SkillMcpManagerState } from "./types"
import type { McpClient, SkillMcpClientConnectionParams, SkillMcpClientInfo, SkillMcpManagerState } from "./types"
function removeClientIfCurrent(state: SkillMcpManagerState, clientKey: string, client: Client): void {
function removeClientIfCurrent(state: SkillMcpManagerState, clientKey: string, client: McpClient): void {
const managed = state.clients.get(clientKey)
if (managed?.client === client) {
state.clients.delete(clientKey)
@@ -21,7 +20,7 @@ export async function getOrCreateClient(params: {
clientKey: string
info: SkillMcpClientInfo
config: ClaudeCodeMcpServer
}): Promise<Client> {
}): Promise<McpClient> {
const { state, clientKey, info, config } = params
if (state.disposed) {
@@ -42,7 +41,7 @@ export async function getOrCreateClient(params: {
const isTrusted = !PROJECT_SCOPES.has(info.scope ?? "")
const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted })
let currentConnectionPromise!: Promise<Client>
let currentConnectionPromise!: Promise<McpClient>
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
currentConnectionPromise = (async () => {
const disconnectGenAtStart = state.disconnectedSessions.get(info.sessionID) ?? 0
@@ -96,7 +95,7 @@ export async function getOrCreateClientWithRetryImpl(params: {
clientKey: string
info: SkillMcpClientInfo
config: ClaudeCodeMcpServer
}): Promise<Client> {
}): Promise<McpClient> {
const { state, clientKey } = params
try {
@@ -115,7 +114,7 @@ async function createClient(params: {
clientKey: string
info: SkillMcpClientInfo
config: ClaudeCodeMcpServer
}): Promise<Client> {
}): Promise<McpClient> {
const { info, config } = params
const connectionType = getConnectionType(config)
+37 -4
View File
@@ -2,7 +2,40 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { registerProcessCleanup, startCleanupTimer } from "./cleanup"
import { buildHttpRequestInit } from "./oauth-handler"
import type { ManagedClient, SkillMcpClientConnectionParams } from "./types"
import type { ManagedClient, McpClient, McpTransport, SkillMcpClientConnectionParams } from "./types"
type HttpClientFactory = (
clientInfo: { name: string; version: string },
options: { capabilities: Record<string, never> }
) => McpClient
type HttpTransportFactory = (
url: URL,
options?: { requestInit?: RequestInit }
) => McpTransport
interface HttpClientDependencies {
createClient: HttpClientFactory
createTransport: HttpTransportFactory
}
const defaultHttpClientDependencies: HttpClientDependencies = {
createClient: (clientInfo, options) => new Client(clientInfo, options),
createTransport: (url, options) => new StreamableHTTPClientTransport(url, options),
}
let httpClientDependencies: HttpClientDependencies = defaultHttpClientDependencies
export function setHttpClientDependenciesForTesting(
dependencies?: Partial<HttpClientDependencies>
): void {
httpClientDependencies = dependencies
? {
...defaultHttpClientDependencies,
...dependencies,
}
: defaultHttpClientDependencies
}
function redactUrl(urlStr: string): string {
try {
@@ -22,7 +55,7 @@ function redactUrl(urlStr: string): string {
}
}
export async function createHttpClient(params: SkillMcpClientConnectionParams): Promise<Client> {
export async function createHttpClient(params: SkillMcpClientConnectionParams): Promise<McpClient> {
const { state, clientKey, info, config } = params
const shutdownGenAtStart = state.shutdownGeneration
@@ -43,11 +76,11 @@ export async function createHttpClient(params: SkillMcpClientConnectionParams):
registerProcessCleanup(state)
const requestInit = await buildHttpRequestInit(config, state.authProviders, state.createOAuthProvider)
const transport = new StreamableHTTPClientTransport(url, {
const transport: McpTransport = httpClientDependencies.createTransport(url, {
requestInit,
})
const client = new Client(
const client: McpClient = httpClientDependencies.createClient(
{ name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
{ capabilities: {} }
)
+80 -37
View File
@@ -1,44 +1,84 @@
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"
// 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 } } = {}
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))
async function importFreshManagerModule(): Promise<typeof import("./manager")> {
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()
}
},
}))
const module = await import(`./manager?test=${Date.now()}-${Math.random()}`)
mock.restore()
return module
}
afterAll(() => { mock.restore() })
describe("SkillMcpManager", () => {
let manager: any
let manager: SkillMcpManager
beforeEach(() => {
setHttpClientDependenciesForTesting({
createClient: (clientInfo, options) => new MockHttpClient(clientInfo, options),
createTransport: (url, options) => new MockStreamableHTTPClientTransport(url, options),
})
setStdioClientDependenciesForTesting()
beforeEach(async () => {
const { SkillMcpManager } = await importFreshManagerModule()
manager = new SkillMcpManager({
createOAuthProvider: () => ({
tokens: () => mockTokens(),
@@ -51,10 +91,13 @@ describe("SkillMcpManager", () => {
mockTokens.mockClear()
mockLogin.mockClear()
mockRefresh.mockClear()
lastTransportInstance = {}
})
afterEach(async () => {
await manager.disconnectAll()
setHttpClientDependenciesForTesting()
setStdioClientDependenciesForTesting()
})
describe("getOrCreateClient", () => {
@@ -697,8 +740,8 @@ describe("SkillMcpManager", () => {
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer stored-access-token")
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 () => {
@@ -724,8 +767,8 @@ describe("SkillMcpManager", () => {
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBeUndefined()
const headers = lastTransportInstance.options?.requestInit?.headers
expect(getHeaderValue(headers, "Authorization")).toBeUndefined()
})
it("preserves existing static headers alongside OAuth token", async () => {
@@ -753,9 +796,9 @@ describe("SkillMcpManager", () => {
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.["X-Custom"]).toBe("custom-value")
expect(headers?.Authorization).toBe("Bearer oauth-token")
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 () => {
@@ -785,8 +828,8 @@ describe("SkillMcpManager", () => {
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer refreshed-token")
const headers = lastTransportInstance.options?.requestInit?.headers
expect(getHeaderValue(headers, "Authorization")).toBe("Bearer refreshed-token")
expect(mockRefresh).toHaveBeenCalledWith("refresh-token")
expect(mockLogin).not.toHaveBeenCalled()
})
@@ -819,8 +862,8 @@ describe("SkillMcpManager", () => {
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer login-token")
const headers = lastTransportInstance.options?.requestInit?.headers
expect(getHeaderValue(headers, "Authorization")).toBe("Bearer login-token")
expect(mockRefresh).toHaveBeenCalledWith("refresh-token")
expect(mockLogin).toHaveBeenCalled()
})
@@ -846,8 +889,8 @@ describe("SkillMcpManager", () => {
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer static-token")
const headers = lastTransportInstance.options?.requestInit?.headers
expect(getHeaderValue(headers, "Authorization")).toBe("Bearer static-token")
expect(mockTokens).not.toHaveBeenCalled()
})
+4 -4
View File
@@ -1,4 +1,3 @@
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import { McpOAuthProvider } from "../mcp-oauth/provider"
@@ -6,6 +5,7 @@ import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup"
import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection"
import { handlePostRequestAuthError, handleStepUpIfNeeded } from "./oauth-handler"
import type {
McpClient,
OAuthProviderFactory,
SkillMcpClientInfo,
SkillMcpManagerState,
@@ -36,7 +36,7 @@ export class SkillMcpManager {
return `${info.sessionID}:${info.skillName}:${info.serverName}`
}
async getOrCreateClient(info: SkillMcpClientInfo, config: ClaudeCodeMcpServer): Promise<Client> {
async getOrCreateClient(info: SkillMcpClientInfo, config: ClaudeCodeMcpServer): Promise<McpClient> {
const clientKey = this.getClientKey(info)
return await getOrCreateClient({
state: this.state,
@@ -106,7 +106,7 @@ export class SkillMcpManager {
private async withOperationRetry<T>(
info: SkillMcpClientInfo,
config: ClaudeCodeMcpServer,
operation: (client: Client) => Promise<T>
operation: (client: McpClient) => Promise<T>
): Promise<T> {
const maxRetries = 3
let lastError: Error | null = null
@@ -158,7 +158,7 @@ export class SkillMcpManager {
}
// NOTE: tests spy on this exact method name via `spyOn(manager as any, 'getOrCreateClientWithRetry')`.
private async getOrCreateClientWithRetry(info: SkillMcpClientInfo, config: ClaudeCodeMcpServer): Promise<Client> {
private async getOrCreateClientWithRetry(info: SkillMcpClientInfo, config: ClaudeCodeMcpServer): Promise<McpClient> {
const clientKey = this.getClientKey(info)
return await getOrCreateClientWithRetryImpl({
state: this.state,
+36 -4
View File
@@ -4,7 +4,39 @@ import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import { createCleanMcpEnvironment } from "./env-cleaner"
import { registerProcessCleanup, startCleanupTimer } from "./cleanup"
import { redactSensitiveData } from "./error-redaction"
import type { ManagedClient, SkillMcpClientConnectionParams } from "./types"
import type { ManagedClient, McpClient, McpTransport, SkillMcpClientConnectionParams } from "./types"
type StdioClientFactory = (
clientInfo: { name: string; version: string },
options: { capabilities: Record<string, never> }
) => McpClient
type StdioTransportFactory = (
options: ConstructorParameters<typeof StdioClientTransport>[0]
) => McpTransport
interface StdioClientDependencies {
createClient: StdioClientFactory
createTransport: StdioTransportFactory
}
const defaultStdioClientDependencies: StdioClientDependencies = {
createClient: (clientInfo, options) => new Client(clientInfo, options),
createTransport: (options) => new StdioClientTransport(options),
}
let stdioClientDependencies: StdioClientDependencies = defaultStdioClientDependencies
export function setStdioClientDependenciesForTesting(
dependencies?: Partial<StdioClientDependencies>
): void {
stdioClientDependencies = dependencies
? {
...defaultStdioClientDependencies,
...dependencies,
}
: defaultStdioClientDependencies
}
function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): string {
if (!config.command) {
@@ -13,7 +45,7 @@ function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): strin
return config.command
}
export async function createStdioClient(params: SkillMcpClientConnectionParams): Promise<Client> {
export async function createStdioClient(params: SkillMcpClientConnectionParams): Promise<McpClient> {
const { state, clientKey, info, config } = params
const shutdownGenAtStart = state.shutdownGeneration
@@ -23,14 +55,14 @@ export async function createStdioClient(params: SkillMcpClientConnectionParams):
registerProcessCleanup(state)
const transport = new StdioClientTransport({
const transport: McpTransport = stdioClientDependencies.createTransport({
command,
args,
env: mergedEnv,
stderr: "ignore",
})
const client = new Client(
const client: McpClient = stdioClientDependencies.createClient(
{ name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
{ capabilities: {} }
)
+18 -6
View File
@@ -1,12 +1,24 @@
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import type { McpOAuthProvider } from "../mcp-oauth/provider"
import type { SkillScope } from "../opencode-skill-loader/types"
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
export type McpTransport = Transport
export interface McpClient {
connect: Client["connect"]
close: Client["close"]
listTools: Client["listTools"]
listResources: Client["listResources"]
listPrompts: Client["listPrompts"]
callTool: Client["callTool"]
readResource: Client["readResource"]
getPrompt: Client["getPrompt"]
}
export interface SkillMcpClientInfo {
serverName: string
skillName: string
@@ -27,7 +39,7 @@ export interface SkillMcpServerContext {
export type ConnectionType = "stdio" | "http"
export interface ManagedClientBase {
client: Client
client: McpClient
skillName: string
lastUsedAt: number
connectionType: ConnectionType
@@ -35,12 +47,12 @@ export interface ManagedClientBase {
export interface ManagedStdioClient extends ManagedClientBase {
connectionType: "stdio"
transport: StdioClientTransport
transport: McpTransport
}
export interface ManagedHttpClient extends ManagedClientBase {
connectionType: "http"
transport: StreamableHTTPClientTransport
transport: McpTransport
}
export type ManagedClient = ManagedStdioClient | ManagedHttpClient
@@ -63,7 +75,7 @@ export type OAuthProviderFactory = (options: {
export interface SkillMcpManagerState {
clients: Map<string, ManagedClient>
pendingConnections: Map<string, Promise<Client>>
pendingConnections: Map<string, Promise<McpClient>>
disconnectedSessions: Map<string, number>
authProviders: Map<string, McpOAuthProvider>
cleanupRegistered: boolean