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
385 artifacts: executor.artifacts,385 artifacts: executor.artifacts,
386 connections: executor.connections,386 connections: executor.connections,
387 tools: executor.tools,387 tools: executor.tools,
388 integrations: executor.integrations,
388 // Artifacts are on by default, opt-out per connection. A session389 // Artifacts are on by default, opt-out per connection. A session
389 // persisted without a value restores to the default, same as a fresh390 // persisted without a value restores to the default, same as a fresh
390 // connection whose URL says nothing about `?artifacts=`.391 // connection whose URL says nothing about `?artifacts=`.
apps/docs/mcp-proxy.mdx+29−0
@@ -56,3 +56,32 @@ Executor: · 55 unmodified lines
56 56
57Once a client is connected, every integration you add to Executor appears in that57Once a client is connected, every integration you add to Executor appears in that
58agent automatically.58agent automatically.
59
60## Search and invoke mode
61
62Add `?mode=passthrough` to your MCP endpoint, or enable **Search and invoke**
63in 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
74For example, call `integrations({})`, then
75`search({ query: "create issue", integration: "github", owner: "org", connection: "main" })`
76using the account names returned by your server. Call `invoke` with the matching
77result's `id` and arguments that follow its `inputSchema`.
78
79Both `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
82has its own pagination for records.
83
84The tool list stays small as you add integrations. Input schemas are loaded only
85for matching search results. Your client handles approval for `invoke`, and
86workspace block policies still apply. This mode does not expose code execution or
87artifact 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
167 artifacts: executor.artifacts,167 artifacts: executor.artifacts,
168 connections: executor.connections,168 connections: executor.connections,
169 tools: executor.tools,169 tools: executor.tools,
170 integrations: executor.integrations,
170 // Artifacts are on by default, opt-out per connection. A session171 // Artifacts are on by default, opt-out per connection. A session
171 // persisted without a value restores to the default, same as a fresh172 // persisted without a value restores to the default, same as a fresh
172 // connection whose URL says nothing about `?artifacts=`.173 // 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
124 artifacts: executor.artifacts,124 artifacts: executor.artifacts,
125 connections: executor.connections,125 connections: executor.connections,
126 tools: executor.tools,126 tools: executor.tools,
127 integrations: executor.integrations,
127 ...appsConfig,128 ...appsConfig,
128 },129 },
129 webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL || undefined,130 webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL || undefined,
@@ -135,6 +136,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 5 unmodified lines
135 artifacts: executor.artifacts,136 artifacts: executor.artifacts,
136 connections: executor.connections,137 connections: executor.connections,
137 tools: executor.tools,138 tools: executor.tools,
139 integrations: executor.integrations,
138 ...appsConfig,140 ...appsConfig,
139 },141 },
140 };142 };
@@ -161,6 +163,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 20 unmodified lines
161 artifacts: handle.executor.artifacts,163 artifacts: handle.executor.artifacts,
162 connections: handle.executor.connections,164 connections: handle.executor.connections,
163 tools: handle.executor.tools,165 tools: handle.executor.tools,
166 integrations: handle.executor.integrations,
164 ...appsConfig,167 ...appsConfig,
165 },168 },
166 close: handle.dispose,169 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.
2// The server retains 10,200 tools; the replay model scripts discovery and a real upstream call.2// The server retains 10,200 tools; the replay model scripts discovery and a real upstream call.
3import { randomBytes } from "node:crypto";3import { randomBytes } from "node:crypto";
4import { createServer } from "node:http";4import { createServer } from "node:http";
e2e/cloud/passthrough-scale.test.ts+6−1
@@ -45,7 +45,12 @@ scenario( · 44 unmodified lines
45 `a ${visible.length}-tool passthrough connect stays bounded (took ${elapsedMs}ms)`,45 `a ${visible.length}-tool passthrough connect stays bounded (took ${elapsedMs}ms)`,
46 ).toBeLessThan(MAX_PASSTHROUGH_CONNECT_MS);46 ).toBeLessThan(MAX_PASSTHROUGH_CONNECT_MS);
47 47
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 ]);
49 const first = decodeToolSearch(54 const first = decodeToolSearch(
50 (yield* session.call("search", { query: "org", limit: 20 })).raw,55 (yield* session.call("search", { query: "org", limit: 20 })).raw,
51 ).structuredContent;56 ).structuredContent;
e2e/scenarios/mcp-passthrough.test.ts+77−4
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto"; · 2 unmodified lines
3import { createServer, type IncomingMessage } from "node:http";3import { createServer, type IncomingMessage } from "node:http";
4 4
5import { expect } from "@effect/vitest";5import { expect } from "@effect/vitest";
6import { Effect } from "effect";6import { Effect, Schema } from "effect";
7import { composePluginApi } from "@executor-js/api/server";7import { composePluginApi } from "@executor-js/api/server";
8import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";8import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
9import {9import {
@@ -16,7 +16,23 @@ import { · 6 unmodified lines
16import { decodeToolSearch } from "./support/search-invoke";16import { decodeToolSearch } from "./support/search-invoke";
17 17
18import { scenario } from "../src/scenario";18import { scenario } from "../src/scenario";
19import { Api, Mcp, Target } from "../src/services";19import { Api, Browser, Mcp, Target } from "../src/services";
20import { visit, settle } from "../src/surfaces/browser";
21
22const 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);
20 36
21const api = composePluginApi([openApiHttpPlugin()] as const);37const api = composePluginApi([openApiHttpPlugin()] as const);
22 38
@@ -133,6 +149,7 @@ scenario( · 110 unmodified lines
133 Effect.gen(function* () {149 Effect.gen(function* () {
134 const target = yield* Target;150 const target = yield* Target;
135 const mcp = yield* Mcp;151 const mcp = yield* Mcp;
152 const browser = yield* Browser;
136 const { client: makeClient } = yield* Api;153 const { client: makeClient } = yield* Api;
137 154
138 const identity = yield* target.newIdentity();155 const identity = yield* target.newIdentity();
@@ -170,12 +187,35 @@ scenario( · 31 unmodified lines
170 });187 });
171 }188 }
172 189
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
173 const codemode = mcp.session(identity);208 const codemode = mcp.session(identity);
174 expect(yield* codemode.listTools()).toContain("execute");209 expect(yield* codemode.listTools()).toContain("execute");
175 210
176 const passthrough = mcp.session(identity, { mode: "passthrough" });211 const passthrough = mcp.session(identity, { mode: "passthrough" });
177 const described = yield* passthrough.describeTools();212 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 ]);
179 expect(described.find((tool) => tool.name === "search")?.annotations).toMatchObject({219 expect(described.find((tool) => tool.name === "search")?.annotations).toMatchObject({
180 readOnlyHint: true,220 readOnlyHint: true,
181 destructiveHint: false,221 destructiveHint: false,
@@ -184,9 +224,42 @@ scenario( · 2 unmodified lines
184 readOnlyHint: false,224 readOnlyHint: false,
185 destructiveHint: true,225 destructiveHint: true,
186 });226 });
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);
187 const found = decodeToolSearch(245 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,
189 ).structuredContent;261 ).structuredContent;
262 expect(missingAccount.items).toEqual([]);
190 const listDef = found.items.find((tool) => tool.id.endsWith(".listNotes"));263 const listDef = found.items.find((tool) => tool.id.endsWith(".listNotes"));
191 const createDef = found.items.find((tool) => tool.id.endsWith(".createNote"));264 const createDef = found.items.find((tool) => tool.id.endsWith(".createNote"));
192 if (!listDef || !createDef) return yield* Effect.die("Search omitted notes operations");265 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
72 artifacts: executor.artifacts,72 artifacts: executor.artifacts,
73 connections: executor.connections,73 connections: executor.connections,
74 tools: executor.tools,74 tools: executor.tools,
75 integrations: executor.integrations,
75 ...(hostOptions?.loadAppShellHtml76 ...(hostOptions?.loadAppShellHtml
76 ? { loadAppShellHtml: hostOptions.loadAppShellHtml }77 ? { loadAppShellHtml: hostOptions.loadAppShellHtml }
77 : {}),78 : {}),
packages/hosts/mcp/src/passthrough-tools.test.ts+236−6
@@ -61,10 +61,15 @@ const toolPort = ( · 60 unmodified lines
61 schemaReads: string[] = [],61 schemaReads: string[] = [],
62 lists: string[] = [],62 lists: string[] = [],
63): McpToolsPort => ({63): McpToolsPort => ({
64 list: () =>64 list: (filter) =>
65 Effect.sync(() => {65 Effect.sync(() => {
66 lists.push("list");66 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 );
68 }),73 }),
69 schema: (address) =>74 schema: (address) =>
70 Effect.sync(() => {75 Effect.sync(() => {
@@ -113,7 +118,13 @@ const withClient = async <E extends Cause.YieldableError>( · 42 unmodified lines
113 config: ExecutorMcpServerConfig<E>,118 config: ExecutorMcpServerConfig<E>,
114 fn: (client: Client) => Promise<void>,119 fn: (client: Client) => Promise<void>,
115) => {120) => {
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 );
117 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();128 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
118 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });129 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
119 await mcpServer.connect(serverTransport);130 await mcpServer.connect(serverTransport);
@@ -203,7 +214,221 @@ describe("readToolMode", () => { · 83 unmodified lines
203// ---------------------------------------------------------------------------214// ---------------------------------------------------------------------------
204 215
205describe("passthrough mode server", () => {216describe("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 () => {
207 const { engine, executed } = makeRecordingEngine();432 const { engine, executed } = makeRecordingEngine();
208 const schemaReads: string[] = [];433 const schemaReads: string[] = [];
209 const lists: string[] = [];434 const lists: string[] = [];
@@ -223,7 +448,12 @@ describe("passthrough mode server", () => { · 13 unmodified lines
223 },448 },
224 async (client) => {449 async (client) => {
225 const listed = await client.listTools();450 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 ]);
227 expect(JSON.stringify(listed).length).toBeLessThan(4000);457 expect(JSON.stringify(listed).length).toBeLessThan(4000);
228 expect(lists).toEqual([]);458 expect(lists).toEqual([]);
229 expect(schemaReads).toEqual([]);459 expect(schemaReads).toEqual([]);
@@ -563,7 +793,7 @@ describe("passthrough mode server", () => { · 333 unmodified lines
563 },793 },
564 async (client) => {794 async (client) => {
565 const names = (await client.listTools()).tools.map((tool) => tool.name);795 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"]);
567 },797 },
568 );798 );
569 });799 });
packages/hosts/mcp/src/passthrough-tools.ts+25−0
@@ -1,3 +1,5 @@
1import type { Skill } from "@executor-js/execution";
2
1/**3/**
2 * The sandbox code a passthrough call runs. Built HERE from the session's4 * The sandbox code a passthrough call runs. Built HERE from the session's
3 * resolved address and a JSON-encoded argument — never concatenated from raw5 * 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
23 25
24/** Describe the fixed search/invoke surface without listing the underlying catalog. */26/** Describe the fixed search/invoke surface without listing the underlying catalog. */
25export const passthroughInstructions = (): string =>27export const passthroughInstructions = (): string =>
28 'Use integrations to see connected accounts and skills({ name: "search-invoke" }) for the workflow. ' +
26 "Find connected integration tools with search, then call invoke with the returned tool ID and JSON arguments. " +29 "Find connected integration tools with search, then call invoke with the returned tool ID and JSON arguments. " +
27 "Search returns input schemas and account details. Use its nextOffset to get more matches. " +30 "Search returns input schemas and account details. Use its nextOffset to get more matches. " +
28 "Invoke can change external state; your client handles approval for each call. Workspace block policies remain enforced. " +31 "Invoke can change external state; your client handles approval for each call. Workspace block policies remain enforced. " +
29 "No JavaScript, execute, resume, or artifact tools are exposed in this mode.";32 "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. */
35export 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
24import {24import {
25 CurrentOrgWriteAccess,25 CurrentOrgWriteAccess,
26 ToolAddress,26 ToolAddress,
27 IntegrationSlug,
28 ConnectionName,
27 parseToolAddress,29 parseToolAddress,
28 isToolFile,30 isToolFile,
29 isToolResult,31 isToolResult,
@@ -33,6 +35,8 @@ import { · 3 unmodified lines
33} from "@executor-js/sdk";35} from "@executor-js/sdk";
34import type {36import type {
35 Artifact,37 Artifact,
38 Connection,
39 Integration,
36 ArtifactBinding,40 ArtifactBinding,
37 ArtifactSummary,41 ArtifactSummary,
38 ElicitationResponse,42 ElicitationResponse,
@@ -82,7 +86,11 @@ import { · 43 unmodified lines
82 type BindableConnection,86 type BindableConnection,
83} from "./artifact-bindings";87} from "./artifact-bindings";
84import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams";88import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams";
85import { passthroughCallCode, passthroughInstructions } from "./passthrough-tools";89import {
90 passthroughCallCode,
91 passthroughInstructions,
92 SEARCH_INVOKE_SKILL,
93} from "./passthrough-tools";
86import type { McpToolMode } from "./browser-approval";94import type { McpToolMode } from "./browser-approval";
87 95
88// ---------------------------------------------------------------------------96// ---------------------------------------------------------------------------
@@ -234,14 +242,16 @@ type SharedMcpServerConfig = { · 145 unmodified lines
234 */242 */
235 readonly artifacts?: McpArtifactsPort;243 readonly artifacts?: McpArtifactsPort;
236 /**244 /**
237 * The caller's saved connections, for binding an artifact's integration roles245 * The caller's saved connections, for the search/invoke account inventory
238 * at create time. Structurally satisfied by `executor.connections`; hosts pass246 * and binding artifact integration roles at create time. Structurally satisfied by `executor.connections`; hosts pass
239 * the same scoped executor they pass `artifacts`.247 * the same scoped executor they pass `artifacts`.
240 *248 *
241 * Absent means `create-artifact` cannot bind, so it refuses code that calls an249 * Absent means `create-artifact` cannot bind, so it refuses code that calls an
242 * integration rather than saving an artifact that could never run.250 * integration rather than saving an artifact that could never run.
243 */251 */
244 readonly connections?: McpConnectionsPort;252 readonly connections?: McpConnectionsPort;
253 /** Scoped integration metadata for the search/invoke account inventory. */
254 readonly integrations?: McpIntegrationsPort;
245 /**255 /**
246 * Builds the web-app deep link for a saved artifact. Clients that can't256 * Builds the web-app deep link for a saved artifact. Clients that can't
247 * render MCP Apps get this URL instead of an inline widget. Absent (stdio has257 * render MCP Apps get this URL instead of an inline widget. Absent (stdio has
@@ -291,12 +301,24 @@ export type McpArtifactsPort = { · 43 unmodified lines
291};301};
292 302
293/**303/**
294 * The connection surface binding needs: list what this caller can reach. The304 * The connection surface binding and discovery need: list what this caller can reach. The
295 * scoped executor has already narrowed it, so an inferred binding can never305 * scoped executor has already narrowed it, so an inferred binding can never
296 * name a connection the caller couldn't call themselves.306 * name a connection the caller couldn't call themselves.
297 */307 */
298export type McpConnectionsPort = {308export 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. */
317export type McpIntegrationsPort = {
318 readonly list: () => Effect.Effect<
319 readonly Pick<Integration, "slug" | "name" | "description">[],
320 unknown
321 >;
300};322};
301 323
302/** The same list and schema APIs used by codemode discovery. */324/** The same list and schema APIs used by codemode discovery. */
@@ -1249,6 +1271,8 @@ const passthroughInputSchema = (view: ToolSchemaView): unknown => · 946 unmodified lines
1249const registerPassthroughTools = <E extends Cause.YieldableError>(1271const registerPassthroughTools = <E extends Cause.YieldableError>(
1250 server: McpServer,1272 server: McpServer,
1251 tools: McpToolsPort,1273 tools: McpToolsPort,
1274 connections: McpConnectionsPort,
1275 integrations: McpIntegrationsPort,
1252 run: (1276 run: (
1253 address: ToolAddress,1277 address: ToolAddress,
1254 args: unknown,1278 args: unknown,
@@ -1258,14 +1282,6 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 3 unmodified lines
1258 Effect.gen(function* () {1282 Effect.gen(function* () {
1259 const context = yield* Effect.context<never>();1283 const context = yield* Effect.context<never>();
1260 const validator = new CfWorkerJsonSchemaValidator();1284 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 };
1269 const boundary = <A extends McpToolResult, F>(1285 const boundary = <A extends McpToolResult, F>(
1270 effect: Effect.Effect<A, F>,1286 effect: Effect.Effect<A, F>,
1271 extra: McpRequestJoinKeys,1287 extra: McpRequestJoinKeys,
@@ -1280,11 +1296,82 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 8 unmodified lines
1280 ),1296 ),
1281 );1297 );
1282 yield* Effect.sync(() => {1298 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 );
1283 server.registerTool(1370 server.registerTool(
1284 "search",1371 "search",
1285 {1372 {
1286 description:1373 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.",
1288 inputSchema: {1375 inputSchema: {
1289 query: z1376 query: z
1290 .string()1377 .string()
@@ -1292,14 +1379,46 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 1 unmodified line
1292 .min(1)1379 .min(1)
1293 .max(500)1380 .max(500)
1294 .describe("Keywords describing the tool or task, such as github create issue."),1381 .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 ),
1295 limit: z.number().int().min(1).max(20).default(10),1397 limit: z.number().int().min(1).max(20).default(10),
1296 offset: z.number().int().min(0).default(0),1398 offset: z.number().int().min(0).default(0),
1297 },1399 },
1298 annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },1400 annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1299 },1401 },
1300 ({ query, limit, offset }, extra) =>1402 ({ query, integration, owner, connection, limit, offset }, extra) =>
1301 boundary(1403 boundary(
1302 Effect.gen(function* () {1404 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 };
1303 const page = yield* searchTools(discovery, query, limit, { offset });1422 const page = yield* searchTools(discovery, query, limit, { offset });
1304 const candidates = yield* Effect.forEach(1423 const candidates = yield* Effect.forEach(
1305 page.items,1424 page.items,
@@ -1362,14 +1481,15 @@ const registerPassthroughTools = <E extends Cause.YieldableError>( · 56 unmodified lines
1362 if (!identity) return unavailable;1481 if (!identity) return unavailable;
1363 const address = ToolAddress.make(id);1482 const address = ToolAddress.make(id);
1364 // Use the existing visibility filter and exclude static configuration tools.1483 // Use the existing visibility filter and exclude static configuration tools.
1365 const visible = yield* discovery.tools.list({1484 const visible = yield* tools.list({
1366 integration: identity.integration,1485 integration: identity.integration,
1367 owner: identity.owner,1486 owner: identity.owner,
1368 connection: identity.connection,1487 connection: identity.connection,
1369 query: String(identity.tool),1488 query: String(identity.tool),
1370 includeAnnotations: false,1489 includeAnnotations: false,
1371 });1490 });
1372 if (!visible.some((tool) => tool.address === address)) return unavailable;1491 if (!visible.some((tool) => tool.static !== true && tool.address === address))
1492 return unavailable;
1373 const schema = yield* tools.schema(address);1493 const schema = yield* tools.schema(address);
1374 if (!schema) return unavailable;1494 if (!schema) return unavailable;
1375 // The SDK validator checks this dynamic JSON schema at the MCP boundary.1495 // 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
1416 // Search/invoke serves no artifact tools: artifacts run sandboxed code.1536 // Search/invoke serves no artifact tools: artifacts run sandboxed code.
1417 const artifactsEnabled =1537 const artifactsEnabled =
1418 config.mode === "passthrough" ? false : (config.artifactsEnabled ?? true);1538 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 });
1420 // Per-integration search tools are off unless this connection opted in1543 // Per-integration search tools are off unless this connection opted in
1421 // (`?search_tools=true`).1544 // (`?search_tools=true`).
1422 const searchToolsEnabled = config.searchToolsEnabled ?? false;1545 const searchToolsEnabled = config.searchToolsEnabled ?? false;
@@ -1426,9 +1549,10 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 3 unmodified lines
1426 // each other.1549 // each other.
1427 const mode: McpToolMode = config.mode ?? "codemode";1550 const mode: McpToolMode = config.mode ?? "codemode";
1428 const passthrough = mode === "passthrough";1551 const passthrough = mode === "passthrough";
1429 if (passthrough && !config.tools) {1552 if (passthrough && (!config.tools || !config.connections || !config.integrations)) {
1430 return yield* new McpPassthroughUnavailableError({1553 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",
1432 });1556 });
1433 }1557 }
1434 1558
@@ -1914,8 +2038,14 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 479 unmodified lines
1914 // --- tools ---2038 // --- tools ---
1915 2039
1916 // Passthrough serves search and invoke in place of the codemode tools.2040 // Passthrough serves search and invoke in place of the codemode tools.
1917 if (passthrough && config.tools) {2041 if (passthrough && config.tools && config.connections && config.integrations) {
1918 yield* registerPassthroughTools(server, config.tools, executePassthroughCall);2042 yield* registerPassthroughTools(
2043 server,
2044 config.tools,
2045 config.connections,
2046 config.integrations,
2047 executePassthroughCall,
2048 );
1919 }2049 }
1920 2050
1921 if (!passthrough)2051 if (!passthrough)
@@ -1934,37 +2064,36 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 12 unmodified lines
1934 }),2064 }),
1935 );2065 );
1936 2066
1937 if (!passthrough)2067 yield* Effect.sync(() =>
1938 yield* Effect.sync(() =>2068 server.registerTool(
1939 server.registerTool(2069 "skills",
1940 "skills",2070 {
1941 {2071 annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1942 description: [2072 description: passthrough
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.",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.'
1944 "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",2074 : [
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).',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.",
1946 "Call with no name to list the few docs available.",2076 "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
1947 ].join("\n"),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).',
1948 inputSchema: {2078 "Call with no name to list the few docs available.",
1949 name: z2079 ].join("\n"),
1950 .string()2080 inputSchema: {
1951 .optional()2081 name: z
1952 .describe(2082 .string()
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.',2083 .optional()
1954 ),2084 .describe(
1955 },2085 `A doc from this server's own catalog, e.g. "${passthrough ? "search-invoke" : "execute"}". Omit to list the catalog.`,
2086 ),
1956 },2087 },
1957 ({ name }, extra) =>2088 },
1958 runToolEffect(2089 ({ name }, extra) =>
1959 Effect.succeed(skillsResult(name, executeInventory, skillCatalog)),2090 runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra),
1960 extra,2091 ),
1961 ),2092 ).pipe(
1962 ),2093 Effect.withSpan("mcp.host.register_tool", {
1963 ).pipe(2094 attributes: { "mcp.tool.name": "skills" },
1964 Effect.withSpan("mcp.host.register_tool", {2095 }),
1965 attributes: { "mcp.tool.name": "skills" },2096 );
1966 }),
1967 );
1968 2097
1969 if (!passthrough)2098 if (!passthrough)
1970 yield* Effect.sync(() => {2099 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
313 <div className="text-xs font-medium text-foreground">Search and invoke</div>313 <div className="text-xs font-medium text-foreground">Search and invoke</div>
314 <div className="mt-0.5 text-xs leading-5 text-muted-foreground">314 <div className="mt-0.5 text-xs leading-5 text-muted-foreground">
315 {toolMode === "passthrough"315 {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."
317 : "Disabled: agents write code against your tools through one execute tool."}317 : "Disabled: agents write code against your tools through one execute tool."}
318 </div>318 </div>
319 </div>319 </div>