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
7Add 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
1363 readonly elicitationMode: "browser" | "model";1363 readonly elicitationMode: "browser" | "model";
1364 readonly artifacts: boolean;1364 readonly artifacts: boolean;
1365 readonly searchTools: boolean;1365 readonly searchTools: boolean;
1366 readonly toolMode: "codemode" | "passthrough";
1366}): URL => {1367}): URL => {
1367 const url = new URL("/mcp", input.connection.origin);1368 const url = new URL("/mcp", input.connection.origin);
1368 if (input.elicitationMode === "browser") {1369 if (input.elicitationMode === "browser") {
@@ -1378,6 +1379,10 @@ const mcpUrlForActiveLocalServer = (input: { · 9 unmodified lines
1378 if (input.searchTools) {1379 if (input.searchTools) {
1379 url.searchParams.set("search_tools", "true");1380 url.searchParams.set("search_tools", "true");
1380 }1381 }
1382 // Passthrough is the non-default surface; only it is spelled out.
1383 if (input.toolMode === "passthrough") {
1384 url.searchParams.set("mode", "passthrough");
1385 }
1381 return url;1386 return url;
1382};1387};
1383 1388
@@ -1394,6 +1399,7 @@ const runMcpHttpBridge = async (input: { · 10 unmodified lines
1394 readonly elicitationMode: "browser" | "model";1399 readonly elicitationMode: "browser" | "model";
1395 readonly artifacts: boolean;1400 readonly artifacts: boolean;
1396 readonly searchTools: boolean;1401 readonly searchTools: boolean;
1402 readonly toolMode: "codemode" | "passthrough";
1397}): Promise<void> => {1403}): Promise<void> => {
1398 const stdio = new StdioServerTransport();1404 const stdio = new StdioServerTransport();
1399 const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection);1405 const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection);
@@ -1403,6 +1409,7 @@ const runMcpHttpBridge = async (input: { · 3 unmodified lines
1403 elicitationMode: input.elicitationMode,1409 elicitationMode: input.elicitationMode,
1404 artifacts: input.artifacts,1410 artifacts: input.artifacts,
1405 searchTools: input.searchTools,1411 searchTools: input.searchTools,
1412 toolMode: input.toolMode,
1406 }),1413 }),
1407 authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined,1414 authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined,
1408 );1415 );
@@ -1482,6 +1489,7 @@ const runStdioMcpSession = (input: { · 73 unmodified lines
1482 readonly elicitationMode: "browser" | "model";1489 readonly elicitationMode: "browser" | "model";
1483 readonly artifacts: boolean;1490 readonly artifacts: boolean;
1484 readonly searchTools: boolean;1491 readonly searchTools: boolean;
1492 readonly toolMode: "codemode" | "passthrough";
1485}) =>1493}) =>
1486 Effect.gen(function* () {1494 Effect.gen(function* () {
1487 // `executor mcp` never owns the local database. If a local server is already1495 // `executor mcp` never owns the local database. If a local server is already
@@ -1499,6 +1507,7 @@ const runStdioMcpSession = (input: { · 11 unmodified lines
1499 elicitationMode: input.elicitationMode,1507 elicitationMode: input.elicitationMode,
1500 artifacts: input.artifacts,1508 artifacts: input.artifacts,
1501 searchTools: input.searchTools,1509 searchTools: input.searchTools,
1510 toolMode: input.toolMode,
1502 }),1511 }),
1503 );1512 );
1504 return;1513 return;
@@ -1526,6 +1535,7 @@ const runStdioMcpSession = (input: { · 21 unmodified lines
1526 elicitationMode: input.elicitationMode,1535 elicitationMode: input.elicitationMode,
1527 artifacts: input.artifacts,1536 artifacts: input.artifacts,
1528 searchTools: input.searchTools,1537 searchTools: input.searchTools,
1538 toolMode: input.toolMode,
1529 }),1539 }),
1530 );1540 );
1531 });1541 });
@@ -2898,11 +2908,30 @@ const mcpCommand = Command.make( · 1366 unmodified lines
2898 "Serve one search_<integration> tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.",2908 "Serve one search_<integration> tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.",
2899 ),2909 ),
2900 ),2910 ),
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 ),
2901 },2918 },
2902 ({ scope, elicitationMode, noArtifacts, searchTools }) =>2919 ({ scope, elicitationMode, noArtifacts, searchTools, toolMode }) =>
2903 Effect.gen(function* () {2920 Effect.gen(function* () {
2904 applyScope(scope);2921 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 });
2906 }),2935 }),
2907).pipe(Command.withDescription("Start an MCP server over stdio"));2936).pipe(Command.withDescription("Start an MCP server over stdio"));
2908 2937
apps/cloud/src/mcp/agent-handler.ts+2−0
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16 readArtifactsEnabled,16 readArtifactsEnabled,
17 readElicitationMode,17 readElicitationMode,
18 readSearchToolsEnabled,18 readSearchToolsEnabled,
19 readToolMode,
19 withVerifiedIdentityHeaders,20 withVerifiedIdentityHeaders,
20} from "@executor-js/cloudflare/mcp/do-headers";21} from "@executor-js/cloudflare/mcp/do-headers";
21import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";22import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
@@ -189,6 +190,7 @@ const propsForPrincipal = ( · 167 unmodified lines
189 elicitationMode: readElicitationMode(request),190 elicitationMode: readElicitationMode(request),
190 artifactsEnabled: readArtifactsEnabled(request),191 artifactsEnabled: readArtifactsEnabled(request),
191 searchToolsEnabled: readSearchToolsEnabled(request),192 searchToolsEnabled: readSearchToolsEnabled(request),
193 toolMode: readToolMode(request),
192 resource,194 resource,
193 webOrigin: new URL(request.url).origin,195 webOrigin: new URL(request.url).origin,
194 },196 },
apps/cloud/src/mcp/session-durable-object.ts+4−0
@@ -384,6 +384,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 383 unmodified lines
384 description,384 description,
385 artifacts: executor.artifacts,385 artifacts: executor.artifacts,
386 connections: executor.connections,386 connections: executor.connections,
387 tools: executor.tools,
387 // Artifacts are on by default, opt-out per connection. A session388 // Artifacts are on by default, opt-out per connection. A session
388 // persisted without a value restores to the default, same as a fresh389 // persisted without a value restores to the default, same as a fresh
389 // connection whose URL says nothing about `?artifacts=`.390 // connection whose URL says nothing about `?artifacts=`.
@@ -391,6 +392,9 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 1 unmodified line
391 // Per-integration search tools are off by default, opt-in per392 // Per-integration search tools are off by default, opt-in per
392 // connection (`?search_tools=true`). Same restore rule as artifacts.393 // connection (`?search_tools=true`). Same restore rule as artifacts.
393 searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false,394 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",
394 // Cold restores rebuild this server with no `initialize` to replay, so398 // Cold restores rebuild this server with no `initialize` to replay, so
395 // the negotiated apps support comes back from storage instead.399 // the negotiated apps support comes back from storage instead.
396 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,400 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
apps/cloud/src/mcp/session-meta.ts+1−0
@@ -127,6 +127,7 @@ const metaFromIdentity = ( · 126 unmodified lines
127 elicitationMode: token.elicitationMode,127 elicitationMode: token.elicitationMode,
128 artifactsEnabled: token.artifactsEnabled,128 artifactsEnabled: token.artifactsEnabled,
129 searchToolsEnabled: token.searchToolsEnabled,129 searchToolsEnabled: token.searchToolsEnabled,
130 toolMode: token.toolMode,
130 };131 };
131};132};
132 133
apps/host-cloudflare/src/mcp/agent-handler.ts+2−0
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
14 readArtifactsEnabled,14 readArtifactsEnabled,
15 readElicitationMode,15 readElicitationMode,
16 readSearchToolsEnabled,16 readSearchToolsEnabled,
17 readToolMode,
17 withVerifiedIdentityHeaders,18 withVerifiedIdentityHeaders,
18} from "@executor-js/cloudflare/mcp/do-headers";19} from "@executor-js/cloudflare/mcp/do-headers";
19import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";20import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
@@ -86,6 +87,7 @@ const propsForPrincipal = ( · 66 unmodified lines
86 elicitationMode: readElicitationMode(request),87 elicitationMode: readElicitationMode(request),
87 artifactsEnabled: readArtifactsEnabled(request),88 artifactsEnabled: readArtifactsEnabled(request),
88 searchToolsEnabled: readSearchToolsEnabled(request),89 searchToolsEnabled: readSearchToolsEnabled(request),
90 toolMode: readToolMode(request),
89 // host-cloudflare only routes the bare `/mcp` endpoint to the Agent91 // host-cloudflare only routes the bare `/mcp` endpoint to the Agent
90 // bridge (see worker.ts), so the session always serves the default92 // bridge (see worker.ts), so the session always serves the default
91 // resource.93 // 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
133 elicitationMode: token.elicitationMode,133 elicitationMode: token.elicitationMode,
134 artifactsEnabled: token.artifactsEnabled,134 artifactsEnabled: token.artifactsEnabled,
135 searchToolsEnabled: token.searchToolsEnabled,135 searchToolsEnabled: token.searchToolsEnabled,
136 toolMode: token.toolMode,
136 } satisfies SessionMeta);137 } satisfies SessionMeta);
137 }138 }
138 139
@@ -165,6 +166,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 26 unmodified lines
165 engine,166 engine,
166 artifacts: executor.artifacts,167 artifacts: executor.artifacts,
167 connections: executor.connections,168 connections: executor.connections,
169 tools: executor.tools,
168 // Artifacts are on by default, opt-out per connection. A session170 // Artifacts are on by default, opt-out per connection. A session
169 // persisted without a value restores to the default, same as a fresh171 // persisted without a value restores to the default, same as a fresh
170 // connection whose URL says nothing about `?artifacts=`.172 // connection whose URL says nothing about `?artifacts=`.
@@ -172,6 +174,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 1 unmodified line
172 // Per-integration search tools are off by default, opt-in per174 // Per-integration search tools are off by default, opt-in per
173 // connection (`?search_tools=true`). Same restore rule as artifacts.175 // connection (`?search_tools=true`). Same restore rule as artifacts.
174 searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false,176 searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false,
177 mode: sessionMeta.toolMode ?? "codemode",
175 // Cold restores rebuild this server with no `initialize` to replay, so178 // Cold restores rebuild this server with no `initialize` to replay, so
176 // the negotiated apps support comes back from storage instead.179 // the negotiated apps support comes back from storage instead.
177 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,180 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
123 engine,123 engine,
124 artifacts: executor.artifacts,124 artifacts: executor.artifacts,
125 connections: executor.connections,125 connections: executor.connections,
126 tools: executor.tools,
126 ...appsConfig,127 ...appsConfig,
127 },128 },
128 webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL || undefined,129 webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL || undefined,
@@ -133,6 +134,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 4 unmodified lines
133 engine,134 engine,
134 artifacts: executor.artifacts,135 artifacts: executor.artifacts,
135 connections: executor.connections,136 connections: executor.connections,
137 tools: executor.tools,
136 ...appsConfig,138 ...appsConfig,
137 },139 },
138 };140 };
@@ -158,6 +160,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 19 unmodified lines
158 engine: toolkitEngine,160 engine: toolkitEngine,
159 artifacts: handle.executor.artifacts,161 artifacts: handle.executor.artifacts,
160 connections: handle.executor.connections,162 connections: handle.executor.connections,
163 tools: handle.executor.tools,
161 ...appsConfig,164 ...appsConfig,
162 },165 },
163 close: handle.dispose,166 close: handle.dispose,
apps/local/src/mcp.ts+2−0
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
21 readArtifactsEnabled,21 readArtifactsEnabled,
22 readElicitationMode,22 readElicitationMode,
23 readSearchToolsEnabled,23 readSearchToolsEnabled,
24 readToolMode,
24} from "@executor-js/host-mcp/browser-approval";25} from "@executor-js/host-mcp/browser-approval";
25import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";26import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";
26import {27import {
@@ -246,6 +247,7 @@ export const createMcpRequestHandler = ( · 219 unmodified lines
246 browserApprovalStore: approvals.store,247 browserApprovalStore: approvals.store,
247 artifactsEnabled: readArtifactsEnabled(request),248 artifactsEnabled: readArtifactsEnabled(request),
248 searchToolsEnabled: readSearchToolsEnabled(request),249 searchToolsEnabled: readSearchToolsEnabled(request),
250 mode: readToolMode(request),
249 elicitationMode:251 elicitationMode:
250 elicitationMode === "browser"252 elicitationMode === "browser"
251 ? {253 ? {
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.
3import { randomBytes } from "node:crypto";
4import { createServer } from "node:http";
5import { join } from "node:path";
6
7import { expect } from "@effect/vitest";
8import { Effect } from "effect";
9import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
10
11import { serveReplayBrain } from "../src/clients/replay-brain";
12import { scenario } from "../src/scenario";
13import { Api, Cli, OpenCode, RunDir, Target } from "../src/services";
14import { catalogApi, seedLargeCatalog } from "../scenarios/support/large-catalog";
15
16const 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.
20const SYNTHETIC_INTEGRATIONS = 12;
21const OPS_PER_INTEGRATION = 823;
22const 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.
27const OPENCODE_CONNECT_TIMEOUT_MS = 30_000;
28
29const 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. */
33const 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
66interface RecordedRequest {
67 readonly method: string;
68 readonly authorization: string | undefined;
69}
70
71const 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
107scenario(
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.
2import { expect } from "@effect/vitest";
3import { Effect } from "effect";
4
5import { decodeToolSearch } from "../scenarios/support/search-invoke";
6
7import { scenario } from "../src/scenario";
8import { Api, Mcp, Target } from "../src/services";
9import { catalogApi, seedLargeCatalog } from "../scenarios/support/large-catalog";
10
11// Catalog loading and the two-tool handshake must complete within 20 seconds.
12const MAX_PASSTHROUGH_CONNECT_MS = 20_000;
13
14scenario(
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.
2import { randomBytes } from "node:crypto";
3import { createServer, type IncomingMessage } from "node:http";
4
5import { expect } from "@effect/vitest";
6import { Effect } from "effect";
7import { composePluginApi } from "@executor-js/api/server";
8import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
9import {
10 AuthTemplateSlug,
11 ConnectionName,
12 IntegrationSlug,
13 ToolAddress,
14} from "@executor-js/sdk/shared";
15
16import { decodeToolSearch } from "./support/search-invoke";
17
18import { scenario } from "../src/scenario";
19import { Api, Mcp, Target } from "../src/services";
20
21const api = composePluginApi([openApiHttpPlugin()] as const);
22
23const 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. */
28const 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
64interface 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. */
73const 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
122const 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
129scenario(
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 @@
1import { Schema } from "effect";
2import { ToolAnnotationsView } from "@executor-js/sdk";
3
4/** Parse the public search result, including schemas and account identity. */
5export 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
100export interface McpToolDef {100export interface McpToolDef {
101 readonly name: string;101 readonly name: string;
102 readonly description: string;102 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;
103}108}
104 109
105/** How a connection surfaces a paused (approval-gated) execution. `browser` is110/** How a connection surfaces a paused (approval-gated) execution. `browser` is
@@ -172,6 +177,9 @@ export interface McpSurface { · 66 unmodified lines
172 * `search_<integration>` tools (`?search_tools=true`). Omitted means177 * `search_<integration>` tools (`?search_tools=true`). Omitted means
173 * the product default: none. */178 * the product default: none. */
174 readonly searchTools?: boolean;179 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";
175 readonly url?: string;183 readonly url?: string;
176 },184 },
177 ) => McpSession;185 ) => McpSession;
@@ -315,6 +323,7 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 137 unmodified lines
315 ...(options?.elicitationMode ? [`elicitation_mode=${options.elicitationMode}`] : []),323 ...(options?.elicitationMode ? [`elicitation_mode=${options.elicitationMode}`] : []),
316 ...(options?.artifacts === false ? ["artifacts=false"] : []),324 ...(options?.artifacts === false ? ["artifacts=false"] : []),
317 ...(options?.searchTools === true ? ["search_tools=true"] : []),325 ...(options?.searchTools === true ? ["search_tools=true"] : []),
326 ...(options?.mode === "passthrough" ? ["mode=passthrough"] : []),
318 ].join("&");327 ].join("&");
319 const sessionUrl = sessionQuery ? `${mcpUrl}?${sessionQuery}` : mcpUrl;328 const sessionUrl = sessionQuery ? `${mcpUrl}?${sessionQuery}` : mcpUrl;
320 329
@@ -358,6 +367,10 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 37 unmodified lines
358 return listed.tools.map((tool) => ({367 return listed.tools.map((tool) => ({
359 name: tool.name,368 name: tool.name,
360 description: tool.description ?? "",369 description: tool.description ?? "",
370 ...(tool.annotations
371 ? { annotations: tool.annotations as Record<string, unknown> }
372 : {}),
373 inputSchema: tool.inputSchema,
361 }));374 }));
362 }),375 }),
363 call,376 call,
@@ -408,14 +421,37 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 44 unmodified lines
408 return defs.map((tool: { name: string }) => tool.name);421 return defs.map((tool: { name: string }) => tool.name);
409 });422 });
410 423
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.
411 const describeTools = () =>427 const describeTools = () =>
412 Effect.promise(async (): Promise<ReadonlyArray<McpToolDef>> => {428 Effect.promise(async (): Promise<ReadonlyArray<McpToolDef>> => {
413 const defs = await (await runtime()).listTools(serverName, callOptions);429 const rt = await runtime();
414 connected = true;430 if (!connected) {
415 return defs.map((tool: { name: string; description?: string }) => ({431 await rt.listTools(serverName, callOptions);
416 name: tool.name,432 connected = true;
417 description: tool.description ?? "",433 }
418 }));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;
419 });455 });
420 456
421 const call = (name: string, args: Record<string, unknown> = {}) =>457 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
71 engine,71 engine,
72 artifacts: executor.artifacts,72 artifacts: executor.artifacts,
73 connections: executor.connections,73 connections: executor.connections,
74 tools: executor.tools,
74 ...(hostOptions?.loadAppShellHtml75 ...(hostOptions?.loadAppShellHtml
75 ? { loadAppShellHtml: hostOptions.loadAppShellHtml }76 ? { loadAppShellHtml: hostOptions.loadAppShellHtml }
76 : {}),77 : {}),
@@ -88,6 +89,8 @@ export const makeMcpBuildServer = · 11 unmodified lines
88 ...(options ?? {}),89 ...(options ?? {}),
89 }).pipe(90 }).pipe(
90 Effect.withSpan("mcp.server.create"),91 Effect.withSpan("mcp.server.create"),
92 // Catalog failures use the same retryable build envelope.
93 Effect.mapError((cause) => new McpEngineBuildError({ cause })),
91 Effect.map((mcpServer) => ({ mcpServer, engine, executor })),94 Effect.map((mcpServer) => ({ mcpServer, engine, executor })),
92 ),95 ),
93 ),96 ),
packages/core/execution/src/tool-invoker.ts+1−1
@@ -672,7 +672,7 @@ const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResul · 671 unmodified lines
672 672
673/** What `tools.search()` calls inside the sandbox. */673/** What `tools.search()` calls inside the sandbox. */
674export const searchTools = Effect.fn("executor.tools.search")(function* (674export const searchTools = Effect.fn("executor.tools.search")(function* (
675 executor: Executor,675 executor: { readonly tools: Pick<Executor["tools"], "list"> },
676 query: string,676 query: string,
677 limit = 12,677 limit = 12,
678 options?: { readonly namespace?: string; readonly offset?: number },678 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
48});48});
49export type ElicitationResponse = typeof ElicitationResponse.Type;49export type ElicitationResponse = typeof ElicitationResponse.Type;
50 50
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"`. */
58export type ElicitationSource = "policy" | "tool";
59
51/** Handler input — the tool address being invoked, its args, and the request. */60/** Handler input — the tool address being invoked, its args, and the request. */
52export interface ElicitationContext {61export interface ElicitationContext {
53 readonly address: ToolAddress;62 readonly address: ToolAddress;
54 readonly args: unknown;63 readonly args: unknown;
55 readonly request: ElicitationRequest;64 readonly request: ElicitationRequest;
65 readonly source?: ElicitationSource;
56}66}
57 67
58/** Host-provided handler the SDK calls when a tool suspends for input. */68/** 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
6246 address,6246 address,
6247 args,6247 args,
6248 request,6248 request,
6249 source: "tool",
6249 });6250 });
6250 if (response.action !== "accept") {6251 if (response.action !== "accept") {
6251 return yield* new ElicitationDeclinedError({6252 return yield* new ElicitationDeclinedError({
@@ -6287,7 +6288,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 35 unmodified lines
6287 message: `${message}\n\nArguments:\n${approvalArgumentPreview(args)}`,6288 message: `${message}\n\nArguments:\n${approvalArgumentPreview(args)}`,
6288 requestedSchema: { type: "object", properties: {} },6289 requestedSchema: { type: "object", properties: {} },
6289 });6290 });
6290 const response = yield* handler({ address, args, request });6291 const response = yield* handler({ address, args, request, source: "policy" });
6291 if (response.action !== "accept") {6292 if (response.action !== "accept") {
6292 return yield* new ElicitationDeclinedError({6293 return yield* new ElicitationDeclinedError({
6293 address,6294 address,
packages/core/sdk/src/host-internal.ts+3−0
@@ -58,3 +58,6 @@ export { · 57 unmodified lines
58 type ExecutorFumaDb,58 type ExecutorFumaDb,
59 type ExecutorFumaSchema,59 type ExecutorFumaSchema,
60} from "./executor-fuma-db";60} from "./executor-fuma-db";
61
62// Reuse the SDK schema serializer at MCP boundaries.
63export { reattachDefs } from "./schema-refs";
packages/core/sdk/src/index.ts+1−0
@@ -231,6 +231,7 @@ export { · 230 unmodified lines
231 type ElicitationRequest,231 type ElicitationRequest,
232 type ElicitationHandler,232 type ElicitationHandler,
233 type ElicitationContext,233 type ElicitationContext,
234 type ElicitationSource,
234 type OnElicitation,235 type OnElicitation,
235 type InvokeOptions,236 type InvokeOptions,
236} from "./elicitation";237} 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
23import {23import {
24 ResumeResponsePayload,24 ResumeResponsePayload,
25 decodeResumeResponse,25 decodeResumeResponse,
26 type McpToolMode,
26} from "@executor-js/host-mcp/browser-approval";27} from "@executor-js/host-mcp/browser-approval";
27 28
28import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";29import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";
@@ -77,6 +78,9 @@ interface McpSessionInitBase { · 48 unmodified lines
77 * tools, read off `?search_tools=` at connect time. Absent means the78 * tools, read off `?search_tools=` at connect time. Absent means the
78 * default (disabled). */79 * default (disabled). */
79 readonly searchToolsEnabled?: boolean;80 readonly searchToolsEnabled?: boolean;
81 /** The tool surface, read off `?mode=` at connect time. Absent means the
82 * default (codemode). */
83 readonly toolMode?: McpToolMode;
80 /** The MCP resource the session was minted against (`/mcp` default vs a84 /** The MCP resource the session was minted against (`/mcp` default vs a
81 * `/mcp/toolkits/<slug>` toolkit), so the tool catalog is scoped to it. */85 * `/mcp/toolkits/<slug>` toolkit), so the tool catalog is scoped to it. */
82 readonly resource: McpResource;86 readonly resource: McpResource;
@@ -151,6 +155,11 @@ interface SessionMetaBase { · 68 unmodified lines
151 * {@link McpSessionInit}). Absent — including for sessions persisted before155 * {@link McpSessionInit}). Absent — including for sessions persisted before
152 * the flag existed — means the default (disabled). */156 * the flag existed — means the default (disabled). */
153 readonly searchToolsEnabled?: boolean;157 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;
154 /** The MCP resource the session serves (carried from {@link McpSessionInit});163 /** The MCP resource the session serves (carried from {@link McpSessionInit});
155 * `buildMcpServer` scopes the tool catalog to it. */164 * `buildMcpServer` scopes the tool catalog to it. */
156 readonly resource: McpResource;165 readonly resource: McpResource;
packages/hosts/cloudflare/src/mcp/do-headers.ts+2−0
@@ -133,5 +133,7 @@ export { · 132 unmodified lines
133 readArtifactsEnabled,133 readArtifactsEnabled,
134 readElicitationMode,134 readElicitationMode,
135 readSearchToolsEnabled,135 readSearchToolsEnabled,
136 readToolMode,
136 type McpElicitationMode,137 type McpElicitationMode,
138 type McpToolMode,
137} from "@executor-js/host-mcp/browser-approval";139} 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
79 return TRUE_QUERY_VALUES.has(value.toLowerCase());79 return TRUE_QUERY_VALUES.has(value.toLowerCase());
80};80};
81 81
82export 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 */
92export const readToolMode = (request: Request): McpToolMode => {
93 const value = new URL(request.url).searchParams.get("mode");
94 return value === "passthrough" ? "passthrough" : "codemode";
95};
96
82/**97/**
83 * Build the console approval URL for a paused execution:98 * Build the console approval URL for a paused execution:
84 * `<origin>/<organizationSlug>/resume/<executionId>?mcp_session_id=<sessionId>`99 * `<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
12 readArtifactsEnabled,12 readArtifactsEnabled,
13 readElicitationMode,13 readElicitationMode,
14 readSearchToolsEnabled,14 readSearchToolsEnabled,
15 readToolMode,
16 type McpToolMode,
15} from "./browser-approval";17} from "./browser-approval";
16import {18import {
17 makeInProcessBrowserApprovalStore,19 makeInProcessBrowserApprovalStore,
@@ -31,7 +33,7 @@ import { · 13 unmodified lines
31 type Principal,33 type Principal,
32 type McpResource,34 type McpResource,
33} from "./seams";35} from "./seams";
34import type { BrowserApprovalStore } from "./tool-server";36import type { BrowserApprovalStore, McpPassthroughUnavailableError } from "./tool-server";
35 37
36// ---------------------------------------------------------------------------38// ---------------------------------------------------------------------------
37// In-process McpSessionStore — the single-node serving store, shared by every39// In-process McpSessionStore — the single-node serving store, shared by every
@@ -117,13 +119,15 @@ export interface McpBuildServerOptions { · 79 unmodified lines
117 /** Whether this session serves the per-integration `search_<integration>`119 /** Whether this session serves the per-integration `search_<integration>`
118 * tools. False unless the client connected with `?search_tools=true`. */120 * tools. False unless the client connected with `?search_tools=true`. */
119 readonly searchToolsEnabled?: boolean;121 readonly searchToolsEnabled?: boolean;
122 /** The tool surface (`?mode=`): codemode (default) or passthrough. */
123 readonly mode?: McpToolMode;
120}124}
121 125
122/** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */126/** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */
123export type McpBuildServer = (127export type McpBuildServer = (
124 principal: Principal,128 principal: Principal,
125 options?: McpBuildServerOptions,129 options?: McpBuildServerOptions,
126) => Effect.Effect<BuiltMcpServer, McpEngineBuildError>;130) => Effect.Effect<BuiltMcpServer, McpEngineBuildError | McpPassthroughUnavailableError>;
127 131
128export interface InMemoryMcpSessionStore {132export interface InMemoryMcpSessionStore {
129 /** The `McpSessionStore` seam value to hand to `inMemoryMcpSessionsLayer`. */133 /** The `McpSessionStore` seam value to hand to `inMemoryMcpSessionsLayer`. */
@@ -414,13 +418,18 @@ export const makeInMemoryMcpSessionStore = ( · 284 unmodified lines
414 ): McpBuildServerOptions => {418 ): McpBuildServerOptions => {
415 const artifactsEnabled = readArtifactsEnabled(request);419 const artifactsEnabled = readArtifactsEnabled(request);
416 const searchToolsEnabled = readSearchToolsEnabled(request);420 const searchToolsEnabled = readSearchToolsEnabled(request);
421 const toolMode = readToolMode(request);
422 const surface = {
423 artifactsEnabled,
424 searchToolsEnabled,
425 mode: toolMode,
426 };
417 const mode = readElicitationMode(request);427 const mode = readElicitationMode(request);
418 if (mode !== "browser") {428 if (mode !== "browser") {
419 return { artifactsEnabled, searchToolsEnabled, elicitationMode: { mode } };429 return { ...surface, elicitationMode: { mode } };
420 }430 }
421 return {431 return {
422 artifactsEnabled,432 ...surface,
423 searchToolsEnabled,
424 elicitationMode: {433 elicitationMode: {
425 mode: "browser",434 mode: "browser",
426 // Prefer the pinned public origin; fall back to the request URL (correct435 // Prefer the pinned public origin; fall back to the request URL (correct
@@ -489,9 +498,12 @@ export const makeInMemoryMcpSessionStore = ( · 62 unmodified lines
489 }),498 }),
490 ),499 ),
491 // A build failure has nowhere typed to go in the envelope; render a 500.500 // A build failure has nowhere typed to go in the envelope; render a 500.
492 Effect.catchTag("McpEngineBuildError", () =>501 Effect.catchTags({
493 Effect.succeed(jsonRpcError(500, -32603, "Internal server error")),502 McpEngineBuildError: () =>
494 ),503 Effect.succeed(jsonRpcError(500, -32603, "Internal server error")),
504 McpPassthroughUnavailableError: () =>
505 Effect.succeed(jsonRpcError(500, -32603, "Internal server error")),
506 }),
495 );507 );
496 };508 };
497 509
packages/hosts/mcp/src/passthrough-tools.test.ts+668−0
@@ -0,0 +1,668 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5import type * as Cause from "effect/Cause";
6
7import type { ExecutionEngine } from "@executor-js/execution";
8import {
9 ToolAddress,
10 IntegrationSlug,
11 ConnectionName,
12 ToolName,
13 type Tool,
14 type ToolSchemaView,
15} from "@executor-js/sdk";
16
17import { readToolMode } from "./browser-approval";
18import { passthroughCallCode } from "./passthrough-tools";
19import {
20 createExecutorMcpServer,
21 McpPassthroughUnavailableError,
22 type ExecutorMcpServerConfig,
23 type McpToolsPort,
24} from "./tool-server";
25
26// ---------------------------------------------------------------------------
27// Fixtures
28// ---------------------------------------------------------------------------
29
30const 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. */
59const 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`). */
87const 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
112const 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
130const decodeJsonString = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.String));
131const decodeJsonRecord = Schema.decodeUnknownSync(
132 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
133);
134
135const decodeSearchItems = Schema.decodeUnknownSync(
136 Schema.Struct({ items: Schema.Array(Schema.Struct({ id: Schema.String })) }),
137);
138
139const 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
155describe("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
190describe("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
205describe("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 */
9export 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. */
25export 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 @@
1import { reattachDefs } from "@executor-js/sdk/host-internal";
1import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect";2import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect";
2import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -22,7 +23,10 @@ import * as z from "zod/v4"; · 18 unmodified lines
22 23
23import {24import {
24 CurrentOrgWriteAccess,25 CurrentOrgWriteAccess,
26 ToolAddress,
27 parseToolAddress,
25 isToolFile,28 isToolFile,
29 isToolResult,
26 makeOrgWriteAccessState,30 makeOrgWriteAccessState,
27 sanitizeArtifactPreviewMarkup,31 sanitizeArtifactPreviewMarkup,
28 type OrgWriteAccess,32 type OrgWriteAccess,
@@ -37,10 +41,13 @@ import type { · 8 unmodified lines
37 ElicitationRequest,41 ElicitationRequest,
38 SaveArtifactInput,42 SaveArtifactInput,
39 ToolFileValue,43 ToolFileValue,
44 Executor,
45 ToolSchemaView,
40} from "@executor-js/sdk";46} from "@executor-js/sdk";
41import type * as Tracer from "effect/Tracer";47import type * as Tracer from "effect/Tracer";
42import {48import {
43 createExecutionEngine,49 createExecutionEngine,
50 searchTools,
44 formatExecuteResult,51 formatExecuteResult,
45 formatPausedExecution,52 formatPausedExecution,
46 formatTtlDuration,53 formatTtlDuration,
@@ -74,6 +81,8 @@ import { · 27 unmodified lines
74 type BindableConnection,81 type BindableConnection,
75} from "./artifact-bindings";82} from "./artifact-bindings";
76import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams";83import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams";
84import { passthroughCallCode, passthroughInstructions } from "./passthrough-tools";
85import type { McpToolMode } from "./browser-approval";
77 86
78// ---------------------------------------------------------------------------87// ---------------------------------------------------------------------------
79// Workers-compatible JSON Schema validator (replaces Ajv which uses new Function())88// Workers-compatible JSON Schema validator (replaces Ajv which uses new Function())
@@ -180,9 +189,26 @@ type SharedMcpServerConfig = { · 100 unmodified lines
180 * the `execute` description lists). The tools exist to carry the namespaces189 * the `execute` description lists). The tools exist to carry the namespaces
181 * into the model's context as tool names; each call routes through the same190 * into the model's context as tool names; each call routes through the same
182 * execution flow as `tools.search({ namespace })` inside `execute`, so the191 * 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).
184 */195 */
185 readonly searchToolsEnabled?: boolean;196 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;
186 /**212 /**
187 * Renders an artifact once, server-side, before it is saved — so a component213 * Renders an artifact once, server-side, before it is saved — so a component
188 * that throws on its first render is refused at create time with the real214 * that throws on its first render is refused at create time with the real
@@ -272,6 +298,15 @@ export type McpConnectionsPort = { · 83 unmodified lines
272 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;298 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
273};299};
274 300
301/** The same list and schema APIs used by codemode discovery. */
302export 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. */
306export class McpPassthroughUnavailableError extends Data.TaggedError(
307 "McpPassthroughUnavailableError",
308)<{ readonly reason: string }> {}
309
275export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =310export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
276 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)311 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
277 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)312 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
@@ -638,6 +673,64 @@ const toMcpResult = (result: FormattedExecuteInput): McpToolResult => { · 360 unmodified lines
638 };673 };
639};674};
640 675
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 */
684const 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 */
708const 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
641const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({734const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({
642 content: [{ type: "text", text: formatted.text }],735 content: [{ type: "text", text: formatted.text }],
643 structuredContent: formatted.structured,736 structuredContent: formatted.structured,
@@ -1104,13 +1197,174 @@ const parseJsonContent = (raw: string): Record<string, unknown> | undefined => { · 460 unmodified lines
1104 return Option.isSome(parsed) ? parsed.value : undefined;1197 return Option.isSome(parsed) ? parsed.value : undefined;
1105};1198};
1106 1199
1200// ---------------------------------------------------------------------------
1201// Passthrough surface
1202// ---------------------------------------------------------------------------
1203
1204/** Serialize one existing schema view as a self-contained MCP input schema. */
1205const 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. */
1212const 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
1107// ---------------------------------------------------------------------------1361// ---------------------------------------------------------------------------
1108// Server factory1362// Server factory
1109// ---------------------------------------------------------------------------1363// ---------------------------------------------------------------------------
1110 1364
1111export const createExecutorMcpServer = <E extends Cause.YieldableError>(1365export const createExecutorMcpServer = <E extends Cause.YieldableError>(
1112 config: ExecutorMcpServerConfig<E>,1366 config: ExecutorMcpServerConfig<E>,
1113): Effect.Effect<McpServer> =>1367): Effect.Effect<McpServer, McpPassthroughUnavailableError> =>
1114 Effect.gen(function* () {1368 Effect.gen(function* () {
1115 const engine = "engine" in config ? config.engine : createExecutionEngine(config);1369 const engine = "engine" in config ? config.engine : createExecutionEngine(config);
1116 const description =1370 const description =
@@ -1122,11 +1376,24 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 5 unmodified lines
1122 // Artifacts are on unless this connection opted out (`?artifacts=false`).1376 // Artifacts are on unless this connection opted out (`?artifacts=false`).
1123 // One flag decides the whole surface: the tools, the shell resource, and1377 // One flag decides the whole surface: the tools, the shell resource, and
1124 // the skills catalog below.1378 // 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);
1126 const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled });1382 const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled });
1127 // Per-integration search tools are off unless this connection opted in1383 // Per-integration search tools are off unless this connection opted in
1128 // (`?search_tools=true`).1384 // (`?search_tools=true`).
1129 const searchToolsEnabled = config.searchToolsEnabled ?? false;1385 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 }
1130 1397
1131 // Captured at construction time. SDK callbacks fire later (often1398 // Captured at construction time. SDK callbacks fire later (often
1132 // deferred past the outer Effect's await), so we use the runtime to1399 // 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
1227 // per host.1494 // per host.
1228 capabilities: { resources: {}, tools: {} },1495 capabilities: { resources: {}, tools: {} },
1229 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),1496 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
1497 ...(passthrough
1498 ? {
1499 instructions: passthroughInstructions(),
1500 }
1501 : {}),
1230 },1502 },
1231 ),1503 ),
1232 ).pipe(Effect.withSpan("mcp.host.create_server"));1504 ).pipe(Effect.withSpan("mcp.host.create_server"));
@@ -1545,103 +1817,171 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 312 unmodified lines
1545 Effect.annotateSpans(joinKeyAttributes(extra)),1817 Effect.annotateSpans(joinKeyAttributes(extra)),
1546 );1818 );
1547 1819
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
1548 // --- tools ---1877 // --- tools ---
1549 1878
1550 yield* Effect.sync(() =>1879 // Passthrough serves search and invoke in place of the codemode tools.
1551 server.registerTool(1880 if (passthrough && config.tools) {
1552 "execute",1881 yield* registerPassthroughTools(server, config.tools, executePassthroughCall);
1553 {1882 }
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 );
1564 1883
1565 yield* Effect.sync(() =>1884 if (!passthrough)
1566 server.registerTool(1885 yield* Effect.sync(() =>
1567 "skills",1886 server.registerTool(
1568 {1887 "execute",
1569 description: [1888 {
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.",1889 description,
1571 "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",1890 inputSchema: { code: z.string().trim().min(1) },
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 ),
1582 },1891 },
1583 },1892 ({ code }, extra) => runToolEffect(executeCode(code, extra), extra),
1584 ({ name }, extra) =>1893 ),
1585 runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra),1894 ).pipe(
1586 ),1895 Effect.withSpan("mcp.host.register_tool", {
1587 ).pipe(1896 attributes: { "mcp.tool.name": "execute" },
1588 Effect.withSpan("mcp.host.register_tool", {1897 }),
1589 attributes: { "mcp.tool.name": "skills" },1898 );
1590 }),
1591 );
1592
1593 yield* Effect.sync(() => {
1594 if (elicitationMode.mode === "native") {
1595 return undefined;
1596 }
1597 1899
1598 if (elicitationMode.mode === "model") {1900 if (!passthrough)
1599 return server.registerTool(1901 yield* Effect.sync(() =>
1600 "resume",1902 server.registerTool(
1903 "skills",
1601 {1904 {
1602 description: [1905 description: [
1603 "Resume a paused execution using the executionId returned by execute.",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.",
1604 "This connection explicitly allows model-side resume via elicitation_mode=model.",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.",
1605 ].join("\n"),1910 ].join("\n"),
1606 inputSchema: {1911 inputSchema: {
1607 executionId: z.string().describe("The execution ID from the paused result"),1912 name: z
1608 action: z
1609 .enum(["accept", "decline", "cancel"])
1610 .describe("How to respond to the interaction"),
1611 content: z
1612 .string()1913 .string()
1613 .describe("Optional JSON-encoded response content for form elicitations")1914 .optional()
1614 .default("{}"),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 ),
1615 },1918 },
1616 },1919 },
1617 ({ executionId, action, content: rawContent }, extra) =>1920 ({ name }, extra) =>
1618 runToolEffect(1921 runToolEffect(
1619 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),1922 Effect.succeed(skillsResult(name, executeInventory, skillCatalog)),
1620 extra,1923 extra,
1621 ),1924 ),
1622 );1925 ),
1623 }1926 ).pipe(
1927 Effect.withSpan("mcp.host.register_tool", {
1928 attributes: { "mcp.tool.name": "skills" },
1929 }),
1930 );
1624 1931
1625 return server.registerTool(1932 if (!passthrough)
1626 "resume",1933 yield* Effect.sync(() => {
1627 {1934 if (elicitationMode.mode === "native") {
1628 description: [1935 return undefined;
1629 "Request user approval to resume a paused execution.",1936 }
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.",1937
1631 "This connection does not allow the model to choose accept, decline, cancel, or content.",1938 if (elicitationMode.mode === "model") {
1632 ].join("\n"),1939 return server.registerTool(
1633 inputSchema: {1940 "resume",
1634 executionId: z.string().describe("The execution ID from the paused result"),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 },
1635 },1976 },
1636 },1977 ({ executionId }, extra) =>
1637 ({ executionId }, extra) =>1978 runToolEffect(resumeAfterBrowserApproval(executionId, extra), extra),
1638 runToolEffect(resumeAfterBrowserApproval(executionId, extra), extra),1979 );
1980 }).pipe(
1981 Effect.withSpan("mcp.host.register_tool", {
1982 attributes: { "mcp.tool.name": "resume" },
1983 }),
1639 );1984 );
1640 }).pipe(
1641 Effect.withSpan("mcp.host.register_tool", {
1642 attributes: { "mcp.tool.name": "resume" },
1643 }),
1644 );
1645 1985
1646 // --- per-integration search tools (opt-in, `?search_tools=true`) ---1986 // --- per-integration search tools (opt-in, `?search_tools=true`) ---
1647 //1987 //
@@ -1659,7 +1999,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 11 unmodified lines
1659 // would only repeat the name) and a single bare `query` parameter — no1999 // would only repeat the name) and a single bare `query` parameter — no
1660 // paging knobs, because anything past the first page belongs in `execute`.2000 // paging knobs, because anything past the first page belongs in `execute`.
1661 // `namespace-search-tools.test.ts` pins the serialized size.2001 // `namespace-search-tools.test.ts` pins the serialized size.
1662 if (searchToolsEnabled) {2002 if (searchToolsEnabled && !passthrough) {
1663 // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already2003 // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already
1664 // conform (they are `tools.<slug>` property names in sandbox code); one2004 // conform (they are `tools.<slug>` property names in sandbox code); one
1665 // that somehow doesn't is skipped rather than failing the whole session.2005 // 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
169 mcp_install_elicitation_mode_changed: { elicitation_mode: string };169 mcp_install_elicitation_mode_changed: { elicitation_mode: string };
170 mcp_install_artifacts_toggled: { artifacts: boolean };170 mcp_install_artifacts_toggled: { artifacts: boolean };
171 mcp_install_search_tools_toggled: { search_tools: boolean };171 mcp_install_search_tools_toggled: { search_tools: boolean };
172 mcp_install_tool_mode_changed: { tool_mode: "codemode" | "passthrough" };
172 173
173 // ── Command palette ──────────────────────────────────────────────────────174 // ── Command palette ──────────────────────────────────────────────────────
174 command_palette_navigated: {175 command_palette_navigated: {
packages/react/src/components/mcp-install-card.tsx+54−10
@@ -26,6 +26,9 @@ const McpInstallPreferencesSchema = Schema.Struct({ · 25 unmodified lines
26 httpElicitationMode: Schema.Literals(["browser", "model", "native"]),26 httpElicitationMode: Schema.Literals(["browser", "model", "native"]),
27 artifacts: Schema.Boolean,27 artifacts: Schema.Boolean,
28 searchTools: Schema.Boolean,28 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"])),
29});32});
30 33
31type McpInstallPreferences = typeof McpInstallPreferencesSchema.Type;34type McpInstallPreferences = typeof McpInstallPreferencesSchema.Type;
@@ -52,6 +55,7 @@ const DEFAULT_MCP_INSTALL_PREFERENCES: McpInstallPreferences = { · 20 unmodified lines
52 httpElicitationMode: "model",55 httpElicitationMode: "model",
53 artifacts: true,56 artifacts: true,
54 searchTools: false,57 searchTools: false,
58 toolMode: "codemode",
55};59};
56const decodeMcpInstallPreferences = Schema.decodeUnknownOption(60const decodeMcpInstallPreferences = Schema.decodeUnknownOption(
57 Schema.fromJsonString(McpInstallPreferencesSchema),61 Schema.fromJsonString(McpInstallPreferencesSchema),
@@ -116,6 +120,9 @@ export const buildMcpHttpEndpoint = (input: { · 58 unmodified lines
116 /** Per-integration search tools are off by default, so only the opt-in is120 /** Per-integration search tools are off by default, so only the opt-in is
117 * spelled out on the URL (`&search_tools=true`). */121 * spelled out on the URL (`&search_tools=true`). */
118 readonly searchTools?: boolean;122 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";
119 // Cloud only: pins the URL to `/<org-slug>/mcp` (the server also accepts the126 // Cloud only: pins the URL to `/<org-slug>/mcp` (the server also accepts the
120 // legacy `/<org_id>/mcp` form). Desktop/local pass nothing and get the bare127 // legacy `/<org_id>/mcp` form). Desktop/local pass nothing and get the bare
121 // `/mcp` path.128 // `/mcp` path.
@@ -137,7 +144,12 @@ export const buildMcpHttpEndpoint = (input: { · 15 unmodified lines
137 params.push(["elicitation_mode", input.elicitationMode]);144 params.push(["elicitation_mode", input.elicitationMode]);
138 }145 }
139 if (input.artifacts === false) params.push(["artifacts", "false"]);146 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"]);
141 if (params.length === 0) return endpoint;153 if (params.length === 0) return endpoint;
142 154
143 const query = params.map(([key, value]) => `${key}=${value}`).join("&");155 const query = params.map(([key, value]) => `${key}=${value}`).join("&");
@@ -160,6 +172,7 @@ export const buildMcpInstallCommand = (input: { · 16 unmodified lines
160 readonly elicitationMode?: McpElicitationMode;172 readonly elicitationMode?: McpElicitationMode;
161 readonly artifacts?: boolean;173 readonly artifacts?: boolean;
162 readonly searchTools?: boolean;174 readonly searchTools?: boolean;
175 readonly toolMode?: "codemode" | "passthrough";
163 readonly devCliCwd?: string;176 readonly devCliCwd?: string;
164 readonly organizationSlug?: string | null;177 readonly organizationSlug?: string | null;
165}): string => {178}): string => {
@@ -170,6 +183,7 @@ export const buildMcpInstallCommand = (input: { · 4 unmodified lines
170 elicitationMode: input.elicitationMode,183 elicitationMode: input.elicitationMode,
171 artifacts: input.artifacts,184 artifacts: input.artifacts,
172 searchTools: input.searchTools,185 searchTools: input.searchTools,
186 toolMode: input.toolMode,
173 organizationSlug: input.organizationSlug,187 organizationSlug: input.organizationSlug,
174 });188 });
175 const headerFlags: string[] = [];189 const headerFlags: string[] = [];
@@ -197,9 +211,12 @@ export const buildMcpInstallCommand = (input: { · 21 unmodified lines
197 if (input.artifacts === false) {211 if (input.artifacts === false) {
198 innerArgs.push("--no-artifacts");212 innerArgs.push("--no-artifacts");
199 }213 }
200 if (input.searchTools === true) {214 if (input.searchTools === true && input.toolMode !== "passthrough") {
201 innerArgs.push("--search-tools");215 innerArgs.push("--search-tools");
202 }216 }
217 if (input.toolMode === "passthrough") {
218 innerArgs.push("--mode", "passthrough");
219 }
203 return `npx add-mcp ${shellQuoteWord(innerArgs.map(shellQuoteWord).join(" "))} --name executor`;220 return `npx add-mcp ${shellQuoteWord(innerArgs.map(shellQuoteWord).join(" "))} --name executor`;
204};221};
205 222
@@ -227,6 +244,7 @@ export function McpInstallCard(props: { className?: string }) { · 21 unmodified lines
227 }244 }
228 const [advancedOpen, setAdvancedOpen] = useState(false);245 const [advancedOpen, setAdvancedOpen] = useState(false);
229 const { mode, httpElicitationMode, artifacts, searchTools } = preferences;246 const { mode, httpElicitationMode, artifacts, searchTools } = preferences;
247 const toolMode = preferences.toolMode ?? "codemode";
230 248
231 useEffect(() => {249 useEffect(() => {
232 writeMcpInstallPreferences(storageKey, preferences);250 writeMcpInstallPreferences(storageKey, preferences);
@@ -268,6 +286,7 @@ export function McpInstallCard(props: { className?: string }) { · 35 unmodified lines
268 elicitationMode,286 elicitationMode,
269 artifacts,287 artifacts,
270 searchTools,288 searchTools,
289 toolMode,
271 devCliCwd,290 devCliCwd,
272 organizationSlug,291 organizationSlug,
273 });292 });
@@ -290,16 +309,38 @@ export function McpInstallCard(props: { className?: string }) { · 16 unmodified lines
290 </CollapsibleTrigger>309 </CollapsibleTrigger>
291 <CollapsibleContent>310 <CollapsibleContent>
292 <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">311 <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">
293 <div className="min-w-0">331 <div className="min-w-0">
294 <div className="text-xs font-medium text-foreground">Artifacts</div>332 <div className="text-xs font-medium text-foreground">Artifacts</div>
295 <div className="mt-0.5 text-xs leading-5 text-muted-foreground">333 <div className="mt-0.5 text-xs leading-5 text-muted-foreground">
296 {artifacts334 {toolMode === "passthrough"
297 ? "Generated UI components are saved to your workspace."335 ? "Not available in search and invoke mode."
298 : "Disabled: this connection serves no artifact tools."}336 : artifacts
337 ? "Generated UI components are saved to your workspace."
338 : "Disabled: this connection serves no artifact tools."}
299 </div>339 </div>
300 </div>340 </div>
301 <Switch341 <Switch
302 checked={artifacts}342 checked={toolMode === "passthrough" ? false : artifacts}
343 disabled={toolMode === "passthrough"}
303 onCheckedChange={(next) => {344 onCheckedChange={(next) => {
304 setPreferences((current) => ({ ...current, artifacts: next }));345 setPreferences((current) => ({ ...current, artifacts: next }));
305 trackEvent("mcp_install_artifacts_toggled", { artifacts: next });346 trackEvent("mcp_install_artifacts_toggled", { artifacts: next });
@@ -311,13 +352,16 @@ export function McpInstallCard(props: { className?: string }) { · 5 unmodified lines
311 <div className="min-w-0">352 <div className="min-w-0">
312 <div className="text-xs font-medium text-foreground">Integration search tools</div>353 <div className="text-xs font-medium text-foreground">Integration search tools</div>
313 <div className="mt-0.5 text-xs leading-5 text-muted-foreground">354 <div className="mt-0.5 text-xs leading-5 text-muted-foreground">
314 {searchTools355 {toolMode === "passthrough"
315 ? "One search tool per connected integration, so agents see your integrations as tool names."356 ? "This mode already provides one search tool for all integrations."
316 : "Disabled: agents discover tools through search inside execute."}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."}
317 </div>360 </div>
318 </div>361 </div>
319 <Switch362 <Switch
320 checked={searchTools}363 checked={toolMode === "passthrough" ? false : searchTools}
364 disabled={toolMode === "passthrough"}
321 onCheckedChange={(next) => {365 onCheckedChange={(next) => {
322 setPreferences((current) => ({ ...current, searchTools: next }));366 setPreferences((current) => ({ ...current, searchTools: next }));
323 trackEvent("mcp_install_search_tools_toggled", { search_tools: next });367 trackEvent("mcp_install_search_tools_toggled", { search_tools: next });