feat: add connectMCPTools() to register MCP server tools as agent tools
This commit is contained in:
parent
f1c7477a26
commit
7aa1bb7b5d
25
README.md
25
README.md
|
|
@ -133,6 +133,7 @@ npx tsx examples/01-single-agent.ts
|
|||
| [11 — Trace Observability](examples/11-trace-observability.ts) | `onTrace` callback — structured spans for LLM calls, tools, tasks, and agents |
|
||||
| [12 — Grok](examples/12-grok.ts) | Same as example 02 (`runTeam()` collaboration) with Grok (`XAI_API_KEY`) |
|
||||
| [13 — Gemini](examples/13-gemini.ts) | Gemini adapter smoke test with `gemini-2.5-flash` (`GEMINI_API_KEY`) |
|
||||
| [16 — MCP GitHub Tools](examples/16-mcp-github.ts) | Connect MCP over stdio and use server tools as native `ToolDefinition`s |
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -235,6 +236,30 @@ const customAgent: AgentConfig = {
|
|||
|
||||
Tools added via `agent.addTool()` are always available regardless of filtering.
|
||||
|
||||
### MCP Tools (Model Context Protocol)
|
||||
|
||||
`open-multi-agent` can connect to any MCP server and expose its tools directly to agents.
|
||||
|
||||
```typescript
|
||||
import { connectMCPTools } from '@jackchen_me/open-multi-agent/mcp'
|
||||
|
||||
const { tools, disconnect } = await connectMCPTools({
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-github'],
|
||||
env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN },
|
||||
namePrefix: 'github',
|
||||
})
|
||||
|
||||
// Register each MCP tool in your ToolRegistry, then include their names in AgentConfig.tools
|
||||
// Don't forget cleanup when done
|
||||
await disconnect()
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `@modelcontextprotocol/sdk` is an optional peer dependency, only needed when using MCP.
|
||||
- Current transport support is stdio.
|
||||
- MCP input validation is delegated to the MCP server (`inputSchema` is `z.any()`).
|
||||
|
||||
## Supported Providers
|
||||
|
||||
| Provider | Config | Env var | Status |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Example 16 — MCP GitHub Tools
|
||||
*
|
||||
* Connect an MCP server over stdio and register all exposed MCP tools as
|
||||
* standard open-multi-agent tools.
|
||||
*
|
||||
* Run:
|
||||
* npx tsx examples/16-mcp-github.ts
|
||||
*
|
||||
* Prerequisites:
|
||||
* - ANTHROPIC_API_KEY
|
||||
* - GITHUB_TOKEN
|
||||
* - @modelcontextprotocol/sdk installed
|
||||
*/
|
||||
|
||||
import { Agent, ToolExecutor, ToolRegistry, registerBuiltInTools } from '../src/index.js'
|
||||
import { connectMCPTools } from '../src/mcp.js'
|
||||
|
||||
const { tools, disconnect } = await connectMCPTools({
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-github'],
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||
},
|
||||
namePrefix: 'github',
|
||||
})
|
||||
|
||||
const registry = new ToolRegistry()
|
||||
registerBuiltInTools(registry)
|
||||
for (const tool of tools) registry.register(tool)
|
||||
const executor = new ToolExecutor(registry)
|
||||
|
||||
const agent = new Agent(
|
||||
{
|
||||
name: 'github-agent',
|
||||
model: 'claude-sonnet-4-6',
|
||||
tools: tools.map((tool) => tool.name),
|
||||
systemPrompt: 'Use GitHub MCP tools to answer repository questions.',
|
||||
},
|
||||
registry,
|
||||
executor,
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await agent.run(
|
||||
'List the last 3 open issues in JackChen-me/open-multi-agent with title and number.',
|
||||
)
|
||||
|
||||
console.log(result.output)
|
||||
} finally {
|
||||
await disconnect()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
|
|
@ -14,6 +14,10 @@
|
|||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./mcp": {
|
||||
"types": "./dist/mcp.d.ts",
|
||||
"import": "./dist/mcp.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
|
@ -48,15 +52,20 @@
|
|||
"zod": "^3.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@google/genai": "^1.48.0"
|
||||
"@google/genai": "^1.48.0",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@google/genai": {
|
||||
"optional": true
|
||||
},
|
||||
"@modelcontextprotocol/sdk": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/genai": "^1.48.0",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export type {
|
||||
ConnectMCPToolsConfig,
|
||||
ConnectedMCPTools,
|
||||
} from './tool/mcp.js'
|
||||
export { connectMCPTools } from './tool/mcp.js'
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { z } from 'zod'
|
||||
import { defineTool } from './framework.js'
|
||||
import type { ToolDefinition } from '../types.js'
|
||||
|
||||
interface MCPToolDescriptor {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface MCPListToolsResponse {
|
||||
tools?: MCPToolDescriptor[]
|
||||
}
|
||||
|
||||
interface MCPCallToolResponse {
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
structuredContent?: unknown
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
interface MCPClientLike {
|
||||
connect(transport: unknown): Promise<void>
|
||||
listTools(): Promise<MCPListToolsResponse>
|
||||
callTool(request: { name: string; arguments: Record<string, unknown> }): Promise<MCPCallToolResponse>
|
||||
close?: () => Promise<void>
|
||||
}
|
||||
|
||||
type MCPClientConstructor = new (
|
||||
info: { name: string; version: string },
|
||||
options: { capabilities: Record<string, unknown> },
|
||||
) => MCPClientLike
|
||||
|
||||
type StdioTransportConstructor = new (config: {
|
||||
command: string
|
||||
args?: string[]
|
||||
env?: Record<string, string | undefined>
|
||||
cwd?: string
|
||||
}) => { close?: () => Promise<void> }
|
||||
|
||||
interface MCPModules {
|
||||
Client: MCPClientConstructor
|
||||
StdioClientTransport: StdioTransportConstructor
|
||||
}
|
||||
|
||||
async function loadMCPModules(): Promise<MCPModules> {
|
||||
const [{ Client }, { StdioClientTransport }] = await Promise.all([
|
||||
import('@modelcontextprotocol/sdk/client/index.js') as Promise<{
|
||||
Client: MCPClientConstructor
|
||||
}>,
|
||||
import('@modelcontextprotocol/sdk/client/stdio.js') as Promise<{
|
||||
StdioClientTransport: StdioTransportConstructor
|
||||
}>,
|
||||
])
|
||||
return { Client, StdioClientTransport }
|
||||
}
|
||||
|
||||
export interface ConnectMCPToolsConfig {
|
||||
command: string
|
||||
args?: string[]
|
||||
env?: Record<string, string | undefined>
|
||||
cwd?: string
|
||||
/**
|
||||
* Optional prefix used when generating framework tool names.
|
||||
* Example: "github" -> "github/search_issues"
|
||||
*/
|
||||
namePrefix?: string
|
||||
/**
|
||||
* Client metadata sent to the MCP server.
|
||||
*/
|
||||
clientName?: string
|
||||
clientVersion?: string
|
||||
}
|
||||
|
||||
export interface ConnectedMCPTools {
|
||||
tools: ToolDefinition[]
|
||||
disconnect: () => Promise<void>
|
||||
}
|
||||
|
||||
function normalizeToolName(rawName: string, namePrefix?: string): string {
|
||||
if (namePrefix === undefined || namePrefix.trim() === '') {
|
||||
return rawName
|
||||
}
|
||||
return `${namePrefix}/${rawName}`
|
||||
}
|
||||
|
||||
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 (textBlocks.length > 0) {
|
||||
return textBlocks.join('\n')
|
||||
}
|
||||
|
||||
if (result.structuredContent !== undefined) {
|
||||
try {
|
||||
return JSON.stringify(result.structuredContent, null, 2)
|
||||
} catch {
|
||||
return String(result.structuredContent)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(result)
|
||||
} catch {
|
||||
return 'MCP tool completed with non-text output.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an MCP server over stdio and convert exposed MCP tools into
|
||||
* open-multi-agent ToolDefinitions.
|
||||
*/
|
||||
export async function connectMCPTools(
|
||||
config: ConnectMCPToolsConfig,
|
||||
): Promise<ConnectedMCPTools> {
|
||||
const { Client, StdioClientTransport } = await loadMCPModules()
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args ?? [],
|
||||
env: config.env,
|
||||
cwd: config.cwd,
|
||||
})
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: config.clientName ?? 'open-multi-agent',
|
||||
version: config.clientVersion ?? '0.0.0',
|
||||
},
|
||||
{ capabilities: {} },
|
||||
)
|
||||
|
||||
await client.connect(transport)
|
||||
|
||||
const listed = await client.listTools()
|
||||
const mcpTools = listed.tools ?? []
|
||||
|
||||
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(),
|
||||
execute: async (input: Record<string, unknown>) => {
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name: tool.name,
|
||||
arguments: input,
|
||||
})
|
||||
return {
|
||||
data: toToolResultData(result),
|
||||
isError: result.isError === true,
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
data: `MCP tool "${tool.name}" failed: ${message}`,
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
tools,
|
||||
disconnect: async () => {
|
||||
await client.close?.()
|
||||
await transport.close?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import type { ToolUseContext } from '../src/types.js'
|
||||
|
||||
const listToolsMock = vi.fn()
|
||||
const callToolMock = vi.fn()
|
||||
const connectMock = vi.fn()
|
||||
const clientCloseMock = vi.fn()
|
||||
const transportCloseMock = vi.fn()
|
||||
|
||||
class MockClient {
|
||||
async connect(transport: unknown): Promise<void> {
|
||||
connectMock(transport)
|
||||
}
|
||||
|
||||
async listTools(): Promise<{ tools: Array<{ name: string; description: string }> }> {
|
||||
return listToolsMock()
|
||||
}
|
||||
|
||||
async callTool(request: { name: string; arguments: Record<string, unknown> }): Promise<{
|
||||
content?: Array<{ type: string; text: string }>
|
||||
structuredContent?: unknown
|
||||
isError?: boolean
|
||||
}> {
|
||||
return callToolMock(request)
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
clientCloseMock()
|
||||
}
|
||||
}
|
||||
|
||||
class MockStdioTransport {
|
||||
readonly config: unknown
|
||||
|
||||
constructor(config: unknown) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
transportCloseMock()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: MockClient,
|
||||
}))
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: MockStdioTransport,
|
||||
}))
|
||||
|
||||
const context: ToolUseContext = {
|
||||
agent: { name: 'test-agent', role: 'tester', model: 'test-model' },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('connectMCPTools', () => {
|
||||
it('connects, discovers tools, and executes MCP calls', async () => {
|
||||
listToolsMock.mockResolvedValue({
|
||||
tools: [{ name: 'search_issues', description: 'Search repository issues.' }],
|
||||
})
|
||||
callToolMock.mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'found 2 issues' }],
|
||||
isError: false,
|
||||
})
|
||||
|
||||
const { connectMCPTools } = await import('../src/tool/mcp.js')
|
||||
const connected = await connectMCPTools({
|
||||
command: 'npx',
|
||||
args: ['-y', 'mock-mcp-server'],
|
||||
env: { GITHUB_TOKEN: 'token' },
|
||||
namePrefix: 'github',
|
||||
})
|
||||
|
||||
expect(connectMock).toHaveBeenCalledTimes(1)
|
||||
expect(connected.tools).toHaveLength(1)
|
||||
expect(connected.tools[0].name).toBe('github/search_issues')
|
||||
|
||||
const result = await connected.tools[0].execute({ q: 'bug' }, context)
|
||||
expect(callToolMock).toHaveBeenCalledWith({
|
||||
name: 'search_issues',
|
||||
arguments: { q: 'bug' },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('found 2 issues')
|
||||
|
||||
await connected.disconnect()
|
||||
expect(clientCloseMock).toHaveBeenCalledTimes(1)
|
||||
expect(transportCloseMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks tool result as error when MCP returns isError', async () => {
|
||||
listToolsMock.mockResolvedValue({
|
||||
tools: [{ name: 'danger', description: 'Dangerous op.' }],
|
||||
})
|
||||
callToolMock.mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'permission denied' }],
|
||||
isError: true,
|
||||
})
|
||||
|
||||
const { connectMCPTools } = await import('../src/tool/mcp.js')
|
||||
const connected = await connectMCPTools({
|
||||
command: 'npx',
|
||||
args: ['-y', 'mock-mcp-server'],
|
||||
})
|
||||
|
||||
const result = await connected.tools[0].execute({}, context)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.data).toContain('permission denied')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue