From 7f6bed688dfd6efab6d730c3ac203ed5d7409e38 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:49:26 -0700 Subject: [PATCH] Add account discovery and guides to search and invoke --- apps/cloud/src/mcp/session-durable-object.ts | 1 + apps/docs/mcp-proxy.mdx | 29 +++ .../src/mcp/session-durable-object.ts | 1 + apps/local/src/main.ts | 3 + .../passthrough-opencode-codemode.test.ts | 2 +- e2e/cloud/passthrough-scale.test.ts | 7 +- e2e/scenarios/mcp-passthrough.test.ts | 81 +++++- packages/core/api/src/server/mcp-build.ts | 1 + .../hosts/mcp/src/passthrough-tools.test.ts | 242 +++++++++++++++++- packages/hosts/mcp/src/passthrough-tools.ts | 25 ++ packages/hosts/mcp/src/tool-server.ts | 233 +++++++++++++---- .../react/src/components/mcp-install-card.tsx | 2 +- 12 files changed, 562 insertions(+), 65 deletions(-) diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 3f6be9ef28..701166ece7 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -385,6 +385,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase tool.name).sort()).toEqual(["invoke", "search"]); + expect(served.map((tool) => tool.name).sort()).toEqual([ + "integrations", + "invoke", + "search", + "skills", + ]); const first = decodeToolSearch( (yield* session.call("search", { query: "org", limit: 20 })).raw, ).structuredContent; diff --git a/e2e/scenarios/mcp-passthrough.test.ts b/e2e/scenarios/mcp-passthrough.test.ts index 34aaf54f4f..827982f316 100644 --- a/e2e/scenarios/mcp-passthrough.test.ts +++ b/e2e/scenarios/mcp-passthrough.test.ts @@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto"; import { createServer, type IncomingMessage } from "node:http"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { @@ -16,7 +16,23 @@ import { import { decodeToolSearch } from "./support/search-invoke"; import { scenario } from "../src/scenario"; -import { Api, Mcp, Target } from "../src/services"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit, settle } from "../src/surfaces/browser"; + +const decodeInventory = Schema.decodeUnknownSync( + Schema.Struct({ + structuredContent: Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + integration: Schema.String, + owner: Schema.String, + connection: Schema.String, + }), + ), + total: Schema.Number, + }), + }), +); const api = composePluginApi([openApiHttpPlugin()] as const); @@ -133,6 +149,7 @@ scenario( Effect.gen(function* () { const target = yield* Target; const mcp = yield* Mcp; + const browser = yield* Browser; const { client: makeClient } = yield* Api; const identity = yield* target.newIdentity(); @@ -170,12 +187,35 @@ scenario( }); } + yield* browser.session(identity, async ({ page, step }) => { + await step("Choose Search and invoke in the Connect card", async () => { + await visit(page, "/"); + await page.getByRole("button", { name: "Advanced" }).click(); + await page.getByRole("switch", { name: "Search and invoke" }).check(); + await settle(page); + expect(await page.locator("code").first().innerText()).toContain("mode=passthrough"); + expect( + await page + .getByText( + "Discover connected accounts with integrations and read the guide with skills.", + { exact: false }, + ) + .isVisible(), + ).toBe(true); + }); + }); + const codemode = mcp.session(identity); expect(yield* codemode.listTools()).toContain("execute"); const passthrough = mcp.session(identity, { mode: "passthrough" }); const described = yield* passthrough.describeTools(); - expect(described.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]); + expect(described.map((tool) => tool.name).sort()).toEqual([ + "integrations", + "invoke", + "search", + "skills", + ]); expect(described.find((tool) => tool.name === "search")?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false, @@ -184,9 +224,42 @@ scenario( readOnlyHint: false, destructiveHint: true, }); + const inventory = yield* passthrough.call("integrations", { + integration: slug, + owner: "org", + }); + expect(inventory.ok).toBe(true); + const decodedInventory = decodeInventory(inventory.raw).structuredContent; + expect(decodedInventory).toEqual({ + items: [{ integration: slug, owner: "org", connection: "main" }], + total: 1, + }); + expect(inventory.text).not.toContain(`tok_${slug}`); + const skills = yield* passthrough.call("skills", {}); + expect(skills.ok).toBe(true); + expect(skills.text).toContain("search-invoke"); + const guide = yield* passthrough.call("skills", { name: "search-invoke" }); + expect(guide.ok).toBe(true); + expect(guide.text).toContain("integrations({})"); + expect((yield* passthrough.call("skills", { name: "execute" })).ok).toBe(false); const found = decodeToolSearch( - (yield* passthrough.call("search", { query: slug })).raw, + (yield* passthrough.call("search", { + query: "notes", + integration: slug, + owner: "org", + connection: "main", + })).raw, + ).structuredContent; + expect(found.items.every((tool) => tool.integration === slug)).toBe(true); + const missingAccount = decodeToolSearch( + (yield* passthrough.call("search", { + query: "notes", + integration: slug, + owner: "user", + connection: "main", + })).raw, ).structuredContent; + expect(missingAccount.items).toEqual([]); const listDef = found.items.find((tool) => tool.id.endsWith(".listNotes")); const createDef = found.items.find((tool) => tool.id.endsWith(".createNote")); if (!listDef || !createDef) return yield* Effect.die("Search omitted notes operations"); diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index e2d57acb54..3891f6cae3 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -72,6 +72,7 @@ export const makeMcpBuildServer = artifacts: executor.artifacts, connections: executor.connections, tools: executor.tools, + integrations: executor.integrations, ...(hostOptions?.loadAppShellHtml ? { loadAppShellHtml: hostOptions.loadAppShellHtml } : {}), diff --git a/packages/hosts/mcp/src/passthrough-tools.test.ts b/packages/hosts/mcp/src/passthrough-tools.test.ts index fe6764be79..9d09425b72 100644 --- a/packages/hosts/mcp/src/passthrough-tools.test.ts +++ b/packages/hosts/mcp/src/passthrough-tools.test.ts @@ -61,10 +61,15 @@ const toolPort = ( schemaReads: string[] = [], lists: string[] = [], ): McpToolsPort => ({ - list: () => + list: (filter) => Effect.sync(() => { lists.push("list"); - return catalog; + return catalog.filter( + (tool) => + (filter?.integration === undefined || tool.integration === filter.integration) && + (filter?.owner === undefined || tool.owner === filter.owner) && + (filter?.connection === undefined || tool.connection === filter.connection), + ); }), schema: (address) => Effect.sync(() => { @@ -113,7 +118,13 @@ const withClient = async ( config: ExecutorMcpServerConfig, fn: (client: Client) => Promise, ) => { - const mcpServer = await Effect.runPromise(createExecutorMcpServer(config)); + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ + connections: { list: () => Effect.succeed([]) }, + integrations: { list: () => Effect.succeed([]) }, + ...config, + }), + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); await mcpServer.connect(serverTransport); @@ -203,7 +214,221 @@ describe("readToolMode", () => { // --------------------------------------------------------------------------- describe("passthrough mode server", () => { - it("serves exactly search and invoke even for 10000 tools", async () => { + it("lists live account metadata with paging, exact filters, and no schemas or secrets", async () => { + const { engine, executed } = makeRecordingEngine(); + const schemaReads: string[] = []; + const lists: string[] = []; + const reads: string[] = []; + const account = (integration: string, owner: "org" | "user", name: string) => ({ + integration, + owner, + name, + identityLabel: "Example account", + description: "Use for test issues", + lastHealth: { + status: "healthy" as const, + checkedAt: 123, + detail: "private probe details", + responseSample: [{ path: "token", value: "secret" }], + }, + oauthScope: "private grants", + provider: "private credential provider", + }); + let accounts = [ + account("github", "user", "main"), + account("github", "org", "main"), + account("github_other", "org", "main"), + account("unavailable", "org", "hidden"), + ]; + await withClient( + { + engine, + mode: "passthrough", + tools: toolPort(CATALOG, schemaReads, lists), + connections: { + list: () => + Effect.sync(() => { + reads.push("connections"); + return accounts; + }), + }, + integrations: { + list: () => + Effect.sync(() => { + reads.push("integrations"); + return [ + { + slug: IntegrationSlug.make("github"), + name: "GitHub", + description: "Issues and repositories", + }, + { + slug: IntegrationSlug.make("github_other"), + name: "Other GitHub", + description: "Another integration", + }, + { + slug: IntegrationSlug.make("not_connected"), + name: "Not connected", + description: "No account", + }, + ]; + }), + }, + }, + async (client) => { + await client.listTools(); + expect(reads).toEqual([]); + const first = await client.callTool({ + name: "integrations", + arguments: { integration: "github", limit: 1 }, + }); + expect(first.structuredContent).toEqual({ + items: [ + { + integration: "github", + integrationName: "GitHub", + integrationDescription: "Issues and repositories", + owner: "org", + connection: "main", + identityLabel: "Example account", + description: "Use for test issues", + lastHealth: { status: "healthy", checkedAt: 123 }, + }, + ], + total: 2, + hasMore: true, + nextOffset: 1, + }); + const next = await client.callTool({ + name: "integrations", + arguments: { integration: "github", limit: 1, offset: 1 }, + }); + expect(next.structuredContent).toMatchObject({ + items: [{ owner: "user" }], + total: 2, + hasMore: false, + nextOffset: null, + }); + const filtered = await client.callTool({ + name: "integrations", + arguments: { owner: "org", integration: "github" }, + }); + expect(filtered.structuredContent).toMatchObject({ + items: [{ owner: "org", integration: "github" }], + total: 1, + }); + const all = await client.callTool({ name: "integrations", arguments: {} }); + expect(all.structuredContent).toMatchObject({ total: 3 }); + expect(JSON.stringify(all)).not.toContain("secret"); + expect(JSON.stringify(all)).not.toContain("private"); + accounts = []; + const empty = await client.callTool({ name: "integrations", arguments: {} }); + expect(empty.structuredContent).toEqual({ + items: [], + total: 0, + hasMore: false, + nextOffset: null, + }); + expect(lists).toEqual([]); + expect(schemaReads).toEqual([]); + expect(executed).toEqual([]); + }, + ); + }); + + it("serves only the search/invoke guide as text", async () => { + const { engine } = makeRecordingEngine(); + await withClient({ engine, mode: "passthrough", tools: toolPort(CATALOG) }, async (client) => { + const index = await client.callTool({ name: "skills", arguments: {} }); + expect(JSON.stringify(index.content)).toContain("search-invoke"); + expect(JSON.stringify(index.content)).not.toContain("create-artifact"); + const guide = await client.callTool({ name: "skills", arguments: { name: "search-invoke" } }); + expect(guide.structuredContent).toBeUndefined(); + expect(JSON.stringify(guide.content)).toContain("integrations({})"); + expect(JSON.stringify(guide.content)).toContain("nextOffset"); + expect(JSON.stringify(guide.content)).not.toContain("tools.describe"); + for (const name of ["execute", "create-artifact", "artifact-style", "/tmp/SKILL.md"]) { + expect((await client.callTool({ name: "skills", arguments: { name } })).isError).toBe(true); + } + }); + }); + + it("filters exact accounts before ranking, pagination, and schema reads", async () => { + const { engine } = makeRecordingEngine(); + const schemaReads: string[] = []; + const catalog = [ + projection({ integration: "github", owner: "org", connection: "main", name: "issues.first" }), + projection({ + integration: "github", + owner: "org", + connection: "main", + name: "issues.second", + }), + projection({ + integration: "github", + owner: "user", + connection: "main", + name: "issues.first", + }), + projection({ + integration: "github", + owner: "org", + connection: "main2", + name: "issues.first", + }), + projection({ + integration: "github_other", + owner: "org", + 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) }, + async (client) => { + const args = { + query: "issues", + integration: "github", + owner: "org", + connection: "main", + limit: 1, + }; + const first = await client.callTool({ name: "search", arguments: args }); + expect(first.structuredContent).toMatchObject({ total: 2, hasMore: true, nextOffset: 1 }); + const next = await client.callTool({ name: "search", arguments: { ...args, offset: 1 } }); + expect(next.structuredContent).toMatchObject({ + total: 2, + hasMore: false, + nextOffset: null, + }); + expect(schemaReads.sort()).toEqual([ + "tools.github.org.main.issues.first", + "tools.github.org.main.issues.second", + ]); + const missing = await client.callTool({ + name: "search", + arguments: { ...args, connection: "absent" }, + }); + expect(missing.structuredContent).toEqual({ + items: [], + total: 0, + hasMore: false, + nextOffset: null, + }); + expect(schemaReads).toHaveLength(2); + }, + ); + }); + + it("serves four discovery and call tools even for 10000 tools", async () => { const { engine, executed } = makeRecordingEngine(); const schemaReads: string[] = []; const lists: string[] = []; @@ -223,7 +448,12 @@ describe("passthrough mode server", () => { }, async (client) => { const listed = await client.listTools(); - expect(listed.tools.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]); + expect(listed.tools.map((tool) => tool.name).sort()).toEqual([ + "integrations", + "invoke", + "search", + "skills", + ]); expect(JSON.stringify(listed).length).toBeLessThan(4000); expect(lists).toEqual([]); expect(schemaReads).toEqual([]); @@ -563,7 +793,7 @@ describe("passthrough mode server", () => { }, async (client) => { const names = (await client.listTools()).tools.map((tool) => tool.name); - expect(names.sort()).toEqual(["invoke", "search"]); + expect(names.sort()).toEqual(["integrations", "invoke", "search", "skills"]); }, ); }); diff --git a/packages/hosts/mcp/src/passthrough-tools.ts b/packages/hosts/mcp/src/passthrough-tools.ts index c79d59861f..44e3f3705d 100644 --- a/packages/hosts/mcp/src/passthrough-tools.ts +++ b/packages/hosts/mcp/src/passthrough-tools.ts @@ -1,3 +1,5 @@ +import type { Skill } from "@executor-js/execution"; + /** * The sandbox code a passthrough call runs. Built HERE from the session's * resolved address and a JSON-encoded argument — never concatenated from raw @@ -23,7 +25,30 @@ export const passthroughCallCode = (address: string, args: unknown): string => { /** Describe the fixed search/invoke surface without listing the underlying catalog. */ export const passthroughInstructions = (): string => + 'Use integrations to see connected accounts and skills({ name: "search-invoke" }) for the workflow. ' + "Find connected integration tools with search, then call invoke with the returned tool ID and JSON arguments. " + "Search returns input schemas and account details. Use its nextOffset to get more matches. " + "Invoke can change external state; your client handles approval for each call. Workspace block policies remain enforced. " + "No JavaScript, execute, resume, or artifact tools are exposed in this mode."; + +/** On-demand guidance for the JSON tool surface; no sandbox or artifact instructions. */ +export const SEARCH_INVOKE_SKILL: Skill = { + name: "search-invoke", + summary: "Discover connected accounts, search for actions, and invoke tools with JSON arguments.", + body: [ + "# Search and invoke", + "", + "1. Call `integrations({})` to see connected integrations and accounts. Each item includes an integration description, account label, and last recorded health. A null health verdict means the account has not been checked; a saved connection does not guarantee a working credential.", + '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.", + "", + "## 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.", + "", + "## Results and approval", + "Invoke forwards the tool's result, including supported MCP content. Check `isError` and any returned error before treating a call as successful. Your client handles approval for invoke; workspace block policies still apply. An upstream request for user input needs a client that supports native elicitation.", + "If a tool is no longer available, search again. If an account needs authentication, ask the user to reconnect it in Executor. Never ask for credentials in chat.", + "This mode accepts JSON tool arguments. It does not expose execute, resume, or artifact tools. The skills tool serves only this server's guides, not files or skills from your harness or project.", + ].join("\n"), +}; diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 228881f185..0255ff582e 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -24,6 +24,8 @@ import * as z from "zod/v4"; import { CurrentOrgWriteAccess, ToolAddress, + IntegrationSlug, + ConnectionName, parseToolAddress, isToolFile, isToolResult, @@ -33,6 +35,8 @@ import { } from "@executor-js/sdk"; import type { Artifact, + Connection, + Integration, ArtifactBinding, ArtifactSummary, ElicitationResponse, @@ -82,7 +86,11 @@ import { type BindableConnection, } from "./artifact-bindings"; import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams"; -import { passthroughCallCode, passthroughInstructions } from "./passthrough-tools"; +import { + passthroughCallCode, + passthroughInstructions, + SEARCH_INVOKE_SKILL, +} from "./passthrough-tools"; import type { McpToolMode } from "./browser-approval"; // --------------------------------------------------------------------------- @@ -234,14 +242,16 @@ type SharedMcpServerConfig = { */ readonly artifacts?: McpArtifactsPort; /** - * The caller's saved connections, for binding an artifact's integration roles - * at create time. Structurally satisfied by `executor.connections`; hosts pass + * The caller's saved connections, for the search/invoke account inventory + * and binding artifact integration roles at create time. Structurally satisfied by `executor.connections`; hosts pass * the same scoped executor they pass `artifacts`. * * Absent means `create-artifact` cannot bind, so it refuses code that calls an * integration rather than saving an artifact that could never run. */ readonly connections?: McpConnectionsPort; + /** Scoped integration metadata for the search/invoke account inventory. */ + readonly integrations?: McpIntegrationsPort; /** * Builds the web-app deep link for a saved artifact. Clients that can't * render MCP Apps get this URL instead of an inline widget. Absent (stdio has @@ -291,12 +301,24 @@ export type McpArtifactsPort = { }; /** - * The connection surface binding needs: list what this caller can reach. The + * The connection surface binding and discovery need: list what this caller can reach. The * scoped executor has already narrowed it, so an inferred binding can never * name a connection the caller couldn't call themselves. */ export type McpConnectionsPort = { - readonly list: () => Effect.Effect; + readonly list: () => Effect.Effect< + readonly (BindableConnection & + Pick)[], + unknown + >; +}; + +/** Catalog metadata visible to this caller; no tool schemas or credentials. */ +export type McpIntegrationsPort = { + readonly list: () => Effect.Effect< + readonly Pick[], + unknown + >; }; /** The same list and schema APIs used by codemode discovery. */ @@ -1249,6 +1271,8 @@ const passthroughInputSchema = (view: ToolSchemaView): unknown => const registerPassthroughTools = ( server: McpServer, tools: McpToolsPort, + connections: McpConnectionsPort, + integrations: McpIntegrationsPort, run: ( address: ToolAddress, args: unknown, @@ -1258,14 +1282,6 @@ const registerPassthroughTools = ( Effect.gen(function* () { const context = yield* Effect.context(); const validator = new CfWorkerJsonSchemaValidator(); - const discovery = { - tools: { - list: (filter?: Parameters[0]) => - tools - .list(filter) - .pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))), - }, - }; const boundary = ( effect: Effect.Effect, extra: McpRequestJoinKeys, @@ -1280,11 +1296,82 @@ const registerPassthroughTools = ( ), ); yield* Effect.sync(() => { + server.registerTool( + "integrations", + { + description: + "List connected integrations and accounts visible to you. Returns integration descriptions, account labels, exact search filters, and last recorded health (null means unchecked). One item per account; use nextOffset for more. Does not load tool schemas or check credentials.", + inputSchema: { + integration: z.string().trim().min(1).optional().describe("Exact integration slug."), + owner: z.enum(["org", "user"]).optional(), + limit: z.number().int().min(1).max(50).default(20), + offset: z.number().int().min(0).default(0), + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + ({ integration, owner, limit, offset }, extra) => + boundary( + Effect.gen(function* () { + const [accounts, catalog] = yield* Effect.all([ + connections.list(), + integrations.list(), + ]); + const metadata = new Map(catalog.map((item) => [String(item.slug), item])); + const visible = accounts + .flatMap((account) => { + const item = metadata.get(account.integration); + if ( + !item || + (integration !== undefined && account.integration !== integration) || + (owner !== undefined && account.owner !== owner) + ) + return []; + return [ + { + integration: account.integration, + integrationName: item.name, + integrationDescription: item.description, + owner: account.owner, + connection: account.name, + identityLabel: account.identityLabel ?? null, + description: account.description ?? null, + lastHealth: + account.lastHealth == null + ? null + : { + status: account.lastHealth.status, + checkedAt: account.lastHealth.checkedAt, + }, + }, + ]; + }) + .sort( + (a, b) => + a.integration.localeCompare(b.integration) || + a.owner.localeCompare(b.owner) || + a.connection.localeCompare(b.connection), + ); + const items = visible.slice(offset, offset + limit); + const hasMore = offset + items.length < visible.length; + const result = { + items, + total: visible.length, + hasMore, + nextOffset: hasMore ? offset + items.length : null, + }; + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + }), + extra, + ), + ); server.registerTool( "search", { description: - "Search connected integration tools by action, integration, or account. Returns matching tool IDs, account details, and full JSON input schemas. Pass the returned ID and arguments to invoke. Use nextOffset to page through matches.", + "Search connected integration tools by action, integration, or account. Returns matching tool IDs, account details, and full JSON input schemas. Pass the returned ID and arguments to invoke. Use integrations to discover accounts, then pass exact integration, owner, and connection filters. Use nextOffset to page through matches.", inputSchema: { query: z .string() @@ -1292,14 +1379,46 @@ const registerPassthroughTools = ( .min(1) .max(500) .describe("Keywords describing the tool or task, such as github create issue."), + integration: z + .string() + .trim() + .min(1) + .optional() + .describe("Exact integration slug from integrations."), + owner: z.enum(["org", "user"]).optional(), + connection: z + .string() + .trim() + .min(1) + .optional() + .describe( + "Exact account name from integrations; pair with integration and owner to select one account.", + ), limit: z.number().int().min(1).max(20).default(10), offset: z.number().int().min(0).default(0), }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, }, - ({ query, limit, offset }, extra) => + ({ query, integration, owner, connection, limit, offset }, extra) => boundary( Effect.gen(function* () { + const discovery = { + tools: { + list: (filter?: Parameters[0]) => + tools + .list({ + ...filter, + ...(integration === undefined + ? {} + : { integration: IntegrationSlug.make(integration) }), + ...(owner === undefined ? {} : { owner }), + ...(connection === undefined + ? {} + : { connection: ConnectionName.make(connection) }), + }) + .pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))), + }, + }; const page = yield* searchTools(discovery, query, limit, { offset }); const candidates = yield* Effect.forEach( page.items, @@ -1362,14 +1481,15 @@ const registerPassthroughTools = ( if (!identity) return unavailable; const address = ToolAddress.make(id); // Use the existing visibility filter and exclude static configuration tools. - const visible = yield* discovery.tools.list({ + 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.address === address)) return unavailable; + if (!visible.some((tool) => tool.static !== true && 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. @@ -1416,7 +1536,10 @@ export const createExecutorMcpServer = ( // Search/invoke serves no artifact tools: artifacts run sandboxed code. const artifactsEnabled = config.mode === "passthrough" ? false : (config.artifactsEnabled ?? true); - const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled }); + const skillCatalog: readonly Skill[] = + config.mode === "passthrough" + ? [SEARCH_INVOKE_SKILL] + : skillCatalogFor({ artifacts: artifactsEnabled }); // Per-integration search tools are off unless this connection opted in // (`?search_tools=true`). const searchToolsEnabled = config.searchToolsEnabled ?? false; @@ -1426,9 +1549,10 @@ export const createExecutorMcpServer = ( // each other. const mode: McpToolMode = config.mode ?? "codemode"; const passthrough = mode === "passthrough"; - if (passthrough && !config.tools) { + if (passthrough && (!config.tools || !config.connections || !config.integrations)) { return yield* new McpPassthroughUnavailableError({ - reason: "passthrough mode requires tool list and schema APIs", + reason: + "passthrough mode requires tool list/schema, connection list, and integration list APIs", }); } @@ -1914,8 +2038,14 @@ export const createExecutorMcpServer = ( // --- tools --- // Passthrough serves search and invoke in place of the codemode tools. - if (passthrough && config.tools) { - yield* registerPassthroughTools(server, config.tools, executePassthroughCall); + if (passthrough && config.tools && config.connections && config.integrations) { + yield* registerPassthroughTools( + server, + config.tools, + config.connections, + config.integrations, + executePassthroughCall, + ); } if (!passthrough) @@ -1934,37 +2064,36 @@ export const createExecutorMcpServer = ( }), ); - if (!passthrough) - yield* Effect.sync(() => - server.registerTool( - "skills", - { - description: [ - "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", - "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", - 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', - "Call with no name to list the few docs available.", - ].join("\n"), - inputSchema: { - name: z - .string() - .optional() - .describe( - 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.', - ), - }, + yield* Effect.sync(() => + server.registerTool( + "skills", + { + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + description: passthrough + ? 'Documentation for this server only, not harness or project skills. Call with no name to list guides, or skills({ name: "search-invoke" }) for account discovery, tool search, invocation, and pagination.' + : [ + "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", + "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", + 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', + "Call with no name to list the few docs available.", + ].join("\n"), + inputSchema: { + name: z + .string() + .optional() + .describe( + `A doc from this server's own catalog, e.g. "${passthrough ? "search-invoke" : "execute"}". Omit to list the catalog.`, + ), }, - ({ name }, extra) => - runToolEffect( - Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), - extra, - ), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "skills" }, - }), - ); + }, + ({ name }, extra) => + runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "skills" }, + }), + ); if (!passthrough) yield* Effect.sync(() => { diff --git a/packages/react/src/components/mcp-install-card.tsx b/packages/react/src/components/mcp-install-card.tsx index e77a3bef73..e61e713d9e 100644 --- a/packages/react/src/components/mcp-install-card.tsx +++ b/packages/react/src/components/mcp-install-card.tsx @@ -313,7 +313,7 @@ export function McpInstallCard(props: { className?: string }) {
Search and invoke
{toolMode === "passthrough" - ? "Find connected tools with search, then call them with invoke. Your client handles approval for each call." + ? "Discover connected accounts with integrations and read the guide with skills. Find tools with search, then call them with invoke. Your client handles approval." : "Disabled: agents write code against your tools through one execute tool."}