Unified · Split
| @@ -4,8 +4,7 @@ import { Effect, Predicate } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import { |
| 5 | 5 | McpAuthProvider, |
| 6 | 6 | jsonRpcErrorBody, |
| 7 | − defaultMcpResource, | |
| 8 | − mcpResourceFromPathname, | |
| 7 | + mcpResourceFromRequest, | |
| 9 | 8 | orgWriteAccessForPrincipal, |
| 10 | 9 | withOrgWriteAccess, |
| 11 | 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 | 158 | const propsForPrincipal = ( |
| 168 | 159 | request: Request, |
| 169 | 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 | 285 | const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); |
| 292 | 286 | (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; |
| 293 | 287 | const forwarded = withOrgWriteAccess( |
| @@ -37,6 +37,8 @@ import { · 36 unmodified lines | ||
| 37 | 37 | unauthorized, |
| 38 | 38 | unavailable, |
| 39 | 39 | McpAuthProvider, |
| 40 | + mcpResourceFromRequest, | |
| 41 | + scopedMcpRoutePaths, | |
| 40 | 42 | type AuthOutcome, |
| 41 | 43 | type McpDiscoveryRoute, |
| 42 | 44 | type Principal, |
| @@ -48,7 +50,6 @@ import { CoreSharedServices } from "../auth/workos"; · 5 unmodified lines | ||
| 48 | 50 | import { |
| 49 | 51 | bearerChallengeFor, |
| 50 | 52 | mcpOrganizationFromRequest, |
| 51 | − mcpResourceFromRequest, | |
| 52 | 53 | protectedResourceMetadataUrlFor, |
| 53 | 54 | PROTECTED_RESOURCE_METADATA_PATH, |
| 54 | 55 | McpAuth, |
| @@ -66,13 +67,6 @@ import { · 11 unmodified lines | ||
| 66 | 67 | } from "./oauth-metadata"; |
| 67 | 68 | |
| 68 | 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 | 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 | 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 | 139 | const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [ |
| 144 | 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 | 142 | path, |
| 147 | 143 | handler: protectedResourceMetadata, |
| 148 | 144 | })), |
| @@ -14,8 +14,8 @@ import { Context, Effect, Layer, Predicate } from "effect"; · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | import { |
| 16 | 16 | defaultMcpResource, |
| 17 | − mcpResourceFromPathname, | |
| 18 | 17 | mcpResourcePath, |
| 18 | + OAUTH_PROTECTED_RESOURCE_PREFIX, | |
| 19 | 19 | type McpResource, |
| 20 | 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 | 75 | export const mcpOrganizationFromRequest = (request: Request): string | null => |
| 76 | 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 | 78 | /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy |
| 93 | 79 | * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client |
| 94 | 80 | * used), or the bare resource. */ |
| @@ -106,8 +92,8 @@ export const protectedResourceMetadataUrlFor = ( · 11 unmodified lines | ||
| 106 | 92 | resource: McpResource = defaultMcpResource, |
| 107 | 93 | ): string => |
| 108 | 94 | organizationSelector |
| 109 | − ? `${RESOURCE_ORIGIN}${PRM_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}` | |
| 110 | − : `${RESOURCE_ORIGIN}${PRM_PREFIX}${mcpResourcePath(resource)}`; | |
| 95 | + ? `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}` | |
| 96 | + : `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}${mcpResourcePath(resource)}`; | |
| 111 | 97 | |
| 112 | 98 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; |
| 113 | 99 | |
| @@ -1,6 +1,8 @@ | ||
| 1 | 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 | 6 | import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; |
| 5 | 7 | |
| 6 | 8 | describe("cloud MCP scoped route normalization", () => { |
| @@ -3,8 +3,7 @@ import { Effect, Predicate } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | McpAuthProvider, |
| 5 | 5 | jsonRpcErrorBody, |
| 6 | − defaultMcpResource, | |
| 7 | − mcpResourceFromPathname, | |
| 6 | + mcpResourceFromRequest, | |
| 8 | 7 | orgWriteAccessForPrincipal, |
| 9 | 8 | withOrgWriteAccess, |
| 10 | 9 | type AuthOutcome, |
| @@ -74,12 +73,6 @@ const authenticate = (request: Request, config: CloudflareConfig) => · 63 unmodified lines | ||
| 74 | 73 | return { auth, outcome }; |
| 75 | 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 | 76 | const propsForPrincipal = ( |
| 84 | 77 | request: Request, |
| 85 | 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 | 152 | const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource)); |
| 158 | 153 | (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; |
| 159 | 154 | const forwarded = withOrgWriteAccess( |
| @@ -2,38 +2,23 @@ import { Effect, Layer } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | authenticated, |
| 5 | − defaultMcpResource, | |
| 6 | 5 | McpAuthProvider, |
| 7 | − mcpResourceFromPathname, | |
| 6 | + mcpResourceFromRequest, | |
| 8 | 7 | mcpResourcePath, |
| 8 | + OAUTH_PROTECTED_RESOURCE_PREFIX, | |
| 9 | + scopedMcpRoutePaths, | |
| 9 | 10 | unauthorized, |
| 10 | 11 | type McpDiscoveryRoute, |
| 11 | − type McpResource, | |
| 12 | 12 | } from "@executor-js/host-mcp"; |
| 13 | 13 | |
| 14 | 14 | import { makeAccessVerifier } from "../auth/cloudflare-access"; |
| 15 | 15 | import type { CloudflareConfig } from "../config"; |
| 16 | 16 | |
| 17 | −const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; | |
| 18 | −const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`; | |
| 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 | −}; | |
| 17 | +const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX; | |
| 18 | +const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp` as const; | |
| 34 | 19 | |
| 35 | 20 | const resourcePathForRequest = (request: Request): string => |
| 36 | − mcpResourcePath(resourceForRequest(request)); | |
| 21 | + mcpResourcePath(mcpResourceFromRequest(request)); | |
| 37 | 22 | |
| 38 | 23 | const metadataPathForRequest = (request: Request): string => |
| 39 | 24 | `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePathForRequest(request)}`; |
| @@ -76,7 +61,7 @@ export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<M · 36 unmodified lines | ||
| 76 | 61 | path: MCP_PROTECTED_RESOURCE_METADATA_PATH, |
| 77 | 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 | 65 | path, |
| 81 | 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 | 5 | import { |
| 6 | 6 | authenticated, |
| 7 | 7 | McpAuthProvider, |
| 8 | − mcpResourceFromPathname, | |
| 8 | + mcpResourceFromRequest, | |
| 9 | 9 | mcpResourcePath, |
| 10 | + OAUTH_PROTECTED_RESOURCE_PREFIX, | |
| 11 | + scopedMcpRoutePaths, | |
| 10 | 12 | unauthorized, |
| 11 | 13 | type AuthOutcome, |
| 12 | 14 | type McpDiscoveryRoute, |
| 13 | − type McpResource, | |
| 14 | 15 | type Principal, |
| 15 | 16 | } from "@executor-js/host-mcp"; |
| 16 | 17 | |
| @@ -57,14 +58,9 @@ import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org · 40 unmodified lines | ||
| 57 | 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 | −// One metadata doc per scoped MCP sub-resource kind (the default doc is the | |
| 62 | −// bare well-known path itself, Better Auth's convention). | |
| 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; | |
| 61 | +// The default doc is the bare well-known path itself (Better Auth's | |
| 62 | +// convention); scoped docs sit under `<prefix>/mcp/...`. | |
| 63 | +const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX; | |
| 68 | 64 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; |
| 69 | 65 | |
| 70 | 66 | const parseRoles = (role: string | null | undefined): ReadonlyArray<string> => |
| @@ -99,26 +95,11 @@ const originalOrgScopedPathFor = (request: Request): string | null => { · 28 unmodified lines | ||
| 99 | 95 | return header ? mcpResourcePathFromOriginalPath(header) : null; |
| 100 | 96 | }; |
| 101 | 97 | |
| 102 | −/** | |
| 103 | − * The scoped MCP resource a bare request names, read off its own path (the | |
| 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 | −}; | |
| 98 | +const isScopedRequest = (request: Request): boolean => | |
| 99 | + mcpResourceFromRequest(request).kind !== "default"; | |
| 115 | 100 | |
| 116 | −const mcpResourcePathFor = (request: Request): string => { | |
| 117 | − const orgScoped = originalOrgScopedPathFor(request); | |
| 118 | − if (orgScoped) return orgScoped; | |
| 119 | − const scoped = scopedResourceFor(request); | |
| 120 | − return scoped ? mcpResourcePath(scoped) : "/mcp"; | |
| 121 | −}; | |
| 101 | +const mcpResourcePathFor = (request: Request): string => | |
| 102 | + originalOrgScopedPathFor(request) ?? mcpResourcePath(mcpResourceFromRequest(request)); | |
| 122 | 103 | |
| 123 | 104 | /** |
| 124 | 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 | 112 | const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; |
| 132 | 113 | const orgScoped = originalOrgScopedPathFor(request); |
| 133 | 114 | if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`; |
| 134 | − const scoped = scopedResourceFor(request); | |
| 135 | − return scoped | |
| 136 | − ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePath(scoped)}` | |
| 115 | + return isScopedRequest(request) | |
| 116 | + ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePathFor(request)}` | |
| 137 | 117 | : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`; |
| 138 | 118 | }; |
| 139 | 119 | |
| @@ -150,7 +130,7 @@ const scopedProtectedResourceMetadata = ( · 10 unmodified lines | ||
| 150 | 130 | response: Response, |
| 151 | 131 | baseURL: string | undefined, |
| 152 | 132 | ): Effect.Effect<Response> => { |
| 153 | − if (scopedResourceFor(request) === null) return Effect.succeed(response); | |
| 133 | + if (!isScopedRequest(request)) return Effect.succeed(response); | |
| 154 | 134 | return Effect.promise(async () => { |
| 155 | 135 | const body = (await response.json()) as Record<string, unknown>; |
| 156 | 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 | 170 | const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [ |
| 191 | 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 | 173 | path, |
| 194 | 174 | handler: protectedResourceMetadata, |
| 195 | 175 | })), |
| @@ -11,6 +11,7 @@ import { localAnalytics } from "./analytics"; · 10 unmodified lines | ||
| 11 | 11 | import { makeLocalApiHandler } from "./app"; |
| 12 | 12 | import { disposeExecutor, getExecutorBundle } from "./executor"; |
| 13 | 13 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| 14 | +import type { ExecutorMcpServerConfig } from "@executor-js/host-mcp/tool-server"; | |
| 14 | 15 | |
| 15 | 16 | // --------------------------------------------------------------------------- |
| 16 | 17 | // Local server handlers. |
| @@ -88,21 +89,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 71 unmodified lines | ||
| 88 | 89 | // part of the shared API). Reuse the shared boot bundle so the MCP executor is |
| 89 | 90 | // byte-identical to the one the API serves. |
| 90 | 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 | 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 | 93 | // Including the create-time smoke render, on the same terms as every other |
| 107 | 94 | // host. This daemon is a dependency of `apps/cli`, whose build does not |
| 108 | 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 | 106 | onArtifactUsage: (action: "created" | "viewed" | "updated") => |
| 120 | 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 | 127 | mcp = createMcpRequestHandler({ |
| 123 | − defaultConfig: { | |
| 124 | − engine, | |
| 125 | − artifacts: executor.artifacts, | |
| 126 | − connections: executor.connections, | |
| 127 | − ...appsConfig, | |
| 128 | − }, | |
| 128 | + defaultConfig: configFor(executor, false), | |
| 129 | 129 | createConfigForResource: async (resource) => { |
| 130 | 130 | const projection = projectionForMcpResource(resource); |
| 131 | − if (projection === undefined) { | |
| 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. | |
| 131 | + if (projection === undefined) return { config: configFor(executor, false) }; | |
| 145 | 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 | 133 | return { |
| 155 | − config: { | |
| 156 | − engine: projectedEngine, | |
| 157 | − artifacts: projected.artifacts, | |
| 158 | − connections: projected.connections, | |
| 159 | − ...appsConfig, | |
| 160 | − }, | |
| 134 | + config: configFor(projected, true), | |
| 161 | 135 | close: () => Effect.runPromise(Effect.ignore(projected.close())), |
| 162 | 136 | }; |
| 163 | 137 | }, |
| @@ -205,11 +205,7 @@ export { · 204 unmodified lines | ||
| 205 | 205 | |
| 206 | 206 | // Tool projections — a narrowed view over the catalog, layered on policies. |
| 207 | 207 | export { |
| 208 | − fullToolProjection, | |
| 209 | − isVisibleInProjection, | |
| 210 | − mostRestrictivePolicy, | |
| 211 | 208 | resolveProjectedPolicy, |
| 212 | − resolveProjectionRule, | |
| 213 | 209 | type ToolProjection, |
| 214 | 210 | type ToolProjectionRule, |
| 215 | 211 | } from "./tool-projection"; |
| @@ -51,9 +51,6 @@ export interface ToolProjection { · 50 unmodified lines | ||
| 51 | 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 | 54 | const isPersonalToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; |
| 58 | 55 | |
| 59 | 56 | const blockedOutsideProjection: EffectivePolicy = { |
| @@ -87,7 +84,7 @@ const actionRank = (action: ToolPolicyAction): number => · 27 unmodified lines | ||
| 87 | 84 | * workspace "approve" does. When neither side has a rule both fall through to |
| 88 | 85 | * the same plugin default. |
| 89 | 86 | */ |
| 90 | −export const mostRestrictivePolicy = ( | |
| 87 | +const mostRestrictivePolicy = ( | |
| 91 | 88 | workspace: EffectivePolicy, |
| 92 | 89 | overlay: EffectivePolicy, |
| 93 | 90 | ): EffectivePolicy => { |
| @@ -101,7 +98,7 @@ export const mostRestrictivePolicy = ( · 7 unmodified lines | ||
| 101 | 98 | }; |
| 102 | 99 | |
| 103 | 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 | 102 | if (projection.excludePersonal && isPersonalToolId(toolId)) return false; |
| 106 | 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 | 108 | * rule, else the plugin default. Does not consult the workspace policy; see |
| 112 | 109 | * {@link resolveProjectedPolicy} for the combined answer. |
| 113 | 110 | */ |
| 114 | −export const resolveProjectionRule = ( | |
| 111 | +const resolveProjectionRule = ( | |
| 115 | 112 | projection: ToolProjection, |
| 116 | 113 | toolId: string, |
| 117 | 114 | defaultRequiresApproval?: boolean, |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | McpErrorReporter, |
| 9 | 9 | McpSessionStore, |
| 10 | 10 | mcpResourceFromPathname, |
| 11 | + scopedMcpRoutePaths, | |
| 11 | 12 | type AuthOutcome, |
| 12 | 13 | type McpDispatchResult, |
| 13 | 14 | type McpResource, |
| @@ -48,11 +49,6 @@ import { · 34 unmodified lines | ||
| 48 | 49 | // --------------------------------------------------------------------------- |
| 49 | 50 | |
| 50 | 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 | 53 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ |
| 58 | 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 | 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 | 459 | yield* router.add("*", path, scopedMcpRoute); |
| 464 | 460 | } |
| 465 | 461 | }), |
| @@ -20,9 +20,12 @@ export { · 19 unmodified lines | ||
| 20 | 20 | McpErrorReporterNoop, |
| 21 | 21 | defaultMcpResource, |
| 22 | 22 | mcpResourceFromPathname, |
| 23 | + mcpResourceFromRequest, | |
| 23 | 24 | mcpResourceFromSegments, |
| 24 | 25 | mcpResourceKey, |
| 25 | 26 | mcpResourcePath, |
| 27 | + OAUTH_PROTECTED_RESOURCE_PREFIX, | |
| 28 | + scopedMcpRoutePaths, | |
| 26 | 29 | principalOwns, |
| 27 | 30 | orgWriteAccessForPrincipal, |
| 28 | 31 | withOrgWriteAccess, |
| @@ -173,6 +173,37 @@ export const mcpResourceFromSegments = (segments: readonly string[]): McpResourc · 172 unmodified lines | ||
| 173 | 173 | export const mcpResourceFromPathname = (pathname: string): McpResource | null => |
| 174 | 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 | 208 | * The bare pathname that names `resource`: the inverse of |
| 178 | 209 | * {@link mcpResourceFromPathname}. Hosts prefix it with their org selector or |