Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/mcp-namespace-search-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@executor-js/execution": patch
"executor": patch
---

**Opt-in per-integration search tools on the MCP surface**

Connecting with `?search_tools=true` (stdio: `executor mcp --search-tools`) adds one minimally-described `search_<integration>` MCP tool per connected integration, so the integration namespaces reach the model as tool names it can see without calling anything. Each call routes through the same flow as `tools.search({ namespace })` inside `execute`, and the tool list comes from the same inventory the `execute` description shows. Off by default; a clean endpoint URL is unchanged.
22 changes: 20 additions & 2 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,7 @@ const mcpUrlForActiveLocalServer = (input: {
readonly connection: ExecutorServerConnection;
readonly elicitationMode: "browser" | "model";
readonly artifacts: boolean;
readonly searchTools: boolean;
}): URL => {
const url = new URL("/mcp", input.connection.origin);
if (input.elicitationMode === "browser") {
Expand All @@ -1372,6 +1373,11 @@ const mcpUrlForActiveLocalServer = (input: {
if (!input.artifacts) {
url.searchParams.set("artifacts", "false");
}
// Per-integration search tools are off by default; only the opt-in is
// spelled out.
if (input.searchTools) {
url.searchParams.set("search_tools", "true");
}
return url;
};

Expand All @@ -1387,6 +1393,7 @@ const runMcpHttpBridge = async (input: {
readonly manifest: ExecutorLocalServerManifest;
readonly elicitationMode: "browser" | "model";
readonly artifacts: boolean;
readonly searchTools: boolean;
}): Promise<void> => {
const stdio = new StdioServerTransport();
const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection);
Expand All @@ -1395,6 +1402,7 @@ const runMcpHttpBridge = async (input: {
connection: input.manifest.connection,
elicitationMode: input.elicitationMode,
artifacts: input.artifacts,
searchTools: input.searchTools,
}),
authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined,
);
Expand Down Expand Up @@ -1473,6 +1481,7 @@ const runMcpHttpBridge = async (input: {
const runStdioMcpSession = (input: {
readonly elicitationMode: "browser" | "model";
readonly artifacts: boolean;
readonly searchTools: boolean;
}) =>
Effect.gen(function* () {
// `executor mcp` never owns the local database. If a local server is already
Expand All @@ -1489,6 +1498,7 @@ const runStdioMcpSession = (input: {
manifest: active,
elicitationMode: input.elicitationMode,
artifacts: input.artifacts,
searchTools: input.searchTools,
}),
);
return;
Expand All @@ -1515,6 +1525,7 @@ const runStdioMcpSession = (input: {
manifest: elected,
elicitationMode: input.elicitationMode,
artifacts: input.artifacts,
searchTools: input.searchTools,
}),
);
});
Expand Down Expand Up @@ -2880,11 +2891,18 @@ const mcpCommand = Command.make(
"Withhold the artifact surface from this connection: the artifact tools, the app shell resource, and the artifact skills. Served by default.",
),
),
searchTools: Options.boolean("search-tools")
.pipe(Options.withDefault(false))
.pipe(
Options.withDescription(
"Serve one search_<integration> tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.",
),
),
},
({ scope, elicitationMode, noArtifacts }) =>
({ scope, elicitationMode, noArtifacts, searchTools }) =>
Effect.gen(function* () {
applyScope(scope);
yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts });
yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools });
}),
).pipe(Command.withDescription("Start an MCP server over stdio"));

Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
currentPropagationHeaders,
readArtifactsEnabled,
readElicitationMode,
readSearchToolsEnabled,
withVerifiedIdentityHeaders,
} from "@executor-js/cloudflare/mcp/do-headers";
import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
Expand Down Expand Up @@ -133,6 +134,7 @@ const propsForPrincipal = (
userId: principal.accountId,
elicitationMode: readElicitationMode(request),
artifactsEnabled: readArtifactsEnabled(request),
searchToolsEnabled: readSearchToolsEnabled(request),
resource,
webOrigin: new URL(request.url).origin,
},
Expand Down
4 changes: 4 additions & 0 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
resource: token.resource,
elicitationMode: token.elicitationMode,
artifactsEnabled: token.artifactsEnabled,
searchToolsEnabled: token.searchToolsEnabled,
} satisfies SessionMeta;
}).pipe(
Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),
Expand Down Expand Up @@ -302,6 +303,9 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
// persisted without a value restores to the default, same as a fresh
// connection whose URL says nothing about `?artifacts=`.
artifactsEnabled: sessionMeta.artifactsEnabled ?? true,
// Per-integration search tools are off by default, opt-in per
// connection (`?search_tools=true`). Same restore rule as artifacts.
searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false,
// Cold restores rebuild this server with no `initialize` to replay, so
// the negotiated apps support comes back from storage instead.
restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
Expand Down
2 changes: 2 additions & 0 deletions apps/host-cloudflare/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
currentPropagationHeaders,
readArtifactsEnabled,
readElicitationMode,
readSearchToolsEnabled,
withVerifiedIdentityHeaders,
} from "@executor-js/cloudflare/mcp/do-headers";
import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
Expand Down Expand Up @@ -80,6 +81,7 @@ const propsForPrincipal = (
userId: principal.accountId,
elicitationMode: readElicitationMode(request),
artifactsEnabled: readArtifactsEnabled(request),
searchToolsEnabled: readSearchToolsEnabled(request),
// host-cloudflare only routes the bare `/mcp` endpoint to the Agent
// bridge (see worker.ts), so the session always serves the default
// resource.
Expand Down
4 changes: 4 additions & 0 deletions apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
resource: token.resource,
elicitationMode: token.elicitationMode,
artifactsEnabled: token.artifactsEnabled,
searchToolsEnabled: token.searchToolsEnabled,
} satisfies SessionMeta);
}

Expand Down Expand Up @@ -149,6 +150,9 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
// persisted without a value restores to the default, same as a fresh
// connection whose URL says nothing about `?artifacts=`.
artifactsEnabled: sessionMeta.artifactsEnabled ?? true,
// Per-integration search tools are off by default, opt-in per
// connection (`?search_tools=true`). Same restore rule as artifacts.
searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false,
// Cold restores rebuild this server with no `initialize` to replay, so
// the negotiated apps support comes back from storage instead.
restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
Expand Down
2 changes: 2 additions & 0 deletions apps/local/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
formatResumeAcknowledgement,
readArtifactsEnabled,
readElicitationMode,
readSearchToolsEnabled,
} from "@executor-js/host-mcp/browser-approval";
import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";
import {
Expand Down Expand Up @@ -220,6 +221,7 @@ export const createMcpRequestHandler = (
...resourceConfig.config,
browserApprovalStore: approvals.store,
artifactsEnabled: readArtifactsEnabled(request),
searchToolsEnabled: readSearchToolsEnabled(request),
elicitationMode:
elicitationMode === "browser"
? {
Expand Down
141 changes: 141 additions & 0 deletions e2e/scenarios/namespace-search-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// The per-integration search tools opt-in. A plain MCP endpoint serves only
// the core surface; a connection that says `?search_tools=true` also gets one
// minimally-described `search_<integration>` tool per connected integration,
// whose whole point is to carry the integration namespaces into the model's
// context as tool names. A call routes through the same flow as
// `tools.search({ namespace })` inside `execute`, so its results match what
// code-side enumeration returns. The proof is comparative: two sessions, same
// identity, same server, differing only in that query.
import { randomBytes } from "node:crypto";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Api, Mcp, Target } from "../src/services";

const api = composePluginApi([openApiHttpPlugin()] as const);

const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;

const spec = (baseUrl: string): string =>
JSON.stringify({
openapi: "3.0.3",
info: { title: "Searchable API", version: "1.0.0" },
servers: [{ url: baseUrl }],
paths: {
"/alpha": {
get: {
operationId: "alphaOp",
summary: "First operation",
responses: { "200": { description: "ok" } },
},
},
"/bravo": {
post: {
operationId: "bravoOp",
summary: "Second operation",
responses: { "200": { description: "ok" } },
},
},
},
});

scenario(
"Discovery · a session connected with search_tools=true serves one search tool per integration",
{ timeout: 120_000 },
Effect.gen(function* () {
const target = yield* Target;
const mcp = yield* Mcp;
const { client: makeClient } = yield* Api;

const identity = yield* target.newIdentity();
const client = yield* makeClient(api, identity);
const slug = unique("nssearch");

yield* Effect.ensuring(
Effect.gen(function* () {
// The connection must exist before a session opens: the tool list is
// built from the integration inventory at session creation.
yield* client.openapi.addSpec({
payload: {
spec: { kind: "blob", value: spec("http://127.0.0.1:59999") },
slug,
baseUrl: "http://127.0.0.1:59999", // never contacted: discovery only
authenticationTemplate: [
{
slug: "apiKey",
type: "apiKey",
headers: { "x-api-key": [{ type: "variable", name: "token" }] },
},
],
},
});
yield* client.connections.create({
payload: {
owner: "org",
name: ConnectionName.make("main"),
integration: IntegrationSlug.make(slug),
template: AuthTemplateSlug.make("apiKey"),
value: "tok_nssearch",
},
});

const searchTool = `search_${slug}`;

// The default: a plain endpoint serves no per-integration search tools.
const defaultSession = mcp.session(identity);
const defaultTools = yield* defaultSession.listTools();
expect(
defaultTools.filter((name) => name.startsWith("search_")),
"a plain session serves no search_<integration> tools",
).toEqual([]);

// The opt-in: same identity, `?search_tools=true`.
const optedIn = mcp.session(identity, { searchTools: true });
const optedInTools = yield* optedIn.describeTools();
const names = optedInTools.map((tool) => tool.name);
expect(names, "the opted-in session serves the integration's search tool").toContain(
searchTool,
);
// The core surface is untouched.
expect(names, "execute still works on an opted-in session").toContain("execute");
expect(names, "skills still works on an opted-in session").toContain("skills");
// The description is minimal and points back at the execute flow.
const described = optedInTools.find((tool) => tool.name === searchTool);
expect(described?.description, "the tool description names its namespace").toContain(slug);
expect(described?.description, "the tool description points at execute").toContain(
"execute",
);

// A keyword call returns the matching tool, exactly as
// `tools.search({ query, namespace })` inside execute would.
const searched = yield* optedIn.call(searchTool, { query: "alpha" });
expect(searched.ok, `the search came back: ${searched.text}`).toBe(true);
expect(searched.text, "the keyword match is returned").toContain("alphaOp");
expect(searched.text, "the non-match is not").not.toContain("bravoOp");

// An empty call enumerates the whole namespace.
const enumerated = yield* optedIn.call(searchTool, {});
expect(enumerated.ok, `the enumeration came back: ${enumerated.text}`).toBe(true);
expect(enumerated.text, "enumeration lists every operation").toContain("alphaOp");
expect(enumerated.text, "enumeration lists every operation").toContain("bravoOp");
}),
Effect.gen(function* () {
yield* client.connections
.remove({
params: {
owner: "org",
integration: IntegrationSlug.make(slug),
name: ConnectionName.make("main"),
},
})
.pipe(Effect.ignore);
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
}),
);
}),
);
12 changes: 9 additions & 3 deletions e2e/src/surfaces/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ export interface McpSurface {
* (`?artifacts=false`). Omitted means the product default: the full
* artifact surface. */
readonly artifacts?: boolean;
/** Set `true` to opt this session into the per-integration
* `search_<integration>` tools (`?search_tools=true`). Omitted means
* the product default: none. */
readonly searchTools?: boolean;
readonly url?: string;
},
) => McpSession;
Expand Down Expand Up @@ -303,12 +307,14 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => (
const serverName = `${target.name}-${randomUUID().slice(0, 8)}`;
// Per-connection settings ride the MCP endpoint query, the ecosystem
// convention: `elicitation_mode` so a paused execution yields an approvalUrl
// instead of letting the model resume inline, and `artifacts=false` to opt
// the session out of the artifact surface. Both are non-defaults, so a
// plain session's URL carries no query at all.
// instead of letting the model resume inline, `artifacts=false` to opt the
// session out of the artifact surface, and `search_tools=true` to opt into
// the per-integration search tools. All are non-defaults, so a plain
// session's URL carries no query at all.
const sessionQuery = [
...(options?.elicitationMode ? [`elicitation_mode=${options.elicitationMode}`] : []),
...(options?.artifacts === false ? ["artifacts=false"] : []),
...(options?.searchTools === true ? ["search_tools=true"] : []),
].join("&");
const sessionUrl = sessionQuery ? `${mcpUrl}?${sessionQuery}` : mcpUrl;

Expand Down
Loading
Loading