Skip to content
Merged
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/mcp-no-org-moved-to-v2.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 74 additions & 1 deletion apps/cloud/src/mcp/auth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,31 @@ 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)({
verifyBearer: () =>
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")),
});
Expand Down Expand Up @@ -87,10 +105,11 @@ class StubInfraError extends Data.TaggedError("StubInfraError")<{
const authenticateWith = (
orgAuth: Layer.Layer<McpOrganizationAuth>,
auth: Layer.Layer<McpAuth> = 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)))),
);
Expand Down Expand Up @@ -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<string> = [];
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<string> = [];
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);
Expand Down
64 changes: 64 additions & 0 deletions e2e/cloud/mcp-no-organization-moved.test.ts
Original file line number Diff line number Diff line change
@@ -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.",
);
}),
);
Loading