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
1 change: 1 addition & 0 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
artifacts: executor.artifacts,
connections: executor.connections,
tools: executor.tools,
integrations: executor.integrations,
// Artifacts are on by default, opt-out per connection. A session
// persisted without a value restores to the default, same as a fresh
// connection whose URL says nothing about `?artifacts=`.
Expand Down
29 changes: 29 additions & 0 deletions apps/docs/mcp-proxy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,32 @@ Executor:

Once a client is connected, every integration you add to Executor appears in that
agent automatically.

## Search and invoke mode

Add `?mode=passthrough` to your MCP endpoint, or enable **Search and invoke**
in the Connect card. This mode exposes four tools:

- `integrations`: list connected accounts with integration descriptions, account
labels, and their last recorded health. Results are paginated, with one item per
account. Health is not checked again by this call.
- `skills`: list this server's guides, or read `skills({ name: "search-invoke" })`.
- `search`: find tools and read their JSON input schemas. Use the exact
`integration`, `owner`, and `connection` fields from `integrations` to select an
account.
- `invoke`: call the exact tool ID from search with JSON arguments.

For example, call `integrations({})`, then
`search({ query: "create issue", integration: "github", owner: "org", connection: "main" })`
using the account names returned by your server. Call `invoke` with the matching
result's `id` and arguments that follow its `inputSchema`.

Both `integrations` and `search` return `items`, `total`, `hasMore`, and
`nextOffset`. To get another page, repeat the same query and filters with
`offset: nextOffset`. This pagination finds tools and accounts; each upstream API
has its own pagination for records.

The tool list stays small as you add integrations. Input schemas are loaded only
for matching search results. Your client handles approval for `invoke`, and
workspace block policies still apply. This mode does not expose code execution or
artifact tools.
1 change: 1 addition & 0 deletions apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
artifacts: executor.artifacts,
connections: executor.connections,
tools: executor.tools,
integrations: executor.integrations,
// Artifacts are on by default, opt-out per connection. A session
// persisted without a value restores to the default, same as a fresh
// connection whose URL says nothing about `?artifacts=`.
Expand Down
3 changes: 3 additions & 0 deletions apps/local/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler
artifacts: executor.artifacts,
connections: executor.connections,
tools: executor.tools,
integrations: executor.integrations,
...appsConfig,
},
webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL || undefined,
Expand All @@ -135,6 +136,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler
artifacts: executor.artifacts,
connections: executor.connections,
tools: executor.tools,
integrations: executor.integrations,
...appsConfig,
},
};
Expand All @@ -161,6 +163,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler
artifacts: handle.executor.artifacts,
connections: handle.executor.connections,
tools: handle.executor.tools,
integrations: handle.executor.integrations,
...appsConfig,
},
close: handle.dispose,
Expand Down
2 changes: 1 addition & 1 deletion e2e/cloud/passthrough-opencode-codemode.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Real OpenCode codemode discovers and invokes tools through a two-tool MCP surface.
// Real OpenCode codemode discovers and invokes tools through a fixed MCP tool surface.
// The server retains 10,200 tools; the replay model scripts discovery and a real upstream call.
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
Expand Down
7 changes: 6 additions & 1 deletion e2e/cloud/passthrough-scale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ scenario(
`a ${visible.length}-tool passthrough connect stays bounded (took ${elapsedMs}ms)`,
).toBeLessThan(MAX_PASSTHROUGH_CONNECT_MS);

expect(served.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]);
expect(served.map((tool) => tool.name).sort()).toEqual([
"integrations",
"invoke",
"search",
"skills",
]);
const first = decodeToolSearch(
(yield* session.call("search", { query: "org", limit: 20 })).raw,
).structuredContent;
Expand Down
81 changes: 77 additions & 4 deletions e2e/scenarios/mcp-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
import { createServer, type IncomingMessage } from "node:http";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { Effect, Schema } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
import {
Expand All @@ -16,7 +16,23 @@ import {
import { decodeToolSearch } from "./support/search-invoke";

import { scenario } from "../src/scenario";
import { Api, Mcp, Target } from "../src/services";
import { Api, Browser, Mcp, Target } from "../src/services";
import { visit, settle } from "../src/surfaces/browser";

const decodeInventory = Schema.decodeUnknownSync(
Schema.Struct({
structuredContent: Schema.Struct({
items: Schema.Array(
Schema.Struct({
integration: Schema.String,
owner: Schema.String,
connection: Schema.String,
}),
),
total: Schema.Number,
}),
}),
);

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

Expand Down Expand Up @@ -133,6 +149,7 @@ scenario(
Effect.gen(function* () {
const target = yield* Target;
const mcp = yield* Mcp;
const browser = yield* Browser;
const { client: makeClient } = yield* Api;

const identity = yield* target.newIdentity();
Expand Down Expand Up @@ -170,12 +187,35 @@ scenario(
});
}

yield* browser.session(identity, async ({ page, step }) => {
await step("Choose Search and invoke in the Connect card", async () => {
await visit(page, "/");
await page.getByRole("button", { name: "Advanced" }).click();
await page.getByRole("switch", { name: "Search and invoke" }).check();
await settle(page);
expect(await page.locator("code").first().innerText()).toContain("mode=passthrough");
expect(
await page
.getByText(
"Discover connected accounts with integrations and read the guide with skills.",
{ exact: false },
)
.isVisible(),
).toBe(true);
});
});

const codemode = mcp.session(identity);
expect(yield* codemode.listTools()).toContain("execute");

const passthrough = mcp.session(identity, { mode: "passthrough" });
const described = yield* passthrough.describeTools();
expect(described.map((tool) => tool.name).sort()).toEqual(["invoke", "search"]);
expect(described.map((tool) => tool.name).sort()).toEqual([
"integrations",
"invoke",
"search",
"skills",
]);
expect(described.find((tool) => tool.name === "search")?.annotations).toMatchObject({
readOnlyHint: true,
destructiveHint: false,
Expand All @@ -184,9 +224,42 @@ scenario(
readOnlyHint: false,
destructiveHint: true,
});
const inventory = yield* passthrough.call("integrations", {
integration: slug,
owner: "org",
});
expect(inventory.ok).toBe(true);
const decodedInventory = decodeInventory(inventory.raw).structuredContent;
expect(decodedInventory).toEqual({
items: [{ integration: slug, owner: "org", connection: "main" }],
total: 1,
});
expect(inventory.text).not.toContain(`tok_${slug}`);
const skills = yield* passthrough.call("skills", {});
expect(skills.ok).toBe(true);
expect(skills.text).toContain("search-invoke");
const guide = yield* passthrough.call("skills", { name: "search-invoke" });
expect(guide.ok).toBe(true);
expect(guide.text).toContain("integrations({})");
expect((yield* passthrough.call("skills", { name: "execute" })).ok).toBe(false);
const found = decodeToolSearch(
(yield* passthrough.call("search", { query: slug })).raw,
(yield* passthrough.call("search", {
query: "notes",
integration: slug,
owner: "org",
connection: "main",
})).raw,
).structuredContent;
expect(found.items.every((tool) => tool.integration === slug)).toBe(true);
const missingAccount = decodeToolSearch(
(yield* passthrough.call("search", {
query: "notes",
integration: slug,
owner: "user",
connection: "main",
})).raw,
).structuredContent;
expect(missingAccount.items).toEqual([]);
const listDef = found.items.find((tool) => tool.id.endsWith(".listNotes"));
const createDef = found.items.find((tool) => tool.id.endsWith(".createNote"));
if (!listDef || !createDef) return yield* Effect.die("Search omitted notes operations");
Expand Down
1 change: 1 addition & 0 deletions packages/core/api/src/server/mcp-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const makeMcpBuildServer =
artifacts: executor.artifacts,
connections: executor.connections,
tools: executor.tools,
integrations: executor.integrations,
...(hostOptions?.loadAppShellHtml
? { loadAppShellHtml: hostOptions.loadAppShellHtml }
: {}),
Expand Down
Loading
Loading