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
6 changes: 6 additions & 0 deletions .changeset/slim-search-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@executor-js/execution": patch
"executor": patch
---

Slim the per-integration `search_<integration>` 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`.
9 changes: 7 additions & 2 deletions e2e/scenarios/namespace-search-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 27 additions & 7 deletions packages/hosts/mcp/src/namespace-search-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
},
);
Expand Down
37 changes: 15 additions & 22 deletions packages/hosts/mcp/src/tool-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,20 +1267,12 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(

// `search_<integration>` 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
Expand Down Expand Up @@ -1627,6 +1619,13 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(
// `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.<slug>` property names in sandbox code); one
Expand All @@ -1640,19 +1639,13 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(
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}`,
Expand Down
Loading