diff --git a/packages/mcp-tools/README.md b/packages/mcp-tools/README.md index fbbc72749..459c6a429 100644 --- a/packages/mcp-tools/README.md +++ b/packages/mcp-tools/README.md @@ -21,16 +21,19 @@ via the same routes without the person typing a URL. - **`mcp_list_tools`** — read-only. Discovery, in four modes, narrowest first: - no arguments — a catalog of every connected server with its tool - names and **truncated** descriptions, for a first skim. + names and **truncated** descriptions and schemas, for a first skim. - `{ pattern }` — regex-searches tool and server names across every connected server, for when it's unclear which server has the tool. - - `{ server }` — one server's full tool list with full descriptions. - - `{ server, toolName }` — one tool's full input schema. + - `{ server }` — one server's tool list with truncated descriptions + and schemas (same 100-character bound as the catalog). + - `{ server, toolName }` — one tool, same truncated description and + schema. - **`mcp_read`** — calls a tool on a connected server without human approval. Only works when that server's live `tools/list` marks the tool `readOnlyHint: true` — re-checked at call time, never assumed from the model's claim (`readOnlyGate` in `src/tool.ts`). Errors, pointing at - `mcp_call`, when the tool isn't marked read-only. + `mcp_call`, when the tool isn't marked read-only. A single payload is + bounded to about 8,000 characters. - **`mcp_call`** — calls any tool, read or write, on a connected server. Gated `approval: "ask"` unconditionally: one downstream MCP server can bind under any name at deploy time, so a single grant for `mcp_call` diff --git a/packages/mcp-tools/package.json b/packages/mcp-tools/package.json index 4f829fcc2..2b7aa04fe 100644 --- a/packages/mcp-tools/package.json +++ b/packages/mcp-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/mcp-tools", "private": true, "description": "Generic MCP server integration: connect any Streamable HTTP MCP server through Plugins and its tools become reachable by any agent via mcp_list_servers/mcp_list_tools/mcp_call", - "version": "0.0.8", + "version": "0.0.9", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/mcp-tools/src/tool.ts b/packages/mcp-tools/src/tool.ts index 2faeaa537..e3c9f3cdc 100644 --- a/packages/mcp-tools/src/tool.ts +++ b/packages/mcp-tools/src/tool.ts @@ -6,8 +6,8 @@ // mcp_list_tools -- discover tools: no args for a truncated // catalog of every server, {pattern} to regex // search names across all servers, {server} for -// one server's full list, {server, toolName} -// for one tool's full schema. +// one server's truncated list, {server, toolName} +// for one tool's truncated schema. // mcp_call({server, tool, arguments}) -- invoke one of those tools. // // Credentials: each connected server is a `mcp.` credential @@ -182,6 +182,7 @@ const CallInput = type({ const TRUNCATE_LENGTH = 100; const TRUNCATE_SUFFIX = "… [truncated]"; +const MCP_READ_MAX_CHARS = 8_000; function truncateDescription(description: string | undefined): string { if (description === undefined) return ""; @@ -189,11 +190,21 @@ function truncateDescription(description: string | undefined): string { return description.slice(0, TRUNCATE_LENGTH) + TRUNCATE_SUFFIX; } +function truncateSchema(schema: unknown): string { + return truncateDescription(JSON.stringify(schema)); +} + +function boundReadContent(content: string): string { + if (content.length <= MCP_READ_MAX_CHARS) return content; + const keep = Math.max(0, MCP_READ_MAX_CHARS - TRUNCATE_SUFFIX.length); + return content.slice(0, keep) + TRUNCATE_SUFFIX; +} + function toolSummary(tool: McpToolInfo) { return { name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, + description: truncateDescription(tool.description), + inputSchema: truncateSchema(tool.inputSchema), readOnly: tool.annotations?.readOnlyHint === true, }; } @@ -360,6 +371,7 @@ async function runListToolsCatalog( tools: loaded.tools.map((tool) => ({ name: tool.name, description: truncateDescription(tool.description), + schema: truncateSchema(tool.inputSchema), })), }), }); @@ -477,10 +489,11 @@ async function runRead(env: McpToolsEnv, call: ToolCall): Promise { return { callId: call.id, isError: result.isError, - content: + content: boundReadContent( typeof result.content === "string" ? result.content : JSON.stringify(result.content), + ), }; } catch (err) { return errorResult(call.id, err); @@ -570,11 +583,11 @@ export const mcpTools = defineTool({ "descriptions, for a first skim. `{pattern}` regex-searches " + "tool AND server names across every connected server — use " + "this when you're not sure which server has the tool you " + - "want. `{server}` returns one server's full tool list with " + - "full descriptions. `{server, toolName}` returns that one " + - "tool's full input schema, once you know exactly which tool " + - "you're calling. Never dump a whole server's catalog into a " + - "reply to the human.", + "want. `{server}` returns one server's tool list with " + + "truncated descriptions and schemas. `{server, toolName}` " + + "returns that one tool's truncated input schema, once you " + + "know exactly which tool you're calling. Never dump a whole " + + "server's catalog into a reply to the human.", inputSchema: { type: "object", properties: { @@ -589,8 +602,8 @@ export const mcpTools = defineTool({ toolName: { type: "string", description: - "A specific tool's exact name, to get its full input " + - "schema. Requires `server`.", + "A specific tool's exact name, to get its truncated " + + "input schema. Requires `server`.", }, pattern: { type: "string", @@ -610,7 +623,8 @@ export const mcpTools = defineTool({ "marks the tool `readOnlyHint: true`; this is re-checked live " + `at call time, never assumed. Use for reads. Call ` + `${MCP_LIST_TOOLS_TOOL} once first to find the tool and its ` + - `exact name and input schema. If this errors telling you the ` + + `exact name and input schema. Payloads over 8k characters ` + + `are truncated. If this errors telling you the ` + `tool isn't read-only, use ${MCP_CALL_TOOL} instead.`, inputSchema: { type: "object", diff --git a/packages/mcp-tools/test/scenario.test.ts b/packages/mcp-tools/test/scenario.test.ts index 6135f003f..041d86e11 100644 --- a/packages/mcp-tools/test/scenario.test.ts +++ b/packages/mcp-tools/test/scenario.test.ts @@ -274,3 +274,110 @@ test("mcp_call against an unconnected server degrades to an honest error, never globalThis.fetch = originalFetch; } }); + +test("mcp_list_tools truncates long descriptions and schemas like the catalog", async () => { + stub.stop(); + stub = startStubMcpServer({ + requiredToken: TOKEN, + echoDescription: "d".repeat(250), + echoInputSchema: { + type: "object", + properties: { text: { type: "string", description: "s".repeat(250) } }, + required: ["text"], + }, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input, init) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.endsWith("/api/workflow-connections/mcp-servers")) { + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ slug: "notion", name: "Notion", url: stub.url }], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + } + return originalFetch(input, init); + }) as typeof fetch; + + try { + const bundle = mcpTools(fakeEnv()); + const result = await bundle.run( + { + id: "c5", + name: MCP_LIST_TOOLS_TOOL, + arguments: { server: "notion" }, + } satisfies ToolCall, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + const body = JSON.parse(result.content as string) as { + tools: { + name: string; + description: string; + inputSchema: unknown; + }[]; + }; + const echo = body.tools.find((tool) => tool.name === "echo"); + expect(echo).toBeDefined(); + expect(echo?.description.length ?? 0).toBeLessThanOrEqual(120); + expect(echo?.description).toContain("[truncated]"); + const schemaText = + typeof echo?.inputSchema === "string" + ? echo.inputSchema + : JSON.stringify(echo?.inputSchema); + expect(schemaText.length).toBeLessThanOrEqual(120); + expect(schemaText).toContain("[truncated]"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("mcp_read bounds a single payload to about 8k characters", async () => { + stub.stop(); + stub = startStubMcpServer({ + requiredToken: TOKEN, + echoResult: "x".repeat(20_000), + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input, init) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.endsWith("/api/workflow-connections/mcp-servers")) { + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ slug: "notion", name: "Notion", url: stub.url }], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + } + return originalFetch(input, init); + }) as typeof fetch; + + try { + const bundle = mcpTools(fakeEnv()); + const result = await bundle.run( + { + id: "r3", + name: MCP_READ_TOOL, + arguments: { + server: "notion", + tool: "echo", + arguments: { text: "ignored" }, + }, + } satisfies ToolCall, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + const content = result.content as string; + expect(content.length).toBeLessThanOrEqual(8_200); + expect(content).toContain("[truncated]"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/mcp-tools/test/stub-mcp-server.ts b/packages/mcp-tools/test/stub-mcp-server.ts index 933c1f567..ff10aeefb 100644 --- a/packages/mcp-tools/test/stub-mcp-server.ts +++ b/packages/mcp-tools/test/stub-mcp-server.ts @@ -32,6 +32,11 @@ export interface StubMcpServerHandle { */ function buildServer( calls: { name: string; args: Record }[], + opts?: { + echoDescription?: string; + echoInputSchema?: Record; + echoResult?: string; + }, ): Server { const server = new Server( { name: "stub-mcp-server", version: "0.0.1" }, @@ -42,8 +47,8 @@ function buildServer( tools: [ { name: "echo", - description: "Echoes back its input.", - inputSchema: { + description: opts?.echoDescription ?? "Echoes back its input.", + inputSchema: opts?.echoInputSchema ?? { type: "object", properties: { text: { type: "string" } }, required: ["text"], @@ -67,7 +72,9 @@ function buildServer( const args = (request.params.arguments ?? {}) as Record; calls.push({ name: request.params.name, args }); if (request.params.name === "echo") { - return { content: [{ type: "text", text: String(args["text"]) }] }; + const text = + opts?.echoResult !== undefined ? opts.echoResult : String(args["text"]); + return { content: [{ type: "text", text }] }; } if (request.params.name === "write_note") { return { @@ -104,6 +111,9 @@ function buildServer( */ export function startStubMcpServer(opts?: { requiredToken?: string; + echoDescription?: string; + echoInputSchema?: Record; + echoResult?: string; }): StubMcpServerHandle { const calls: { name: string; args: Record }[] = []; const sessions = new Map(); @@ -132,7 +142,7 @@ export function startStubMcpServer(opts?: { sessions.delete(id); }, }); - await buildServer(calls).connect(transport); + await buildServer(calls, opts).connect(transport); return transport.handleRequest(req); }, }); diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 1640466d8..432d85bb3 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -48,7 +48,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/connections-tools", version: "0.0.6" }, { name: "@corbits/catalog-tools", version: "0.0.1" }, { name: "@corbits/skills-tools", version: "0.0.6" }, - { name: "@corbits/mcp-tools", version: "0.0.8" }, + { name: "@corbits/mcp-tools", version: "0.0.9" }, { name: "@corbits/interaction-tools", version: "0.0.2" }, ]; diff --git a/workflows/attio-task-agent/src/index.ts b/workflows/attio-task-agent/src/index.ts index d89f65acf..f46097e26 100644 --- a/workflows/attio-task-agent/src/index.ts +++ b/workflows/attio-task-agent/src/index.ts @@ -90,7 +90,7 @@ export const ATTIO_TASK_AGENT_SYSTEM_PROMPT = buildAttioTaskAgentSystemPrompt({ * deploy of this package itself. */ export const ATTIO_TASK_AGENT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ - { name: "@corbits/mcp-tools", version: "0.0.8" }, + { name: "@corbits/mcp-tools", version: "0.0.9" }, ]; /** diff --git a/workflows/attio-task-agent/test/definition.test.ts b/workflows/attio-task-agent/test/definition.test.ts index d96ae8fcb..8c161a88c 100644 --- a/workflows/attio-task-agent/test/definition.test.ts +++ b/workflows/attio-task-agent/test/definition.test.ts @@ -70,7 +70,7 @@ test("one MCP pin covers the CRM, past calls, and the web — the OG needed thre const agent = workStep(buildAttioTaskAgentWorkflow(INPUT)).agent; expect(agent.toolPackagePins).toEqual(ATTIO_TASK_AGENT_TOOL_PACKAGE_PINS); expect(ATTIO_TASK_AGENT_TOOL_PACKAGE_PINS).toEqual([ - { name: "@corbits/mcp-tools", version: "0.0.8" }, + { name: "@corbits/mcp-tools", version: "0.0.9" }, ]); }); diff --git a/workflows/exa-topic-watch/src/index.ts b/workflows/exa-topic-watch/src/index.ts index 332c9370b..98a802164 100644 --- a/workflows/exa-topic-watch/src/index.ts +++ b/workflows/exa-topic-watch/src/index.ts @@ -110,7 +110,7 @@ export const EXA_TOPIC_WATCH_SYSTEM_PROMPT = [ * travels with the deploy of this package itself. */ export const EXA_TOPIC_WATCH_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ - { name: "@corbits/mcp-tools", version: "0.0.8" }, + { name: "@corbits/mcp-tools", version: "0.0.9" }, ]; /** diff --git a/workflows/exa-topic-watch/test/definition.test.ts b/workflows/exa-topic-watch/test/definition.test.ts index 990612546..23109a2e3 100644 --- a/workflows/exa-topic-watch/test/definition.test.ts +++ b/workflows/exa-topic-watch/test/definition.test.ts @@ -71,7 +71,7 @@ test("the step pins the MCP tools bundle, the one package a deploy here can reso const agent = digestStep(buildExaTopicWatchWorkflow(INPUT)).agent; expect(agent.toolPackagePins).toEqual(EXA_TOPIC_WATCH_TOOL_PACKAGE_PINS); expect(EXA_TOPIC_WATCH_TOOL_PACKAGE_PINS).toEqual([ - { name: "@corbits/mcp-tools", version: "0.0.8" }, + { name: "@corbits/mcp-tools", version: "0.0.9" }, ]); });