From f04ee925aa3292d8e42193d498c16b56c67532a7 Mon Sep 17 00:00:00 2001 From: fforres Date: Wed, 7 Oct 2026 19:19:19 -0700 Subject: [PATCH 1/3] feat(sdk): route configured internal hosts to service bindings outside the SSRF guard HostedHttpClientOptions and HostConfig gain internalHosts: an exact hostname match goes to its fetcher; any other *.internal host is refused, and the guard rejects redirects into one. Applies to the MCP transport and the plain fetch. --- .../core/api/src/server/scoped-executor.ts | 10 ++ packages/core/sdk/src/host-internal.ts | 2 + .../core/sdk/src/hosted-http-client.test.ts | 164 ++++++++++++++++++ packages/core/sdk/src/hosted-http-client.ts | 73 +++++++- .../plugins/mcp/src/sdk/internal-host.test.ts | 49 ++++++ 5 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 packages/plugins/mcp/src/sdk/internal-host.test.ts diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 28dfc4cda7..950e3b1e5c 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -49,6 +49,7 @@ import { makeHostedFetch, makeHostedHttpClientLayer, touchSubject, + type HostedInternalHosts, } from "@executor-js/sdk/host-internal"; import { DbProvider } from "./executor-fuma-db"; @@ -65,6 +66,13 @@ export interface HostConfigShape { * production hosts leave it off. Drives `makeHostedHttpClientLayer`. */ readonly allowLocalNetwork: boolean; + /** + * Private hosts served by a service binding, keyed by exact lowercase hostname + * (`tools.internal`). Requests to them skip the network and the SSRF guard; any + * other `*.internal` host is refused. Applies to the MCP transport and to the + * plain fetch (OAuth discovery, probes). + */ + readonly internalHosts?: HostedInternalHosts; /** * AI Gateway for the Jev classifier. Present only on a host that configured * one; absent leaves tool search purely lexical, which is the correct default @@ -330,6 +338,7 @@ export const makeScopedExecutor = < const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, requireTls: config.requireTls, + internalHosts: config.internalHosts, }; const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions); const hostedFetch = makeHostedFetch(hostedHttpOptions); @@ -435,6 +444,7 @@ export const makePlatformExecutor = ( const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, requireTls: config.requireTls, + internalHosts: config.internalHosts, }; return yield* createExecutor({ diff --git a/packages/core/sdk/src/host-internal.ts b/packages/core/sdk/src/host-internal.ts index 51d9292252..b4500a1136 100644 --- a/packages/core/sdk/src/host-internal.ts +++ b/packages/core/sdk/src/host-internal.ts @@ -35,6 +35,8 @@ export { makeHostedHttpClientLayer, spanRedactedHeaderNames, type HostedHttpClientOptions, + type HostedInternalFetcher, + type HostedInternalHosts, } from "./hosted-http-client"; export { diff --git a/packages/core/sdk/src/hosted-http-client.test.ts b/packages/core/sdk/src/hosted-http-client.test.ts index 7b377b2f6c..bee556e30a 100644 --- a/packages/core/sdk/src/hosted-http-client.test.ts +++ b/packages/core/sdk/src/hosted-http-client.test.ts @@ -397,3 +397,167 @@ describe("hosted TLS policy", () => { expect(calls).toBe(1); }); }); + +describe("internal host routing", () => { + const recordingFetcher = () => { + const seen: Array<{ url: string; method: string; header: string | null; body: string }> = []; + return { + seen, + fetch: async (request: Request) => { + seen.push({ + url: request.url, + method: request.method, + header: request.headers.get("x-probe"), + body: await request.text(), + }); + return new Response("from-binding", { status: 200 }); + }, + }; + }; + + const externalStub = () => { + const urls: string[] = []; + const fetch = (async (input) => { + const url = input instanceof Request ? input.url : String(input); + urls.push(url); + if (url === "https://evil.example/start") { + return new Response(null, { + status: 302, + headers: { location: "https://tools.internal/mcp/websearch" }, + }); + } + return new Response("from-network", { status: 200 }); + }) as typeof globalThis.fetch; + return { urls, fetch }; + }; + + it.effect("sends an internal host through the HTTP client to its binding", () => + Effect.gen(function* () { + const binding = recordingFetcher(); + const external = externalStub(); + const response = yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + return yield* client.execute( + HttpClientRequest.post("https://tools.internal/mcp/websearch").pipe( + HttpClientRequest.setHeader("x-probe", "abc"), + HttpClientRequest.bodyText('{"jsonrpc":"2.0"}', "application/json"), + ), + ); + }).pipe( + Effect.provide( + makeHostedHttpClientLayer({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }), + ), + ); + + expect(yield* response.text).toBe("from-binding"); + expect(binding.seen).toEqual([ + { + url: "https://tools.internal/mcp/websearch", + method: "POST", + header: "abc", + body: '{"jsonrpc":"2.0"}', + }, + ]); + expect(external.urls).toEqual([]); + }), + ); + + it("sends an internal host through the plain fetch to its binding", async () => { + const binding = recordingFetcher(); + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + const response = await hostedFetch( + "https://tools.internal/.well-known/oauth-authorization-server", + ); + + expect(await response.text()).toBe("from-binding"); + expect(binding.seen.map((call) => call.url)).toEqual([ + "https://tools.internal/.well-known/oauth-authorization-server", + ]); + expect(external.urls).toEqual([]); + }); + + it("sends an external host through the guarded fetch", async () => { + const binding = recordingFetcher(); + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + const response = await hostedFetch("https://api.example/openapi.json"); + + expect(await response.text()).toBe("from-network"); + expect(external.urls).toEqual(["https://api.example/openapi.json"]); + expect(binding.seen).toEqual([]); + }); + + it("still blocks a private address for an external host", async () => { + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + internalHosts: { "tools.internal": recordingFetcher() }, + }); + + await expect(hostedFetch("http://127.0.0.1:3000/mcp")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + }); + expect(external.urls).toEqual([]); + }); + + it("rejects an external redirect into an internal host", async () => { + const binding = recordingFetcher(); + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + await expect(hostedFetch("https://evil.example/start")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + }); + expect(external.urls).toEqual(["https://evil.example/start"]); + expect(binding.seen).toEqual([]); + }); + + it("rejects an internal host with no binding, naming the host", async () => { + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": recordingFetcher() }, + }); + + await expect(hostedFetch("https://other.internal/mcp")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + reason: "No internal binding is configured for host other.internal", + }); + expect(external.urls).toEqual([]); + }); + + it("matches the host exactly, not by suffix", async () => { + const binding = recordingFetcher(); + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + await expect(hostedFetch("https://x.tools.internal/mcp")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + }); + expect(binding.seen).toEqual([]); + }); +}); diff --git a/packages/core/sdk/src/hosted-http-client.ts b/packages/core/sdk/src/hosted-http-client.ts index a4009358ed..7caae764e9 100644 --- a/packages/core/sdk/src/hosted-http-client.ts +++ b/packages/core/sdk/src/hosted-http-client.ts @@ -18,7 +18,22 @@ export type HostedHostnameResolver = ( hostname: string, ) => Promise>; +/** A Workers service binding (or anything shaped like one). */ +export interface HostedInternalFetcher { + readonly fetch: (request: Request) => Promise; +} + +/** Exact lowercase hostname to the fetcher that serves it, e.g. `tools.internal`. */ +export type HostedInternalHosts = Readonly>; + export interface HostedHttpClientOptions { + /** + * Private hosts served by a service binding instead of the network. A request + * whose hostname matches exactly goes to the fetcher, outside the SSRF guard; + * any other `*.internal` host is rejected, and the guard refuses redirects into + * one. Empty or absent leaves routing off. + */ + readonly internalHosts?: HostedInternalHosts; readonly allowLocalNetwork?: boolean; /** Require HTTPS, except private addresses explicitly allowed for local development. */ readonly requireTls?: boolean; @@ -219,6 +234,14 @@ const stripCredentialHeaders = (init: RequestInit | undefined): RequestInit => { return { ...init, headers }; }; +const INTERNAL_SUFFIX = ".internal"; + +const hasInternalHosts = (options: HostedHttpClientOptions): boolean => + Object.keys(options.internalHosts ?? {}).length > 0; + +const isInternalHostname = (hostname: string): boolean => + hostname.toLowerCase().replace(/\.$/, "").endsWith(INTERNAL_SUFFIX); + const guardFetch = ( underlying: typeof globalThis.fetch, options: HostedHttpClientOptions, @@ -233,6 +256,16 @@ const guardFetch = ( let currentInit = init; for (let redirects = 0; redirects <= maxRedirects; redirects++) { const url = current instanceof Request ? current.url : String(current); + if (hasInternalHosts(options) && isInternalHostname(new URL(url).hostname)) { + await Effect.runPromise( + Effect.fail( + new HostedOutboundRequestBlocked({ + url, + reason: "Internal hosts are only reachable directly, not through a redirect", + }), + ), + ); + } await Effect.runPromise(validateHostedOutboundUrl(url, guardOptions)); const response = await underlying(current, { ...currentInit, @@ -259,9 +292,39 @@ const guardFetch = ( return await underlying(current, { ...currentInit, redirect: "manual" }); }) as typeof globalThis.fetch; +// The router sits OUTSIDE the guard: a configured internal host goes straight to +// its binding, everything else takes the guarded path unchanged. +const routeInternalHosts = ( + guarded: typeof globalThis.fetch, + options: HostedHttpClientOptions, +): typeof globalThis.fetch => { + const hosts = options.internalHosts ?? {}; + if (!hasInternalHosts(options)) return guarded; + return (async (input, init) => { + const request = new Request(input, init); + const hostname = new URL(request.url).hostname.toLowerCase().replace(/\.$/, ""); + const target = Object.hasOwn(hosts, hostname) ? hosts[hostname] : undefined; + if (target) return await target.fetch(request); + if (isInternalHostname(hostname)) { + return await Effect.runPromise( + Effect.fail( + new HostedOutboundRequestBlocked({ + url: request.url, + reason: `No internal binding is configured for host ${hostname}`, + }), + ), + ); + } + return await guarded(request); + }) as typeof globalThis.fetch; +}; + export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof globalThis.fetch => - // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch - guardFetch(options.fetch ?? globalThis.fetch, options); + routeInternalHosts( + // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch + guardFetch(options.fetch ?? globalThis.fetch, options), + options, + ); // --------------------------------------------------------------------------- // Span header redaction. @@ -336,11 +399,13 @@ export const makeHostedHttpClientLayer = ( Layer.provide(FetchHttpClient.layer), Layer.provide( options.fetch - ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options)) + ? Layer.succeed(FetchHttpClient.Fetch)( + routeInternalHosts(guardFetch(options.fetch, options), options), + ) : Layer.effect( FetchHttpClient.Fetch, Effect.map(Effect.service(FetchHttpClient.Fetch), (underlying) => - guardFetch(underlying, options), + routeInternalHosts(guardFetch(underlying, options), options), ), ), ), diff --git a/packages/plugins/mcp/src/sdk/internal-host.test.ts b/packages/plugins/mcp/src/sdk/internal-host.test.ts new file mode 100644 index 0000000000..6ad1fc4c3c --- /dev/null +++ b/packages/plugins/mcp/src/sdk/internal-host.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal"; + +import { createMcpConnector } from "./connection"; +import { discoverTools } from "./discover"; +import { makeEchoMcpServer } from "../testing"; + +// A fake service binding that is a real (stateless) MCP server, so the full +// initialize and tools/list handshake crosses the internal route. +const mcpBinding = () => { + const seen: string[] = []; + return { + seen, + fetch: async (request: Request) => { + seen.push(`${request.method} ${new URL(request.url).pathname}`); + const server = makeEchoMcpServer({ name: "websearch", toolName: "search" }); + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + }); + await server.connect(transport); + return transport.handleRequest(request); + }, + }; +}; + +describe("MCP over an internal service binding", () => { + it.live("discovers the catalog of an integration at an internal host", () => + Effect.gen(function* () { + const binding = mcpBinding(); + const manifest = yield* discoverTools( + createMcpConnector({ + transport: "remote", + endpoint: "https://tools.internal/mcp/websearch", + remoteTransport: "streamable-http", + httpClientLayer: makeHostedHttpClientLayer({ + internalHosts: { "tools.internal": binding }, + }), + }), + ); + + expect(manifest.server?.name).toBe("websearch"); + expect(manifest.tools.map((tool) => tool.toolName)).toEqual(["search"]); + expect(binding.seen.length).toBeGreaterThan(0); + expect(binding.seen.every((call) => call.endsWith(" /mcp/websearch"))).toBe(true); + }), + ); +}); From 2c74cba9fd13a2f5a5d39074394fb610a4c10ae8 Mon Sep 17 00:00:00 2001 From: fforres Date: Wed, 7 Oct 2026 19:41:04 -0700 Subject: [PATCH 2/3] refactor(sdk): one normalizeHostname and isInternalHostname, never follow a redirect from an internal binding host-cloudflare config validates INTERNAL_MCP_HOSTS with the same helpers. Requests to a binding carry redirect: manual so the runtime cannot follow a Location past the SSRF guard. UPSTREAM.md splits 3ba849c1; README documents the trust boundary. --- packages/core/sdk/src/host-internal.ts | 2 + .../core/sdk/src/hosted-http-client.test.ts | 44 +++++++++++++++++++ packages/core/sdk/src/hosted-http-client.ts | 15 +++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/core/sdk/src/host-internal.ts b/packages/core/sdk/src/host-internal.ts index b4500a1136..824b13cc14 100644 --- a/packages/core/sdk/src/host-internal.ts +++ b/packages/core/sdk/src/host-internal.ts @@ -31,8 +31,10 @@ export { HostedOutboundRequestBlocked, + isInternalHostname, makeHostedFetch, makeHostedHttpClientLayer, + normalizeHostname, spanRedactedHeaderNames, type HostedHttpClientOptions, type HostedInternalFetcher, diff --git a/packages/core/sdk/src/hosted-http-client.test.ts b/packages/core/sdk/src/hosted-http-client.test.ts index bee556e30a..e689c5de6e 100644 --- a/packages/core/sdk/src/hosted-http-client.test.ts +++ b/packages/core/sdk/src/hosted-http-client.test.ts @@ -5,8 +5,10 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { type HostedHostnameResolver, + isInternalHostname, makeHostedFetch, makeHostedHttpClientLayer, + normalizeHostname, validateHostedOutboundUrl, } from "./hosted-http-client"; @@ -398,6 +400,20 @@ describe("hosted TLS policy", () => { }); }); +describe("internal hostnames", () => { + it("normalises case and one trailing dot", () => { + expect(normalizeHostname("Tools.Internal.")).toBe("tools.internal"); + expect(normalizeHostname("tools.internal")).toBe("tools.internal"); + }); + + it("recognises *.internal hosts only", () => { + expect(isInternalHostname("TOOLS.internal.")).toBe(true); + expect(isInternalHostname("a.b.internal")).toBe(true); + expect(isInternalHostname("internal.example.com")).toBe(false); + expect(isInternalHostname("notinternal")).toBe(false); + }); +}); + describe("internal host routing", () => { const recordingFetcher = () => { const seen: Array<{ url: string; method: string; header: string | null; body: string }> = []; @@ -486,6 +502,34 @@ describe("internal host routing", () => { expect(external.urls).toEqual([]); }); + it("hands the binding a manual-redirect request and returns its redirect untouched", async () => { + const seen: string[] = []; + const binding = { + fetch: async (request: Request) => { + seen.push(request.redirect); + return new Response(null, { + status: 302, + headers: { location: "https://elsewhere.example/loot" }, + }); + }, + }; + const external = externalStub(); + const hostedFetch = makeHostedFetch({ + fetch: external.fetch, + resolveHostname: publicResolver, + internalHosts: { "tools.internal": binding }, + }); + + const response = await hostedFetch("https://tools.internal/mcp/websearch", { + redirect: "follow", + }); + + expect(seen).toEqual(["manual"]); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("https://elsewhere.example/loot"); + expect(external.urls).toEqual([]); + }); + it("sends an external host through the guarded fetch", async () => { const binding = recordingFetcher(); const external = externalStub(); diff --git a/packages/core/sdk/src/hosted-http-client.ts b/packages/core/sdk/src/hosted-http-client.ts index 7caae764e9..d48460ceed 100644 --- a/packages/core/sdk/src/hosted-http-client.ts +++ b/packages/core/sdk/src/hosted-http-client.ts @@ -239,8 +239,13 @@ const INTERNAL_SUFFIX = ".internal"; const hasInternalHosts = (options: HostedHttpClientOptions): boolean => Object.keys(options.internalHosts ?? {}).length > 0; -const isInternalHostname = (hostname: string): boolean => - hostname.toLowerCase().replace(/\.$/, "").endsWith(INTERNAL_SUFFIX); +/** Lowercase, without a trailing dot: the form `internalHosts` keys are matched in. */ +export const normalizeHostname = (hostname: string): string => + hostname.toLowerCase().replace(/\.$/, ""); + +/** True for any `*.internal` host, whether or not a binding is configured for it. */ +export const isInternalHostname = (hostname: string): boolean => + normalizeHostname(hostname).endsWith(INTERNAL_SUFFIX); const guardFetch = ( underlying: typeof globalThis.fetch, @@ -301,8 +306,10 @@ const routeInternalHosts = ( const hosts = options.internalHosts ?? {}; if (!hasInternalHosts(options)) return guarded; return (async (input, init) => { - const request = new Request(input, init); - const hostname = new URL(request.url).hostname.toLowerCase().replace(/\.$/, ""); + // `manual`: a 3xx from the bound Worker comes back to the caller as is. The + // runtime would otherwise follow its `Location` outside the SSRF guard. + const request = new Request(input, { ...init, redirect: "manual" }); + const hostname = normalizeHostname(new URL(request.url).hostname); const target = Object.hasOwn(hosts, hostname) ? hosts[hostname] : undefined; if (target) return await target.fetch(request); if (isInternalHostname(hostname)) { From 78c6e20ed3c688232ec7e08846c151553c34acb7 Mon Sep 17 00:00:00 2001 From: fforres Date: Thu, 8 Oct 2026 21:07:11 -0700 Subject: [PATCH 3/3] feat(host-cloudflare): INTERNAL_MCP_HOSTS maps internal hosts to service bindings Validates host=BINDING pairs at config load and fails loudly (503) when a mapped binding is absent or a host does not end in .internal. Passes the mapping to the hosted HTTP client, documents the trust boundary and the deploy path, ignores generated wrangler configs, and makes the e2e worker ignore a developer's local .dev.vars. --- apps/host-cloudflare/.gitignore | 2 + apps/host-cloudflare/README.md | 38 ++++++++++++++ .../src/account/account-provider.test.ts | 1 + .../src/auth/cloudflare-access.test.ts | 1 + apps/host-cloudflare/src/config.test.ts | 37 ++++++++++++++ apps/host-cloudflare/src/config.ts | 49 +++++++++++++++++++ apps/host-cloudflare/src/execution.ts | 1 + .../src/worker.e2e.node.test.ts | 6 +++ 8 files changed, 135 insertions(+) diff --git a/apps/host-cloudflare/.gitignore b/apps/host-cloudflare/.gitignore index 726b7f10f1..e82237e2a9 100644 --- a/apps/host-cloudflare/.gitignore +++ b/apps/host-cloudflare/.gitignore @@ -2,3 +2,5 @@ dist/ .dev.vars .wrangler/ wrangler.preview.jsonc +.wrangler.deploy.json +.wrangler.local.json diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md index 8bc2044e1f..1ac16f32d6 100644 --- a/apps/host-cloudflare/README.md +++ b/apps/host-cloudflare/README.md @@ -29,6 +29,39 @@ org. Members and credentials are managed in Cloudflare Access, not in-app. everything else is the SPA. Every API/MCP route is gated by the Access JWT (401 without). The SPA's auth context reads `/api/account/me`. +### Internal tools over service bindings + +The surfaces above are inbound. Outbound, a private MCP server in another Worker +is reached over a service binding, never the internet. `INTERNAL_MCP_HOSTS` is a +comma-separated list of `host=BINDING` pairs (host ends in `.internal`), and each +BINDING is a service binding on this Worker: + +```jsonc +"services": [{ "binding": "TOOLS", "service": "my-tools", "entrypoint": "ToolsMcp" }], +"vars": { "INTERNAL_MCP_HOSTS": "tools.internal=TOOLS" } +``` + +An MCP integration with endpoint `https://tools.internal/mcp/websearch` is then +served by `env.TOOLS.fetch(request)`, for the MCP transport and for the plain +fetch (OAuth discovery, probes, `/.well-known/*`; the bound Worker should answer +404 for paths it does not serve). The match is on the exact hostname, outside the +SSRF guard. Every other host takes the guarded path unchanged, any other +`*.internal` host is refused, and an external server redirecting to an internal +host stays blocked. Requests to a binding go out with `redirect: "manual"`, so a +redirect answered by the bound Worker reaches the caller as is and is never +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. + +**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 +Executor is its only intended caller. Hosts match exactly (`tools.internal`, not +`x.tools.internal`), and a redirect from an external server into `*.internal` is +refused. Because an integration pointing at an internal host reaches code that +skips the SSRF guard, only admins should create such integrations, never +end users. + ## Deploy ```bash @@ -75,6 +108,11 @@ cd apps/host-cloudflare bun run deploy # vite build -> assert-shell-asset -> wrangler deploy ``` +If the deployment declares internal service bindings, deploy through the +monorepo that owns them (`pnpm executor:deploy`) instead of `bun run deploy` +here. A deploy from this directory does not carry those bindings, and internal +integrations then fail until it is redeployed the right way. + Requires `bunx wrangler login` to the Skyward account. `keep_vars` preserves the Access variables set above, so there is nothing else to pass. Do not re-run `deploy:setup` for a routine deploy; that script is for first-time diff --git a/apps/host-cloudflare/src/account/account-provider.test.ts b/apps/host-cloudflare/src/account/account-provider.test.ts index 89d579536e..973344127f 100644 --- a/apps/host-cloudflare/src/account/account-provider.test.ts +++ b/apps/host-cloudflare/src/account/account-provider.test.ts @@ -15,6 +15,7 @@ const config: CloudflareConfig = { organizationSlug: "default", secretKey: "x".repeat(32), allowLocalNetwork: false, + internalHosts: {}, webBaseUrl: "https://localhost", enableDevAuth: false, }; diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts index c9dbaad5b4..c1d9ad99f0 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -23,6 +23,7 @@ const config: CloudflareConfig = { organizationSlug: "default", secretKey: "x".repeat(32), allowLocalNetwork: false, + internalHosts: {}, webBaseUrl: "https://localhost", enableDevAuth: false, }; diff --git a/apps/host-cloudflare/src/config.test.ts b/apps/host-cloudflare/src/config.test.ts index 82b81094e9..ddf00e15f7 100644 --- a/apps/host-cloudflare/src/config.test.ts +++ b/apps/host-cloudflare/src/config.test.ts @@ -119,3 +119,40 @@ describe("jevGateway", () => { expect(config.jevGateway?.authToken).toBeUndefined(); }); }); + +describe("INTERNAL_MCP_HOSTS", () => { + const TOOLS = { fetch: async () => new Response("ok") }; + const devEnv = (overrides: Record): ConfigEnv => + Object.assign(makeEnv({ ENABLE_DEV_AUTH: "true" }), overrides); + + it("resolves a host to the service binding it names", () => { + const config = loadConfig(devEnv({ INTERNAL_MCP_HOSTS: "Tools.Internal=TOOLS", TOOLS })); + + expect(Object.keys(config.internalHosts)).toEqual(["tools.internal"]); + expect(config.internalHosts["tools.internal"]).toBe(TOOLS); + }); + + it("accepts a trailing dot on the host and stores it normalised", () => { + const config = loadConfig(devEnv({ INTERNAL_MCP_HOSTS: "Tools.Internal.=TOOLS", TOOLS })); + + expect(Object.keys(config.internalHosts)).toEqual(["tools.internal"]); + }); + + it("has no internal hosts when the variable is unset", () => { + expect(loadConfig(devEnv({ TOOLS })).internalHosts).toEqual({}); + }); + + it("fails loudly when a host names a binding that is absent", () => { + expect(() => loadConfig(devEnv({ INTERNAL_MCP_HOSTS: "tools.internal=TOOLS" }))).toThrowError( + "INTERNAL_MCP_HOSTS maps tools.internal to TOOLS, but no service binding named TOOLS is configured", + ); + }); + + it("refuses a host that does not end in .internal", () => { + expect(() => + loadConfig(devEnv({ INTERNAL_MCP_HOSTS: "api.example.com=TOOLS", TOOLS })), + ).toThrowError( + 'INTERNAL_MCP_HOSTS entry "api.example.com=TOOLS" must look like "tools.internal=TOOLS" (a host ending in .internal, then a service binding name)', + ); + }); +}); diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index dc554ed741..1e07874d0f 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -2,6 +2,12 @@ import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/w import type { JevGatewayConfig } from "@executor-js/execution"; import { isValidOrgSlug } from "@executor-js/api"; +import { + isInternalHostname, + normalizeHostname, + type HostedInternalFetcher, + type HostedInternalHosts, +} from "@executor-js/sdk/host-internal"; import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin"; let warnedNoCloudflareOrigin = false; @@ -61,6 +67,12 @@ export interface CloudflareEnv { /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */ readonly EXECUTOR_SECRET_KEY?: string; readonly ALLOW_LOCAL_NETWORK?: string; + /** + * Private MCP hosts served by service bindings: comma-separated `host=BINDING` + * pairs, e.g. `tools.internal=TOOLS`. Each BINDING names a service binding on + * this Worker (wrangler `services`). + */ + readonly INTERNAL_MCP_HOSTS?: string; /** * Cloudflare AI Gateway, for the Jev classifier (`custom-typesafe`). Every * model call goes through a gateway rather than a provider directly, so the @@ -102,6 +114,8 @@ export interface CloudflareConfig { readonly organizationSlug: string; readonly secretKey: string; readonly allowLocalNetwork: boolean; + /** Internal MCP hosts mapped to their service bindings (see `INTERNAL_MCP_HOSTS`). */ + readonly internalHosts: HostedInternalHosts; /** 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; @@ -187,6 +201,40 @@ const resolveOrgSlug = (value: string | undefined): string => { return value; }; +const isInternalFetcher = (value: unknown): value is HostedInternalFetcher => + typeof value === "object" && + value !== null && + "fetch" in value && + typeof value.fetch === "function"; + +// A host naming an absent binding must stop the Worker (503 at the door) rather +// than leave an integration that silently cannot be reached. +const resolveInternalHosts = (env: CloudflareConfigEnv): HostedInternalHosts => { + const hosts: Record = {}; + for (const pair of (env.INTERNAL_MCP_HOSTS ?? "").split(",")) { + const entry = pair.trim(); + if (entry.length === 0) continue; + const [rawHost, rawBinding, ...rest] = entry.split("=").map((part) => part.trim()); + const host = normalizeHostname(rawHost ?? ""); + const binding = rawBinding ?? ""; + if (rest.length > 0 || !isInternalHostname(host) || binding.length === 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a malformed internal host mapping must fail the boot, not route silently + throw new Error( + `INTERNAL_MCP_HOSTS entry ${JSON.stringify(entry)} must look like "tools.internal=TOOLS" (a host ending in .internal, then a service binding name)`, + ); + } + const fetcher: unknown = Reflect.get(env, binding); + if (!isInternalFetcher(fetcher)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: an internal host whose service binding is absent must fail loudly instead of leaving an unreachable integration + throw new Error( + `INTERNAL_MCP_HOSTS maps ${host} to ${binding}, but no service binding named ${binding} is configured`, + ); + } + hosts[host] = fetcher; + } + return hosts; +}; + export interface LoadConfigOptions { /** Build the trusted config for the service-binding entrypoint (see * {@link CloudflareConfig.trustedInternal}). */ @@ -234,6 +282,7 @@ export const loadConfig = ( organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG), secretKey, allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", + internalHosts: resolveInternalHosts(env), // 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 diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts index 6e2174b7d8..44de35104b 100644 --- a/apps/host-cloudflare/src/execution.ts +++ b/apps/host-cloudflare/src/execution.ts @@ -54,6 +54,7 @@ export const makeCloudflarePluginsProvider = ( export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer => Layer.succeed(HostConfig)({ allowLocalNetwork: config.allowLocalNetwork, + internalHosts: config.internalHosts, webBaseUrl: config.webBaseUrl, oauthCallbackPath: "/api/oauth/callback", // Each MCP session's executor lives in a Durable Object with a small memory diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 30798ca2f2..1cbcd306f1 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -20,6 +20,10 @@ import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsof // --------------------------------------------------------------------------- const dir = fileURLToPath(new URL(".", import.meta.url)); +// wrangler loads `.dev.vars` next to the config only when `envFiles` is empty; a non-empty list +// that names no existing file makes the worker see exactly the `vars` each test passes, never a +// developer's local `.dev.vars` (which enables dev auth). +const HERMETIC_ENV_FILES = [".e2e-no-env-file"]; const runId = randomUUID().slice(0, 8); const ensureStaticAssets = () => { @@ -99,6 +103,7 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { ip: "127.0.0.1", local: true, persist: false, + envFiles: HERMETIC_ENV_FILES, experimental: { disableExperimentalWarning: true }, vars: { EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", @@ -627,6 +632,7 @@ describe("cloudflare host configuration errors", () => { ip: "127.0.0.1", local: true, persist: false, + envFiles: HERMETIC_ENV_FILES, experimental: { disableExperimentalWarning: true }, vars: { EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",