History

Add account discovery and guides to search and invoke

Rhys Sullivan committed 1 month ago

Commit 7f6bed61 parent 387d0b312 files · +562 −65Browse files

Unified · Split

12 files
  1. apps/cloud/src/mcp/session-durable-object.ts +1 −0
  2. apps/docs/mcp-proxy.mdx +29 −0
  3. apps/host-cloudflare/src/mcp/session-durable-object.ts +1 −0
  4. apps/local/src/main.ts +3 −0
  5. e2e/cloud/passthrough-opencode-codemode.test.ts +1 −1
  6. e2e/cloud/passthrough-scale.test.ts +6 −1
  7. e2e/scenarios/mcp-passthrough.test.ts +77 −4
  8. packages/core/api/src/server/mcp-build.ts +1 −0
  9. packages/hosts/mcp/src/passthrough-tools.test.ts +236 −6
  10. packages/hosts/mcp/src/passthrough-tools.ts +25 −0
  11. packages/hosts/mcp/src/tool-server.ts +181 −52
  12. packages/react/src/components/mcp-install-card.tsx +1 −1
apps/cloud/src/mcp/session-durable-object.ts+1−0
@@ -385,6 +385,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 384 unmodified lines
385385 artifacts: executor.artifacts,
386386 connections: executor.connections,
387387 tools: executor.tools,
388+ integrations: executor.integrations,
388389 // Artifacts are on by default, opt-out per connection. A session
389390 // persisted without a value restores to the default, same as a fresh
390391 // connection whose URL says nothing about `?artifacts=`.
apps/docs/mcp-proxy.mdx+29−0
@@ -56,3 +56,32 @@ Executor: · 55 unmodified lines
5656
5757 Once a client is connected, every integration you add to Executor appears in that
5858 agent automatically.
59+
60+## Search and invoke mode
61+
62+Add `?mode=passthrough` to your MCP endpoint, or enable **Search and invoke**
63+in the Connect card. This mode exposes four tools:
64+
65+- `integrations`: list connected accounts with integration descriptions, account
66+ labels, and their last recorded health. Results are paginated, with one item per
67+ account. Health is not checked again by this call.
68+- `skills`: list this server's guides, or read `skills({ name: "search-invoke" })`.
69+- `search`: find tools and read their JSON input schemas. Use the exact
70+ `integration`, `owner`, and `connection` fields from `integrations` to select an
71+ account.
72+- `invoke`: call the exact tool ID from search with JSON arguments.
73+
74+For example, call `integrations({})`, then
75+`search({ query: "create issue", integration: "github", owner: "org", connection: "main" })`
76+using the account names returned by your server. Call `invoke` with the matching
77+result's `id` and arguments that follow its `inputSchema`.
78+
79+Both `integrations` and `search` return `items`, `total`, `hasMore`, and
80+`nextOffset`. To get another page, repeat the same query and filters with
81+`offset: nextOffset`. This pagination finds tools and accounts; each upstream API
82+has its own pagination for records.
83+
84+The tool list stays small as you add integrations. Input schemas are loaded only
85+for matching search results. Your client handles approval for `invoke`, and
86+workspace block policies still apply. This mode does not expose code execution or
87+artifact tools.
apps/host-cloudflare/src/mcp/session-durable-object.ts+1−0
@@ -167,6 +167,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 166 unmodified lines
167167 artifacts: executor.artifacts,
168168 connections: executor.connections,
169169 tools: executor.tools,
170+ integrations: executor.integrations,
170171 // Artifacts are on by default, opt-out per connection. A session
171172 // persisted without a value restores to the default, same as a fresh
172173 // connection whose URL says nothing about `?artifacts=`.
apps/local/src/main.ts+3−0
@@ -124,6 +124,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 123 unmodified lines
124124 artifacts: executor.artifacts,
125125 connections: executor.connections,
126126 tools: executor.tools,
127+ integrations: executor.integrations,
127128 ...appsConfig,
128129 },
129130 webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL || undefined,
@@ -135,6 +136,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 5 unmodified lines
135136 artifacts: executor.artifacts,
136137 connections: executor.connections,
137138 tools: executor.tools,
139+ integrations: executor.integrations,
138140 ...appsConfig,
139141 },
140142 };
@@ -161,6 +163,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 20 unmodified lines
161163 artifacts: handle.executor.artifacts,
162164 connections: handle.executor.connections,
163165 tools: handle.executor.tools,
166+ integrations: handle.executor.integrations,
164167 ...appsConfig,
165168 },
166169 close: handle.dispose,
e2e/cloud/passthrough-opencode-codemode.test.ts+1−1
@@ -1,4 +1,4 @@
1−// Real OpenCode codemode discovers and invokes tools through a two-tool MCP surface.
1+// Real OpenCode codemode discovers and invokes tools through a fixed MCP tool surface.
22 // The server retains 10,200 tools; the replay model scripts discovery and a real upstream call.
33 import { randomBytes } from "node:crypto";
44 import { createServer } from "node:http";
e2e/cloud/passthrough-scale.test.ts+6−1
@@ -45,7 +45,12 @@ scenario( · 44 unmodified lines
4545 `a ${visible.length}-tool passthrough connect stays bounded (took ${elapsedMs}ms)`,
4646 ).toBeLessThan(MAX_PASSTHROUGH_CONNECT_MS);
4747
48− expect(served.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]);
48+ expect(served.map((tool) => tool.name).sort()).toEqual([
49+ "integrations",
50+ "invoke",
51+ "search",
52+ "skills",
53+ ]);
4954 const first = decodeToolSearch(
5055 (yield* session.call("search", { query: "org", limit: 20 })).raw,
5156 ).structuredContent;
e2e/scenarios/mcp-passthrough.test.ts+77−4
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto"; · 2 unmodified lines
33 import { createServer, type IncomingMessage } from "node:http";
44
55 import { expect } from "@effect/vitest";
6−import { Effect } from "effect";
6+import { Effect, Schema } from "effect";
77 import { composePluginApi } from "@executor-js/api/server";
88 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
99 import {
@@ -16,7 +16,23 @@ import { · 6 unmodified lines
1616 import { decodeToolSearch } from "./support/search-invoke";
1717
1818 import { scenario } from "../src/scenario";
19−import { Api, Mcp, Target } from "../src/services";
19+import { Api, Browser, Mcp, Target } from "../src/services";
20+import { visit, settle } from "../src/surfaces/browser";
21+
22+const decodeInventory = Schema.decodeUnknownSync(
23+ Schema.Struct({
24+ structuredContent: Schema.Struct({
25+ items: Schema.Array(
26+ Schema.Struct({
27+ integration: Schema.String,
28+ owner: Schema.String,
29+ connection: Schema.String,
30+ }),
31+ ),
32+ total: Schema.Number,
33+ }),
34+ }),
35+);
2036
2137 const api = composePluginApi([openApiHttpPlugin()] as const);
2238
@@ -133,6 +149,7 @@ scenario( · 110 unmodified lines
133149 Effect.gen(function* () {
134150 const target = yield* Target;
135151 const mcp = yield* Mcp;
152+ const browser = yield* Browser;
136153 const { client: makeClient } = yield* Api;
137154
138155 const identity = yield* target.newIdentity();
@@ -170,12 +187,35 @@ scenario( · 31 unmodified lines
170187 });
171188 }
172189
190+ yield* browser.session(identity, async ({ page, step }) => {
191+ await step("Choose Search and invoke in the Connect card", async () => {
192+ await visit(page, "/");
193+ await page.getByRole("button", { name: "Advanced" }).click();
194+ await page.getByRole("switch", { name: "Search and invoke" }).check();
195+ await settle(page);
196+ expect(await page.locator("code").first().innerText()).toContain("mode=passthrough");
197+ expect(
198+ await page
199+ .getByText(
200+ "Discover connected accounts with integrations and read the guide with skills.",
201+ { exact: false },
202+ )
203+ .isVisible(),
204+ ).toBe(true);
205+ });
206+ });
207+
173208 const codemode = mcp.session(identity);
174209 expect(yield* codemode.listTools()).toContain("execute");
175210
176211 const passthrough = mcp.session(identity, { mode: "passthrough" });
177212 const described = yield* passthrough.describeTools();
178− expect(described.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]);
213+ expect(described.map((tool) => tool.name).sort()).toEqual([
214+ "integrations",
215+ "invoke",
216+ "search",
217+ "skills",
218+ ]);
179219 expect(described.find((tool) => tool.name === "search")?.annotations).toMatchObject({
180220 readOnlyHint: true,
181221 destructiveHint: false,
@@ -184,9 +224,42 @@ scenario( · 2 unmodified lines
184224 readOnlyHint: false,
185225 destructiveHint: true,
186226 });
227+ const inventory = yield* passthrough.call("integrations", {
228+ integration: slug,
229+ owner: "org",
230+ });
231+ expect(inventory.ok).toBe(true);
232+ const decodedInventory = decodeInventory(inventory.raw).structuredContent;
233+ expect(decodedInventory).toEqual({
234+ items: [{ integration: slug, owner: "org", connection: "main" }],
235+ total: 1,
236+ });
237+ expect(inventory.text).not.toContain(`tok_${slug}`);
238+ const skills = yield* passthrough.call("skills", {});
239+ expect(skills.ok).toBe(true);
240+ expect(skills.text).toContain("search-invoke");
241+ const guide = yield* passthrough.call("skills", { name: "search-invoke" });
242+ expect(guide.ok).toBe(true);
243+ expect(guide.text).toContain("integrations({})");
244+ expect((yield* passthrough.call("skills", { name: "execute" })).ok).toBe(false);
187245 const found = decodeToolSearch(
188− (yield* passthrough.call("search", { query: slug })).raw,
246+ (yield* passthrough.call("search", {
247+ query: "notes",
248+ integration: slug,
249+ owner: "org",
250+ connection: "main",
251+ })).raw,
252+ ).structuredContent;
253+ expect(found.items.every((tool) => tool.integration === slug)).toBe(true);
254+ const missingAccount = decodeToolSearch(
255+ (yield* passthrough.call("search", {
256+ query: "notes",
257+ integration: slug,
258+ owner: "user",
259+ connection: "main",
260+ })).raw,
189261 ).structuredContent;
262+ expect(missingAccount.items).toEqual([]);
190263 const listDef = found.items.find((tool) => tool.id.endsWith(".listNotes"));
191264 const createDef = found.items.find((tool) => tool.id.endsWith(".createNote"));
192265 if (!listDef || !createDef) return yield* Effect.die("Search omitted notes operations");
packages/core/api/src/server/mcp-build.ts+1−0
@@ -72,6 +72,7 @@ export const makeMcpBuildServer = · 71 unmodified lines
7272 artifacts: executor.artifacts,
7373 connections: executor.connections,
7474 tools: executor.tools,
75+ integrations: executor.integrations,
7576 ...(hostOptions?.loadAppShellHtml
7677 ? { loadAppShellHtml: hostOptions.loadAppShellHtml }
7778 : {}),
packages/hosts/mcp/src/passthrough-tools.test.ts+236−6
@@ -61,10 +61,15 @@ const toolPort = ( · 60 unmodified lines
6161 schemaReads: string[] = [],
6262 lists: string[] = [],
6363 ): McpToolsPort => ({
64− list: () =>
64+ list: (filter) =>
6565 Effect.sync(() => {
6666 lists.push("list");
67− return catalog;
67+ return catalog.filter(
68+ (tool) =>
69+ (filter?.integration === undefined || tool.integration === filter.integration) &&
70+ (filter?.owner === undefined || tool.owner === filter.owner) &&
71+ (filter?.connection === undefined || tool.connection === filter.connection),
72+ );
6873 }),
6974 schema: (address) =>
7075 Effect.sync(() => {
@@ -113,7 +118,13 @@ const withClient = async <E extends Cause.YieldableError>( · 42 unmodified lines
113118 config: ExecutorMcpServerConfig<E>,
114119 fn: (client: Client) => Promise<void>,
115120 ) => {
116− const mcpServer = await Effect.runPromise(createExecutorMcpServer(config));
121+ const mcpServer = await Effect.runPromise(
122+ createExecutorMcpServer({
123+ connections: { list: () => Effect.succeed([]) },
124+ integrations: { list: () => Effect.succeed([]) },
125+ ...config,
126+ }),
127+ );
117128 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
118129 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
119130 await mcpServer.connect(serverTransport);
@@ -203,7 +214,221 @@ describe("readToolMode", () => { · 83 unmodified lines
203214 // ---------------------------------------------------------------------------
204215
205216 describe("passthrough mode server", () => {
206− it("serves exactly search and invoke even for 10000 tools", async () => {
217+ it("lists live account metadata with paging, exact filters, and no schemas or secrets", async () => {
218+ const { engine, executed } = makeRecordingEngine();
219+ const schemaReads: string[] = [];
220+ const lists: string[] = [];
221+ const reads: string[] = [];
222+ const account = (integration: string, owner: "org" | "user", name: string) => ({
223+ integration,
224+ owner,
225+ name,
226+ identityLabel: "Example account",
227+ description: "Use for test issues",
228+ lastHealth: {
229+ status: "healthy" as const,
230+ checkedAt: 123,
231+ detail: "private probe details",
232+ responseSample: [{ path: "token", value: "secret" }],
233+ },
234+ oauthScope: "private grants",
235+ provider: "private credential provider",
236+ });
237+ let accounts = [
238+ account("github", "user", "main"),
239+ account("github", "org", "main"),
240+ account("github_other", "org", "main"),
241+ account("unavailable", "org", "hidden"),
242+ ];
243+ await withClient(
244+ {
245+ engine,
246+ mode: "passthrough",
247+ tools: toolPort(CATALOG, schemaReads, lists),
248+ connections: {
249+ list: () =>
250+ Effect.sync(() => {
251+ reads.push("connections");
252+ return accounts;
253+ }),
254+ },
255+ integrations: {
256+ list: () =>
257+ Effect.sync(() => {
258+ reads.push("integrations");
259+ return [
260+ {
261+ slug: IntegrationSlug.make("github"),
262+ name: "GitHub",
263+ description: "Issues and repositories",
264+ },
265+ {
266+ slug: IntegrationSlug.make("github_other"),
267+ name: "Other GitHub",
268+ description: "Another integration",
269+ },
270+ {
271+ slug: IntegrationSlug.make("not_connected"),
272+ name: "Not connected",
273+ description: "No account",
274+ },
275+ ];
276+ }),
277+ },
278+ },
279+ async (client) => {
280+ await client.listTools();
281+ expect(reads).toEqual([]);
282+ const first = await client.callTool({
283+ name: "integrations",
284+ arguments: { integration: "github", limit: 1 },
285+ });
286+ expect(first.structuredContent).toEqual({
287+ items: [
288+ {
289+ integration: "github",
290+ integrationName: "GitHub",
291+ integrationDescription: "Issues and repositories",
292+ owner: "org",
293+ connection: "main",
294+ identityLabel: "Example account",
295+ description: "Use for test issues",
296+ lastHealth: { status: "healthy", checkedAt: 123 },
297+ },
298+ ],
299+ total: 2,
300+ hasMore: true,
301+ nextOffset: 1,
302+ });
303+ const next = await client.callTool({
304+ name: "integrations",
305+ arguments: { integration: "github", limit: 1, offset: 1 },
306+ });
307+ expect(next.structuredContent).toMatchObject({
308+ items: [{ owner: "user" }],
309+ total: 2,
310+ hasMore: false,
311+ nextOffset: null,
312+ });
313+ const filtered = await client.callTool({
314+ name: "integrations",
315+ arguments: { owner: "org", integration: "github" },
316+ });
317+ expect(filtered.structuredContent).toMatchObject({
318+ items: [{ owner: "org", integration: "github" }],
319+ total: 1,
320+ });
321+ const all = await client.callTool({ name: "integrations", arguments: {} });
322+ expect(all.structuredContent).toMatchObject({ total: 3 });
323+ expect(JSON.stringify(all)).not.toContain("secret");
324+ expect(JSON.stringify(all)).not.toContain("private");
325+ accounts = [];
326+ const empty = await client.callTool({ name: "integrations", arguments: {} });
327+ expect(empty.structuredContent).toEqual({
328+ items: [],
329+ total: 0,
330+ hasMore: false,
331+ nextOffset: null,
332+ });
333+ expect(lists).toEqual([]);
334+ expect(schemaReads).toEqual([]);
335+ expect(executed).toEqual([]);
336+ },
337+ );
338+ });
339+
340+ it("serves only the search/invoke guide as text", async () => {
341+ const { engine } = makeRecordingEngine();
342+ await withClient({ engine, mode: "passthrough", tools: toolPort(CATALOG) }, async (client) => {
343+ const index = await client.callTool({ name: "skills", arguments: {} });
344+ expect(JSON.stringify(index.content)).toContain("search-invoke");
345+ expect(JSON.stringify(index.content)).not.toContain("create-artifact");
346+ const guide = await client.callTool({ name: "skills", arguments: { name: "search-invoke" } });
347+ expect(guide.structuredContent).toBeUndefined();
348+ expect(JSON.stringify(guide.content)).toContain("integrations({})");
349+ expect(JSON.stringify(guide.content)).toContain("nextOffset");
350+ expect(JSON.stringify(guide.content)).not.toContain("tools.describe");
351+ for (const name of ["execute", "create-artifact", "artifact-style", "/tmp/SKILL.md"]) {
352+ expect((await client.callTool({ name: "skills", arguments: { name } })).isError).toBe(true);
353+ }
354+ });
355+ });
356+
357+ it("filters exact accounts before ranking, pagination, and schema reads", async () => {
358+ const { engine } = makeRecordingEngine();
359+ const schemaReads: string[] = [];
360+ const catalog = [
361+ projection({ integration: "github", owner: "org", connection: "main", name: "issues.first" }),
362+ projection({
363+ integration: "github",
364+ owner: "org",
365+ connection: "main",
366+ name: "issues.second",
367+ }),
368+ projection({
369+ integration: "github",
370+ owner: "user",
371+ connection: "main",
372+ name: "issues.first",
373+ }),
374+ projection({
375+ integration: "github",
376+ owner: "org",
377+ connection: "main2",
378+ name: "issues.first",
379+ }),
380+ projection({
381+ integration: "github_other",
382+ owner: "org",
383+ connection: "main",
384+ name: "issues.first",
385+ }),
386+ projection({
387+ integration: "github",
388+ owner: "org",
389+ connection: "main",
390+ name: "issues.static",
391+ static: true,
392+ }),
393+ ];
394+ await withClient(
395+ { engine, mode: "passthrough", tools: toolPort(catalog, schemaReads) },
396+ async (client) => {
397+ const args = {
398+ query: "issues",
399+ integration: "github",
400+ owner: "org",
401+ connection: "main",
402+ limit: 1,
403+ };
404+ const first = await client.callTool({ name: "search", arguments: args });
405+ expect(first.structuredContent).toMatchObject({ total: 2, hasMore: true, nextOffset: 1 });
406+ const next = await client.callTool({ name: "search", arguments: { ...args, offset: 1 } });
407+ expect(next.structuredContent).toMatchObject({
408+ total: 2,
409+ hasMore: false,
410+ nextOffset: null,
411+ });
412+ expect(schemaReads.sort()).toEqual([
413+ "tools.github.org.main.issues.first",
414+ "tools.github.org.main.issues.second",
415+ ]);
416+ const missing = await client.callTool({
417+ name: "search",
418+ arguments: { ...args, connection: "absent" },
419+ });
420+ expect(missing.structuredContent).toEqual({
421+ items: [],
422+ total: 0,
423+ hasMore: false,
424+ nextOffset: null,
425+ });
426+ expect(schemaReads).toHaveLength(2);
427+ },
428+ );
429+ });
430+
431+ it("serves four discovery and call tools even for 10000 tools", async () => {
207432 const { engine, executed } = makeRecordingEngine();
208433 const schemaReads: string[] = [];
209434 const lists: string[] = [];
@@ -223,7 +448,12 @@ describe("passthrough mode server", () => { · 13 unmodified lines
223448 },
224449 async (client) => {
225450 const listed = await client.listTools();
226− expect(listed.tools.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]);
451+ expect(listed.tools.map((tool) => tool.name).sort()).toEqual([
452+ "integrations",
453+ "invoke",
454+ "search",
455+ "skills",
456+ ]);
227457 expect(JSON.stringify(listed).length).toBeLessThan(4000);
228458 expect(lists).toEqual([]);
229459 expect(schemaReads).toEqual([]);
@@ -563,7 +793,7 @@ describe("passthrough mode server", () => { · 333 unmodified lines
563793 },
564794 async (client) => {
565795 const names = (await client.listTools()).tools.map((tool) => tool.name);
566− expect(names.sort()).toEqual(["invoke", "search"]);
796+ expect(names.sort()).toEqual(["integrations", "invoke", "search", "skills"]);
567797 },
568798 );
569799 });
packages/hosts/mcp/src/passthrough-tools.ts+25−0
@@ -1,3 +1,5 @@
1+import type { Skill } from "@executor-js/execution";
2+
13 /**
24 * The sandbox code a passthrough call runs. Built HERE from the session's
35 * resolved address and a JSON-encoded argument — never concatenated from raw
@@ -23,7 +25,30 @@ export const passthroughCallCode = (address: string, args: unknown): string => { · 19 unmodified lines
2325
2426 /** Describe the fixed search/invoke surface without listing the underlying catalog. */
2527 export const passthroughInstructions = (): string =>
28+ 'Use integrations to see connected accounts and skills({ name: "search-invoke" }) for the workflow. ' +
2629 "Find connected integration tools with search, then call invoke with the returned tool ID and JSON arguments. " +
2730 "Search returns input schemas and account details. Use its nextOffset to get more matches. " +
2831 "Invoke can change external state; your client handles approval for each call. Workspace block policies remain enforced. " +
2932 "No JavaScript, execute, resume, or artifact tools are exposed in this mode.";
33+
34+/** On-demand guidance for the JSON tool surface; no sandbox or artifact instructions. */
35+export const SEARCH_INVOKE_SKILL: Skill = {
36+ name: "search-invoke",
37+ summary: "Discover connected accounts, search for actions, and invoke tools with JSON arguments.",
38+ body: [
39+ "# Search and invoke",
40+ "",
41+ "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.",
42+ '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.',
43+ "3. Read the matching tool's `inputSchema`. Call `invoke({ tool: <exact returned id>, arguments: <JSON object matching inputSchema> })`. Do not guess tool IDs or arguments.",
44+ "",
45+ "## Pagination",
46+ "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.",
47+ "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.",
48+ "",
49+ "## Results and approval",
50+ "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.",
51+ "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.",
52+ "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.",
53+ ].join("\n"),
54+};
packages/hosts/mcp/src/tool-server.ts+181−52
@@ -24,6 +24,8 @@ import * as z from "zod/v4"; · 23 unmodified lines
2424 import {
2525 CurrentOrgWriteAccess,
2626 ToolAddress,
27+ IntegrationSlug,
28+ ConnectionName,
2729 parseToolAddress,
2830 isToolFile,
2931 isToolResult,
@@ -33,6 +35,8 @@ import { · 3 unmodified lines
3335 } from "@executor-js/sdk";
3436 import type {
3537 Artifact,
38+ Connection,
39+ Integration,
3640 ArtifactBinding,
3741 ArtifactSummary,
3842 ElicitationResponse,
@@ -82,7 +86,11 @@ import { · 43 unmodified lines
8286 type BindableConnection,
8387 } from "./artifact-bindings";
8488 import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams";
85−import { passthroughCallCode, passthroughInstructions } from "./passthrough-tools";
89+import {
90+ passthroughCallCode,
91+ passthroughInstructions,
92+ SEARCH_INVOKE_SKILL,
93+} from "./passthrough-tools";
8694 import type { McpToolMode } from "./browser-approval";
8795
8896 // ---------------------------------------------------------------------------
@@ -234,14 +242,16 @@ type SharedMcpServerConfig = { · 145 unmodified lines
234242 */
235243 readonly artifacts?: McpArtifactsPort;
236244 /**
237− * The caller's saved connections, for binding an artifact's integration roles
238− * at create time. Structurally satisfied by `executor.connections`; hosts pass
245+ * The caller's saved connections, for the search/invoke account inventory
246+ * and binding artifact integration roles at create time. Structurally satisfied by `executor.connections`; hosts pass
239247 * the same scoped executor they pass `artifacts`.
240248 *
241249 * Absent means `create-artifact` cannot bind, so it refuses code that calls an
242250 * integration rather than saving an artifact that could never run.
243251 */
244252 readonly connections?: McpConnectionsPort;
253+ /** Scoped integration metadata for the search/invoke account inventory. */
254+ readonly integrations?: McpIntegrationsPort;
245255 /**
246256 * Builds the web-app deep link for a saved artifact. Clients that can't
247257 * render MCP Apps get this URL instead of an inline widget. Absent (stdio has
@@ -291,12 +301,24 @@ export type McpArtifactsPort = { · 43 unmodified lines
291301 };
292302
293303 /**
294− * The connection surface binding needs: list what this caller can reach. The
304+ * The connection surface binding and discovery need: list what this caller can reach. The
295305 * scoped executor has already narrowed it, so an inferred binding can never
296306 * name a connection the caller couldn't call themselves.
297307 */
298308 export type McpConnectionsPort = {
299− readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
309+ readonly list: () => Effect.Effect<
310+ readonly (BindableConnection &
311+ Pick<Connection, "identityLabel" | "description" | "lastHealth">)[],
312+ unknown
313+ >;
314+};
315+
316+/** Catalog metadata visible to this caller; no tool schemas or credentials. */
317+export type McpIntegrationsPort = {
318+ readonly list: () => Effect.Effect<
319+ readonly Pick<Integration, "slug" | "name" | "description">[],
320+ unknown
321+ >;
300322 };
301323
302324 /** The same list and schema APIs used by codemode discovery. */
@@ -1249,6 +1271,8 @@ const passthroughInputSchema = (view: ToolSchemaView): unknown => · 946 unmodified lines
12491271 const registerPassthroughTools = <E extends Cause.YieldableError>(
12501272 server: McpServer,
12511273 tools: McpToolsPort,
1274+ connections: McpConnectionsPort,
1275+ integrations: McpIntegrationsPort,
12521276 run: (
12531277 address: ToolAddress,
12541278 args: unknown,
@@ -1258,14 +1282,6 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 3 unmodified lines
12581282 Effect.gen(function* () {
12591283 const context = yield* Effect.context<never>();
12601284 const validator = new CfWorkerJsonSchemaValidator();
1261− const discovery = {
1262− tools: {
1263− list: (filter?: Parameters<McpToolsPort["list"]>[0]) =>
1264− tools
1265− .list(filter)
1266− .pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))),
1267− },
1268− };
12691285 const boundary = <A extends McpToolResult, F>(
12701286 effect: Effect.Effect<A, F>,
12711287 extra: McpRequestJoinKeys,
@@ -1280,11 +1296,82 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 8 unmodified lines
12801296 ),
12811297 );
12821298 yield* Effect.sync(() => {
1299+ server.registerTool(
1300+ "integrations",
1301+ {
1302+ description:
1303+ "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.",
1304+ inputSchema: {
1305+ integration: z.string().trim().min(1).optional().describe("Exact integration slug."),
1306+ owner: z.enum(["org", "user"]).optional(),
1307+ limit: z.number().int().min(1).max(50).default(20),
1308+ offset: z.number().int().min(0).default(0),
1309+ },
1310+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1311+ },
1312+ ({ integration, owner, limit, offset }, extra) =>
1313+ boundary(
1314+ Effect.gen(function* () {
1315+ const [accounts, catalog] = yield* Effect.all([
1316+ connections.list(),
1317+ integrations.list(),
1318+ ]);
1319+ const metadata = new Map(catalog.map((item) => [String(item.slug), item]));
1320+ const visible = accounts
1321+ .flatMap((account) => {
1322+ const item = metadata.get(account.integration);
1323+ if (
1324+ !item ||
1325+ (integration !== undefined && account.integration !== integration) ||
1326+ (owner !== undefined && account.owner !== owner)
1327+ )
1328+ return [];
1329+ return [
1330+ {
1331+ integration: account.integration,
1332+ integrationName: item.name,
1333+ integrationDescription: item.description,
1334+ owner: account.owner,
1335+ connection: account.name,
1336+ identityLabel: account.identityLabel ?? null,
1337+ description: account.description ?? null,
1338+ lastHealth:
1339+ account.lastHealth == null
1340+ ? null
1341+ : {
1342+ status: account.lastHealth.status,
1343+ checkedAt: account.lastHealth.checkedAt,
1344+ },
1345+ },
1346+ ];
1347+ })
1348+ .sort(
1349+ (a, b) =>
1350+ a.integration.localeCompare(b.integration) ||
1351+ a.owner.localeCompare(b.owner) ||
1352+ a.connection.localeCompare(b.connection),
1353+ );
1354+ const items = visible.slice(offset, offset + limit);
1355+ const hasMore = offset + items.length < visible.length;
1356+ const result = {
1357+ items,
1358+ total: visible.length,
1359+ hasMore,
1360+ nextOffset: hasMore ? offset + items.length : null,
1361+ };
1362+ return {
1363+ content: [{ type: "text" as const, text: JSON.stringify(result) }],
1364+ structuredContent: result,
1365+ };
1366+ }),
1367+ extra,
1368+ ),
1369+ );
12831370 server.registerTool(
12841371 "search",
12851372 {
12861373 description:
1287− "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.",
1374+ "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.",
12881375 inputSchema: {
12891376 query: z
12901377 .string()
@@ -1292,14 +1379,46 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 1 unmodified line
12921379 .min(1)
12931380 .max(500)
12941381 .describe("Keywords describing the tool or task, such as github create issue."),
1382+ integration: z
1383+ .string()
1384+ .trim()
1385+ .min(1)
1386+ .optional()
1387+ .describe("Exact integration slug from integrations."),
1388+ owner: z.enum(["org", "user"]).optional(),
1389+ connection: z
1390+ .string()
1391+ .trim()
1392+ .min(1)
1393+ .optional()
1394+ .describe(
1395+ "Exact account name from integrations; pair with integration and owner to select one account.",
1396+ ),
12951397 limit: z.number().int().min(1).max(20).default(10),
12961398 offset: z.number().int().min(0).default(0),
12971399 },
12981400 annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
12991401 },
1300− ({ query, limit, offset }, extra) =>
1402+ ({ query, integration, owner, connection, limit, offset }, extra) =>
13011403 boundary(
13021404 Effect.gen(function* () {
1405+ const discovery = {
1406+ tools: {
1407+ list: (filter?: Parameters<McpToolsPort["list"]>[0]) =>
1408+ tools
1409+ .list({
1410+ ...filter,
1411+ ...(integration === undefined
1412+ ? {}
1413+ : { integration: IntegrationSlug.make(integration) }),
1414+ ...(owner === undefined ? {} : { owner }),
1415+ ...(connection === undefined
1416+ ? {}
1417+ : { connection: ConnectionName.make(connection) }),
1418+ })
1419+ .pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))),
1420+ },
1421+ };
13031422 const page = yield* searchTools(discovery, query, limit, { offset });
13041423 const candidates = yield* Effect.forEach(
13051424 page.items,
@@ -1362,14 +1481,15 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 56 unmodified lines
13621481 if (!identity) return unavailable;
13631482 const address = ToolAddress.make(id);
13641483 // Use the existing visibility filter and exclude static configuration tools.
1365− const visible = yield* discovery.tools.list({
1484+ const visible = yield* tools.list({
13661485 integration: identity.integration,
13671486 owner: identity.owner,
13681487 connection: identity.connection,
13691488 query: String(identity.tool),
13701489 includeAnnotations: false,
13711490 });
1372− if (!visible.some((tool) => tool.address === address)) return unavailable;
1491+ if (!visible.some((tool) => tool.static !== true && tool.address === address))
1492+ return unavailable;
13731493 const schema = yield* tools.schema(address);
13741494 if (!schema) return unavailable;
13751495 // The SDK validator checks this dynamic JSON schema at the MCP boundary.
@@ -1416,7 +1536,10 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 40 unmodified lines
14161536 // Search/invoke serves no artifact tools: artifacts run sandboxed code.
14171537 const artifactsEnabled =
14181538 config.mode === "passthrough" ? false : (config.artifactsEnabled ?? true);
1419− const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled });
1539+ const skillCatalog: readonly Skill[] =
1540+ config.mode === "passthrough"
1541+ ? [SEARCH_INVOKE_SKILL]
1542+ : skillCatalogFor({ artifacts: artifactsEnabled });
14201543 // Per-integration search tools are off unless this connection opted in
14211544 // (`?search_tools=true`).
14221545 const searchToolsEnabled = config.searchToolsEnabled ?? false;
@@ -1426,9 +1549,10 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 3 unmodified lines
14261549 // each other.
14271550 const mode: McpToolMode = config.mode ?? "codemode";
14281551 const passthrough = mode === "passthrough";
1429− if (passthrough && !config.tools) {
1552+ if (passthrough && (!config.tools || !config.connections || !config.integrations)) {
14301553 return yield* new McpPassthroughUnavailableError({
1431− reason: "passthrough mode requires tool list and schema APIs",
1554+ reason:
1555+ "passthrough mode requires tool list/schema, connection list, and integration list APIs",
14321556 });
14331557 }
14341558
@@ -1914,8 +2038,14 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 479 unmodified lines
19142038 // --- tools ---
19152039
19162040 // Passthrough serves search and invoke in place of the codemode tools.
1917− if (passthrough && config.tools) {
1918− yield* registerPassthroughTools(server, config.tools, executePassthroughCall);
2041+ if (passthrough && config.tools && config.connections && config.integrations) {
2042+ yield* registerPassthroughTools(
2043+ server,
2044+ config.tools,
2045+ config.connections,
2046+ config.integrations,
2047+ executePassthroughCall,
2048+ );
19192049 }
19202050
19212051 if (!passthrough)
@@ -1934,37 +2064,36 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 12 unmodified lines
19342064 }),
19352065 );
19362066
1937− if (!passthrough)
1938− yield* Effect.sync(() =>
1939− server.registerTool(
1940− "skills",
1941− {
1942− description: [
1943− "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.",
1944− "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
1945− '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).',
1946− "Call with no name to list the few docs available.",
1947− ].join("\n"),
1948− inputSchema: {
1949− name: z
1950− .string()
1951− .optional()
1952− .describe(
1953− 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.',
1954− ),
1955− },
2067+ yield* Effect.sync(() =>
2068+ server.registerTool(
2069+ "skills",
2070+ {
2071+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
2072+ description: passthrough
2073+ ? '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.'
2074+ : [
2075+ "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.",
2076+ "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
2077+ '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).',
2078+ "Call with no name to list the few docs available.",
2079+ ].join("\n"),
2080+ inputSchema: {
2081+ name: z
2082+ .string()
2083+ .optional()
2084+ .describe(
2085+ `A doc from this server's own catalog, e.g. "${passthrough ? "search-invoke" : "execute"}". Omit to list the catalog.`,
2086+ ),
19562087 },
1957− ({ name }, extra) =>
1958− runToolEffect(
1959− Effect.succeed(skillsResult(name, executeInventory, skillCatalog)),
1960− extra,
1961− ),
1962− ),
1963− ).pipe(
1964− Effect.withSpan("mcp.host.register_tool", {
1965− attributes: { "mcp.tool.name": "skills" },
1966− }),
1967− );
2088+ },
2089+ ({ name }, extra) =>
2090+ runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra),
2091+ ),
2092+ ).pipe(
2093+ Effect.withSpan("mcp.host.register_tool", {
2094+ attributes: { "mcp.tool.name": "skills" },
2095+ }),
2096+ );
19682097
19692098 if (!passthrough)
19702099 yield* Effect.sync(() => {
packages/react/src/components/mcp-install-card.tsx+1−1
@@ -313,7 +313,7 @@ export function McpInstallCard(props: { className?: string }) { · 312 unmodified lines
313313 <div className="text-xs font-medium text-foreground">Search and invoke</div>
314314 <div className="mt-0.5 text-xs leading-5 text-muted-foreground">
315315 {toolMode === "passthrough"
316− ? "Find connected tools with search, then call them with invoke. Your client handles approval for each call."
316+ ? "Discover connected accounts with integrations and read the guide with skills. Find tools with search, then call them with invoke. Your client handles approval."
317317 : "Disabled: agents write code against your tools through one execute tool."}
318318 </div>
319319 </div>