Skip to content

feat: add connectMCPTools() to register MCP server tools as standard agent tools#89

Merged
JackChen-me merged 3 commits intoJackChen-me:mainfrom
ibrahimkzmv:feat.mcp-tool-integration
Apr 12, 2026
Merged

feat: add connectMCPTools() to register MCP server tools as standard agent tools#89
JackChen-me merged 3 commits intoJackChen-me:mainfrom
ibrahimkzmv:feat.mcp-tool-integration

Conversation

@ibrahimkzmv
Copy link
Copy Markdown
Contributor

@ibrahimkzmv ibrahimkzmv commented Apr 9, 2026

What

Introduces connectMCPTools(), a utility to connect to an MCP (Model Context Protocol) server, discover its tools, and register them as standard ToolDefinitions in the existing ToolRegistry. Supports stdio transport and returns a disconnect() handle for lifecycle management.

Why

Manual wrapping of MCP servers via defineTool() is unnecessary friction. This change allows agents to consume MCP tools with minimal setup, streamlining multi-agent workflows and external tool integration.

Closes #86

Checklist

  • connectMCPTools(config) implemented and returns ToolDefinition[] + disconnect()
  • stdio transport fully functional
  • @modelcontextprotocol/sdk added as optional peer dependency
  • Example agent using an MCP GitHub server added
  • Tests with mocked MCP server
  • README section updated for MCP usage
  • npm run lint and npm test pass

@JackChen-me
Copy link
Copy Markdown
Owner

Two blockers:

1. / in tool names gets 400 from Anthropic

normalizeToolName() produces github/search_issues. Anthropic validates tool names against ^[a-zA-Z0-9_-]{1,128}$ server-side and rejects anything with slashes. Gemini is stricter (^[a-zA-Z_][a-zA-Z0-9_]*$), also rejects. Known pain point in the MCP ecosystem (anthropics/claude-code#2257, anthropics/claude-code#5835). Swap / for _ so it becomes github_search_issues.

2. MCP inputSchema is dropped, LLM sees empty params

MCPToolDescriptor only captures name and description. The PR uses z.any() as a placeholder, which flows through zodToJsonSchema as {}, so Anthropic ends up with input_schema: { type: 'object' } (no properties, no required). The model has zero guidance on arguments and will hallucinate on anything non-trivial.

The fix isn't bad: MCP SDK's Client.listTools() already returns inputSchema in exactly the { type: 'object', properties, required } shape Anthropic/OpenAI expect. You can bypass Zod for MCP tools and stuff the raw JSON Schema into LLMToolDef.inputSchema (already typed Record<string, unknown>). Keep z.any() for the runtime safeParse passthrough.

Smaller stuff, not blocking:

  • toToolResultData() only handles text content blocks. MCP callTool() can also return image/audio/resource/resource_link (see SDK types in client/index.d.ts). Those get silently dropped right now.
  • listTools() has nextCursor for pagination, PR only reads the first page. Edge case, most servers won't hit it.
  • disconnect() calls both client.close() and transport.close(), but Client.close() already closes the transport internally. Harmless since stdio close is idempotent, but redundant.
  • No timeout on connect() or listTools(). npx -y can hang for a while on first run while downloading.
  • Example should fail fast on missing GITHUB_TOKEN instead of passing undefined through.

@ibrahimkzmv
Copy link
Copy Markdown
Contributor Author

I've committed the changes.
Thanks for the clear write-up @JackChen-me - that matches what we were seeing: z.any() → zodToJsonSchema → {}, so providers only got a bare type: 'object' and the model had no real parameter contract.

I implemented the approach you outlined: Client.listTools() / tools/list inputSchema is forwarded into LLMToolDef.inputSchema as plain JSON Schema, while inputSchema on the tool stays z.any() so ToolExecutor still does a normal safeParse passthrough and the MCP server remains the source of truth for real validation.

One small design choice vs. wiring it only inside mcp.ts: I added an optional llmInputSchema on ToolDefinition (and on defineTool’s config). ToolRegistry.toToolDefs() uses tool.llmInputSchema ?? zodToJsonSchema(tool.inputSchema), so MCP tools bypass Zod for the LLM payload without special-casing the registry. Same idea as “stuff the raw JSON Schema into LLMToolDef.inputSchema,” just stored on the tool def until conversion.

We don’t assume the server always returns only { type: 'object', properties, required } - we pass through any object-shaped inputSchema from the list response; if it’s missing or not a plain object, we fall back to { type: 'object' } so we never send undefined into the LLM slot.

toLLMTools() was updated to respect llmInputSchema as well, and its input_schema typing was loosened to Record<string, unknown> so full MCP-style schemas type-check.

If anything in the above diverges from what you had in mind for the PR, happy to align.

@JackChen-me
Copy link
Copy Markdown
Owner

All blockers from round 1 resolved. llmInputSchema on ToolDefinition is better than what I suggested, keeps it generic instead of MCP-specific.

Merging.

@JackChen-me JackChen-me merged commit ced1d90 into JackChen-me:main Apr 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP integration: connect to external tool servers via optional peer dependency

2 participants