mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -13,7 +13,9 @@ describe('InProcessTransport', () => {
|
||||
const [client, server] = createLinkedTransportPair()
|
||||
|
||||
let received: JSONRPCMessage | null = null
|
||||
server.onmessage = (msg) => { received = msg }
|
||||
server.onmessage = msg => {
|
||||
received = msg
|
||||
}
|
||||
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
@@ -36,7 +38,9 @@ describe('InProcessTransport', () => {
|
||||
const [client, server] = createLinkedTransportPair()
|
||||
|
||||
let received: JSONRPCMessage | null = null
|
||||
client.onmessage = (msg) => { received = msg }
|
||||
client.onmessage = msg => {
|
||||
received = msg
|
||||
}
|
||||
|
||||
await server.send({ jsonrpc: '2.0', result: 42, id: 1 } as any)
|
||||
|
||||
@@ -50,8 +54,12 @@ describe('InProcessTransport', () => {
|
||||
|
||||
let clientClosed = false
|
||||
let serverClosed = false
|
||||
client.onclose = () => { clientClosed = true }
|
||||
server.onclose = () => { serverClosed = true }
|
||||
client.onclose = () => {
|
||||
clientClosed = true
|
||||
}
|
||||
server.onclose = () => {
|
||||
serverClosed = true
|
||||
}
|
||||
|
||||
await client.close()
|
||||
|
||||
@@ -63,7 +71,9 @@ describe('InProcessTransport', () => {
|
||||
const [client] = createLinkedTransportPair()
|
||||
|
||||
let closeCount = 0
|
||||
client.onclose = () => { closeCount++ }
|
||||
client.onclose = () => {
|
||||
closeCount++
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close()
|
||||
@@ -75,6 +85,8 @@ describe('InProcessTransport', () => {
|
||||
const [client] = createLinkedTransportPair()
|
||||
await client.close()
|
||||
|
||||
expect(client.send({ jsonrpc: '2.0', method: 'test' } as any)).rejects.toThrow('Transport is closed')
|
||||
expect(
|
||||
client.send({ jsonrpc: '2.0', method: 'test' } as any),
|
||||
).rejects.toThrow('Transport is closed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,11 @@ describe('memoizeWithLRU', () => {
|
||||
test('caches results', () => {
|
||||
let callCount = 0
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => { callCount++; return x * 2 },
|
||||
(x) => `key-${x}`,
|
||||
(x: number) => {
|
||||
callCount++
|
||||
return x * 2
|
||||
},
|
||||
x => `key-${x}`,
|
||||
10,
|
||||
)
|
||||
|
||||
@@ -19,7 +22,7 @@ describe('memoizeWithLRU', () => {
|
||||
test('evicts least recently used entries', () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x,
|
||||
(x) => `key-${x}`,
|
||||
x => `key-${x}`,
|
||||
2,
|
||||
)
|
||||
|
||||
@@ -36,7 +39,7 @@ describe('memoizeWithLRU', () => {
|
||||
test('cache.clear removes all entries', () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x,
|
||||
(x) => `key-${x}`,
|
||||
x => `key-${x}`,
|
||||
10,
|
||||
)
|
||||
|
||||
@@ -51,7 +54,7 @@ describe('memoizeWithLRU', () => {
|
||||
test('cache.delete removes specific entry', () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x,
|
||||
(x) => `key-${x}`,
|
||||
x => `key-${x}`,
|
||||
10,
|
||||
)
|
||||
|
||||
@@ -65,7 +68,7 @@ describe('memoizeWithLRU', () => {
|
||||
test('cache.get returns value without promoting', () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x * 10,
|
||||
(x) => `key-${x}`,
|
||||
x => `key-${x}`,
|
||||
2,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ describe('isTerminalConnectionError', () => {
|
||||
})
|
||||
|
||||
test('detects ETIMEDOUT', () => {
|
||||
expect(isTerminalConnectionError('Connection timed out: ETIMEDOUT')).toBe(true)
|
||||
expect(isTerminalConnectionError('Connection timed out: ETIMEDOUT')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('detects EPIPE', () => {
|
||||
@@ -29,16 +31,22 @@ describe('isTerminalConnectionError', () => {
|
||||
})
|
||||
|
||||
test('detects EHOSTUNREACH', () => {
|
||||
expect(isTerminalConnectionError('Host unreachable: EHOSTUNREACH')).toBe(true)
|
||||
expect(isTerminalConnectionError('Host unreachable: EHOSTUNREACH')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('detects ECONNREFUSED', () => {
|
||||
expect(isTerminalConnectionError('Connection refused: ECONNREFUSED')).toBe(true)
|
||||
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)
|
||||
expect(isTerminalConnectionError('Failed to reconnect SSE stream')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('detects terminated', () => {
|
||||
@@ -48,13 +56,17 @@ describe('isTerminalConnectionError', () => {
|
||||
test('rejects non-terminal errors', () => {
|
||||
expect(isTerminalConnectionError('some random error')).toBe(false)
|
||||
expect(isTerminalConnectionError('')).toBe(false)
|
||||
expect(isTerminalConnectionError('timeout waiting for response')).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"}')
|
||||
const error = new Error(
|
||||
'Not found: {"code":-32001,"message":"Session not found"}',
|
||||
)
|
||||
Object.assign(error, { code: 404 })
|
||||
expect(isMcpSessionExpiredError(error)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -55,11 +55,20 @@ describe('discoverTools', () => {
|
||||
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.mcpInfo).toEqual({
|
||||
serverName: 'my-server',
|
||||
toolName: 'search',
|
||||
})
|
||||
expect(tool.isMcp).toBe(true)
|
||||
expect(tool.isReadOnly({} as any)).toBe(true)
|
||||
expect(tool.userFacingName(undefined)).toBe('Search Items')
|
||||
expect(await tool.description({} as any, { isNonInteractiveSession: false, toolPermissionContext: {}, tools: [] })).toBe('Search for items')
|
||||
expect(
|
||||
await tool.description({} as any, {
|
||||
isNonInteractiveSession: false,
|
||||
toolPermissionContext: {},
|
||||
tools: [],
|
||||
}),
|
||||
).toBe('Search for items')
|
||||
})
|
||||
|
||||
test('respects skipPrefix option', async () => {
|
||||
|
||||
@@ -2,7 +2,11 @@ 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 {
|
||||
ScopedMcpServerConfig,
|
||||
MCPServerConnection,
|
||||
ConnectedMCPServer,
|
||||
} from '../types.js'
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
|
||||
function createMockDeps(): McpClientDependencies {
|
||||
@@ -36,14 +40,17 @@ describe('createMcpManager', () => {
|
||||
|
||||
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')
|
||||
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 })
|
||||
manager.on('connected', (name: string) => {
|
||||
connectedEvent = name
|
||||
})
|
||||
|
||||
const mockConnection: ConnectedMCPServer = {
|
||||
type: 'connected',
|
||||
@@ -53,17 +60,26 @@ describe('createMcpManager', () => {
|
||||
onclose: null,
|
||||
} as unknown as Client,
|
||||
capabilities: {},
|
||||
config: { command: 'npx', args: [], scope: 'dynamic' } as ScopedMcpServerConfig,
|
||||
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
|
||||
})
|
||||
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: [] })
|
||||
const result = await manager.connect('test-server', {
|
||||
command: 'npx',
|
||||
args: [],
|
||||
})
|
||||
expect(result.type).toBe('connected')
|
||||
expect(connectedEvent as unknown as string).toBe('test-server')
|
||||
})
|
||||
@@ -71,15 +87,23 @@ describe('createMcpManager', () => {
|
||||
test('disconnect calls cleanup and emits disconnected', async () => {
|
||||
const manager = createMcpManager(createMockDeps()) as any
|
||||
let disconnected = false
|
||||
manager.on('disconnected', () => { disconnected = true })
|
||||
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,
|
||||
client: {
|
||||
request: mock(() => Promise.resolve({ tools: [] })),
|
||||
} as unknown as Client,
|
||||
capabilities: {},
|
||||
config: { command: 'npx', args: [], scope: 'dynamic' } as ScopedMcpServerConfig,
|
||||
config: {
|
||||
command: 'npx',
|
||||
args: [],
|
||||
scope: 'dynamic',
|
||||
} as ScopedMcpServerConfig,
|
||||
cleanup: mockCleanup,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ describe('normalizeNameForMCP', () => {
|
||||
|
||||
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')
|
||||
expect(normalizeNameForMCP('claude.ai My Server')).toBe(
|
||||
'claude_ai_My_Server',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +35,9 @@ describe('buildMcpToolName', () => {
|
||||
})
|
||||
|
||||
test('normalizes server name with dots', () => {
|
||||
expect(buildMcpToolName('test.server', 'tool')).toBe('mcp__test_server__tool')
|
||||
expect(buildMcpToolName('test.server', 'tool')).toBe(
|
||||
'mcp__test_server__tool',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,10 +72,12 @@ describe('getMcpPrefix', () => {
|
||||
|
||||
describe('getToolNameForPermissionCheck', () => {
|
||||
test('uses mcp prefix for MCP tools', () => {
|
||||
expect(getToolNameForPermissionCheck({
|
||||
name: 'query',
|
||||
mcpInfo: { serverName: 'my-server', toolName: 'query' },
|
||||
})).toBe('mcp__my-server__query')
|
||||
expect(
|
||||
getToolNameForPermissionCheck({
|
||||
name: 'query',
|
||||
mcpInfo: { serverName: 'my-server', toolName: 'query' },
|
||||
}),
|
||||
).toBe('mcp__my-server__query')
|
||||
})
|
||||
|
||||
test('uses raw name for non-MCP tools', () => {
|
||||
@@ -82,13 +88,17 @@ describe('getToolNameForPermissionCheck', () => {
|
||||
describe('getMcpDisplayName', () => {
|
||||
test('strips MCP prefix', () => {
|
||||
// getMcpDisplayName normalizes server name before building prefix
|
||||
expect(getMcpDisplayName('mcp__my_server__query', 'my.server')).toBe('query')
|
||||
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')
|
||||
expect(extractMcpToolDisplayName('github - Add comment (MCP)')).toBe(
|
||||
'Add comment',
|
||||
)
|
||||
})
|
||||
|
||||
test('handles no dash', () => {
|
||||
@@ -96,6 +106,8 @@ describe('extractMcpToolDisplayName', () => {
|
||||
})
|
||||
|
||||
test('handles no suffix', () => {
|
||||
expect(extractMcpToolDisplayName('github - Add comment')).toBe('Add comment')
|
||||
expect(extractMcpToolDisplayName('github - Add comment')).toBe(
|
||||
'Add comment',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,11 +91,7 @@ export async function withConnectionTimeout<T>(
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const timeoutId = setTimeout(async () => {
|
||||
await onTimeout()
|
||||
reject(
|
||||
new Error(
|
||||
`MCP connection timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
)
|
||||
reject(new Error(`MCP connection timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
// Clean up timeout if connect resolves or rejects
|
||||
@@ -119,7 +115,11 @@ export async function withConnectionTimeout<T>(
|
||||
export function captureStderr(
|
||||
transport: StdioClientTransport,
|
||||
maxSize = 64 * 1024 * 1024,
|
||||
): { getOutput: () => string; clearOutput: () => void; removeHandler: () => void } {
|
||||
): {
|
||||
getOutput: () => string
|
||||
clearOutput: () => void
|
||||
removeHandler: () => void
|
||||
} {
|
||||
let stderrOutput = ''
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
@@ -136,8 +136,12 @@ export function captureStderr(
|
||||
|
||||
return {
|
||||
getOutput: () => stderrOutput,
|
||||
clearOutput: () => { stderrOutput = '' },
|
||||
removeHandler: () => { transport.stderr?.off('data', handler) },
|
||||
clearOutput: () => {
|
||||
stderrOutput = ''
|
||||
},
|
||||
removeHandler: () => {
|
||||
transport.stderr?.off('data', handler)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +201,13 @@ export function installConnectionMonitor(
|
||||
client: Client,
|
||||
options: ConnectionMonitorOptions,
|
||||
): () => void {
|
||||
const { serverName, transportType, logger, closeTransport, onConnectionClosed } = options
|
||||
const {
|
||||
serverName,
|
||||
transportType,
|
||||
logger,
|
||||
closeTransport,
|
||||
onConnectionClosed,
|
||||
} = options
|
||||
const connectionStartTime = Date.now()
|
||||
let hasErrorOccurred = false
|
||||
let consecutiveConnectionErrors = 0
|
||||
@@ -310,6 +320,7 @@ export async function terminateWithSignalEscalation(
|
||||
return
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: complex cleanup logic requires async in executor
|
||||
await new Promise<void>(async resolve => {
|
||||
let resolved = false
|
||||
|
||||
@@ -331,7 +342,9 @@ export async function terminateWithSignalEscalation(
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearInterval(checkInterval)
|
||||
logger.debug(`[${serverName}] Cleanup timeout reached, stopping process monitoring`)
|
||||
logger.debug(
|
||||
`[${serverName}] Cleanup timeout reached, stopping process monitoring`,
|
||||
)
|
||||
resolve()
|
||||
}
|
||||
}, 600)
|
||||
@@ -348,7 +361,9 @@ export async function terminateWithSignalEscalation(
|
||||
try {
|
||||
process.kill(childPid, 'SIGTERM')
|
||||
} catch (termError) {
|
||||
logger.debug(`[${serverName}] Error sending SIGTERM: ${termError}`)
|
||||
logger.debug(
|
||||
`[${serverName}] Error sending SIGTERM: ${termError}`,
|
||||
)
|
||||
resolved = true
|
||||
clearInterval(checkInterval)
|
||||
clearTimeout(failsafeTimeout)
|
||||
@@ -373,7 +388,9 @@ export async function terminateWithSignalEscalation(
|
||||
try {
|
||||
process.kill(childPid, 'SIGKILL')
|
||||
} catch (killError) {
|
||||
logger.debug(`[${serverName}] Error sending SIGKILL: ${killError}`)
|
||||
logger.debug(
|
||||
`[${serverName}] Error sending SIGKILL: ${killError}`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
resolved = true
|
||||
@@ -446,7 +463,9 @@ export function createCleanup(options: CleanupOptions): () => Promise<void> {
|
||||
try {
|
||||
await inProcessServer.close()
|
||||
} catch (error) {
|
||||
logger.debug(`[${serverName}] Error closing in-process server: ${error}`)
|
||||
logger.debug(
|
||||
`[${serverName}] Error closing in-process server: ${error}`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
await client.close()
|
||||
@@ -500,7 +519,8 @@ export function buildConnectedServer(
|
||||
|
||||
let instructions = rawInstructions
|
||||
if (rawInstructions && rawInstructions.length > MAX_MCP_DESCRIPTION_LENGTH) {
|
||||
instructions = rawInstructions.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '… [truncated]'
|
||||
instructions =
|
||||
rawInstructions.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '… [truncated]'
|
||||
logger.debug(
|
||||
`[${name}] Server instructions truncated from ${rawInstructions.length} to ${MAX_MCP_DESCRIPTION_LENGTH} chars`,
|
||||
)
|
||||
|
||||
@@ -44,7 +44,9 @@ export interface DiscoveryOptions {
|
||||
* 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[]> {
|
||||
export async function discoverTools(
|
||||
options: DiscoveryOptions,
|
||||
): Promise<CoreTool[]> {
|
||||
const { serverName, client, capabilities, skipPrefix, deps } = options
|
||||
|
||||
if (!capabilities?.tools) {
|
||||
@@ -89,7 +91,10 @@ export async function discoverTools(options: DiscoveryOptions): Promise<CoreTool
|
||||
toAutoClassifierInput: () => '',
|
||||
userFacingName: () => tool.annotations?.title ?? tool.name,
|
||||
maxResultSizeChars: 100_000,
|
||||
mapToolResultToToolResultBlockParam: (content: unknown, id: string) => ({
|
||||
mapToolResultToToolResultBlockParam: (
|
||||
content: unknown,
|
||||
id: string,
|
||||
) => ({
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: id,
|
||||
content,
|
||||
@@ -118,11 +123,17 @@ export function createCachedToolDiscovery(
|
||||
deps: McpClientDependencies,
|
||||
cacheSize: number = MCP_FETCH_CACHE_SIZE,
|
||||
): {
|
||||
discover: (server: ConnectedMCPServer, skipPrefix?: boolean) => Promise<CoreTool[]>
|
||||
discover: (
|
||||
server: ConnectedMCPServer,
|
||||
skipPrefix?: boolean,
|
||||
) => Promise<CoreTool[]>
|
||||
cache: { delete(key: string): void; clear(): void }
|
||||
} {
|
||||
const discover = memoizeWithLRU(
|
||||
async (server: ConnectedMCPServer, skipPrefix?: boolean): Promise<CoreTool[]> => {
|
||||
async (
|
||||
server: ConnectedMCPServer,
|
||||
skipPrefix?: boolean,
|
||||
): Promise<CoreTool[]> => {
|
||||
if (server.type !== 'connected') return []
|
||||
return discoverTools({
|
||||
serverName: server.name,
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
// 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 { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { ConnectedMCPServer } from './types.js'
|
||||
import type { McpClientDependencies } from './interfaces.js'
|
||||
import {
|
||||
McpToolCallError,
|
||||
McpAuthError,
|
||||
} from './errors.js'
|
||||
import { McpToolCallError, McpAuthError } from './errors.js'
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
@@ -34,7 +29,11 @@ export interface CallToolOptions {
|
||||
/** Abort signal for cancellation */
|
||||
signal: AbortSignal
|
||||
/** Progress callback */
|
||||
onProgress?: (data: { progress?: number; total?: number; message?: string }) => void
|
||||
onProgress?: (data: {
|
||||
progress?: number
|
||||
total?: number
|
||||
message?: string
|
||||
}) => void
|
||||
/** Tool call timeout in ms (defaults to ~27.8 hours) */
|
||||
timeoutMs?: number
|
||||
}
|
||||
@@ -68,12 +67,9 @@ export async function callMcpTool(
|
||||
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,
|
||||
)
|
||||
progressInterval = setInterval(() => {
|
||||
deps.logger.debug(`[${serverName}] Tool '${tool}' still running`)
|
||||
}, 30_000)
|
||||
|
||||
const result = await Promise.race([
|
||||
mcpClient.callTool(
|
||||
@@ -126,9 +122,7 @@ export async function callMcpTool(
|
||||
}
|
||||
|
||||
if (e instanceof Error && e.name !== 'AbortError') {
|
||||
deps.logger.debug(
|
||||
`[${serverName}] Tool '${tool}' failed: ${e.message}`,
|
||||
)
|
||||
deps.logger.debug(`[${serverName}] Tool '${tool}' failed: ${e.message}`)
|
||||
}
|
||||
|
||||
// Check for 401 errors
|
||||
@@ -167,16 +161,13 @@ function createTimeoutPromise(
|
||||
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,
|
||||
)
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`MCP server "${serverName}" tool "${tool}" timed out after ${Math.floor(timeoutMs / 1000)}s`,
|
||||
),
|
||||
)
|
||||
}, timeoutMs)
|
||||
timeoutId.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
// 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 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'
|
||||
@@ -17,11 +15,7 @@ import type {
|
||||
NeedsAuthMCPServer,
|
||||
} from './types.js'
|
||||
import type { McpClientDependencies } from './interfaces.js'
|
||||
import {
|
||||
McpConnectionError,
|
||||
McpAuthError,
|
||||
McpTimeoutError,
|
||||
} from './errors.js'
|
||||
import { McpConnectionError, McpAuthError, McpTimeoutError } from './errors.js'
|
||||
import { memoizeWithLRU } from './cache.js'
|
||||
import { discoverTools } from './discovery.js'
|
||||
import { callMcpTool } from './execution.js'
|
||||
@@ -51,8 +45,15 @@ export interface McpManager {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -72,20 +73,35 @@ class McpManagerImpl implements McpManager {
|
||||
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
|
||||
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 {
|
||||
setConnectFn(
|
||||
fn: (
|
||||
name: string,
|
||||
config: ScopedMcpServerConfig,
|
||||
) => Promise<MCPServerConnection>,
|
||||
): void {
|
||||
this.connectFn = fn
|
||||
}
|
||||
|
||||
async connect(name: string, config: McpServerConfig): Promise<MCPServerConnection> {
|
||||
async connect(
|
||||
name: string,
|
||||
config: McpServerConfig,
|
||||
): Promise<MCPServerConnection> {
|
||||
if (!this.connectFn) {
|
||||
throw new Error('McpManager: connectFn not set. Call setConnectFn() first.')
|
||||
throw new Error(
|
||||
'McpManager: connectFn not set. Call setConnectFn() first.',
|
||||
)
|
||||
}
|
||||
|
||||
const scopedConfig: ScopedMcpServerConfig = { ...config, scope: 'dynamic' }
|
||||
@@ -148,10 +164,17 @@ class McpManagerImpl implements McpManager {
|
||||
return all
|
||||
}
|
||||
|
||||
async callTool(serverName: string, toolName: string, args: unknown): Promise<unknown> {
|
||||
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`)
|
||||
throw new McpConnectionError(
|
||||
serverName,
|
||||
`Server ${serverName} is not connected`,
|
||||
)
|
||||
}
|
||||
|
||||
return callMcpTool(
|
||||
@@ -165,7 +188,10 @@ class McpManagerImpl implements McpManager {
|
||||
)
|
||||
}
|
||||
|
||||
on<E extends keyof McpManagerEvents>(event: E, handler: McpManagerEvents[E]): void {
|
||||
on<E extends keyof McpManagerEvents>(
|
||||
event: E,
|
||||
handler: McpManagerEvents[E],
|
||||
): void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set())
|
||||
}
|
||||
@@ -188,7 +214,10 @@ class McpManagerImpl implements McpManager {
|
||||
})
|
||||
}
|
||||
|
||||
private async refreshTools(name: string, conn: ConnectedMCPServer): Promise<void> {
|
||||
private async refreshTools(
|
||||
name: string,
|
||||
conn: ConnectedMCPServer,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const tools = await discoverTools({
|
||||
serverName: name,
|
||||
|
||||
@@ -9,10 +9,13 @@ 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
|
||||
return (
|
||||
data
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control character sanitization
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
||||
.replace(/\uFFFD/g, '') // replacement character
|
||||
.normalize('NFC') as unknown as T
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
@@ -21,7 +24,9 @@ export function recursivelySanitizeUnicode<T>(data: T): 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>)) {
|
||||
for (const [key, value] of Object.entries(
|
||||
data as Record<string, unknown>,
|
||||
)) {
|
||||
result[key] = recursivelySanitizeUnicode(value)
|
||||
}
|
||||
return result as T
|
||||
|
||||
Reference in New Issue
Block a user