Unified · Split
| @@ -4,8 +4,7 @@ import { Effect, Predicate } from "effect"; · 3 unmodified lines | |||
| 4 | import { | 4 | import { |
| 5 | McpAuthProvider, | 5 | McpAuthProvider, |
| 6 | jsonRpcErrorBody, | 6 | jsonRpcErrorBody, |
| 7 | defaultMcpResource, | 7 | mcpResourceFromRequest, |
| 8 | mcpResourceFromPathname, | ||
| 9 | orgWriteAccessForPrincipal, | 8 | orgWriteAccessForPrincipal, |
| 10 | withOrgWriteAccess, | 9 | withOrgWriteAccess, |
| 11 | UNAVAILABLE_RETRY_AFTER_SECONDS, | 10 | UNAVAILABLE_RETRY_AFTER_SECONDS, |
| @@ -156,14 +155,6 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> = · 144 unmodified lines | |||
| 156 | ); | 155 | ); |
| 157 | }; | 156 | }; |
| 158 | 157 | ||
| 159 | // The MCP resource the request targets. `server.ts` routes the bare `/mcp` and | ||
| 160 | // every scoped sub-resource to this handler (`prepareMcpOrgScope` strips the | ||
| 161 | // org selector but keeps the resource segments), so a session minted on a | ||
| 162 | // scoped path serves that projection of the catalog. The grammar is the shared | ||
| 163 | // one from host-mcp; a path `classifyMcpPath` accepted always parses here. | ||
| 164 | const resourceFromPath = (request: Request): McpResource => | ||
| 165 | mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource; | ||
| 166 | |||
| 167 | const propsForPrincipal = ( | 158 | const propsForPrincipal = ( |
| 168 | request: Request, | 159 | request: Request, |
| 169 | principal: Extract<AuthOutcome, { readonly _tag: "Authenticated" }>["principal"], | 160 | principal: Extract<AuthOutcome, { readonly _tag: "Authenticated" }>["principal"], |
| @@ -287,7 +278,10 @@ export const makeCloudMcpAgentHandler = () => { · 117 unmodified lines | |||
| 287 | } | 278 | } |
| 288 | } | 279 | } |
| 289 | 280 | ||
| 290 | const resource = resourceFromPath(request); | 281 | // `prepareMcpOrgScope` already stripped the org selector, so the path is |
| 282 | // the bare resource path and a session minted on a scoped path serves that | ||
| 283 | // projection of the catalog. | ||
| 284 | const resource = mcpResourceFromRequest(request); | ||
| 291 | const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); | 285 | const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); |
| 292 | (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; | 286 | (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; |
| 293 | const forwarded = withOrgWriteAccess( | 287 | const forwarded = withOrgWriteAccess( |
| @@ -37,6 +37,8 @@ import { · 36 unmodified lines | |||
| 37 | unauthorized, | 37 | unauthorized, |
| 38 | unavailable, | 38 | unavailable, |
| 39 | McpAuthProvider, | 39 | McpAuthProvider, |
| 40 | mcpResourceFromRequest, | ||
| 41 | scopedMcpRoutePaths, | ||
| 40 | type AuthOutcome, | 42 | type AuthOutcome, |
| 41 | type McpDiscoveryRoute, | 43 | type McpDiscoveryRoute, |
| 42 | type Principal, | 44 | type Principal, |
| @@ -48,7 +50,6 @@ import { CoreSharedServices } from "../auth/workos"; · 5 unmodified lines | |||
| 48 | import { | 50 | import { |
| 49 | bearerChallengeFor, | 51 | bearerChallengeFor, |
| 50 | mcpOrganizationFromRequest, | 52 | mcpOrganizationFromRequest, |
| 51 | mcpResourceFromRequest, | ||
| 52 | protectedResourceMetadataUrlFor, | 53 | protectedResourceMetadataUrlFor, |
| 53 | PROTECTED_RESOURCE_METADATA_PATH, | 54 | PROTECTED_RESOURCE_METADATA_PATH, |
| 54 | McpAuth, | 55 | McpAuth, |
| @@ -66,13 +67,6 @@ import { · 11 unmodified lines | |||
| 66 | } from "./oauth-metadata"; | 67 | } from "./oauth-metadata"; |
| 67 | 68 | ||
| 68 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; | 69 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; |
| 69 | // One metadata doc per MCP sub-resource kind; the doc's `resource` mirrors the | ||
| 70 | // path the client dialed (RFC 9728 same-origin check). | ||
| 71 | const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [ | ||
| 72 | `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:slug`, | ||
| 73 | `${PROTECTED_RESOURCE_METADATA_PATH}/integrations/:slugs`, | ||
| 74 | `${PROTECTED_RESOURCE_METADATA_PATH}/tools/:toolId`, | ||
| 75 | ] as const; | ||
| 76 | 70 | ||
| 77 | const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; | 71 | const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; |
| 78 | 72 | ||
| @@ -140,9 +134,11 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 61 unmodified lines | |||
| 140 | mcpResourceFromRequest(request), | 134 | mcpResourceFromRequest(request), |
| 141 | ), | 135 | ), |
| 142 | ); | 136 | ); |
| 137 | // One metadata doc per resource; its `resource` mirrors the path the client | ||
| 138 | // dialed (RFC 9728 same-origin check). | ||
| 143 | const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [ | 139 | const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [ |
| 144 | { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata }, | 140 | { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata }, |
| 145 | ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({ | 141 | ...scopedMcpRoutePaths(PROTECTED_RESOURCE_METADATA_PATH).map((path) => ({ |
| 146 | path, | 142 | path, |
| 147 | handler: protectedResourceMetadata, | 143 | handler: protectedResourceMetadata, |
| 148 | })), | 144 | })), |
| @@ -14,8 +14,8 @@ import { Context, Effect, Layer, Predicate } from "effect"; · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | import { | 15 | import { |
| 16 | defaultMcpResource, | 16 | defaultMcpResource, |
| 17 | mcpResourceFromPathname, | ||
| 18 | mcpResourcePath, | 17 | mcpResourcePath, |
| 18 | OAUTH_PROTECTED_RESOURCE_PREFIX, | ||
| 19 | type McpResource, | 19 | type McpResource, |
| 20 | } from "@executor-js/host-mcp"; | 20 | } from "@executor-js/host-mcp"; |
| 21 | 21 | ||
| @@ -75,20 +75,6 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization"; · 53 unmodified lines | |||
| 75 | export const mcpOrganizationFromRequest = (request: Request): string | null => | 75 | export const mcpOrganizationFromRequest = (request: Request): string | null => |
| 76 | request.headers.get(MCP_ORGANIZATION_HEADER); | 76 | request.headers.get(MCP_ORGANIZATION_HEADER); |
| 77 | 77 | ||
| 78 | /** | ||
| 79 | * The MCP resource a request names, read off its (already org-stripped) path. | ||
| 80 | * Both the transport path (`/mcp/toolkits/<slug>`) and its metadata doc | ||
| 81 | * (`/.well-known/oauth-protected-resource/mcp/toolkits/<slug>`) resolve to the | ||
| 82 | * same resource. Defaults to the whole catalog for anything else. | ||
| 83 | */ | ||
| 84 | export const mcpResourceFromRequest = (request: Request): McpResource => { | ||
| 85 | const pathname = new URL(request.url).pathname; | ||
| 86 | const bare = pathname.startsWith(PRM_PREFIX) ? pathname.slice(PRM_PREFIX.length) : pathname; | ||
| 87 | return mcpResourceFromPathname(bare) ?? defaultMcpResource; | ||
| 88 | }; | ||
| 89 | |||
| 90 | const PRM_PREFIX = "/.well-known/oauth-protected-resource"; | ||
| 91 | |||
| 92 | /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy | 78 | /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy |
| 93 | * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client | 79 | * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client |
| 94 | * used), or the bare resource. */ | 80 | * used), or the bare resource. */ |
| @@ -106,8 +92,8 @@ export const protectedResourceMetadataUrlFor = ( · 11 unmodified lines | |||
| 106 | resource: McpResource = defaultMcpResource, | 92 | resource: McpResource = defaultMcpResource, |
| 107 | ): string => | 93 | ): string => |
| 108 | organizationSelector | 94 | organizationSelector |
| 109 | ? `${RESOURCE_ORIGIN}${PRM_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}` | 95 | ? `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}` |
| 110 | : `${RESOURCE_ORIGIN}${PRM_PREFIX}${mcpResourcePath(resource)}`; | 96 | : `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}${mcpResourcePath(resource)}`; |
| 111 | 97 | ||
| 112 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; | 98 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; |
| 113 | 99 | ||
| @@ -1,6 +1,8 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { mcpResourceFromRequest, protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth"; | 3 | import { mcpResourceFromRequest } from "@executor-js/host-mcp"; |
| 4 | |||
| 5 | import { protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth"; | ||
| 4 | import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; | 6 | import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; |
| 5 | 7 | ||
| 6 | describe("cloud MCP scoped route normalization", () => { | 8 | describe("cloud MCP scoped route normalization", () => { |
| @@ -3,8 +3,7 @@ import { Effect, Predicate } from "effect"; · 2 unmodified lines | |||
| 3 | import { | 3 | import { |
| 4 | McpAuthProvider, | 4 | McpAuthProvider, |
| 5 | jsonRpcErrorBody, | 5 | jsonRpcErrorBody, |
| 6 | defaultMcpResource, | 6 | mcpResourceFromRequest, |
| 7 | mcpResourceFromPathname, | ||
| 8 | orgWriteAccessForPrincipal, | 7 | orgWriteAccessForPrincipal, |
| 9 | withOrgWriteAccess, | 8 | withOrgWriteAccess, |
| 10 | type AuthOutcome, | 9 | type AuthOutcome, |
| @@ -74,12 +73,6 @@ const authenticate = (request: Request, config: CloudflareConfig) => · 63 unmodified lines | |||
| 74 | return { auth, outcome }; | 73 | return { auth, outcome }; |
| 75 | }).pipe(Effect.provide(cloudflareAccessMcpAuth(config))); | 74 | }).pipe(Effect.provide(cloudflareAccessMcpAuth(config))); |
| 76 | 75 | ||
| 77 | // The MCP resource the request targets (`worker.ts` routes every path the | ||
| 78 | // shared grammar recognizes here), so a session minted on a scoped path serves | ||
| 79 | // that projection of the catalog. | ||
| 80 | const resourceFromPath = (request: Request): McpResource => | ||
| 81 | mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource; | ||
| 82 | |||
| 83 | const propsForPrincipal = ( | 76 | const propsForPrincipal = ( |
| 84 | request: Request, | 77 | request: Request, |
| 85 | principal: Principal, | 78 | principal: Principal, |
| @@ -153,7 +146,9 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 67 unmodified lines | |||
| 153 | } | 146 | } |
| 154 | } | 147 | } |
| 155 | 148 | ||
| 156 | const resource = resourceFromPath(request); | 149 | // `worker.ts` routes every path the shared grammar recognizes here, so a |
| 150 | // session minted on a scoped path serves that projection of the catalog. | ||
| 151 | const resource = mcpResourceFromRequest(request); | ||
| 157 | const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource)); | 152 | const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource)); |
| 158 | (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; | 153 | (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; |
| 159 | const forwarded = withOrgWriteAccess( | 154 | const forwarded = withOrgWriteAccess( |
| @@ -2,38 +2,23 @@ import { Effect, Layer } from "effect"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | authenticated, | 4 | authenticated, |
| 5 | defaultMcpResource, | ||
| 6 | McpAuthProvider, | 5 | McpAuthProvider, |
| 7 | mcpResourceFromPathname, | 6 | mcpResourceFromRequest, |
| 8 | mcpResourcePath, | 7 | mcpResourcePath, |
| 8 | OAUTH_PROTECTED_RESOURCE_PREFIX, | ||
| 9 | scopedMcpRoutePaths, | ||
| 9 | unauthorized, | 10 | unauthorized, |
| 10 | type McpDiscoveryRoute, | 11 | type McpDiscoveryRoute, |
| 11 | type McpResource, | ||
| 12 | } from "@executor-js/host-mcp"; | 12 | } from "@executor-js/host-mcp"; |
| 13 | 13 | ||
| 14 | import { makeAccessVerifier } from "../auth/cloudflare-access"; | 14 | import { makeAccessVerifier } from "../auth/cloudflare-access"; |
| 15 | import type { CloudflareConfig } from "../config"; | 15 | import type { CloudflareConfig } from "../config"; |
| 16 | 16 | ||
| 17 | const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; | 17 | const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX; |
| 18 | const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`; | 18 | const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp` as const; |
| 19 | const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [ | ||
| 20 | `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:slug`, | ||
| 21 | `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/integrations/:slugs`, | ||
| 22 | `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/tools/:toolId`, | ||
| 23 | ] as const; | ||
| 24 | |||
| 25 | // The resource a request names, whether it dialed the transport path or its | ||
| 26 | // metadata doc. The grammar is the shared one from host-mcp. | ||
| 27 | const resourceForRequest = (request: Request): McpResource => { | ||
| 28 | const pathname = new URL(request.url).pathname; | ||
| 29 | const bare = pathname.startsWith(PROTECTED_RESOURCE_METADATA_PATH) | ||
| 30 | ? pathname.slice(PROTECTED_RESOURCE_METADATA_PATH.length) | ||
| 31 | : pathname; | ||
| 32 | return mcpResourceFromPathname(bare) ?? defaultMcpResource; | ||
| 33 | }; | ||
| 34 | 19 | ||
| 35 | const resourcePathForRequest = (request: Request): string => | 20 | const resourcePathForRequest = (request: Request): string => |
| 36 | mcpResourcePath(resourceForRequest(request)); | 21 | mcpResourcePath(mcpResourceFromRequest(request)); |
| 37 | 22 | ||
| 38 | const metadataPathForRequest = (request: Request): string => | 23 | const metadataPathForRequest = (request: Request): string => |
| 39 | `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePathForRequest(request)}`; | 24 | `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePathForRequest(request)}`; |
| @@ -76,7 +61,7 @@ export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<M · 36 unmodified lines | |||
| 76 | path: MCP_PROTECTED_RESOURCE_METADATA_PATH, | 61 | path: MCP_PROTECTED_RESOURCE_METADATA_PATH, |
| 77 | handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)), | 62 | handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)), |
| 78 | }, | 63 | }, |
| 79 | ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({ | 64 | ...scopedMcpRoutePaths(MCP_PROTECTED_RESOURCE_METADATA_PATH).map((path) => ({ |
| 80 | path, | 65 | path, |
| 81 | handler: (request: Request) => Effect.succeed(protectedResourceMetadataResponse(request)), | 66 | handler: (request: Request) => Effect.succeed(protectedResourceMetadataResponse(request)), |
| 82 | })), | 67 | })), |
| @@ -5,12 +5,13 @@ import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; · 4 unmodified lines | |||
| 5 | import { | 5 | import { |
| 6 | authenticated, | 6 | authenticated, |
| 7 | McpAuthProvider, | 7 | McpAuthProvider, |
| 8 | mcpResourceFromPathname, | 8 | mcpResourceFromRequest, |
| 9 | mcpResourcePath, | 9 | mcpResourcePath, |
| 10 | OAUTH_PROTECTED_RESOURCE_PREFIX, | ||
| 11 | scopedMcpRoutePaths, | ||
| 10 | unauthorized, | 12 | unauthorized, |
| 11 | type AuthOutcome, | 13 | type AuthOutcome, |
| 12 | type McpDiscoveryRoute, | 14 | type McpDiscoveryRoute, |
| 13 | type McpResource, | ||
| 14 | type Principal, | 15 | type Principal, |
| 15 | } from "@executor-js/host-mcp"; | 16 | } from "@executor-js/host-mcp"; |
| 16 | 17 | ||
| @@ -57,14 +58,9 @@ import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org · 40 unmodified lines | |||
| 57 | // stay on the Better Auth handler mounted at /api/auth — NOT in this seam. | 58 | // stay on the Better Auth handler mounted at /api/auth — NOT in this seam. |
| 58 | // --------------------------------------------------------------------------- | 59 | // --------------------------------------------------------------------------- |
| 59 | 60 | ||
| 60 | const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; | 61 | // The default doc is the bare well-known path itself (Better Auth's |
| 61 | // One metadata doc per scoped MCP sub-resource kind (the default doc is the | 62 | // convention); scoped docs sit under `<prefix>/mcp/...`. |
| 62 | // bare well-known path itself, Better Auth's convention). | 63 | const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX; |
| 63 | const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [ | ||
| 64 | `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:slug`, | ||
| 65 | `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/integrations/:slugs`, | ||
| 66 | `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/tools/:toolId`, | ||
| 67 | ] as const; | ||
| 68 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; | 64 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; |
| 69 | 65 | ||
| 70 | const parseRoles = (role: string | null | undefined): ReadonlyArray<string> => | 66 | const parseRoles = (role: string | null | undefined): ReadonlyArray<string> => |
| @@ -99,26 +95,11 @@ const originalOrgScopedPathFor = (request: Request): string | null => { · 28 unmodified lines | |||
| 99 | return header ? mcpResourcePathFromOriginalPath(header) : null; | 95 | return header ? mcpResourcePathFromOriginalPath(header) : null; |
| 100 | }; | 96 | }; |
| 101 | 97 | ||
| 102 | /** | 98 | const isScopedRequest = (request: Request): boolean => |
| 103 | * The scoped MCP resource a bare request names, read off its own path (the | 99 | mcpResourceFromRequest(request).kind !== "default"; |
| 104 | * transport path or its metadata doc), or `null` for the default endpoint. | ||
| 105 | * The grammar is the shared one from host-mcp. | ||
| 106 | */ | ||
| 107 | const scopedResourceFor = (request: Request): McpResource | null => { | ||
| 108 | const pathname = new URL(request.url).pathname; | ||
| 109 | const bare = pathname.startsWith(PROTECTED_RESOURCE_METADATA_PATH) | ||
| 110 | ? pathname.slice(PROTECTED_RESOURCE_METADATA_PATH.length) | ||
| 111 | : pathname; | ||
| 112 | const resource = mcpResourceFromPathname(bare); | ||
| 113 | return resource === null || resource.kind === "default" ? null : resource; | ||
| 114 | }; | ||
| 115 | 100 | ||
| 116 | const mcpResourcePathFor = (request: Request): string => { | 101 | const mcpResourcePathFor = (request: Request): string => |
| 117 | const orgScoped = originalOrgScopedPathFor(request); | 102 | originalOrgScopedPathFor(request) ?? mcpResourcePath(mcpResourceFromRequest(request)); |
| 118 | if (orgScoped) return orgScoped; | ||
| 119 | const scoped = scopedResourceFor(request); | ||
| 120 | return scoped ? mcpResourcePath(scoped) : "/mcp"; | ||
| 121 | }; | ||
| 122 | 103 | ||
| 123 | /** | 104 | /** |
| 124 | * Absolute protected-resource metadata URL for the 401 challenge. Derive the | 105 | * Absolute protected-resource metadata URL for the 401 challenge. Derive the |
| @@ -131,9 +112,8 @@ const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): · 6 unmodified lines | |||
| 131 | const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; | 112 | const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; |
| 132 | const orgScoped = originalOrgScopedPathFor(request); | 113 | const orgScoped = originalOrgScopedPathFor(request); |
| 133 | if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`; | 114 | if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`; |
| 134 | const scoped = scopedResourceFor(request); | 115 | return isScopedRequest(request) |
| 135 | return scoped | 116 | ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePathFor(request)}` |
| 136 | ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePath(scoped)}` | ||
| 137 | : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`; | 117 | : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`; |
| 138 | }; | 118 | }; |
| 139 | 119 | ||
| @@ -150,7 +130,7 @@ const scopedProtectedResourceMetadata = ( · 10 unmodified lines | |||
| 150 | response: Response, | 130 | response: Response, |
| 151 | baseURL: string | undefined, | 131 | baseURL: string | undefined, |
| 152 | ): Effect.Effect<Response> => { | 132 | ): Effect.Effect<Response> => { |
| 153 | if (scopedResourceFor(request) === null) return Effect.succeed(response); | 133 | if (!isScopedRequest(request)) return Effect.succeed(response); |
| 154 | return Effect.promise(async () => { | 134 | return Effect.promise(async () => { |
| 155 | const body = (await response.json()) as Record<string, unknown>; | 135 | const body = (await response.json()) as Record<string, unknown>; |
| 156 | const headers = new Headers(response.headers); | 136 | const headers = new Headers(response.headers); |
| @@ -189,7 +169,7 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 32 unmodified lines | |||
| 189 | ); | 169 | ); |
| 190 | const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [ | 170 | const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [ |
| 191 | { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata }, | 171 | { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata }, |
| 192 | ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({ | 172 | ...scopedMcpRoutePaths(`${PROTECTED_RESOURCE_METADATA_PATH}/mcp`).map((path) => ({ |
| 193 | path, | 173 | path, |
| 194 | handler: protectedResourceMetadata, | 174 | handler: protectedResourceMetadata, |
| 195 | })), | 175 | })), |
| @@ -11,6 +11,7 @@ import { localAnalytics } from "./analytics"; · 10 unmodified lines | |||
| 11 | import { makeLocalApiHandler } from "./app"; | 11 | import { makeLocalApiHandler } from "./app"; |
| 12 | import { disposeExecutor, getExecutorBundle } from "./executor"; | 12 | import { disposeExecutor, getExecutorBundle } from "./executor"; |
| 13 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; | 13 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| 14 | import type { ExecutorMcpServerConfig } from "@executor-js/host-mcp/tool-server"; | ||
| 14 | 15 | ||
| 15 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
| 16 | // Local server handlers. | 17 | // Local server handlers. |
| @@ -88,21 +89,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 71 unmodified lines | |||
| 88 | // part of the shared API). Reuse the shared boot bundle so the MCP executor is | 89 | // part of the shared API). Reuse the shared boot bundle so the MCP executor is |
| 89 | // byte-identical to the one the API serves. | 90 | // byte-identical to the one the API serves. |
| 90 | const { executor, webBaseUrl } = await getExecutorBundle(); | 91 | const { executor, webBaseUrl } = await getExecutorBundle(); |
| 91 | // Both engines below serve MCP endpoints, so the wrap binds the "mcp" | ||
| 92 | // plane structurally; a projected engine additionally marks `toolkit` | ||
| 93 | // (the resource's own label is user data and never recorded). | ||
| 94 | const engine = withExecutionAnalytics( | ||
| 95 | createExecutionEngine({ | ||
| 96 | executor, | ||
| 97 | codeExecutor: makeQuickJsExecutor(), | ||
| 98 | }), | ||
| 99 | localAnalytics, | ||
| 100 | { plane: "mcp", toolkit: false }, | ||
| 101 | ); | ||
| 102 | // The generative-UI surface, shared by every resource this daemon serves. | 92 | // The generative-UI surface, shared by every resource this daemon serves. |
| 103 | // A scoped resource is a projection of the boot executor, so `artifacts` | ||
| 104 | // is bound per resource below rather than hoisted with the rest. | ||
| 105 | // | ||
| 106 | // Including the create-time smoke render, on the same terms as every other | 93 | // Including the create-time smoke render, on the same terms as every other |
| 107 | // host. This daemon is a dependency of `apps/cli`, whose build does not | 94 | // host. This daemon is a dependency of `apps/cli`, whose build does not |
| 108 | // configure `jsx`, and the renderer used to be unusable here for that | 95 | // configure `jsx`, and the renderer used to be unusable here for that |
| @@ -119,45 +106,32 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 10 unmodified lines | |||
| 119 | onArtifactUsage: (action: "created" | "viewed" | "updated") => | 106 | onArtifactUsage: (action: "created" | "viewed" | "updated") => |
| 120 | localAnalytics.record(`artifact_${action}`, { via: "agent" }), | 107 | localAnalytics.record(`artifact_${action}`, { via: "agent" }), |
| 121 | }; | 108 | }; |
| 109 | // One config shape for every resource. The default endpoint serves the | ||
| 110 | // boot executor; a scoped one serves the same executor through a | ||
| 111 | // projection (same database handle — this process holds the data dir's | ||
| 112 | // exclusive lock, so a second open would contend with ourselves — same | ||
| 113 | // plugins, same workspace policies, narrowed to what the resource | ||
| 114 | // exposes). The wrap binds the "mcp" plane structurally; `toolkit` marks | ||
| 115 | // a projected engine (the resource's own label is user data and never | ||
| 116 | // recorded). | ||
| 117 | const configFor = (view: typeof executor, toolkit: boolean): ExecutorMcpServerConfig => ({ | ||
| 118 | engine: withExecutionAnalytics( | ||
| 119 | createExecutionEngine({ executor: view, codeExecutor: makeQuickJsExecutor() }), | ||
| 120 | localAnalytics, | ||
| 121 | { plane: "mcp", toolkit }, | ||
| 122 | ), | ||
| 123 | artifacts: view.artifacts, | ||
| 124 | connections: view.connections, | ||
| 125 | ...appsConfig, | ||
| 126 | }); | ||
| 122 | mcp = createMcpRequestHandler({ | 127 | mcp = createMcpRequestHandler({ |
| 123 | defaultConfig: { | 128 | defaultConfig: configFor(executor, false), |
| 124 | engine, | ||
| 125 | artifacts: executor.artifacts, | ||
| 126 | connections: executor.connections, | ||
| 127 | ...appsConfig, | ||
| 128 | }, | ||
| 129 | createConfigForResource: async (resource) => { | 129 | createConfigForResource: async (resource) => { |
| 130 | const projection = projectionForMcpResource(resource); | 130 | const projection = projectionForMcpResource(resource); |
| 131 | if (projection === undefined) { | 131 | if (projection === undefined) return { config: configFor(executor, false) }; |
| 132 | return { | ||
| 133 | config: { | ||
| 134 | engine, | ||
| 135 | artifacts: executor.artifacts, | ||
| 136 | connections: executor.connections, | ||
| 137 | ...appsConfig, | ||
| 138 | }, | ||
| 139 | }; | ||
| 140 | } | ||
| 141 | // A scoped resource is the boot executor seen through a projection: | ||
| 142 | // same database handle (this process holds the data dir's exclusive | ||
| 143 | // lock, so a second open would contend with ourselves), same plugins, | ||
| 144 | // same workspace policies, narrowed to what the resource exposes. | ||
| 145 | const projected = await Effect.runPromise(executor.project(projection)); | 132 | const projected = await Effect.runPromise(executor.project(projection)); |
| 146 | const projectedEngine = withExecutionAnalytics( | ||
| 147 | createExecutionEngine({ | ||
| 148 | executor: projected, | ||
| 149 | codeExecutor: makeQuickJsExecutor(), | ||
| 150 | }), | ||
| 151 | localAnalytics, | ||
| 152 | { plane: "mcp", toolkit: true }, | ||
| 153 | ); | ||
| 154 | return { | 133 | return { |
| 155 | config: { | 134 | config: configFor(projected, true), |
| 156 | engine: projectedEngine, | ||
| 157 | artifacts: projected.artifacts, | ||
| 158 | connections: projected.connections, | ||
| 159 | ...appsConfig, | ||
| 160 | }, | ||
| 161 | close: () => Effect.runPromise(Effect.ignore(projected.close())), | 135 | close: () => Effect.runPromise(Effect.ignore(projected.close())), |
| 162 | }; | 136 | }; |
| 163 | }, | 137 | }, |
| @@ -205,11 +205,7 @@ export { · 204 unmodified lines | |||
| 205 | 205 | ||
| 206 | // Tool projections — a narrowed view over the catalog, layered on policies. | 206 | // Tool projections — a narrowed view over the catalog, layered on policies. |
| 207 | export { | 207 | export { |
| 208 | fullToolProjection, | ||
| 209 | isVisibleInProjection, | ||
| 210 | mostRestrictivePolicy, | ||
| 211 | resolveProjectedPolicy, | 208 | resolveProjectedPolicy, |
| 212 | resolveProjectionRule, | ||
| 213 | type ToolProjection, | 209 | type ToolProjection, |
| 214 | type ToolProjectionRule, | 210 | type ToolProjectionRule, |
| 215 | } from "./tool-projection"; | 211 | } from "./tool-projection"; |
| @@ -51,9 +51,6 @@ export interface ToolProjection { · 50 unmodified lines | |||
| 51 | readonly excludePersonal?: boolean; | 51 | readonly excludePersonal?: boolean; |
| 52 | } | 52 | } |
| 53 | 53 | ||
| 54 | /** The projection that exposes every tool under the workspace policy alone. */ | ||
| 55 | export const fullToolProjection: ToolProjection = { visible: ["*"], rules: [] }; | ||
| 56 | |||
| 57 | const isPersonalToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; | 54 | const isPersonalToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; |
| 58 | 55 | ||
| 59 | const blockedOutsideProjection: EffectivePolicy = { | 56 | const blockedOutsideProjection: EffectivePolicy = { |
| @@ -87,7 +84,7 @@ const actionRank = (action: ToolPolicyAction): number => · 27 unmodified lines | |||
| 87 | * workspace "approve" does. When neither side has a rule both fall through to | 84 | * workspace "approve" does. When neither side has a rule both fall through to |
| 88 | * the same plugin default. | 85 | * the same plugin default. |
| 89 | */ | 86 | */ |
| 90 | export const mostRestrictivePolicy = ( | 87 | const mostRestrictivePolicy = ( |
| 91 | workspace: EffectivePolicy, | 88 | workspace: EffectivePolicy, |
| 92 | overlay: EffectivePolicy, | 89 | overlay: EffectivePolicy, |
| 93 | ): EffectivePolicy => { | 90 | ): EffectivePolicy => { |
| @@ -101,7 +98,7 @@ export const mostRestrictivePolicy = ( · 7 unmodified lines | |||
| 101 | }; | 98 | }; |
| 102 | 99 | ||
| 103 | /** Whether `toolId` falls inside the projection's capability boundary. */ | 100 | /** Whether `toolId` falls inside the projection's capability boundary. */ |
| 104 | export const isVisibleInProjection = (projection: ToolProjection, toolId: string): boolean => { | 101 | const isVisibleInProjection = (projection: ToolProjection, toolId: string): boolean => { |
| 105 | if (projection.excludePersonal && isPersonalToolId(toolId)) return false; | 102 | if (projection.excludePersonal && isPersonalToolId(toolId)) return false; |
| 106 | return projection.visible.some((pattern) => matchPattern(pattern, toolId)); | 103 | return projection.visible.some((pattern) => matchPattern(pattern, toolId)); |
| 107 | }; | 104 | }; |
| @@ -111,7 +108,7 @@ export const isVisibleInProjection = (projection: ToolProjection, toolId: string · 3 unmodified lines | |||
| 111 | * rule, else the plugin default. Does not consult the workspace policy; see | 108 | * rule, else the plugin default. Does not consult the workspace policy; see |
| 112 | * {@link resolveProjectedPolicy} for the combined answer. | 109 | * {@link resolveProjectedPolicy} for the combined answer. |
| 113 | */ | 110 | */ |
| 114 | export const resolveProjectionRule = ( | 111 | const resolveProjectionRule = ( |
| 115 | projection: ToolProjection, | 112 | projection: ToolProjection, |
| 116 | toolId: string, | 113 | toolId: string, |
| 117 | defaultRequiresApproval?: boolean, | 114 | defaultRequiresApproval?: boolean, |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | McpErrorReporter, | 8 | McpErrorReporter, |
| 9 | McpSessionStore, | 9 | McpSessionStore, |
| 10 | mcpResourceFromPathname, | 10 | mcpResourceFromPathname, |
| 11 | scopedMcpRoutePaths, | ||
| 11 | type AuthOutcome, | 12 | type AuthOutcome, |
| 12 | type McpDispatchResult, | 13 | type McpDispatchResult, |
| 13 | type McpResource, | 14 | type McpResource, |
| @@ -48,11 +49,6 @@ import { · 34 unmodified lines | |||
| 48 | // --------------------------------------------------------------------------- | 49 | // --------------------------------------------------------------------------- |
| 49 | 50 | ||
| 50 | const MCP_PATH = "/mcp"; | 51 | const MCP_PATH = "/mcp"; |
| 51 | const SCOPED_MCP_PATHS = [ | ||
| 52 | "/mcp/toolkits/:slug", | ||
| 53 | "/mcp/integrations/:slugs", | ||
| 54 | "/mcp/tools/:toolId", | ||
| 55 | ] as const; | ||
| 56 | 52 | ||
| 57 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ | 53 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ |
| 58 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); | 54 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| @@ -459,7 +455,7 @@ export const McpServingRoutes = HttpRouter.use((router) => · 400 unmodified lines | |||
| 459 | ); | 455 | ); |
| 460 | } | 456 | } |
| 461 | yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); | 457 | yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); |
| 462 | for (const path of SCOPED_MCP_PATHS) { | 458 | for (const path of scopedMcpRoutePaths(MCP_PATH)) { |
| 463 | yield* router.add("*", path, scopedMcpRoute); | 459 | yield* router.add("*", path, scopedMcpRoute); |
| 464 | } | 460 | } |
| 465 | }), | 461 | }), |
| @@ -20,9 +20,12 @@ export { · 19 unmodified lines | |||
| 20 | McpErrorReporterNoop, | 20 | McpErrorReporterNoop, |
| 21 | defaultMcpResource, | 21 | defaultMcpResource, |
| 22 | mcpResourceFromPathname, | 22 | mcpResourceFromPathname, |
| 23 | mcpResourceFromRequest, | ||
| 23 | mcpResourceFromSegments, | 24 | mcpResourceFromSegments, |
| 24 | mcpResourceKey, | 25 | mcpResourceKey, |
| 25 | mcpResourcePath, | 26 | mcpResourcePath, |
| 27 | OAUTH_PROTECTED_RESOURCE_PREFIX, | ||
| 28 | scopedMcpRoutePaths, | ||
| 26 | principalOwns, | 29 | principalOwns, |
| 27 | orgWriteAccessForPrincipal, | 30 | orgWriteAccessForPrincipal, |
| 28 | withOrgWriteAccess, | 31 | withOrgWriteAccess, |
| @@ -173,6 +173,37 @@ export const mcpResourceFromSegments = (segments: readonly string[]): McpResourc · 172 unmodified lines | |||
| 173 | export const mcpResourceFromPathname = (pathname: string): McpResource | null => | 173 | export const mcpResourceFromPathname = (pathname: string): McpResource | null => |
| 174 | mcpResourceFromSegments(pathname.split("/")); | 174 | mcpResourceFromSegments(pathname.split("/")); |
| 175 | 175 | ||
| 176 | /** The RFC 9728 well-known prefix every host mounts its resource metadata under. */ | ||
| 177 | export const OAUTH_PROTECTED_RESOURCE_PREFIX = "/.well-known/oauth-protected-resource"; | ||
| 178 | |||
| 179 | /** | ||
| 180 | * The resource a request names, whether it dialed the transport path or that | ||
| 181 | * path's protected-resource metadata document. Anything the grammar does not | ||
| 182 | * recognize (a host's bare metadata doc, an unrelated path) is the default. | ||
| 183 | * Hosts that add a leading org selector strip it before the request gets here. | ||
| 184 | */ | ||
| 185 | export const mcpResourceFromRequest = (request: Request): McpResource => { | ||
| 186 | const pathname = new URL(request.url).pathname; | ||
| 187 | const bare = pathname.startsWith(OAUTH_PROTECTED_RESOURCE_PREFIX) | ||
| 188 | ? pathname.slice(OAUTH_PROTECTED_RESOURCE_PREFIX.length) | ||
| 189 | : pathname; | ||
| 190 | return mcpResourceFromPathname(bare) ?? defaultMcpResource; | ||
| 191 | }; | ||
| 192 | |||
| 193 | const SCOPED_MCP_ROUTE_SUFFIXES = [ | ||
| 194 | `/${TOOLKITS_SEGMENT}/:slug`, | ||
| 195 | `/${INTEGRATIONS_SEGMENT}/:slugs`, | ||
| 196 | `/${TOOLS_SEGMENT}/:toolId`, | ||
| 197 | ] as const; | ||
| 198 | |||
| 199 | /** | ||
| 200 | * Router patterns for the scoped sub-resources under `prefix` (a path ending | ||
| 201 | * in `/mcp`): the transport routes for the envelope, the metadata-doc routes | ||
| 202 | * for a host's discovery seam. One list, so a new resource kind is one edit. | ||
| 203 | */ | ||
| 204 | export const scopedMcpRoutePaths = (prefix: `/${string}`): readonly `/${string}`[] => | ||
| 205 | SCOPED_MCP_ROUTE_SUFFIXES.map((suffix) => `${prefix}${suffix}` as const); | ||
| 206 | |||
| 176 | /** | 207 | /** |
| 177 | * The bare pathname that names `resource`: the inverse of | 208 | * The bare pathname that names `resource`: the inverse of |
| 178 | * {@link mcpResourceFromPathname}. Hosts prefix it with their org selector or | 209 | * {@link mcpResourceFromPathname}. Hosts prefix it with their org selector or |