Skip to content
Draft
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-js/plugin-mcp": minor
---

Add automatic MCP 2026-07-28 negotiation for HTTP, SSE, and stdio connections while preserving legacy server compatibility.
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
90 changes: 90 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,92 @@ 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();

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
35 changes: 35 additions & 0 deletions apps/host-selfhost/src/mcp/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterAll, expect, test } from "@effect/vitest";
import {
Client as ModernClient,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";

import { mintInviteCode } from "../testing/mint-invite";

Expand Down Expand Up @@ -87,6 +91,37 @@ test("an authenticated MCP client initializes, lists tools, and executes code",
expect(JSON.stringify(await call.json())).toContain("42");
});

test("an authenticated MCP 2026-07-28 client discovers, lists, and executes statelessly", async () => {
const token = await signUp("modern@mcp.test");
const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), {
requestInit: { headers: { authorization: `Bearer ${token}` } },
fetch: (url, init) =>
handler(url instanceof Request ? new Request(url, init) : new Request(url.toString(), init)),
});
const client = new ModernClient(
{ name: "selfhost-modern-test", version: "1" },
{ versionNegotiation: { mode: "auto" } },
);

await client.connect(transport);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- test cleanup boundary: the 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();
} finally {
await client.close();
}
});

test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {
const alice = await signUp("alice2@mcp.test");
const bob = await signUp("bob2@mcp.test");
Expand Down
2 changes: 1 addition & 1 deletion apps/host-selfhost/src/mcp/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const makeSelfHostMcpSessionStore = (
selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }),
},
),
{ webBaseUrl },
{ webBaseUrl, modernEnabled: process.env.MCP_2026_07_28_ENABLED !== "false" },
);

/** The `McpSessionStore` envelope seam over a freshly built in-process store. */
Expand Down
1 change: 1 addition & 0 deletions apps/local/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler
localAnalytics.record(`artifact_${action}`, { via: "agent" }),
};
mcp = createMcpRequestHandler({
modernEnabled: process.env.MCP_2026_07_28_ENABLED !== "false",
defaultConfig: {
engine,
artifacts: executor.artifacts,
Expand Down
Loading