From 12dd802ad8f5fbd537efb01279822a5313174d59 Mon Sep 17 00:00:00 2001 From: MrAvalonApple <74775400+ibrahimkazimov@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:01:22 +0300 Subject: [PATCH] feat: update MCP GitHub example and added llmInputSchema --- examples/16-mcp-github.ts | 10 ++- src/tool/framework.ts | 27 ++++-- src/tool/mcp.ts | 169 ++++++++++++++++++++++++++++++++------ src/types.ts | 8 +- tests/mcp-tools.test.ts | 125 ++++++++++++++++++++++++---- 5 files changed, 293 insertions(+), 46 deletions(-) diff --git a/examples/16-mcp-github.ts b/examples/16-mcp-github.ts index 5ab5fcd..8019cd2 100644 --- a/examples/16-mcp-github.ts +++ b/examples/16-mcp-github.ts @@ -8,7 +8,7 @@ * npx tsx examples/16-mcp-github.ts * * Prerequisites: - * - ANTHROPIC_API_KEY + * - GEMINI_API_KEY * - GITHUB_TOKEN * - @modelcontextprotocol/sdk installed */ @@ -16,6 +16,11 @@ import { Agent, ToolExecutor, ToolRegistry, registerBuiltInTools } from '../src/index.js' import { connectMCPTools } from '../src/mcp.js' +if (!process.env.GITHUB_TOKEN?.trim()) { + console.error('Missing GITHUB_TOKEN: set a GitHub personal access token in the environment.') + process.exit(1) +} + const { tools, disconnect } = await connectMCPTools({ command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'], @@ -34,7 +39,8 @@ const executor = new ToolExecutor(registry) const agent = new Agent( { name: 'github-agent', - model: 'claude-sonnet-4-6', + model: 'gemini-2.5-flash', + provider: 'gemini', tools: tools.map((tool) => tool.name), systemPrompt: 'Use GitHub MCP tools to answer repository questions.', }, diff --git a/src/tool/framework.ts b/src/tool/framework.ts index 3b25c97..9add2be 100644 --- a/src/tool/framework.ts +++ b/src/tool/framework.ts @@ -72,12 +72,19 @@ export function defineTool(config: { name: string description: string inputSchema: ZodSchema + /** + * Optional JSON Schema for the LLM (bypasses Zod → JSON Schema conversion). + */ + llmInputSchema?: Record execute: (input: TInput, context: ToolUseContext) => Promise }): ToolDefinition { return { name: config.name, description: config.description, inputSchema: config.inputSchema, + ...(config.llmInputSchema !== undefined + ? { llmInputSchema: config.llmInputSchema } + : {}), execute: config.execute, } } @@ -169,7 +176,8 @@ export class ToolRegistry { */ toToolDefs(): LLMToolDef[] { return Array.from(this.tools.values()).map((tool) => { - const schema = zodToJsonSchema(tool.inputSchema) + const schema = + tool.llmInputSchema ?? zodToJsonSchema(tool.inputSchema) return { name: tool.name, description: tool.description, @@ -194,13 +202,20 @@ export class ToolRegistry { toLLMTools(): Array<{ name: string description: string - input_schema: { - type: 'object' - properties: Record - required?: string[] - } + /** Anthropic-style tool input JSON Schema (`type` is usually `object`). */ + input_schema: Record }> { return Array.from(this.tools.values()).map((tool) => { + if (tool.llmInputSchema !== undefined) { + return { + name: tool.name, + description: tool.description, + input_schema: { + type: 'object' as const, + ...(tool.llmInputSchema as Record), + }, + } + } const schema = zodToJsonSchema(tool.inputSchema) return { name: tool.name, diff --git a/src/tool/mcp.ts b/src/tool/mcp.ts index b9c08ff..ad7f9fe 100644 --- a/src/tool/mcp.ts +++ b/src/tool/mcp.ts @@ -5,22 +5,33 @@ import type { ToolDefinition } from '../types.js' interface MCPToolDescriptor { name: string description?: string + /** MCP tool JSON Schema; same shape LLM APIs expect for object parameters. */ + inputSchema?: Record } interface MCPListToolsResponse { tools?: MCPToolDescriptor[] + nextCursor?: string } interface MCPCallToolResponse { - content?: Array<{ type?: string; text?: string }> + content?: Array> structuredContent?: unknown isError?: boolean + toolResult?: unknown } interface MCPClientLike { - connect(transport: unknown): Promise - listTools(): Promise - callTool(request: { name: string; arguments: Record }): Promise + connect(transport: unknown, options?: { timeout?: number; signal?: AbortSignal }): Promise + listTools( + params?: { cursor?: string }, + options?: { timeout?: number; signal?: AbortSignal }, + ): Promise + callTool( + request: { name: string; arguments: Record }, + resultSchema?: unknown, + options?: { timeout?: number; signal?: AbortSignal }, + ): Promise close?: () => Promise } @@ -41,6 +52,8 @@ interface MCPModules { StdioClientTransport: StdioTransportConstructor } +const DEFAULT_MCP_REQUEST_TIMEOUT_MS = 60_000 + async function loadMCPModules(): Promise { const [{ Client }, { StdioClientTransport }] = await Promise.all([ import('@modelcontextprotocol/sdk/client/index.js') as Promise<{ @@ -59,10 +72,14 @@ export interface ConnectMCPToolsConfig { env?: Record cwd?: string /** - * Optional prefix used when generating framework tool names. - * Example: "github" -> "github/search_issues" + * Optional segment prepended to MCP tool names for the framework tool (and LLM) name. + * Example: prefix `github` + MCP tool `search_issues` → `github_search_issues`. */ namePrefix?: string + /** + * Timeout (ms) for MCP connect and each `tools/list` page. Defaults to 60000. + */ + requestTimeoutMs?: number /** * Client metadata sent to the MCP server. */ @@ -75,20 +92,100 @@ export interface ConnectedMCPTools { disconnect: () => Promise } +/** + * Build an LLM-safe tool name: MCP and prior examples used `prefix/name`, but + * Anthropic and other providers reject `/` in tool names. + */ function normalizeToolName(rawName: string, namePrefix?: string): string { - if (namePrefix === undefined || namePrefix.trim() === '') { - return rawName + const trimmedPrefix = namePrefix?.trim() + const base = + trimmedPrefix !== undefined && trimmedPrefix !== '' + ? `${trimmedPrefix}_${rawName}` + : rawName + return base.replace(/\//g, '_') +} + +/** MCP `tools/list` JSON Schema; forwarded to the LLM as-is (runtime validation stays `z.any()`). */ +function mcpLlmInputSchema( + schema: Record | undefined, +): Record { + if (schema !== undefined && typeof schema === 'object' && !Array.isArray(schema)) { + return schema } - return `${namePrefix}/${rawName}` + return { type: 'object' } +} + +function contentBlockToText(block: Record): string | undefined { + const typ = block.type + if (typ === 'text' && typeof block.text === 'string') { + return block.text + } + if (typ === 'image' && typeof block.data === 'string') { + const mime = + typeof block.mimeType === 'string' ? block.mimeType : 'image/*' + return `[image ${mime}; base64 length=${block.data.length}]` + } + if (typ === 'audio' && typeof block.data === 'string') { + const mime = + typeof block.mimeType === 'string' ? block.mimeType : 'audio/*' + return `[audio ${mime}; base64 length=${block.data.length}]` + } + if ( + typ === 'resource' && + block.resource !== null && + typeof block.resource === 'object' + ) { + const r = block.resource as Record + const uri = typeof r.uri === 'string' ? r.uri : '' + if (typeof r.text === 'string') { + return `[resource ${uri}]\n${r.text}` + } + if (typeof r.blob === 'string') { + const mime = typeof r.mimeType === 'string' ? r.mimeType : '' + return `[resource ${uri}; mimeType=${mime}; blob base64 length=${r.blob.length}]` + } + return `[resource ${uri}]` + } + if (typ === 'resource_link') { + const uri = typeof block.uri === 'string' ? block.uri : '' + const name = typeof block.name === 'string' ? block.name : '' + const desc = + typeof block.description === 'string' ? block.description : '' + const head = `[resource_link name=${JSON.stringify(name)} uri=${JSON.stringify(uri)}]` + return desc === '' ? head : `${head}\n${desc}` + } + return undefined } function toToolResultData(result: MCPCallToolResponse): string { - const textBlocks = (result.content ?? []) - .filter((block) => block.type === 'text' && typeof block.text === 'string') - .map((block) => block.text as string) + if ('toolResult' in result && result.toolResult !== undefined) { + try { + return JSON.stringify(result.toolResult, null, 2) + } catch { + return String(result.toolResult) + } + } - if (textBlocks.length > 0) { - return textBlocks.join('\n') + const lines: string[] = [] + for (const block of result.content ?? []) { + if (block === null || typeof block !== 'object') continue + const rec = block as Record + const line = contentBlockToText(rec) + if (line !== undefined) { + lines.push(line) + continue + } + try { + lines.push( + `[${String(rec.type ?? 'unknown')}]\n${JSON.stringify(rec, null, 2)}`, + ) + } catch { + lines.push('[mcp content block]') + } + } + + if (lines.length > 0) { + return lines.join('\n') } if (result.structuredContent !== undefined) { @@ -106,6 +203,26 @@ function toToolResultData(result: MCPCallToolResponse): string { } } +async function listAllMcpTools( + client: MCPClientLike, + requestOpts: { timeout: number }, +): Promise { + const acc: MCPToolDescriptor[] = [] + let cursor: string | undefined + do { + const page = await client.listTools( + cursor !== undefined ? { cursor } : {}, + requestOpts, + ) + acc.push(...(page.tools ?? [])) + cursor = + typeof page.nextCursor === 'string' && page.nextCursor !== '' + ? page.nextCursor + : undefined + } while (cursor !== undefined) + return acc +} + /** * Connect to an MCP server over stdio and convert exposed MCP tools into * open-multi-agent ToolDefinitions. @@ -130,23 +247,30 @@ export async function connectMCPTools( { capabilities: {} }, ) - await client.connect(transport) + const requestOpts = { + timeout: config.requestTimeoutMs ?? DEFAULT_MCP_REQUEST_TIMEOUT_MS, + } - const listed = await client.listTools() - const mcpTools = listed.tools ?? [] + await client.connect(transport, requestOpts) + + const mcpTools = await listAllMcpTools(client, requestOpts) const tools: ToolDefinition[] = mcpTools.map((tool) => defineTool({ name: normalizeToolName(tool.name, config.namePrefix), description: tool.description ?? `MCP tool: ${tool.name}`, - // MCP servers validate arguments internally. inputSchema: z.any(), + llmInputSchema: mcpLlmInputSchema(tool.inputSchema), execute: async (input: Record) => { try { - const result = await client.callTool({ - name: tool.name, - arguments: input, - }) + const result = await client.callTool( + { + name: tool.name, + arguments: input, + }, + undefined, + requestOpts, + ) return { data: toToolResultData(result), isError: result.isError === true, @@ -167,7 +291,6 @@ export async function connectMCPTools( tools, disconnect: async () => { await client.close?.() - await transport.close?.() }, } } diff --git a/src/types.ts b/src/types.ts index 98f0397..e1cec7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,12 +170,18 @@ export interface ToolResult { * A tool registered with the framework. * * `inputSchema` is a Zod schema used for validation before `execute` is called. - * At API call time it is converted to JSON Schema via {@link LLMToolDef}. + * At API call time it is converted to JSON Schema for {@link LLMToolDef}, unless + * `llmInputSchema` is set (e.g. MCP tools ship JSON Schema from the server). */ export interface ToolDefinition> { readonly name: string readonly description: string readonly inputSchema: ZodSchema + /** + * When present, used as {@link LLMToolDef.inputSchema} as-is instead of + * deriving JSON Schema from `inputSchema` (Zod). + */ + readonly llmInputSchema?: Record execute(input: TInput, context: ToolUseContext): Promise } diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts index 09f9cf3..ce321e2 100644 --- a/tests/mcp-tools.test.ts +++ b/tests/mcp-tools.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import type { ToolUseContext } from '../src/types.js' +import { ToolRegistry } from '../src/tool/framework.js' const listToolsMock = vi.fn() const callToolMock = vi.fn() @@ -8,20 +9,38 @@ const clientCloseMock = vi.fn() const transportCloseMock = vi.fn() class MockClient { - async connect(transport: unknown): Promise { + async connect( + transport: unknown, + _options?: { timeout?: number }, + ): Promise { connectMock(transport) } - async listTools(): Promise<{ tools: Array<{ name: string; description: string }> }> { - return listToolsMock() + async listTools( + params?: { cursor?: string }, + options?: { timeout?: number }, + ): Promise<{ + tools: Array<{ + name: string + description: string + inputSchema?: Record + }> + nextCursor?: string + }> { + return listToolsMock(params, options) } - async callTool(request: { name: string; arguments: Record }): Promise<{ - content?: Array<{ type: string; text: string }> + async callTool( + request: { name: string; arguments: Record }, + resultSchema?: unknown, + options?: { timeout?: number }, + ): Promise<{ + content?: Array> structuredContent?: unknown isError?: boolean + toolResult?: unknown }> { - return callToolMock(request) + return callToolMock(request, resultSchema, options) } async close(): Promise { @@ -60,7 +79,17 @@ beforeEach(() => { describe('connectMCPTools', () => { it('connects, discovers tools, and executes MCP calls', async () => { listToolsMock.mockResolvedValue({ - tools: [{ name: 'search_issues', description: 'Search repository issues.' }], + tools: [ + { + name: 'search_issues', + description: 'Search repository issues.', + inputSchema: { + type: 'object', + properties: { q: { type: 'string' } }, + required: ['q'], + }, + }, + ], }) callToolMock.mockResolvedValue({ content: [{ type: 'text', text: 'found 2 issues' }], @@ -77,24 +106,92 @@ describe('connectMCPTools', () => { expect(connectMock).toHaveBeenCalledTimes(1) expect(connected.tools).toHaveLength(1) - expect(connected.tools[0].name).toBe('github/search_issues') + expect(connected.tools[0].name).toBe('github_search_issues') + + const registry = new ToolRegistry() + registry.register(connected.tools[0]) + const defs = registry.toToolDefs() + expect(defs[0].inputSchema).toMatchObject({ + type: 'object', + properties: { q: { type: 'string' } }, + required: ['q'], + }) const result = await connected.tools[0].execute({ q: 'bug' }, context) - expect(callToolMock).toHaveBeenCalledWith({ - name: 'search_issues', - arguments: { q: 'bug' }, - }) + expect(callToolMock).toHaveBeenCalledWith( + { + name: 'search_issues', + arguments: { q: 'bug' }, + }, + undefined, + expect.objectContaining({ timeout: expect.any(Number) }), + ) expect(result.isError).toBe(false) expect(result.data).toContain('found 2 issues') await connected.disconnect() expect(clientCloseMock).toHaveBeenCalledTimes(1) - expect(transportCloseMock).toHaveBeenCalledTimes(1) + expect(transportCloseMock).not.toHaveBeenCalled() + }) + + it('aggregates paginated listTools results', async () => { + listToolsMock.mockImplementation( + async (params?: { cursor?: string }) => { + if (params?.cursor === 'c1') { + return { + tools: [ + { name: 'b', description: 'B', inputSchema: { type: 'object' } }, + ], + } + } + return { + tools: [ + { name: 'a', description: 'A', inputSchema: { type: 'object' } }, + ], + nextCursor: 'c1', + } + }, + ) + + callToolMock.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }) + + const { connectMCPTools } = await import('../src/tool/mcp.js') + const connected = await connectMCPTools({ + command: 'npx', + args: ['-y', 'mock-mcp-server'], + }) + + expect(listToolsMock).toHaveBeenCalledTimes(2) + expect(listToolsMock.mock.calls[1][0]).toEqual({ cursor: 'c1' }) + expect(connected.tools).toHaveLength(2) + expect(connected.tools.map((t) => t.name)).toEqual(['a', 'b']) + }) + + it('serializes non-text MCP content blocks', async () => { + listToolsMock.mockResolvedValue({ + tools: [{ name: 'pic', description: 'Pic', inputSchema: { type: 'object' } }], + }) + callToolMock.mockResolvedValue({ + content: [ + { + type: 'image', + data: 'AAA', + mimeType: 'image/png', + }, + ], + isError: false, + }) + + const { connectMCPTools } = await import('../src/tool/mcp.js') + const connected = await connectMCPTools({ command: 'npx', args: ['x'] }) + const result = await connected.tools[0].execute({}, context) + expect(result.data).toContain('image') + expect(result.data).toContain('base64 length=3') }) it('marks tool result as error when MCP returns isError', async () => { listToolsMock.mockResolvedValue({ - tools: [{ name: 'danger', description: 'Dangerous op.' }], + tools: [{ name: 'danger', description: 'Dangerous op.', inputSchema: {} }], }) callToolMock.mockResolvedValue({ content: [{ type: 'text', text: 'permission denied' }],