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

* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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