feat: 工具层及 mcp 大重构 (#252)

* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

* fix: 修复对穷鬼模式的 auto dream 和 session memory 越过

* feat: 穷鬼模式去除 session-summary

* feat: 创建 builtin-tools 包,搬运所有工具实现

将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/,
内部导入路径已更新为 src/ alias 模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/

- src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/
- 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock

- tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射
- 新增 packages/builtin-tools/src 至 include

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀

所有包名及 import 路径统一添加 @claude-code-best/ 前缀:
- builtin-tools → @claude-code-best/builtin-tools
- mcp-client → @claude-code-best/mcp-client
- agent-tools → @claude-code-best/agent-tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 node 环境没有 bun 的问题

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-13 09:52:05 +08:00
committed by GitHub
parent bbb8b613a9
commit 2fb1c9dcd8
559 changed files with 9346 additions and 1837 deletions

View File

@@ -0,0 +1,16 @@
{
"name": "@claude-code-best/mcp-client",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@claude-code-best/agent-tools": "workspace:*",
"lru-cache": "^10.0.0",
"lodash-es": "^4.17.21",
"p-map": "^4.0.0",
"zod": "^3.25.0"
}
}

View File

@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test'
import { createLinkedTransportPair } from '../transport/InProcessTransport.js'
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
describe('InProcessTransport', () => {
test('creates linked pair', () => {
const [client, server] = createLinkedTransportPair()
expect(client).toBeDefined()
expect(server).toBeDefined()
})
test('delivers messages from client to server', async () => {
const [client, server] = createLinkedTransportPair()
let received: JSONRPCMessage | null = null
server.onmessage = (msg) => { received = msg }
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 1,
}
await client.send(message)
// Wait for queueMicrotask to deliver
await new Promise(resolve => setTimeout(resolve, 10))
expect(received).not.toBeNull()
expect(received!.jsonrpc).toBe('2.0')
expect((received as any).method).toBe('test')
})
test('delivers messages from server to client', async () => {
const [client, server] = createLinkedTransportPair()
let received: JSONRPCMessage | null = null
client.onmessage = (msg) => { received = msg }
await server.send({ jsonrpc: '2.0', result: 42, id: 1 })
await new Promise(resolve => setTimeout(resolve, 10))
expect(received).not.toBeNull()
})
test('close triggers onclose on both sides', async () => {
const [client, server] = createLinkedTransportPair()
let clientClosed = false
let serverClosed = false
client.onclose = () => { clientClosed = true }
server.onclose = () => { serverClosed = true }
await client.close()
expect(clientClosed).toBe(true)
expect(serverClosed).toBe(true)
})
test('close is idempotent', async () => {
const [client] = createLinkedTransportPair()
let closeCount = 0
client.onclose = () => { closeCount++ }
await client.close()
await client.close()
expect(closeCount).toBe(1)
})
test('send after close throws', async () => {
const [client] = createLinkedTransportPair()
await client.close()
expect(client.send({ jsonrpc: '2.0', method: 'test' } as any)).rejects.toThrow('Transport is closed')
})
})

View File

@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test'
import { memoizeWithLRU } from '../cache.js'
describe('memoizeWithLRU', () => {
test('caches results', () => {
let callCount = 0
const fn = memoizeWithLRU(
(x: number) => { callCount++; return x * 2 },
(x) => `key-${x}`,
10,
)
expect(fn(5)).toBe(10)
expect(callCount).toBe(1)
expect(fn(5)).toBe(10)
expect(callCount).toBe(1) // cached, no new call
})
test('evicts least recently used entries', () => {
const fn = memoizeWithLRU(
(x: number) => x,
(x) => `key-${x}`,
2,
)
fn(1)
fn(2)
fn(3) // should evict key-1
expect(fn.cache.size()).toBe(2)
expect(fn.cache.has('key-1')).toBe(false)
expect(fn.cache.has('key-2')).toBe(true)
expect(fn.cache.has('key-3')).toBe(true)
})
test('cache.clear removes all entries', () => {
const fn = memoizeWithLRU(
(x: number) => x,
(x) => `key-${x}`,
10,
)
fn(1)
fn(2)
expect(fn.cache.size()).toBe(2)
fn.cache.clear()
expect(fn.cache.size()).toBe(0)
})
test('cache.delete removes specific entry', () => {
const fn = memoizeWithLRU(
(x: number) => x,
(x) => `key-${x}`,
10,
)
fn(1)
fn(2)
expect(fn.cache.delete('key-1')).toBe(true)
expect(fn.cache.has('key-1')).toBe(false)
expect(fn.cache.has('key-2')).toBe(true)
})
test('cache.get returns value without promoting', () => {
const fn = memoizeWithLRU(
(x: number) => x * 10,
(x) => `key-${x}`,
2,
)
fn(1)
fn(2)
// key-1 is LRU, but get() should not promote it
expect(fn.cache.get('key-1')).toBe(10)
// Adding key-3 should still evict key-1 (not promoted by get)
fn(3)
expect(fn.cache.has('key-1')).toBe(false)
})
})

View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from 'bun:test'
import {
DEFAULT_CONNECTION_TIMEOUT_MS,
MAX_MCP_DESCRIPTION_LENGTH,
MAX_ERRORS_BEFORE_RECONNECT,
isTerminalConnectionError,
isMcpSessionExpiredError,
} from '../connection.js'
describe('connection constants', () => {
test('has reasonable defaults', () => {
expect(DEFAULT_CONNECTION_TIMEOUT_MS).toBe(30_000)
expect(MAX_MCP_DESCRIPTION_LENGTH).toBe(2048)
expect(MAX_ERRORS_BEFORE_RECONNECT).toBe(3)
})
})
describe('isTerminalConnectionError', () => {
test('detects ECONNRESET', () => {
expect(isTerminalConnectionError('Connection reset: ECONNRESET')).toBe(true)
})
test('detects ETIMEDOUT', () => {
expect(isTerminalConnectionError('Connection timed out: ETIMEDOUT')).toBe(true)
})
test('detects EPIPE', () => {
expect(isTerminalConnectionError('Broken pipe: EPIPE')).toBe(true)
})
test('detects EHOSTUNREACH', () => {
expect(isTerminalConnectionError('Host unreachable: EHOSTUNREACH')).toBe(true)
})
test('detects ECONNREFUSED', () => {
expect(isTerminalConnectionError('Connection refused: ECONNREFUSED')).toBe(true)
})
test('detects SSE disconnection messages', () => {
expect(isTerminalConnectionError('SSE stream disconnected')).toBe(true)
expect(isTerminalConnectionError('Failed to reconnect SSE stream')).toBe(true)
})
test('detects terminated', () => {
expect(isTerminalConnectionError('Process terminated')).toBe(true)
})
test('rejects non-terminal errors', () => {
expect(isTerminalConnectionError('some random error')).toBe(false)
expect(isTerminalConnectionError('')).toBe(false)
expect(isTerminalConnectionError('timeout waiting for response')).toBe(false)
})
})
describe('isMcpSessionExpiredError', () => {
test('detects 404 with JSON-RPC session-not-found code', () => {
const error = new Error('Not found: {"code":-32001,"message":"Session not found"}')
Object.assign(error, { code: 404 })
expect(isMcpSessionExpiredError(error)).toBe(true)
})
test('detects 404 with spaced JSON-RPC code', () => {
const error = new Error('Not found: {"code": -32001}')
Object.assign(error, { code: 404 })
expect(isMcpSessionExpiredError(error)).toBe(true)
})
test('rejects non-404 errors', () => {
const error = new Error('{"code":-32001}')
Object.assign(error, { code: 500 })
expect(isMcpSessionExpiredError(error)).toBe(false)
})
test('rejects 404 without session code', () => {
const error = new Error('Not found')
Object.assign(error, { code: 404 })
expect(isMcpSessionExpiredError(error)).toBe(false)
})
test('rejects errors without code property', () => {
const error = new Error('Session not found')
expect(isMcpSessionExpiredError(error)).toBe(false)
})
})

View File

@@ -0,0 +1,162 @@
import { describe, expect, test, mock } from 'bun:test'
import { discoverTools, createCachedToolDiscovery } from '../discovery.js'
import type { DiscoveryOptions } from '../discovery.js'
import type { ConnectedMCPServer } from '../types.js'
import type { McpClientDependencies } from '../interfaces.js'
function createMockDeps(): McpClientDependencies {
return {
logger: {
debug: mock(() => {}),
info: mock(() => {}),
warn: mock(() => {}),
error: mock(() => {}),
},
httpConfig: {
getUserAgent: () => 'test-agent/1.0',
},
}
}
describe('discoverTools', () => {
test('returns empty array when capabilities.tools is missing', async () => {
const result = await discoverTools({
serverName: 'test',
client: {} as any,
capabilities: {},
deps: createMockDeps(),
})
expect(result).toEqual([])
})
test('fetches and transforms tools from server', async () => {
const mockClient = {
request: mock(() =>
Promise.resolve({
tools: [
{
name: 'search',
description: 'Search for items',
inputSchema: { type: 'object' },
annotations: { readOnlyHint: true, title: 'Search Items' },
},
],
}),
),
}
const result = await discoverTools({
serverName: 'my-server',
client: mockClient as any,
capabilities: { tools: {} },
deps: createMockDeps(),
})
expect(result).toHaveLength(1)
const tool = result[0]
expect(tool.name).toBe('mcp__my-server__search')
expect(tool.mcpInfo).toEqual({ serverName: 'my-server', toolName: 'search' })
expect(tool.isMcp).toBe(true)
expect(tool.isReadOnly()).toBe(true)
expect(tool.userFacingName()).toBe('Search Items')
expect(await tool.description()).toBe('Search for items')
})
test('respects skipPrefix option', async () => {
const mockClient = {
request: mock(() =>
Promise.resolve({
tools: [{ name: 'search', description: 'Search' }],
}),
),
}
const result = await discoverTools({
serverName: 'my-server',
client: mockClient as any,
capabilities: { tools: {} },
skipPrefix: true,
deps: createMockDeps(),
})
expect(result[0].name).toBe('search')
})
test('returns empty array on fetch error', async () => {
const mockClient = {
request: mock(() => Promise.reject(new Error('Connection lost'))),
}
const deps = createMockDeps()
const result = await discoverTools({
serverName: 'failing-server',
client: mockClient as any,
capabilities: { tools: {} },
deps,
})
expect(result).toEqual([])
expect(deps.logger.warn).toHaveBeenCalled()
})
test('sanitizes tool data', async () => {
const mockClient = {
request: mock(() =>
Promise.resolve({
tools: [
{
name: 'tool\x00with\x07control',
description: 'desc',
},
],
}),
),
}
const result = await discoverTools({
serverName: 'test',
client: mockClient as any,
capabilities: { tools: {} },
deps: createMockDeps(),
})
expect(result[0].name).not.toContain('\x00')
})
})
describe('createCachedToolDiscovery', () => {
test('caches results by server name', async () => {
const deps = createMockDeps()
const { discover, cache } = createCachedToolDiscovery(deps)
const mockConn = {
type: 'connected' as const,
name: 'cached-server',
client: {
request: mock(() =>
Promise.resolve({
tools: [{ name: 'tool1', description: 'Tool 1' }],
}),
),
},
capabilities: { tools: {} },
} as unknown as ConnectedMCPServer
// First call — should fetch
const result1 = await discover(mockConn)
expect(result1).toHaveLength(1)
// Second call — should use cache
const result2 = await discover(mockConn)
expect(result2).toHaveLength(1)
// Request was called only once
expect(mockConn.client.request).toHaveBeenCalledTimes(1)
// Cache delete works
cache.delete('cached-server')
const result3 = await discover(mockConn)
expect(result3).toHaveLength(1)
expect(mockConn.client.request).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from 'bun:test'
import {
McpError,
McpConnectionError,
McpAuthError,
McpTimeoutError,
McpToolCallError,
McpSessionExpiredError,
} from '../errors.js'
describe('McpError', () => {
test('has correct properties', () => {
const err = new McpError('test message', 'my-server', 'TEST_CODE')
expect(err.message).toBe('test message')
expect(err.serverName).toBe('my-server')
expect(err.code).toBe('TEST_CODE')
expect(err.name).toBe('McpError')
expect(err).toBeInstanceOf(Error)
})
})
describe('McpConnectionError', () => {
test('inherits from McpError', () => {
const cause = new Error('ECONNREFUSED')
const err = new McpConnectionError('my-server', 'Connection failed', cause)
expect(err).toBeInstanceOf(McpError)
expect(err).toBeInstanceOf(Error)
expect(err.code).toBe('CONNECTION_FAILED')
expect(err.serverName).toBe('my-server')
expect(err.cause).toBe(cause)
})
test('works without cause', () => {
const err = new McpConnectionError('my-server', 'Failed')
expect(err.cause).toBeUndefined()
})
})
describe('McpAuthError', () => {
test('has AUTH_REQUIRED code', () => {
const err = new McpAuthError('my-server', 'Auth needed')
expect(err.code).toBe('AUTH_REQUIRED')
expect(err).toBeInstanceOf(McpError)
})
})
describe('McpTimeoutError', () => {
test('has timeout info in message', () => {
const err = new McpTimeoutError('my-server', 5000)
expect(err.code).toBe('TIMEOUT')
expect(err.timeoutMs).toBe(5000)
expect(err.message).toContain('5000')
})
})
describe('McpToolCallError', () => {
test('has tool name', () => {
const err = new McpToolCallError('my-server', 'query', 'Tool failed')
expect(err.code).toBe('TOOL_CALL_FAILED')
expect(err.toolName).toBe('query')
})
})
describe('McpSessionExpiredError', () => {
test('has SESSION_EXPIRED code', () => {
const err = new McpSessionExpiredError('my-server')
expect(err.code).toBe('SESSION_EXPIRED')
})
})

View File

@@ -0,0 +1,144 @@
import { describe, expect, test, mock } from 'bun:test'
import { callMcpTool } from '../execution.js'
import type { ConnectedMCPServer } from '../types.js'
import type { McpClientDependencies } from '../interfaces.js'
import { McpAuthError, McpToolCallError } from '../errors.js'
function createMockDeps(): McpClientDependencies {
return {
logger: {
debug: mock(() => {}),
info: mock(() => {}),
warn: mock(() => {}),
error: mock(() => {}),
},
httpConfig: {
getUserAgent: () => 'test-agent/1.0',
},
}
}
describe('callMcpTool', () => {
test('calls tool and returns result', async () => {
const mockResult = {
content: [{ type: 'text', text: 'result data' }],
_meta: { requestId: '123' },
}
const mockConn = {
name: 'test-server',
client: {
callTool: mock(() => Promise.resolve(mockResult)),
},
type: 'connected' as const,
} as unknown as ConnectedMCPServer
const result = await callMcpTool(
{
client: mockConn,
tool: 'search',
args: { query: 'test' },
signal: new AbortController().signal,
},
createMockDeps(),
)
expect(result.content).toBeDefined()
})
test('throws McpToolCallError when result has isError=true', async () => {
const mockResult = {
isError: true,
content: [{ type: 'text', text: 'Something went wrong' }],
}
const mockConn = {
name: 'test-server',
client: {
callTool: mock(() => Promise.resolve(mockResult)),
},
type: 'connected' as const,
} as unknown as ConnectedMCPServer
await expect(
callMcpTool(
{
client: mockConn,
tool: 'fail-tool',
args: {},
signal: new AbortController().signal,
},
createMockDeps(),
),
).rejects.toThrow()
try {
await callMcpTool(
{
client: mockConn,
tool: 'fail-tool',
args: {},
signal: new AbortController().signal,
},
createMockDeps(),
)
} catch (e) {
expect(e).toBeInstanceOf(McpToolCallError)
expect((e as McpToolCallError).serverName).toBe('test-server')
expect((e as McpToolCallError).toolName).toBe('fail-tool')
}
})
test('throws McpAuthError on 401 response', async () => {
const error = new Error('Unauthorized')
Object.assign(error, { code: 401 })
const mockConn = {
name: 'auth-server',
client: {
callTool: mock(() => Promise.reject(error)),
},
type: 'connected' as const,
} as unknown as ConnectedMCPServer
await expect(
callMcpTool(
{
client: mockConn,
tool: 'protected-tool',
args: {},
signal: new AbortController().signal,
},
createMockDeps(),
),
).rejects.toThrow(McpAuthError)
})
test('passes metadata to the client', async () => {
const mockResult = { content: [{ type: 'text', text: 'ok' }] }
const callToolMock = mock(() => Promise.resolve(mockResult))
const mockConn = {
name: 'test-server',
client: {
callTool: callToolMock,
},
type: 'connected' as const,
} as unknown as ConnectedMCPServer
await callMcpTool(
{
client: mockConn,
tool: 'my-tool',
args: { key: 'value' },
meta: { 'custom-key': 'custom-value' },
signal: new AbortController().signal,
},
createMockDeps(),
)
expect(callToolMock).toHaveBeenCalled()
const callArgs = callToolMock.mock.calls[0] as any[]
expect(callArgs[0]._meta).toEqual({ 'custom-key': 'custom-value' })
})
})

View File

@@ -0,0 +1,113 @@
import { describe, expect, test, mock } from 'bun:test'
import { createMcpManager } from '../manager.js'
import type { McpManager } from '../manager.js'
import type { McpClientDependencies } from '../interfaces.js'
import type { ScopedMcpServerConfig, MCPServerConnection, ConnectedMCPServer } from '../types.js'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
function createMockDeps(): McpClientDependencies {
return {
logger: {
debug: mock(() => {}),
info: mock(() => {}),
warn: mock(() => {}),
error: mock(() => {}),
},
httpConfig: {
getUserAgent: () => 'test-agent/1.0',
getSessionId: () => 'test-session',
},
}
}
describe('createMcpManager', () => {
test('creates a manager instance', () => {
const manager = createMcpManager(createMockDeps())
expect(manager).toBeDefined()
expect(manager.getConnections).toBeTypeOf('function')
expect(manager.connect).toBeTypeOf('function')
expect(manager.disconnect).toBeTypeOf('function')
expect(manager.getTools).toBeTypeOf('function')
expect(manager.getAllTools).toBeTypeOf('function')
expect(manager.callTool).toBeTypeOf('function')
expect(manager.on).toBeTypeOf('function')
expect(manager.off).toBeTypeOf('function')
})
test('connect throws if connectFn not set', async () => {
const manager = createMcpManager(createMockDeps())
await expect(manager.connect('test', { command: 'npx', args: [] }))
.rejects.toThrow('connectFn not set')
})
test('connect calls connectFn and emits connected event', async () => {
const manager = createMcpManager(createMockDeps()) as any
let connectedEvent: string | null = null
manager.on('connected', (name: string) => { connectedEvent = name })
const mockConnection: ConnectedMCPServer = {
type: 'connected',
name: 'test-server',
client: {
request: mock(() => Promise.resolve({ tools: [] })),
onclose: null,
} as unknown as Client,
capabilities: {},
config: { command: 'npx', args: [], scope: 'dynamic' } as ScopedMcpServerConfig,
cleanup: mock(() => Promise.resolve()),
}
manager.setConnectFn(async (name: string, config: ScopedMcpServerConfig) => {
expect(name).toBe('test-server')
expect(config.scope).toBe('dynamic')
return mockConnection
})
const result = await manager.connect('test-server', { command: 'npx', args: [] })
expect(result.type).toBe('connected')
expect(connectedEvent).toBe('test-server')
})
test('disconnect calls cleanup and emits disconnected', async () => {
const manager = createMcpManager(createMockDeps()) as any
let disconnected = false
manager.on('disconnected', () => { disconnected = true })
const mockCleanup = mock(() => Promise.resolve())
const mockConnection: ConnectedMCPServer = {
type: 'connected',
name: 'test-server',
client: { request: mock(() => Promise.resolve({ tools: [] })) } as unknown as Client,
capabilities: {},
config: { command: 'npx', args: [], scope: 'dynamic' } as ScopedMcpServerConfig,
cleanup: mockCleanup,
}
manager.setConnectFn(async () => mockConnection)
await manager.connect('test-server', { command: 'npx', args: [] })
await manager.disconnect('test-server')
expect(mockCleanup).toHaveBeenCalled()
expect(disconnected).toBe(true)
expect(manager.getConnections().size).toBe(0)
})
test('on/off event handling', () => {
const manager = createMcpManager(createMockDeps()) as any
const handler = mock(() => {})
manager.on('error', handler)
manager.off('error', handler)
// No crash — just verifying it works
expect(true).toBe(true)
})
test('getTools returns empty array for unknown server', () => {
const manager = createMcpManager(createMockDeps())
expect(manager.getTools('unknown')).toEqual([])
})
test('getAllTools returns empty array initially', () => {
const manager = createMcpManager(createMockDeps())
expect(manager.getAllTools()).toEqual([])
})
})

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from 'bun:test'
import { recursivelySanitizeUnicode } from '../sanitization.js'
describe('recursivelySanitizeUnicode', () => {
test('passes through clean strings', () => {
expect(recursivelySanitizeUnicode('hello world')).toBe('hello world')
expect(recursivelySanitizeUnicode('')).toBe('')
})
test('removes control characters', () => {
expect(recursivelySanitizeUnicode('hello\x00world')).toBe('helloworld')
expect(recursivelySanitizeUnicode('test\x07bell')).toBe('testbell')
})
test('preserves allowed whitespace', () => {
expect(recursivelySanitizeUnicode('hello\tworld')).toBe('hello\tworld')
expect(recursivelySanitizeUnicode('hello\nworld')).toBe('hello\nworld')
expect(recursivelySanitizeUnicode('hello\rworld')).toBe('hello\rworld')
})
test('removes replacement character', () => {
expect(recursivelySanitizeUnicode('hello\uFFFDworld')).toBe('helloworld')
})
test('normalizes to NFC', () => {
// é can be composed (U+00E9) or decomposed (U+0065 + U+0301)
const decomposed = 'e\u0301'
const result = recursivelySanitizeUnicode(decomposed)
expect(result).toBe('é')
})
test('sanitizes arrays recursively', () => {
const input = ['hello\x00world', 'clean']
expect(recursivelySanitizeUnicode(input)).toEqual(['helloworld', 'clean'])
})
test('sanitizes objects recursively', () => {
const input = { name: 'test\x07', nested: { value: 'a\x00b' } }
expect(recursivelySanitizeUnicode(input)).toEqual({
name: 'test',
nested: { value: 'ab' },
})
})
test('handles null and non-string primitives', () => {
expect(recursivelySanitizeUnicode(null)).toBe(null)
expect(recursivelySanitizeUnicode(42)).toBe(42)
expect(recursivelySanitizeUnicode(true)).toBe(true)
expect(recursivelySanitizeUnicode(undefined)).toBe(undefined)
})
})

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from 'bun:test'
import {
buildMcpToolName,
normalizeNameForMCP,
mcpInfoFromString,
getMcpPrefix,
getToolNameForPermissionCheck,
getMcpDisplayName,
extractMcpToolDisplayName,
} from '../strings.js'
describe('normalizeNameForMCP', () => {
test('keeps valid names unchanged', () => {
expect(normalizeNameForMCP('my-server')).toBe('my-server')
expect(normalizeNameForMCP('my_server')).toBe('my_server')
expect(normalizeNameForMCP('server123')).toBe('server123')
})
test('replaces dots and spaces with underscores', () => {
expect(normalizeNameForMCP('test.server')).toBe('test_server')
expect(normalizeNameForMCP('test server')).toBe('test_server')
})
test('collapses underscores for claude.ai prefix', () => {
expect(normalizeNameForMCP('claude.ai Slack')).toBe('claude_ai_Slack')
expect(normalizeNameForMCP('claude.ai My Server')).toBe('claude_ai_My_Server')
})
})
describe('buildMcpToolName', () => {
test('builds fully qualified name', () => {
expect(buildMcpToolName('my-server', 'query')).toBe('mcp__my-server__query')
})
test('normalizes server name with dots', () => {
expect(buildMcpToolName('test.server', 'tool')).toBe('mcp__test_server__tool')
})
})
describe('mcpInfoFromString', () => {
test('parses valid MCP tool name', () => {
const info = mcpInfoFromString('mcp__my-server__query')
expect(info).toEqual({ serverName: 'my-server', toolName: 'query' })
})
test('returns null for non-MCP names', () => {
expect(mcpInfoFromString('bash')).toBeNull()
expect(mcpInfoFromString('mcp__')).toBeNull()
expect(mcpInfoFromString('')).toBeNull()
})
test('handles tool names with double underscores', () => {
const info = mcpInfoFromString('mcp__server__tool__part')
expect(info).toEqual({ serverName: 'server', toolName: 'tool__part' })
})
test('handles server-only (no tool name)', () => {
const info = mcpInfoFromString('mcp__server')
expect(info).toEqual({ serverName: 'server', toolName: undefined })
})
})
describe('getMcpPrefix', () => {
test('returns correct prefix', () => {
expect(getMcpPrefix('my-server')).toBe('mcp__my-server__')
})
})
describe('getToolNameForPermissionCheck', () => {
test('uses mcp prefix for MCP tools', () => {
expect(getToolNameForPermissionCheck({
name: 'query',
mcpInfo: { serverName: 'my-server', toolName: 'query' },
})).toBe('mcp__my-server__query')
})
test('uses raw name for non-MCP tools', () => {
expect(getToolNameForPermissionCheck({ name: 'bash' })).toBe('bash')
})
})
describe('getMcpDisplayName', () => {
test('strips MCP prefix', () => {
// getMcpDisplayName normalizes server name before building prefix
expect(getMcpDisplayName('mcp__my_server__query', 'my.server')).toBe('query')
})
})
describe('extractMcpToolDisplayName', () => {
test('removes MCP suffix', () => {
expect(extractMcpToolDisplayName('github - Add comment (MCP)')).toBe('Add comment')
})
test('handles no dash', () => {
expect(extractMcpToolDisplayName('Add comment (MCP)')).toBe('Add comment')
})
test('handles no suffix', () => {
expect(extractMcpToolDisplayName('github - Add comment')).toBe('Add comment')
})
})

View File

@@ -0,0 +1,58 @@
// LRU memoization cache for MCP tool discovery
// Adapted from src/utils/memoize.ts — only memoizeWithLRU needed
import { LRUCache } from 'lru-cache'
type LRUMemoizedFunction<Args extends unknown[], Result> = {
(...args: Args): Result
cache: {
clear: () => void
size: () => number
delete: (key: string) => boolean
get: (key: string) => Result | undefined
has: (key: string) => boolean
}
}
/**
* Creates a memoized function with LRU eviction policy.
* Prevents unbounded memory growth by evicting least recently used entries.
*
* @param f The function to memoize
* @param cacheFn Key generation function
* @param maxCacheSize Maximum cache entries (default 100)
*/
export function memoizeWithLRU<
Args extends unknown[],
Result extends NonNullable<unknown>,
>(
f: (...args: Args) => Result,
cacheFn: (...args: Args) => string,
maxCacheSize: number = 100,
): LRUMemoizedFunction<Args, Result> {
const cache = new LRUCache<string, Result>({
max: maxCacheSize,
})
const memoized = (...args: Args): Result => {
const key = cacheFn(...args)
const cached = cache.get(key)
if (cached !== undefined) {
return cached
}
const result = f(...args)
cache.set(key, result)
return result
}
memoized.cache = {
clear: () => cache.clear(),
size: () => cache.size,
delete: (key: string) => cache.delete(key),
get: (key: string) => cache.peek(key),
has: (key: string) => cache.has(key),
}
return memoized
}

View File

@@ -0,0 +1,519 @@
// MCP connection utilities — protocol-level helpers for establishing and managing connections
// These are building blocks used by the host's connectToServer implementation.
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import type { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import type { McpClientDependencies } from './interfaces.js'
import type { ConnectedMCPServer, ScopedMcpServerConfig } from './types.js'
// ============================================================================
// Constants
// ============================================================================
/** Default connection timeout in milliseconds */
export const DEFAULT_CONNECTION_TIMEOUT_MS = 30_000
/** Maximum length for MCP descriptions/instructions */
export const MAX_MCP_DESCRIPTION_LENGTH = 2048
/** Maximum consecutive terminal errors before triggering reconnection */
export const MAX_ERRORS_BEFORE_RECONNECT = 3
// ============================================================================
// Client creation
// ============================================================================
export interface CreateClientOptions {
/** Client name (e.g., "claude-code") */
name: string
/** Client title */
title?: string
/** Client version */
version: string
/** Client description */
description?: string
/** Client website URL */
websiteUrl?: string
/** Root URI for ListRoots requests (defaults to current working directory) */
rootUri?: string
}
/**
* Creates a configured MCP Client instance with standard capabilities and handlers.
* The host can further customize the client before connecting.
*/
export function createMcpClient(options: CreateClientOptions): Client {
const client = new Client(
{
name: options.name,
title: options.title ?? options.name,
version: options.version,
description: options.description,
websiteUrl: options.websiteUrl,
},
{
capabilities: {
roots: {},
elicitation: {},
},
},
)
// Register default ListRoots handler
client.setRequestHandler(ListRootsRequestSchema, async () => ({
roots: [
{
uri: options.rootUri ?? `file://${process.cwd()}`,
},
],
}))
return client
}
// ============================================================================
// Connection timeout
// ============================================================================
/**
* Wraps a connection promise with a timeout.
* Returns the result of connectPromise or rejects with a timeout error.
*/
export async function withConnectionTimeout<T>(
connectPromise: Promise<T>,
timeoutMs: number,
onTimeout: () => Promise<void> | void,
): Promise<T> {
const startTime = Date.now()
const timeoutPromise = new Promise<never>((_, reject) => {
const timeoutId = setTimeout(async () => {
await onTimeout()
reject(
new Error(
`MCP connection timed out after ${timeoutMs}ms`,
),
)
}, timeoutMs)
// Clean up timeout if connect resolves or rejects
connectPromise.then(
() => clearTimeout(timeoutId),
() => clearTimeout(timeoutId),
)
})
return Promise.race([connectPromise, timeoutPromise])
}
// ============================================================================
// Stderr capture
// ============================================================================
/**
* Sets up stderr capture for stdio transports.
* Returns the stderr output accumulator and cleanup function.
*/
export function captureStderr(
transport: StdioClientTransport,
maxSize = 64 * 1024 * 1024,
): { getOutput: () => string; clearOutput: () => void; removeHandler: () => void } {
let stderrOutput = ''
const handler = (data: Buffer) => {
if (stderrOutput.length < maxSize) {
try {
stderrOutput += data.toString()
} catch {
// Ignore errors from exceeding max string length
}
}
}
transport.stderr?.on('data', handler)
return {
getOutput: () => stderrOutput,
clearOutput: () => { stderrOutput = '' },
removeHandler: () => { transport.stderr?.off('data', handler) },
}
}
// ============================================================================
// Error/close handlers
// ============================================================================
/**
* Terminal connection error patterns that indicate the connection is broken.
*/
export function isTerminalConnectionError(msg: string): boolean {
return (
msg.includes('ECONNRESET') ||
msg.includes('ETIMEDOUT') ||
msg.includes('EPIPE') ||
msg.includes('EHOSTUNREACH') ||
msg.includes('ECONNREFUSED') ||
msg.includes('Body Timeout Error') ||
msg.includes('terminated') ||
msg.includes('SSE stream disconnected') ||
msg.includes('Failed to reconnect SSE stream')
)
}
/**
* Detects MCP "Session not found" errors (HTTP 404 + JSON-RPC code -32001).
*/
export function isMcpSessionExpiredError(error: Error): boolean {
const httpStatus =
'code' in error ? (error as Error & { code?: number }).code : undefined
if (httpStatus !== 404) {
return false
}
return (
error.message.includes('"code":-32001') ||
error.message.includes('"code": -32001')
)
}
export interface ConnectionMonitorOptions {
serverName: string
transportType: string
logger: McpClientDependencies['logger']
/** Called when the transport should be closed to trigger reconnection */
closeTransport: () => void
/** Called to clear connection caches on close */
onConnectionClosed?: () => void
}
/**
* Installs enhanced error and close handlers on an MCP Client for
* connection drop detection and automatic reconnection.
*
* Returns the cleanup function to remove handlers.
*/
export function installConnectionMonitor(
client: Client,
options: ConnectionMonitorOptions,
): () => void {
const { serverName, transportType, logger, closeTransport, onConnectionClosed } = options
const connectionStartTime = Date.now()
let hasErrorOccurred = false
let consecutiveConnectionErrors = 0
let hasTriggeredClose = false
const originalOnerror = client.onerror
const originalOnclose = client.onclose
const safeClose = (reason: string) => {
if (hasTriggeredClose) return
hasTriggeredClose = true
logger.debug(`[${serverName}] Closing transport (${reason})`)
void client.close().catch(e => {
logger.debug(`[${serverName}] Error during close: ${e}`)
})
}
// Error handler
client.onerror = (error: Error) => {
const uptime = Date.now() - connectionStartTime
hasErrorOccurred = true
logger.debug(
`[${serverName}] ${transportType.toUpperCase()} connection dropped after ${Math.floor(uptime / 1000)}s uptime`,
)
// Session expiry for HTTP transports
if (
(transportType === 'http' || transportType === 'claudeai-proxy') &&
isMcpSessionExpiredError(error)
) {
logger.debug(
`[${serverName}] MCP session expired, triggering reconnection`,
)
safeClose('session expired')
originalOnerror?.(error)
return
}
// Terminal error tracking for remote transports
if (
transportType === 'sse' ||
transportType === 'http' ||
transportType === 'claudeai-proxy'
) {
if (error.message.includes('Maximum reconnection attempts')) {
safeClose('SSE reconnection exhausted')
originalOnerror?.(error)
return
}
if (isTerminalConnectionError(error.message)) {
consecutiveConnectionErrors++
logger.debug(
`[${serverName}] Terminal connection error ${consecutiveConnectionErrors}/${MAX_ERRORS_BEFORE_RECONNECT}`,
)
if (consecutiveConnectionErrors >= MAX_ERRORS_BEFORE_RECONNECT) {
consecutiveConnectionErrors = 0
safeClose('max consecutive terminal errors')
}
} else {
consecutiveConnectionErrors = 0
}
}
originalOnerror?.(error)
}
// Close handler
client.onclose = () => {
const uptime = Date.now() - connectionStartTime
logger.debug(
`[${serverName}] ${transportType.toUpperCase()} connection closed after ${Math.floor(uptime / 1000)}s (${hasErrorOccurred ? 'with errors' : 'cleanly'})`,
)
onConnectionClosed?.()
originalOnclose?.()
}
// Return cleanup function
return () => {
client.onerror = originalOnerror
client.onclose = originalOnclose
}
}
// ============================================================================
// Signal escalation for stdio cleanup
// ============================================================================
/**
* Terminates a stdio child process with escalating signals:
* SIGINT (100ms) → SIGTERM (400ms) → SIGKILL
*
* Total maximum cleanup time: ~500ms
*/
export async function terminateWithSignalEscalation(
childPid: number,
logger: McpClientDependencies['logger'],
serverName: string,
): Promise<void> {
try {
logger.debug(`[${serverName}] Sending SIGINT to MCP server process`)
try {
process.kill(childPid, 'SIGINT')
} catch (error) {
logger.debug(`[${serverName}] Error sending SIGINT: ${error}`)
return
}
await new Promise<void>(async resolve => {
let resolved = false
const checkInterval = setInterval(() => {
try {
process.kill(childPid, 0)
} catch {
if (!resolved) {
resolved = true
clearInterval(checkInterval)
clearTimeout(failsafeTimeout)
logger.debug(`[${serverName}] MCP server process exited cleanly`)
resolve()
}
}
}, 50)
const failsafeTimeout = setTimeout(() => {
if (!resolved) {
resolved = true
clearInterval(checkInterval)
logger.debug(`[${serverName}] Cleanup timeout reached, stopping process monitoring`)
resolve()
}
}, 600)
try {
// Wait 100ms for SIGINT to work
await sleep(100)
if (!resolved) {
try {
process.kill(childPid, 0)
// Process still exists, try SIGTERM
logger.debug(`[${serverName}] SIGINT failed, sending SIGTERM`)
try {
process.kill(childPid, 'SIGTERM')
} catch (termError) {
logger.debug(`[${serverName}] Error sending SIGTERM: ${termError}`)
resolved = true
clearInterval(checkInterval)
clearTimeout(failsafeTimeout)
resolve()
return
}
} catch {
resolved = true
clearInterval(checkInterval)
clearTimeout(failsafeTimeout)
resolve()
return
}
// Wait 400ms for SIGTERM
await sleep(400)
if (!resolved) {
try {
process.kill(childPid, 0)
logger.debug(`[${serverName}] SIGTERM failed, sending SIGKILL`)
try {
process.kill(childPid, 'SIGKILL')
} catch (killError) {
logger.debug(`[${serverName}] Error sending SIGKILL: ${killError}`)
}
} catch {
resolved = true
clearInterval(checkInterval)
clearTimeout(failsafeTimeout)
resolve()
}
}
}
if (!resolved) {
resolved = true
clearInterval(checkInterval)
clearTimeout(failsafeTimeout)
resolve()
}
} catch {
if (!resolved) {
resolved = true
clearInterval(checkInterval)
clearTimeout(failsafeTimeout)
resolve()
}
}
})
} catch (processError) {
logger.debug(`[${serverName}] Error terminating process: ${processError}`)
}
}
/** Simple sleep utility (avoids importing from host) */
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
// ============================================================================
// Cleanup factory
// ============================================================================
export interface CleanupOptions {
client: Client
transport: Transport
transportType: string
childPid?: number
inProcessServer?: { close(): Promise<void> }
stderrCleanup?: { removeHandler: () => void }
logger: McpClientDependencies['logger']
serverName: string
}
/**
* Creates a cleanup function for an MCP connection.
* Handles in-process servers, stderr listener removal, signal escalation, and client close.
*/
export function createCleanup(options: CleanupOptions): () => Promise<void> {
const {
client,
transport,
transportType,
childPid,
inProcessServer,
stderrCleanup,
logger,
serverName,
} = options
return async () => {
// In-process servers
if (inProcessServer) {
try {
await inProcessServer.close()
} catch (error) {
logger.debug(`[${serverName}] Error closing in-process server: ${error}`)
}
try {
await client.close()
} catch (error) {
logger.debug(`[${serverName}] Error closing client: ${error}`)
}
return
}
// Remove stderr listener
stderrCleanup?.removeHandler()
// Signal escalation for stdio
if (transportType === 'stdio' && childPid) {
await terminateWithSignalEscalation(childPid, logger, serverName)
}
// Close the client connection (which also closes the transport)
try {
await client.close()
} catch (error) {
logger.debug(`[${serverName}] Error closing client: ${error}`)
}
}
}
// ============================================================================
// Connected server result builder
// ============================================================================
export interface BuildConnectedServerOptions {
name: string
client: Client
config: ScopedMcpServerConfig
cleanup: () => Promise<void>
}
/**
* Builds a ConnectedMCPServer result from a connected client.
* Truncates server instructions if they exceed MAX_MCP_DESCRIPTION_LENGTH.
*/
export function buildConnectedServer(
options: BuildConnectedServerOptions,
logger: McpClientDependencies['logger'],
): ConnectedMCPServer {
const { name, client, config, cleanup } = options
const capabilities = client.getServerCapabilities() ?? {}
const serverVersion = client.getServerVersion()
const rawInstructions = client.getInstructions()
let instructions = rawInstructions
if (rawInstructions && rawInstructions.length > MAX_MCP_DESCRIPTION_LENGTH) {
instructions = rawInstructions.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '… [truncated]'
logger.debug(
`[${name}] Server instructions truncated from ${rawInstructions.length} to ${MAX_MCP_DESCRIPTION_LENGTH} chars`,
)
}
return {
name,
client,
type: 'connected' as const,
capabilities,
serverInfo: serverVersion,
instructions,
config,
cleanup,
}
}

View File

@@ -0,0 +1,143 @@
// MCP tool discovery — fetch and process tools from connected MCP servers
// Extracted from src/services/mcp/client.ts (fetchToolsForClient)
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import {
ListToolsResultSchema,
type ListToolsResult,
} from '@modelcontextprotocol/sdk/types.js'
import type { CoreTool } from '@claude-code-best/agent-tools'
import type { ConnectedMCPServer } from './types.js'
import type { McpClientDependencies } from './interfaces.js'
import { buildMcpToolName } from './strings.js'
import { memoizeWithLRU } from './cache.js'
import { recursivelySanitizeUnicode } from './sanitization.js'
// ============================================================================
// Constants
// ============================================================================
/** Default max cache size for tool discovery (keyed by server name) */
export const MCP_FETCH_CACHE_SIZE = 20
/** Maximum description length before truncation */
const MAX_MCP_DESCRIPTION_LENGTH = 2048
// ============================================================================
// Tool discovery
// ============================================================================
export interface DiscoveryOptions {
/** Server name for logging and tool naming */
serverName: string
/** Connected MCP server client */
client: Client
/** Server capabilities (checked before fetching) */
capabilities: Record<string, unknown>
/** Whether to skip the mcp__ prefix for tool names */
skipPrefix?: boolean
/** Host dependencies for logging */
deps: McpClientDependencies
}
/**
* Fetches tools from a connected MCP server and converts them to CoreTool format.
* Returns empty array if the server doesn't support tools or if fetching fails.
*/
export async function discoverTools(options: DiscoveryOptions): Promise<CoreTool[]> {
const { serverName, client, capabilities, skipPrefix, deps } = options
if (!capabilities?.tools) {
return []
}
try {
const result = (await client.request(
{ method: 'tools/list' },
ListToolsResultSchema,
)) as ListToolsResult
// Sanitize tool data from MCP server
const toolsToProcess = recursivelySanitizeUnicode(result.tools)
return toolsToProcess.map((tool): CoreTool => {
const fullyQualifiedName = buildMcpToolName(serverName, tool.name)
const effectiveName = skipPrefix ? tool.name : fullyQualifiedName
return {
name: effectiveName,
mcpInfo: { serverName, toolName: tool.name },
isMcp: true,
inputJSONSchema: tool.inputSchema as CoreTool['inputJSONSchema'],
async description() {
return tool.description ?? ''
},
async prompt() {
const desc = tool.description ?? ''
return desc.length > MAX_MCP_DESCRIPTION_LENGTH
? desc.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '… [truncated]'
: desc
},
isConcurrencySafe: () => tool.annotations?.readOnlyHint ?? false,
isReadOnly: () => tool.annotations?.readOnlyHint ?? false,
isDestructive: () => tool.annotations?.destructiveHint ?? false,
isOpenWorld: () => tool.annotations?.openWorldHint ?? false,
isEnabled: () => true,
async checkPermissions() {
return { behavior: 'passthrough' as const }
},
toAutoClassifierInput: () => '',
userFacingName: () => tool.annotations?.title ?? tool.name,
maxResultSizeChars: 100_000,
mapToolResultToToolResultBlockParam: (content: unknown, id: string) => ({
type: 'tool_result' as const,
tool_use_id: id,
content,
}),
async call() {
throw new Error('Use manager.callTool() instead')
},
inputSchema: {} as CoreTool['inputSchema'],
} satisfies CoreTool
})
} catch (error) {
deps.logger.warn(`Failed to fetch tools for ${serverName}:`, error)
return []
}
}
// ============================================================================
// Cached tool discovery (LRU by server name)
// ============================================================================
/**
* Creates a memoized tool discovery function with LRU caching.
* Cache is keyed by server name (stable across reconnects).
*/
export function createCachedToolDiscovery(
deps: McpClientDependencies,
cacheSize: number = MCP_FETCH_CACHE_SIZE,
): {
discover: (server: ConnectedMCPServer, skipPrefix?: boolean) => Promise<CoreTool[]>
cache: { delete(key: string): void; clear(): void }
} {
const discover = memoizeWithLRU(
async (server: ConnectedMCPServer, skipPrefix?: boolean): Promise<CoreTool[]> => {
if (server.type !== 'connected') return []
return discoverTools({
serverName: server.name,
client: server.client,
capabilities: server.capabilities ?? {},
skipPrefix,
deps,
})
},
(server: ConnectedMCPServer) => server.name,
cacheSize,
)
return {
discover,
cache: discover.cache,
}
}

View File

@@ -0,0 +1,80 @@
// MCP typed error hierarchy
/**
* Base error class for all MCP-related errors.
*/
export class McpError extends Error {
constructor(
message: string,
public readonly serverName: string,
public readonly code: string,
) {
super(message)
this.name = 'McpError'
}
}
/**
* Error thrown when connection to an MCP server fails.
*/
export class McpConnectionError extends McpError {
constructor(
serverName: string,
message: string,
public readonly cause?: Error,
) {
super(message, serverName, 'CONNECTION_FAILED')
this.name = 'McpConnectionError'
}
}
/**
* Error thrown when authentication is required but not available.
*/
export class McpAuthError extends McpError {
constructor(serverName: string, message: string) {
super(message, serverName, 'AUTH_REQUIRED')
this.name = 'McpAuthError'
}
}
/**
* Error thrown when a connection or request times out.
*/
export class McpTimeoutError extends McpError {
constructor(
serverName: string,
public readonly timeoutMs: number,
) {
super(
`Connection to ${serverName} timed out after ${timeoutMs}ms`,
serverName,
'TIMEOUT',
)
this.name = 'McpTimeoutError'
}
}
/**
* Error thrown when an MCP tool call fails.
*/
export class McpToolCallError extends McpError {
constructor(
serverName: string,
public readonly toolName: string,
message: string,
) {
super(message, serverName, 'TOOL_CALL_FAILED')
this.name = 'McpToolCallError'
}
}
/**
* Error thrown when an MCP session has expired.
*/
export class McpSessionExpiredError extends McpError {
constructor(serverName: string) {
super(`Session expired for ${serverName}`, serverName, 'SESSION_EXPIRED')
this.name = 'McpSessionExpiredError'
}
}

View File

@@ -0,0 +1,182 @@
// MCP tool execution — call tools on connected MCP servers
// Extracted from src/services/mcp/client.ts (callMCPTool)
import {
CallToolResultSchema,
} from '@modelcontextprotocol/sdk/types.js'
import type { ConnectedMCPServer } from './types.js'
import type { McpClientDependencies } from './interfaces.js'
import {
McpToolCallError,
McpAuthError,
} from './errors.js'
// ============================================================================
// Constants
// ============================================================================
/** Default timeout for MCP tool calls (~27.8 hours — effectively infinite) */
const DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000
// ============================================================================
// Tool execution
// ============================================================================
export interface CallToolOptions {
/** The connected MCP server to call */
client: ConnectedMCPServer
/** Tool name (as registered on the server, not the fully qualified name) */
tool: string
/** Tool arguments */
args: Record<string, unknown>
/** Optional metadata to send with the call */
meta?: Record<string, unknown>
/** Abort signal for cancellation */
signal: AbortSignal
/** Progress callback */
onProgress?: (data: { progress?: number; total?: number; message?: string }) => void
/** Tool call timeout in ms (defaults to ~27.8 hours) */
timeoutMs?: number
}
export interface CallToolResult {
content: unknown
_meta?: Record<string, unknown>
structuredContent?: Record<string, unknown>
isError?: boolean
}
/**
* Call a tool on a connected MCP server with timeout and progress handling.
*
* This is the protocol-level tool execution function. The host is responsible for:
* - Session management (reconnection on expiry)
* - Result transformation (content processing, truncation, persistence)
* - Error wrapping for telemetry
*/
export async function callMcpTool(
options: CallToolOptions,
deps: McpClientDependencies,
): Promise<CallToolResult> {
const { client, tool, args, meta, signal, onProgress, timeoutMs } = options
const { name: serverName, client: mcpClient } = client
const effectiveTimeout = timeoutMs ?? getMcpToolTimeoutMs()
let progressInterval: ReturnType<typeof setInterval> | undefined
try {
deps.logger.debug(`[${serverName}] Calling MCP tool: ${tool}`)
// Progress logging for long-running tools (every 30 seconds)
progressInterval = setInterval(
() => {
deps.logger.debug(`[${serverName}] Tool '${tool}' still running`)
},
30_000,
)
const result = await Promise.race([
mcpClient.callTool(
{
name: tool,
arguments: args,
_meta: meta,
},
CallToolResultSchema,
{
signal,
timeout: effectiveTimeout,
onprogress: onProgress,
},
),
createTimeoutPromise(serverName, tool, effectiveTimeout),
])
// Handle isError in result
if ('isError' in result && result.isError) {
let errorDetails = 'Unknown error'
if (
'content' in result &&
Array.isArray(result.content) &&
result.content.length > 0
) {
const firstContent = result.content[0]
if (
firstContent &&
typeof firstContent === 'object' &&
'text' in firstContent
) {
errorDetails = (firstContent as { text: string }).text
}
}
throw new McpToolCallError(serverName, tool, errorDetails)
}
return {
content: result,
_meta: result._meta as Record<string, unknown> | undefined,
structuredContent: result.structuredContent as
| Record<string, unknown>
| undefined,
}
} catch (e) {
if (progressInterval !== undefined) {
clearInterval(progressInterval)
}
if (e instanceof Error && e.name !== 'AbortError') {
deps.logger.debug(
`[${serverName}] Tool '${tool}' failed: ${e.message}`,
)
}
// Check for 401 errors
if (e instanceof Error) {
const errorCode = 'code' in e ? (e.code as number | undefined) : undefined
if (errorCode === 401) {
throw new McpAuthError(
serverName,
`MCP server "${serverName}" requires re-authorization (token expired)`,
)
}
}
throw e
} finally {
if (progressInterval !== undefined) {
clearInterval(progressInterval)
}
}
}
// ============================================================================
// Helpers
// ============================================================================
function getMcpToolTimeoutMs(): number {
return (
parseInt(process.env.MCP_TOOL_TIMEOUT || '', 10) ||
DEFAULT_MCP_TOOL_TIMEOUT_MS
)
}
function createTimeoutPromise(
serverName: string,
tool: string,
timeoutMs: number,
): Promise<never> {
return new Promise((_, reject) => {
const timeoutId = setTimeout(
() => {
reject(
new Error(
`MCP server "${serverName}" tool "${tool}" timed out after ${Math.floor(timeoutMs / 1000)}s`,
),
)
},
timeoutMs,
)
timeoutId.unref?.()
})
}

View File

@@ -0,0 +1,124 @@
// mcp-client — MCP protocol client
// Strict protocol layer: connection, transport, tool discovery, execution
// Types & schemas
export {
ConfigScope,
TransportType,
McpStdioServerConfigSchema,
McpSSEServerConfigSchema,
McpHTTPServerConfigSchema,
McpWebSocketServerConfigSchema,
McpSdkServerConfigSchema,
McpClaudeAIProxyServerConfigSchema,
McpServerConfigSchema,
McpJsonConfigSchema,
} from './types.js'
export type {
ConfigScope as ConfigScopeType,
Transport,
McpStdioServerConfig,
McpSSEServerConfig,
McpSSEIDEServerConfig,
McpWebSocketIDEServerConfig,
McpHTTPServerConfig,
McpWebSocketServerConfig,
McpSdkServerConfig,
McpClaudeAIProxyServerConfig,
McpServerConfig,
ScopedMcpServerConfig,
McpJsonConfig,
MCPServerConnection,
ConnectedMCPServer,
FailedMCPServer,
NeedsAuthMCPServer,
PendingMCPServer,
DisabledMCPServer,
ServerResource,
SerializedTool,
SerializedClient,
MCPCliState,
} from './types.js'
// Errors
export {
McpError,
McpConnectionError,
McpAuthError,
McpTimeoutError,
McpToolCallError,
McpSessionExpiredError,
} from './errors.js'
// Interfaces (host dependency injection)
export type {
Logger,
AnalyticsSink,
FeatureGate,
AuthProvider,
ProxyConfig,
ContentStorage,
ImageProcessor,
HttpConfig,
SubprocessEnvProvider,
McpClientDependencies,
} from './interfaces.js'
// Transport
export { createLinkedTransportPair } from './transport/InProcessTransport.js'
// String utilities
export {
buildMcpToolName,
normalizeNameForMCP,
mcpInfoFromString,
getMcpPrefix,
getToolNameForPermissionCheck,
getMcpDisplayName,
extractMcpToolDisplayName,
} from './strings.js'
// Cache
export { memoizeWithLRU } from './cache.js'
// Sanitization
export { recursivelySanitizeUnicode } from './sanitization.js'
// Connection utilities
export {
DEFAULT_CONNECTION_TIMEOUT_MS,
MAX_MCP_DESCRIPTION_LENGTH,
MAX_ERRORS_BEFORE_RECONNECT,
createMcpClient,
withConnectionTimeout,
captureStderr,
isTerminalConnectionError,
isMcpSessionExpiredError,
installConnectionMonitor,
terminateWithSignalEscalation,
createCleanup,
buildConnectedServer,
} from './connection.js'
export type {
CreateClientOptions,
ConnectionMonitorOptions,
CleanupOptions,
BuildConnectedServerOptions,
} from './connection.js'
// Tool discovery
export {
MCP_FETCH_CACHE_SIZE,
discoverTools,
createCachedToolDiscovery,
} from './discovery.js'
export type { DiscoveryOptions } from './discovery.js'
// Tool execution
export { callMcpTool } from './execution.js'
export type { CallToolOptions, CallToolResult } from './execution.js'
// Manager (main API)
export { createMcpManager } from './manager.js'
export type { McpManager } from './manager.js'

View File

@@ -0,0 +1,74 @@
// Host dependency injection interfaces
// The MCP client package uses these interfaces to decouple from host infrastructure.
/** Logging interface */
export interface Logger {
debug(message: string, ...args: unknown[]): void
info(message: string, ...args: unknown[]): void
warn(message: string, ...args: unknown[]): void
error(message: string, ...args: unknown[]): void
}
/** Analytics/telemetry callback */
export interface AnalyticsSink {
trackEvent(event: string, metadata: Record<string, unknown>): void
}
/** Feature flag check */
export interface FeatureGate {
isEnabled(flag: string): boolean
}
/** OAuth token provider */
export interface AuthProvider {
getTokens(): Promise<{ accessToken: string } | null>
refreshTokens(): Promise<void>
handleOAuthError?(error: unknown): Promise<void>
}
/** HTTP/WebSocket proxy configuration */
export interface ProxyConfig {
getFetchOptions?(): Record<string, unknown>
getWebSocketAgent?(url: string): unknown
getWebSocketUrl?(url: string): string | undefined
getTLSOptions?(): Record<string, unknown> | undefined
}
/** Binary/image content persistence */
export interface ContentStorage {
persistBinaryContent(data: Buffer, ext: string): Promise<string>
persistToolResult?(toolUseId: string, content: unknown): Promise<void>
}
/** Image processing (resize, downsample) */
export interface ImageProcessor {
resizeAndDownsample?(buffer: Buffer): Promise<Buffer>
}
/** HTTP configuration (user agent, session ID) */
export interface HttpConfig {
getUserAgent(): string
getSessionId?(): string
}
/** Subprocess environment variable provider */
export interface SubprocessEnvProvider {
getEnv(additional?: Record<string, string>): Record<string, string>
}
/**
* Complete set of host dependencies required by the MCP client.
* All fields except `logger` and `httpConfig` are optional —
* the client degrades gracefully when they're not provided.
*/
export interface McpClientDependencies {
logger: Logger
analytics?: AnalyticsSink
featureGate?: FeatureGate
auth?: AuthProvider
proxy?: ProxyConfig
storage?: ContentStorage
imageProcessor?: ImageProcessor
httpConfig: HttpConfig
subprocessEnv?: SubprocessEnvProvider
}

View File

@@ -0,0 +1,241 @@
// McpManager — imperative API for MCP protocol client
// Factory function that creates a manager instance with event-based notifications
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type {
ListToolsResult,
} from '@modelcontextprotocol/sdk/types.js'
import memoize from 'lodash-es/memoize.js'
import { buildMcpToolName } from './strings.js'
import type { CoreTool } from '@claude-code-best/agent-tools'
import type {
McpServerConfig,
ScopedMcpServerConfig,
MCPServerConnection,
ConnectedMCPServer,
FailedMCPServer,
NeedsAuthMCPServer,
} from './types.js'
import type { McpClientDependencies } from './interfaces.js'
import {
McpConnectionError,
McpAuthError,
McpTimeoutError,
} from './errors.js'
import { memoizeWithLRU } from './cache.js'
import { discoverTools } from './discovery.js'
import { callMcpTool } from './execution.js'
// ============================================================================
// Event types
// ============================================================================
export type McpManagerEvents = {
connected: (name: string) => void
disconnected: (name: string, error?: Error) => void
toolsChanged: (serverName: string, tools: CoreTool[]) => void
error: (name: string, error: Error) => void
authRequired: (name: string) => void
}
type EventHandler = (...args: any[]) => void
// ============================================================================
// Manager interface
// ============================================================================
export interface McpManager {
connect(name: string, config: McpServerConfig): Promise<MCPServerConnection>
disconnect(name: string): Promise<void>
disconnectAll(): Promise<void>
getConnections(): Map<string, MCPServerConnection>
getTools(serverName: string): CoreTool[]
getAllTools(): CoreTool[]
callTool(serverName: string, toolName: string, args: unknown): Promise<unknown>
on<E extends keyof McpManagerEvents>(event: E, handler: McpManagerEvents[E]): void
off(event: string, handler: EventHandler): void
}
// ============================================================================
// Default timeout
// ============================================================================
const MCP_TIMEOUT_MS = 30_000
const MCP_REQUEST_TIMEOUT_MS = 60_000
// ============================================================================
// Manager implementation
// ============================================================================
class McpManagerImpl implements McpManager {
private connections = new Map<string, MCPServerConnection>()
private toolsCache = new Map<string, CoreTool[]>()
private listeners = new Map<string, Set<EventHandler>>()
private deps: McpClientDependencies
private connectFn: ((name: string, config: ScopedMcpServerConfig) => Promise<MCPServerConnection>) | null = null
constructor(deps: McpClientDependencies) {
this.deps = deps
}
/** Set the connect function — the host provides this with all transport logic */
setConnectFn(fn: (name: string, config: ScopedMcpServerConfig) => Promise<MCPServerConnection>): void {
this.connectFn = fn
}
async connect(name: string, config: McpServerConfig): Promise<MCPServerConnection> {
if (!this.connectFn) {
throw new Error('McpManager: connectFn not set. Call setConnectFn() first.')
}
const scopedConfig: ScopedMcpServerConfig = { ...config, scope: 'dynamic' }
try {
const connection = await this.connectFn(name, scopedConfig)
this.connections.set(name, connection)
if (connection.type === 'connected') {
this.emit('connected', name)
// Fetch tools for this server
await this.refreshTools(name, connection)
} else if (connection.type === 'needs-auth') {
this.emit('authRequired', name)
}
return connection
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
this.emit('error', name, error)
throw err
}
}
async disconnect(name: string): Promise<void> {
const conn = this.connections.get(name)
if (!conn) return
if (conn.type === 'connected') {
try {
await conn.cleanup()
} catch (err) {
this.deps.logger.warn(`Error disconnecting ${name}:`, err)
}
}
this.connections.delete(name)
this.toolsCache.delete(name)
this.emit('disconnected', name)
}
async disconnectAll(): Promise<void> {
const names = [...this.connections.keys()]
await Promise.all(names.map(name => this.disconnect(name)))
}
getConnections(): Map<string, MCPServerConnection> {
return new Map(this.connections)
}
getTools(serverName: string): CoreTool[] {
return this.toolsCache.get(serverName) ?? []
}
getAllTools(): CoreTool[] {
const all: CoreTool[] = []
for (const tools of this.toolsCache.values()) {
all.push(...tools)
}
return all
}
async callTool(serverName: string, toolName: string, args: unknown): Promise<unknown> {
const conn = this.connections.get(serverName)
if (!conn || conn.type !== 'connected') {
throw new McpConnectionError(serverName, `Server ${serverName} is not connected`)
}
return callMcpTool(
{
client: conn,
tool: toolName,
args: args as Record<string, unknown>,
signal: new AbortController().signal,
},
this.deps,
)
}
on<E extends keyof McpManagerEvents>(event: E, handler: McpManagerEvents[E]): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event)!.add(handler)
}
off(event: string, handler: EventHandler): void {
this.listeners.get(event)?.delete(handler)
}
// ── Private ──
private emit(event: string, ...args: unknown[]): void {
this.listeners.get(event)?.forEach(handler => {
try {
handler(...args)
} catch (err) {
this.deps.logger.error(`Error in ${event} handler:`, err)
}
})
}
private async refreshTools(name: string, conn: ConnectedMCPServer): Promise<void> {
try {
const tools = await discoverTools({
serverName: name,
client: conn.client,
capabilities: conn.capabilities ?? {},
deps: this.deps,
})
this.toolsCache.set(name, tools)
this.emit('toolsChanged', name, tools)
} catch (err) {
this.deps.logger.warn(`Failed to fetch tools for ${name}:`, err)
}
}
}
// ============================================================================
// Factory function
// ============================================================================
/**
* Creates a new MCP manager instance.
*
* The manager handles connection lifecycle, tool discovery, and event notification.
* The host must call `setConnectFn()` to provide the transport-level connection logic.
*
* @param deps Host dependency injections (logger, auth, proxy, etc.)
* @returns McpManager instance
*
* @example
* ```typescript
* const manager = createMcpManager({
* logger: console,
* httpConfig: { getUserAgent: () => 'my-app/1.0' },
* })
*
* manager.setConnectFn(async (name, config) => {
* // Transport-level connection logic here
* })
*
* manager.on('connected', (name) => console.log(`Connected to ${name}`))
* manager.on('toolsChanged', (name, tools) => console.log(`${name}: ${tools.length} tools`))
*
* await manager.connect('my-server', { command: 'npx', args: ['my-mcp-server'] })
* const tools = manager.getAllTools()
* ```
*/
export function createMcpManager(deps: McpClientDependencies): McpManager {
return new McpManagerImpl(deps)
}

View File

@@ -0,0 +1,31 @@
// Unicode sanitization for MCP data
// Extracted from src/utils/sanitization.ts
/**
* Recursively sanitizes Unicode characters in MCP server responses.
* Removes or replaces problematic Unicode that could cause display or parsing issues.
*/
export function recursivelySanitizeUnicode<T>(data: T): T {
if (typeof data === 'string') {
// Remove control characters except \t, \n, \r
// Replace null bytes and other C0 controls
return data
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
.replace(/\uFFFD/g, '') // replacement character
.normalize('NFC') as unknown as T
}
if (Array.isArray(data)) {
return data.map(item => recursivelySanitizeUnicode(item)) as unknown as T
}
if (data !== null && typeof data === 'object') {
const result = {} as Record<string, unknown>
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
result[key] = recursivelySanitizeUnicode(value)
}
return result as T
}
return data
}

View File

@@ -0,0 +1,86 @@
// MCP string utility functions — pure, no dependencies
// Extracted from src/services/mcp/mcpStringUtils.ts and normalization.ts
// Claude.ai server names are prefixed with this string
const CLAUDEAI_SERVER_PREFIX = 'claude.ai '
/**
* Normalize server names to be compatible with the API pattern ^[a-zA-Z0-9_-]{1,64}$
* Replaces any invalid characters (including dots and spaces) with underscores.
*/
export function normalizeNameForMCP(name: string): string {
let normalized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
if (name.startsWith(CLAUDEAI_SERVER_PREFIX)) {
normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '')
}
return normalized
}
/**
* Generates the MCP tool/command name prefix for a given server
*/
export function getMcpPrefix(serverName: string): string {
return `mcp__${normalizeNameForMCP(serverName)}__`
}
/**
* Builds a fully qualified MCP tool name from server and tool names.
* Inverse of mcpInfoFromString().
*/
export function buildMcpToolName(serverName: string, toolName: string): string {
return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}`
}
/**
* Extracts MCP server information from a tool name string.
* @param toolString Expected format: "mcp__serverName__toolName"
*/
export function mcpInfoFromString(toolString: string): {
serverName: string
toolName: string | undefined
} | null {
const parts = toolString.split('__')
const [mcpPart, serverName, ...toolNameParts] = parts
if (mcpPart !== 'mcp' || !serverName) {
return null
}
const toolName =
toolNameParts.length > 0 ? toolNameParts.join('__') : undefined
return { serverName, toolName }
}
/**
* Returns the name to use for permission rule matching.
*/
export function getToolNameForPermissionCheck(tool: {
name: string
mcpInfo?: { serverName: string; toolName: string }
}): string {
return tool.mcpInfo
? buildMcpToolName(tool.mcpInfo.serverName, tool.mcpInfo.toolName)
: tool.name
}
/**
* Extracts the display name from an MCP tool/command name
*/
export function getMcpDisplayName(
fullName: string,
serverName: string,
): string {
const prefix = `mcp__${normalizeNameForMCP(serverName)}__`
return fullName.replace(prefix, '')
}
/**
* Extracts just the tool/command display name from a userFacingName
*/
export function extractMcpToolDisplayName(userFacingName: string): string {
let withoutSuffix = userFacingName.replace(/\s*\(MCP\)\s*$/, '')
withoutSuffix = withoutSuffix.trim()
const dashIndex = withoutSuffix.indexOf(' - ')
if (dashIndex !== -1) {
return withoutSuffix.substring(dashIndex + 3).trim()
}
return withoutSuffix
}

View File

@@ -0,0 +1,63 @@
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
/**
* In-process linked transport pair for running an MCP server and client
* in the same process without spawning a subprocess.
*
* `send()` on one side delivers to `onmessage` on the other.
* `close()` on either side calls `onclose` on both.
*/
class InProcessTransport implements Transport {
private peer: InProcessTransport | undefined
private closed = false
onclose?: () => void
onerror?: (error: Error) => void
onmessage?: (message: JSONRPCMessage) => void
/** @internal */
_setPeer(peer: InProcessTransport): void {
this.peer = peer
}
async start(): Promise<void> {}
async send(message: JSONRPCMessage): Promise<void> {
if (this.closed) {
throw new Error('Transport is closed')
}
// Deliver to the other side asynchronously to avoid stack depth issues
// with synchronous request/response cycles
queueMicrotask(() => {
this.peer?.onmessage?.(message)
})
}
async close(): Promise<void> {
if (this.closed) {
return
}
this.closed = true
this.onclose?.()
// Close the peer if it hasn't already closed
if (this.peer && !this.peer.closed) {
this.peer.closed = true
this.peer.onclose?.()
}
}
}
/**
* Creates a pair of linked transports for in-process MCP communication.
* Messages sent on one transport are delivered to the other's `onmessage`.
*
* @returns [clientTransport, serverTransport]
*/
export function createLinkedTransportPair(): [Transport, Transport] {
const a = new InProcessTransport()
const b = new InProcessTransport()
a._setPeer(b)
b._setPeer(a)
return [a, b]
}

View File

@@ -0,0 +1,240 @@
// MCP configuration types, schemas, and connection state types
// Adapted from src/services/mcp/types.ts — uses zod directly instead of lazySchema
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type {
Resource,
ServerCapabilities,
} from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod/v4'
// ============================================================================
// Configuration scope
// ============================================================================
export const ConfigScope = z.enum([
'local',
'user',
'project',
'dynamic',
'enterprise',
'claudeai',
'managed',
])
export type ConfigScope = z.infer<typeof ConfigScope>
// ============================================================================
// Transport type
// ============================================================================
export const TransportType = z.enum([
'stdio',
'sse',
'sse-ide',
'http',
'ws',
'sdk',
'claudeai-proxy',
])
export type Transport = z.infer<typeof TransportType>
// ============================================================================
// Server configuration schemas
// ============================================================================
export const McpStdioServerConfigSchema = z.object({
type: z.literal('stdio').optional(),
command: z.string().min(1, 'Command cannot be empty'),
args: z.array(z.string()).default([]),
env: z.record(z.string(), z.string()).optional(),
})
const McpOAuthConfigSchema = z.object({
clientId: z.string().optional(),
callbackPort: z.number().int().positive().optional(),
authServerMetadataUrl: z
.string()
.url()
.startsWith('https://', {
message: 'authServerMetadataUrl must use https://',
})
.optional(),
xaa: z.boolean().optional(),
})
export const McpSSEServerConfigSchema = z.object({
type: z.literal('sse'),
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
headersHelper: z.string().optional(),
oauth: McpOAuthConfigSchema.optional(),
})
export const McpSSEIDEServerConfigSchema = z.object({
type: z.literal('sse-ide'),
url: z.string(),
ideName: z.string(),
ideRunningInWindows: z.boolean().optional(),
})
export const McpWebSocketIDEServerConfigSchema = z.object({
type: z.literal('ws-ide'),
url: z.string(),
ideName: z.string(),
authToken: z.string().optional(),
ideRunningInWindows: z.boolean().optional(),
})
export const McpHTTPServerConfigSchema = z.object({
type: z.literal('http'),
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
headersHelper: z.string().optional(),
oauth: McpOAuthConfigSchema.optional(),
})
export const McpWebSocketServerConfigSchema = z.object({
type: z.literal('ws'),
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
headersHelper: z.string().optional(),
})
export const McpSdkServerConfigSchema = z.object({
type: z.literal('sdk'),
name: z.string(),
})
export const McpClaudeAIProxyServerConfigSchema = z.object({
type: z.literal('claudeai-proxy'),
url: z.string(),
id: z.string(),
})
export const McpServerConfigSchema = z.union([
McpStdioServerConfigSchema,
McpSSEServerConfigSchema,
McpSSEIDEServerConfigSchema,
McpWebSocketIDEServerConfigSchema,
McpHTTPServerConfigSchema,
McpWebSocketServerConfigSchema,
McpSdkServerConfigSchema,
McpClaudeAIProxyServerConfigSchema,
])
// ============================================================================
// Inferred config types
// ============================================================================
export type McpStdioServerConfig = z.infer<typeof McpStdioServerConfigSchema>
export type McpSSEServerConfig = z.infer<typeof McpSSEServerConfigSchema>
export type McpSSEIDEServerConfig = z.infer<typeof McpSSEIDEServerConfigSchema>
export type McpWebSocketIDEServerConfig = z.infer<
typeof McpWebSocketIDEServerConfigSchema
>
export type McpHTTPServerConfig = z.infer<typeof McpHTTPServerConfigSchema>
export type McpWebSocketServerConfig = z.infer<
typeof McpWebSocketServerConfigSchema
>
export type McpSdkServerConfig = z.infer<typeof McpSdkServerConfigSchema>
export type McpClaudeAIProxyServerConfig = z.infer<
typeof McpClaudeAIProxyServerConfigSchema
>
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>
export type ScopedMcpServerConfig = McpServerConfig & {
scope: ConfigScope
pluginSource?: string
}
export const McpJsonConfigSchema = z.object({
mcpServers: z.record(z.string(), McpServerConfigSchema),
})
export type McpJsonConfig = z.infer<typeof McpJsonConfigSchema>
// ============================================================================
// Server connection state types
// ============================================================================
export type ConnectedMCPServer = {
client: Client
name: string
type: 'connected'
capabilities: ServerCapabilities
serverInfo?: {
name: string
version: string
}
instructions?: string
config: ScopedMcpServerConfig
cleanup: () => Promise<void>
}
export type FailedMCPServer = {
name: string
type: 'failed'
config: ScopedMcpServerConfig
error?: string
}
export type NeedsAuthMCPServer = {
name: string
type: 'needs-auth'
config: ScopedMcpServerConfig
}
export type PendingMCPServer = {
name: string
type: 'pending'
config: ScopedMcpServerConfig
reconnectAttempt?: number
maxReconnectAttempts?: number
}
export type DisabledMCPServer = {
name: string
type: 'disabled'
config: ScopedMcpServerConfig
}
export type MCPServerConnection =
| ConnectedMCPServer
| FailedMCPServer
| NeedsAuthMCPServer
| PendingMCPServer
| DisabledMCPServer
// ============================================================================
// Resource and serialization types
// ============================================================================
export type ServerResource = Resource & { server: string }
export interface SerializedTool {
name: string
description: string
inputJSONSchema?: {
[x: string]: unknown
type: 'object'
properties?: {
[x: string]: unknown
}
}
isMcp?: boolean
originalToolName?: string
}
export interface SerializedClient {
name: string
type: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled'
capabilities?: ServerCapabilities
}
export interface MCPCliState {
clients: SerializedClient[]
configs: Record<string, ScopedMcpServerConfig>
tools: SerializedTool[]
resources: Record<string, ServerResource[]>
normalizedNames?: Record<string, string>
}