diff --git a/.changeset/slim-search-tools.md b/.changeset/slim-search-tools.md new file mode 100644 index 0000000000..05d45ea03a --- /dev/null +++ b/.changeset/slim-search-tools.md @@ -0,0 +1,6 @@ +--- +"@executor-js/execution": patch +"executor": patch +--- + +Slim the per-integration `search_` tool definitions to under half their size: one shared one-line description (the tool name already carries the namespace) and a single bare `query` parameter, dropping the `limit`/`offset` knobs. A session pays for these definitions once per connected integration, so the surface now costs ~2k tokens instead of ~5k at 30 integrations; paging through a namespace belongs in `execute`. diff --git a/e2e/scenarios/namespace-search-tools.test.ts b/e2e/scenarios/namespace-search-tools.test.ts index 8bec55df8c..51a0d954e3 100644 --- a/e2e/scenarios/namespace-search-tools.test.ts +++ b/e2e/scenarios/namespace-search-tools.test.ts @@ -104,12 +104,17 @@ scenario( // The core surface is untouched. expect(names, "execute still works on an opted-in session").toContain("execute"); expect(names, "skills still works on an opted-in session").toContain("skills"); - // The description is minimal and points back at the execute flow. + // The NAME carries the namespace; the description is one shared line + // that points back at the execute flow, kept tiny because a session + // pays for it once per connected integration. const described = optedInTools.find((tool) => tool.name === searchTool); - expect(described?.description, "the tool description names its namespace").toContain(slug); expect(described?.description, "the tool description points at execute").toContain( "execute", ); + expect( + (described?.description ?? "").length, + "the tool description stays lean", + ).toBeLessThan(120); // A keyword call returns the matching tool, exactly as // `tools.search({ query, namespace })` inside execute would. diff --git a/packages/hosts/mcp/src/namespace-search-tools.test.ts b/packages/hosts/mcp/src/namespace-search-tools.test.ts index cbbd4a2251..6e798f0f89 100644 --- a/packages/hosts/mcp/src/namespace-search-tools.test.ts +++ b/packages/hosts/mcp/src/namespace-search-tools.test.ts @@ -118,16 +118,38 @@ describe("MCP host — per-integration search tools", () => { // session. expect(names.filter((name) => name.startsWith("search_"))).toHaveLength(2); - // Minimal descriptions: one line that names the namespace and points - // back at the execute flow. + // Minimal descriptions: the NAME carries the namespace; the shared + // one-line description only points back at the execute flow. const gmail = tools.find((tool) => tool.name === "search_google_gmail"); - expect(gmail?.description).toContain("google_gmail"); expect(gmail?.description).toContain("execute"); expect(gmail?.description?.includes("\n")).toBe(false); }, ); }); + it("keeps each serialized definition small — the whole point is cheap context", async () => { + // A session serves one of these per connected integration (up to 50), so + // definition bytes multiply. 300 serialized chars/tool keeps the full + // surface around ~2k tokens; the original shipped shape was 551 chars/tool + // (~5k tokens for 30 integrations), which defeated the feature's purpose. + const { engine } = makeRecordingEngine(); + await withClient( + { engine, description: DESCRIPTION_WITH_INVENTORY, searchToolsEnabled: true }, + async (client) => { + const tools = (await client.listTools()).tools.filter((tool) => + tool.name.startsWith("search_"), + ); + expect(tools.length).toBeGreaterThan(0); + for (const tool of tools) { + expect( + JSON.stringify(tool).length, + `${tool.name} definition must stay lean`, + ).toBeLessThan(300); + } + }, + ); + }); + it("registers none when the description carries no inventory", async () => { const { engine } = makeRecordingEngine(); await withClient( @@ -149,11 +171,9 @@ describe("MCP host — per-integration search tools", () => { async (client) => { const result = await client.callTool({ name: "search_github", - arguments: { query: "issues", limit: 5 }, + arguments: { query: "issues" }, }); - expect(executed).toEqual([ - 'return tools.search({"query":"issues","namespace":"github","limit":5})', - ]); + expect(executed).toEqual(['return tools.search({"query":"issues","namespace":"github"})']); expect(result.isError ?? false).toBe(false); }, ); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 0589317217..f8c6a908b9 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1267,20 +1267,12 @@ export const createExecutorMcpServer = ( // `search_` is `execute` running `tools.search` with the // namespace pinned. The code is built HERE, from the slug the tool was - // registered under and JSON-encoded arguments — never concatenated from + // registered under and a JSON-encoded query — never concatenated from // raw model input — and then takes the exact `executeCode` path, so the // results, formatting, and telemetry match a hand-written // `tools.search({ namespace })` call. - const searchNamespaceCode = ( - integration: string, - args: { readonly query?: string; readonly limit?: number; readonly offset?: number }, - ): string => - `return tools.search(${JSON.stringify({ - query: args.query ?? "", - namespace: integration, - ...(args.limit === undefined ? {} : { limit: args.limit }), - ...(args.offset === undefined ? {} : { offset: args.offset }), - })})`; + const searchNamespaceCode = (integration: string, query: string | undefined): string => + `return tools.search(${JSON.stringify({ query: query ?? "", namespace: integration })})`; /** What the caller could bind an unresolved role to. Best effort: the * connections port is optional, and a failure to enumerate must not @@ -1627,6 +1619,13 @@ export const createExecutorMcpServer = ( // `tools.search({ namespace })` inside `execute` (see searchNamespaceCode). // The inventory comes from the same built description the model reads, so // the two surfaces cannot list different integrations. + // + // A session serves up to 50 of these, so every definition byte is paid ~50 + // times in the client's context. The NAME is the payload; everything else + // stays as small as it can: one shared description sentence (the slug + // would only repeat the name) and a single bare `query` parameter — no + // paging knobs, because anything past the first page belongs in `execute`. + // `namespace-search-tools.test.ts` pins the serialized size. if (searchToolsEnabled) { // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already // conform (they are `tools.` property names in sandbox code); one @@ -1640,19 +1639,13 @@ export const createExecutorMcpServer = ( server.registerTool( `search_${integration}`, { - description: `Find \`${integration}\` tools. Same results as \`tools.search({ query, namespace: "${integration}" })\` inside execute; run what you find with execute.`, - inputSchema: { - query: z - .string() - .optional() - .describe("Keywords to match. Omit to list the whole namespace."), - limit: z.number().optional().describe("Max results per page."), - offset: z.number().optional().describe("Pagination offset."), - }, + description: + "Search this integration's tools; empty query lists all. Run results with execute.", + inputSchema: { query: z.string().optional() }, }, - ({ query, limit, offset }, extra) => + ({ query }, extra) => runToolEffect( - executeCode(searchNamespaceCode(integration, { query, limit, offset }), extra).pipe( + executeCode(searchNamespaceCode(integration, query), extra).pipe( Effect.withSpan("mcp.host.tool.namespace_search", { attributes: { "mcp.tool.name": `search_${integration}`,