History

Add search and invoke MCP mode (#1942)

* Add search and invoke MCP mode * Preserve full annotations in MCP scenario decoding

Rhys Sullivan committed 2 months ago

Commit 3c263d71 parent 0e9d80029 files · +2064 −111Browse files

Unified · Split

29 files
  1. .changeset/mcp-passthrough-mode.md +7 −0
  2. apps/cli/src/main.ts +31 −2
  3. apps/cloud/src/mcp/agent-handler.ts +2 −0
  4. apps/cloud/src/mcp/session-durable-object.ts +4 −0
  5. apps/cloud/src/mcp/session-meta.ts +1 −0
  6. apps/host-cloudflare/src/mcp/agent-handler.ts +2 −0
  7. apps/host-cloudflare/src/mcp/session-durable-object.ts +3 −0
  8. apps/local/src/main.ts +3 −0
  9. apps/local/src/mcp.ts +2 −0
  10. e2e/cloud/passthrough-opencode-codemode.test.ts +327 −0
  11. e2e/cloud/passthrough-scale.test.ts +75 −0
  12. e2e/scenarios/mcp-passthrough.test.ts +300 −0
  13. e2e/scenarios/support/search-invoke.ts +24 −0
  14. e2e/src/surfaces/mcp.ts +42 −6
  15. packages/core/api/src/server/mcp-build.ts +3 −0
  16. packages/core/execution/src/tool-invoker.ts +1 −1
  17. packages/core/sdk/src/elicitation.ts +10 −0
  18. packages/core/sdk/src/executor.ts +2 −1
  19. packages/core/sdk/src/host-internal.ts +3 −0
  20. packages/core/sdk/src/index.ts +1 −0
  21. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +9 −0
  22. packages/hosts/cloudflare/src/mcp/do-headers.ts +2 −0
  23. packages/hosts/mcp/src/browser-approval.ts +15 −0
  24. packages/hosts/mcp/src/in-memory-session-store.ts +20 −8
  25. packages/hosts/mcp/src/passthrough-tools.test.ts +668 −0
  26. packages/hosts/mcp/src/passthrough-tools.ts +29 −0
  27. packages/hosts/mcp/src/tool-server.ts +423 −83
  28. packages/react/src/api/analytics.tsx +1 −0
  29. packages/react/src/components/mcp-install-card.tsx +54 −10
.changeset/mcp-passthrough-mode.md+7−0
@@ -0,0 +1,7 @@
1+---
2+"@executor-js/sdk": minor
3+"@executor-js/execution": minor
4+"executor": minor
5+---
6+
7+Add a search and invoke MCP mode (`?mode=passthrough`, `executor mcp --mode passthrough`). Search returns bounded pages of matching tool IDs and input schemas. Invoke validates arguments and runs the selected tool, with native client approval and workspace blocks enforced. The MCP catalog stays at two tools regardless of integration count.
apps/cli/src/main.ts+31−2
@@ -1363,6 +1363,7 @@ const mcpUrlForActiveLocalServer = (input: { · 1362 unmodified lines
13631363 readonly elicitationMode: "browser" | "model";
13641364 readonly artifacts: boolean;
13651365 readonly searchTools: boolean;
1366+ readonly toolMode: "codemode" | "passthrough";
13661367 }): URL => {
13671368 const url = new URL("/mcp", input.connection.origin);
13681369 if (input.elicitationMode === "browser") {
@@ -1378,6 +1379,10 @@ const mcpUrlForActiveLocalServer = (input: { · 9 unmodified lines
13781379 if (input.searchTools) {
13791380 url.searchParams.set("search_tools", "true");
13801381 }
1382+ // Passthrough is the non-default surface; only it is spelled out.
1383+ if (input.toolMode === "passthrough") {
1384+ url.searchParams.set("mode", "passthrough");
1385+ }
13811386 return url;
13821387 };
13831388
@@ -1394,6 +1399,7 @@ const runMcpHttpBridge = async (input: { · 10 unmodified lines
13941399 readonly elicitationMode: "browser" | "model";
13951400 readonly artifacts: boolean;
13961401 readonly searchTools: boolean;
1402+ readonly toolMode: "codemode" | "passthrough";
13971403 }): Promise<void> => {
13981404 const stdio = new StdioServerTransport();
13991405 const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection);
@@ -1403,6 +1409,7 @@ const runMcpHttpBridge = async (input: { · 3 unmodified lines
14031409 elicitationMode: input.elicitationMode,
14041410 artifacts: input.artifacts,
14051411 searchTools: input.searchTools,
1412+ toolMode: input.toolMode,
14061413 }),
14071414 authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined,
14081415 );
@@ -1482,6 +1489,7 @@ const runStdioMcpSession = (input: { · 73 unmodified lines
14821489 readonly elicitationMode: "browser" | "model";
14831490 readonly artifacts: boolean;
14841491 readonly searchTools: boolean;
1492+ readonly toolMode: "codemode" | "passthrough";
14851493 }) =>
14861494 Effect.gen(function* () {
14871495 // `executor mcp` never owns the local database. If a local server is already
@@ -1499,6 +1507,7 @@ const runStdioMcpSession = (input: { · 11 unmodified lines
14991507 elicitationMode: input.elicitationMode,
15001508 artifacts: input.artifacts,
15011509 searchTools: input.searchTools,
1510+ toolMode: input.toolMode,
15021511 }),
15031512 );
15041513 return;
@@ -1526,6 +1535,7 @@ const runStdioMcpSession = (input: { · 21 unmodified lines
15261535 elicitationMode: input.elicitationMode,
15271536 artifacts: input.artifacts,
15281537 searchTools: input.searchTools,
1538+ toolMode: input.toolMode,
15291539 }),
15301540 );
15311541 });
@@ -2898,11 +2908,30 @@ const mcpCommand = Command.make( · 1366 unmodified lines
28982908 "Serve one search_<integration> tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.",
28992909 ),
29002910 ),
2911+ toolMode: Options.choice("mode", ["codemode", "passthrough"] as const)
2912+ .pipe(Options.withDefault("codemode"))
2913+ .pipe(
2914+ Options.withDescription(
2915+ "codemode (default) serves the execute tool; passthrough serves search and invoke, with input schemas in search results and client approval for invoke.",
2916+ ),
2917+ ),
29012918 },
2902− ({ scope, elicitationMode, noArtifacts, searchTools }) =>
2919+ ({ scope, elicitationMode, noArtifacts, searchTools, toolMode }) =>
29032920 Effect.gen(function* () {
29042921 applyScope(scope);
2905− yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools });
2922+ if (toolMode === "passthrough" && searchTools) {
2923+ return yield* Effect.fail(
2924+ new Error(
2925+ "--search-tools is a codemode option; passthrough already provides search. Drop --search-tools or --mode passthrough.",
2926+ ),
2927+ );
2928+ }
2929+ yield* runStdioMcpSession({
2930+ elicitationMode,
2931+ artifacts: !noArtifacts,
2932+ searchTools,
2933+ toolMode,
2934+ });
29062935 }),
29072936 ).pipe(Command.withDescription("Start an MCP server over stdio"));
29082937
apps/cloud/src/mcp/agent-handler.ts+2−0
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 readArtifactsEnabled,
1717 readElicitationMode,
1818 readSearchToolsEnabled,
19+ readToolMode,
1920 withVerifiedIdentityHeaders,
2021 } from "@executor-js/cloudflare/mcp/do-headers";
2122 import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
@@ -189,6 +190,7 @@ const propsForPrincipal = ( · 167 unmodified lines
189190 elicitationMode: readElicitationMode(request),
190191 artifactsEnabled: readArtifactsEnabled(request),
191192 searchToolsEnabled: readSearchToolsEnabled(request),
193+ toolMode: readToolMode(request),
192194 resource,
193195 webOrigin: new URL(request.url).origin,
194196 },
apps/cloud/src/mcp/session-durable-object.ts+4−0
@@ -384,6 +384,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 383 unmodified lines
384384 description,
385385 artifacts: executor.artifacts,
386386 connections: executor.connections,
387+ tools: executor.tools,
387388 // Artifacts are on by default, opt-out per connection. A session
388389 // persisted without a value restores to the default, same as a fresh
389390 // connection whose URL says nothing about `?artifacts=`.
@@ -391,6 +392,9 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 1 unmodified line
391392 // Per-integration search tools are off by default, opt-in per
392393 // connection (`?search_tools=true`). Same restore rule as artifacts.
393394 searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false,
395+ // The tool surface must survive a cold restore unchanged: the client
396+ // cached the names it saw at `initialize`.
397+ mode: sessionMeta.toolMode ?? "codemode",
394398 // Cold restores rebuild this server with no `initialize` to replay, so
395399 // the negotiated apps support comes back from storage instead.
396400 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
apps/cloud/src/mcp/session-meta.ts+1−0
@@ -127,6 +127,7 @@ const metaFromIdentity = ( · 126 unmodified lines
127127 elicitationMode: token.elicitationMode,
128128 artifactsEnabled: token.artifactsEnabled,
129129 searchToolsEnabled: token.searchToolsEnabled,
130+ toolMode: token.toolMode,
130131 };
131132 };
132133
apps/host-cloudflare/src/mcp/agent-handler.ts+2−0
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
1414 readArtifactsEnabled,
1515 readElicitationMode,
1616 readSearchToolsEnabled,
17+ readToolMode,
1718 withVerifiedIdentityHeaders,
1819 } from "@executor-js/cloudflare/mcp/do-headers";
1920 import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
@@ -86,6 +87,7 @@ const propsForPrincipal = ( · 66 unmodified lines
8687 elicitationMode: readElicitationMode(request),
8788 artifactsEnabled: readArtifactsEnabled(request),
8889 searchToolsEnabled: readSearchToolsEnabled(request),
90+ toolMode: readToolMode(request),
8991 // host-cloudflare only routes the bare `/mcp` endpoint to the Agent
9092 // bridge (see worker.ts), so the session always serves the default
9193 // resource.
apps/host-cloudflare/src/mcp/session-durable-object.ts+3−0
@@ -133,6 +133,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 132 unmodified lines
133133 elicitationMode: token.elicitationMode,
134134 artifactsEnabled: token.artifactsEnabled,
135135 searchToolsEnabled: token.searchToolsEnabled,
136+ toolMode: token.toolMode,
136137 } satisfies SessionMeta);
137138 }
138139
@@ -165,6 +166,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 26 unmodified lines
165166 engine,
166167 artifacts: executor.artifacts,
167168 connections: executor.connections,
169+ tools: executor.tools,
168170 // Artifacts are on by default, opt-out per connection. A session
169171 // persisted without a value restores to the default, same as a fresh
170172 // connection whose URL says nothing about `?artifacts=`.
@@ -172,6 +174,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 1 unmodified line
172174 // Per-integration search tools are off by default, opt-in per
173175 // connection (`?search_tools=true`). Same restore rule as artifacts.
174176 searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false,
177+ mode: sessionMeta.toolMode ?? "codemode",
175178 // Cold restores rebuild this server with no `initialize` to replay, so
176179 // the negotiated apps support comes back from storage instead.
177180 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
apps/local/src/main.ts+3−0
@@ -123,6 +123,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 122 unmodified lines
123123 engine,
124124 artifacts: executor.artifacts,
125125 connections: executor.connections,
126+ tools: executor.tools,
126127 ...appsConfig,
127128 },
128129 webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL || undefined,
@@ -133,6 +134,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 4 unmodified lines
133134 engine,
134135 artifacts: executor.artifacts,
135136 connections: executor.connections,
137+ tools: executor.tools,
136138 ...appsConfig,
137139 },
138140 };
@@ -158,6 +160,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 19 unmodified lines
158160 engine: toolkitEngine,
159161 artifacts: handle.executor.artifacts,
160162 connections: handle.executor.connections,
163+ tools: handle.executor.tools,
161164 ...appsConfig,
162165 },
163166 close: handle.dispose,
apps/local/src/mcp.ts+2−0
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
2121 readArtifactsEnabled,
2222 readElicitationMode,
2323 readSearchToolsEnabled,
24+ readToolMode,
2425 } from "@executor-js/host-mcp/browser-approval";
2526 import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";
2627 import {
@@ -246,6 +247,7 @@ export const createMcpRequestHandler = ( · 219 unmodified lines
246247 browserApprovalStore: approvals.store,
247248 artifactsEnabled: readArtifactsEnabled(request),
248249 searchToolsEnabled: readSearchToolsEnabled(request),
250+ mode: readToolMode(request),
249251 elicitationMode:
250252 elicitationMode === "browser"
251253 ? {
e2e/cloud/passthrough-opencode-codemode.test.ts+327−0
@@ -0,0 +1,327 @@
1+// Real OpenCode codemode discovers and invokes tools through a two-tool MCP surface.
2+// The server retains 10,200 tools; the replay model scripts discovery and a real upstream call.
3+import { randomBytes } from "node:crypto";
4+import { createServer } from "node:http";
5+import { join } from "node:path";
6+
7+import { expect } from "@effect/vitest";
8+import { Effect } from "effect";
9+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
10+
11+import { serveReplayBrain } from "../src/clients/replay-brain";
12+import { scenario } from "../src/scenario";
13+import { Api, Cli, OpenCode, RunDir, Target } from "../src/services";
14+import { catalogApi, seedLargeCatalog } from "../scenarios/support/large-catalog";
15+
16+const SERVER_NAME = "executor";
17+
18+// 322 (the real Vercel fixture) + 12 × 823 synthetic = 10,198, plus the two
19+// callable operations below = exactly 10,200 searchable tools.
20+const SYNTHETIC_INTEGRATIONS = 12;
21+const OPS_PER_INTEGRATION = 823;
22+const EXPECTED_TOOL_COUNT = 10_200;
23+
24+// OpenCode's MCP connect timeout (`DEFAULT_TIMEOUT` in its mcp service). The
25+// Catalog loading has to fit inside it, or the server reads
26+// "failed" and codemode sees nothing.
27+const OPENCODE_CONNECT_TIMEOUT_MS = 30_000;
28+
29+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
30+
31+/** The one integration that is actually callable: a read and a write against
32+ * the recording upstream. Everything else in the catalog is discovery mass. */
33+const notesSpec = (baseUrl: string): string =>
34+ JSON.stringify({
35+ openapi: "3.0.3",
36+ info: { title: "Notes API", version: "1.0.0" },
37+ servers: [{ url: baseUrl }],
38+ paths: {
39+ "/notes": {
40+ get: {
41+ operationId: "listNotes",
42+ summary: "List every note in the notebook",
43+ responses: { "200": { description: "ok" } },
44+ },
45+ post: {
46+ operationId: "createNote",
47+ summary: "Create a note in the notebook",
48+ requestBody: {
49+ required: true,
50+ content: {
51+ "application/json": {
52+ schema: {
53+ type: "object",
54+ properties: { text: { type: "string" } },
55+ required: ["text"],
56+ },
57+ },
58+ },
59+ },
60+ responses: { "200": { description: "ok" } },
61+ },
62+ },
63+ },
64+ });
65+
66+interface RecordedRequest {
67+ readonly method: string;
68+ readonly authorization: string | undefined;
69+}
70+
71+const serveRecordingUpstream = Effect.acquireRelease(
72+ Effect.callback<{
73+ readonly url: string;
74+ readonly requests: RecordedRequest[];
75+ close: () => void;
76+ }>((resume) => {
77+ const requests: RecordedRequest[] = [];
78+ const server = createServer((request, response) => {
79+ request.on("data", () => undefined);
80+ request.on("end", () => {
81+ requests.push({
82+ method: request.method ?? "",
83+ authorization: request.headers.authorization,
84+ });
85+ response.writeHead(200, { "content-type": "application/json" });
86+ response.end(JSON.stringify({ notes: [{ id: "note_0", text: "existing note" }] }));
87+ });
88+ });
89+ server.listen(0, "127.0.0.1", () => {
90+ const address = server.address();
91+ const port = typeof address === "object" && address ? address.port : 0;
92+ resume(
93+ Effect.succeed({
94+ url: `http://127.0.0.1:${port}`,
95+ requests,
96+ close: () => {
97+ server.close();
98+ server.closeAllConnections();
99+ },
100+ }),
101+ );
102+ });
103+ }),
104+ (upstream) => Effect.sync(() => upstream.close()),
105+);
106+
107+scenario(
108+ "Passthrough · the real OpenCode binary runs its own codemode over 10,200 Executor tools",
109+ { timeout: 900_000 },
110+ Effect.scoped(
111+ Effect.gen(function* () {
112+ const target = yield* Target;
113+ const opencode = yield* OpenCode;
114+ const runDir = yield* RunDir;
115+ const cli = yield* Cli;
116+ const { client: makeClient } = yield* Api;
117+
118+ const identity = yield* target.newIdentity();
119+ const email = identity.credentials?.email ?? identity.label;
120+ const client = yield* makeClient(catalogApi, identity);
121+ const upstream = yield* serveRecordingUpstream;
122+ const notesSlug = unique("notes");
123+
124+ // --- Seed: 10,198 discovery-mass tools + 2 callable ones. ---
125+ const seeded = yield* seedLargeCatalog(client, {
126+ syntheticIntegrations: SYNTHETIC_INTEGRATIONS,
127+ opsPerIntegration: OPS_PER_INTEGRATION,
128+ });
129+ const cleanup = Effect.gen(function* () {
130+ yield* client.connections
131+ .remove({
132+ params: {
133+ owner: "org",
134+ integration: IntegrationSlug.make(notesSlug),
135+ name: ConnectionName.make("main"),
136+ },
137+ })
138+ .pipe(Effect.ignore);
139+ yield* client.openapi.removeSpec({ params: { slug: notesSlug } }).pipe(Effect.ignore);
140+ yield* seeded.cleanup;
141+ });
142+
143+ yield* Effect.ensuring(
144+ Effect.gen(function* () {
145+ yield* client.openapi.addSpec({
146+ payload: {
147+ spec: { kind: "blob", value: notesSpec(upstream.url) },
148+ slug: notesSlug,
149+ baseUrl: upstream.url,
150+ authenticationTemplate: [
151+ {
152+ slug: "apiKey",
153+ type: "apiKey",
154+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
155+ },
156+ ],
157+ },
158+ });
159+ yield* client.connections.create({
160+ payload: {
161+ owner: "org",
162+ name: ConnectionName.make("main"),
163+ integration: IntegrationSlug.make(notesSlug),
164+ template: AuthTemplateSlug.make("apiKey"),
165+ value: "tok_notes",
166+ },
167+ });
168+ const visible = (yield* client.tools.list({ query: {} })).filter(
169+ (tool) => tool.static !== true,
170+ );
171+ expect(visible.length, "the catalog is exactly the advertised size").toBe(
172+ EXPECTED_TOOL_COUNT,
173+ );
174+
175+ // --- The replay brain: two scripted turns of OpenCode codemode. ---
176+ // Turn 0: discover the notes tool through Executor search.
177+ // Turn 1: call the path search returned. Turn 2: summarize, stop.
178+ let discoveredId: string | undefined;
179+ const brain = yield* serveReplayBrain((ctx) => {
180+ // OpenCode also asks the model for a session title, with no tools
181+ // offered. Answer it with text and keep it out of the script.
182+ if (ctx.toolNames.length === 0) return { text: "Notebook" };
183+ if (ctx.lastRole === "user") {
184+ return {
185+ text: "Searching the connected tools.",
186+ tool: {
187+ name: "execute",
188+ args: {
189+ code: `return await tools.${SERVER_NAME}.search({ query: "${notesSlug} listNotes", limit: 5 });`,
190+ },
191+ },
192+ };
193+ }
194+ if (discoveredId === undefined) {
195+ const result = ctx.lastToolResult ?? "";
196+ // Search returns an opaque tool ID for the next invoke call.
197+ const match = new RegExp(`"id":\\s*"([^"\\n]*${notesSlug}[^"]*listNotes)"`).exec(
198+ result,
199+ );
200+ if (!match) {
201+ throw new Error(`search did not surface the notes tool: ${result.slice(0, 600)}`);
202+ }
203+ discoveredId = match[1]!;
204+ return {
205+ text: "Found it. Listing the notes.",
206+ tool: {
207+ name: "execute",
208+ args: {
209+ code: `return await tools.${SERVER_NAME}.invoke({tool: ${JSON.stringify(discoveredId)}, arguments: {}});`,
210+ },
211+ },
212+ };
213+ }
214+ return { text: "The notebook has one existing note." };
215+ });
216+
217+ const passthroughUrl = new URL("/mcp?mode=passthrough", target.baseUrl).toString();
218+ const home = opencode.makeHome(SERVER_NAME, passthroughUrl, {
219+ chatBrainUrl: brain.baseUrl,
220+ });
221+ const env = {
222+ ...home.env,
223+ OPENCODE_EXPERIMENTAL_CODE_MODE: "true",
224+ PS1: "$ ",
225+ BASH_SILENCE_DEPRECATION_WARNING: "1",
226+ };
227+ // First-run database migration happens off camera.
228+ yield* Effect.sync(() => opencode.warmUp(home));
229+
230+ let connectMs = -1;
231+ yield* cli.session(
232+ ["bash", "--norc"],
233+ async (term) => {
234+ await term.screen.waitForText("$", { timeoutMs: 10_000 });
235+
236+ const outputAfter = (text: string, line: string): string | null => {
237+ const echoed = text.lastIndexOf(line);
238+ if (echoed === -1) return null;
239+ const after = text.slice(echoed + line.length);
240+ return after.trimEnd().endsWith("\n$") ? after : null;
241+ };
242+ const sh = async (line: string, timeoutMs: number) => {
243+ await term.keyboard.type(line);
244+ await term.keyboard.press("Enter");
245+ const snapshot = await term.screen.waitUntil(
246+ (current) => outputAfter(current.text, line) !== null,
247+ { timeoutMs },
248+ );
249+ return outputAfter(snapshot.text, line) ?? "";
250+ };
251+
252+ // OpenCode's own OAuth against the target: discovery, DCR, PKCE.
253+ const consent = opencode.completeOAuthConsent(home, email, home.openedUrls().length);
254+ const auth = await sh(`opencode mcp auth ${SERVER_NAME}`, 90_000);
255+ await consent;
256+ expect(auth, "opencode mcp auth completes").not.toContain("failed");
257+
258+ // The load-bearing connect: catalog loading and two MCP definitions
259+ // inside OpenCode's own 30s connect timeout.
260+ const startedAt = Date.now();
261+ const listed = await sh("opencode mcp list", 120_000);
262+ connectMs = Date.now() - startedAt;
263+ expect(
264+ listed,
265+ `OpenCode connects to the 10,200-tool passthrough endpoint (took ${connectMs}ms)`,
266+ ).toContain("connected");
267+
268+ // A real agent turn: OpenCode's codemode over our catalog.
269+ const ran = await sh(`opencode run "List the notes in my notebook"`, 300_000);
270+ expect(ran, "the run did not error").not.toContain("UnknownError");
271+ },
272+ {
273+ cwd: home.projectDir,
274+ env,
275+ record: join(runDir, "terminal.cast"),
276+ viewport: { cols: 100, rows: 40 },
277+ },
278+ );
279+
280+ // --- What OpenCode showed the model, and what came back. ---
281+ expect(brain.errors(), "the scripted brain hit no surprises").toEqual([]);
282+ // Only the turns that carried tools are the agent loop; the
283+ // title request is OpenCode housekeeping.
284+ const requests = brain.requests().filter((request) => request.toolNames.length > 0);
285+ expect(requests.length, "three model turns: search, call, summary").toBe(3);
286+
287+ // Codemode: the model sees ONE execute tool, not 10,200 functions.
288+ const offered = requests[0]!.toolNames;
289+ expect(offered, "OpenCode offers its codemode execute tool").toContain("execute");
290+ expect(
291+ offered.filter((name) => name.startsWith(`${SERVER_NAME}_`)),
292+ "no MCP tool is flattened into the model's tool list",
293+ ).toEqual([]);
294+ expect(
295+ offered.length,
296+ "the model's tool list stays small in front of a 10,200-tool server",
297+ ).toBeLessThan(40);
298+
299+ // Discovery returned the full underlying tool ID.
300+ expect(discoveredId, "search returned a tool ID").toBeDefined();
301+ expect(discoveredId, "the ID names the integration tool").toContain(notesSlug);
302+
303+ // The call went over the wire with the connection's credential and
304+ // the payload came back through OpenCode's interpreter.
305+ const lastToolResult = [...requests[2]!.messages]
306+ .reverse()
307+ .find((message) => message.role === "tool")?.content;
308+ expect(lastToolResult, "the executed program returned the upstream payload").toContain(
309+ "existing note",
310+ );
311+ const upstreamGet = upstream.requests.find((request) => request.method === "GET");
312+ expect(upstreamGet, "the GET reached the upstream").toBeDefined();
313+ expect(upstreamGet?.authorization, "the connection's credential was applied").toBe(
314+ "Bearer tok_notes",
315+ );
316+
317+ // Recorded for the run report; the hard bound is OpenCode's own
318+ // connect timeout, which `mcp list` above already proved.
319+ expect(connectMs, "connect stays inside OpenCode's timeout").toBeLessThan(
320+ OPENCODE_CONNECT_TIMEOUT_MS * 4,
321+ );
322+ }),
323+ cleanup,
324+ );
325+ }),
326+ ),
327+);
e2e/cloud/passthrough-scale.test.ts+75−0
@@ -0,0 +1,75 @@
1+// Large catalogs stay server-side and remain searchable within the connect timeout.
2+import { expect } from "@effect/vitest";
3+import { Effect } from "effect";
4+
5+import { decodeToolSearch } from "../scenarios/support/search-invoke";
6+
7+import { scenario } from "../src/scenario";
8+import { Api, Mcp, Target } from "../src/services";
9+import { catalogApi, seedLargeCatalog } from "../scenarios/support/large-catalog";
10+
11+// Catalog loading and the two-tool handshake must complete within 20 seconds.
12+const MAX_PASSTHROUGH_CONNECT_MS = 20_000;
13+
14+scenario(
15+ "Passthrough · a production-shaped catalog is served completely, in bounded time",
16+ { timeout: 300_000 },
17+ Effect.scoped(
18+ Effect.gen(function* () {
19+ const target = yield* Target;
20+ const mcp = yield* Mcp;
21+ const { client: makeClient } = yield* Api;
22+
23+ const identity = yield* target.newIdentity();
24+ const client = yield* makeClient(catalogApi, identity);
25+ const seeded = yield* seedLargeCatalog(client);
26+
27+ yield* Effect.ensuring(
28+ Effect.gen(function* () {
29+ // What the caller can see through the typed API is the ground truth
30+ // search results must cover completely — minus the plugins'
31+ // static configuration tools (`executor.*`, `openapi.addSpec`, …),
32+ // which are codemode affordances and deliberately not served here.
33+ const visible = (yield* client.tools.list({ query: {} })).filter(
34+ (tool) => tool.static !== true,
35+ );
36+ expect(visible.length, "the seeded catalog is large").toBeGreaterThan(3000);
37+
38+ const session = mcp.session(identity, { mode: "passthrough" });
39+ const startedAt = Date.now();
40+ const served = yield* session.describeTools();
41+ const elapsedMs = Date.now() - startedAt;
42+
43+ expect(
44+ elapsedMs,
45+ `a ${visible.length}-tool passthrough connect stays bounded (took ${elapsedMs}ms)`,
46+ ).toBeLessThan(MAX_PASSTHROUGH_CONNECT_MS);
47+
48+ expect(served.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]);
49+ const first = decodeToolSearch(
50+ (yield* session.call("search", { query: "org", limit: 20 })).raw,
51+ ).structuredContent;
52+ expect(first.total).toBe(visible.length);
53+ expect(first.items).toHaveLength(20);
54+ expect(first.nextOffset).toBe(20);
55+ const last = decodeToolSearch(
56+ (yield* session.call("search", { query: "org", offset: visible.length - 1 })).raw,
57+ ).structuredContent;
58+ expect(last.items).toHaveLength(1);
59+ expect(last.hasMore).toBe(false);
60+ expect(last.nextOffset).toBeNull();
61+ for (const slug of seeded.integrationSlugs) {
62+ const found = decodeToolSearch(
63+ (yield* session.call("search", { query: slug })).raw,
64+ ).structuredContent;
65+ expect(
66+ found.items.some((tool) => tool.integration === slug),
67+ `${slug} is discoverable`,
68+ ).toBe(true);
69+ }
70+ }),
71+ seeded.cleanup,
72+ );
73+ }),
74+ ),
75+);
e2e/scenarios/mcp-passthrough.test.ts+300−0
@@ -0,0 +1,300 @@
1+// Search discovers schemas; invoke reaches the upstream and enforces workspace blocks.
2+import { randomBytes } from "node:crypto";
3+import { createServer, type IncomingMessage } from "node:http";
4+
5+import { expect } from "@effect/vitest";
6+import { Effect } from "effect";
7+import { composePluginApi } from "@executor-js/api/server";
8+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
9+import {
10+ AuthTemplateSlug,
11+ ConnectionName,
12+ IntegrationSlug,
13+ ToolAddress,
14+} from "@executor-js/sdk/shared";
15+
16+import { decodeToolSearch } from "./support/search-invoke";
17+
18+import { scenario } from "../src/scenario";
19+import { Api, Mcp, Target } from "../src/services";
20+
21+const api = composePluginApi([openApiHttpPlugin()] as const);
22+
23+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
24+
25+/** A two-operation API: a read and a write, so the surface carries one tool
26+ * per policy outcome. The write takes a JSON body with a shared `$ref`, so
27+ * the advertised schema must be self-contained to be usable. */
28+const spec = (baseUrl: string): string =>
29+ JSON.stringify({
30+ openapi: "3.0.3",
31+ info: { title: "Passthrough API", version: "1.0.0" },
32+ servers: [{ url: baseUrl }],
33+ components: {
34+ schemas: {
35+ NewNote: {
36+ type: "object",
37+ properties: { text: { type: "string" } },
38+ required: ["text"],
39+ },
40+ },
41+ },
42+ paths: {
43+ "/notes": {
44+ get: {
45+ operationId: "listNotes",
46+ summary: "List notes",
47+ responses: { "200": { description: "ok" } },
48+ },
49+ post: {
50+ operationId: "createNote",
51+ summary: "Create a note",
52+ requestBody: {
53+ required: true,
54+ content: {
55+ "application/json": { schema: { $ref: "#/components/schemas/NewNote" } },
56+ },
57+ },
58+ responses: { "200": { description: "ok" } },
59+ },
60+ },
61+ },
62+ });
63+
64+interface RecordedRequest {
65+ readonly method: string;
66+ readonly path: string;
67+ readonly authorization: string | undefined;
68+ readonly body: string;
69+}
70+
71+/** A real upstream that records what reached it, so a passthrough call can be
72+ * proven to have gone over the wire with the connection's credential. */
73+const serveRecordingUpstream = Effect.acquireRelease(
74+ Effect.callback<{
75+ readonly url: string;
76+ readonly requests: RecordedRequest[];
77+ close: () => void;
78+ }>((resume) => {
79+ const requests: RecordedRequest[] = [];
80+ const readBody = (request: IncomingMessage) =>
81+ new Promise<string>((resolve) => {
82+ const chunks: Buffer[] = [];
83+ request.on("data", (chunk: Buffer) => chunks.push(chunk));
84+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
85+ });
86+ const server = createServer((request, response) => {
87+ void readBody(request).then((body) => {
88+ requests.push({
89+ method: request.method ?? "",
90+ path: request.url ?? "",
91+ authorization: request.headers.authorization,
92+ body,
93+ });
94+ response.writeHead(200, { "content-type": "application/json" });
95+ response.end(
96+ JSON.stringify(
97+ request.method === "POST"
98+ ? { id: "note_1", ...(body ? (JSON.parse(body) as object) : {}) }
99+ : { notes: [{ id: "note_0", text: "existing" }] },
100+ ),
101+ );
102+ });
103+ });
104+ server.listen(0, "127.0.0.1", () => {
105+ const address = server.address();
106+ const port = typeof address === "object" && address ? address.port : 0;
107+ resume(
108+ Effect.succeed({
109+ url: `http://127.0.0.1:${port}`,
110+ requests,
111+ close: () => {
112+ server.close();
113+ server.closeAllConnections();
114+ },
115+ }),
116+ );
117+ });
118+ }),
119+ (upstream) => Effect.sync(() => upstream.close()),
120+);
121+
122+const rawResultOf = (result: { readonly raw: unknown }) =>
123+ result.raw as {
124+ content?: ReadonlyArray<{ type: string; text?: string }>;
125+ structuredContent?: Record<string, unknown>;
126+ isError?: boolean;
127+ };
128+
129+scenario(
130+ "Passthrough · a session connected with mode=passthrough serves search and invoke with schemas and enforced blocks",
131+ { timeout: 180_000 },
132+ Effect.scoped(
133+ Effect.gen(function* () {
134+ const target = yield* Target;
135+ const mcp = yield* Mcp;
136+ const { client: makeClient } = yield* Api;
137+
138+ const identity = yield* target.newIdentity();
139+ const client = yield* makeClient(api, identity);
140+ const upstream = yield* serveRecordingUpstream;
141+ const slug = unique("ptapi");
142+ const otherSlug = unique("ptother");
143+
144+ yield* Effect.ensuring(
145+ Effect.gen(function* () {
146+ // Two integrations, one connection each: both must appear on the surface.
147+ for (const s of [slug, otherSlug]) {
148+ yield* client.openapi.addSpec({
149+ payload: {
150+ spec: { kind: "blob", value: spec(upstream.url) },
151+ slug: s,
152+ baseUrl: upstream.url,
153+ authenticationTemplate: [
154+ {
155+ slug: "apiKey",
156+ type: "apiKey",
157+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
158+ },
159+ ],
160+ },
161+ });
162+ yield* client.connections.create({
163+ payload: {
164+ owner: "org",
165+ name: ConnectionName.make("main"),
166+ integration: IntegrationSlug.make(s),
167+ template: AuthTemplateSlug.make("apiKey"),
168+ value: `tok_${s}`,
169+ },
170+ });
171+ }
172+
173+ const codemode = mcp.session(identity);
174+ expect(yield* codemode.listTools()).toContain("execute");
175+
176+ const passthrough = mcp.session(identity, { mode: "passthrough" });
177+ const described = yield* passthrough.describeTools();
178+ expect(described.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]);
179+ expect(described.find((tool) => tool.name === "search")?.annotations).toMatchObject({
180+ readOnlyHint: true,
181+ destructiveHint: false,
182+ });
183+ expect(described.find((tool) => tool.name === "invoke")?.annotations).toMatchObject({
184+ readOnlyHint: false,
185+ destructiveHint: true,
186+ });
187+ const found = decodeToolSearch(
188+ (yield* passthrough.call("search", { query: slug })).raw,
189+ ).structuredContent;
190+ const listDef = found.items.find((tool) => tool.id.endsWith(".listNotes"));
191+ const createDef = found.items.find((tool) => tool.id.endsWith(".createNote"));
192+ if (!listDef || !createDef) return yield* Effect.die("Search omitted notes operations");
193+ const listTool = listDef.id;
194+ const createTool = createDef.id;
195+ expect(listDef).toMatchObject({
196+ integration: slug,
197+ owner: "org",
198+ connection: "main",
199+ });
200+ const schemaView = yield* client.tools.schema({
201+ query: { address: ToolAddress.make(createTool) },
202+ });
203+ expect(createDef.annotations).toEqual(schemaView.annotations);
204+ expect(JSON.stringify(createDef.inputSchema)).toContain("text");
205+ const other = decodeToolSearch(
206+ (yield* passthrough.call("search", { query: otherSlug })).raw,
207+ ).structuredContent;
208+ expect(other.items.some((tool) => tool.integration === otherSlug)).toBe(true);
209+
210+ // --- A read call reaches the upstream with the connection's credential. ---
211+ const listed = yield* passthrough.call("invoke", { tool: listTool, arguments: {} });
212+ expect(listed.ok, `the read call completes: ${listed.text}`).toBe(true);
213+ expect(listed.text, "the upstream payload comes back").toContain("existing");
214+ const listReq = upstream.requests.find((r) => r.method === "GET");
215+ expect(listReq, "the GET reached the upstream").toBeDefined();
216+ expect(listReq?.authorization, "the connection's credential was applied").toBe(
217+ `Bearer tok_${slug}`,
218+ );
219+
220+ // --- An approval-gated call runs to completion: no pause, no resume. ---
221+ const created = yield* passthrough.call("invoke", {
222+ tool: createTool,
223+ arguments: { body: { text: "hello" } },
224+ });
225+ expect(created.ok, `the gated call completes without a pause: ${created.text}`).toBe(
226+ true,
227+ );
228+ expect(created.text, "the call did not pause").not.toContain("Execution paused");
229+ expect(created.text, "the call did not ask for a resume").not.toContain("executionId");
230+ const createReq = upstream.requests.find((r) => r.method === "POST");
231+ expect(createReq, "the POST reached the upstream").toBeDefined();
232+ expect(createReq?.body, "the JSON body went over the wire").toContain('"text":"hello"');
233+ expect(
234+ rawResultOf(created).structuredContent?.status,
235+ "the result is a completed execution",
236+ ).toBe("completed");
237+
238+ // --- Arguments are validated against the advertised schema. ---
239+ // An MCP error result must not reach the upstream.
240+ const invalid = yield* passthrough
241+ .call("invoke", { tool: createTool, arguments: { body: { wrong: 1 } } })
242+ .pipe(
243+ Effect.map((r) => r.ok),
244+ Effect.catchCause(() => Effect.succeed(false)),
245+ );
246+ expect(invalid, "a body missing its required field is refused").toBe(false);
247+ expect(
248+ upstream.requests.filter((r) => r.method === "POST").length,
249+ "the invalid call never reached the upstream",
250+ ).toBe(1);
251+
252+ // --- `block` is enforced on the list AND the call. ---
253+ const blockRule = yield* client.policies.create({
254+ payload: { owner: "org", pattern: `${slug}.*.*.*.createNote`, action: "block" },
255+ });
256+ yield* Effect.ensuring(
257+ Effect.gen(function* () {
258+ const afterBlock = mcp.session(identity, { mode: "passthrough" });
259+ const afterNames = decodeToolSearch(
260+ (yield* afterBlock.call("search", { query: slug })).raw,
261+ ).structuredContent.items.map((tool) => tool.id);
262+ expect(afterNames, "a blocked tool is not listed").not.toContain(createTool);
263+ expect(afterNames, "the unblocked sibling still is").toContain(listTool);
264+ // A client that cached the old name cannot call it either: the
265+ // executor refuses the call at invoke time, which passthrough
266+ // renders as an MCP error result.
267+ const stale = yield* passthrough.call("invoke", {
268+ tool: createTool,
269+ arguments: { body: { text: "again" } },
270+ });
271+ expect(stale.ok, "a blocked tool fails when called").toBe(false);
272+ expect(stale.text, "the failure names the policy").toContain("blocked");
273+ expect(
274+ upstream.requests.filter((r) => r.method === "POST").length,
275+ "the blocked call never reached the upstream",
276+ ).toBe(1);
277+ }),
278+ client.policies
279+ .remove({ params: { policyId: blockRule.id }, payload: { owner: "org" } })
280+ .pipe(Effect.ignore),
281+ );
282+ }),
283+ Effect.gen(function* () {
284+ for (const s of [slug, otherSlug]) {
285+ yield* client.connections
286+ .remove({
287+ params: {
288+ owner: "org",
289+ integration: IntegrationSlug.make(s),
290+ name: ConnectionName.make("main"),
291+ },
292+ })
293+ .pipe(Effect.ignore);
294+ yield* client.openapi.removeSpec({ params: { slug: s } }).pipe(Effect.ignore);
295+ }
296+ }),
297+ );
298+ }),
299+ ),
300+);
e2e/scenarios/support/search-invoke.ts+24−0
@@ -0,0 +1,24 @@
1+import { Schema } from "effect";
2+import { ToolAnnotationsView } from "@executor-js/sdk";
3+
4+/** Parse the public search result, including schemas and account identity. */
5+export const decodeToolSearch = Schema.decodeUnknownSync(
6+ Schema.Struct({
7+ structuredContent: Schema.Struct({
8+ items: Schema.Array(
9+ Schema.Struct({
10+ id: Schema.String,
11+ name: Schema.String,
12+ integration: Schema.String,
13+ owner: Schema.String,
14+ connection: Schema.String,
15+ inputSchema: Schema.Record(Schema.String, Schema.Unknown),
16+ annotations: Schema.optional(ToolAnnotationsView),
17+ }),
18+ ),
19+ total: Schema.Number,
20+ hasMore: Schema.Boolean,
21+ nextOffset: Schema.NullOr(Schema.Number),
22+ }),
23+ }),
24+);
e2e/src/surfaces/mcp.ts+42−6
@@ -100,6 +100,11 @@ export interface McpCallResult { · 99 unmodified lines
100100 export interface McpToolDef {
101101 readonly name: string;
102102 readonly description: string;
103+ /** The advertised MCP annotations (`readOnlyHint`, `destructiveHint`, …),
104+ * for scenarios that assert on what a harness's native approval reads. */
105+ readonly annotations?: Record<string, unknown>;
106+ /** The advertised input JSON Schema, verbatim. */
107+ readonly inputSchema?: unknown;
103108 }
104109
105110 /** How a connection surfaces a paused (approval-gated) execution. `browser` is
@@ -172,6 +177,9 @@ export interface McpSurface { · 66 unmodified lines
172177 * `search_<integration>` tools (`?search_tools=true`). Omitted means
173178 * the product default: none. */
174179 readonly searchTools?: boolean;
180+ /** `passthrough` serves search and invoke for the visible catalog
181+ * (`?mode=passthrough`). Omitted means the product default: codemode. */
182+ readonly mode?: "codemode" | "passthrough";
175183 readonly url?: string;
176184 },
177185 ) => McpSession;
@@ -315,6 +323,7 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 137 unmodified lines
315323 ...(options?.elicitationMode ? [`elicitation_mode=${options.elicitationMode}`] : []),
316324 ...(options?.artifacts === false ? ["artifacts=false"] : []),
317325 ...(options?.searchTools === true ? ["search_tools=true"] : []),
326+ ...(options?.mode === "passthrough" ? ["mode=passthrough"] : []),
318327 ].join("&");
319328 const sessionUrl = sessionQuery ? `${mcpUrl}?${sessionQuery}` : mcpUrl;
320329
@@ -358,6 +367,10 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 37 unmodified lines
358367 return listed.tools.map((tool) => ({
359368 name: tool.name,
360369 description: tool.description ?? "",
370+ ...(tool.annotations
371+ ? { annotations: tool.annotations as Record<string, unknown> }
372+ : {}),
373+ inputSchema: tool.inputSchema,
361374 }));
362375 }),
363376 call,
@@ -408,14 +421,37 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 44 unmodified lines
408421 return defs.map((tool: { name: string }) => tool.name);
409422 });
410423
424+ // mcporter's `listTools` projects annotations away, so read the raw
425+ // client it holds: the same connection (and cached OAuth), the full tool
426+ // definition.
411427 const describeTools = () =>
412428 Effect.promise(async (): Promise<ReadonlyArray<McpToolDef>> => {
413− const defs = await (await runtime()).listTools(serverName, callOptions);
414− connected = true;
415− return defs.map((tool: { name: string; description?: string }) => ({
416− name: tool.name,
417− description: tool.description ?? "",
418− }));
429+ const rt = await runtime();
430+ if (!connected) {
431+ await rt.listTools(serverName, callOptions);
432+ connected = true;
433+ }
434+ const context = await rt.connect(serverName, {
435+ allowCachedAuth: true,
436+ oauthSessionOptions: callOptions.oauthSessionOptions,
437+ });
438+ const out: McpToolDef[] = [];
439+ let cursor: string | undefined;
440+ do {
441+ const page = await context.client.listTools(cursor ? { cursor } : undefined);
442+ for (const tool of page.tools) {
443+ out.push({
444+ name: tool.name,
445+ description: tool.description ?? "",
446+ ...(tool.annotations
447+ ? { annotations: tool.annotations as Record<string, unknown> }
448+ : {}),
449+ inputSchema: tool.inputSchema,
450+ });
451+ }
452+ cursor = page.nextCursor ?? undefined;
453+ } while (cursor);
454+ return out;
419455 });
420456
421457 const call = (name: string, args: Record<string, unknown> = {}) =>
packages/core/api/src/server/mcp-build.ts+3−0
@@ -71,6 +71,7 @@ export const makeMcpBuildServer = · 70 unmodified lines
7171 engine,
7272 artifacts: executor.artifacts,
7373 connections: executor.connections,
74+ tools: executor.tools,
7475 ...(hostOptions?.loadAppShellHtml
7576 ? { loadAppShellHtml: hostOptions.loadAppShellHtml }
7677 : {}),
@@ -88,6 +89,8 @@ export const makeMcpBuildServer = · 11 unmodified lines
8889 ...(options ?? {}),
8990 }).pipe(
9091 Effect.withSpan("mcp.server.create"),
92+ // Catalog failures use the same retryable build envelope.
93+ Effect.mapError((cause) => new McpEngineBuildError({ cause })),
9194 Effect.map((mcpServer) => ({ mcpServer, engine, executor })),
9295 ),
9396 ),
packages/core/execution/src/tool-invoker.ts+1−1
@@ -672,7 +672,7 @@ const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResul · 671 unmodified lines
672672
673673 /** What `tools.search()` calls inside the sandbox. */
674674 export const searchTools = Effect.fn("executor.tools.search")(function* (
675− executor: Executor,
675+ executor: { readonly tools: Pick<Executor["tools"], "list"> },
676676 query: string,
677677 limit = 12,
678678 options?: { readonly namespace?: string; readonly offset?: number },
packages/core/sdk/src/elicitation.ts+10−0
@@ -48,11 +48,21 @@ export const ElicitationResponse = Schema.Struct({ · 47 unmodified lines
4848 });
4949 export type ElicitationResponse = typeof ElicitationResponse.Type;
5050
51+/** Who raised an elicitation. `"policy"` is the executor's own approval gate
52+ * (`enforceApproval`): a consent-only form whose terms are exactly "run this
53+ * tool with these arguments". `"tool"` is anything the tool itself asked for
54+ * mid-call, which may carry its own terms (a permanent site grant, a scope
55+ * choice) even when the schema is empty. A host that has already obtained
56+ * consent for the tool call may auto-accept the former and must never
57+ * auto-accept the latter. Absent means unknown, which reads as `"tool"`. */
58+export type ElicitationSource = "policy" | "tool";
59+
5160 /** Handler input — the tool address being invoked, its args, and the request. */
5261 export interface ElicitationContext {
5362 readonly address: ToolAddress;
5463 readonly args: unknown;
5564 readonly request: ElicitationRequest;
65+ readonly source?: ElicitationSource;
5666 }
5767
5868 /** Host-provided handler the SDK calls when a tool suspends for input. */
packages/core/sdk/src/executor.ts+2−1
@@ -6246,6 +6246,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6245 unmodified lines
62466246 address,
62476247 args,
62486248 request,
6249+ source: "tool",
62496250 });
62506251 if (response.action !== "accept") {
62516252 return yield* new ElicitationDeclinedError({
@@ -6287,7 +6288,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 35 unmodified lines
62876288 message: `${message}\n\nArguments:\n${approvalArgumentPreview(args)}`,
62886289 requestedSchema: { type: "object", properties: {} },
62896290 });
6290− const response = yield* handler({ address, args, request });
6291+ const response = yield* handler({ address, args, request, source: "policy" });
62916292 if (response.action !== "accept") {
62926293 return yield* new ElicitationDeclinedError({
62936294 address,
packages/core/sdk/src/host-internal.ts+3−0
@@ -58,3 +58,6 @@ export { · 57 unmodified lines
5858 type ExecutorFumaDb,
5959 type ExecutorFumaSchema,
6060 } from "./executor-fuma-db";
61+
62+// Reuse the SDK schema serializer at MCP boundaries.
63+export { reattachDefs } from "./schema-refs";
packages/core/sdk/src/index.ts+1−0
@@ -231,6 +231,7 @@ export { · 230 unmodified lines
231231 type ElicitationRequest,
232232 type ElicitationHandler,
233233 type ElicitationContext,
234+ type ElicitationSource,
234235 type OnElicitation,
235236 type InvokeOptions,
236237 } from "./elicitation";
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+9−0
@@ -23,6 +23,7 @@ import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; · 22 unmodified lines
2323 import {
2424 ResumeResponsePayload,
2525 decodeResumeResponse,
26+ type McpToolMode,
2627 } from "@executor-js/host-mcp/browser-approval";
2728
2829 import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";
@@ -77,6 +78,9 @@ interface McpSessionInitBase { · 48 unmodified lines
7778 * tools, read off `?search_tools=` at connect time. Absent means the
7879 * default (disabled). */
7980 readonly searchToolsEnabled?: boolean;
81+ /** The tool surface, read off `?mode=` at connect time. Absent means the
82+ * default (codemode). */
83+ readonly toolMode?: McpToolMode;
8084 /** The MCP resource the session was minted against (`/mcp` default vs a
8185 * `/mcp/toolkits/<slug>` toolkit), so the tool catalog is scoped to it. */
8286 readonly resource: McpResource;
@@ -151,6 +155,11 @@ interface SessionMetaBase { · 68 unmodified lines
151155 * {@link McpSessionInit}). Absent — including for sessions persisted before
152156 * the flag existed — means the default (disabled). */
153157 readonly searchToolsEnabled?: boolean;
158+ /** The tool surface (carried from {@link McpSessionInit}). Absent —
159+ * including for sessions persisted before the field existed — means
160+ * codemode. A cold restore MUST rebuild the same surface the client first
161+ * saw, or its cached tool names stop resolving mid-conversation. */
162+ readonly toolMode?: McpToolMode;
154163 /** The MCP resource the session serves (carried from {@link McpSessionInit});
155164 * `buildMcpServer` scopes the tool catalog to it. */
156165 readonly resource: McpResource;
packages/hosts/cloudflare/src/mcp/do-headers.ts+2−0
@@ -133,5 +133,7 @@ export { · 132 unmodified lines
133133 readArtifactsEnabled,
134134 readElicitationMode,
135135 readSearchToolsEnabled,
136+ readToolMode,
136137 type McpElicitationMode,
138+ type McpToolMode,
137139 } from "@executor-js/host-mcp/browser-approval";
packages/hosts/mcp/src/browser-approval.ts+15−0
@@ -79,6 +79,21 @@ export const readSearchToolsEnabled = (request: Request): boolean => { · 78 unmodified lines
7979 return TRUE_QUERY_VALUES.has(value.toLowerCase());
8080 };
8181
82+export type McpToolMode = "codemode" | "passthrough";
83+
84+/**
85+ * Read the tool surface mode off an MCP request's `?mode=` query. The default,
86+ * `codemode`, serves `execute` (the model writes sandboxed TypeScript against
87+ * `tools.*`). `?mode=passthrough` instead serves search and invoke, with no
88+ * `execute`, `skills`, or `resume`. Invoke is marked destructive for native
89+ * client approval. Any other value
90+ * reads as the default.
91+ */
92+export const readToolMode = (request: Request): McpToolMode => {
93+ const value = new URL(request.url).searchParams.get("mode");
94+ return value === "passthrough" ? "passthrough" : "codemode";
95+};
96+
8297 /**
8398 * Build the console approval URL for a paused execution:
8499 * `<origin>/<organizationSlug>/resume/<executionId>?mcp_session_id=<sessionId>`
packages/hosts/mcp/src/in-memory-session-store.ts+20−8
@@ -12,6 +12,8 @@ import { · 11 unmodified lines
1212 readArtifactsEnabled,
1313 readElicitationMode,
1414 readSearchToolsEnabled,
15+ readToolMode,
16+ type McpToolMode,
1517 } from "./browser-approval";
1618 import {
1719 makeInProcessBrowserApprovalStore,
@@ -31,7 +33,7 @@ import { · 13 unmodified lines
3133 type Principal,
3234 type McpResource,
3335 } from "./seams";
34−import type { BrowserApprovalStore } from "./tool-server";
36+import type { BrowserApprovalStore, McpPassthroughUnavailableError } from "./tool-server";
3537
3638 // ---------------------------------------------------------------------------
3739 // In-process McpSessionStore — the single-node serving store, shared by every
@@ -117,13 +119,15 @@ export interface McpBuildServerOptions { · 79 unmodified lines
117119 /** Whether this session serves the per-integration `search_<integration>`
118120 * tools. False unless the client connected with `?search_tools=true`. */
119121 readonly searchToolsEnabled?: boolean;
122+ /** The tool surface (`?mode=`): codemode (default) or passthrough. */
123+ readonly mode?: McpToolMode;
120124 }
121125
122126 /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */
123127 export type McpBuildServer = (
124128 principal: Principal,
125129 options?: McpBuildServerOptions,
126−) => Effect.Effect<BuiltMcpServer, McpEngineBuildError>;
130+) => Effect.Effect<BuiltMcpServer, McpEngineBuildError | McpPassthroughUnavailableError>;
127131
128132 export interface InMemoryMcpSessionStore {
129133 /** The `McpSessionStore` seam value to hand to `inMemoryMcpSessionsLayer`. */
@@ -414,13 +418,18 @@ export const makeInMemoryMcpSessionStore = ( · 284 unmodified lines
414418 ): McpBuildServerOptions => {
415419 const artifactsEnabled = readArtifactsEnabled(request);
416420 const searchToolsEnabled = readSearchToolsEnabled(request);
421+ const toolMode = readToolMode(request);
422+ const surface = {
423+ artifactsEnabled,
424+ searchToolsEnabled,
425+ mode: toolMode,
426+ };
417427 const mode = readElicitationMode(request);
418428 if (mode !== "browser") {
419− return { artifactsEnabled, searchToolsEnabled, elicitationMode: { mode } };
429+ return { ...surface, elicitationMode: { mode } };
420430 }
421431 return {
422− artifactsEnabled,
423− searchToolsEnabled,
432+ ...surface,
424433 elicitationMode: {
425434 mode: "browser",
426435 // Prefer the pinned public origin; fall back to the request URL (correct
@@ -489,9 +498,12 @@ export const makeInMemoryMcpSessionStore = ( · 62 unmodified lines
489498 }),
490499 ),
491500 // A build failure has nowhere typed to go in the envelope; render a 500.
492− Effect.catchTag("McpEngineBuildError", () =>
493− Effect.succeed(jsonRpcError(500, -32603, "Internal server error")),
494− ),
501+ Effect.catchTags({
502+ McpEngineBuildError: () =>
503+ Effect.succeed(jsonRpcError(500, -32603, "Internal server error")),
504+ McpPassthroughUnavailableError: () =>
505+ Effect.succeed(jsonRpcError(500, -32603, "Internal server error")),
506+ }),
495507 );
496508 };
497509
packages/hosts/mcp/src/passthrough-tools.test.ts+668−0
@@ -0,0 +1,668 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Schema } from "effect";
3+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5+import type * as Cause from "effect/Cause";
6+
7+import type { ExecutionEngine } from "@executor-js/execution";
8+import {
9+ ToolAddress,
10+ IntegrationSlug,
11+ ConnectionName,
12+ ToolName,
13+ type Tool,
14+ type ToolSchemaView,
15+} from "@executor-js/sdk";
16+
17+import { readToolMode } from "./browser-approval";
18+import { passthroughCallCode } from "./passthrough-tools";
19+import {
20+ createExecutorMcpServer,
21+ McpPassthroughUnavailableError,
22+ type ExecutorMcpServerConfig,
23+ type McpToolsPort,
24+} from "./tool-server";
25+
26+// ---------------------------------------------------------------------------
27+// Fixtures
28+// ---------------------------------------------------------------------------
29+
30+const projection = (input: {
31+ readonly integration: string;
32+ readonly name: string;
33+ readonly owner?: "org" | "user";
34+ readonly connection?: string;
35+ readonly description?: string;
36+ readonly inputSchema?: unknown;
37+ readonly requiresApproval?: boolean;
38+ readonly static?: boolean;
39+}): Tool => {
40+ const owner = input.owner ?? "org";
41+ const connection = input.connection ?? "main";
42+ return {
43+ address: ToolAddress.make(`tools.${input.integration}.${owner}.${connection}.${input.name}`),
44+ integration: IntegrationSlug.make(input.integration),
45+ owner,
46+ connection: ConnectionName.make(connection),
47+ name: ToolName.make(input.name),
48+ pluginId: "test",
49+ description: input.description ?? `${input.integration} ${input.name}`,
50+ ...(input.inputSchema === undefined ? {} : { inputSchema: input.inputSchema }),
51+ ...(input.requiresApproval === undefined
52+ ? {}
53+ : { annotations: { requiresApproval: input.requiresApproval } }),
54+ ...(input.static === undefined ? {} : { static: input.static }),
55+ };
56+};
57+
58+/** Exercise the existing list/schema seam and record which schemas were requested. */
59+const toolPort = (
60+ catalog: readonly Tool[],
61+ schemaReads: string[] = [],
62+ lists: string[] = [],
63+): McpToolsPort => ({
64+ list: () =>
65+ Effect.sync(() => {
66+ lists.push("list");
67+ return catalog;
68+ }),
69+ schema: (address) =>
70+ Effect.sync(() => {
71+ schemaReads.push(String(address));
72+ const tool = catalog.find((item) => item.address === address);
73+ if (!tool) return null;
74+ return {
75+ address,
76+ name: tool.name,
77+ description: tool.description,
78+ inputSchema: tool.inputSchema,
79+ annotations: tool.annotations,
80+ } satisfies ToolSchemaView;
81+ }),
82+});
83+
84+/** A stub engine that records every executed code string and answers with a
85+ * fixed value, so a test can prove a passthrough call became the expected
86+ * single-call code and took `execute` (never `executeWithPause`). */
87+const makeRecordingEngine = (result: unknown = { ok: true, data: { hello: "world" } }) => {
88+ const executed: string[] = [];
89+ let pausedCalls = 0;
90+ const engine: ExecutionEngine = {
91+ execute: (code) =>
92+ Effect.sync(() => {
93+ executed.push(code);
94+ return { result };
95+ }),
96+ executeWithPause: () =>
97+ Effect.sync(() => {
98+ pausedCalls += 1;
99+ return { status: "completed" as const, result: { result } };
100+ }),
101+ resume: () => Effect.succeed(null),
102+ isExecutionSettled: undefined,
103+ getPausedExecution: () => Effect.succeed(null),
104+ pausedExecutionCount: () => Effect.succeed(0),
105+ hasPausedExecutions: () => Effect.succeed(false),
106+ getDescription: Effect.succeed("test executor"),
107+ shutdown: Effect.void,
108+ };
109+ return { engine, executed, pausedCalls: () => pausedCalls };
110+};
111+
112+const withClient = async <E extends Cause.YieldableError>(
113+ config: ExecutorMcpServerConfig<E>,
114+ fn: (client: Client) => Promise<void>,
115+) => {
116+ const mcpServer = await Effect.runPromise(createExecutorMcpServer(config));
117+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
118+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
119+ await mcpServer.connect(serverTransport);
120+ await client.connect(clientTransport);
121+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper must close MCP transports after async client assertions
122+ try {
123+ await fn(client);
124+ } finally {
125+ await clientTransport.close();
126+ await serverTransport.close();
127+ }
128+};
129+
130+const decodeJsonString = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.String));
131+const decodeJsonRecord = Schema.decodeUnknownSync(
132+ Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
133+);
134+
135+const decodeSearchItems = Schema.decodeUnknownSync(
136+ Schema.Struct({ items: Schema.Array(Schema.Struct({ id: Schema.String })) }),
137+);
138+
139+const CATALOG: readonly Tool[] = [
140+ projection({
141+ integration: "github",
142+ name: "issues.create",
143+ requiresApproval: true,
144+ inputSchema: {
145+ type: "object",
146+ properties: { title: { type: "string" }, body: { $ref: "#/$defs/Body" } },
147+ required: ["title"],
148+ $defs: { Body: { type: "string" } },
149+ },
150+ }),
151+ projection({ integration: "github", name: "issues.list" }),
152+ projection({ integration: "linear", name: "issueCreate", requiresApproval: true }),
153+];
154+
155+describe("passthrough catalog", () => {
156+ it("emits exactly one awaited tool call with the whole address as one string literal", () => {
157+ expect(passthroughCallCode("tools.github.org.main.issues.create", { title: "hi" })).toBe(
158+ 'return await tools["github.org.main.issues.create"]({"title":"hi"});',
159+ );
160+ expect(passthroughCallCode("linear.org.main.issueCreate", undefined)).toBe(
161+ 'return await tools["linear.org.main.issueCreate"]({});',
162+ );
163+ // `then` is reserved by every sandbox proxy; as part of one key it is
164+ // just text, so such a tool stays callable.
165+ expect(passthroughCallCode("tools.svc.org.main.items.then", {})).toBe(
166+ 'return await tools["svc.org.main.items.then"]({});',
167+ );
168+ });
169+
170+ it("keeps a hostile tool segment as data, never as code", () => {
171+ // An OpenAPI spec controls its tool paths (`x-executor-toolPath`), so a
172+ // segment can contain anything. It must land inside a JSON string.
173+ const hostile = 'x"](await tools.victim.org.main.destroy({}))["';
174+ const code = passthroughCallCode(`tools.evil.org.main.${hostile}`, {});
175+ // Structural proof the payload never escapes the string literal: the
176+ // source is exactly `return await tools[<one JSON string>](<JSON>);`, and
177+ // that one string decodes back to the raw address.
178+ const shape = /^return await tools\[("(?:[^"\\]|\\.)*")\]\((\{.*\})\);$/s.exec(code);
179+ expect(shape).not.toBeNull();
180+ expect(decodeJsonString(shape![1]!)).toBe(`evil.org.main.${hostile}`);
181+ // And the call's argument is the JSON we passed, untouched by the address.
182+ expect(decodeJsonRecord(shape![2]!)).toEqual({});
183+ });
184+});
185+
186+// ---------------------------------------------------------------------------
187+// Wire flags
188+// ---------------------------------------------------------------------------
189+
190+describe("readToolMode", () => {
191+ const request = (query: string) => new Request(`https://example.test/mcp${query}`);
192+
193+ it("defaults to codemode and only accepts the exact passthrough spelling", () => {
194+ expect(readToolMode(request(""))).toBe("codemode");
195+ expect(readToolMode(request("?mode=passthrough"))).toBe("passthrough");
196+ expect(readToolMode(request("?mode=Passthrough"))).toBe("codemode");
197+ expect(readToolMode(request("?mode=direct"))).toBe("codemode");
198+ });
199+});
200+
201+// ---------------------------------------------------------------------------
202+// Server: the served surface
203+// ---------------------------------------------------------------------------
204+
205+describe("passthrough mode server", () => {
206+ it("serves exactly search and invoke even for 10000 tools", async () => {
207+ const { engine, executed } = makeRecordingEngine();
208+ const schemaReads: string[] = [];
209+ const lists: string[] = [];
210+ const catalog = Array.from({ length: 10000 }, (_, i) =>
211+ projection({
212+ integration: "bench",
213+ name: `record${i}`,
214+ description: i === 9999 ? "cobalt orchard sentinel" : `benchmark record ${i}`,
215+ }),
216+ );
217+ await withClient(
218+ {
219+ engine,
220+ mode: "passthrough",
221+ searchToolsEnabled: true,
222+ tools: toolPort(catalog, schemaReads, lists),
223+ },
224+ async (client) => {
225+ const listed = await client.listTools();
226+ expect(listed.tools.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]);
227+ expect(JSON.stringify(listed).length).toBeLessThan(4000);
228+ expect(lists).toEqual([]);
229+ expect(schemaReads).toEqual([]);
230+ const result = await client.callTool({
231+ name: "search",
232+ arguments: { query: "cobalt orchard sentinel" },
233+ });
234+ expect(result.structuredContent).toMatchObject({
235+ total: 1,
236+ hasMore: false,
237+ nextOffset: null,
238+ items: [{ id: "tools.bench.org.main.record9999" }],
239+ });
240+ expect(schemaReads).toEqual(["tools.bench.org.main.record9999"]);
241+ const found = decodeSearchItems(result.structuredContent).items[0];
242+ expect(found).toBeDefined();
243+ const invoked = await client.callTool({
244+ name: "invoke",
245+ arguments: { tool: found?.id, arguments: {} },
246+ });
247+ expect(invoked.isError ?? false).toBe(false);
248+ expect(executed).toEqual(['return await tools["bench.org.main.record9999"]({});']);
249+ const page = await client.callTool({
250+ name: "search",
251+ arguments: { query: "bench", limit: 2 },
252+ });
253+ expect(page.structuredContent).toMatchObject({
254+ total: 10000,
255+ hasMore: true,
256+ nextOffset: 2,
257+ });
258+ expect(decodeSearchItems(page.structuredContent).items).toHaveLength(2);
259+ expect(schemaReads).toHaveLength(4);
260+ const next = await client.callTool({
261+ name: "search",
262+ arguments: { query: "bench", limit: 2, offset: 2 },
263+ });
264+ expect(next.structuredContent).toMatchObject({ total: 10000, nextOffset: 4 });
265+ expect(next.structuredContent).not.toEqual(page.structuredContent);
266+ const missing = await client.callTool({
267+ name: "search",
268+ arguments: { query: "nonexistent quasar" },
269+ });
270+ expect(missing.structuredContent).toEqual({
271+ items: [],
272+ total: 0,
273+ hasMore: false,
274+ nextOffset: null,
275+ });
276+ },
277+ );
278+ });
279+
280+ it("uses current schemas and excludes static configuration tools", async () => {
281+ const recording = makeRecordingEngine();
282+ const dynamic = projection({
283+ integration: "notes",
284+ name: "create",
285+ inputSchema: { type: "object" },
286+ });
287+ const catalog = [dynamic, projection({ integration: "settings", name: "erase", static: true })];
288+ let current: ToolSchemaView = { address: dynamic.address, inputSchema: dynamic.inputSchema };
289+ const tools: McpToolsPort = { ...toolPort(catalog), schema: () => Effect.succeed(current) };
290+ await withClient({ engine: recording.engine, mode: "passthrough", tools }, async (client) => {
291+ const hidden = await client.callTool({ name: "search", arguments: { query: "settings" } });
292+ expect(decodeSearchItems(hidden.structuredContent).items).toEqual([]);
293+ const staticCall = await client.callTool({
294+ name: "invoke",
295+ arguments: { tool: "tools.settings.org.main.erase", arguments: {} },
296+ });
297+ expect(staticCall.isError).toBe(true);
298+ await client.callTool({ name: "search", arguments: { query: "notes" } });
299+ current = {
300+ address: dynamic.address,
301+ inputSchema: {
302+ type: "object",
303+ properties: { title: { type: "string" } },
304+ required: ["title"],
305+ },
306+ };
307+ const invalid = await client.callTool({
308+ name: "invoke",
309+ arguments: { tool: String(dynamic.address), arguments: {} },
310+ });
311+ expect(invalid.isError).toBe(true);
312+ expect(recording.executed).toEqual([]);
313+ const refreshed = await client.callTool({ name: "search", arguments: { query: "notes" } });
314+ expect(refreshed.structuredContent).toMatchObject({
315+ items: [{ inputSchema: { required: ["title"] } }],
316+ });
317+ });
318+ });
319+
320+ it("returns schemas and account details from search and marks invoke destructive", async () => {
321+ const { engine } = makeRecordingEngine();
322+ await withClient({ engine, mode: "passthrough", tools: toolPort(CATALOG) }, async (client) => {
323+ const listed = await client.listTools();
324+ expect(listed.tools.find((tool) => tool.name === "search")?.annotations).toMatchObject({
325+ readOnlyHint: true,
326+ destructiveHint: false,
327+ });
328+ expect(listed.tools.find((tool) => tool.name === "invoke")?.annotations).toMatchObject({
329+ readOnlyHint: false,
330+ destructiveHint: true,
331+ });
332+ const result = await client.callTool({
333+ name: "search",
334+ arguments: { query: "github issues create", limit: 1 },
335+ });
336+ expect(result.structuredContent).toMatchObject({
337+ items: [
338+ {
339+ id: "tools.github.org.main.issues.create",
340+ owner: "org",
341+ connection: "main",
342+ annotations: { requiresApproval: true },
343+ inputSchema: {
344+ type: "object",
345+ properties: { title: { type: "string" }, body: { $ref: "#/$defs/Body" } },
346+ required: ["title"],
347+ $defs: { Body: { type: "string" } },
348+ },
349+ },
350+ ],
351+ });
352+ });
353+ });
354+
355+ it("runs a call as one execute of single-call code, never a pause", async () => {
356+ const recording = makeRecordingEngine({ ok: true, data: { number: 7 } });
357+ await withClient(
358+ {
359+ engine: recording.engine,
360+ mode: "passthrough",
361+ tools: toolPort(CATALOG),
362+ },
363+ async (client) => {
364+ const result = await client.callTool({
365+ name: "invoke",
366+ arguments: { tool: "tools.github.org.main.issues.create", arguments: { title: "hello" } },
367+ });
368+ expect(recording.executed).toEqual([
369+ 'return await tools["github.org.main.issues.create"]({"title":"hello"});',
370+ ]);
371+ expect(recording.pausedCalls()).toBe(0);
372+ // The tool's `data` is the result: nothing sits between the tool and
373+ // the client to unwrap the `{ ok, data }` envelope for it.
374+ expect(result.isError ?? false).toBe(false);
375+ expect(result.structuredContent).toEqual({
376+ status: "completed",
377+ result: { number: 7 },
378+ logs: [],
379+ });
380+ },
381+ );
382+ });
383+
384+ it("surfaces an expected tool failure as an MCP error result", async () => {
385+ const recording = makeRecordingEngine({
386+ ok: false,
387+ error: { code: "tool_blocked", message: "Tool blocked by policy: github.org.main.x" },
388+ });
389+ await withClient(
390+ {
391+ engine: recording.engine,
392+ mode: "passthrough",
393+ tools: toolPort(CATALOG),
394+ },
395+ async (client) => {
396+ const result = await client.callTool({
397+ name: "invoke",
398+ arguments: { tool: "tools.github.org.main.issues.list", arguments: {} },
399+ });
400+ expect(result.isError).toBe(true);
401+ expect(result.structuredContent).toEqual({
402+ status: "error",
403+ error: { code: "tool_blocked", message: "Tool blocked by policy: github.org.main.x" },
404+ logs: [],
405+ });
406+ const text = (result.content as Array<{ type: string; text?: string }>)[0]?.text ?? "";
407+ expect(text).toContain("tool_blocked");
408+ },
409+ );
410+ });
411+
412+ /** An engine whose tool raises the given elicitations in order and records
413+ * each answer. `source` is what the executor stamps: `policy` for its own
414+ * approval gate, `tool` for anything the tool asked for itself. */
415+ const elicitingEngine = (
416+ requests: ReadonlyArray<{ readonly source: "policy" | "tool"; readonly request: any }>,
417+ seen: string[],
418+ ): ExecutionEngine => ({
419+ ...makeRecordingEngine().engine,
420+ execute: (_code, options) =>
421+ Effect.gen(function* () {
422+ for (const { source, request } of requests) {
423+ const answer = yield* options.onElicitation({
424+ address: CATALOG[0]!.address,
425+ args: {},
426+ request,
427+ source,
428+ });
429+ seen.push(`${source}:${answer.action}`);
430+ if (answer.action !== "accept") {
431+ return { result: { ok: false, error: { code: "declined", message: "declined" } } };
432+ }
433+ }
434+ return { result: { ok: true, data: null } };
435+ }),
436+ });
437+
438+ const approvalGate = {
439+ _tag: "FormElicitation" as const,
440+ message: "Approve github.org.main.issues.create?",
441+ requestedSchema: { type: "object", properties: {} },
442+ };
443+
444+ it("accepts the executor's own approval gate inline", async () => {
445+ const seen: string[] = [];
446+ await withClient(
447+ {
448+ engine: elicitingEngine([{ source: "policy", request: approvalGate }], seen),
449+ mode: "passthrough",
450+ tools: toolPort(CATALOG),
451+ },
452+ async (client) => {
453+ const result = await client.callTool({
454+ name: "invoke",
455+ arguments: { tool: "tools.github.org.main.issues.create", arguments: { title: "x" } },
456+ });
457+ expect(seen).toEqual(["policy:accept"]);
458+ expect(result.isError ?? false).toBe(false);
459+ },
460+ );
461+ });
462+
463+ it("keeps every invoke marked destructive even when the selected tool has no approval annotation", async () => {
464+ const seen: string[] = [];
465+ await withClient(
466+ {
467+ engine: elicitingEngine([{ source: "policy", request: approvalGate }], seen),
468+ mode: "passthrough",
469+ tools: toolPort(CATALOG),
470+ },
471+ async (client) => {
472+ const invoke = (await client.listTools()).tools.find((tool) => tool.name === "invoke");
473+ expect(invoke?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true });
474+ const result = await client.callTool({
475+ name: "invoke",
476+ arguments: { tool: "tools.github.org.main.issues.list", arguments: {} },
477+ });
478+ expect(seen).toEqual(["policy:accept"]);
479+ expect(result.isError ?? false).toBe(false);
480+ },
481+ );
482+ });
483+
484+ it("never auto-accepts a tool-raised prompt, even one with an empty schema", async () => {
485+ // Same wire shape as the approval gate, but raised by the TOOL: a
486+ // per-site grant whose terms live in `meta`. Provenance, not shape,
487+ // decides. With no elicitation capability on the client, the call fails
488+ // and says so — it is not silently granted.
489+ const seen: string[] = [];
490+ const siteGrant = {
491+ _tag: "FormElicitation" as const,
492+ message: "Allow Browser use to access example.com?",
493+ requestedSchema: {},
494+ meta: { persist: "always", origin: "https://example.com" },
495+ };
496+ await withClient(
497+ {
498+ engine: elicitingEngine([{ source: "tool", request: siteGrant }], seen),
499+ mode: "passthrough",
500+ tools: toolPort(CATALOG),
501+ },
502+ async (client) => {
503+ const result = await client.callTool({
504+ name: "invoke",
505+ arguments: { tool: "tools.github.org.main.issues.create", arguments: { title: "x" } },
506+ });
507+ expect(seen).toEqual(["tool:decline"]);
508+ expect(result.isError).toBe(true);
509+ expect(result.structuredContent).toMatchObject({
510+ status: "error",
511+ error: { code: "elicitation_unsupported", request: siteGrant.message },
512+ });
513+ },
514+ );
515+ });
516+
517+ it("reports an unanswerable URL request with the URL, not as a user decline", async () => {
518+ const seen: string[] = [];
519+ const reconnect = {
520+ _tag: "UrlElicitation" as const,
521+ message: "Reconnect GitHub",
522+ url: "https://example.test/oauth/start",
523+ elicitationId: "elic_1",
524+ };
525+ await withClient(
526+ {
527+ engine: elicitingEngine([{ source: "tool", request: reconnect }], seen),
528+ mode: "passthrough",
529+ tools: toolPort(CATALOG),
530+ },
531+ async (client) => {
532+ const result = await client.callTool({
533+ name: "invoke",
534+ arguments: { tool: "tools.github.org.main.issues.create", arguments: { title: "x" } },
535+ });
536+ expect(seen).toEqual(["tool:decline"]);
537+ expect(result.isError).toBe(true);
538+ const text = (result.content as Array<{ text?: string }>)[0]?.text ?? "";
539+ expect(text).toContain("does not support elicitation");
540+ expect(text).toContain("https://example.test/oauth/start");
541+ expect(text).not.toContain("declined by the user");
542+ expect(result.structuredContent).toMatchObject({
543+ error: { code: "elicitation_unsupported", url: reconnect.url },
544+ });
545+ },
546+ );
547+ });
548+
549+ it("serves no artifact tools in passthrough even when artifacts are requested", async () => {
550+ const { engine } = makeRecordingEngine();
551+ await withClient(
552+ {
553+ engine,
554+ mode: "passthrough",
555+ artifactsEnabled: true,
556+ loadAppShellHtml: async () => "<html></html>",
557+ artifacts: {
558+ list: () => Effect.succeed([]),
559+ get: () => Effect.die("unused"),
560+ save: () => Effect.die("unused"),
561+ },
562+ tools: toolPort(CATALOG),
563+ },
564+ async (client) => {
565+ const names = (await client.listTools()).tools.map((tool) => tool.name);
566+ expect(names.sort()).toEqual(["invoke", "search"]);
567+ },
568+ );
569+ });
570+
571+ it("rejects arguments that fail the advertised schema before running anything", async () => {
572+ const recording = makeRecordingEngine();
573+ await withClient(
574+ {
575+ engine: recording.engine,
576+ mode: "passthrough",
577+ tools: toolPort(CATALOG),
578+ },
579+ async (client) => {
580+ const result = await client.callTool({
581+ name: "invoke",
582+ arguments: {
583+ tool: "tools.github.org.main.issues.create",
584+ arguments: { body: "no title" },
585+ },
586+ });
587+ expect(result.isError).toBe(true);
588+ expect(JSON.stringify(result.content)).toContain("Invalid arguments");
589+ expect(recording.executed).toEqual([]);
590+ },
591+ );
592+ });
593+
594+ it("answers an unknown tool name with a not-found error", async () => {
595+ const { engine } = makeRecordingEngine();
596+ await withClient(
597+ {
598+ engine,
599+ mode: "passthrough",
600+ tools: toolPort(CATALOG),
601+ },
602+ async (client) => {
603+ const result = await client.callTool({
604+ name: "invoke",
605+ arguments: { tool: "tools.github.org.main.nope", arguments: {} },
606+ });
607+ expect(result.isError).toBe(true);
608+ expect(JSON.stringify(result.content)).toContain("not found");
609+ },
610+ );
611+ });
612+
613+ it("rejects empty searches, oversized pages and malformed invoke inputs", async () => {
614+ const recording = makeRecordingEngine();
615+ await withClient(
616+ {
617+ engine: recording.engine,
618+ mode: "passthrough",
619+ tools: toolPort(CATALOG),
620+ },
621+ async (client) => {
622+ for (const arguments_ of [
623+ { query: " " },
624+ { query: "github", limit: 21 },
625+ { query: "github", offset: -1 },
626+ ]) {
627+ expect((await client.callTool({ name: "search", arguments: arguments_ })).isError).toBe(
628+ true,
629+ );
630+ }
631+ expect(
632+ (
633+ await client.callTool({
634+ name: "invoke",
635+ arguments: { tool: "tools.github.org.main.issues.create", arguments: "{}" },
636+ })
637+ ).isError,
638+ ).toBe(true);
639+ expect(recording.executed).toEqual([]);
640+ },
641+ );
642+ });
643+
644+ it("leaves codemode untouched when the mode is absent", async () => {
645+ const { engine } = makeRecordingEngine();
646+ await withClient(
647+ {
648+ engine,
649+ description: "Execute TypeScript in a sandboxed runtime.",
650+ tools: toolPort(CATALOG),
651+ },
652+ async (client) => {
653+ const names = (await client.listTools()).tools.map((tool) => tool.name);
654+ expect(names).toContain("execute");
655+ expect(names).toContain("skills");
656+ expect(names).not.toContain("github__issues_create");
657+ },
658+ );
659+ });
660+
661+ it("fails the build, not the session, when the host provides no catalog", async () => {
662+ const { engine } = makeRecordingEngine();
663+ const outcome = await Effect.runPromise(
664+ createExecutorMcpServer({ engine, mode: "passthrough" }).pipe(Effect.flip),
665+ );
666+ expect(outcome).toBeInstanceOf(McpPassthroughUnavailableError);
667+ });
668+});
packages/hosts/mcp/src/passthrough-tools.ts+29−0
@@ -0,0 +1,29 @@
1+/**
2+ * The sandbox code a passthrough call runs. Built HERE from the session's
3+ * resolved address and a JSON-encoded argument — never concatenated from raw
4+ * model input — and shaped exactly like the artifact `execute-action` grammar
5+ * (`return await tools.<path>(<json>)`), so it takes the same engine path as
6+ * every other execution: billing, rate limits, shape memory and analytics all
7+ * see it as one execution.
8+ */
9+export const passthroughCallCode = (address: string, args: unknown): string => {
10+ // The whole dotted address is ONE JSON string literal in bracket notation:
11+ // `tools["github.org.main.items.then"](...)`. Two reasons it is not a chain
12+ // of property accesses. The tool segment is customer-controlled (an OpenAPI
13+ // spec may set `x-executor-toolPath`), so it must be data in the generated
14+ // source, never syntax. And every sandbox proxy reserves the property name
15+ // `then` (a thenable check would otherwise await the proxy itself), so a
16+ // per-segment chain could never reach a tool whose path contains `then`.
17+ // Each proxy joins the accessed keys with `.` to form the dispatch path, so
18+ // a single key holding the dotted address reassembles to exactly the same
19+ // path the chain would have.
20+ const bare = address.startsWith("tools.") ? address.slice("tools.".length) : address;
21+ return `return await tools[${JSON.stringify(bare)}](${JSON.stringify(args ?? {})});`;
22+};
23+
24+/** Describe the fixed search/invoke surface without listing the underlying catalog. */
25+export const passthroughInstructions = (): string =>
26+ "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. " +
28+ "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.";
packages/hosts/mcp/src/tool-server.ts+423−83
@@ -1,3 +1,4 @@
1+import { reattachDefs } from "@executor-js/sdk/host-internal";
12 import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect";
23 import * as Cause from "effect/Cause";
34 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -22,7 +23,10 @@ import * as z from "zod/v4"; · 18 unmodified lines
2223
2324 import {
2425 CurrentOrgWriteAccess,
26+ ToolAddress,
27+ parseToolAddress,
2528 isToolFile,
29+ isToolResult,
2630 makeOrgWriteAccessState,
2731 sanitizeArtifactPreviewMarkup,
2832 type OrgWriteAccess,
@@ -37,10 +41,13 @@ import type { · 8 unmodified lines
3741 ElicitationRequest,
3842 SaveArtifactInput,
3943 ToolFileValue,
44+ Executor,
45+ ToolSchemaView,
4046 } from "@executor-js/sdk";
4147 import type * as Tracer from "effect/Tracer";
4248 import {
4349 createExecutionEngine,
50+ searchTools,
4451 formatExecuteResult,
4552 formatPausedExecution,
4653 formatTtlDuration,
@@ -74,6 +81,8 @@ import { · 27 unmodified lines
7481 type BindableConnection,
7582 } from "./artifact-bindings";
7683 import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams";
84+import { passthroughCallCode, passthroughInstructions } from "./passthrough-tools";
85+import type { McpToolMode } from "./browser-approval";
7786
7887 // ---------------------------------------------------------------------------
7988 // Workers-compatible JSON Schema validator (replaces Ajv which uses new Function())
@@ -180,9 +189,26 @@ type SharedMcpServerConfig = { · 100 unmodified lines
180189 * the `execute` description lists). The tools exist to carry the namespaces
181190 * into the model's context as tool names; each call routes through the same
182191 * execution flow as `tools.search({ namespace })` inside `execute`, so the
183− * results match what code-side search returns.
192+ * results match what code-side search returns. Codemode only: passthrough
193+ * ignores it (it has its own search tool, and codemode search
194+ * results point at an `execute` tool passthrough does not serve).
184195 */
185196 readonly searchToolsEnabled?: boolean;
197+ /**
198+ * The tool surface this connection serves. `codemode` (the default) is the
199+ * `execute` tool plus `skills`/`resume` and the artifact surface.
200+ * `passthrough` (`?mode=passthrough`) serves search and invoke for the
201+ * visible catalog, with no execute, skills, resume, or artifact tools.
202+ * Invoke is marked destructive for client approval. Requires `tools`.
203+ */
204+ readonly mode?: McpToolMode;
205+ /**
206+ * The scoped executor's tool catalog, for passthrough mode. Structurally
207+ * satisfied by `executor.tools`. Hosts that never serve passthrough may
208+ * leave it unset; a passthrough session without it fails at build time
209+ * rather than silently serving an empty surface.
210+ */
211+ readonly tools?: McpToolsPort;
186212 /**
187213 * Renders an artifact once, server-side, before it is saved — so a component
188214 * that throws on its first render is refused at create time with the real
@@ -272,6 +298,15 @@ export type McpConnectionsPort = { · 83 unmodified lines
272298 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
273299 };
274300
301+/** The same list and schema APIs used by codemode discovery. */
302+export type McpToolsPort = Pick<Executor["tools"], "list" | "schema">;
303+
304+/** A passthrough session was requested but the host gave the factory no
305+ * catalog to serve. A configuration defect, not a runtime condition. */
306+export class McpPassthroughUnavailableError extends Data.TaggedError(
307+ "McpPassthroughUnavailableError",
308+)<{ readonly reason: string }> {}
309+
275310 export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
276311 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
277312 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
@@ -638,6 +673,64 @@ const toMcpResult = (result: FormattedExecuteInput): McpToolResult => { · 360 unmodified lines
638673 };
639674 };
640675
676+/**
677+ * A passthrough call's result IS the tool's `ToolResult`. Inside `execute`
678+ * the model reads `{ ok, data | error }` and branches; here nothing runs
679+ * between the tool and the client, so an expected failure (`ok: false` — a
680+ * 4xx wall, a blocked policy, a validation miss) has to be an MCP error
681+ * result, and a success unwraps to the tool's `data`. Everything else
682+ * (sandbox error, emitted output) keeps the codemode rendering.
683+ */
684+const toPassthroughResult = (outcome: FormattedExecuteInput): McpToolResult => {
685+ const value = outcome.result;
686+ if (outcome.error || !isToolResult(value)) return toMcpResult(outcome);
687+ if (value.ok) {
688+ return toMcpResult({ ...outcome, result: value.data });
689+ }
690+ const message = `${value.error.code}: ${value.error.message}`;
691+ return {
692+ content: [{ type: "text", text: `Error: ${message}` }],
693+ structuredContent: {
694+ status: "error",
695+ error: value.error,
696+ logs: outcome.logs ?? [],
697+ },
698+ isError: true,
699+ };
700+};
701+
702+/**
703+ * A passthrough tool asked the user for something and the connected client
704+ * advertises no elicitation capability, so nobody could answer. Say exactly
705+ * that, and carry the request — a reconnect/OAuth URL is the usual content —
706+ * so the model can relay it and the user can act outside the client.
707+ */
708+const elicitationUnsupportedResult = (
709+ toolName: string,
710+ request: ElicitationRequest,
711+): McpToolResult => {
712+ const url = elicitationRequestUrl(request);
713+ const lines = [
714+ `Tool ${toolName} needs input from the user, but this MCP client does not support elicitation, so the call could not complete.`,
715+ `Request: ${request.message}`,
716+ ...(url ? [`Open this URL to continue, then retry the call: ${url}`] : []),
717+ ];
718+ return {
719+ content: [{ type: "text", text: `Error: ${lines.join("\n")}` }],
720+ structuredContent: {
721+ status: "error",
722+ error: {
723+ code: "elicitation_unsupported",
724+ message: lines[0]!,
725+ request: request.message,
726+ ...(url ? { url } : {}),
727+ },
728+ logs: [],
729+ },
730+ isError: true,
731+ };
732+};
733+
641734 const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({
642735 content: [{ type: "text", text: formatted.text }],
643736 structuredContent: formatted.structured,
@@ -1104,13 +1197,174 @@ const parseJsonContent = (raw: string): Record<string, unknown> | undefined => { · 460 unmodified lines
11041197 return Option.isSome(parsed) ? parsed.value : undefined;
11051198 };
11061199
1200+// ---------------------------------------------------------------------------
1201+// Passthrough surface
1202+// ---------------------------------------------------------------------------
1203+
1204+/** Serialize one existing schema view as a self-contained MCP input schema. */
1205+const passthroughInputSchema = (view: ToolSchemaView): unknown =>
1206+ reattachDefs(
1207+ view.inputSchema ?? { type: "object", properties: {} },
1208+ new Map(Object.entries(view.schemaDefinitions ?? {})),
1209+ );
1210+
1211+/** Register discovery over the existing APIs, with no catalog work at connection time. */
1212+const registerPassthroughTools = <E extends Cause.YieldableError>(
1213+ server: McpServer,
1214+ tools: McpToolsPort,
1215+ run: (
1216+ address: ToolAddress,
1217+ args: unknown,
1218+ extra: McpRequestJoinKeys,
1219+ ) => Effect.Effect<McpToolResult, E>,
1220+): Effect.Effect<void> =>
1221+ Effect.gen(function* () {
1222+ const context = yield* Effect.context<never>();
1223+ const validator = new CfWorkerJsonSchemaValidator();
1224+ const discovery = {
1225+ tools: {
1226+ list: (filter?: Parameters<McpToolsPort["list"]>[0]) =>
1227+ tools
1228+ .list(filter)
1229+ .pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))),
1230+ },
1231+ };
1232+ const boundary = <A extends McpToolResult, F>(
1233+ effect: Effect.Effect<A, F>,
1234+ extra: McpRequestJoinKeys,
1235+ ) =>
1236+ Effect.runPromiseWith(context)(
1237+ effect.pipe(
1238+ Effect.provideService(
1239+ CurrentOrgWriteAccess,
1240+ makeOrgWriteAccessState(requestOrgWriteAccess(extra)),
1241+ ),
1242+ Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))),
1243+ ),
1244+ );
1245+ yield* Effect.sync(() => {
1246+ server.registerTool(
1247+ "search",
1248+ {
1249+ description:
1250+ "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.",
1251+ inputSchema: {
1252+ query: z
1253+ .string()
1254+ .trim()
1255+ .min(1)
1256+ .max(500)
1257+ .describe("Keywords describing the tool or task, such as github create issue."),
1258+ limit: z.number().int().min(1).max(20).default(10),
1259+ offset: z.number().int().min(0).default(0),
1260+ },
1261+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1262+ },
1263+ ({ query, limit, offset }, extra) =>
1264+ boundary(
1265+ Effect.gen(function* () {
1266+ const page = yield* searchTools(discovery, query, limit, { offset });
1267+ const candidates = yield* Effect.forEach(
1268+ page.items,
1269+ (match) =>
1270+ Effect.gen(function* () {
1271+ const address = ToolAddress.make(`tools.${match.path}`);
1272+ const identity = parseToolAddress(String(address));
1273+ if (!identity) return null;
1274+ const schema = yield* tools.schema(address);
1275+ // Visibility can change between listing and schema lookup.
1276+ if (!schema) return null;
1277+ return {
1278+ id: String(address),
1279+ name: match.name,
1280+ integration: identity.integration,
1281+ owner: identity.owner,
1282+ connection: identity.connection,
1283+ description: match.description,
1284+ inputSchema: passthroughInputSchema(schema),
1285+ ...(schema.annotations ? { annotations: schema.annotations } : {}),
1286+ };
1287+ }),
1288+ { concurrency: 4 },
1289+ );
1290+ const result = { ...page, items: candidates.filter(Predicate.isNotNull) };
1291+ return {
1292+ content: [{ type: "text" as const, text: JSON.stringify(result) }],
1293+ structuredContent: result,
1294+ };
1295+ }),
1296+ extra,
1297+ ),
1298+ );
1299+ server.registerTool(
1300+ "invoke",
1301+ {
1302+ description:
1303+ "Call one connected integration tool using the exact ID and JSON input schema returned by search. May read or change external state. Your client handles approval for this call; workspace blocks remain enforced.",
1304+ inputSchema: {
1305+ tool: z.string().min(1).describe("Exact tool ID returned by search."),
1306+ arguments: z
1307+ .record(z.string(), z.unknown())
1308+ .describe("Tool arguments matching the inputSchema returned by search."),
1309+ },
1310+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
1311+ },
1312+ ({ tool: id, arguments: args }, extra) =>
1313+ boundary(
1314+ Effect.gen(function* () {
1315+ const identity = parseToolAddress(id);
1316+ const unavailable = {
1317+ isError: true,
1318+ content: [
1319+ {
1320+ type: "text" as const,
1321+ text: "Tool not found or blocked by policy. Search for an available tool.",
1322+ },
1323+ ],
1324+ };
1325+ if (!identity) return unavailable;
1326+ const address = ToolAddress.make(id);
1327+ // Use the existing visibility filter and exclude static configuration tools.
1328+ const visible = yield* discovery.tools.list({
1329+ integration: identity.integration,
1330+ owner: identity.owner,
1331+ connection: identity.connection,
1332+ query: String(identity.tool),
1333+ includeAnnotations: false,
1334+ });
1335+ if (!visible.some((tool) => tool.address === address)) return unavailable;
1336+ const schema = yield* tools.schema(address);
1337+ if (!schema) return unavailable;
1338+ // The SDK validator checks this dynamic JSON schema at the MCP boundary.
1339+ const validate = validator.getValidator<unknown>(
1340+ passthroughInputSchema(schema) as JsonSchemaType,
1341+ );
1342+ const checked = validate(args);
1343+ if (!checked.valid)
1344+ return {
1345+ isError: true,
1346+ content: [
1347+ {
1348+ type: "text" as const,
1349+ text: `Invalid arguments for tool ${id}: ${checked.errorMessage ?? "invalid"}`,
1350+ },
1351+ ],
1352+ };
1353+ return yield* run(address, checked.data, extra);
1354+ }),
1355+ extra,
1356+ ),
1357+ );
1358+ });
1359+ }).pipe(Effect.withSpan("mcp.host.register_search_invoke"));
1360+
11071361 // ---------------------------------------------------------------------------
11081362 // Server factory
11091363 // ---------------------------------------------------------------------------
11101364
11111365 export const createExecutorMcpServer = <E extends Cause.YieldableError>(
11121366 config: ExecutorMcpServerConfig<E>,
1113−): Effect.Effect<McpServer> =>
1367+): Effect.Effect<McpServer, McpPassthroughUnavailableError> =>
11141368 Effect.gen(function* () {
11151369 const engine = "engine" in config ? config.engine : createExecutionEngine(config);
11161370 const description =
@@ -1122,11 +1376,24 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 5 unmodified lines
11221376 // Artifacts are on unless this connection opted out (`?artifacts=false`).
11231377 // One flag decides the whole surface: the tools, the shell resource, and
11241378 // the skills catalog below.
1125− const artifactsEnabled = config.artifactsEnabled ?? true;
1379+ // Search/invoke serves no artifact tools: artifacts run sandboxed code.
1380+ const artifactsEnabled =
1381+ config.mode === "passthrough" ? false : (config.artifactsEnabled ?? true);
11261382 const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled });
11271383 // Per-integration search tools are off unless this connection opted in
11281384 // (`?search_tools=true`).
11291385 const searchToolsEnabled = config.searchToolsEnabled ?? false;
1386+ // Passthrough (`?mode=passthrough`) replaces the codemode surface
1387+ // wholesale. The flag is read once here and every codemode-only
1388+ // registration below is gated on it, so the two surfaces cannot leak into
1389+ // each other.
1390+ const mode: McpToolMode = config.mode ?? "codemode";
1391+ const passthrough = mode === "passthrough";
1392+ if (passthrough && !config.tools) {
1393+ return yield* new McpPassthroughUnavailableError({
1394+ reason: "passthrough mode requires tool list and schema APIs",
1395+ });
1396+ }
11301397
11311398 // Captured at construction time. SDK callbacks fire later (often
11321399 // deferred past the outer Effect's await), so we use the runtime to
@@ -1227,6 +1494,11 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 94 unmodified lines
12271494 // per host.
12281495 capabilities: { resources: {}, tools: {} },
12291496 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
1497+ ...(passthrough
1498+ ? {
1499+ instructions: passthroughInstructions(),
1500+ }
1501+ : {}),
12301502 },
12311503 ),
12321504 ).pipe(Effect.withSpan("mcp.host.create_server"));
@@ -1545,103 +1817,171 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 312 unmodified lines
15451817 Effect.annotateSpans(joinKeyAttributes(extra)),
15461818 );
15471819
1820+ // --- passthrough call path ---
1821+ //
1822+ // Invoke runs one generated call through the existing execution engine.
1823+ // The client approves the generic destructive tool; upstream prompts use
1824+ // native elicitation, or fail with an actionable result when unsupported.
1825+ const executePassthroughCall = (
1826+ address: ToolAddress,
1827+ args: unknown,
1828+ extra: McpRequestJoinKeys,
1829+ ): Effect.Effect<McpToolResult, E> =>
1830+ Effect.gen(function* () {
1831+ yield* startMarker("mcp.host.tool.execute.start", {
1832+ "mcp.tool.id": String(address),
1833+ "mcp.tool.mode": "passthrough",
1834+ "executor.tool.address": address,
1835+ });
1836+ const { url: supportsUrl } = getElicitationSupport(server);
1837+ const native = makeMcpElicitationHandler(server, extra.requestId, debugLog);
1838+ const { form: supportsForm } = getElicitationSupport(server);
1839+ // Set when the tool asked the user for something this client cannot
1840+ // relay. The handler has no error channel (a non-accept is a decline
1841+ // to the executor), so the request is kept here and the whole call is
1842+ // reported as unanswerable below — with what was asked, URL included —
1843+ // instead of as "declined by the user", which nobody did.
1844+ let unanswerable: ElicitationRequest | undefined;
1845+ const onElicitation: ElicitationHandler = (ctx) => {
1846+ // Every invoke is advertised as destructive, so the client's native
1847+ // approval covers the selected ID and arguments, even if policy changed.
1848+ // Tool-raised prompts still require their own response below.
1849+ if (ctx.source === "policy") {
1850+ return Effect.succeed({ action: "accept" as const, content: {} });
1851+ }
1852+ // Anything the tool itself asked for goes to the client natively
1853+ // when it can take it; the native bridge already turns a URL
1854+ // request into a form for form-only clients.
1855+ if (supportsForm || (supportsUrl && Predicate.isTagged(ctx.request, "UrlElicitation"))) {
1856+ return native(ctx);
1857+ }
1858+ unanswerable = ctx.request;
1859+ return Effect.succeed({ action: "decline" as const });
1860+ };
1861+ const outcome = yield* engine.execute(passthroughCallCode(address, args), {
1862+ onElicitation,
1863+ });
1864+ if (unanswerable) return elicitationUnsupportedResult(String(address), unanswerable);
1865+ return toPassthroughResult(outcome);
1866+ }).pipe(
1867+ Effect.withSpan("mcp.host.tool.execute", {
1868+ attributes: {
1869+ "mcp.tool.id": String(address),
1870+ "mcp.tool.mode": "passthrough",
1871+ "executor.integration": parseToolAddress(String(address))?.integration,
1872+ },
1873+ }),
1874+ Effect.annotateSpans(joinKeyAttributes(extra)),
1875+ );
1876+
15481877 // --- tools ---
15491878
1550− yield* Effect.sync(() =>
1551− server.registerTool(
1552− "execute",
1553− {
1554− description,
1555− inputSchema: { code: z.string().trim().min(1) },
1556− },
1557− ({ code }, extra) => runToolEffect(executeCode(code, extra), extra),
1558− ),
1559− ).pipe(
1560− Effect.withSpan("mcp.host.register_tool", {
1561− attributes: { "mcp.tool.name": "execute" },
1562− }),
1563− );
1879+ // Passthrough serves search and invoke in place of the codemode tools.
1880+ if (passthrough && config.tools) {
1881+ yield* registerPassthroughTools(server, config.tools, executePassthroughCall);
1882+ }
15641883
1565− yield* Effect.sync(() =>
1566− server.registerTool(
1567− "skills",
1568− {
1569− description: [
1570− "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.",
1571− "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
1572− '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).',
1573− "Call with no name to list the few docs available.",
1574− ].join("\n"),
1575− inputSchema: {
1576− name: z
1577− .string()
1578− .optional()
1579− .describe(
1580− 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.',
1581− ),
1884+ if (!passthrough)
1885+ yield* Effect.sync(() =>
1886+ server.registerTool(
1887+ "execute",
1888+ {
1889+ description,
1890+ inputSchema: { code: z.string().trim().min(1) },
15821891 },
1583− },
1584− ({ name }, extra) =>
1585− runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra),
1586− ),
1587− ).pipe(
1588− Effect.withSpan("mcp.host.register_tool", {
1589− attributes: { "mcp.tool.name": "skills" },
1590− }),
1591− );
1592−
1593− yield* Effect.sync(() => {
1594− if (elicitationMode.mode === "native") {
1595− return undefined;
1596− }
1892+ ({ code }, extra) => runToolEffect(executeCode(code, extra), extra),
1893+ ),
1894+ ).pipe(
1895+ Effect.withSpan("mcp.host.register_tool", {
1896+ attributes: { "mcp.tool.name": "execute" },
1897+ }),
1898+ );
15971899
1598− if (elicitationMode.mode === "model") {
1599− return server.registerTool(
1600− "resume",
1900+ if (!passthrough)
1901+ yield* Effect.sync(() =>
1902+ server.registerTool(
1903+ "skills",
16011904 {
16021905 description: [
1603− "Resume a paused execution using the executionId returned by execute.",
1604− "This connection explicitly allows model-side resume via elicitation_mode=model.",
1906+ "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.",
1907+ "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
1908+ '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).',
1909+ "Call with no name to list the few docs available.",
16051910 ].join("\n"),
16061911 inputSchema: {
1607− executionId: z.string().describe("The execution ID from the paused result"),
1608− action: z
1609− .enum(["accept", "decline", "cancel"])
1610− .describe("How to respond to the interaction"),
1611− content: z
1912+ name: z
16121913 .string()
1613− .describe("Optional JSON-encoded response content for form elicitations")
1614− .default("{}"),
1914+ .optional()
1915+ .describe(
1916+ 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.',
1917+ ),
16151918 },
16161919 },
1617− ({ executionId, action, content: rawContent }, extra) =>
1920+ ({ name }, extra) =>
16181921 runToolEffect(
1619− resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
1922+ Effect.succeed(skillsResult(name, executeInventory, skillCatalog)),
16201923 extra,
16211924 ),
1622− );
1623− }
1925+ ),
1926+ ).pipe(
1927+ Effect.withSpan("mcp.host.register_tool", {
1928+ attributes: { "mcp.tool.name": "skills" },
1929+ }),
1930+ );
16241931
1625− return server.registerTool(
1626− "resume",
1627− {
1628− description: [
1629− "Request user approval to resume a paused execution.",
1630− "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.",
1631− "This connection does not allow the model to choose accept, decline, cancel, or content.",
1632− ].join("\n"),
1633− inputSchema: {
1634− executionId: z.string().describe("The execution ID from the paused result"),
1932+ if (!passthrough)
1933+ yield* Effect.sync(() => {
1934+ if (elicitationMode.mode === "native") {
1935+ return undefined;
1936+ }
1937+
1938+ if (elicitationMode.mode === "model") {
1939+ return server.registerTool(
1940+ "resume",
1941+ {
1942+ description: [
1943+ "Resume a paused execution using the executionId returned by execute.",
1944+ "This connection explicitly allows model-side resume via elicitation_mode=model.",
1945+ ].join("\n"),
1946+ inputSchema: {
1947+ executionId: z.string().describe("The execution ID from the paused result"),
1948+ action: z
1949+ .enum(["accept", "decline", "cancel"])
1950+ .describe("How to respond to the interaction"),
1951+ content: z
1952+ .string()
1953+ .describe("Optional JSON-encoded response content for form elicitations")
1954+ .default("{}"),
1955+ },
1956+ },
1957+ ({ executionId, action, content: rawContent }, extra) =>
1958+ runToolEffect(
1959+ resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
1960+ extra,
1961+ ),
1962+ );
1963+ }
1964+
1965+ return server.registerTool(
1966+ "resume",
1967+ {
1968+ description: [
1969+ "Request user approval to resume a paused execution.",
1970+ "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.",
1971+ "This connection does not allow the model to choose accept, decline, cancel, or content.",
1972+ ].join("\n"),
1973+ inputSchema: {
1974+ executionId: z.string().describe("The execution ID from the paused result"),
1975+ },
16351976 },
1636− },
1637− ({ executionId }, extra) =>
1638− runToolEffect(resumeAfterBrowserApproval(executionId, extra), extra),
1977+ ({ executionId }, extra) =>
1978+ runToolEffect(resumeAfterBrowserApproval(executionId, extra), extra),
1979+ );
1980+ }).pipe(
1981+ Effect.withSpan("mcp.host.register_tool", {
1982+ attributes: { "mcp.tool.name": "resume" },
1983+ }),
16391984 );
1640− }).pipe(
1641− Effect.withSpan("mcp.host.register_tool", {
1642− attributes: { "mcp.tool.name": "resume" },
1643− }),
1644− );
16451985
16461986 // --- per-integration search tools (opt-in, `?search_tools=true`) ---
16471987 //
@@ -1659,7 +1999,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 11 unmodified lines
16591999 // would only repeat the name) and a single bare `query` parameter — no
16602000 // paging knobs, because anything past the first page belongs in `execute`.
16612001 // `namespace-search-tools.test.ts` pins the serialized size.
1662− if (searchToolsEnabled) {
2002+ if (searchToolsEnabled && !passthrough) {
16632003 // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already
16642004 // conform (they are `tools.<slug>` property names in sandbox code); one
16652005 // that somehow doesn't is skipped rather than failing the whole session.
packages/react/src/api/analytics.tsx+1−0
@@ -169,6 +169,7 @@ export interface AnalyticsEvents { · 168 unmodified lines
169169 mcp_install_elicitation_mode_changed: { elicitation_mode: string };
170170 mcp_install_artifacts_toggled: { artifacts: boolean };
171171 mcp_install_search_tools_toggled: { search_tools: boolean };
172+ mcp_install_tool_mode_changed: { tool_mode: "codemode" | "passthrough" };
172173
173174 // ── Command palette ──────────────────────────────────────────────────────
174175 command_palette_navigated: {
packages/react/src/components/mcp-install-card.tsx+54−10
@@ -26,6 +26,9 @@ const McpInstallPreferencesSchema = Schema.Struct({ · 25 unmodified lines
2626 httpElicitationMode: Schema.Literals(["browser", "model", "native"]),
2727 artifacts: Schema.Boolean,
2828 searchTools: Schema.Boolean,
29+ // Added after v1 shipped; optional so a stored preference from before it
30+ // existed still decodes and takes the default (codemode).
31+ toolMode: Schema.optional(Schema.Literals(["codemode", "passthrough"])),
2932 });
3033
3134 type McpInstallPreferences = typeof McpInstallPreferencesSchema.Type;
@@ -52,6 +55,7 @@ const DEFAULT_MCP_INSTALL_PREFERENCES: McpInstallPreferences = { · 20 unmodified lines
5255 httpElicitationMode: "model",
5356 artifacts: true,
5457 searchTools: false,
58+ toolMode: "codemode",
5559 };
5660 const decodeMcpInstallPreferences = Schema.decodeUnknownOption(
5761 Schema.fromJsonString(McpInstallPreferencesSchema),
@@ -116,6 +120,9 @@ export const buildMcpHttpEndpoint = (input: { · 58 unmodified lines
116120 /** Per-integration search tools are off by default, so only the opt-in is
117121 * spelled out on the URL (`&search_tools=true`). */
118122 readonly searchTools?: boolean;
123+ /** Codemode is the default, so only passthrough is spelled out on the URL
124+ * (`&mode=passthrough`). */
125+ readonly toolMode?: "codemode" | "passthrough";
119126 // Cloud only: pins the URL to `/<org-slug>/mcp` (the server also accepts the
120127 // legacy `/<org_id>/mcp` form). Desktop/local pass nothing and get the bare
121128 // `/mcp` path.
@@ -137,7 +144,12 @@ export const buildMcpHttpEndpoint = (input: { · 15 unmodified lines
137144 params.push(["elicitation_mode", input.elicitationMode]);
138145 }
139146 if (input.artifacts === false) params.push(["artifacts", "false"]);
140− if (input.searchTools === true) params.push(["search_tools", "true"]);
147+ // Per-integration search tools and artifacts are codemode affordances; passthrough serves
148+ // neither, so the URL never claims them alongside it.
149+ if (input.searchTools === true && input.toolMode !== "passthrough") {
150+ params.push(["search_tools", "true"]);
151+ }
152+ if (input.toolMode === "passthrough") params.push(["mode", "passthrough"]);
141153 if (params.length === 0) return endpoint;
142154
143155 const query = params.map(([key, value]) => `${key}=${value}`).join("&");
@@ -160,6 +172,7 @@ export const buildMcpInstallCommand = (input: { · 16 unmodified lines
160172 readonly elicitationMode?: McpElicitationMode;
161173 readonly artifacts?: boolean;
162174 readonly searchTools?: boolean;
175+ readonly toolMode?: "codemode" | "passthrough";
163176 readonly devCliCwd?: string;
164177 readonly organizationSlug?: string | null;
165178 }): string => {
@@ -170,6 +183,7 @@ export const buildMcpInstallCommand = (input: { · 4 unmodified lines
170183 elicitationMode: input.elicitationMode,
171184 artifacts: input.artifacts,
172185 searchTools: input.searchTools,
186+ toolMode: input.toolMode,
173187 organizationSlug: input.organizationSlug,
174188 });
175189 const headerFlags: string[] = [];
@@ -197,9 +211,12 @@ export const buildMcpInstallCommand = (input: { · 21 unmodified lines
197211 if (input.artifacts === false) {
198212 innerArgs.push("--no-artifacts");
199213 }
200− if (input.searchTools === true) {
214+ if (input.searchTools === true && input.toolMode !== "passthrough") {
201215 innerArgs.push("--search-tools");
202216 }
217+ if (input.toolMode === "passthrough") {
218+ innerArgs.push("--mode", "passthrough");
219+ }
203220 return `npx add-mcp ${shellQuoteWord(innerArgs.map(shellQuoteWord).join(" "))} --name executor`;
204221 };
205222
@@ -227,6 +244,7 @@ export function McpInstallCard(props: { className?: string }) { · 21 unmodified lines
227244 }
228245 const [advancedOpen, setAdvancedOpen] = useState(false);
229246 const { mode, httpElicitationMode, artifacts, searchTools } = preferences;
247+ const toolMode = preferences.toolMode ?? "codemode";
230248
231249 useEffect(() => {
232250 writeMcpInstallPreferences(storageKey, preferences);
@@ -268,6 +286,7 @@ export function McpInstallCard(props: { className?: string }) { · 35 unmodified lines
268286 elicitationMode,
269287 artifacts,
270288 searchTools,
289+ toolMode,
271290 devCliCwd,
272291 organizationSlug,
273292 });
@@ -290,16 +309,38 @@ export function McpInstallCard(props: { className?: string }) { · 16 unmodified lines
290309 </CollapsibleTrigger>
291310 <CollapsibleContent>
292311 <div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-muted/25 p-3 sm:flex-row sm:items-center sm:justify-between">
312+ <div className="min-w-0">
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">
315+ {toolMode === "passthrough"
316+ ? "Find connected tools with search, then call them with invoke. Your client handles approval for each call."
317+ : "Disabled: agents write code against your tools through one execute tool."}
318+ </div>
319+ </div>
320+ <Switch
321+ checked={toolMode === "passthrough"}
322+ onCheckedChange={(next) => {
323+ const nextMode = next ? "passthrough" : "codemode";
324+ setPreferences((current) => ({ ...current, toolMode: nextMode }));
325+ trackEvent("mcp_install_tool_mode_changed", { tool_mode: nextMode });
326+ }}
327+ aria-label="Search and invoke"
328+ />
329+ </div>
330+ <div className="mt-2 flex flex-col gap-2 rounded-md border border-border bg-muted/25 p-3 sm:flex-row sm:items-center sm:justify-between">
293331 <div className="min-w-0">
294332 <div className="text-xs font-medium text-foreground">Artifacts</div>
295333 <div className="mt-0.5 text-xs leading-5 text-muted-foreground">
296− {artifacts
297− ? "Generated UI components are saved to your workspace."
298− : "Disabled: this connection serves no artifact tools."}
334+ {toolMode === "passthrough"
335+ ? "Not available in search and invoke mode."
336+ : artifacts
337+ ? "Generated UI components are saved to your workspace."
338+ : "Disabled: this connection serves no artifact tools."}
299339 </div>
300340 </div>
301341 <Switch
302− checked={artifacts}
342+ checked={toolMode === "passthrough" ? false : artifacts}
343+ disabled={toolMode === "passthrough"}
303344 onCheckedChange={(next) => {
304345 setPreferences((current) => ({ ...current, artifacts: next }));
305346 trackEvent("mcp_install_artifacts_toggled", { artifacts: next });
@@ -311,13 +352,16 @@ export function McpInstallCard(props: { className?: string }) { · 5 unmodified lines
311352 <div className="min-w-0">
312353 <div className="text-xs font-medium text-foreground">Integration search tools</div>
313354 <div className="mt-0.5 text-xs leading-5 text-muted-foreground">
314− {searchTools
315− ? "One search tool per connected integration, so agents see your integrations as tool names."
316− : "Disabled: agents discover tools through search inside execute."}
355+ {toolMode === "passthrough"
356+ ? "This mode already provides one search tool for all integrations."
357+ : searchTools
358+ ? "One search tool per connected integration, so agents see your integrations as tool names."
359+ : "Disabled: agents discover tools through search inside execute."}
317360 </div>
318361 </div>
319362 <Switch
320− checked={searchTools}
363+ checked={toolMode === "passthrough" ? false : searchTools}
364+ disabled={toolMode === "passthrough"}
321365 onCheckedChange={(next) => {
322366 setPreferences((current) => ({ ...current, searchTools: next }));
323367 trackEvent("mcp_install_search_tools_toggled", { search_tools: next });