From 3c00c2ce0228a5074f0880e6af69b68f5a83abb0 Mon Sep 17 00:00:00 2001 From: fforres Date: Fri, 9 Oct 2026 07:07:05 -0700 Subject: [PATCH] feat(host-mcp): serve Executor configuration tools on the passthrough surface --- .changeset/passthrough-config-tools.md | 5 ++ .../hosts/mcp/src/passthrough-tools.test.ts | 74 ++++++++++++++----- packages/hosts/mcp/src/passthrough-tools.ts | 3 + packages/hosts/mcp/src/tool-server.ts | 61 +++++++++------ 4 files changed, 105 insertions(+), 38 deletions(-) create mode 100644 .changeset/passthrough-config-tools.md diff --git a/.changeset/passthrough-config-tools.md b/.changeset/passthrough-config-tools.md new file mode 100644 index 0000000000..d3c7a6510e --- /dev/null +++ b/.changeset/passthrough-config-tools.md @@ -0,0 +1,5 @@ +--- +"@executor-js/host-mcp": patch +--- + +Passthrough connections (`?mode=passthrough`) now search and invoke Executor's own configuration tools, such as `executor.mcp.addServer`, `executor.connections.createHandoff` and `executor.oauth.start`, so an agent on that surface can add an integration when asked. Block policies still hide them. diff --git a/packages/hosts/mcp/src/passthrough-tools.test.ts b/packages/hosts/mcp/src/passthrough-tools.test.ts index 179d803428..9bea56b141 100644 --- a/packages/hosts/mcp/src/passthrough-tools.test.ts +++ b/packages/hosts/mcp/src/passthrough-tools.test.ts @@ -383,13 +383,6 @@ describe("passthrough mode server", () => { connection: "main", name: "issues.first", }), - projection({ - integration: "github", - owner: "org", - connection: "main", - name: "issues.static", - static: true, - }), ]; await withClient( { engine, mode: "passthrough", tools: toolPort(catalog, schemaReads) }, @@ -507,24 +500,71 @@ describe("passthrough mode server", () => { ); }); - it("uses current schemas and excludes static configuration tools", async () => { + it("searches and invokes Executor's own configuration tools by their fqid", async () => { + const recording = makeRecordingEngine(); + const addServer: Tool = { + address: ToolAddress.make("executor.mcp.addServer"), + integration: IntegrationSlug.make("executor"), + owner: "org", + connection: ConnectionName.make("coreTools"), + name: ToolName.make("mcp.addServer"), + pluginId: "mcp", + description: "Register an MCP server in the catalog as an integration.", + inputSchema: { + type: "object", + properties: { endpoint: { type: "string" } }, + required: ["endpoint"], + }, + annotations: { requiresApproval: true }, + static: true, + }; + await withClient( + { engine: recording.engine, mode: "passthrough", tools: toolPort([addServer]) }, + async (client) => { + const found = await client.callTool({ + name: "search", + arguments: { query: "add mcp server" }, + }); + expect(found.structuredContent).toMatchObject({ + items: [ + { + id: "executor.mcp.addServer", + integration: "executor", + owner: "org", + connection: "coreTools", + inputSchema: { required: ["endpoint"] }, + }, + ], + }); + await client.callTool({ + name: "invoke", + arguments: { + tool: "executor.mcp.addServer", + arguments: { endpoint: "https://blog.mcp.cloudflare.com/mcp" }, + }, + }); + const unknown = await client.callTool({ + name: "invoke", + arguments: { tool: "executor.mcp.removeEverything", arguments: {} }, + }); + expect(unknown.isError).toBe(true); + expect(recording.executed).toEqual([ + 'return await tools["executor.mcp.addServer"]({"endpoint":"https://blog.mcp.cloudflare.com/mcp"});', + ]); + }, + ); + }); + + it("uses current schemas", async () => { const recording = makeRecordingEngine(); const dynamic = projection({ integration: "notes", name: "create", inputSchema: { type: "object" }, }); - const catalog = [dynamic, projection({ integration: "settings", name: "erase", static: true })]; let current: ToolSchemaView = { address: dynamic.address, inputSchema: dynamic.inputSchema }; - const tools: McpToolsPort = { ...toolPort(catalog), schema: () => Effect.succeed(current) }; + const tools: McpToolsPort = { ...toolPort([dynamic]), schema: () => Effect.succeed(current) }; await withClient({ engine: recording.engine, mode: "passthrough", tools }, async (client) => { - const hidden = await client.callTool({ name: "search", arguments: { query: "settings" } }); - expect(decodeSearchItems(hidden.structuredContent).items).toEqual([]); - const staticCall = await client.callTool({ - name: "invoke", - arguments: { tool: "tools.settings.org.main.erase", arguments: {} }, - }); - expect(staticCall.isError).toBe(true); await client.callTool({ name: "search", arguments: { query: "notes" } }); current = { address: dynamic.address, diff --git a/packages/hosts/mcp/src/passthrough-tools.ts b/packages/hosts/mcp/src/passthrough-tools.ts index 5ad32f1f6e..7fd79a0753 100644 --- a/packages/hosts/mcp/src/passthrough-tools.ts +++ b/packages/hosts/mcp/src/passthrough-tools.ts @@ -42,6 +42,9 @@ export const SEARCH_INVOKE_SKILL: Skill = { '2. Call `search({ query: "create issue", integration: "github", owner: "org", connection: "main" })`. Use the exact integration, owner, and connection returned by integrations to select an account. Omit filters to search across accounts visible to you.', "3. Read the matching tool's `inputSchema`. Call `invoke({ tool: , arguments: })`. Do not guess tool IDs or arguments.", "", + "## Adding an integration", + 'Executor\'s own configuration tools are searchable too, under the `executor` integration. To add an MCP server, search "add mcp server" and invoke `executor.mcp.addServer`. Then connect it: `executor.connections.createHandoff` returns a URL where the user enters an API key, and `executor.oauth.probe` + `executor.oauth.start` return an authorization URL for OAuth. Give the user the URL. Never ask for the credential in chat.', + "", "## Pagination", "Both integrations and search return `{ items, total, hasMore, nextOffset }`. If hasMore is true, repeat the call with the same filters and `offset: nextOffset`. Search also needs the same query. Search returns at most 20 tools per page, with schemas only for those matches.", "Tool-search pagination is separate from an upstream API's pagination. Follow the invoked tool's schema and response for cursor or page arguments when retrieving more records.", diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index c0d662ea03..4dd72d6cf9 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -47,6 +47,7 @@ import type { SaveArtifactInput, ToolFileValue, Executor, + Tool, ToolSchemaView, } from "@executor-js/sdk"; import type * as Tracer from "effect/Tracer"; @@ -1297,6 +1298,12 @@ const parseJsonContent = (raw: string): Record | undefined => { // Passthrough surface // --------------------------------------------------------------------------- +/** The sandbox path discovery reports for a tool: its address without the proxy root. */ +const toolPath = (address: ToolAddress): string => { + const value = String(address); + return value.startsWith("tools.") ? value.slice("tools.".length) : value; +}; + /** Serialize one existing schema view as a self-contained MCP input schema. */ const passthroughInputSchema = (view: ToolSchemaView): unknown => reattachDefs( @@ -1443,6 +1450,7 @@ const registerPassthroughTools = ( ({ query, integration, owner, connection, limit, offset }, extra) => boundary( Effect.gen(function* () { + const listed = new Map(); const discovery = { tools: { list: (filter?: Parameters[0]) => @@ -1457,7 +1465,13 @@ const registerPassthroughTools = ( ? {} : { connection: ConnectionName.make(connection) }), }) - .pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))), + .pipe( + Effect.tap((items) => + Effect.sync(() => { + for (const tool of items) listed.set(toolPath(tool.address), tool); + }), + ), + ), }, }; // Through the configured provider, not the raw ranker: passthrough @@ -1473,18 +1487,17 @@ const registerPassthroughTools = ( page.items, (match) => Effect.gen(function* () { - const address = ToolAddress.make(`tools.${match.path}`); - const identity = parseToolAddress(String(address)); - if (!identity) return null; - const schema = yield* tools.schema(address); + const tool = listed.get(match.path); + if (!tool) return null; + const schema = yield* tools.schema(tool.address); // Visibility can change between listing and schema lookup. if (!schema) return null; return { - id: String(address), + id: String(tool.address), name: match.name, - integration: identity.integration, - owner: identity.owner, - connection: identity.connection, + integration: tool.integration, + owner: tool.owner, + connection: tool.connection, description: match.description, inputSchema: passthroughInputSchema(schema), ...(schema.annotations ? { annotations: schema.annotations } : {}), @@ -1517,7 +1530,6 @@ const registerPassthroughTools = ( ({ tool: id, arguments: args }, extra) => boundary( Effect.gen(function* () { - const identity = parseToolAddress(id); const unavailable = { isError: true, content: [ @@ -1527,18 +1539,25 @@ const registerPassthroughTools = ( }, ], }; - if (!identity) return unavailable; const address = ToolAddress.make(id); - // Use the existing visibility filter and exclude static configuration tools. - const visible = yield* tools.list({ - integration: identity.integration, - owner: identity.owner, - connection: identity.connection, - query: String(identity.tool), - includeAnnotations: false, - }); - if (!visible.some((tool) => tool.static !== true && tool.address === address)) - return unavailable; + const identity = parseToolAddress(id); + // Executor's own configuration tools are addressed by their fqid, so + // they are found by integration rather than the five-segment address. + const visible = yield* tools.list( + identity + ? { + integration: identity.integration, + owner: identity.owner, + connection: identity.connection, + query: String(identity.tool), + includeAnnotations: false, + } + : { + integration: IntegrationSlug.make(id.split(".")[0] ?? id), + includeAnnotations: false, + }, + ); + if (!visible.some((tool) => tool.address === address)) return unavailable; const schema = yield* tools.schema(address); if (!schema) return unavailable; // The SDK validator checks this dynamic JSON schema at the MCP boundary.