Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/host-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ followed. A host naming a missing binding fails config load: the Worker answers
503 with a message naming the host and the binding. Both the Worker and
`McpSessionDO` read the same config, so both paths route.

`INTERNAL_MCP_REGISTRIES` declares what each internal host serves, as a JSON
array of `{ "host", "servers": [{ "slug", "name", "description", "route" }] }`
with `route` equal to `/mcp/<slug>` and every `host` present in
`INTERNAL_MCP_HOSTS` (otherwise config load fails). Once per isolate, each
declared server whose slug does not exist yet is created as an org-level MCP
integration at `https://<host><route>` (no auth) with an org connection named
`main`, and its tools are loaded. An existing integration with the same slug is
never changed, and nothing is ever deleted.

**Trust boundary.** The boundary is the Cloudflare account, not Executor: any
Worker in the same account could bind the target Worker, so the bound Worker
must be private (no public route, `workers_dev` and preview URLs off) and
Expand Down
1 change: 1 addition & 0 deletions apps/host-cloudflare/src/account/account-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const config: CloudflareConfig = {
secretKey: "x".repeat(32),
allowLocalNetwork: false,
internalHosts: {},
internalIntegrations: [],
webBaseUrl: "https://localhost",
enableDevAuth: false,
};
Expand Down
48 changes: 46 additions & 2 deletions apps/host-cloudflare/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { Effect } from "effect";
import { HttpEffect, HttpRouter } from "effect/unstable/http";

import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
import {
dbProviderLayer,
ExecutorApp,
type ExecutorDbHandle,
makeScopedExecutor,
textFailureStrategy,
} from "@executor-js/api/server";

import { internalPrincipal } from "./auth/cloudflare-access";
import { reconcileInternalIntegrations } from "./internal-integrations/reconcile";
import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "./config";
import { makeCloudflarePlugins } from "./plugins";
import { makeCloudflarePlugins, type CloudflarePlugins } from "./plugins";
import { createD1ExecutorDb } from "./db/d1";
import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access";
import {
Expand Down Expand Up @@ -33,6 +41,41 @@ import { preloadQuickJs } from "./quickjs";
// so the providers close over it instead of reading process.env.
// ===========================================================================

let internalIntegrationsReconciled: Promise<void> | null = null;

// Once per isolate, by a system executor that may write org rows. Never fails the boot.
const reconcileOncePerIsolate = (
config: CloudflareConfig,
dbHandle: ExecutorDbHandle,
): Promise<void> => {
if (config.internalIntegrations.length === 0) return Promise.resolve();
internalIntegrationsReconciled ??= Effect.runPromise(
Effect.gen(function* () {
const accountId = internalPrincipal(config, { subject: null, email: null })?.accountId;
if (accountId === undefined) return;
const executor = yield* makeScopedExecutor<CloudflarePlugins>(
accountId,
config.organizationId,
config.organizationName,
{ orgWrites: "allowed" },
);
yield* reconcileInternalIntegrations(executor, config.internalIntegrations).pipe(
Effect.ensuring(executor.close().pipe(Effect.ignore)),
);
}).pipe(
Effect.provide(dbProviderLayer(Effect.succeed(dbHandle))),
Effect.provide(makeCloudflarePluginsProvider(config)),
Effect.provide(makeCloudflareHostConfig(config)),
Effect.catchCause((cause) =>
Effect.logWarning("internal integration reconcile aborted", cause).pipe(
Effect.annotateLogs({ step: "reconcile-internal-integrations" }),
),
),
),
);
return internalIntegrationsReconciled;
};

export const makeCloudflareApp = async (
env: CloudflareEnv,
config: CloudflareConfig = loadConfig(env),
Expand All @@ -46,6 +89,7 @@ export const makeCloudflareApp = async (
// Open and idempotently bring up the D1 schema once. This is the long-lived
// handle the per-request scoped executor reads through the DbProvider seam.
const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS);
await reconcileOncePerIsolate(config, dbHandle);
const identityLayer = cloudflareAccessIdentityLayer(config);
const mcpAgentHandler = makeCloudflareMcpAgentHandler(config);
const approvalHandler = makeCloudflareApprovalHandler(config, env);
Expand Down
1 change: 1 addition & 0 deletions apps/host-cloudflare/src/auth/cloudflare-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const config: CloudflareConfig = {
secretKey: "x".repeat(32),
allowLocalNetwork: false,
internalHosts: {},
internalIntegrations: [],
webBaseUrl: "https://localhost",
enableDevAuth: false,
};
Expand Down
54 changes: 54 additions & 0 deletions apps/host-cloudflare/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,57 @@ describe("INTERNAL_MCP_HOSTS", () => {
);
});
});

describe("INTERNAL_MCP_REGISTRIES", () => {
const TOOLS = { fetch: async () => new Response("ok") };
const REGISTRIES =
'[{"host":"tools.internal","servers":[{"slug":"websearch","name":"Web Search","description":"Search the web","route":"/mcp/websearch"}]}]';
const devEnv = (overrides: Record<string, unknown>): ConfigEnv =>
Object.assign(makeEnv({ ENABLE_DEV_AUTH: "true" }), overrides);
const withHosts = (registries: string) =>
devEnv({
INTERNAL_MCP_HOSTS: "tools.internal=TOOLS",
INTERNAL_MCP_REGISTRIES: registries,
TOOLS,
});

it("derives one integration per declared server", () => {
expect(loadConfig(withHosts(REGISTRIES)).internalIntegrations).toEqual([
{
slug: "websearch",
name: "Web Search",
description: "Search the web",
endpoint: "https://tools.internal/mcp/websearch",
},
]);
});

it("declares nothing when the variable is unset", () => {
expect(loadConfig(devEnv({})).internalIntegrations).toEqual([]);
});

it("fails loudly when a registry host has no INTERNAL_MCP_HOSTS entry", () => {
expect(() => loadConfig(devEnv({ INTERNAL_MCP_REGISTRIES: REGISTRIES }))).toThrowError(
"INTERNAL_MCP_REGISTRIES names host tools.internal, but INTERNAL_MCP_HOSTS has no entry for it",
);
});

it("refuses a route that is not /mcp/<slug>", () => {
expect(() =>
loadConfig(withHosts(REGISTRIES.replace("/mcp/websearch", "/other"))),
).toThrowError('INTERNAL_MCP_REGISTRIES route "/other" must be /mcp/websearch');
});

it("refuses a duplicate slug", () => {
const server = '{"slug":"a","name":"A","description":"d","route":"/mcp/a"}';
expect(() =>
loadConfig(withHosts(`[{"host":"tools.internal","servers":[${server},${server}]}]`)),
).toThrowError("INTERNAL_MCP_REGISTRIES slug a is declared more than once");
});

it("refuses a value that is not the registry shape", () => {
expect(() => loadConfig(withHosts('{"host":"tools.internal"}'))).toThrowError(
"INTERNAL_MCP_REGISTRIES must be a JSON array of { host, servers: [{ slug, name, description, route }] } objects",
);
});
});
88 changes: 87 additions & 1 deletion apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Option, Schema } from "effect";
import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types";

import type { JevGatewayConfig } from "@executor-js/execution";
Expand Down Expand Up @@ -73,6 +74,14 @@ export interface CloudflareEnv {
* this Worker (wrangler `services`).
*/
readonly INTERNAL_MCP_HOSTS?: string;
/**
* JSON array of registries, one per internal host:
* `[{"host":"tools.internal","servers":[{"slug","name","description","route"}]}]`.
* Each server becomes an org-level MCP integration at `https://<host><route>`,
* created at boot when its slug does not exist yet. Every `host` must be a key
* of `INTERNAL_MCP_HOSTS`.
*/
readonly INTERNAL_MCP_REGISTRIES?: string;
/**
* Cloudflare AI Gateway, for the Jev classifier (`custom-typesafe`). Every
* model call goes through a gateway rather than a provider directly, so the
Expand Down Expand Up @@ -116,6 +125,8 @@ export interface CloudflareConfig {
readonly allowLocalNetwork: boolean;
/** Internal MCP hosts mapped to their service bindings (see `INTERNAL_MCP_HOSTS`). */
readonly internalHosts: HostedInternalHosts;
/** Integrations declared by `INTERNAL_MCP_REGISTRIES`, ready to reconcile. */
readonly internalIntegrations: readonly InternalIntegration[];
/** Explicit web base URL (`VITE_PUBLIC_SITE_URL`). Unset on a Worker with no
* static URL — the per-request origin is used instead (see RequestWebOrigin). */
readonly webBaseUrl?: string;
Expand Down Expand Up @@ -235,6 +246,79 @@ const resolveInternalHosts = (env: CloudflareConfigEnv): HostedInternalHosts =>
return hosts;
};

export interface InternalIntegration {
readonly slug: string;
readonly name: string;
readonly description: string;
readonly endpoint: string;
}

const InternalRegistries = Schema.Array(
Schema.Struct({
host: Schema.String,
servers: Schema.Array(
Schema.Struct({
slug: Schema.String,
name: Schema.String,
description: Schema.String,
route: Schema.String,
}),
),
}),
);

const decodeInternalRegistries = Schema.decodeUnknownOption(
Schema.fromJsonString(InternalRegistries),
);

const INTERNAL_SLUG = /^[a-z][a-z0-9_]*$/;

const resolveInternalIntegrations = (
env: CloudflareConfigEnv,
internalHosts: HostedInternalHosts,
): readonly InternalIntegration[] => {
const raw = (env.INTERNAL_MCP_REGISTRIES ?? "").trim();
if (raw.length === 0) return [];
const refuse = (message: string): never => {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a malformed internal registry must fail the boot, not drop integrations silently
throw new Error(`INTERNAL_MCP_REGISTRIES ${message}`);
};
const registries = decodeInternalRegistries(raw);
if (Option.isNone(registries)) {
return refuse(
"must be a JSON array of { host, servers: [{ slug, name, description, route }] } objects",
);
}
const seen = new Set<string>();
const integrations: InternalIntegration[] = [];
for (const registry of registries.value) {
const host = normalizeHostname(registry.host);
if (!Object.hasOwn(internalHosts, host)) {
refuse(`names host ${host}, but INTERNAL_MCP_HOSTS has no entry for it`);
}
for (const server of registry.servers) {
if (!INTERNAL_SLUG.test(server.slug)) {
refuse(`slug ${JSON.stringify(server.slug)} must match ^[a-z][a-z0-9_]*$`);
}
if (server.route !== `/mcp/${server.slug}`) {
refuse(`route ${JSON.stringify(server.route)} must be /mcp/${server.slug}`);
}
if (server.name.trim().length === 0 || server.description.trim().length === 0) {
refuse(`slug ${server.slug} needs a non-empty name and description`);
}
if (seen.has(server.slug)) refuse(`slug ${server.slug} is declared more than once`);
seen.add(server.slug);
integrations.push({
slug: server.slug,
name: server.name,
description: server.description,
endpoint: `https://${host}${server.route}`,
});
}
}
return integrations;
};

export interface LoadConfigOptions {
/** Build the trusted config for the service-binding entrypoint (see
* {@link CloudflareConfig.trustedInternal}). */
Expand Down Expand Up @@ -270,6 +354,7 @@ export const loadConfig = (
}),
);
}
const internalHosts = resolveInternalHosts(env);
return {
accessTeamDomain,
accessAud,
Expand All @@ -282,7 +367,8 @@ export const loadConfig = (
organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG),
secretKey,
allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
internalHosts: resolveInternalHosts(env),
internalHosts,
internalIntegrations: resolveInternalIntegrations(env, internalHosts),
// Pinned origin via the shared resolver. A Worker receives no PaaS platform
// vars (env: {} — there is nothing to detect), so only the explicit
// VITE_PUBLIC_SITE_URL applies; when it's unset we leave webBaseUrl undefined
Expand Down
Loading
Loading