From 644b295cd2f29a90f1346c3faf9d4922d0eaa87f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 10:42:49 +0100 Subject: [PATCH 1/2] perf(ai): serve /mcp from its own route graph A cold maple-ai isolate built one route graph for every path. Its module statically imported the Settings > MCP catalog route, which imports the full tool registry, plus the chat, security-audit and triage routes and the OpenRouter SDK. So the first /mcp request in an isolate, even an initialize, evaluated every MCP tool module and service. /mcp now has its own graph (runtime/mcp-http-graph.ts) with the shared auth graph. Every other path keeps the existing graph. The tools load on the first tools/call, as McpToolExecutorLoader intended. Adds apps/ai/scripts/cold-path to bundle the Worker like the deploy and time the cold graphs offline. --- apps/ai/scripts/cold-path/build-bundle.mjs | 71 +++++++++++++++++++ apps/ai/scripts/cold-path/graph-probe.ts | 27 ++++++++ apps/ai/scripts/cold-path/measure.mjs | 66 ++++++++++++++++++ apps/ai/src/runtime/auth-graph.ts | 35 ++++++++++ apps/ai/src/runtime/http-graph.ts | 79 ++-------------------- apps/ai/src/runtime/mcp-http-graph.test.ts | 76 +++++++++++++++++++++ apps/ai/src/runtime/mcp-http-graph.ts | 18 +++++ apps/ai/src/runtime/raw-routes.ts | 32 +++++++++ apps/ai/src/worker.ts | 5 +- apps/ai/src/worker/http.ts | 40 +++++++++-- apps/ai/test/offline-http-ports.ts | 27 ++++++++ 11 files changed, 394 insertions(+), 82 deletions(-) create mode 100644 apps/ai/scripts/cold-path/build-bundle.mjs create mode 100644 apps/ai/scripts/cold-path/graph-probe.ts create mode 100644 apps/ai/scripts/cold-path/measure.mjs create mode 100644 apps/ai/src/runtime/auth-graph.ts create mode 100644 apps/ai/src/runtime/mcp-http-graph.test.ts create mode 100644 apps/ai/src/runtime/mcp-http-graph.ts create mode 100644 apps/ai/src/runtime/raw-routes.ts create mode 100644 apps/ai/test/offline-http-ports.ts diff --git a/apps/ai/scripts/cold-path/build-bundle.mjs b/apps/ai/scripts/cold-path/build-bundle.mjs new file mode 100644 index 0000000000..32893bb9b1 --- /dev/null +++ b/apps/ai/scripts/cold-path/build-bundle.mjs @@ -0,0 +1,71 @@ +// Build maple-ai the way the deploy does: alchemy's virtual entry (DO + Workflow +// class stubs), the Cloudflare Rolldown plugin, the runtime define, minify and +// `strictExecutionOrder: false`. Writes the bundle analyzer report beside it. +// PROBE=1 adds the offline graph probe as a second entry, for measure.mjs. +// OUT=apps/ai/node_modules/.cache/cold-path/bundle bun apps/ai/scripts/cold-path/build-bundle.mjs +import { createRequire } from "node:module" +import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs" +import { resolve } from "node:path" + +const alchemy = realpathSync("node_modules/alchemy") +const cloudflare = realpathSync("node_modules/@alchemy.run/cloudflare") +const require = createRequire(alchemy + "/package.json") +const { rolldown } = await import(require.resolve("rolldown")) +const { bundleAnalyzerPlugin } = await import(require.resolve("rolldown/experimental")) +const { default: cloudflareRolldown } = await import("@alchemy.run/cloudflare-runtime/rolldown") +const { makeEffectVirtualEntry } = await import(cloudflare + "/src/Workers/Sources/Rolldown.ts") + +const cache = resolve("apps/ai/node_modules/.cache") +const out = resolve(process.env.OUT ?? "apps/ai/node_modules/.cache/cold-path/bundle") +if (!out.startsWith(cache + "/")) throw new Error("OUT must be under apps/ai/node_modules/.cache") +rmSync(out, { recursive: true, force: true }) + +const entryDir = resolve(cache, "cold-path-entry") +mkdirSync(entryDir, { recursive: true }) +const entry = resolve(entryDir, "entry.ts") +writeFileSync( + entry, + makeEffectVirtualEntry( + { ChatSession: { kind: "durableObject" }, SecurityAuditWorkflow: { kind: "workflow" } }, + { name: "maple", stage: "cold-path" }, + )(resolve("apps/ai/src/worker.ts")), +) + +const build = await rolldown({ + input: + process.env.PROBE === "1" + ? { worker: entry, probe: resolve("apps/ai/scripts/cold-path/graph-probe.ts") } + : { worker: entry }, + external: ["lightningcss", "fsevents"], + transform: { define: { "globalThis.__ALCHEMY_RUNTIME__": "true" } }, + moduleTypes: { ".sql": "text", ".txt": "text", ".html": "text" }, + plugins: [ + cloudflareRolldown({ compatibilityDate: "2026-10-01" }), + bundleAnalyzerPlugin({ fileName: "analyze-data.json", format: "json" }), + { + // Static vs dynamic edges per module, which the analyzer report does not separate. + name: "import-graph", + generateBundle() { + const graph = {} + for (const id of this.getModuleIds()) { + const info = this.getModuleInfo(id) + graph[id] = { + static: info?.importedIds ?? [], + dynamic: info?.dynamicallyImportedIds ?? [], + } + } + this.emitFile({ type: "asset", fileName: "import-graph.json", source: JSON.stringify(graph) }) + }, + }, + ], + checks: { unresolvedImport: false, ineffectiveDynamicImport: false }, +}) +await build.write({ + format: "esm", + sourcemap: "hidden", + minify: true, + keepNames: true, + dir: out, + strictExecutionOrder: false, +}) +console.log("built", out) diff --git a/apps/ai/scripts/cold-path/graph-probe.ts b/apps/ai/scripts/cold-path/graph-probe.ts new file mode 100644 index 0000000000..b4a1f78e2e --- /dev/null +++ b/apps/ai/scripts/cold-path/graph-probe.ts @@ -0,0 +1,27 @@ +/** Offline probe of maple-ai's real request graphs. Every external call fails instead of running. */ +import { Context, Effect, Layer } from "effect" +import { WorkerPlatformLive } from "@maple/infra/worker-http" +import { DatabasePgLive } from "@maple/backend/platform/DatabasePgLive" +import { buildApp, buildMcpApp } from "../../src/worker/http" +import { offlinePorts } from "../../test/offline-http-ports" + +/** The route graph the first request builds: `/mcp`'s own, or the rest. */ +export const build = (graph: "mcp" | "full") => + Effect.runPromise((graph === "mcp" ? buildMcpApp : buildApp)(Context.empty(), offlinePorts)) + +/** The tool executor graph the first `tools/*` request imports and builds. */ +export const buildTools = () => + Effect.runPromise( + Effect.gen(function* () { + const { McpServicesLive } = yield* Effect.promise( + () => import("../../src/runtime/mcp-service-graph"), + ) + return yield* Layer.build( + McpServicesLive.pipe( + Layer.provide(WorkerPlatformLive), + Layer.provide(DatabasePgLive), + Layer.provide(offlinePorts), + ), + ) + }).pipe(Effect.scoped), + ) diff --git a/apps/ai/scripts/cold-path/measure.mjs b/apps/ai/scripts/cold-path/measure.mjs new file mode 100644 index 0000000000..efc93ea621 --- /dev/null +++ b/apps/ai/scripts/cold-path/measure.mjs @@ -0,0 +1,66 @@ +// Fresh-process desktop V8 proxy of maple-ai's cold path. CPU includes runtime/GC work, no I/O. +// Run from the repo root: bun apps/ai/scripts/cold-path/measure.mjs [runs] +import { spawnSync } from "node:child_process" +import { resolve } from "node:path" +import { pathToFileURL } from "node:url" + +const [dir, count = "11"] = process.argv.slice(2) +if (!dir) throw new Error("Expected bundle directory") +if (process.env.MAPLE_CPU_PROBE_CHILD === "1") { + globalThis.caches = { default: {} } + globalThis.HTMLRewriter = class {} + globalThis.fetch = async () => { + throw new Error("External I/O is forbidden in the CPU probe") + } + const base = pathToFileURL(resolve(dir) + "/") + const results = {} + const measure = async (name, action) => { + const cpu = process.cpuUsage() + const wall = performance.now() + const value = await action() + const used = process.cpuUsage(cpu) + results[name] = { wallMs: performance.now() - wall, cpuMs: (used.user + used.system) / 1000 } + return value + } + await measure("startup", () => import(new URL("worker.js", base))) + const probe = await measure("probeImport", () => import(new URL("probe.js", base))) + await measure("routeGraph", () => probe.build("mcp")) + await measure("toolGraph", () => probe.buildTools()) + console.log("MEASUREMENT " + JSON.stringify(results)) +} else { + const runs = Array.from({ length: Number(count) }, () => { + const child = spawnSync( + "node", + [ + "--no-warnings", + "--loader", + "./apps/api/scripts/cold-path/cf-loader.mjs", + import.meta.filename, + dir, + ], + { encoding: "utf8", env: { ...process.env, MAPLE_CPU_PROBE_CHILD: "1" } }, + ) + const line = child.stdout.split("\n").find((l) => l.startsWith("MEASUREMENT ")) + if (child.status !== 0 || !line) throw new Error(child.stderr || child.stdout) + return JSON.parse(line.slice(12)) + }) + const median = (xs) => xs.toSorted((a, b) => a - b)[Math.floor(xs.length / 2)] + const keys = Object.keys(runs[0]) + const summary = Object.fromEntries( + keys.map((key) => [ + key, + { wallMs: median(runs.map((r) => r[key].wallMs)), cpuMs: median(runs.map((r) => r[key].cpuMs)) }, + ]), + ) + console.log( + JSON.stringify( + { + engine: spawnSync("node", ["--version"], { encoding: "utf8" }).stdout.trim(), + runs: runs.length, + median: summary, + }, + null, + 2, + ), + ) +} diff --git a/apps/ai/src/runtime/auth-graph.ts b/apps/ai/src/runtime/auth-graph.ts new file mode 100644 index 0000000000..cd6312991c --- /dev/null +++ b/apps/ai/src/runtime/auth-graph.ts @@ -0,0 +1,35 @@ +import { EdgeCacheServiceLive } from "@maple/backend/platform/CacheBackendLive" +import { Env } from "@maple/backend/platform/Env" +import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" +import { AuthService } from "@maple/backend/services/auth/AuthService" +import { McpToolRateLimiter } from "@maple/backend/services/auth/McpToolRateLimiter" +import { SessionAuthorizationLive } from "@maple/backend/services/auth/SessionAuthorizationLive" +import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" +import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService" +import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" +import { Layer } from "effect" + +/** + * What authenticates a request here. + * + * `/mcp` resolves its own tenant inside the transport, from an API key, an MCP + * OAuth bearer or a session cookie, so it needs `ApiKeysService` and the tool + * rate limiter rather than a route-level authorization layer. The chat routes + * are session-only, the same as they were on api. + * + * `McpOAuthRateLimiter` is deliberately absent: the OAuth endpoints stayed on + * api, which still owns its own limiter for them. + */ +export const AiAuthLive = Layer.mergeAll(SessionAuthorizationLive).pipe( + // `/mcp` falls back to session auth when the bearer is neither an API key nor + // an MCP OAuth token, so the transport resolves tenants through this too. + Layer.provideMerge(AuthService.layer), + Layer.provideMerge(McpToolRateLimiter.layer), + Layer.provideMerge(ApiKeysService.layer), + // `/mcp` checks a requested agent identity against the org's actors. + Layer.provideMerge(ErrorActorsService.layer), + Layer.provideMerge(OrganizationRegionService.layer), + // Denied attempts and audited reads are recorded from inside the auth layers. + Layer.provideMerge(AuditLogService.layer.pipe(Layer.provide(Env.layer))), + Layer.provideMerge(Layer.mergeAll(Env.layer, EdgeCacheServiceLive)), +) diff --git a/apps/ai/src/runtime/http-graph.ts b/apps/ai/src/runtime/http-graph.ts index 5892c2c43a..1b6f71300d 100644 --- a/apps/ai/src/runtime/http-graph.ts +++ b/apps/ai/src/runtime/http-graph.ts @@ -1,11 +1,9 @@ -import { EdgeCacheServiceLive } from "@maple/backend/platform/CacheBackendLive" /** - * Every route the AI Worker serves, as one layer. + * Every route the AI Worker serves except `/mcp`, as one layer. `/mcp` is its + * own graph (`./mcp-http-graph`), so a cold `/mcp` request never loads these. * * Several surfaces, and they are deliberately different shapes: * - * - `/mcp` — the public MCP transport, a raw router because the protocol is - * JSON-RPC over one POST rather than a set of typed endpoints. * - `/api/chat/sessions/*`: the transport for an investigation's follow-up * conversation, raw because `HttpApi` cannot model an open `text/event-stream`. * - `/internal/chat/apply` — a typed `HttpApi` group, because deciding an @@ -19,16 +17,13 @@ import { EdgeCacheServiceLive } from "@maple/backend/platform/CacheBackendLive" * gate, typed for the same reason. Reached only over a service binding: api * does not forward it, and the callers are the Workers that open incidents. * - * The api still owns the hostname. It forwards the public ones here over a service - * binding, which is what keeps `/mcp`'s OAuth issuer and RFC 8707 resource - * identifiers on api's origin — moving them would invalidate every registered - * MCP client. + * The api still owns the hostname and forwards the public ones here over a + * service binding. */ import { MapleAiApi } from "@maple/domain/http" import { Effect, Layer } from "effect" import { HttpRouter } from "effect/http" import { HttpApiBuilder } from "effect/http-api" -import { McpLive } from "../mcp/app" import { HttpChatLive } from "../routes/internal/chat.http" import { HttpTriageLive } from "../routes/internal/triage.http" import { HttpMcpToolsLive } from "../routes/internal/mcp-tools.http" @@ -38,51 +33,14 @@ import { WorkersAiGateway } from "../platform/WorkersAiHttpClient" import { ChatSessionsRouter } from "../routes/chat-sessions.http" import { HealthRouter } from "../routes/health" import { API_CORS_OPTIONS } from "@maple/backend/http/api-cors" -import { Env } from "@maple/backend/platform/Env" -import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService" -import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" -import { AuthService } from "@maple/backend/services/auth/AuthService" -import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" -import { McpToolRateLimiter } from "@maple/backend/services/auth/McpToolRateLimiter" -import { SessionAuthorizationLive } from "@maple/backend/services/auth/SessionAuthorizationLive" -import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" import { SecurityAuditCredits } from "@maple/backend/services/security-audit/SecurityAuditCredits" import { SecurityAuditRepositorySize } from "@maple/backend/services/security-audit/SecurityAuditRepositorySize" import { SecurityAuditStore } from "@maple/backend/services/security-audit/SecurityAuditStore" import { ApiErrorBoundaryLive } from "@maple/backend/http/error-boundary" -import type { AiPortsLayer } from "../worker/bindings" +import { rawRoutes } from "./raw-routes" -/** - * Services a raw router's handlers still expect from the request context, beyond the Worker's - * ports, which every request carries. Each is a runtime "Service not found". - */ -type LeakedRequestServices = - Layer.Services extends infer Marker - ? Marker extends HttpRouter.Request<"Requires", infer Service> - ? Exclude> - : never - : never - -/** - * A raw `HttpRouter` handler runs in the request's own context — unlike an `HttpApiBuilder` - * group, nothing carries the router's build context into it — so a service it reads per request - * has to arrive through `HttpRouter.provideRequest` (see `ChatSessionsRouter`). Read inside the - * handler instead, it compiles, because the isolate builder erases the marker, and fails every - * request with "Service not found", which is what took the chat routes down on 2026-09-08. This - * turns that into a build failure naming the leaked service. - * - * Carried over from apps/api verbatim. It is worth more here, not less: this Worker is almost - * entirely raw routers. - */ -const rawRoutes = ( - routes: Routes & - ([LeakedRequestServices] extends [never] - ? unknown - : { readonly leakedRequestServices: LeakedRequestServices }), -) => routes - -const RawRoutes = rawRoutes(Layer.mergeAll(HealthRouter, ChatSessionsRouter, McpLive)) +const RawRoutes = rawRoutes(Layer.mergeAll(HealthRouter, ChatSessionsRouter)) /** * The decision model the triage route asks, built once per isolate from the @@ -113,28 +71,3 @@ const AiInternalRoutes = HttpApiBuilder.layer(MapleAiApi).pipe( export const AllRoutes = Layer.mergeAll(AiInternalRoutes, RawRoutes).pipe( Layer.provideMerge(HttpRouter.cors(API_CORS_OPTIONS)), ) - -/** - * What authenticates a request here. - * - * `/mcp` resolves its own tenant inside the transport, from an API key, an MCP - * OAuth bearer or a session cookie, so it needs `ApiKeysService` and the tool - * rate limiter rather than a route-level authorization layer. The chat routes - * are session-only, the same as they were on api. - * - * `McpOAuthRateLimiter` is deliberately absent: the OAuth endpoints stayed on - * api, which still owns its own limiter for them. - */ -export const AiAuthLive = Layer.mergeAll(SessionAuthorizationLive).pipe( - // `/mcp` falls back to session auth when the bearer is neither an API key nor - // an MCP OAuth token, so the transport resolves tenants through this too. - Layer.provideMerge(AuthService.layer), - Layer.provideMerge(McpToolRateLimiter.layer), - Layer.provideMerge(ApiKeysService.layer), - // `/mcp` checks a requested agent identity against the org's actors. - Layer.provideMerge(ErrorActorsService.layer), - Layer.provideMerge(OrganizationRegionService.layer), - // Denied attempts and audited reads are recorded from inside the auth layers. - Layer.provideMerge(AuditLogService.layer.pipe(Layer.provide(Env.layer))), - Layer.provideMerge(Layer.mergeAll(Env.layer, EdgeCacheServiceLive)), -) diff --git a/apps/ai/src/runtime/mcp-http-graph.test.ts b/apps/ai/src/runtime/mcp-http-graph.test.ts new file mode 100644 index 0000000000..d6255b1332 --- /dev/null +++ b/apps/ai/src/runtime/mcp-http-graph.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest" +import * as Cloudflare from "@alchemy.run/cloudflare" +import { IsolateAge } from "@maple/infra/isolate-age" +import type { HttpEffect } from "alchemy/Http" +import { Context, Effect, Schema, Scope } from "effect" +import { buildApp, buildMcpApp, makeFetch } from "../worker/http" +import { offlinePorts } from "../../test/offline-http-ports" + +class UnexpectedGraphBuild extends Schema.TaggedError()("UnexpectedGraphBuild", { + message: Schema.String, +}) {} + +const serve = (fetch: HttpEffect, method: string, path: string, body?: string) => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const responseEffect: Effect.Effect = + Cloudflare.Workers.makeRequestHandler(fetch)({ + kind: "Cloudflare.Workers.WorkerEvent", + type: "fetch", + input: new Request(`http://ai.maple.test${path}`, { + method, + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body, + }), + })! + const response = yield* responseEffect + return { + status: response.status, + challenge: response.headers.get("www-authenticate") !== null, + } + }), + ).pipe(Effect.provide(offlinePorts)), + ) + +const initialize = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1" } }, +}) + +describe("the maple-ai route graphs", () => { + it("answers /mcp from its own graph without building the other routes", async () => { + const mcpApp = buildMcpApp(Context.empty(), offlinePorts) + const rest = Effect.fail(new UnexpectedGraphBuild({ message: "a /mcp request built the rest graph" })) + const fetch = await Effect.runPromise( + makeFetch(rest, offlinePorts, mcpApp).pipe(Effect.provide(IsolateAge.layer)), + ) + + const unauthenticated = await serve(fetch, "POST", "/mcp", initialize) + expect({ status: unauthenticated.status, challenge: unauthenticated.challenge }).toEqual({ + status: 401, + challenge: true, + }) + // The rest graph is still what every other path selects. + expect((await serve(fetch, "GET", "/internal/mcp/tools")).status).toBe(503) + }) + + it("serves the other routes from the rest graph without building /mcp", async () => { + const app = buildApp(Context.empty(), offlinePorts) + const mcp = Effect.fail( + new UnexpectedGraphBuild({ message: "a catalog request built the /mcp graph" }), + ) + const fetch = await Effect.runPromise( + makeFetch(app, offlinePorts, mcp).pipe(Effect.provide(IsolateAge.layer)), + ) + + // Session-only, so unauthenticated it is the rest graph's 401: not `/mcp`'s 404, nor a 503. + expect((await serve(fetch, "GET", "/internal/mcp/tools")).status).toBe(401) + expect((await serve(fetch, "POST", "/mcp", initialize)).status).toBe(503) + }) +}) diff --git a/apps/ai/src/runtime/mcp-http-graph.ts b/apps/ai/src/runtime/mcp-http-graph.ts new file mode 100644 index 0000000000..32748ce245 --- /dev/null +++ b/apps/ai/src/runtime/mcp-http-graph.ts @@ -0,0 +1,18 @@ +/** + * `/mcp`, the public MCP transport, as a route graph of its own: a raw router because the + * protocol is JSON-RPC over one POST rather than a set of typed endpoints. + * + * Separate from `./http-graph` so a cold isolate serving `/mcp` loads only the transport and + * the auth graph, not the chat, security-audit, triage and tool-catalog routes. The tools + * themselves load on the first `tools/call` (`McpToolExecutorLoader`). + * + * The api still owns the hostname and forwards `/mcp` here over a service binding, which is + * what keeps its OAuth issuer and RFC 8707 resource identifiers on api's origin. + */ +import { Layer } from "effect" +import { HttpRouter } from "effect/http" +import { API_CORS_OPTIONS } from "@maple/backend/http/api-cors" +import { McpLive } from "../mcp/app" +import { rawRoutes } from "./raw-routes" + +export const McpRoutes = rawRoutes(McpLive).pipe(Layer.provideMerge(HttpRouter.cors(API_CORS_OPTIONS))) diff --git a/apps/ai/src/runtime/raw-routes.ts b/apps/ai/src/runtime/raw-routes.ts new file mode 100644 index 0000000000..0803a051e3 --- /dev/null +++ b/apps/ai/src/runtime/raw-routes.ts @@ -0,0 +1,32 @@ +import type { Layer } from "effect" +import type { HttpRouter } from "effect/http" +import type { AiPortsLayer } from "../worker/bindings" + +/** + * Services a raw router's handlers still expect from the request context, beyond the Worker's + * ports, which every request carries. Each is a runtime "Service not found". + */ +type LeakedRequestServices = + Layer.Services extends infer Marker + ? Marker extends HttpRouter.Request<"Requires", infer Service> + ? Exclude> + : never + : never + +/** + * A raw `HttpRouter` handler runs in the request's own context. Unlike an `HttpApiBuilder` + * group, nothing carries the router's build context into it, so a service it reads per request + * has to arrive through `HttpRouter.provideRequest` (see `ChatSessionsRouter`). Read inside the + * handler instead, it compiles, because the isolate builder erases the marker, and fails every + * request with "Service not found", which is what took the chat routes down on 2026-09-08. This + * turns that into a build failure naming the leaked service. + * + * Carried over from apps/api verbatim. It is worth more here, not less: this Worker is almost + * entirely raw routers. + */ +export const rawRoutes = ( + routes: Routes & + ([LeakedRequestServices] extends [never] + ? unknown + : { readonly leakedRequestServices: LeakedRequestServices }), +) => routes diff --git a/apps/ai/src/worker.ts b/apps/ai/src/worker.ts index a6f0a706ba..ae0cff0455 100644 --- a/apps/ai/src/worker.ts +++ b/apps/ai/src/worker.ts @@ -36,7 +36,7 @@ import SecurityAuditWorkflow from "./security-audit/SecurityAuditWorkflow" import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "./mcp/expected-failures" import { WorkersAiGateway } from "./platform/WorkersAiHttpClient" import { aiPorts, AiBindingsLive, bindAiClients, WorkersAiGatewayLive } from "./worker/bindings" -import { buildApp, makeFetch } from "./worker/http" +import { buildApp, buildMcpApp, makeFetch } from "./worker/http" import { AiObservabilityLive } from "./worker/observability" /** Config-sourced env. The tools query the warehouse as the calling org, so this is largely api's set. */ @@ -115,7 +115,8 @@ export default MapleAi.make( // Captured before any event, so the first request's context cannot leak into later ones. const isolate = isolateContext(yield* Effect.context()) const app = yield* cachedRecoverable(buildApp(isolate, ports)) - return { fetch: yield* makeFetch(app, ports) } + const mcpApp = yield* cachedRecoverable(buildMcpApp(isolate, ports)) + return { fetch: yield* makeFetch(app, ports, mcpApp) } }).pipe( // oxlint-disable-next-line effecttsgo/strict-effect-provide Effect.provide( diff --git a/apps/ai/src/worker/http.ts b/apps/ai/src/worker/http.ts index 7ae76687f5..37136c6f29 100644 --- a/apps/ai/src/worker/http.ts +++ b/apps/ai/src/worker/http.ts @@ -36,13 +36,16 @@ export const buildIsolateHandler = ( >, ) => forIsolate(isolate)(HttpRouter.toHttpEffect(routes)).pipe(Effect.map(bridgeHandler)) -/** The route graph as one request handler, built once per isolate on the first request. */ -export const buildApp = (isolate: Context.Context, ports: AiPortsLayer) => +/** The `/mcp` graph as one request handler, built once per isolate on the first `/mcp` request. */ +export const buildMcpApp = (isolate: Context.Context, ports: AiPortsLayer) => Effect.gen(function* () { - const { AllRoutes, AiAuthLive } = yield* Effect.promise(() => import("../runtime/http-graph")) + const [{ McpRoutes }, { AiAuthLive }] = yield* Effect.all([ + Effect.promise(() => import("../runtime/mcp-http-graph")), + Effect.promise(() => import("../runtime/auth-graph")), + ]) return yield* buildIsolateHandler( isolate, - AllRoutes.pipe( + McpRoutes.pipe( // The tool executor's graph loads on the first `tools/*` request, not here. Layer.provideMerge(McpToolExecutorLoader.layer), Layer.provideMerge(AiAuthLive), @@ -53,17 +56,40 @@ export const buildApp = (isolate: Context.Context, ports: AiPortsLayer) = ) }) +/** Every other route as one request handler, built once per isolate on the first such request. */ +export const buildApp = (isolate: Context.Context, ports: AiPortsLayer) => + Effect.gen(function* () { + const [{ AllRoutes }, { AiAuthLive }] = yield* Effect.all([ + Effect.promise(() => import("../runtime/http-graph")), + Effect.promise(() => import("../runtime/auth-graph")), + ]) + return yield* buildIsolateHandler( + isolate, + AllRoutes.pipe( + Layer.provideMerge(AiAuthLive), + Layer.provideMerge(WorkerPlatformLive), + Layer.provideMerge(DatabasePgLive), + Layer.provide(ports), + ), + ) + }) + /** * The request handler the bridge serves. Liveness answers before the route graph * exists: it needs neither the domain graph nor the database, and a cold isolate - * can report health when an unrelated binding is unavailable. + * can report health when an unrelated binding is unavailable. `/mcp` runs on its + * own graph, so a cold `/mcp` request does not load the other routes. * * No CORS preflight branch here, unlike api's: the api owns the origin and * answers `OPTIONS` before it forwards, so a second set of headers from this * Worker would be a duplicate `access-control-allow-origin`, which browsers * reject outright. */ -export const makeFetch = (app: Effect.Effect, ports: Layer.Layer) => +export const makeFetch = ( + app: Effect.Effect, + ports: Layer.Layer, + mcpApp: Effect.Effect = app, +) => IsolateAge.useSync((isolateAge) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest @@ -75,7 +101,7 @@ export const makeFetch = (app: Effect.Effect, ports: Layer.Lay // Before the graph builds, so its failures count too and cold and warm compare unconditioned. // The stamp goes back to the caller, whose span is the only one that sees cold-start CPU. const stamp = yield* isolateAge.record - const built = yield* Effect.exit(app) + const built = yield* Effect.exit(path === "/mcp" ? mcpApp : app) if (Exit.isFailure(built)) { yield* Effect.logError("AI worker route graph failed to build", built.cause).pipe( Effect.annotateLogs({ method: request.method, path }), diff --git a/apps/ai/test/offline-http-ports.ts b/apps/ai/test/offline-http-ports.ts new file mode 100644 index 0000000000..2f0b62b0cf --- /dev/null +++ b/apps/ai/test/offline-http-ports.ts @@ -0,0 +1,27 @@ +/** Offline graph fixtures. Every external operation fails rather than reaching a real service. */ +import { Effect, Layer, Option } from "effect" +import { fakeChatSessionsLayer } from "@maple/backend/platform/chat-sessions-fake" +import { envPorts } from "@maple/backend/platform/env-ports" +import { + MapleDbConnection, + McpToolsRateLimit, + SecurityAuditWorkflowStarter, +} from "@maple/backend/platform/bindings" +import { WorkersAiGateway } from "../src/platform/WorkersAiHttpClient" + +const rejectIO = () => Effect.die("Unexpected external I/O in an offline graph") + +export const offlinePorts = Layer.mergeAll( + Layer.succeed(WorkersAiGateway, Option.none()), + Layer.succeed(SecurityAuditWorkflowStarter, { create: rejectIO, cancel: rejectIO }), + Layer.succeed(McpToolsRateLimit, { limit: rejectIO }), + Layer.succeed(MapleDbConnection, Option.none()), + fakeChatSessionsLayer(() => ({})), + envPorts({ + TINYBIRD_HOST: "https://warehouse.invalid", + TINYBIRD_TOKEN: "offline", + MAPLE_ROOT_PASSWORD: "offline-benchmark-only", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", + }), +) From bb9a5dde054b996c9567af526f1b777169727a59 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 10:47:15 +0100 Subject: [PATCH 2/2] chore(ai): register the cold-path scripts with knip They run by path, like apps/api's. Rolldown resolves through alchemy's own dependencies and the loader path is built at run time, so knip sees neither as an import. --- apps/ai/scripts/cold-path/build-bundle.mjs | 7 ++++--- apps/ai/scripts/cold-path/measure.mjs | 2 +- knip.json | 7 ++++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/ai/scripts/cold-path/build-bundle.mjs b/apps/ai/scripts/cold-path/build-bundle.mjs index 32893bb9b1..975e733966 100644 --- a/apps/ai/scripts/cold-path/build-bundle.mjs +++ b/apps/ai/scripts/cold-path/build-bundle.mjs @@ -9,9 +9,10 @@ import { resolve } from "node:path" const alchemy = realpathSync("node_modules/alchemy") const cloudflare = realpathSync("node_modules/@alchemy.run/cloudflare") -const require = createRequire(alchemy + "/package.json") -const { rolldown } = await import(require.resolve("rolldown")) -const { bundleAnalyzerPlugin } = await import(require.resolve("rolldown/experimental")) +// Resolved through alchemy's own dependencies, the copy the deploy bundles with. +const alchemyRequire = createRequire(alchemy + "/package.json") +const { rolldown } = await import(alchemyRequire.resolve("rolldown")) +const { bundleAnalyzerPlugin } = await import(alchemyRequire.resolve("rolldown/experimental")) const { default: cloudflareRolldown } = await import("@alchemy.run/cloudflare-runtime/rolldown") const { makeEffectVirtualEntry } = await import(cloudflare + "/src/Workers/Sources/Rolldown.ts") diff --git a/apps/ai/scripts/cold-path/measure.mjs b/apps/ai/scripts/cold-path/measure.mjs index efc93ea621..bd41e791a9 100644 --- a/apps/ai/scripts/cold-path/measure.mjs +++ b/apps/ai/scripts/cold-path/measure.mjs @@ -34,7 +34,7 @@ if (process.env.MAPLE_CPU_PROBE_CHILD === "1") { [ "--no-warnings", "--loader", - "./apps/api/scripts/cold-path/cf-loader.mjs", + pathToFileURL(resolve("apps/api/scripts/cold-path/cf-loader.mjs")).href, import.meta.filename, dir, ], diff --git a/knip.json b/knip.json index 035ba3ce9a..076655857e 100644 --- a/knip.json +++ b/knip.json @@ -37,7 +37,12 @@ // alchemy bundles it, so it is the app's one entry. // The tool playground config is run by path from scripts/tool-playground.sh. "apps/ai": { - "entry": ["src/worker.ts", "vitest.playground.config.ts", "src/evals/playground/*.playground.ts"] + "entry": [ + "src/worker.ts", + "vitest.playground.config.ts", + "src/evals/playground/*.playground.ts", + "scripts/cold-path/*.{ts,mjs}" + ] }, "apps/alerting": { "entry": ["src/worker.ts"]