Skip to content
Open
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/modern-mcp-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": minor
---

Add automatic MCP 2026-07-28 negotiation for HTTP, SSE, and stdio connections, including the `executor mcp` stdio bridge, while preserving legacy server compatibility.
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,16 @@ jobs:
# scenario boots its own `executor web`). Run just the stdio MCP scenario
# here: it is the auto-connect / env-as-secret regression guard, and
# running it alone avoids the boot-resource accumulation and the
# pre-existing browser flakiness of the rest of the local suite. Expanding
# pre-existing browser flakiness of the rest of the local suite. The CLI
# protocol scenario also guards modern `server/discover` forwarding and
# legacy `initialize` compatibility through the real stdio bridge. Expanding
# to the full `local` project (bun run test:local) is a follow-up once
# those are stabilized.
- name: Run the stdio MCP scenario
run: bunx vitest run --project local local/stdio-mcp.test.ts
- name: Run the stdio MCP scenarios
run: >-
bunx vitest run --project local
local/stdio-mcp.test.ts
local/cli-mcp-protocol.test.ts
working-directory: e2e

desktop-smoke:
Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@executor-js/runtime-quickjs": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sentry/bun": "^10.57.0",
"effect": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Cause from "effect/Cause";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";

Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ declare global {
MCP_RESOURCE_ORIGIN?: string;
MCP_SESSION_TIMEOUT_MS?: string;
MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
/** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
MCP_2026_07_28_ENABLED?: string;
NODE_ENV?: string;

// Shared with frontend
Expand Down
43 changes: 41 additions & 2 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
McpAuthProvider,
jsonRpcErrorBody,
defaultMcpResource,
isLegacyMcpRequest,
mcpResourceKey,
validateMcpRequestAuthority,
UNAVAILABLE_RETRY_AFTER_SECONDS,
type AuthOutcome,
type McpResource,
Expand All @@ -31,7 +34,7 @@ const corsPreflightResponse = (): Response =>
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
"access-control-allow-headers":
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name",
"access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
},
});
Expand All @@ -46,6 +49,17 @@ const jsonRpcResponse = (
? jsonRpcErrorBody(status, code, message)
: jsonRpcErrorBody(status, code, message, { challenge });

const withCors = (response: Response): Response => {
const headers = new Headers(response.headers);
headers.set("access-control-allow-origin", "*");
headers.set("access-control-expose-headers", "mcp-session-id, WWW-Authenticate");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};

const renderAuthError = (
auth: McpAuthProvider["Service"],
request: Request,
Expand Down Expand Up @@ -167,6 +181,8 @@ export const makeCloudMcpAgentHandler = () => {
if (!ALLOWED_METHODS.has(request.method)) {
return jsonRpcResponse(405, -32001, "Method not allowed");
}
const authorityRejection = validateMcpRequestAuthority(request);
if (authorityRejection) return authorityRejection;
const sessionId = request.headers.get("mcp-session-id");

const { auth, outcome } = await runTraced(request, authenticate(request));
Expand All @@ -188,6 +204,30 @@ export const makeCloudMcpAgentHandler = () => {
return renderAuthError(auth, request, outcome);
}

const resource = resourceFromPath(request);
if (!(await isLegacyMcpRequest(request))) {
if (env.MCP_2026_07_28_ENABLED === "false") {
return jsonRpcResponse(400, -32022, "MCP 2026-07-28 support is disabled");
}
const props = await runTraced(
request,
propsForPrincipal(request, outcome.principal, resource),
);
const flowId = JSON.stringify([
"modern",
outcome.principal.accountId,
outcome.principal.organizationId,
mcpResourceKey(resource),
]);
const response = await mcpSessionStub(env.MCP_SESSION, flowId).handleModernRequest(
request,
outcome.principal,
props.session,
props.propagation,
);
return wrapMcpSseResponse(request, env, withCors(response));
}

if (!sessionId && request.method === "DELETE") {
// Matches the old envelope's contract (@modelcontextprotocol/sdk's
// `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
Expand Down Expand Up @@ -217,7 +257,6 @@ export const makeCloudMcpAgentHandler = () => {
}
}

const resource = resourceFromPath(request);
const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withVerifiedIdentityHeaders(
Expand Down
1 change: 1 addition & 0 deletions apps/host-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@cloudflare/workers-types": "^4.20250410.0",
"@effect/vitest": "catalog:",
"@executor-js/vite-plugin": "workspace:*",
"@modelcontextprotocol/client": "2.0.0",
"@tailwindcss/vite": "catalog:",
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.162.0",
Expand Down
2 changes: 2 additions & 0 deletions apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface CloudflareEnv {
* behind Access, or the instance is wide open.
*/
readonly ENABLE_DEV_AUTH?: string;
/** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
readonly MCP_2026_07_28_ENABLED?: string;
}

export interface CloudflareConfig {
Expand Down
42 changes: 41 additions & 1 deletion apps/host-cloudflare/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {
McpAuthProvider,
jsonRpcErrorBody,
defaultMcpResource,
isLegacyMcpRequest,
mcpResourceKey,
validateMcpRequestAuthority,
type AuthOutcome,
type Principal,
} from "@executor-js/host-mcp";
Expand All @@ -27,7 +30,7 @@ const corsPreflightResponse = (): Response =>
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
"access-control-allow-headers":
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name",
"access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
},
});
Expand All @@ -42,6 +45,17 @@ const jsonRpcResponse = (
? jsonRpcErrorBody(status, code, message)
: jsonRpcErrorBody(status, code, message, { challenge });

const withCors = (response: Response): Response => {
const headers = new Headers(response.headers);
headers.set("access-control-allow-origin", "*");
headers.set("access-control-expose-headers", "mcp-session-id, WWW-Authenticate");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};

const renderAuthError = (
auth: McpAuthProvider["Service"],
request: Request,
Expand Down Expand Up @@ -95,9 +109,15 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
binding: "MCP_SESSION",
transport: "streamable-http",
});
const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);

return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
if (request.method === "OPTIONS") return corsPreflightResponse();
if (!ALLOWED_METHODS.has(request.method)) {
return jsonRpcResponse(405, -32001, "Method not allowed");
}
const authorityRejection = validateMcpRequestAuthority(request);
if (authorityRejection) return authorityRejection;
const sessionId = request.headers.get("mcp-session-id");

const { auth, outcome } = await Effect.runPromise(authenticate(request, config));
Expand All @@ -114,6 +134,26 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
return renderAuthError(auth, request, outcome);
}

if (!(await isLegacyMcpRequest(request))) {
if (env.MCP_2026_07_28_ENABLED === "false") {
return jsonRpcResponse(400, -32022, "MCP 2026-07-28 support is disabled");
}
const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
const flowId = JSON.stringify([
"modern",
outcome.principal.accountId,
outcome.principal.organizationId,
mcpResourceKey(defaultMcpResource),
]);
const response = await mcpSessionStub(env.MCP_SESSION, flowId).handleModernRequest(
request,
outcome.principal,
props.session,
props.propagation,
);
return withCors(response);
}

if (!sessionId && request.method === "DELETE") {
return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
}
Expand Down
104 changes: 104 additions & 0 deletions apps/host-cloudflare/src/worker.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { unstable_dev, type Unstable_DevWorker } from "wrangler";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import {
Client as ModernClient,
StreamableHTTPClientTransport as ModernStreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsoft";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -389,6 +393,106 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
expect(result.result?.structuredContent?.result).toBe(42);
}, 60_000);

it("discovers, lists, and executes over stateless MCP 2026-07-28", async () => {
const transport = new ModernStreamableHTTPClientTransport(
new URL("/mcp", `http://${worker.address}:${worker.port}`),
);
const client = new ModernClient(
{ name: "cloudflare-modern-test", version: "1" },
{
capabilities: { elicitation: { form: {}, url: {} } },
versionNegotiation: { mode: "auto" },
},
);

await client.connect(transport);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- test cleanup boundary: the network client must close when an assertion fails.
try {
expect(client.getProtocolEra()).toBe("modern");
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toEqual(["execute", "skills"]);
expect(transport.sessionId).toBeUndefined();

const result = await client.callTool({
name: "execute",
arguments: { code: "export default 6 * 7" },
});
expect(result.structuredContent).toMatchObject({ status: "completed", result: 42 });
expect(transport.sessionId).toBeUndefined();

const [left, right] = await Promise.all([
client.callTool({
name: "execute",
arguments: { code: 'export default "left"' },
}),
client.callTool({
name: "execute",
arguments: { code: 'export default "right"' },
}),
]);
expect(left.structuredContent).toMatchObject({ status: "completed", result: "left" });
expect(right.structuredContent).toMatchObject({ status: "completed", result: "right" });
expect(transport.sessionId).toBeUndefined();

let receivedElicitation = false;
client.setRequestHandler("elicitation/create", () => {
receivedElicitation = true;
return { action: "accept", content: {} };
});
const resumed = await client.callTool({
name: "execute",
arguments: {
code: [
"return await tools.executor.coreTools.policies.create({",
' owner: "org",',
` pattern: "modern-input-required-${runId}.*",`,
' action: "require_approval"',
"});",
].join("\n"),
},
});
expect(receivedElicitation).toBe(true);
expect(resumed.isError).toBeFalsy();
expect(transport.sessionId).toBeUndefined();
} finally {
await client.close();
}
}, 60_000);

it("falls back to legacy MCP when modern support is rolled back", async () => {
const rollbackWorker = await unstable_dev(resolve(dir, "worker.ts"), {
config: resolve(dir, "../wrangler.jsonc"),
ip: "127.0.0.1",
local: true,
persist: false,
experimental: { disableExperimentalWarning: true },
vars: {
EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",
ENABLE_DEV_AUTH: "true",
MCP_2026_07_28_ENABLED: "false",
},
});
const transport = new ModernStreamableHTTPClientTransport(
new URL("/mcp", `http://${rollbackWorker.address}:${rollbackWorker.port}`),
);
const client = new ModernClient(
{ name: "cloudflare-rollback-test", version: "1" },
{ versionNegotiation: { mode: "auto" } },
);

// oxlint-disable-next-line executor/no-try-catch-or-throw -- test cleanup boundary: both the network client and temporary worker must close on failure.
try {
await client.connect(transport);
expect(client.getProtocolEra()).toBe("legacy");
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toContain("execute");
expect(transport.sessionId).toBeTruthy();
} finally {
await client.close();
await rollbackWorker.stop();
}
}, 120_000);

it("delivers native elicitation on the approval-gated tool call stream", async () => {
const client = new Client(
{ name: "native-elicitation-test", version: "1.0.0" },
Expand Down
1 change: 1 addition & 0 deletions apps/host-selfhost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"devDependencies": {
"@effect/vitest": "catalog:",
"@executor-js/vite-plugin": "workspace:*",
"@modelcontextprotocol/client": "2.0.0",
"@tailwindcss/vite": "catalog:",
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.162.0",
Expand Down
Loading