diff --git a/.changeset/mcp-no-org-moved-to-v2.md b/.changeset/mcp-no-org-moved-to-v2.md new file mode 100644 index 0000000000..42cf32df11 --- /dev/null +++ b/.changeset/mcp-no-org-moved-to-v2.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +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. diff --git a/apps/cloud/src/mcp/auth-provider.test.ts b/apps/cloud/src/mcp/auth-provider.test.ts index 7a6d05f4cf..2e1ffbe6b2 100644 --- a/apps/cloud/src/mcp/auth-provider.test.ts +++ b/apps/cloud/src/mcp/auth-provider.test.ts @@ -48,6 +48,17 @@ const request = () => body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }), }); +// The bare `/mcp`, which pins no organization in the URL. +const bareRequest = () => + new Request("https://executor.sh/mcp", { + method: "POST", + headers: { + authorization: "Bearer token_fixture", + "content-type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1 }), + }); + // A verified bearer that carries an account + org — the org-authorize branch is // what we exercise, so verifyBearer succeeds here. const stubAuth = Layer.succeed(McpAuth)({ @@ -55,6 +66,13 @@ const stubAuth = Layer.succeed(McpAuth)({ Effect.succeed(mcpAuthorized({ accountId: ACCOUNT_ID, organizationId: ORG_ID })), }); +// A verified bearer whose token names no organization: an account signed in +// through an MCP client's OAuth with no v1 organization. +const stubAuthNoOrganization = Layer.succeed(McpAuth)({ + verifyBearer: () => + Effect.succeed(mcpAuthorized({ accountId: ACCOUNT_ID, organizationId: null })), +}); + const stubAuthMissingBearer = Layer.succeed(McpAuth)({ verifyBearer: () => Effect.succeed(mcpUnauthorized("missing_bearer")), }); @@ -87,10 +105,11 @@ class StubInfraError extends Data.TaggedError("StubInfraError")<{ const authenticateWith = ( orgAuth: Layer.Layer, auth: Layer.Layer = stubAuth, + makeRequest: () => Request = request, ) => Effect.gen(function* () { const provider = yield* McpAuthProvider; - return yield* provider.authenticate(request()); + return yield* provider.authenticate(makeRequest()); }).pipe( Effect.provide(cloudMcpAuthProviderLayer.pipe(Layer.provide(Layer.mergeAll(auth, orgAuth)))), ); @@ -200,6 +219,60 @@ describe("cloud MCP org-authorization classification", () => { }), ); + it.effect( + "no organization in the token or the URL -> Forbidden naming Executor v2's MCP URL, with no lookup", + () => + Effect.gen(function* () { + const selectors: Array = []; + const outcome = yield* authenticateWith( + Layer.succeed(McpOrganizationAuth)({ + authorize: (_accountId, selector) => { + selectors.push(selector); + return Effect.succeed(null); + }, + }), + stubAuthNoOrganization, + bareRequest, + ); + expect(outcome).toEqual({ + _tag: "Forbidden", + code: -32001, + message: + "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.", + }); + expect(selectors, "nothing names an organization to look up").toEqual([]); + }), + ); + + it.effect("a token naming no organization still opens the organization its URL pins", () => + Effect.gen(function* () { + const selectors: Array = []; + const outcome = yield* authenticateWith( + Layer.succeed(McpOrganizationAuth)({ + authorize: (_accountId, selector) => { + selectors.push(selector); + return Effect.succeed({ id: ORG_ID, name: "Stub Org", memberRole: "member" }); + }, + }), + stubAuthNoOrganization, + ); + const principal = Predicate.isTagged(outcome, "Authenticated") ? outcome.principal : null; + expect(principal?.organizationId).toBe(ORG_ID); + expect(selectors).toEqual([ORG_ID]); + }), + ); + + it.effect("a revoked membership keeps its own Forbidden copy", () => + Effect.gen(function* () { + const outcome = yield* authenticateWith(stubOrgAuthNoMembership); + expect(outcome).toEqual({ + _tag: "Forbidden", + code: -32001, + message: "No organization in session — log in via the web app first", + }); + }), + ); + it.effect("missing bearer still short-circuits to Unauthorized", () => Effect.gen(function* () { const outcome = yield* authenticateWith(stubOrgAuthActive, stubAuthMissingBearer); diff --git a/e2e/cloud/mcp-no-organization-moved.test.ts b/e2e/cloud/mcp-no-organization-moved.test.ts new file mode 100644 index 0000000000..5b079a3ea0 --- /dev/null +++ b/e2e/cloud/mcp-no-organization-moved.test.ts @@ -0,0 +1,64 @@ +// Cloud: an MCP client that signs in to an account with no organization. +// +// New sign-ups happen on Executor v2, so an account that signs in through an +// MCP client's OAuth here and has no organization cannot be served: there is +// nothing to scope a session to. The client gets a Forbidden whose message +// says Executor moved and names v2's MCP URL to connect instead, rather than +// a pointer back to this web app. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Mcp, Target } from "../src/services"; +import type { Identity } from "../src/target"; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +type JsonRpcError = { + readonly jsonrpc: string; + readonly error: { readonly code: number; readonly message: string }; +}; + +scenario( + "MCP sessions · an account with no organization is told to connect Executor v2's MCP URL", + {}, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity({ org: false }); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + + const response = yield* Effect.promise(() => + fetch(target.mcpUrl, { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + authorization: `Bearer ${bearer}`, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "executor-e2e-no-organization", version: "0.0.1" }, + }, + }), + }), + ); + const body = (yield* Effect.promise(() => response.json())) as JsonRpcError; + + expect(response.status, "authenticated, but there is no organization to serve").toBe(403); + expect( + response.headers.get("www-authenticate"), + "no challenge: signing in again would land on the same account", + ).toBeNull(); + expect(response.headers.get("mcp-session-id"), "no session opens").toBeNull(); + expect(body.error.code).toBe(-32001); + expect(body.error.message).toBe( + "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.", + ); + }), +);