v1.15.0-mcp-multi: multi-server MCP client + stdio transport + config file + tool globs
Generalizes the v1.14.1 single-server Context7 PoC into a multi-server MCP client registry with per-server graceful degradation. JSON config at /data/mcp.json (bind-mounted alongside AGENTS.md) matches opencode's mcpServers schema shape. Config file missing = no MCP (opt-in by presence). Two transports: Streamable HTTP (remote servers like Context7) and stdio (local subprocess servers like codecontext). Stdio spawns a persistent child via the SDK's StdioClientTransport; shutdown hook closes all transports. Tool prefix generalized from context7_<name> to <serverName>_<toolName> with a toolToServer reverse map for dispatch routing. AGENTS.md tools: field now supports glob patterns (context7_*, !web_*) via matchToolGlob — last-match- wins with ! deny prefix. Replaces exact-match .includes() in stream-phase.ts. refreshToolNames() in agents.ts rebuilds the DEFAULT_TOOLS snapshot after appendMcpTools so agents without explicit tools: lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant: readOnlyHint === false rejected at discovery. Result size capped at 5MB. v1.14.1 env vars removed — superseded by config file. Default data/mcp.json ships with Context7 disabled. 363/363 server tests passing. No schema changes, no frontend changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* v1.14.1-mcp-poc: unit tests for the MCP client service.
|
||||
* Pure unit tests — no live MCP server needed. Tests the tool-wrapping,
|
||||
* v1.15.0-mcp-multi: unit tests for the multi-server MCP client.
|
||||
* Pure unit tests — no live MCP server needed. Tests tool-wrapping,
|
||||
* read-only guard, name prefixing, content extraction, and error handling.
|
||||
* Multi-server routing tested via wrapMcpTool's server-name prefix.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js';
|
||||
|
||||
describe('mcp-client', () => {
|
||||
describe('wrapMcpTool', () => {
|
||||
it('produces a ToolDef with context7_ prefix', () => {
|
||||
describe('wrapMcpTool — multi-server prefixing', () => {
|
||||
it('produces a ToolDef with <serverName>_ prefix', () => {
|
||||
const mcpTool = {
|
||||
name: 'resolve-library-id',
|
||||
description: 'Resolve a library identifier',
|
||||
@@ -19,7 +20,7 @@ describe('mcp-client', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool(mcpTool);
|
||||
const wrapped = wrapMcpTool('context7', mcpTool);
|
||||
|
||||
expect(wrapped.name).toBe('context7_resolve-library-id');
|
||||
expect(wrapped.description).toBe('Resolve a library identifier');
|
||||
@@ -29,13 +30,56 @@ describe('mcp-client', () => {
|
||||
expect(typeof wrapped.execute).toBe('function');
|
||||
});
|
||||
|
||||
it('prefixes tools from different servers correctly', () => {
|
||||
const toolA = {
|
||||
name: 'query-docs',
|
||||
description: 'Query docs',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
const toolB = {
|
||||
name: 'overview',
|
||||
description: 'Get overview',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrappedA = wrapMcpTool('context7', toolA);
|
||||
const wrappedB = wrapMcpTool('codecontext', toolB);
|
||||
|
||||
expect(wrappedA.name).toBe('context7_query-docs');
|
||||
expect(wrappedB.name).toBe('codecontext_overview');
|
||||
});
|
||||
|
||||
it('multi-server: two servers with 2 tools each produce 4 prefixed tools', () => {
|
||||
const serverATools = [
|
||||
{ name: 'query-docs', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
{ name: 'resolve-library-id', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
];
|
||||
const serverBTools = [
|
||||
{ name: 'overview', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
{ name: 'search', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
];
|
||||
|
||||
const allWrapped = [
|
||||
...serverATools.map((t) => wrapMcpTool('context7', t)),
|
||||
...serverBTools.map((t) => wrapMcpTool('codecontext', t)),
|
||||
];
|
||||
|
||||
expect(allWrapped).toHaveLength(4);
|
||||
expect(allWrapped.map((t) => t.name)).toEqual([
|
||||
'context7_query-docs',
|
||||
'context7_resolve-library-id',
|
||||
'codecontext_overview',
|
||||
'codecontext_search',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults description to empty string when absent', () => {
|
||||
const mcpTool = {
|
||||
name: 'no-desc',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool(mcpTool);
|
||||
const wrapped = wrapMcpTool('myserver', mcpTool);
|
||||
|
||||
expect(wrapped.description).toBe('');
|
||||
expect(wrapped.jsonSchema.function.description).toBe('');
|
||||
@@ -47,9 +91,8 @@ describe('mcp-client', () => {
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool(mcpTool);
|
||||
const wrapped = wrapMcpTool('s', mcpTool);
|
||||
|
||||
// z.record(z.unknown()) should accept any object
|
||||
const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
@@ -73,7 +116,6 @@ describe('mcp-client', () => {
|
||||
});
|
||||
|
||||
it('accepts tools with only destructiveHint set', () => {
|
||||
// readOnlyHint is not set, so it should be accepted per D3
|
||||
expect(isToolReadOnly({ destructiveHint: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -124,18 +166,4 @@ describe('mcp-client', () => {
|
||||
expect(result).toEqual({ error: true, output: 'error 1\nerror 2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('name prefix', () => {
|
||||
it('prefixed name maps correctly in wrapped tool', () => {
|
||||
const mcpTool = {
|
||||
name: 'query-docs',
|
||||
description: 'Query documentation',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool(mcpTool);
|
||||
expect(wrapped.name).toBe('context7_query-docs');
|
||||
expect(wrapped.jsonSchema.function.name).toBe('context7_query-docs');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* v1.15.0-mcp-multi: unit tests for matchToolGlob.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { matchToolGlob } from '../agents.js';
|
||||
|
||||
describe('matchToolGlob', () => {
|
||||
it('exact match: "grep" matches "grep"', () => {
|
||||
expect(matchToolGlob('grep', ['grep'])).toBe(true);
|
||||
});
|
||||
|
||||
it('exact match: "grep" does not match "grep2"', () => {
|
||||
expect(matchToolGlob('grep2', ['grep'])).toBe(false);
|
||||
});
|
||||
|
||||
it('exact match: multiple tools', () => {
|
||||
expect(matchToolGlob('grep', ['grep', 'view_file'])).toBe(true);
|
||||
expect(matchToolGlob('view_file', ['grep', 'view_file'])).toBe(true);
|
||||
expect(matchToolGlob('find_files', ['grep', 'view_file'])).toBe(false);
|
||||
});
|
||||
|
||||
it('wildcard: "context7_*" matches "context7_query-docs"', () => {
|
||||
expect(matchToolGlob('context7_query-docs', ['context7_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard: "context7_*" matches "context7_resolve-library-id"', () => {
|
||||
expect(matchToolGlob('context7_resolve-library-id', ['context7_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard: "context7_*" does not match "codecontext_overview"', () => {
|
||||
expect(matchToolGlob('codecontext_overview', ['context7_*'])).toBe(false);
|
||||
});
|
||||
|
||||
it('wildcard: "view_*" matches "view_file" and "view_truncated_output"', () => {
|
||||
expect(matchToolGlob('view_file', ['view_*'])).toBe(true);
|
||||
expect(matchToolGlob('view_truncated_output', ['view_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard: "*" matches everything', () => {
|
||||
expect(matchToolGlob('anything', ['*'])).toBe(true);
|
||||
expect(matchToolGlob('context7_query-docs', ['*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('deny: "!web_*" excludes "web_search"', () => {
|
||||
// With only a deny rule and no prior match, the tool is not matched
|
||||
expect(matchToolGlob('web_search', ['!web_*'])).toBe(false);
|
||||
});
|
||||
|
||||
it('last-match-wins: ["*", "!web_*"] excludes web tools, includes others', () => {
|
||||
expect(matchToolGlob('web_search', ['*', '!web_*'])).toBe(false);
|
||||
expect(matchToolGlob('web_fetch', ['*', '!web_*'])).toBe(false);
|
||||
expect(matchToolGlob('grep', ['*', '!web_*'])).toBe(true);
|
||||
expect(matchToolGlob('context7_query-docs', ['*', '!web_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('last-match-wins: deny then re-allow', () => {
|
||||
// ["!web_*", "web_search"] — deny all web, then re-allow web_search
|
||||
expect(matchToolGlob('web_search', ['!web_*', 'web_search'])).toBe(true);
|
||||
expect(matchToolGlob('web_fetch', ['!web_*', 'web_fetch'])).toBe(true);
|
||||
});
|
||||
|
||||
it('empty patterns: nothing matches', () => {
|
||||
expect(matchToolGlob('grep', [])).toBe(false);
|
||||
expect(matchToolGlob('anything', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('no-glob fallback: exact-match only, same as pre-v1.15', () => {
|
||||
const patterns = ['grep', 'view_file'];
|
||||
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||
expect(matchToolGlob('view_file', patterns)).toBe(true);
|
||||
expect(matchToolGlob('find_files', patterns)).toBe(false);
|
||||
expect(matchToolGlob('web_search', patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it('mixed glob and exact patterns', () => {
|
||||
const patterns = ['grep', 'context7_*', '!context7_dangerous'];
|
||||
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||
expect(matchToolGlob('context7_query-docs', patterns)).toBe(true);
|
||||
expect(matchToolGlob('context7_dangerous', patterns)).toBe(false);
|
||||
expect(matchToolGlob('view_file', patterns)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,62 @@ const CACHE_TTL_MS = 60_000;
|
||||
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
|
||||
// codecontext tools were missing), silently filtering valid tool names out
|
||||
// of agents that opted in. Single source of truth is tools.ts now.
|
||||
const ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||
let ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||
let DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||
|
||||
export function refreshToolNames(): void {
|
||||
ALL_TOOL_NAMES = ALL_TOOLS.map((t) => t.name);
|
||||
DEFAULT_TOOLS = [...ALL_TOOL_NAMES];
|
||||
}
|
||||
const DEFAULT_TEMPERATURE = 0.7;
|
||||
|
||||
// ---- Tool glob matching (v1.15.0-mcp-multi) --------------------------------
|
||||
|
||||
/**
|
||||
* Simple glob match for tool names. Supports `*` as a wildcard for any
|
||||
* characters. No `?` or `**` — tool names are flat (no path separators).
|
||||
*/
|
||||
function simpleGlobMatch(str: string, pattern: string): boolean {
|
||||
if (pattern === '*') return true;
|
||||
if (!pattern.includes('*')) return str === pattern;
|
||||
// Escape regex metacharacters, then replace escaped \* with .*
|
||||
const regex = new RegExp(
|
||||
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
|
||||
);
|
||||
return regex.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool name matches a set of glob patterns. Last-match-wins.
|
||||
* Patterns starting with `!` are deny rules.
|
||||
*
|
||||
* Examples:
|
||||
* - `["grep", "view_file"]` — exact-match whitelist (same as pre-v1.15)
|
||||
* - `["context7_*"]` — all tools from the context7 MCP server
|
||||
* - `["*", "!web_*"]` — all tools except web tools
|
||||
* - `[]` — nothing matches (agent gets no tools)
|
||||
*/
|
||||
export function matchToolGlob(toolName: string, patterns: string[]): boolean {
|
||||
let matched = false;
|
||||
for (const pattern of patterns) {
|
||||
const deny = pattern.startsWith('!');
|
||||
const glob = deny ? pattern.slice(1) : pattern;
|
||||
if (simpleGlobMatch(toolName, glob)) {
|
||||
matched = !deny;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a tools: entry is a glob pattern (contains * or starts
|
||||
* with !). Glob patterns can't be validated against the current tool list
|
||||
* since MCP tools are discovered at runtime.
|
||||
*/
|
||||
function isGlobPattern(entry: string): boolean {
|
||||
return entry.includes('*') || entry.startsWith('!');
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
@@ -207,10 +259,14 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
||||
|
||||
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
|
||||
// Unset → resolveToolTier returns ALL tool names → no narrowing.
|
||||
// v1.15.0-mcp-multi: glob patterns (entries containing * or starting with !)
|
||||
// pass through unvalidated — MCP tools are discovered at runtime and can't
|
||||
// be checked against ALL_TOOL_NAMES at parse time.
|
||||
const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
|
||||
const filteredTools = Array.isArray(fm.tools)
|
||||
? fm.tools.filter((t): t is string =>
|
||||
(ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t),
|
||||
isGlobPattern(t) ||
|
||||
((ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t)),
|
||||
)
|
||||
: DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '../../types/api.js';
|
||||
import * as modelContext from '../model-context.js';
|
||||
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
||||
import { matchToolGlob } from '../agents.js';
|
||||
import type { OpenAiMessage } from './payload.js';
|
||||
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
||||
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
||||
@@ -376,14 +377,14 @@ export async function executeStreamPhase(
|
||||
};
|
||||
|
||||
// Tool whitelist: if an agent is set, filter the global tool list to only the
|
||||
// tool names it allows. Unknown names in agent.tools are dropped silently
|
||||
// (handled here by intersection). When no agent: send all tools.
|
||||
// tool names it allows. v1.15.0-mcp-multi: uses matchToolGlob for glob
|
||||
// pattern support (e.g. `context7_*`, `!web_*`). When no agent: send all tools.
|
||||
// v1.11.8: a second filter strips web_search + web_fetch unless the chat
|
||||
// has them explicitly enabled. Counts as an opt-in security boundary: the
|
||||
// model can't summon a tool that wasn't offered to it.
|
||||
const WEB_TOOL_NAMES: ReadonlySet<string> = new Set(['web_search', 'web_fetch']);
|
||||
const effectiveTools: ToolJsonSchema[] = (agent
|
||||
? toolJsonSchemas().filter((t) => agent.tools.includes(t.function.name))
|
||||
? toolJsonSchemas().filter((t) => matchToolGlob(t.function.name, agent.tools))
|
||||
: toolJsonSchemas()
|
||||
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
|
||||
const effectiveTemperature = agent?.temperature;
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
/**
|
||||
* v1.14.1-mcp-poc: singleton MCP client for Context7.
|
||||
* v1.15.0-mcp-multi: multi-server MCP client registry.
|
||||
*
|
||||
* Connects via Streamable HTTP transport, discovers tools at startup,
|
||||
* wraps each as a BooCode ToolDef with a `context7_` name prefix.
|
||||
* Graceful degradation: if the server is unreachable, zero tools are
|
||||
* exposed and BooCode functions normally with native tools.
|
||||
* Connects to multiple MCP servers (Streamable HTTP or stdio transport),
|
||||
* discovers tools from each, wraps them as BooCode ToolDefs with a
|
||||
* `<serverName>_<toolName>` name prefix, and routes callTool by prefix.
|
||||
*
|
||||
* Graceful degradation: one failing server doesn't block others.
|
||||
* Read-only invariant: tools with readOnlyHint === false are rejected.
|
||||
*/
|
||||
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Config } from '../config.js';
|
||||
import type { McpServerEntry, McpServerConfig } from './mcp-config.js';
|
||||
import type { ToolDef } from './tools.js';
|
||||
|
||||
// ---- Types for the MCP tool shape returned by listTools ----
|
||||
// ---- Types ----
|
||||
|
||||
interface McpToolAnnotations {
|
||||
readOnlyHint?: boolean;
|
||||
destructiveHint?: boolean;
|
||||
@@ -27,99 +31,86 @@ interface McpToolDef {
|
||||
annotations?: McpToolAnnotations;
|
||||
}
|
||||
|
||||
interface ServerState {
|
||||
client: Client;
|
||||
transport: StreamableHTTPClientTransport | StdioClientTransport;
|
||||
tools: ToolDef<Record<string, unknown>>[];
|
||||
type: 'streamableHttp' | 'stdio';
|
||||
}
|
||||
|
||||
// ---- Module-level state ----
|
||||
let client: Client | null = null;
|
||||
let tools: ToolDef<Record<string, unknown>>[] = [];
|
||||
let initialized = false;
|
||||
|
||||
const servers = new Map<string, ServerState>();
|
||||
// Reverse map: prefixed tool name → server name (built during discovery)
|
||||
const toolToServer = new Map<string, string>();
|
||||
let log: FastifyBaseLogger | null = null;
|
||||
|
||||
const NAME_PREFIX = 'context7_';
|
||||
const MAX_RESULT_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
/**
|
||||
* Connect to the Context7 MCP server, discover tools, and wrap them
|
||||
* as BooCode ToolDefs. On failure, logs a warning and exposes zero tools.
|
||||
* Connect to all configured MCP servers, discover tools, and wrap them.
|
||||
* Per-server graceful degradation: a failing server is logged and skipped.
|
||||
*/
|
||||
export async function initialize(config: Config, logger: FastifyBaseLogger): Promise<void> {
|
||||
export async function initialize(
|
||||
entries: McpServerEntry[],
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<void> {
|
||||
log = logger;
|
||||
if (!config.MCP_CONTEXT7_URL) {
|
||||
log.info('mcp: MCP_CONTEXT7_URL not set, skipping Context7 initialization');
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
client = new Client({ name: 'boocode', version: '1.14.1' });
|
||||
|
||||
const requestInit: RequestInit = {};
|
||||
if (config.MCP_CONTEXT7_API_KEY) {
|
||||
requestInit.headers = { Authorization: `Bearer ${config.MCP_CONTEXT7_API_KEY}` };
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(config.MCP_CONTEXT7_URL),
|
||||
{ requestInit },
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
const result = await client.listTools();
|
||||
const mcpTools = (result.tools ?? []) as McpToolDef[];
|
||||
|
||||
tools = [];
|
||||
for (const t of mcpTools) {
|
||||
// D3: read-only invariant guard. Reject tools that explicitly declare
|
||||
// readOnlyHint: false (i.e. write tools). Accept readOnlyHint: true
|
||||
// or absent annotations (fail-open — most MCP servers don't annotate).
|
||||
if (t.annotations?.readOnlyHint === false) {
|
||||
log.info({ tool: t.name }, 'mcp: skipping non-read-only tool');
|
||||
continue;
|
||||
// Connect servers in parallel — each wrapped in try/catch for isolation
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
try {
|
||||
await connectServer(entry);
|
||||
} catch (err) {
|
||||
log!.warn(
|
||||
{ err, server: entry.name },
|
||||
`mcp: failed to initialize server "${entry.name}" — its tools will be unavailable`,
|
||||
);
|
||||
}
|
||||
tools.push(wrapMcpTool(t));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (servers.size > 0) {
|
||||
const totalTools = Array.from(servers.values()).reduce((n, s) => n + s.tools.length, 0);
|
||||
log.info(
|
||||
{ count: tools.length, names: tools.map((t) => t.name) },
|
||||
'mcp: initialized Context7',
|
||||
{ servers: servers.size, tools: totalTools },
|
||||
'mcp: multi-server initialization complete',
|
||||
);
|
||||
initialized = true;
|
||||
} catch (err) {
|
||||
log.warn({ err }, 'mcp: failed to initialize Context7 — MCP tools will be unavailable');
|
||||
client = null;
|
||||
tools = [];
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an MCP tool by its prefixed name. Strips the prefix before
|
||||
* forwarding to the MCP server. Returns a string on success or an
|
||||
* error-shaped object on failure.
|
||||
* Call an MCP tool by its prefixed name. Routes to the correct server
|
||||
* using the toolToServer reverse map.
|
||||
*/
|
||||
export async function callTool(
|
||||
prefixedName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
if (!client) {
|
||||
return { error: true, output: 'MCP client not initialized' };
|
||||
const serverName = toolToServer.get(prefixedName);
|
||||
if (!serverName) {
|
||||
return { error: true, output: `MCP tool "${prefixedName}" not found in any server` };
|
||||
}
|
||||
|
||||
const originalName = prefixedName.startsWith(NAME_PREFIX)
|
||||
? prefixedName.slice(NAME_PREFIX.length)
|
||||
: prefixedName;
|
||||
const state = servers.get(serverName);
|
||||
if (!state) {
|
||||
return { error: true, output: `MCP server "${serverName}" not available` };
|
||||
}
|
||||
|
||||
// Strip the "<serverName>_" prefix to get the original tool name
|
||||
const originalName = prefixedName.slice(serverName.length + 1);
|
||||
|
||||
try {
|
||||
const result = await client.callTool({ name: originalName, arguments: args });
|
||||
const result = await state.client.callTool({ name: originalName, arguments: args });
|
||||
|
||||
// D8: extract content blocks
|
||||
const content = result.content as Array<{ type: string; text?: string; [key: string]: unknown }>;
|
||||
if (!content || content.length === 0) {
|
||||
return '(no output)';
|
||||
}
|
||||
|
||||
// If MCP reports an error, return error shape
|
||||
if (result.isError) {
|
||||
const joined = content
|
||||
.map((block) => (block.type === 'text' ? block.text ?? '' : JSON.stringify(block)))
|
||||
@@ -133,12 +124,12 @@ export async function callTool(
|
||||
});
|
||||
const joined = parts.join('\n');
|
||||
if (joined.length > MAX_RESULT_BYTES) {
|
||||
log?.warn({ tool: originalName, bytes: joined.length, cap: MAX_RESULT_BYTES }, 'mcp: result truncated');
|
||||
log?.warn({ tool: originalName, server: serverName, bytes: joined.length, cap: MAX_RESULT_BYTES }, 'mcp: result truncated');
|
||||
return joined.slice(0, MAX_RESULT_BYTES) + '\n\n[truncated — MCP result exceeded size limit]';
|
||||
}
|
||||
return joined;
|
||||
} catch (err) {
|
||||
log?.warn({ err, tool: originalName }, 'mcp: callTool failed');
|
||||
log?.warn({ err, tool: originalName, server: serverName }, 'mcp: callTool failed');
|
||||
return {
|
||||
error: true,
|
||||
output: err instanceof Error ? err.message : 'MCP server unreachable',
|
||||
@@ -146,21 +137,114 @@ export async function callTool(
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the wrapped ToolDefs discovered at initialization. */
|
||||
/** Return all wrapped ToolDefs from all connected servers, flattened. */
|
||||
export function getTools(): ToolDef<Record<string, unknown>>[] {
|
||||
return tools;
|
||||
const all: ToolDef<Record<string, unknown>>[] = [];
|
||||
for (const state of servers.values()) {
|
||||
all.push(...state.tools);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Whether initialize() has been called (even if it failed). */
|
||||
export function isInitialized(): boolean {
|
||||
return initialized;
|
||||
/** Return status of each server (for debug/status endpoints). */
|
||||
export function getMcpServers(): Array<{
|
||||
name: string;
|
||||
type: 'streamableHttp' | 'stdio';
|
||||
toolCount: number;
|
||||
connected: boolean;
|
||||
}> {
|
||||
return Array.from(servers.entries()).map(([name, state]) => ({
|
||||
name,
|
||||
type: state.type,
|
||||
toolCount: state.tools.length,
|
||||
connected: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown. For stdio servers, the SDK's transport.close() handles
|
||||
* SIGTERM + timeout. For HTTP servers, close the transport.
|
||||
*/
|
||||
export async function shutdown(): Promise<void> {
|
||||
const closePromises: Promise<void>[] = [];
|
||||
for (const [name, state] of servers) {
|
||||
closePromises.push(
|
||||
(async () => {
|
||||
try {
|
||||
await state.transport.close();
|
||||
log?.info({ server: name }, 'mcp: server transport closed');
|
||||
} catch (err) {
|
||||
log?.warn({ err, server: name }, 'mcp: error closing server transport');
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
await Promise.all(closePromises);
|
||||
servers.clear();
|
||||
toolToServer.clear();
|
||||
}
|
||||
|
||||
// ---- Internal helpers ----
|
||||
|
||||
/** Exposed for unit tests. */
|
||||
export function wrapMcpTool(mcpTool: McpToolDef): ToolDef<Record<string, unknown>> {
|
||||
const prefixedName = `${NAME_PREFIX}${mcpTool.name}`;
|
||||
async function connectServer(entry: McpServerEntry): Promise<void> {
|
||||
const { name, config } = entry;
|
||||
|
||||
const client = new Client({ name: 'boocode', version: '1.15.0' });
|
||||
let transport: StreamableHTTPClientTransport | StdioClientTransport;
|
||||
|
||||
if (config.type === 'streamableHttp') {
|
||||
transport = createHttpTransport(config);
|
||||
} else {
|
||||
transport = createStdioTransport(config);
|
||||
}
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
const result = await client.listTools();
|
||||
const mcpTools = (result.tools ?? []) as McpToolDef[];
|
||||
|
||||
const tools: ToolDef<Record<string, unknown>>[] = [];
|
||||
for (const t of mcpTools) {
|
||||
if (t.annotations?.readOnlyHint === false) {
|
||||
log!.info({ tool: t.name, server: name }, 'mcp: skipping non-read-only tool');
|
||||
continue;
|
||||
}
|
||||
const wrapped = wrapMcpTool(name, t);
|
||||
tools.push(wrapped);
|
||||
toolToServer.set(wrapped.name, name);
|
||||
}
|
||||
|
||||
servers.set(name, { client, transport, tools, type: config.type });
|
||||
|
||||
log!.info(
|
||||
{ server: name, type: config.type, count: tools.length, names: tools.map((t) => t.name) },
|
||||
'mcp: server initialized',
|
||||
);
|
||||
}
|
||||
|
||||
function createHttpTransport(config: Extract<McpServerConfig, { type: 'streamableHttp' }>): StreamableHTTPClientTransport {
|
||||
const requestInit: RequestInit = {};
|
||||
if (config.headers && Object.keys(config.headers).length > 0) {
|
||||
requestInit.headers = config.headers;
|
||||
}
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), { requestInit });
|
||||
}
|
||||
|
||||
function createStdioTransport(config: Extract<McpServerConfig, { type: 'stdio' }>): StdioClientTransport {
|
||||
return new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap an MCP tool as a BooCode ToolDef with a server-name prefix. */
|
||||
export function wrapMcpTool(
|
||||
serverName: string,
|
||||
mcpTool: McpToolDef,
|
||||
): ToolDef<Record<string, unknown>> {
|
||||
const prefixedName = `${serverName}_${mcpTool.name}`;
|
||||
return {
|
||||
name: prefixedName,
|
||||
description: mcpTool.description ?? '',
|
||||
@@ -200,6 +284,5 @@ export function extractContent(
|
||||
|
||||
/** Exposed for unit tests — the read-only guard predicate. */
|
||||
export function isToolReadOnly(annotations?: McpToolAnnotations): boolean {
|
||||
// Reject explicitly non-read-only tools; accept everything else
|
||||
return annotations?.readOnlyHint !== false;
|
||||
}
|
||||
|
||||
78
apps/server/src/services/mcp-config.ts
Normal file
78
apps/server/src/services/mcp-config.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* v1.15.0-mcp-multi: MCP config file schema + loader.
|
||||
*
|
||||
* Reads a JSON config file (default `/data/mcp.json`) that declares MCP
|
||||
* servers — their transport type, connection parameters, and enabled state.
|
||||
* Schema shape matches opencode's `mcpServers` key for copy-paste compat.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
// ---- Zod schema ----
|
||||
|
||||
const McpServerConfigSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('streamableHttp'),
|
||||
url: z.string().url(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('stdio'),
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).default([]),
|
||||
env: z.record(z.string()).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
]);
|
||||
|
||||
const McpConfigSchema = z.object({
|
||||
mcpServers: z.record(z.string(), McpServerConfigSchema).default({}),
|
||||
});
|
||||
|
||||
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>;
|
||||
|
||||
export interface McpServerEntry {
|
||||
name: string;
|
||||
config: McpServerConfig;
|
||||
}
|
||||
|
||||
// ---- Loader ----
|
||||
|
||||
/**
|
||||
* Read and validate the MCP config file. Returns enabled servers only.
|
||||
* File missing → log info, return []. Parse/validation error → log warn, return [].
|
||||
*/
|
||||
export function loadMcpConfig(configPath: string, log: FastifyBaseLogger): McpServerEntry[] {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
log.info(`mcp: config not found at ${configPath}, skipping`);
|
||||
return [];
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
log.warn({ err }, `mcp: failed to parse ${configPath} as JSON`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = McpConfigSchema.safeParse(json);
|
||||
if (!result.success) {
|
||||
log.warn({ errors: result.error.flatten().fieldErrors }, `mcp: invalid config at ${configPath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: McpServerEntry[] = [];
|
||||
for (const [name, config] of Object.entries(result.data.mcpServers)) {
|
||||
if (config.enabled) {
|
||||
entries.push({ name, config });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
Reference in New Issue
Block a user