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: 4 additions & 1 deletion apps/cloud/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";

// ---------------------------------------------------------------------------
// Single source of truth for the cloud app's plugin list.
Expand Down Expand Up @@ -40,10 +41,11 @@ interface CloudPluginDeps {
* bypass the real WorkOS API. Production leaves this undefined and
* falls back to the credential-driven default. */
readonly workosVaultClient?: WorkOSVaultClient;
readonly activeToolkitSlug?: string;
}

export default defineExecutorConfig({
plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) =>
plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
[
openApiHttpPlugin(),
googleHttpPlugin(),
Expand All @@ -52,6 +54,7 @@ export default defineExecutorConfig({
dangerouslyAllowStdioMCP: false,
}),
graphqlHttpPlugin(),
toolkitsPlugin({ activeToolkitSlug }),
workosVaultPlugin({
credentials: workosCredentials ?? { apiKey: "", clientId: "" },
...(workosVaultClient ? { client: workosVaultClient } : {}),
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@executor-js/plugin-mcp": "workspace:*",
"@executor-js/plugin-microsoft": "workspace:*",
"@executor-js/plugin-openapi": "workspace:*",
"@executor-js/plugin-toolkits": "workspace:*",
"@executor-js/plugin-workos-vault": "workspace:*",
"@executor-js/react": "workspace:*",
"@executor-js/runtime-dynamic-worker": "workspace:*",
Expand Down
8 changes: 8 additions & 0 deletions apps/cloud/src/app-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,20 @@ describe("isAppOwnedPath", () => {
"/api/billing/attach",
"/api/docs", // Swagger UI
"/mcp",
"/mcp/toolkits/deploy-kit",
"/.well-known/oauth-protected-resource/mcp",
"/.well-known/oauth-protected-resource/mcp/toolkits/deploy-kit",
"/.well-known/oauth-authorization-server",
// Org-pinned MCP: the org's URL slug (what the install card prints) and
// the legacy WorkOS org-id form both select an org on the MCP plane.
"/acme-corp/mcp",
"/acme-corp/mcp/toolkits/deploy-kit",
"/org_01ABCDEF/mcp",
"/org_01ABCDEF/mcp/toolkits/deploy-kit",
"/.well-known/oauth-protected-resource/acme-corp/mcp",
"/.well-known/oauth-protected-resource/acme-corp/mcp/toolkits/deploy-kit",
"/.well-known/oauth-protected-resource/org_01ABCDEF/mcp",
"/.well-known/oauth-protected-resource/org_01ABCDEF/mcp/toolkits/deploy-kit",
];
for (const pathname of appOwned) {
it(`forwards ${pathname} to the app handler`, () => {
Expand All @@ -46,7 +52,9 @@ describe("isAppOwnedPath", () => {
"/org",
"/assets/app.js",
"/settings/mcp",
"/settings/mcp/toolkits/deploy-kit",
"/integrations/mcp",
"/integrations/mcp/toolkits/deploy-kit",
];
for (const pathname of startOwned) {
it(`leaves ${pathname} to the Start router`, () => {
Expand Down
4 changes: 3 additions & 1 deletion apps/cloud/src/engine/execution-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,15 @@ export const CloudDbProvider = cloudDbProviderLayer(collectTables());
// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault
// credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.
export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({
plugins: () =>
plugins: (context) =>
executorConfig.plugins({
workosCredentials: {
apiKey: env.WORKOS_API_KEY,
clientId: env.WORKOS_CLIENT_ID,
apiUrl: env.WORKOS_API_URL,
},
activeToolkitSlug:
context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
}),
});

Expand Down
34 changes: 31 additions & 3 deletions apps/cloud/src/mcp/auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
mcpOrganizationFromRequest,
protectedResourceMetadataUrlFor,
PROTECTED_RESOURCE_METADATA_PATH,
toolkitSlugFromRequest,
McpAuth,
McpAuthLive,
McpOrganizationAuth,
Expand All @@ -57,6 +58,7 @@ import {
} from "./oauth-metadata";

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

const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";

Expand Down Expand Up @@ -94,7 +96,22 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
// The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an
// org-scoped discovery doc onto it and pins the org in the header we read.
handler: (request) =>
Effect.succeed(protectedResourceMetadataResponse(mcpOrganizationFromRequest(request))),
Effect.succeed(
protectedResourceMetadataResponse(
mcpOrganizationFromRequest(request),
toolkitSlugFromRequest(request),
),
),
},
{
path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,
handler: (request) =>
Effect.succeed(
protectedResourceMetadataResponse(
mcpOrganizationFromRequest(request),
toolkitSlugFromRequest(request),
),
),
},
{
path: AUTHORIZATION_SERVER_METADATA_PATH,
Expand All @@ -103,7 +120,10 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
];

const resourceMetadataUrl = (request: Request): string =>
protectedResourceMetadataUrlFor(mcpOrganizationFromRequest(request));
protectedResourceMetadataUrlFor(
mcpOrganizationFromRequest(request),
toolkitSlugFromRequest(request),
);

/**
* Resolve a verified bearer to a final AuthOutcome by running the live org
Expand Down Expand Up @@ -160,7 +180,15 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
return finishAuthorized(request, result.token);
}
return annotateMcpRequest(request, { token: null, parseBody: false }).pipe(
Effect.as(unauthorized(bearerChallengeFor(result, mcpOrganizationFromRequest(request)))),
Effect.as(
unauthorized(
bearerChallengeFor(
result,
mcpOrganizationFromRequest(request),
toolkitSlugFromRequest(request),
),
),
),
);
};

Expand Down
38 changes: 31 additions & 7 deletions apps/cloud/src/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const MCP_PATH = "/mcp";
export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";
export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
const TOOLKIT_SEGMENT = "/toolkits/";

// ---------------------------------------------------------------------------
// Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`)
Expand All @@ -68,17 +69,39 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization";
export const mcpOrganizationFromRequest = (request: Request): string | null =>
request.headers.get(MCP_ORGANIZATION_HEADER);

/** The toolkit slug selected by `/mcp/toolkits/:slug` or its metadata doc. */
export const toolkitSlugFromRequest = (request: Request): string | null => {
const pathname = new URL(request.url).pathname;
const index = pathname.indexOf(TOOLKIT_SEGMENT);
if (index < 0) return null;
const slug = pathname.slice(index + TOOLKIT_SEGMENT.length).split("/", 1)[0];
return slug && slug.length > 0 ? slug : null;
};

const toolkitMcpPath = (toolkitSlug: string | null): string =>
toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH;

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

/** The protected-resource-metadata URL for an org selector, or the bare one. */
export const protectedResourceMetadataUrlFor = (organizationSelector: string | null): string =>
organizationSelector
? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp`
: PROTECTED_RESOURCE_METADATA_URL;
export const protectedResourceMetadataUrlFor = (
organizationSelector: string | null,
toolkitSlug: string | null = null,
): string => {
const toolkitSuffix = toolkitSlug ? `/toolkits/${toolkitSlug}` : "";
return organizationSelector
? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}`
: `${PROTECTED_RESOURCE_METADATA_URL}${toolkitSuffix}`;
};

type McpUnauthorizedReason = "missing_bearer" | "invalid_token";

Expand Down Expand Up @@ -117,10 +140,11 @@ export const mcpUnauthorized = (
export const bearerChallengeFor = (
result: McpUnauthorizedResult,
organizationId: string | null = null,
toolkitSlug: string | null = null,
): string =>
bearerChallenge(
{ reason: result.reason, description: result.description },
protectedResourceMetadataUrlFor(organizationId),
protectedResourceMetadataUrlFor(organizationId, toolkitSlug),
);

// ---------------------------------------------------------------------------
Expand Down
57 changes: 57 additions & 0 deletions apps/cloud/src/mcp/mount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "@effect/vitest";

import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth";
import { classifyMcpPath, prepareMcpOrgScope } from "./mount";

describe("cloud MCP toolkit route normalization", () => {
it("classifies toolkit MCP and protected-resource metadata paths", () => {
expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({
kind: "mcp",
organizationId: null,
toolkitSlug: "deploy",
});
expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({
kind: "mcp",
organizationId: "acme",
toolkitSlug: "deploy",
});
expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({
kind: "oauth-protected-resource",
organizationId: null,
toolkitSlug: "deploy",
});
expect(
classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"),
).toEqual({
kind: "oauth-protected-resource",
organizationId: "acme",
toolkitSlug: "deploy",
});
});

it("rewrites org-scoped toolkit metadata to the mounted toolkit metadata route", () => {
const request = new Request(
"https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy?x=1",
{ headers: { "x-executor-mcp-organization": "spoofed" } },
);

const rewritten = prepareMcpOrgScope(request);
const url = new URL(rewritten.url);

expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy");
expect(url.search).toBe("?x=1");
expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme");
expect(toolkitSlugFromRequest(rewritten)).toBe("deploy");
});

it("builds toolkit-specific resource and metadata URLs", () => {
expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy");
expect(resourceUrlFor("acme", "deploy")).toBe("https://executor.sh/acme/mcp/toolkits/deploy");
expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe(
"https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy",
);
expect(protectedResourceMetadataUrlFor("acme", "deploy")).toBe(
"https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy",
);
});
});
52 changes: 35 additions & 17 deletions apps/cloud/src/mcp/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type McpRoute = {
* id), or `null` for the bare path. Resolved to an org id — and re-checked
* against live membership — in the auth provider. */
readonly organizationId: string | null;
readonly toolkitSlug?: string;
} | null;

// A path segment counts as an org selector when it's the org's URL slug (the
Expand All @@ -60,12 +61,27 @@ type McpRoute = {
const orgSelectorSegment = (segment: string | undefined): string | null =>
segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null;

// Matches a trailing MCP endpoint — `mcp` (bare) or `<org>/mcp`. Returns the org
// selector, `null` for the bare form, or `undefined` when the segments are neither.
const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => {
if (segments.length === 1 && segments[0] === "mcp") return null;
type MatchedMcpSuffix = {
readonly organizationId: string | null;
readonly toolkitSlug?: string;
};

// Matches a trailing MCP endpoint: `mcp`, `mcp/toolkits/<slug>`, or either with
// a leading org selector. Returns undefined when the segments are not MCP.
const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefined => {
if (segments.length === 1 && segments[0] === "mcp") return { organizationId: null };
if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits") {
const toolkitSlug = segments[2];
return toolkitSlug ? { organizationId: null, toolkitSlug } : undefined;
}
if (segments.length === 2 && segments[1] === "mcp") {
return orgSelectorSegment(segments[0]) ?? undefined;
const organizationId = orgSelectorSegment(segments[0]);
return organizationId ? { organizationId } : undefined;
}
if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") {
const organizationId = orgSelectorSegment(segments[0]);
const toolkitSlug = segments[3];
return organizationId && toolkitSlug ? { organizationId, toolkitSlug } : undefined;
}
return undefined;
};
Expand Down Expand Up @@ -93,22 +109,24 @@ export const classifyMcpPath = (pathname: string): McpRoute => {
// org sits after the well-known prefix (RFC 9728), not at the path root.
const prmPrefix = "/.well-known/oauth-protected-resource";
if (pathname.startsWith(`${prmPrefix}/`)) {
const organizationId = matchMcpSuffix(segments.slice(2));
return organizationId === undefined
? null
: { kind: "oauth-protected-resource", organizationId };
const matched = matchMcpSuffix(segments.slice(2));
return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched };
}

// MCP transport: `/mcp` or `/<org>/mcp`.
const organizationId = matchMcpSuffix(segments);
return organizationId === undefined ? null : { kind: "mcp", organizationId };
const matched = matchMcpSuffix(segments);
return matched === undefined ? null : { kind: "mcp", ...matched };
};

const bareMcpPath = (kind: McpRouteKind): string =>
kind === "mcp"
? MCP_PATH
: kind === "oauth-protected-resource"
? PROTECTED_RESOURCE_METADATA_PATH
const bareMcpPath = (route: Exclude<McpRoute, null>): string =>
route.kind === "mcp"
? route.toolkitSlug
? `${MCP_PATH}/toolkits/${route.toolkitSlug}`
: MCP_PATH
: route.kind === "oauth-protected-resource"
? route.toolkitSlug
? `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${route.toolkitSlug}`
: PROTECTED_RESOURCE_METADATA_PATH
: AUTHORIZATION_SERVER_METADATA_PATH;

/**
Expand All @@ -125,7 +143,7 @@ export const prepareMcpOrgScope = (request: Request): Request => {
const url = new URL(request.url);
const route = classifyMcpPath(url.pathname);
if (route === null) return request;
const bare = bareMcpPath(route.kind);
const bare = bareMcpPath(route);
if (url.pathname === bare && !request.headers.has(MCP_ORGANIZATION_HEADER)) return request;
url.pathname = bare;
const rewritten = new Request(url, request);
Expand Down
7 changes: 5 additions & 2 deletions apps/cloud/src/mcp/oauth-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ const jsonWebResponse = (body: unknown, status = 200): Response =>
// The `resource` reflects the URL-pinned org (`…/org_xxx/mcp`) when present, so a
// client that discovered metadata via the org-scoped well-known doc gets back the
// matching org-scoped resource id; the bare path yields the bare resource.
export const protectedResourceMetadataResponse = (organizationId: string | null = null): Response =>
export const protectedResourceMetadataResponse = (
organizationId: string | null = null,
toolkitSlug: string | null = null,
): Response =>
jsonWebResponse({
resource: resourceUrlFor(organizationId),
resource: resourceUrlFor(organizationId, toolkitSlug),
authorization_servers: [AUTHKIT_DOMAIN],
bearer_methods_supported: ["header"],
// Spec-faithful clients (OpenCode, mcporter) request exactly what is
Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> {
organizationName: org.name,
organizationSlug: org.slug,
userId: token.userId,
resource: token.resource,
elicitationMode: token.elicitationMode,
} satisfies SessionMeta;
}).pipe(
Expand All @@ -193,6 +194,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> {
sessionMeta.userId,
sessionMeta.organizationId,
sessionMeta.organizationName,
{ mcpResource: sessionMeta.resource },
).pipe(
Effect.provide(CloudExecutionStackLayer),
Effect.withSpan("McpSessionDO.makeExecutionStack"),
Expand Down
Loading
Loading