Skip to content

Commit d49434d

Browse files
committed
feat(mcp): add persistent client channels
1 parent 906bee5 commit d49434d

42 files changed

Lines changed: 3491 additions & 154 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/cloud/src/env-augment.d.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
declare global {
66
namespace Cloudflare {
77
interface Env {
8+
MCP_CLIENT_CHANNEL: DurableObjectNamespace<
9+
import("./mcp/client-channel-durable-object").McpClientChannelDO
10+
>;
811
/** TOTP enrollment and verification attempts; absence refuses verification. */
912
ADMIN_MFA_RATE_LIMITER?: {
1013
readonly limit: (options: {

‎apps/cloud/src/mcp/agent-handler.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
3030

3131
import { wrapMcpSseResponse } from "../observability/memory-metrics";
3232
import { WorkerTelemetryLive } from "../observability/telemetry";
33-
import { cloudMcpAuth } from "./auth-provider";
33+
import { authenticateClientChannelToken, cloudMcpAuth } from "./auth-provider";
3434
import { isMcpSessionMetaUnavailable } from "./session-meta";
3535
import { McpSessionDOSqlite } from "./session-durable-object";
3636
import { parseTraceparent } from "./traceparent";
@@ -44,7 +44,7 @@ const corsPreflightResponse = (): Response =>
4444
"access-control-allow-origin": "*",
4545
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
4646
"access-control-allow-headers":
47-
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
47+
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version, x-executor-client-token",
4848
"access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
4949
},
5050
});
@@ -171,6 +171,8 @@ const resourceFromPath = (request: Request): McpResource => {
171171
const propsForPrincipal = (
172172
request: Request,
173173
principal: Extract<AuthOutcome, { readonly _tag: "Authenticated" }>["principal"],
174+
clientIdentity: string | undefined,
175+
clientManifestGeneration: number | undefined,
174176
resource: McpResource,
175177
): Effect.Effect<McpSessionProps> =>
176178
Effect.gen(function* () {
@@ -187,6 +189,8 @@ const propsForPrincipal = (
187189
...(principal.organizationSlug ? { organizationSlug: principal.organizationSlug } : {}),
188190
...sessionOrgRoleMetadata(principal),
189191
userId: principal.accountId,
192+
...(clientIdentity === undefined ? {} : { clientIdentity }),
193+
...(clientManifestGeneration === undefined ? {} : { clientManifestGeneration }),
190194
elicitationMode: readElicitationMode(request),
191195
artifactsEnabled: readArtifactsEnabled(request),
192196
searchToolsEnabled: readSearchToolsEnabled(request),
@@ -246,6 +250,23 @@ export const makeCloudMcpAgentHandler = () => {
246250
return renderAuthError(auth, request, outcome);
247251
}
248252

253+
// A channel token is only a session binding layered on top of the normal
254+
// cloud bearer auth above. Its opaque value selects a DO; identity is read
255+
// from durable server-side state and must match the bearer principal.
256+
const channelToken = request.headers.get("x-executor-client-token");
257+
let clientIdentity = outcome.clientIdentity;
258+
let clientManifestGeneration = outcome.clientManifestGeneration;
259+
if (channelToken !== null) {
260+
const channelIdentity = await authenticateClientChannelToken(
261+
env.MCP_CLIENT_CHANNEL,
262+
channelToken,
263+
outcome.principal,
264+
);
265+
if (!channelIdentity) return jsonRpcResponse(401, -32001, "Invalid client channel token");
266+
clientIdentity = channelIdentity.binding;
267+
clientManifestGeneration = channelIdentity.generation;
268+
}
269+
249270
if (!sessionId && request.method === "DELETE") {
250271
// Matches the old envelope's contract (@modelcontextprotocol/sdk's
251272
// `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
@@ -266,6 +287,8 @@ export const makeCloudMcpAgentHandler = () => {
266287
{
267288
accountId: outcome.principal.accountId,
268289
organizationId: outcome.principal.organizationId,
290+
...(clientIdentity === undefined ? {} : { clientIdentity }),
291+
...(clientManifestGeneration === undefined ? {} : { clientManifestGeneration }),
269292
},
270293
resource,
271294
);
@@ -297,7 +320,16 @@ export const makeCloudMcpAgentHandler = () => {
297320
}
298321
}
299322

300-
const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
323+
const props = await runTraced(
324+
request,
325+
propsForPrincipal(
326+
request,
327+
outcome.principal,
328+
clientIdentity,
329+
clientManifestGeneration,
330+
resource,
331+
),
332+
);
301333
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
302334
const forwarded = withOrgWriteAccess(
303335
withVerifiedIdentityHeaders(

‎apps/cloud/src/mcp/auth-provider.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ import { McpAuthProvider, orgWriteAccessForPrincipal } from "@executor-js/host-m
2626

2727
import { WorkOSError } from "../auth/errors";
2828
import { cloudMcpAuthProviderLayer } from "./auth-provider";
29+
import { authenticateClientChannelToken } from "./auth-provider";
2930
import {
3031
MCP_ORGANIZATION_HEADER,
3132
McpAuth,
3233
McpOrganizationAuth,
3334
mcpAuthorized,
3435
mcpUnauthorized,
3536
} from "./auth";
37+
import { authenticateClientToken, createClientChannelState } from "./client-channel";
3638

3739
const ACCOUNT_ID = "user_test";
3840
const ORG_ID = "org_test";
@@ -96,6 +98,65 @@ const authenticateWith = (
9698
);
9799

98100
describe("cloud MCP org-authorization classification", () => {
101+
it("resolves channel credentials to durable identity and generation, rejecting invalid tokens", () => {
102+
const state = createClientChannelState();
103+
const binding = state.register(
104+
{ accountId: ACCOUNT_ID, organizationId: ORG_ID },
105+
{
106+
tools: [{ name: "alpha" }],
107+
resources: [],
108+
prompts: [],
109+
},
110+
);
111+
expect(typeof binding).toBe("string");
112+
if (typeof binding !== "string") return;
113+
expect(authenticateClientToken(state, binding)).toEqual({
114+
principal: { accountId: ACCOUNT_ID, organizationId: ORG_ID },
115+
generation: 0,
116+
binding,
117+
});
118+
expect(authenticateClientToken(state, "forged-token")).toBeNull();
119+
state.disconnect(binding);
120+
expect(authenticateClientToken(state, binding)).toBeNull();
121+
});
122+
123+
it("authenticates supplied channel tokens against verified tenant identity", async () => {
124+
const state = createClientChannelState();
125+
const binding = state.register(
126+
{ accountId: ACCOUNT_ID, organizationId: ORG_ID },
127+
{
128+
tools: [{ name: "alpha" }],
129+
resources: [],
130+
prompts: [],
131+
},
132+
);
133+
expect(typeof binding).toBe("string");
134+
if (typeof binding !== "string") return;
135+
const namespace = {
136+
idFromName: (name: string) => name,
137+
get: () => ({
138+
authenticate: (token: string) => Promise.resolve(authenticateClientToken(state, token)),
139+
}),
140+
};
141+
await expect(
142+
authenticateClientChannelToken(namespace, binding, {
143+
accountId: ACCOUNT_ID,
144+
organizationId: ORG_ID,
145+
}),
146+
).resolves.toMatchObject({ binding, generation: 0 });
147+
await expect(
148+
authenticateClientChannelToken(namespace, "invalid-token", {
149+
accountId: ACCOUNT_ID,
150+
organizationId: ORG_ID,
151+
}),
152+
).resolves.toBeNull();
153+
await expect(
154+
authenticateClientChannelToken(namespace, binding, {
155+
accountId: "other",
156+
organizationId: ORG_ID,
157+
}),
158+
).resolves.toBeNull();
159+
});
99160
it.effect("WorkOS 5xx -> Unavailable (retryable 503, session preserved)", () =>
100161
Effect.gen(function* () {
101162
const outcome = yield* authenticateWith(stubOrgAuthFailing(new WorkOSError({ status: 503 })));

‎apps/cloud/src/mcp/auth-provider.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ import {
6767
authorizationServerMetadataResponse,
6868
protectedResourceMetadataResponse,
6969
} from "./oauth-metadata";
70+
import type { McpClientChannelDO } from "./client-channel-durable-object";
71+
import { clientChannelBelongsToPrincipal } from "./client-channel";
72+
import { mcpClientChannelIdFromName } from "@executor-js/cloudflare/mcp/execution-owner-directory";
73+
74+
export interface McpClientChannelNamespace<Id> {
75+
readonly idFromName: (name: string) => Id;
76+
readonly get: (id: Id) => Pick<McpClientChannelDO, "authenticate">;
77+
}
78+
79+
export const authenticateClientChannelToken = async <Id>(
80+
namespace: McpClientChannelNamespace<Id>,
81+
token: string,
82+
principal: Pick<Principal, "accountId" | "organizationId">,
83+
) => {
84+
if (!/^[0-9a-f-]{36}$/i.test(token)) return null;
85+
const channel = namespace.get(mcpClientChannelIdFromName(namespace, token));
86+
const identity = await channel.authenticate(token);
87+
if (!clientChannelBelongsToPrincipal(identity, principal)) return null;
88+
return identity;
89+
};
7090

7191
const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
7292
const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;

0 commit comments

Comments
 (0)