Merge pull request #89 from ibrahimkzmv/feat.mcp-tool-integration
feat: add connectMCPTools() to register MCP server tools as standard agent tools
This commit is contained in:
commit
ced1d90a93
26
README.md
26
README.md
|
|
@ -138,7 +138,7 @@ For MapReduce-style fan-out without task dependencies, use `AgentPool.runParalle
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
15 runnable scripts in [`examples/`](./examples/). Start with these four:
|
16 runnable scripts in [`examples/`](./examples/). Start with these four:
|
||||||
|
|
||||||
- [02 — Team Collaboration](examples/02-team-collaboration.ts): `runTeam()` coordinator pattern.
|
- [02 — Team Collaboration](examples/02-team-collaboration.ts): `runTeam()` coordinator pattern.
|
||||||
- [06 — Local Model](examples/06-local-model.ts): Ollama and Claude in one pipeline via `baseURL`.
|
- [06 — Local Model](examples/06-local-model.ts): Ollama and Claude in one pipeline via `baseURL`.
|
||||||
|
|
@ -248,6 +248,30 @@ const customAgent: AgentConfig = {
|
||||||
|
|
||||||
Tools added via `agent.addTool()` are always available regardless of filtering.
|
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
|
## Supported Providers
|
||||||
|
|
||||||
| Provider | Config | Env var | Status |
|
| Provider | Config | Env var | Status |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* 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:
|
||||||
|
* - GEMINI_API_KEY
|
||||||
|
* - GITHUB_TOKEN
|
||||||
|
* - @modelcontextprotocol/sdk installed
|
||||||
|
*/
|
||||||
|
|
||||||
|
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'],
|
||||||
|
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: 'gemini-2.5-flash',
|
||||||
|
provider: 'gemini',
|
||||||
|
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",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./mcp": {
|
||||||
|
"types": "./dist/mcp.d.ts",
|
||||||
|
"import": "./dist/mcp.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -48,15 +52,20 @@
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@google/genai": "^1.48.0"
|
"@google/genai": "^1.48.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.18.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@google/genai": {
|
"@google/genai": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"@modelcontextprotocol/sdk": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@google/genai": "^1.48.0",
|
"@google/genai": "^1.48.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@vitest/coverage-v8": "^2.1.9",
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type {
|
||||||
|
ConnectMCPToolsConfig,
|
||||||
|
ConnectedMCPTools,
|
||||||
|
} from './tool/mcp.js'
|
||||||
|
export { connectMCPTools } from './tool/mcp.js'
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { defineTool } from './framework.js'
|
||||||
|
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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPListToolsResponse {
|
||||||
|
tools?: MCPToolDescriptor[]
|
||||||
|
nextCursor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPCallToolResponse {
|
||||||
|
content?: Array<Record<string, unknown>>
|
||||||
|
structuredContent?: unknown
|
||||||
|
isError?: boolean
|
||||||
|
toolResult?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPClientLike {
|
||||||
|
connect(transport: unknown, options?: { timeout?: number; signal?: AbortSignal }): Promise<void>
|
||||||
|
listTools(
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MCP_REQUEST_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
*/
|
||||||
|
clientName?: string
|
||||||
|
clientVersion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectedMCPTools {
|
||||||
|
tools: ToolDefinition[]
|
||||||
|
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 {
|
||||||
|
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<string, unknown> | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (schema !== undefined && typeof schema === 'object' && !Array.isArray(schema)) {
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
if ('toolResult' in result && result.toolResult !== undefined) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(result.toolResult, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(result.toolResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
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) {
|
||||||
|
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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
* 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: {} },
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestOpts = {
|
||||||
|
timeout: config.requestTimeoutMs ?? DEFAULT_MCP_REQUEST_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
inputSchema: z.any(),
|
||||||
|
llmInputSchema: mcpLlmInputSchema(tool.inputSchema),
|
||||||
|
execute: async (input: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
const result = await client.callTool(
|
||||||
|
{
|
||||||
|
name: tool.name,
|
||||||
|
arguments: input,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
requestOpts,
|
||||||
|
)
|
||||||
|
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?.()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -182,12 +182,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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
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()
|
||||||
|
const connectMock = vi.fn()
|
||||||
|
const clientCloseMock = vi.fn()
|
||||||
|
const transportCloseMock = vi.fn()
|
||||||
|
|
||||||
|
class MockClient {
|
||||||
|
async connect(
|
||||||
|
transport: unknown,
|
||||||
|
_options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
connectMock(transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(
|
||||||
|
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> },
|
||||||
|
resultSchema?: unknown,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<{
|
||||||
|
content?: Array<Record<string, unknown>>
|
||||||
|
structuredContent?: unknown
|
||||||
|
isError?: boolean
|
||||||
|
toolResult?: unknown
|
||||||
|
}> {
|
||||||
|
return callToolMock(request, resultSchema, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { q: { type: 'string' } },
|
||||||
|
required: ['q'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
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 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' },
|
||||||
|
},
|
||||||
|
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).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.', inputSchema: {} }],
|
||||||
|
})
|
||||||
|
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