Skip to content

Commit bdfda42

Browse files
authored
Test the moved-to-v2 answer for MCP sign-ins with no organization (#2495)
1 parent 5a2a032 commit bdfda42

3 files changed

Lines changed: 143 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/cloud": patch
3+
---
4+
5+
An MCP client signed in to an account with no Executor Cloud organization is now told that Executor has moved and to connect `https://mcp.executor.sh/mcp`, instead of being sent to the web app.

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

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,31 @@ const request = () =>
4848
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }),
4949
});
5050

51+
// The bare `/mcp`, which pins no organization in the URL.
52+
const bareRequest = () =>
53+
new Request("https://executor.sh/mcp", {
54+
method: "POST",
55+
headers: {
56+
authorization: "Bearer token_fixture",
57+
"content-type": "application/json",
58+
},
59+
body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1 }),
60+
});
61+
5162
// A verified bearer that carries an account + org — the org-authorize branch is
5263
// what we exercise, so verifyBearer succeeds here.
5364
const stubAuth = Layer.succeed(McpAuth)({
5465
verifyBearer: () =>
5566
Effect.succeed(mcpAuthorized({ accountId: ACCOUNT_ID, organizationId: ORG_ID })),
5667
});
5768

69+
// A verified bearer whose token names no organization: an account signed in
70+
// through an MCP client's OAuth with no v1 organization.
71+
const stubAuthNoOrganization = Layer.succeed(McpAuth)({
72+
verifyBearer: () =>
73+
Effect.succeed(mcpAuthorized({ accountId: ACCOUNT_ID, organizationId: null })),
74+
});
75+
5876
const stubAuthMissingBearer = Layer.succeed(McpAuth)({
5977
verifyBearer: () => Effect.succeed(mcpUnauthorized("missing_bearer")),
6078
});
@@ -87,10 +105,11 @@ class StubInfraError extends Data.TaggedError("StubInfraError")<{
87105
const authenticateWith = (
88106
orgAuth: Layer.Layer<McpOrganizationAuth>,
89107
auth: Layer.Layer<McpAuth> = stubAuth,
108+
makeRequest: () => Request = request,
90109
) =>
91110
Effect.gen(function* () {
92111
const provider = yield* McpAuthProvider;
93-
return yield* provider.authenticate(request());
112+
return yield* provider.authenticate(makeRequest());
94113
}).pipe(
95114
Effect.provide(cloudMcpAuthProviderLayer.pipe(Layer.provide(Layer.mergeAll(auth, orgAuth)))),
96115
);
@@ -200,6 +219,60 @@ describe("cloud MCP org-authorization classification", () => {
200219
}),
201220
);
202221

222+
it.effect(
223+
"no organization in the token or the URL -> Forbidden naming Executor v2's MCP URL, with no lookup",
224+
() =>
225+
Effect.gen(function* () {
226+
const selectors: Array<string> = [];
227+
const outcome = yield* authenticateWith(
228+
Layer.succeed(McpOrganizationAuth)({
229+
authorize: (_accountId, selector) => {
230+
selectors.push(selector);
231+
return Effect.succeed(null);
232+
},
233+
}),
234+
stubAuthNoOrganization,
235+
bareRequest,
236+
);
237+
expect(outcome).toEqual({
238+
_tag: "Forbidden",
239+
code: -32001,
240+
message:
241+
"Executor has moved. This account has no organization on executor.sh/mcp: connect https://mcp.executor.sh/mcp instead. If you belong to an organization on executor.sh, reconnect this server and sign in again to choose it.",
242+
});
243+
expect(selectors, "nothing names an organization to look up").toEqual([]);
244+
}),
245+
);
246+
247+
it.effect("a token naming no organization still opens the organization its URL pins", () =>
248+
Effect.gen(function* () {
249+
const selectors: Array<string> = [];
250+
const outcome = yield* authenticateWith(
251+
Layer.succeed(McpOrganizationAuth)({
252+
authorize: (_accountId, selector) => {
253+
selectors.push(selector);
254+
return Effect.succeed({ id: ORG_ID, name: "Stub Org", memberRole: "member" });
255+
},
256+
}),
257+
stubAuthNoOrganization,
258+
);
259+
const principal = Predicate.isTagged(outcome, "Authenticated") ? outcome.principal : null;
260+
expect(principal?.organizationId).toBe(ORG_ID);
261+
expect(selectors).toEqual([ORG_ID]);
262+
}),
263+
);
264+
265+
it.effect("a revoked membership keeps its own Forbidden copy", () =>
266+
Effect.gen(function* () {
267+
const outcome = yield* authenticateWith(stubOrgAuthNoMembership);
268+
expect(outcome).toEqual({
269+
_tag: "Forbidden",
270+
code: -32001,
271+
message: "No organization in session — log in via the web app first",
272+
});
273+
}),
274+
);
275+
203276
it.effect("missing bearer still short-circuits to Unauthorized", () =>
204277
Effect.gen(function* () {
205278
const outcome = yield* authenticateWith(stubOrgAuthActive, stubAuthMissingBearer);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Cloud: an MCP client that signs in to an account with no organization.
2+
//
3+
// New sign-ups happen on Executor v2, so an account that signs in through an
4+
// MCP client's OAuth here and has no organization cannot be served: there is
5+
// nothing to scope a session to. The client gets a Forbidden whose message
6+
// says Executor moved and names v2's MCP URL to connect instead, rather than
7+
// a pointer back to this web app.
8+
import { expect } from "@effect/vitest";
9+
import { Effect } from "effect";
10+
11+
import { scenario } from "../src/scenario";
12+
import { Mcp, Target } from "../src/services";
13+
import type { Identity } from "../src/target";
14+
15+
const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
16+
17+
type JsonRpcError = {
18+
readonly jsonrpc: string;
19+
readonly error: { readonly code: number; readonly message: string };
20+
};
21+
22+
scenario(
23+
"MCP sessions · an account with no organization is told to connect Executor v2's MCP URL",
24+
{},
25+
Effect.gen(function* () {
26+
const target = yield* Target;
27+
const mcp = yield* Mcp;
28+
const identity = yield* target.newIdentity({ org: false });
29+
const bearer = yield* mcp.mintBearer(emailOf(identity));
30+
31+
const response = yield* Effect.promise(() =>
32+
fetch(target.mcpUrl, {
33+
method: "POST",
34+
headers: {
35+
accept: "application/json, text/event-stream",
36+
"content-type": "application/json",
37+
authorization: `Bearer ${bearer}`,
38+
},
39+
body: JSON.stringify({
40+
jsonrpc: "2.0",
41+
id: 1,
42+
method: "initialize",
43+
params: {
44+
protocolVersion: "2025-03-26",
45+
capabilities: {},
46+
clientInfo: { name: "executor-e2e-no-organization", version: "0.0.1" },
47+
},
48+
}),
49+
}),
50+
);
51+
const body = (yield* Effect.promise(() => response.json())) as JsonRpcError;
52+
53+
expect(response.status, "authenticated, but there is no organization to serve").toBe(403);
54+
expect(
55+
response.headers.get("www-authenticate"),
56+
"no challenge: signing in again would land on the same account",
57+
).toBeNull();
58+
expect(response.headers.get("mcp-session-id"), "no session opens").toBeNull();
59+
expect(body.error.code).toBe(-32001);
60+
expect(body.error.message).toBe(
61+
"Executor has moved. This account has no organization on executor.sh/mcp: connect https://mcp.executor.sh/mcp instead. If you belong to an organization on executor.sh, reconnect this server and sign in again to choose it.",
62+
);
63+
}),
64+
);

0 commit comments

Comments
 (0)