Skip to content

Commit 62c04ef

Browse files
committed
MCP install URLs pin the org slug; the server accepts slug or legacy id
The connect-agent card and setup-mcp onboarding now print /<org-slug>/mcp instead of /org_xxx/mcp. Server-side the MCP path classifier claims slug-shaped segments alongside the legacy org_ form (the slug grammar reserves every routable root segment, so console paths ending in /mcp still fall through to the SPA), and the org authorizer resolves a slug through the mirror before the live WorkOS membership check — the URL stays a selector, not a trust boundary. Existing org_-pinned URLs in agents' configs keep working; discovery docs echo whichever selector form the client used. Self-host and cloudflare cards print the bare /mcp — their servers only mount the bare endpoint and a single-org instance has nothing to select.
1 parent 96edc49 commit 62c04ef

13 files changed

Lines changed: 189 additions & 65 deletions

File tree

apps/cloud/src/app-paths.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ describe("isAppOwnedPath", () => {
1919
"/mcp",
2020
"/.well-known/oauth-protected-resource/mcp",
2121
"/.well-known/oauth-authorization-server",
22+
// Org-pinned MCP: the org's URL slug (what the install card prints) and
23+
// the legacy WorkOS org-id form both select an org on the MCP plane.
24+
"/acme-corp/mcp",
25+
"/org_01ABCDEF/mcp",
26+
"/.well-known/oauth-protected-resource/acme-corp/mcp",
27+
"/.well-known/oauth-protected-resource/org_01ABCDEF/mcp",
2228
];
2329
for (const pathname of appOwned) {
2430
it(`forwards ${pathname} to the app handler`, () => {
@@ -28,7 +34,19 @@ describe("isAppOwnedPath", () => {
2834

2935
// Start-owned: the React shell + its routes. Note `/billing` (the React page)
3036
// is distinct from `/api/billing/*` (the proxy) — only the latter is app-owned.
31-
const startOwned = ["/", "/policies", "/login", "/billing", "/org", "/assets/app.js"];
37+
// `/settings/mcp` guards the slug-selector grammar: a RESERVED first segment
38+
// can never be an org slug, so console-route-shaped paths ending in /mcp fall
39+
// through to the SPA instead of being swallowed by the MCP plane.
40+
const startOwned = [
41+
"/",
42+
"/policies",
43+
"/login",
44+
"/billing",
45+
"/org",
46+
"/assets/app.js",
47+
"/settings/mcp",
48+
"/integrations/mcp",
49+
];
3250
for (const pathname of startOwned) {
3351
it(`leaves ${pathname} to the Start router`, () => {
3452
expect(isAppOwnedPath(pathname)).toBe(false);

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,33 +123,35 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
123123
// not on the org outcome, to preserve that telemetry.
124124
const parseBody = request.method === "POST";
125125

126-
// URL is the source of truth for the active org when pinned (`/org_xxx/mcp`,
127-
// carried in the header by `prepareMcpOrgScope`); the bare `/mcp` falls back
128-
// to the token's `org_id`. Either way `orgAuth.authorize` re-checks live
129-
// WorkOS membership below, so the URL is a selector, not a trust boundary.
130-
const organizationId = mcpOrganizationFromRequest(request) ?? token.organizationId;
131-
if (!organizationId) {
126+
// URL is the source of truth for the active org when pinned — the org's
127+
// slug (`/acme/mcp`, what the install card prints) or a legacy org id
128+
// (`/org_xxx/mcp`), carried in the header by `prepareMcpOrgScope`; the
129+
// bare `/mcp` falls back to the token's `org_id`. Either way
130+
// `orgAuth.authorize` resolves the selector and re-checks live WorkOS
131+
// membership below, so the URL is a selector, not a trust boundary.
132+
const organizationSelector = mcpOrganizationFromRequest(request) ?? token.organizationId;
133+
if (!organizationSelector) {
132134
yield* annotateMcpRequest(request, { token, parseBody });
133135
return forbidden(NO_ORGANIZATION_MESSAGE, -32001);
134136
}
135137

136-
const allowed = yield* orgAuth.authorize(token.accountId, organizationId).pipe(
138+
const organizationId = yield* orgAuth.authorize(token.accountId, organizationSelector).pipe(
137139
Effect.catchCause((error) =>
138140
Effect.gen(function* () {
139141
yield* Effect.annotateCurrentSpan({
140142
"mcp.auth.organization_authorize_error": Cause.pretty(error),
141143
});
142-
return false;
144+
return null;
143145
}),
144146
),
145147
Effect.withSpan("mcp.auth.authorize_organization", {
146-
attributes: { "mcp.auth.organization_id": organizationId },
148+
attributes: { "mcp.auth.organization_selector": organizationSelector },
147149
}),
148150
);
149151

150152
yield* annotateMcpRequest(request, { token, parseBody });
151153

152-
if (!allowed) return forbidden(NO_ORGANIZATION_MESSAGE, -32001);
154+
if (!organizationId) return forbidden(NO_ORGANIZATION_MESSAGE, -32001);
153155
return authenticated(principalFromToken(token, organizationId));
154156
});
155157

apps/cloud/src/mcp/auth.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { createCachedRemoteJWKSet } from "../auth/jwks-cache";
1616
import { ApiKeyService } from "../auth/api-keys";
1717
import { BEARER_PREFIX } from "../auth/bearer";
1818
import { authorizeOrganization } from "../auth/organization";
19-
import { makeUserStoreLayer } from "../auth/context";
19+
import { UserStoreService, makeUserStoreLayer } from "../auth/context";
2020
import { CoreSharedServices } from "../auth/workos";
2121
import { makeDbLayer } from "../db/db";
2222
import { bearerChallenge } from "./responses";
@@ -68,14 +68,16 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization";
6868
export const mcpOrganizationFromRequest = (request: Request): string | null =>
6969
request.headers.get(MCP_ORGANIZATION_HEADER);
7070

71-
/** The MCP resource URL for an org (`…/org_xxx/mcp`), or the bare resource. */
72-
export const resourceUrlFor = (organizationId: string | null): string =>
73-
organizationId ? `${RESOURCE_ORIGIN}/${organizationId}${MCP_PATH}` : RESOURCE_URL;
71+
/** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy
72+
* `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client
73+
* used), or the bare resource. */
74+
export const resourceUrlFor = (organizationSelector: string | null): string =>
75+
organizationSelector ? `${RESOURCE_ORIGIN}/${organizationSelector}${MCP_PATH}` : RESOURCE_URL;
7476

75-
/** The protected-resource-metadata URL for an org, or the bare one. */
76-
export const protectedResourceMetadataUrlFor = (organizationId: string | null): string =>
77-
organizationId
78-
? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationId}/mcp`
77+
/** The protected-resource-metadata URL for an org selector, or the bare one. */
78+
export const protectedResourceMetadataUrlFor = (organizationSelector: string | null): string =>
79+
organizationSelector
80+
? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp`
7981
: PROTECTED_RESOURCE_METADATA_URL;
8082

8183
type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
@@ -137,10 +139,16 @@ export class McpAuth extends Context.Service<
137139
export class McpOrganizationAuth extends Context.Service<
138140
McpOrganizationAuth,
139141
{
142+
/**
143+
* Authorize `accountId` against an org SELECTOR — a WorkOS org id
144+
* (`org_…`, from the token or a legacy URL) or the org's URL slug (the
145+
* form the install card prints). Returns the resolved org id when the
146+
* caller holds an active membership, `null` otherwise.
147+
*/
140148
readonly authorize: (
141149
accountId: string,
142-
organizationId: string,
143-
) => Effect.Effect<boolean, unknown>;
150+
organizationSelector: string,
151+
) => Effect.Effect<string | null, unknown>;
144152
}
145153
>()("@executor-js/cloud/McpOrganizationAuth") {}
146154

@@ -164,10 +172,28 @@ const makeMcpOrganizationAuthServices = () => {
164172
return Layer.mergeAll(dbLive, userStoreLive, CoreSharedServices);
165173
};
166174

175+
// A URL slug resolves through the mirror to its org id before the membership
176+
// check; an unknown slug authorizes nothing. Ids pass straight through —
177+
// `authorizeOrganization` verifies live WorkOS membership either way.
178+
const resolveOrgSelector = (selector: string) =>
179+
selector.startsWith("org_")
180+
? Effect.succeed(selector)
181+
: Effect.gen(function* () {
182+
const users = yield* UserStoreService;
183+
const org = yield* users.use((s) => s.getOrganizationBySlug(selector));
184+
return org?.id ?? null;
185+
});
186+
167187
export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({
168-
authorize: (accountId, organizationId) =>
169-
authorizeOrganization(accountId, organizationId).pipe(
170-
Effect.map((org) => org !== null),
188+
authorize: (accountId, organizationSelector) =>
189+
resolveOrgSelector(organizationSelector).pipe(
190+
Effect.flatMap((organizationId) =>
191+
organizationId
192+
? authorizeOrganization(accountId, organizationId).pipe(
193+
Effect.map((org) => (org ? org.id : null)),
194+
)
195+
: Effect.succeed(null),
196+
),
171197
Effect.provide(makeMcpOrganizationAuthServices()),
172198
),
173199
});

apps/cloud/src/mcp/mount.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import { HttpRouter, HttpServer } from "effect/unstable/http";
2828
import { Layer } from "effect";
2929

30+
import { isValidOrgSlug } from "@executor-js/api";
3031
import { McpServingRoutes } from "@executor-js/host-mcp";
3132

3233
import {
@@ -45,20 +46,27 @@ type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-se
4546

4647
type McpRoute = {
4748
readonly kind: McpRouteKind;
48-
/** Org id pinned in the URL (`/org_xxx/mcp`), or `null` for the bare path. */
49+
/** Org selector pinned in the URL (`/acme/mcp` slug or legacy `/org_xxx/mcp`
50+
* id), or `null` for the bare path. Resolved to an org id — and re-checked
51+
* against live membership — in the auth provider. */
4952
readonly organizationId: string | null;
5053
} | null;
5154

52-
// A path segment counts as an org selector only when it has the WorkOS org-id
53-
// shape (`org_…`), so an unrelated `/<seg>/mcp` still falls through to routing.
54-
const orgIdSegment = (segment: string | undefined): string | null =>
55-
segment && segment.startsWith("org_") ? segment : null;
55+
// A path segment counts as an org selector when it's the org's URL slug (the
56+
// canonical form the install card prints) or has the WorkOS org-id shape
57+
// (`org_…`, the legacy form already in agents' configs). The slug grammar
58+
// reserves every routable root segment (`integrations`, `api-keys`, …), so an
59+
// unrelated `/<seg>/mcp` still falls through to routing.
60+
const orgSelectorSegment = (segment: string | undefined): string | null =>
61+
segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null;
5662

5763
// Matches a trailing MCP endpoint — `mcp` (bare) or `<org>/mcp`. Returns the org
58-
// id, `null` for the bare form, or `undefined` when the segments are neither.
64+
// selector, `null` for the bare form, or `undefined` when the segments are neither.
5965
const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => {
6066
if (segments.length === 1 && segments[0] === "mcp") return null;
61-
if (segments.length === 2 && segments[1] === "mcp") return orgIdSegment(segments[0]) ?? undefined;
67+
if (segments.length === 2 && segments[1] === "mcp") {
68+
return orgSelectorSegment(segments[0]) ?? undefined;
69+
}
6270
return undefined;
6371
};
6472

apps/cloud/src/routes/__root.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,10 @@ function AuthGate() {
296296
<ExecutorProvider onHandledError={captureFrontendError}>
297297
<React.Suspense fallback={<ShellSkeleton />}>
298298
<ExecutorPluginsProvider plugins={clientPlugins}>
299-
<OrganizationProvider organizationId={auth.organization.id}>
299+
<OrganizationProvider
300+
organizationId={auth.organization.id}
301+
organizationSlug={activeSlug}
302+
>
300303
<OrgSlugGate
301304
activeSlug={activeSlug}
302305
foreignSlug={(slug) => <ForeignOrgSlug slug={slug} />}

apps/cloud/src/web/pages/setup-mcp.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import { useAuth } from "../auth";
2020
export const SetupMcpPage = () => {
2121
const navigate = useNavigate();
2222
const auth = useAuth();
23-
const organizationId = auth.status === "authenticated" ? (auth.organization?.id ?? null) : null;
23+
const organizationSlug =
24+
auth.status === "authenticated" ? (auth.organization?.slug ?? null) : null;
2425
const [origin, setOrigin] = useState<string | null>(null);
2526
const [advancedOpen, setAdvancedOpen] = useState(false);
2627
const [elicitationMode, setElicitationMode] = useState<McpElicitationMode>("model");
@@ -34,7 +35,7 @@ export const SetupMcpPage = () => {
3435
origin,
3536
desktop: null,
3637
elicitationMode,
37-
organizationId,
38+
organizationSlug,
3839
})
3940
: "";
4041
const command = origin
@@ -43,7 +44,7 @@ export const SetupMcpPage = () => {
4344
isDev: false,
4445
origin,
4546
elicitationMode,
46-
organizationId,
47+
organizationSlug,
4748
})
4849
: "";
4950

apps/host-cloudflare/web/routes/__root.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ function AuthenticatedApp() {
7474
return (
7575
<ExecutorProvider>
7676
<ExecutorPluginsProvider plugins={clientPlugins}>
77+
{/* No organizationSlug: the worker serves only the bare /mcp (see
78+
wrangler.jsonc run_worker_first), so a slug-pinned URL would fall
79+
through to the SPA. */}
7780
<OrganizationProvider organizationId={organization?.id ?? null}>
7881
{organization ? <OrgSlugGate activeSlug={organization.slug}>{gated}</OrgSlugGate> : gated}
7982
</OrganizationProvider>

apps/host-selfhost/web/routes/__root.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ function AuthenticatedApp() {
8282
return (
8383
<ExecutorProvider>
8484
<ExecutorPluginsProvider plugins={clientPlugins}>
85+
{/* No organizationSlug: the self-host MCP endpoint is the bare /mcp —
86+
a slug-pinned URL would 404, and a single-org instance has nothing
87+
to select anyway. */}
8588
<OrganizationProvider organizationId={organization?.id ?? null}>
8689
{organization ? <OrgSlugGate activeSlug={organization.slug}>{gated}</OrgSlugGate> : gated}
8790
</OrganizationProvider>

e2e/cloud/mcp-protocol.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,37 @@ scenario(
296296
).toBeTruthy();
297297
yield* Effect.promise(() => member.text());
298298

299+
// The slug form — what the install card prints (`/acme/mcp`) — selects
300+
// the same org. The slug comes from the account surface.
301+
const me = yield* Effect.promise(() =>
302+
fetch(new URL("/api/account/me", target.baseUrl), { headers: identity.headers }).then(
303+
(r) => r.json() as Promise<{ organization: { slug: string } | null }>,
304+
),
305+
);
306+
const ownSlug = me.organization?.slug;
307+
expect(ownSlug, "the account surface advertises the org's URL slug").toBeTruthy();
308+
const slugged = yield* Effect.promise(() =>
309+
mcpPost(new URL(`/${ownSlug}/mcp`, target.baseUrl), {
310+
bearer,
311+
body: INITIALIZE_REQUEST,
312+
}),
313+
);
314+
expect(slugged.status, "the member's slug-pinned URL opens a session").toBe(200);
315+
expect(
316+
slugged.headers.get("mcp-session-id"),
317+
"the slug-pinned session is a real session",
318+
).toBeTruthy();
319+
yield* Effect.promise(() => slugged.text());
320+
321+
// An unknown slug selects nothing — same rejection as a foreign org id.
322+
const unknownSlug = yield* Effect.promise(() =>
323+
mcpPost(new URL(`/zz-no-such-org-${randomBytes(3).toString("hex")}/mcp`, target.baseUrl), {
324+
bearer,
325+
body: INITIALIZE_REQUEST,
326+
}),
327+
);
328+
expect(unknownSlug.status, "an unknown slug authorizes nothing").toBe(403);
329+
299330
const foreignOrg = `org_e2e_${randomBytes(4).toString("hex")}`;
300331
const foreign = yield* Effect.promise(() =>
301332
mcpPost(new URL(`/${foreignOrg}/mcp`, target.baseUrl), {

e2e/cloud/onboarding-mcp-url.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,21 @@ scenario(
4545

4646
const mcpUrlSection = page.getByRole("region", { name: "MCP server URL" });
4747
const mcpUrl = await mcpUrlSection.locator("span.font-mono").innerText();
48-
expect(mcpUrl, "MCP URL is org-scoped").toMatch(/\/org_[^/]+\/mcp/);
48+
// Org-scoped via the org's URL slug (the readable form), not the raw
49+
// WorkOS org_… id — and not the bare /mcp path.
50+
expect(mcpUrl, "MCP URL is slug-scoped").toMatch(/\/[a-z0-9-]+\/mcp/);
51+
expect(mcpUrl, "the slug form, not the org_ id").not.toMatch(/\/org_[^/]+\/mcp/);
4952

5053
const installSection = page.getByRole("region", { name: "Install command" });
5154
await installSection.waitFor();
5255
const installCommand = await installSection.locator("code").innerText();
5356

5457
// The install command must reference the SAME org as the displayed URL
5558
// — not a different one or a bare /mcp path.
56-
const orgId = /\/(org_[^/]+)\/mcp/.exec(mcpUrl)?.[1] ?? "(no org segment in MCP URL)";
57-
expect(installCommand, "the install command references the same org").toContain(orgId);
59+
const orgSlug = /\/([a-z0-9-]+)\/mcp/.exec(mcpUrl)?.[1] ?? "(no org segment in MCP URL)";
60+
expect(installCommand, "the install command references the same org").toContain(
61+
`/${orgSlug}/mcp`,
62+
);
5863
});
5964
}),
6065
);

0 commit comments

Comments
 (0)