Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions packages/mcp-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
40 changes: 27 additions & 13 deletions packages/mcp-tools/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<slug>` credential
Expand Down Expand Up @@ -182,18 +182,29 @@ 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 "";
if (description.length <= TRUNCATE_LENGTH) return description;
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,
};
}
Expand Down Expand Up @@ -360,6 +371,7 @@ async function runListToolsCatalog(
tools: loaded.tools.map((tool) => ({
name: tool.name,
description: truncateDescription(tool.description),
schema: truncateSchema(tool.inputSchema),
})),
}),
});
Expand Down Expand Up @@ -477,10 +489,11 @@ async function runRead(env: McpToolsEnv, call: ToolCall): Promise<ToolResult> {
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);
Expand Down Expand Up @@ -570,11 +583,11 @@ export const mcpTools = defineTool<McpToolsEnv>({
"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: {
Expand All @@ -589,8 +602,8 @@ export const mcpTools = defineTool<McpToolsEnv>({
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",
Expand All @@ -610,7 +623,8 @@ export const mcpTools = defineTool<McpToolsEnv>({
"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",
Expand Down
107 changes: 107 additions & 0 deletions packages/mcp-tools/test/scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
18 changes: 14 additions & 4 deletions packages/mcp-tools/test/stub-mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export interface StubMcpServerHandle {
*/
function buildServer(
calls: { name: string; args: Record<string, unknown> }[],
opts?: {
echoDescription?: string;
echoInputSchema?: Record<string, unknown>;
echoResult?: string;
},
): Server {
const server = new Server(
{ name: "stub-mcp-server", version: "0.0.1" },
Expand All @@ -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"],
Expand All @@ -67,7 +72,9 @@ function buildServer(
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
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 {
Expand Down Expand Up @@ -104,6 +111,9 @@ function buildServer(
*/
export function startStubMcpServer(opts?: {
requiredToken?: string;
echoDescription?: string;
echoInputSchema?: Record<string, unknown>;
echoResult?: string;
}): StubMcpServerHandle {
const calls: { name: string; args: Record<string, unknown> }[] = [];
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
Expand Down Expand Up @@ -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);
},
});
Expand Down
2 changes: 1 addition & 1 deletion workflows/assistant/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down
2 changes: 1 addition & 1 deletion workflows/attio-task-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

/**
Expand Down
2 changes: 1 addition & 1 deletion workflows/attio-task-agent/test/definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
});

Expand Down
2 changes: 1 addition & 1 deletion workflows/exa-topic-watch/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

/**
Expand Down
2 changes: 1 addition & 1 deletion workflows/exa-topic-watch/test/definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
});

Expand Down
Loading