feat: update MCP GitHub example and added llmInputSchema

This commit is contained in:
MrAvalonApple 2026-04-12 00:01:22 +03:00
parent 7aa1bb7b5d
commit 12dd802ad8
5 changed files with 293 additions and 46 deletions

View File

@ -8,7 +8,7 @@
* npx tsx examples/16-mcp-github.ts * npx tsx examples/16-mcp-github.ts
* *
* Prerequisites: * Prerequisites:
* - ANTHROPIC_API_KEY * - GEMINI_API_KEY
* - GITHUB_TOKEN * - GITHUB_TOKEN
* - @modelcontextprotocol/sdk installed * - @modelcontextprotocol/sdk installed
*/ */
@ -16,6 +16,11 @@
import { Agent, ToolExecutor, ToolRegistry, registerBuiltInTools } from '../src/index.js' import { Agent, ToolExecutor, ToolRegistry, registerBuiltInTools } from '../src/index.js'
import { connectMCPTools } from '../src/mcp.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({ const { tools, disconnect } = await connectMCPTools({
command: 'npx', command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'], args: ['-y', '@modelcontextprotocol/server-github'],
@ -34,7 +39,8 @@ const executor = new ToolExecutor(registry)
const agent = new Agent( const agent = new Agent(
{ {
name: 'github-agent', name: 'github-agent',
model: 'claude-sonnet-4-6', model: 'gemini-2.5-flash',
provider: 'gemini',
tools: tools.map((tool) => tool.name), tools: tools.map((tool) => tool.name),
systemPrompt: 'Use GitHub MCP tools to answer repository questions.', systemPrompt: 'Use GitHub MCP tools to answer repository questions.',
}, },

View File

@ -72,12 +72,19 @@ export function defineTool<TInput>(config: {
name: string name: string
description: string description: string
inputSchema: ZodSchema<TInput> inputSchema: ZodSchema<TInput>
/**
* Optional JSON Schema for the LLM (bypasses Zod JSON Schema conversion).
*/
llmInputSchema?: Record<string, unknown>
execute: (input: TInput, context: ToolUseContext) => Promise<ToolResult> execute: (input: TInput, context: ToolUseContext) => Promise<ToolResult>
}): ToolDefinition<TInput> { }): ToolDefinition<TInput> {
return { return {
name: config.name, name: config.name,
description: config.description, description: config.description,
inputSchema: config.inputSchema, inputSchema: config.inputSchema,
...(config.llmInputSchema !== undefined
? { llmInputSchema: config.llmInputSchema }
: {}),
execute: config.execute, execute: config.execute,
} }
} }
@ -169,7 +176,8 @@ export class ToolRegistry {
*/ */
toToolDefs(): LLMToolDef[] { toToolDefs(): LLMToolDef[] {
return Array.from(this.tools.values()).map((tool) => { return Array.from(this.tools.values()).map((tool) => {
const schema = zodToJsonSchema(tool.inputSchema) const schema =
tool.llmInputSchema ?? zodToJsonSchema(tool.inputSchema)
return { return {
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
@ -194,13 +202,20 @@ export class ToolRegistry {
toLLMTools(): Array<{ toLLMTools(): Array<{
name: string name: string
description: string description: string
input_schema: { /** Anthropic-style tool input JSON Schema (`type` is usually `object`). */
type: 'object' input_schema: Record<string, unknown>
properties: Record<string, JSONSchemaProperty>
required?: string[]
}
}> { }> {
return Array.from(this.tools.values()).map((tool) => { 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<string, unknown>),
},
}
}
const schema = zodToJsonSchema(tool.inputSchema) const schema = zodToJsonSchema(tool.inputSchema)
return { return {
name: tool.name, name: tool.name,

View File

@ -5,22 +5,33 @@ import type { ToolDefinition } from '../types.js'
interface MCPToolDescriptor { interface MCPToolDescriptor {
name: string name: string
description?: string description?: string
/** MCP tool JSON Schema; same shape LLM APIs expect for object parameters. */
inputSchema?: Record<string, unknown>
} }
interface MCPListToolsResponse { interface MCPListToolsResponse {
tools?: MCPToolDescriptor[] tools?: MCPToolDescriptor[]
nextCursor?: string
} }
interface MCPCallToolResponse { interface MCPCallToolResponse {
content?: Array<{ type?: string; text?: string }> content?: Array<Record<string, unknown>>
structuredContent?: unknown structuredContent?: unknown
isError?: boolean isError?: boolean
toolResult?: unknown
} }
interface MCPClientLike { interface MCPClientLike {
connect(transport: unknown): Promise<void> connect(transport: unknown, options?: { timeout?: number; signal?: AbortSignal }): Promise<void>
listTools(): Promise<MCPListToolsResponse> listTools(
callTool(request: { name: string; arguments: Record<string, unknown> }): Promise<MCPCallToolResponse> params?: { cursor?: string },
options?: { timeout?: number; signal?: AbortSignal },
): Promise<MCPListToolsResponse>
callTool(
request: { name: string; arguments: Record<string, unknown> },
resultSchema?: unknown,
options?: { timeout?: number; signal?: AbortSignal },
): Promise<MCPCallToolResponse>
close?: () => Promise<void> close?: () => Promise<void>
} }
@ -41,6 +52,8 @@ interface MCPModules {
StdioClientTransport: StdioTransportConstructor StdioClientTransport: StdioTransportConstructor
} }
const DEFAULT_MCP_REQUEST_TIMEOUT_MS = 60_000
async function loadMCPModules(): Promise<MCPModules> { async function loadMCPModules(): Promise<MCPModules> {
const [{ Client }, { StdioClientTransport }] = await Promise.all([ const [{ Client }, { StdioClientTransport }] = await Promise.all([
import('@modelcontextprotocol/sdk/client/index.js') as Promise<{ import('@modelcontextprotocol/sdk/client/index.js') as Promise<{
@ -59,10 +72,14 @@ export interface ConnectMCPToolsConfig {
env?: Record<string, string | undefined> env?: Record<string, string | undefined>
cwd?: string cwd?: string
/** /**
* Optional prefix used when generating framework tool names. * Optional segment prepended to MCP tool names for the framework tool (and LLM) name.
* Example: "github" -> "github/search_issues" * Example: prefix `github` + MCP tool `search_issues` `github_search_issues`.
*/ */
namePrefix?: string namePrefix?: string
/**
* Timeout (ms) for MCP connect and each `tools/list` page. Defaults to 60000.
*/
requestTimeoutMs?: number
/** /**
* Client metadata sent to the MCP server. * Client metadata sent to the MCP server.
*/ */
@ -75,20 +92,100 @@ export interface ConnectedMCPTools {
disconnect: () => Promise<void> disconnect: () => Promise<void>
} }
/**
* 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 { function normalizeToolName(rawName: string, namePrefix?: string): string {
if (namePrefix === undefined || namePrefix.trim() === '') { const trimmedPrefix = namePrefix?.trim()
return rawName 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<string, unknown> | undefined,
): Record<string, unknown> {
if (schema !== undefined && typeof schema === 'object' && !Array.isArray(schema)) {
return schema
} }
return `${namePrefix}/${rawName}` return { type: 'object' }
}
function contentBlockToText(block: Record<string, unknown>): 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<string, unknown>
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 { function toToolResultData(result: MCPCallToolResponse): string {
const textBlocks = (result.content ?? []) if ('toolResult' in result && result.toolResult !== undefined) {
.filter((block) => block.type === 'text' && typeof block.text === 'string') try {
.map((block) => block.text as string) return JSON.stringify(result.toolResult, null, 2)
} catch {
return String(result.toolResult)
}
}
if (textBlocks.length > 0) { const lines: string[] = []
return textBlocks.join('\n') for (const block of result.content ?? []) {
if (block === null || typeof block !== 'object') continue
const rec = block as Record<string, unknown>
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) { if (result.structuredContent !== undefined) {
@ -106,6 +203,26 @@ function toToolResultData(result: MCPCallToolResponse): string {
} }
} }
async function listAllMcpTools(
client: MCPClientLike,
requestOpts: { timeout: number },
): Promise<MCPToolDescriptor[]> {
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 * Connect to an MCP server over stdio and convert exposed MCP tools into
* open-multi-agent ToolDefinitions. * open-multi-agent ToolDefinitions.
@ -130,23 +247,30 @@ export async function connectMCPTools(
{ capabilities: {} }, { capabilities: {} },
) )
await client.connect(transport) const requestOpts = {
timeout: config.requestTimeoutMs ?? DEFAULT_MCP_REQUEST_TIMEOUT_MS,
}
const listed = await client.listTools() await client.connect(transport, requestOpts)
const mcpTools = listed.tools ?? []
const mcpTools = await listAllMcpTools(client, requestOpts)
const tools: ToolDefinition[] = mcpTools.map((tool) => const tools: ToolDefinition[] = mcpTools.map((tool) =>
defineTool({ defineTool({
name: normalizeToolName(tool.name, config.namePrefix), name: normalizeToolName(tool.name, config.namePrefix),
description: tool.description ?? `MCP tool: ${tool.name}`, description: tool.description ?? `MCP tool: ${tool.name}`,
// MCP servers validate arguments internally.
inputSchema: z.any(), inputSchema: z.any(),
llmInputSchema: mcpLlmInputSchema(tool.inputSchema),
execute: async (input: Record<string, unknown>) => { execute: async (input: Record<string, unknown>) => {
try { try {
const result = await client.callTool({ const result = await client.callTool(
name: tool.name, {
arguments: input, name: tool.name,
}) arguments: input,
},
undefined,
requestOpts,
)
return { return {
data: toToolResultData(result), data: toToolResultData(result),
isError: result.isError === true, isError: result.isError === true,
@ -167,7 +291,6 @@ export async function connectMCPTools(
tools, tools,
disconnect: async () => { disconnect: async () => {
await client.close?.() await client.close?.()
await transport.close?.()
}, },
} }
} }

View File

@ -170,12 +170,18 @@ export interface ToolResult {
* A tool registered with the framework. * A tool registered with the framework.
* *
* `inputSchema` is a Zod schema used for validation before `execute` is called. * `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<TInput = Record<string, unknown>> { export interface ToolDefinition<TInput = Record<string, unknown>> {
readonly name: string readonly name: string
readonly description: string readonly description: string
readonly inputSchema: ZodSchema<TInput> readonly inputSchema: ZodSchema<TInput>
/**
* When present, used as {@link LLMToolDef.inputSchema} as-is instead of
* deriving JSON Schema from `inputSchema` (Zod).
*/
readonly llmInputSchema?: Record<string, unknown>
execute(input: TInput, context: ToolUseContext): Promise<ToolResult> execute(input: TInput, context: ToolUseContext): Promise<ToolResult>
} }

View File

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { ToolUseContext } from '../src/types.js' import type { ToolUseContext } from '../src/types.js'
import { ToolRegistry } from '../src/tool/framework.js'
const listToolsMock = vi.fn() const listToolsMock = vi.fn()
const callToolMock = vi.fn() const callToolMock = vi.fn()
@ -8,20 +9,38 @@ const clientCloseMock = vi.fn()
const transportCloseMock = vi.fn() const transportCloseMock = vi.fn()
class MockClient { class MockClient {
async connect(transport: unknown): Promise<void> { async connect(
transport: unknown,
_options?: { timeout?: number },
): Promise<void> {
connectMock(transport) connectMock(transport)
} }
async listTools(): Promise<{ tools: Array<{ name: string; description: string }> }> { async listTools(
return listToolsMock() params?: { cursor?: string },
options?: { timeout?: number },
): Promise<{
tools: Array<{
name: string
description: string
inputSchema?: Record<string, unknown>
}>
nextCursor?: string
}> {
return listToolsMock(params, options)
} }
async callTool(request: { name: string; arguments: Record<string, unknown> }): Promise<{ async callTool(
content?: Array<{ type: string; text: string }> request: { name: string; arguments: Record<string, unknown> },
resultSchema?: unknown,
options?: { timeout?: number },
): Promise<{
content?: Array<Record<string, unknown>>
structuredContent?: unknown structuredContent?: unknown
isError?: boolean isError?: boolean
toolResult?: unknown
}> { }> {
return callToolMock(request) return callToolMock(request, resultSchema, options)
} }
async close(): Promise<void> { async close(): Promise<void> {
@ -60,7 +79,17 @@ beforeEach(() => {
describe('connectMCPTools', () => { describe('connectMCPTools', () => {
it('connects, discovers tools, and executes MCP calls', async () => { it('connects, discovers tools, and executes MCP calls', async () => {
listToolsMock.mockResolvedValue({ 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({ callToolMock.mockResolvedValue({
content: [{ type: 'text', text: 'found 2 issues' }], content: [{ type: 'text', text: 'found 2 issues' }],
@ -77,24 +106,92 @@ describe('connectMCPTools', () => {
expect(connectMock).toHaveBeenCalledTimes(1) expect(connectMock).toHaveBeenCalledTimes(1)
expect(connected.tools).toHaveLength(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) const result = await connected.tools[0].execute({ q: 'bug' }, context)
expect(callToolMock).toHaveBeenCalledWith({ expect(callToolMock).toHaveBeenCalledWith(
name: 'search_issues', {
arguments: { q: 'bug' }, name: 'search_issues',
}) arguments: { q: 'bug' },
},
undefined,
expect.objectContaining({ timeout: expect.any(Number) }),
)
expect(result.isError).toBe(false) expect(result.isError).toBe(false)
expect(result.data).toContain('found 2 issues') expect(result.data).toContain('found 2 issues')
await connected.disconnect() await connected.disconnect()
expect(clientCloseMock).toHaveBeenCalledTimes(1) 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 () => { it('marks tool result as error when MCP returns isError', async () => {
listToolsMock.mockResolvedValue({ listToolsMock.mockResolvedValue({
tools: [{ name: 'danger', description: 'Dangerous op.' }], tools: [{ name: 'danger', description: 'Dangerous op.', inputSchema: {} }],
}) })
callToolMock.mockResolvedValue({ callToolMock.mockResolvedValue({
content: [{ type: 'text', text: 'permission denied' }], content: [{ type: 'text', text: 'permission denied' }],