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
5 changes: 5 additions & 0 deletions .changeset/passthrough-config-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/host-mcp": patch
---

Passthrough connections (`?mode=passthrough`) now search and invoke Executor's own configuration tools, such as `executor.mcp.addServer`, `executor.connections.createHandoff` and `executor.oauth.start`, so an agent on that surface can add an integration when asked. Block policies still hide them.
74 changes: 57 additions & 17 deletions packages/hosts/mcp/src/passthrough-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,13 +383,6 @@ describe("passthrough mode server", () => {
connection: "main",
name: "issues.first",
}),
projection({
integration: "github",
owner: "org",
connection: "main",
name: "issues.static",
static: true,
}),
];
await withClient(
{ engine, mode: "passthrough", tools: toolPort(catalog, schemaReads) },
Expand Down Expand Up @@ -507,24 +500,71 @@ describe("passthrough mode server", () => {
);
});

it("uses current schemas and excludes static configuration tools", async () => {
it("searches and invokes Executor's own configuration tools by their fqid", async () => {
const recording = makeRecordingEngine();
const addServer: Tool = {
address: ToolAddress.make("executor.mcp.addServer"),
integration: IntegrationSlug.make("executor"),
owner: "org",
connection: ConnectionName.make("coreTools"),
name: ToolName.make("mcp.addServer"),
pluginId: "mcp",
description: "Register an MCP server in the catalog as an integration.",
inputSchema: {
type: "object",
properties: { endpoint: { type: "string" } },
required: ["endpoint"],
},
annotations: { requiresApproval: true },
static: true,
};
await withClient(
{ engine: recording.engine, mode: "passthrough", tools: toolPort([addServer]) },
async (client) => {
const found = await client.callTool({
name: "search",
arguments: { query: "add mcp server" },
});
expect(found.structuredContent).toMatchObject({
items: [
{
id: "executor.mcp.addServer",
integration: "executor",
owner: "org",
connection: "coreTools",
inputSchema: { required: ["endpoint"] },
},
],
});
await client.callTool({
name: "invoke",
arguments: {
tool: "executor.mcp.addServer",
arguments: { endpoint: "https://blog.mcp.cloudflare.com/mcp" },
},
});
const unknown = await client.callTool({
name: "invoke",
arguments: { tool: "executor.mcp.removeEverything", arguments: {} },
});
expect(unknown.isError).toBe(true);
expect(recording.executed).toEqual([
'return await tools["executor.mcp.addServer"]({"endpoint":"https://blog.mcp.cloudflare.com/mcp"});',
]);
},
);
});

it("uses current schemas", async () => {
const recording = makeRecordingEngine();
const dynamic = projection({
integration: "notes",
name: "create",
inputSchema: { type: "object" },
});
const catalog = [dynamic, projection({ integration: "settings", name: "erase", static: true })];
let current: ToolSchemaView = { address: dynamic.address, inputSchema: dynamic.inputSchema };
const tools: McpToolsPort = { ...toolPort(catalog), schema: () => Effect.succeed(current) };
const tools: McpToolsPort = { ...toolPort([dynamic]), schema: () => Effect.succeed(current) };
await withClient({ engine: recording.engine, mode: "passthrough", tools }, async (client) => {
const hidden = await client.callTool({ name: "search", arguments: { query: "settings" } });
expect(decodeSearchItems(hidden.structuredContent).items).toEqual([]);
const staticCall = await client.callTool({
name: "invoke",
arguments: { tool: "tools.settings.org.main.erase", arguments: {} },
});
expect(staticCall.isError).toBe(true);
await client.callTool({ name: "search", arguments: { query: "notes" } });
current = {
address: dynamic.address,
Expand Down
3 changes: 3 additions & 0 deletions packages/hosts/mcp/src/passthrough-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export const SEARCH_INVOKE_SKILL: Skill = {
'2. Call `search({ query: "create issue", integration: "github", owner: "org", connection: "main" })`. Use the exact integration, owner, and connection returned by integrations to select an account. Omit filters to search across accounts visible to you.',
"3. Read the matching tool's `inputSchema`. Call `invoke({ tool: <exact returned id>, arguments: <JSON object matching inputSchema> })`. Do not guess tool IDs or arguments.",
"",
"## Adding an integration",
'Executor\'s own configuration tools are searchable too, under the `executor` integration. To add an MCP server, search "add mcp server" and invoke `executor.mcp.addServer`. Then connect it: `executor.connections.createHandoff` returns a URL where the user enters an API key, and `executor.oauth.probe` + `executor.oauth.start` return an authorization URL for OAuth. Give the user the URL. Never ask for the credential in chat.',
"",
"## Pagination",
"Both integrations and search return `{ items, total, hasMore, nextOffset }`. If hasMore is true, repeat the call with the same filters and `offset: nextOffset`. Search also needs the same query. Search returns at most 20 tools per page, with schemas only for those matches.",
"Tool-search pagination is separate from an upstream API's pagination. Follow the invoked tool's schema and response for cursor or page arguments when retrieving more records.",
Expand Down
61 changes: 40 additions & 21 deletions packages/hosts/mcp/src/tool-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
SaveArtifactInput,
ToolFileValue,
Executor,
Tool,
ToolSchemaView,
} from "@executor-js/sdk";
import type * as Tracer from "effect/Tracer";
Expand Down Expand Up @@ -1297,6 +1298,12 @@ const parseJsonContent = (raw: string): Record<string, unknown> | undefined => {
// Passthrough surface
// ---------------------------------------------------------------------------

/** The sandbox path discovery reports for a tool: its address without the proxy root. */
const toolPath = (address: ToolAddress): string => {
const value = String(address);
return value.startsWith("tools.") ? value.slice("tools.".length) : value;
};

/** Serialize one existing schema view as a self-contained MCP input schema. */
const passthroughInputSchema = (view: ToolSchemaView): unknown =>
reattachDefs(
Expand Down Expand Up @@ -1443,6 +1450,7 @@ const registerPassthroughTools = <E extends Cause.YieldableError>(
({ query, integration, owner, connection, limit, offset }, extra) =>
boundary(
Effect.gen(function* () {
const listed = new Map<string, Tool>();
const discovery = {
tools: {
list: (filter?: Parameters<McpToolsPort["list"]>[0]) =>
Expand All @@ -1457,7 +1465,13 @@ const registerPassthroughTools = <E extends Cause.YieldableError>(
? {}
: { connection: ConnectionName.make(connection) }),
})
.pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))),
.pipe(
Effect.tap((items) =>
Effect.sync(() => {
for (const tool of items) listed.set(toolPath(tool.address), tool);
}),
),
),
},
};
// Through the configured provider, not the raw ranker: passthrough
Expand All @@ -1473,18 +1487,17 @@ const registerPassthroughTools = <E extends Cause.YieldableError>(
page.items,
(match) =>
Effect.gen(function* () {
const address = ToolAddress.make(`tools.${match.path}`);
const identity = parseToolAddress(String(address));
if (!identity) return null;
const schema = yield* tools.schema(address);
const tool = listed.get(match.path);
if (!tool) return null;
const schema = yield* tools.schema(tool.address);
// Visibility can change between listing and schema lookup.
if (!schema) return null;
return {
id: String(address),
id: String(tool.address),
name: match.name,
integration: identity.integration,
owner: identity.owner,
connection: identity.connection,
integration: tool.integration,
owner: tool.owner,
connection: tool.connection,
description: match.description,
inputSchema: passthroughInputSchema(schema),
...(schema.annotations ? { annotations: schema.annotations } : {}),
Expand Down Expand Up @@ -1517,7 +1530,6 @@ const registerPassthroughTools = <E extends Cause.YieldableError>(
({ tool: id, arguments: args }, extra) =>
boundary(
Effect.gen(function* () {
const identity = parseToolAddress(id);
const unavailable = {
isError: true,
content: [
Expand All @@ -1527,18 +1539,25 @@ const registerPassthroughTools = <E extends Cause.YieldableError>(
},
],
};
if (!identity) return unavailable;
const address = ToolAddress.make(id);
// Use the existing visibility filter and exclude static configuration tools.
const visible = yield* tools.list({
integration: identity.integration,
owner: identity.owner,
connection: identity.connection,
query: String(identity.tool),
includeAnnotations: false,
});
if (!visible.some((tool) => tool.static !== true && tool.address === address))
return unavailable;
const identity = parseToolAddress(id);
// Executor's own configuration tools are addressed by their fqid, so
// they are found by integration rather than the five-segment address.
const visible = yield* tools.list(
identity
? {
integration: identity.integration,
owner: identity.owner,
connection: identity.connection,
query: String(identity.tool),
includeAnnotations: false,
}
: {
integration: IntegrationSlug.make(id.split(".")[0] ?? id),
includeAnnotations: false,
},
);
if (!visible.some((tool) => tool.address === address)) return unavailable;
const schema = yield* tools.schema(address);
if (!schema) return unavailable;
// The SDK validator checks this dynamic JSON schema at the MCP boundary.
Expand Down
Loading