diff --git a/.changeset/mcp-namespace-search-tools.md b/.changeset/mcp-namespace-search-tools.md new file mode 100644 index 000000000..28818f61b --- /dev/null +++ b/.changeset/mcp-namespace-search-tools.md @@ -0,0 +1,8 @@ +--- +"@executor-js/execution": patch +"executor": patch +--- + +**Opt-in per-integration search tools on the MCP surface** + +Connecting with `?search_tools=true` (stdio: `executor mcp --search-tools`) adds one minimally-described `search_` MCP tool per connected integration, so the integration namespaces reach the model as tool names it can see without calling anything. Each call routes through the same flow as `tools.search({ namespace })` inside `execute`, and the tool list comes from the same inventory the `execute` description shows. Off by default; a clean endpoint URL is unchanged. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 6fa8a26fa..fc2a9bf39 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -1362,6 +1362,7 @@ const mcpUrlForActiveLocalServer = (input: { readonly connection: ExecutorServerConnection; readonly elicitationMode: "browser" | "model"; readonly artifacts: boolean; + readonly searchTools: boolean; }): URL => { const url = new URL("/mcp", input.connection.origin); if (input.elicitationMode === "browser") { @@ -1372,6 +1373,11 @@ const mcpUrlForActiveLocalServer = (input: { if (!input.artifacts) { url.searchParams.set("artifacts", "false"); } + // Per-integration search tools are off by default; only the opt-in is + // spelled out. + if (input.searchTools) { + url.searchParams.set("search_tools", "true"); + } return url; }; @@ -1387,6 +1393,7 @@ const runMcpHttpBridge = async (input: { readonly manifest: ExecutorLocalServerManifest; readonly elicitationMode: "browser" | "model"; readonly artifacts: boolean; + readonly searchTools: boolean; }): Promise => { const stdio = new StdioServerTransport(); const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); @@ -1395,6 +1402,7 @@ const runMcpHttpBridge = async (input: { connection: input.manifest.connection, elicitationMode: input.elicitationMode, artifacts: input.artifacts, + searchTools: input.searchTools, }), authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, ); @@ -1473,6 +1481,7 @@ const runMcpHttpBridge = async (input: { const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model"; readonly artifacts: boolean; + readonly searchTools: boolean; }) => Effect.gen(function* () { // `executor mcp` never owns the local database. If a local server is already @@ -1489,6 +1498,7 @@ const runStdioMcpSession = (input: { manifest: active, elicitationMode: input.elicitationMode, artifacts: input.artifacts, + searchTools: input.searchTools, }), ); return; @@ -1515,6 +1525,7 @@ const runStdioMcpSession = (input: { manifest: elected, elicitationMode: input.elicitationMode, artifacts: input.artifacts, + searchTools: input.searchTools, }), ); }); @@ -2880,11 +2891,18 @@ const mcpCommand = Command.make( "Withhold the artifact surface from this connection: the artifact tools, the app shell resource, and the artifact skills. Served by default.", ), ), + searchTools: Options.boolean("search-tools") + .pipe(Options.withDefault(false)) + .pipe( + Options.withDescription( + "Serve one search_ tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.", + ), + ), }, - ({ scope, elicitationMode, noArtifacts }) => + ({ scope, elicitationMode, noArtifacts, searchTools }) => Effect.gen(function* () { applyScope(scope); - yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts }); + yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools }); }), ).pipe(Command.withDescription("Start an MCP server over stdio")); diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 7738e79c6..9d75300bf 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -13,6 +13,7 @@ import { currentPropagationHeaders, readArtifactsEnabled, readElicitationMode, + readSearchToolsEnabled, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; @@ -133,6 +134,7 @@ const propsForPrincipal = ( userId: principal.accountId, elicitationMode: readElicitationMode(request), artifactsEnabled: readArtifactsEnabled(request), + searchToolsEnabled: readSearchToolsEnabled(request), resource, webOrigin: new URL(request.url).origin, }, diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 87161366d..c272e5026 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -223,6 +223,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase` tool per connected integration, +// whose whole point is to carry the integration namespaces into the model's +// context as tool names. A call routes through the same flow as +// `tools.search({ namespace })` inside `execute`, so its results match what +// code-side enumeration returns. The proof is comparative: two sessions, same +// identity, same server, differing only in that query. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +const spec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Searchable API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/alpha": { + get: { + operationId: "alphaOp", + summary: "First operation", + responses: { "200": { description: "ok" } }, + }, + }, + "/bravo": { + post: { + operationId: "bravoOp", + summary: "Second operation", + responses: { "200": { description: "ok" } }, + }, + }, + }, + }); + +scenario( + "Discovery · a session connected with search_tools=true serves one search tool per integration", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = unique("nssearch"); + + yield* Effect.ensuring( + Effect.gen(function* () { + // The connection must exist before a session opens: the tool list is + // built from the integration inventory at session creation. + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: spec("http://127.0.0.1:59999") }, + slug, + baseUrl: "http://127.0.0.1:59999", // never contacted: discovery only + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("apiKey"), + value: "tok_nssearch", + }, + }); + + const searchTool = `search_${slug}`; + + // The default: a plain endpoint serves no per-integration search tools. + const defaultSession = mcp.session(identity); + const defaultTools = yield* defaultSession.listTools(); + expect( + defaultTools.filter((name) => name.startsWith("search_")), + "a plain session serves no search_ tools", + ).toEqual([]); + + // The opt-in: same identity, `?search_tools=true`. + const optedIn = mcp.session(identity, { searchTools: true }); + const optedInTools = yield* optedIn.describeTools(); + const names = optedInTools.map((tool) => tool.name); + expect(names, "the opted-in session serves the integration's search tool").toContain( + searchTool, + ); + // 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. + 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", + ); + + // A keyword call returns the matching tool, exactly as + // `tools.search({ query, namespace })` inside execute would. + const searched = yield* optedIn.call(searchTool, { query: "alpha" }); + expect(searched.ok, `the search came back: ${searched.text}`).toBe(true); + expect(searched.text, "the keyword match is returned").toContain("alphaOp"); + expect(searched.text, "the non-match is not").not.toContain("bravoOp"); + + // An empty call enumerates the whole namespace. + const enumerated = yield* optedIn.call(searchTool, {}); + expect(enumerated.ok, `the enumeration came back: ${enumerated.text}`).toBe(true); + expect(enumerated.text, "enumeration lists every operation").toContain("alphaOp"); + expect(enumerated.text, "enumeration lists every operation").toContain("bravoOp"); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: ConnectionName.make("main"), + }, + }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), +); diff --git a/e2e/src/surfaces/mcp.ts b/e2e/src/surfaces/mcp.ts index 1069aad7b..e1fa12c5f 100644 --- a/e2e/src/surfaces/mcp.ts +++ b/e2e/src/surfaces/mcp.ts @@ -168,6 +168,10 @@ export interface McpSurface { * (`?artifacts=false`). Omitted means the product default: the full * artifact surface. */ readonly artifacts?: boolean; + /** Set `true` to opt this session into the per-integration + * `search_` tools (`?search_tools=true`). Omitted means + * the product default: none. */ + readonly searchTools?: boolean; readonly url?: string; }, ) => McpSession; @@ -303,12 +307,14 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( const serverName = `${target.name}-${randomUUID().slice(0, 8)}`; // Per-connection settings ride the MCP endpoint query, the ecosystem // convention: `elicitation_mode` so a paused execution yields an approvalUrl - // instead of letting the model resume inline, and `artifacts=false` to opt - // the session out of the artifact surface. Both are non-defaults, so a - // plain session's URL carries no query at all. + // instead of letting the model resume inline, `artifacts=false` to opt the + // session out of the artifact surface, and `search_tools=true` to opt into + // the per-integration search tools. All are non-defaults, so a plain + // session's URL carries no query at all. const sessionQuery = [ ...(options?.elicitationMode ? [`elicitation_mode=${options.elicitationMode}`] : []), ...(options?.artifacts === false ? ["artifacts=false"] : []), + ...(options?.searchTools === true ? ["search_tools=true"] : []), ].join("&"); const sessionUrl = sessionQuery ? `${mcpUrl}?${sessionQuery}` : mcpUrl; diff --git a/packages/core/execution/src/description.test.ts b/packages/core/execution/src/description.test.ts index c95573fc1..365f11558 100644 --- a/packages/core/execution/src/description.test.ts +++ b/packages/core/execution/src/description.test.ts @@ -13,7 +13,7 @@ import { } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; -import { buildExecuteDescription } from "./description"; +import { buildExecuteDescription, parseIntegrationInventory } from "./description"; const memoryProvider = (): CredentialProvider => { const store = new Map(); @@ -190,3 +190,52 @@ describe("buildExecuteDescription", () => { }), ); }); + +describe("parseIntegrationInventory", () => { + it.effect("round-trips the slugs a built description lists", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), + ); + yield* executor["slack-plugin"].seed(); + yield* executor["github-plugin"].seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: SLACK, + template: TEMPLATE, + value: "slack-token", + }); + yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: GITHUB, + template: TEMPLATE, + value: "user-token", + }); + + const description = yield* buildExecuteDescription(executor); + + expect(parseIntegrationInventory(description)).toEqual(["github", "slack"]); + }), + ); + + it("returns nothing for a description without an inventory block", () => { + expect(parseIntegrationInventory("Execute TypeScript in a sandboxed runtime.")).toEqual([]); + }); + + it("reads item lines only, not the overflow marker or prose", () => { + const description = [ + "Execute TypeScript in a sandboxed runtime.", + "", + "## Available integrations", + "", + "Integrations you have connected. Their tools live under `tools..…`.", + "- `github`", + "- `google_gmail`", + "- ... 3 more", + ].join("\n"); + + expect(parseIntegrationInventory(description)).toEqual(["github", "google_gmail"]); + }); +}); diff --git a/packages/core/execution/src/description.ts b/packages/core/execution/src/description.ts index f9cda53d6..dec2876fd 100644 --- a/packages/core/execution/src/description.ts +++ b/packages/core/execution/src/description.ts @@ -74,6 +74,28 @@ const connectionPath = (connection: Connection): string => { // connected. const INVENTORY_LIMIT = 50; +/** One inventory line per integration: `` - `slug` ``. Owned here beside the + * formatter below so {@link parseIntegrationInventory} cannot drift from it. */ +const INVENTORY_ITEM_PATTERN = /^- `([^`]+)`$/; + +/** + * Recover the integration slugs from a built execute description — the exact + * list `formatIntegrationInventory` rendered, overflow marker excluded. Lets a + * host derive per-integration surfaces (the opt-in `search_` MCP + * tools) from the description it already holds, without a second + * `connections.list()` that could disagree with what the model reads. + */ +export const parseIntegrationInventory = (description: string): readonly string[] => { + const index = description.indexOf(INTEGRATION_INVENTORY_HEADER); + if (index === -1) return []; + const slugs: string[] = []; + for (const line of description.slice(index).split("\n")) { + const match = INVENTORY_ITEM_PATTERN.exec(line); + if (match?.[1]) slugs.push(match[1]); + } + return slugs; +}; + const formatIntegrationInventory = (connections: readonly Connection[]): string => { const slugs = [...new Set(connections.map((connection) => String(connection.integration)))].sort( (a, b) => a.localeCompare(b), diff --git a/packages/core/execution/src/index.ts b/packages/core/execution/src/index.ts index 3e68509cd..23175a01b 100644 --- a/packages/core/execution/src/index.ts +++ b/packages/core/execution/src/index.ts @@ -11,7 +11,11 @@ export { type ResumeResponse, } from "./engine"; -export { buildExecuteDescription, INTEGRATION_INVENTORY_HEADER } from "./description"; +export { + buildExecuteDescription, + parseIntegrationInventory, + INTEGRATION_INVENTORY_HEADER, +} from "./description"; export { EXECUTE_SKILL, CREATE_ARTIFACT_SKILL, diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 4ccc51965..9ff71cfed 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -43,6 +43,10 @@ export interface McpSessionInit { /** Whether this session serves artifacts, read off `?artifacts=` at connect * time. Absent means the default (enabled). */ readonly artifactsEnabled?: boolean; + /** Whether this session serves the per-integration `search_` + * tools, read off `?search_tools=` at connect time. Absent means the + * default (disabled). */ + readonly searchToolsEnabled?: boolean; /** The MCP resource the session was minted against (`/mcp` default vs a * `/mcp/toolkits/` toolkit), so the tool catalog is scoped to it. */ readonly resource: McpResource; @@ -105,6 +109,10 @@ export interface SessionMeta { * Absent — including for sessions persisted before the flag existed — means * the default (enabled). */ readonly artifactsEnabled?: boolean; + /** Whether the session serves the per-integration search tools (carried from + * {@link McpSessionInit}). Absent — including for sessions persisted before + * the flag existed — means the default (disabled). */ + readonly searchToolsEnabled?: boolean; /** The MCP resource the session serves (carried from {@link McpSessionInit}); * `buildMcpServer` scopes the tool catalog to it. */ readonly resource: McpResource; diff --git a/packages/hosts/cloudflare/src/mcp/do-headers.ts b/packages/hosts/cloudflare/src/mcp/do-headers.ts index ca101ba60..02c4b196d 100644 --- a/packages/hosts/cloudflare/src/mcp/do-headers.ts +++ b/packages/hosts/cloudflare/src/mcp/do-headers.ts @@ -125,11 +125,13 @@ export const withMcpResponseHeaders = (response: Response): Response => { }; // The endpoint query contract — `?elicitation_mode=` (plus the legacy -// `?allow_model_resume` alias) and the `?artifacts=` opt-in — is shared with -// every host that serves these connections. Re-exported here so the worker -// dispatcher's existing import site (`./do-headers`) is unchanged. +// `?allow_model_resume` alias), the `?artifacts=` opt-out, and the +// `?search_tools=` opt-in — is shared with every host that serves these +// connections. Re-exported here so the worker dispatcher's existing import +// site (`./do-headers`) is unchanged. export { readArtifactsEnabled, readElicitationMode, + readSearchToolsEnabled, type McpElicitationMode, } from "@executor-js/host-mcp/browser-approval"; diff --git a/packages/hosts/mcp/src/browser-approval.ts b/packages/hosts/mcp/src/browser-approval.ts index 20e6693de..a524b89fb 100644 --- a/packages/hosts/mcp/src/browser-approval.ts +++ b/packages/hosts/mcp/src/browser-approval.ts @@ -66,6 +66,19 @@ export const readArtifactsEnabled = (request: Request): boolean => { return TRUE_QUERY_VALUES.has(value.toLowerCase()); }; +/** + * Read the per-integration search tools opt-IN off an MCP request's + * `?search_tools=` query. OFF by default: a clean endpoint URL serves only the + * core surface, and only an accepted truthy spelling (`?search_tools=true`) + * adds one `search_` tool per connected integration. Any other + * explicit value reads as the default (disabled). + */ +export const readSearchToolsEnabled = (request: Request): boolean => { + const value = new URL(request.url).searchParams.get("search_tools"); + if (value === null) return false; + return TRUE_QUERY_VALUES.has(value.toLowerCase()); +}; + /** * Build the console approval URL for a paused execution: * `//resume/?mcp_session_id=` diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index 2cd870fc1..869691648 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -10,6 +10,7 @@ import { formatResumeAcknowledgement, readArtifactsEnabled, readElicitationMode, + readSearchToolsEnabled, } from "./browser-approval"; import { makeInProcessBrowserApprovalStore, @@ -75,6 +76,9 @@ export interface McpBuildServerOptions { * with `?artifacts=false`; opted out, the built server registers none of * the artifact tools, resource, or skills. */ readonly artifactsEnabled?: boolean; + /** Whether this session serves the per-integration `search_` + * tools. False unless the client connected with `?search_tools=true`. */ + readonly searchToolsEnabled?: boolean; } /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */ @@ -224,10 +228,14 @@ export const makeInMemoryMcpSessionStore = ( sessionId: () => string | null, ): McpBuildServerOptions => { const artifactsEnabled = readArtifactsEnabled(request); + const searchToolsEnabled = readSearchToolsEnabled(request); const mode = readElicitationMode(request); - if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } }; + if (mode !== "browser") { + return { artifactsEnabled, searchToolsEnabled, elicitationMode: { mode } }; + } return { artifactsEnabled, + searchToolsEnabled, elicitationMode: { mode: "browser", // Prefer the pinned public origin; fall back to the request URL (correct diff --git a/packages/hosts/mcp/src/namespace-search-tools.test.ts b/packages/hosts/mcp/src/namespace-search-tools.test.ts new file mode 100644 index 000000000..cbbd4a225 --- /dev/null +++ b/packages/hosts/mcp/src/namespace-search-tools.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type * as Cause from "effect/Cause"; + +import type { ExecutionEngine } from "@executor-js/execution"; + +import { readSearchToolsEnabled } from "./browser-approval"; +import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/** A stub engine that records every executed code string, so a test can prove + * a `search_` call became the expected `tools.search` code. */ +const makeRecordingEngine = (): { + engine: ExecutionEngine; + executed: string[]; +} => { + const executed: string[] = []; + return { + executed, + engine: { + execute: () => Effect.succeed({ result: "default" }), + executeWithPause: (code) => + Effect.sync(() => { + executed.push(code); + return { status: "completed" as const, result: { result: "default" } }; + }), + resume: () => Effect.succeed(null), + isExecutionSettled: undefined, + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test executor"), + }, + }; +}; + +// The inventory block exactly as `buildExecuteDescription` renders it, +// including an overflow marker and a slug that cannot form a legal MCP tool +// name (which registration must skip, not fail on). +const DESCRIPTION_WITH_INVENTORY = [ + "Execute TypeScript in a sandboxed runtime.", + "", + "## Available integrations", + "", + "Integrations you have connected. Their tools live under `tools..…`.", + "- `github`", + "- `google_gmail`", + "- `not a tool name!`", + "- ... 3 more", +].join("\n"); + +const DESCRIPTION_WITHOUT_INVENTORY = "Execute TypeScript in a sandboxed runtime."; + +const withClient = async ( + config: ExecutorMcpServerConfig, + fn: (client: Client) => Promise, +) => { + const mcpServer = await Effect.runPromise(createExecutorMcpServer(config)); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper must close MCP transports after async client assertions + try { + await fn(client); + } finally { + await clientTransport.close(); + await serverTransport.close(); + } +}; + +const toolNames = async (client: Client): Promise => + (await client.listTools()).tools.map((tool) => tool.name); + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +describe("MCP host — per-integration search tools", () => { + it("registers none by default: the option is opt-in", async () => { + const { engine } = makeRecordingEngine(); + await withClient({ engine, description: DESCRIPTION_WITH_INVENTORY }, async (client) => { + const names = await toolNames(client); + expect(names).toContain("execute"); + expect(names.filter((name) => name.startsWith("search_"))).toEqual([]); + }); + }); + + it("registers none when the flag is explicitly false", async () => { + const { engine } = makeRecordingEngine(); + await withClient( + { engine, description: DESCRIPTION_WITH_INVENTORY, searchToolsEnabled: false }, + async (client) => { + expect((await toolNames(client)).filter((name) => name.startsWith("search_"))).toEqual([]); + }, + ); + }); + + it("registers one search_ tool per inventory entry when opted in", async () => { + const { engine } = makeRecordingEngine(); + await withClient( + { engine, description: DESCRIPTION_WITH_INVENTORY, searchToolsEnabled: true }, + async (client) => { + const tools = (await client.listTools()).tools; + const names = tools.map((tool) => tool.name); + expect(names).toContain("search_github"); + expect(names).toContain("search_google_gmail"); + // The core surface is untouched. + expect(names).toContain("execute"); + expect(names).toContain("skills"); + // The overflow marker is not an integration, and a slug that cannot + // form a legal MCP tool name is skipped rather than failing the + // 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. + 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("registers none when the description carries no inventory", async () => { + const { engine } = makeRecordingEngine(); + await withClient( + { engine, description: DESCRIPTION_WITHOUT_INVENTORY, searchToolsEnabled: true }, + async (client) => { + expect((await toolNames(client)).filter((name) => name.startsWith("search_"))).toEqual([]); + }, + ); + }); + + // --------------------------------------------------------------------------- + // Dispatch: the call is the same flow as `tools.search` inside execute + // --------------------------------------------------------------------------- + + it("routes a call through the engine as tools.search with the namespace pinned", async () => { + const { engine, executed } = makeRecordingEngine(); + await withClient( + { engine, description: DESCRIPTION_WITH_INVENTORY, searchToolsEnabled: true }, + async (client) => { + const result = await client.callTool({ + name: "search_github", + arguments: { query: "issues", limit: 5 }, + }); + expect(executed).toEqual([ + 'return tools.search({"query":"issues","namespace":"github","limit":5})', + ]); + expect(result.isError ?? false).toBe(false); + }, + ); + }); + + it("enumerates the namespace when the query is omitted", async () => { + const { engine, executed } = makeRecordingEngine(); + await withClient( + { engine, description: DESCRIPTION_WITH_INVENTORY, searchToolsEnabled: true }, + async (client) => { + await client.callTool({ name: "search_google_gmail", arguments: {} }); + expect(executed).toEqual(['return tools.search({"query":"","namespace":"google_gmail"})']); + }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Endpoint query contract +// --------------------------------------------------------------------------- + +describe("search tools opt-in query", () => { + const requestFor = (url: string): Request => new Request(url); + + it("defaults to disabled on a clean endpoint", () => { + expect(readSearchToolsEnabled(requestFor("https://executor.example/mcp"))).toBe(false); + }); + + it("accepts the truthy spellings", () => { + for (const value of ["1", "true", "yes", "on", "TRUE", "On"]) { + expect( + readSearchToolsEnabled(requestFor(`https://executor.example/mcp?search_tools=${value}`)), + ).toBe(true); + } + }); + + it("reads any other explicit value as the default (disabled)", () => { + for (const value of ["false", "0", "no", "off", "maybe", ""]) { + expect( + readSearchToolsEnabled(requestFor(`https://executor.example/mcp?search_tools=${value}`)), + ).toBe(false); + } + }); +}); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 26014e563..058931721 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -39,6 +39,7 @@ import { formatPausedExecution, formatTtlDuration, findSkill, + parseIntegrationInventory, renderSkillsIndex, skillCatalogFor, EXECUTE_SKILL, @@ -165,6 +166,16 @@ type SharedMcpServerConfig = { * serves. `execute`, `skills` and `resume` are untouched. */ readonly artifactsEnabled?: boolean; + /** + * Per-connection opt-IN for the per-integration search tools. Defaults to + * false. A client that connects with `?search_tools=true` gets one + * `search_` tool per connected integration (the same inventory + * the `execute` description lists). The tools exist to carry the namespaces + * into the model's context as tool names; each call routes through the same + * execution flow as `tools.search({ namespace })` inside `execute`, so the + * results match what code-side search returns. + */ + readonly searchToolsEnabled?: boolean; /** * Renders an artifact once, server-side, before it is saved — so a component * that throws on its first render is refused at create time with the real @@ -1094,6 +1105,9 @@ export const createExecutorMcpServer = ( // the skills catalog below. const artifactsEnabled = config.artifactsEnabled ?? true; const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled }); + // Per-integration search tools are off unless this connection opted in + // (`?search_tools=true`). + const searchToolsEnabled = config.searchToolsEnabled ?? false; // Captured at construction time. SDK callbacks fire later (often // deferred past the outer Effect's await), so we use the runtime to @@ -1251,6 +1265,23 @@ export const createExecutorMcpServer = ( Effect.annotateSpans(joinKeyAttributes(extra)), ); + // `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 + // 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 }), + })})`; + /** What the caller could bind an unresolved role to. Best effort: the * connections port is optional, and a failure to enumerate must not * replace the real error with a different one. */ @@ -1587,6 +1618,61 @@ export const createExecutorMcpServer = ( }), ); + // --- per-integration search tools (opt-in, `?search_tools=true`) --- + // + // One minimally-described tool per connected integration, named + // `search_`. Their job is to put the integration namespaces + // into the model's context as tool names it can see without calling + // anything; a call routes through the same flow as + // `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. + if (searchToolsEnabled) { + // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already + // conform (they are `tools.` property names in sandbox code); one + // that somehow doesn't is skipped rather than failing the whole session. + const TOOL_NAME_SAFE_SLUG = /^[A-Za-z0-9_-]+$/; + const namespaces = parseIntegrationInventory(description).filter((slug) => + TOOL_NAME_SAFE_SLUG.test(slug), + ); + yield* Effect.sync(() => { + for (const integration of namespaces) { + 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."), + }, + }, + ({ query, limit, offset }, extra) => + runToolEffect( + executeCode(searchNamespaceCode(integration, { query, limit, offset }), extra).pipe( + Effect.withSpan("mcp.host.tool.namespace_search", { + attributes: { + "mcp.tool.name": `search_${integration}`, + "executor.integration": integration, + }, + }), + ), + ), + ); + } + }).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { + "mcp.tool.name": "search_", + "mcp.namespace_search.count": namespaces.length, + }, + }), + ); + } + // --- artifacts / MCP Apps --- // // These register unconditionally once a shell loader is configured. Whether diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 2b68f57ad..48ded2492 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -157,6 +157,7 @@ export interface AnalyticsEvents { mcp_install_transport_switched: { transport: "http" | "stdio" }; mcp_install_elicitation_mode_changed: { elicitation_mode: string }; mcp_install_artifacts_toggled: { artifacts: boolean }; + mcp_install_search_tools_toggled: { search_tools: boolean }; // ── Command palette ────────────────────────────────────────────────────── command_palette_navigated: { diff --git a/packages/react/src/components/mcp-install-card.test.ts b/packages/react/src/components/mcp-install-card.test.ts index 44f4c642c..38ed2a7ba 100644 --- a/packages/react/src/components/mcp-install-card.test.ts +++ b/packages/react/src/components/mcp-install-card.test.ts @@ -111,6 +111,46 @@ describe("MCP install command rendering", () => { ); }); + // Per-integration search tools are off by default, so only the opt-in is + // ever spelled out: a card left alone must still produce the bare endpoint. + it("emits the search tools opt-in only when enabled", () => { + expect( + buildMcpHttpEndpoint({ + origin: "https://executor.example", + desktop: null, + searchTools: false, + }), + ).toBe("https://executor.example/mcp"); + + expect( + buildMcpHttpEndpoint({ + origin: "https://executor.example", + desktop: null, + searchTools: true, + }), + ).toBe("https://executor.example/mcp?search_tools=true"); + + // Both non-defaults together, in the order the card renders them. + expect( + buildMcpHttpEndpoint({ + origin: "https://executor.example", + desktop: null, + artifacts: false, + searchTools: true, + }), + ).toBe("https://executor.example/mcp?artifacts=false&search_tools=true"); + }); + + it("passes the search tools opt-in to the stdio CLI as a flag", () => { + expect( + buildMcpInstallCommand({ mode: "stdio", isDev: false, origin: null, searchTools: false }), + ).toBe("npx add-mcp 'executor mcp' --name executor"); + + expect( + buildMcpInstallCommand({ mode: "stdio", isDev: false, origin: null, searchTools: true }), + ).toBe("npx add-mcp 'executor mcp --search-tools' --name executor"); + }); + it("passes the artifacts opt-out to the stdio CLI as a flag", () => { expect( buildMcpInstallCommand({ mode: "stdio", isDev: false, origin: null, artifacts: true }), diff --git a/packages/react/src/components/mcp-install-card.tsx b/packages/react/src/components/mcp-install-card.tsx index da371aa77..7701913ec 100644 --- a/packages/react/src/components/mcp-install-card.tsx +++ b/packages/react/src/components/mcp-install-card.tsx @@ -52,6 +52,9 @@ export const buildMcpHttpEndpoint = (input: { /** Artifacts are on by default, so only the opt-out is spelled out on the * URL (`&artifacts=false`) and a default endpoint stays clean. */ readonly artifacts?: boolean; + /** Per-integration search tools are off by default, so only the opt-in is + * spelled out on the URL (`&search_tools=true`). */ + readonly searchTools?: boolean; // Cloud only: pins the URL to `//mcp` (the server also accepts the // legacy `//mcp` form). Desktop/local pass nothing and get the bare // `/mcp` path. @@ -73,6 +76,7 @@ export const buildMcpHttpEndpoint = (input: { params.push(["elicitation_mode", input.elicitationMode]); } if (input.artifacts === false) params.push(["artifacts", "false"]); + if (input.searchTools === true) params.push(["search_tools", "true"]); if (params.length === 0) return endpoint; const query = params.map(([key, value]) => `${key}=${value}`).join("&"); @@ -94,6 +98,7 @@ export const buildMcpInstallCommand = (input: { readonly authorizationHeader?: string | null; readonly elicitationMode?: McpElicitationMode; readonly artifacts?: boolean; + readonly searchTools?: boolean; readonly devCliCwd?: string; readonly organizationSlug?: string | null; }): string => { @@ -103,6 +108,7 @@ export const buildMcpInstallCommand = (input: { desktop: input.desktop ? { port: input.desktop.port } : null, elicitationMode: input.elicitationMode, artifacts: input.artifacts, + searchTools: input.searchTools, organizationSlug: input.organizationSlug, }); const headerFlags: string[] = []; @@ -130,6 +136,9 @@ export const buildMcpInstallCommand = (input: { if (input.artifacts === false) { innerArgs.push("--no-artifacts"); } + if (input.searchTools === true) { + innerArgs.push("--search-tools"); + } return `npx add-mcp ${shellQuoteWord(innerArgs.map(shellQuoteWord).join(" "))} --name executor`; }; @@ -138,6 +147,7 @@ export function McpInstallCard(props: { className?: string }) { const [advancedOpen, setAdvancedOpen] = useState(false); const [httpElicitationMode, setHttpElicitationMode] = useState("model"); const [artifacts, setArtifacts] = useState(true); + const [searchTools, setSearchTools] = useState(false); const organizationSlug = useOrganizationSlug(); const serverConnection = useExecutorServerConnection(); // Desktop hosts ship Electron without putting an `executor` binary on @@ -181,6 +191,7 @@ export function McpInstallCard(props: { className?: string }) { authorizationHeader, elicitationMode, artifacts, + searchTools, devCliCwd, organizationSlug, }); @@ -220,6 +231,24 @@ export function McpInstallCard(props: { className?: string }) { aria-label="Artifacts" /> +
+
+
Integration search tools
+
+ {searchTools + ? "One search tool per connected integration, so agents see your integrations as tool names." + : "Disabled: agents discover tools through search inside execute."} +
+
+ { + setSearchTools(next); + trackEvent("mcp_install_search_tools_toggled", { search_tools: next }); + }} + aria-label="Integration search tools" + /> +
Resume approvals