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:
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user