mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 00:35:51 +00:00
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:
16
packages/mcp-client/package.json
Normal file
16
packages/mcp-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
80
packages/mcp-client/src/__tests__/InProcessTransport.test.ts
Normal file
80
packages/mcp-client/src/__tests__/InProcessTransport.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
80
packages/mcp-client/src/__tests__/cache.test.ts
Normal file
80
packages/mcp-client/src/__tests__/cache.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
84
packages/mcp-client/src/__tests__/connection.test.ts
Normal file
84
packages/mcp-client/src/__tests__/connection.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
162
packages/mcp-client/src/__tests__/discovery.test.ts
Normal file
162
packages/mcp-client/src/__tests__/discovery.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
69
packages/mcp-client/src/__tests__/errors.test.ts
Normal file
69
packages/mcp-client/src/__tests__/errors.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
144
packages/mcp-client/src/__tests__/execution.test.ts
Normal file
144
packages/mcp-client/src/__tests__/execution.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
113
packages/mcp-client/src/__tests__/manager.test.ts
Normal file
113
packages/mcp-client/src/__tests__/manager.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
51
packages/mcp-client/src/__tests__/sanitization.test.ts
Normal file
51
packages/mcp-client/src/__tests__/sanitization.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
101
packages/mcp-client/src/__tests__/strings.test.ts
Normal file
101
packages/mcp-client/src/__tests__/strings.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
58
packages/mcp-client/src/cache.ts
Normal file
58
packages/mcp-client/src/cache.ts
Normal 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
|
||||
}
|
||||
519
packages/mcp-client/src/connection.ts
Normal file
519
packages/mcp-client/src/connection.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
143
packages/mcp-client/src/discovery.ts
Normal file
143
packages/mcp-client/src/discovery.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
80
packages/mcp-client/src/errors.ts
Normal file
80
packages/mcp-client/src/errors.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
182
packages/mcp-client/src/execution.ts
Normal file
182
packages/mcp-client/src/execution.ts
Normal 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?.()
|
||||
})
|
||||
}
|
||||
124
packages/mcp-client/src/index.ts
Normal file
124
packages/mcp-client/src/index.ts
Normal 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'
|
||||
74
packages/mcp-client/src/interfaces.ts
Normal file
74
packages/mcp-client/src/interfaces.ts
Normal 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
|
||||
}
|
||||
241
packages/mcp-client/src/manager.ts
Normal file
241
packages/mcp-client/src/manager.ts
Normal 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)
|
||||
}
|
||||
31
packages/mcp-client/src/sanitization.ts
Normal file
31
packages/mcp-client/src/sanitization.ts
Normal 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
|
||||
}
|
||||
86
packages/mcp-client/src/strings.ts
Normal file
86
packages/mcp-client/src/strings.ts
Normal 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
|
||||
}
|
||||
63
packages/mcp-client/src/transport/InProcessTransport.ts
Normal file
63
packages/mcp-client/src/transport/InProcessTransport.ts
Normal 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]
|
||||
}
|
||||
240
packages/mcp-client/src/types.ts
Normal file
240
packages/mcp-client/src/types.ts
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user