diff --git a/dev-server-mcp/src/dev-server-service.js b/dev-server-mcp/src/dev-server-service.js index 62b1b9b..52c733b 100644 --- a/dev-server-mcp/src/dev-server-service.js +++ b/dev-server-mcp/src/dev-server-service.js @@ -68,6 +68,9 @@ function publicStatus(definition, instance) { signal: instance?.signal ?? null, cwd: instance?.clientCwd ?? definition.clientCwd, publicUrl: isUnresolved(resolvedUrl) ? null : (resolvedUrl ?? null), + // Where a person can open this, as opposed to publicUrl, which only another container can + // reach. Carries its own one-time token: see PreviewProxy for why the key is not enough. + previewUrl: instance?.previewUrl ?? null, // Operator-written, non-secret instructions for an agent creating a project from scratch. // The commands themselves stay operator-owned; the agent receives the contract it must meet. ...(definition.agentInstructions ? { agentInstructions: definition.agentInstructions } : {}) @@ -88,7 +91,8 @@ export class DevServerService { constructor({ services, logLimit = DEFAULT_LOG_LIMIT, workspaceRoot = null, instancesRoot = null, portAllocator = null, maxInstances = 4, npmRegistry = null, npmCache = null, workspaceLimits = null, instanceHome = "/tmp/dev-server", - childPath = "/usr/local/bin:/usr/bin:/bin", proxy = null, javaHome = null }) { + childPath = "/usr/local/bin:/usr/bin:/bin", proxy = null, javaHome = null, + previewProxy = null, previewBaseUrl = null }) { this.services = services; this.instances = new Map(); this.logLimit = logLimit; @@ -103,6 +107,10 @@ export class DevServerService { this.childPath = childPath; this.proxy = proxy; this.javaHome = javaHome; + // Distinct from `proxy` above, which is the egress proxy an install fetches through. This one + // is the door a person walks through to look at what the execution built. + this.previewProxy = previewProxy; + this.previewBaseUrl = previewBaseUrl; } definition(service) { @@ -185,6 +193,13 @@ export class DevServerService { await this.spawnChild(definition, instance); await this.awaitHealth(definition, instance, handle); instance.status = "running"; + // Issued after the service is actually answering, and only for a per-execution one: a + // shared service has no key to scope a preview to, and a link handed out before the health + // check would point at something that may never come up. + if (this.previewProxy && this.previewBaseUrl && definition.workspaceMode === "execution") { + const token = this.previewProxy.issueToken(slot); + instance.previewUrl = `${this.previewBaseUrl}/preview/${slot}/?t=${token}`; + } return publicStatus(definition, instance); } catch (error) { instance.status = "stopped"; @@ -313,6 +328,10 @@ export class DevServerService { await Promise.race([forcedExit, new Promise((resolve) => setTimeout(resolve, 2000))]); } this.portAllocator?.releaseAllOf(handle); + // The next start issues a new token; this one must stop opening anything the moment the + // process behind it is gone, or a link from an earlier run would outlive what it pointed at. + if (instance.key) this.previewProxy?.forget(instance.key); + instance.previewUrl = null; return publicStatus(definition, instance); } diff --git a/dev-server-mcp/src/preview-proxy.js b/dev-server-mcp/src/preview-proxy.js new file mode 100644 index 0000000..eeef250 --- /dev/null +++ b/dev-server-mcp/src/preview-proxy.js @@ -0,0 +1,148 @@ +import http from "node:http"; +import { randomBytes, timingSafeEqual } from "node:crypto"; + +const KEY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const COOKIE_NAME = "dev_server_preview"; + +/** + * Serves one execution's running preview to a person's browser, over the gateway. + * + *

Everything else in this stack is reached by a machine holding an API key. This is the one + * door a human walks through, which changes what authorisation can look like: a browser will not + * attach a bearer token, and the execution key alone is no secret - rootExecutionId appears in the + * flow's events, in its logs and on screen in the editor. So a preview carries a token of its own, + * handed out once when the service starts, exchanged on first visit for a cookie scoped to that + * one preview's path, and never written into the page's own links. + * + *

It lives in the worker because the worker is the only process that knows which port belongs + * to which execution. The gateway in front of it is a plain reverse proxy with no such map. + */ +export class PreviewProxy { + /** + * @param resolve (key) => ({ port, touch }) for a running instance, or null. `touch` is called + * on every proxied request, so a preview a person is still reading does not + * expire underneath them - the whole reason a human step needs its own clock. + */ + constructor({ resolve, host = "127.0.0.1", port = 4500, tokenBytes = 32, mountPath = "/preview" }) { + this.resolve = resolve; + // Where the gateway publishes this proxy. It has to be told, because the gateway strips the + // prefix before forwarding: what arrives is //..., so nothing in the request says where + // the browser actually is. Both the redirect and the cookie's Path are built from this - they + // were written separately once, one of them assumed the prefix and the other did not, and the + // link simply led out of the mount into a 404. + this.mountPath = mountPath.replace(/\/+$/, ""); + this.host = host; + this.port = port; + this.tokenBytes = tokenBytes; + this.tokens = new Map(); + this.server = null; + } + + /** A fresh token for one execution's preview, replacing any the previous start handed out. */ + issueToken(key) { + const token = randomBytes(this.tokenBytes).toString("base64url"); + this.tokens.set(key, token); + return token; + } + + forget(key) { + this.tokens.delete(key); + } + + /** + * Constant-time, and only ever against the token of the key being asked for: comparing against + * every known token would let a caller learn about previews that are not theirs from timing. + */ + tokenMatches(key, candidate) { + const expected = this.tokens.get(key); + if (typeof expected !== "string" || typeof candidate !== "string") return false; + const a = Buffer.from(expected); + const b = Buffer.from(candidate); + return a.length === b.length && timingSafeEqual(a, b); + } + + cookieFrom(request, key) { + const header = request.headers.cookie; + if (typeof header !== "string") return null; + for (const part of header.split(";")) { + const separator = part.indexOf("="); + if (separator < 0) continue; + if (part.slice(0, separator).trim() !== `${COOKIE_NAME}_${key}`) continue; + return part.slice(separator + 1).trim(); + } + return null; + } + + async handle(request, response) { + const url = new URL(request.url ?? "/", "http://preview.invalid"); + const segments = url.pathname.split("/").filter(Boolean); + const key = segments[0]; + if (!key || !KEY_SEGMENT.test(key)) return refuse(response, 404, "No preview at this address"); + + const instance = this.resolve(key); + if (!instance) return refuse(response, 404, "No preview is running for this execution"); + + // The token arrives once, in a link a person was given. Accepting it sets a cookie and sends + // the browser back to the same page without it, so the token stops travelling in the address + // bar - and stops leaking through Referer to whatever the previewed page links to. + const supplied = url.searchParams.get("t"); + if (supplied !== null) { + if (!this.tokenMatches(key, supplied)) return refuse(response, 403, "This preview link is not valid"); + url.searchParams.delete("t"); + const target = `${this.mountPath}${url.pathname}${url.search}`; + response.writeHead(302, { + location: target, + "set-cookie": `${COOKIE_NAME}_${key}=${this.tokens.get(key)}; Path=${this.mountPath}/${key}/; HttpOnly; Secure; SameSite=Lax`, + "cache-control": "no-store" + }); + response.end(); + return; + } + + if (!this.tokenMatches(key, this.cookieFrom(request, key))) { + return refuse(response, 403, "This preview needs the link it was opened with"); + } + + instance.touch?.(); + this.forward(request, response, instance.port, url, segments); + } + + forward(request, response, port, url, segments) { + // The gateway strips /preview, and the key is ours: what the application behind sees is the + // path it would see if it were served at the root, which is the only way an app that was not + // written for a prefix can work at all. + const rest = "/" + segments.slice(1).join("/") + (url.pathname.endsWith("/") && segments.length > 1 ? "/" : ""); + const headers = { ...request.headers }; + delete headers.cookie; + delete headers.host; + + const upstream = http.request( + { host: "127.0.0.1", port, method: request.method, path: `${rest}${url.search}`, headers }, + (answer) => { + response.writeHead(answer.statusCode ?? 502, answer.headers); + answer.pipe(response); + } + ); + upstream.on("error", () => refuse(response, 502, "The preview stopped answering")); + request.pipe(upstream); + } + + listen() { + this.server = http.createServer((request, response) => { + response.on("error", () => {}); + this.handle(request, response).catch(() => refuse(response, 500, "Preview error")); + }); + this.server.headersTimeout = 10_000; + return new Promise((resolve) => this.server.listen(this.port, this.host, () => resolve(this.server))); + } + + close() { + return new Promise((resolve) => (this.server ? this.server.close(resolve) : resolve())); + } +} + +function refuse(response, status, message) { + if (response.headersSent) return response.end(); + response.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); + response.end(`${message}\n`); +} diff --git a/dev-server-mcp/src/worker-index.js b/dev-server-mcp/src/worker-index.js index 9fcac9d..8547166 100644 --- a/dev-server-mcp/src/worker-index.js +++ b/dev-server-mcp/src/worker-index.js @@ -5,6 +5,7 @@ import fs from "node:fs"; import { loadDevServerConfig } from "./config.js"; import { DevServerService } from "./dev-server-service.js"; import { PortAllocator, parsePortRange } from "./ports.js"; +import { PreviewProxy } from "./preview-proxy.js"; function csv(value) { return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean); } function integer(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } @@ -26,6 +27,17 @@ const config = loadDevServerConfig({ // container gives us: a node_modules carries native .node modules that will not load from a // noexec mount, and a 333 MB dependency tree in RAM eats a quarter of the worker's memory limit. const instancesRoot = directory(process.env.DEV_SERVER_INSTANCES_ROOT); +// The door a person opens. Only stood up when a public base URL is configured: without one there +// is nowhere to send anybody, and an unreachable link is worse than no link at all. +const previewBaseUrl = (process.env.DEV_SERVER_PREVIEW_BASE_URL || "").replace(/\/+$/, "") || null; +const previewProxy = previewBaseUrl + ? new PreviewProxy({ + host: process.env.DEV_SERVER_PREVIEW_HOST ?? "0.0.0.0", + port: integer(process.env.DEV_SERVER_PREVIEW_PORT, 4500), + resolve: (key) => resolvePreview(key) + }) + : null; + const devServer = new DevServerService({ services: config.services, workspaceRoot: config.root, @@ -43,12 +55,29 @@ const devServer = new DevServerService({ proxy: process.env.DEV_SERVER_EGRESS_PROXY ? { url: process.env.DEV_SERVER_EGRESS_PROXY, noProxy: process.env.DEV_SERVER_NO_PROXY || null } : null, + previewProxy, + previewBaseUrl, workspaceLimits: { maxEntries: integer(process.env.DEV_SERVER_MAX_WORKSPACE_ENTRIES, 20000), maxBytes: integer(process.env.DEV_SERVER_MAX_WORKSPACE_BYTES, 512 * 1024 * 1024) } }); +/** + * Which port, if any, is serving this execution right now - and a way to say it is still wanted. + * + *

Looked up live rather than remembered by the proxy: an instance can die between one request + * and the next, and a proxy holding its own copy of the map would keep forwarding to a port that + * has since been handed to a different execution. + */ +function resolvePreview(key) { + for (const [handle, instance] of devServer.instances) { + if (instance.key !== key || !instance.child || instance.port === null) continue; + return { port: instance.port, touch: () => { instance.lastSeen = Date.now(); } }; + } + return null; +} + function authorized(request) { const supplied = /^Bearer\s+(.+)$/i.exec(request.headers.authorization ?? "")?.[1]?.trim(); if (!supplied) return false; @@ -98,7 +127,13 @@ server.listen(integer(process.env.DEV_SERVER_WORKER_PORT, 4000), process.env.DEV process.stderr.write("dev-server worker ready\n"); }); +if (previewProxy) { + await previewProxy.listen(); + process.stderr.write(`dev-server preview proxy on ${previewProxy.host}:${previewProxy.port}, published at ${previewBaseUrl}/preview/\n`); +} + async function shutdown() { + await previewProxy?.close().catch(() => {}); await devServer.close(); server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 10000).unref(); diff --git a/dev-server-mcp/test/execution-instances.test.js b/dev-server-mcp/test/execution-instances.test.js index dff8a9b..887e8c9 100644 --- a/dev-server-mcp/test/execution-instances.test.js +++ b/dev-server-mcp/test/execution-instances.test.js @@ -200,3 +200,38 @@ test("the default log tail fits inside the client's per-result budget", () => { assert.ok(4096 < 6000); } finally { fs.rmSync(item.base, { recursive: true, force: true }); } }); + +test("a running execution gets a preview link, and stopping it takes the link away", async () => { + // The link is what a person is eventually handed, so it may not outlive the process it points + // at: a stale one would open somebody else's preview once the port is handed out again. + const { PreviewProxy } = await import("../src/preview-proxy.js"); + const previewProxy = new PreviewProxy({ port: 0, resolve: () => null }); + const item = stack({ services: { web: WEB } }); + item.service.previewProxy = previewProxy; + item.service.previewBaseUrl = "https://mcp.example.it"; + try { + item.write("exec-alpha", { "app.txt": "A" }); + + const started = await item.service.start({ service: "web", key: "exec-alpha" }); + assert.match(started.previewUrl, /^https:\/\/mcp\.example\.it\/preview\/exec-alpha\/\?t=.+/); + assert.ok(previewProxy.tokenMatches("exec-alpha", new URL(started.previewUrl).searchParams.get("t"))); + + const stopped = await item.service.stop({ service: "web", key: "exec-alpha" }); + + assert.equal(stopped.previewUrl, null); + assert.equal(previewProxy.tokenMatches("exec-alpha", "anything"), false); + } finally { await item.cleanup(); } +}); + +test("a shared service gets no preview link, having no execution to scope one to", async () => { + const { PreviewProxy } = await import("../src/preview-proxy.js"); + const shared = { ...WEB, workspaceMode: "shared", args: [...SERVER, "5399"], + healthUrl: null, publicUrl: "http://dev-server-worker:5399" }; + const item = stack({ services: { web: shared } }); + item.service.previewProxy = new PreviewProxy({ port: 0, resolve: () => null }); + item.service.previewBaseUrl = "https://mcp.example.it"; + try { + const started = await item.service.start({ service: "web" }); + assert.equal(started.previewUrl, null); + } finally { await item.cleanup(); } +}); diff --git a/dev-server-mcp/test/preview-proxy.test.js b/dev-server-mcp/test/preview-proxy.test.js new file mode 100644 index 0000000..ed6cb34 --- /dev/null +++ b/dev-server-mcp/test/preview-proxy.test.js @@ -0,0 +1,183 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { PreviewProxy } from "../src/preview-proxy.js"; + +/** A stand-in for a previewed application: answers with the path it was actually asked for. */ +async function application(body = "the app") { + const server = http.createServer((request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.end(`${body} at ${request.url}`); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { server, port: server.address().port, close: () => new Promise((r) => server.close(r)) }; +} + +async function proxyFor(instances) { + const touched = []; + const proxy = new PreviewProxy({ + port: 0, + resolve: (key) => { + const found = instances[key]; + return found ? { port: found, touch: () => touched.push(key) } : null; + } + }); + await proxy.listen(); + return { proxy, touched, port: proxy.server.address().port }; +} + +function fetchNoRedirect(url, headers = {}) { + return fetch(url, { headers, redirect: "manual" }); +} + +test("a valid token is exchanged for a cookie, and the token leaves the address bar", async () => { + // The token travels in a link a person was given; leaving it in the URL would send it onward in + // Referer to whatever the previewed page links to. + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + const token = proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/?t=${token}`); + + assert.equal(answer.status, 302); + // The path the browser is at, not the one this proxy receives. The gateway strips /preview + // before forwarding, so redirecting to what arrived would send the browser out of the mount + // and into the gateway's own 404 - which is exactly what happened the first time. + assert.equal(answer.headers.get("location"), "/preview/exec-a/"); + const cookie = answer.headers.get("set-cookie"); + assert.match(cookie, /dev_server_preview_exec-a=/); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /Path=\/preview\/exec-a\//, "the cookie must not be usable on another preview"); + } finally { await proxy.close(); await app.close(); } +}); + +test("the cookie from one preview does not open another", async () => { + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port, "exec-b": app.port }); + try { + const tokenA = proxy.issueToken("exec-a"); + proxy.issueToken("exec-b"); + + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-b/`, { + cookie: `dev_server_preview_exec-a=${tokenA}` + }); + + assert.equal(answer.status, 403); + } finally { await proxy.close(); await app.close(); } +}); + +test("an execution key alone is not enough to open a preview", async () => { + // rootExecutionId is on screen in the editor and in every event the flow logs. If knowing it + // were sufficient, the preview would effectively be public. + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/`); + + assert.equal(answer.status, 403); + } finally { await proxy.close(); await app.close(); } +}); + +test("a wrong token is refused", async () => { + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/?t=not-the-token`); + + assert.equal(answer.status, 403); + } finally { await proxy.close(); await app.close(); } +}); + +test("a restart invalidates the link the previous start handed out", async () => { + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + const first = proxy.issueToken("exec-a"); + proxy.issueToken("exec-a"); + + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/?t=${first}`); + + assert.equal(answer.status, 403); + } finally { await proxy.close(); await app.close(); } +}); + +test("an authorised request reaches the application at the path it expects", async () => { + // The app was not written to live under a prefix, so what reaches it must be the path it would + // see at the root - the key and the /preview mount are ours, not its business. + const app = await application("inner"); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + const token = proxy.issueToken("exec-a"); + const answer = await fetch(`http://127.0.0.1:${port}/exec-a/orders?page=2`, { + headers: { cookie: `dev_server_preview_exec-a=${token}` } + }); + + assert.equal(answer.status, 200); + assert.equal(await answer.text(), "inner at /orders?page=2"); + } finally { await proxy.close(); await app.close(); } +}); + +test("reading the preview keeps it alive", async () => { + // A person takes hours where a model takes seconds. Without this the instance's own idle clock + // would reclaim a preview somebody is still looking at. + const app = await application(); + const { proxy, touched, port } = await proxyFor({ "exec-a": app.port }); + try { + const token = proxy.issueToken("exec-a"); + await fetch(`http://127.0.0.1:${port}/exec-a/`, { headers: { cookie: `dev_server_preview_exec-a=${token}` } }); + + assert.deepEqual(touched, ["exec-a"]); + } finally { await proxy.close(); await app.close(); } +}); + +test("an execution with nothing running says so, rather than failing obscurely", async () => { + const { proxy, port } = await proxyFor({}); + try { + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-missing/`); + + assert.equal(answer.status, 404); + assert.match(await answer.text(), /No preview is running/); + } finally { await proxy.close(); } +}); + +test("a key that is not one safe segment is refused before anything is looked up", async () => { + const { proxy, port } = await proxyFor({}); + try { + for (const path of ["/", "/..%2fetc/", "/with%20space/"]) { + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}${path}`); + assert.equal(answer.status, 404, `accepted ${path}`); + } + } finally { await proxy.close(); } +}); + +test("an application that has died answers as a gateway failure, not a hang", async () => { + const app = await application(); + const dead = app.port; + await app.close(); + const { proxy, port } = await proxyFor({ "exec-a": dead }); + try { + const token = proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/`, { + cookie: `dev_server_preview_exec-a=${token}` + }); + + assert.equal(answer.status, 502); + } finally { await proxy.close(); } +}); + +test("the mount the gateway publishes is one setting, used by both the redirect and the cookie", async () => { + // These were written separately once and disagreed: the cookie assumed /preview, the redirect + // did not, and the link led out of the mount. One parameter now, so they cannot drift again. + const app = await application(); + const proxy = new PreviewProxy({ port: 0, mountPath: "/elsewhere", resolve: () => ({ port: app.port }) }); + await proxy.listen(); + try { + const token = proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${proxy.server.address().port}/exec-a/?t=${token}`); + + assert.equal(answer.headers.get("location"), "/elsewhere/exec-a/"); + assert.match(answer.headers.get("set-cookie"), /Path=\/elsewhere\/exec-a\//); + } finally { await proxy.close(); await app.close(); } +}); diff --git a/mcp-stack.Caddyfile b/mcp-stack.Caddyfile index e1c38ad..4db62af 100644 --- a/mcp-stack.Caddyfile +++ b/mcp-stack.Caddyfile @@ -25,6 +25,18 @@ } } + # The one door a person opens, as opposed to the three above, which a machine opens with an API + # key. handle_path strips the prefix, so the worker's proxy sees //... and the + # application behind it sees the path it would see at the root. + # + # Same origin as the MCP endpoints above, which is a compromise worth naming: JavaScript in a + # previewed page can reach /dev-server/mcp. It gets 401 - those endpoints authenticate by + # bearer token, never by cookie, so there is no ambient authority for a page to borrow - and + # the preview's own cookie is scoped to /preview// and travels nowhere else. + handle_path /preview/* { + reverse_proxy dev-server-worker:4500 + } + handle { respond "No MCP server is published at this path" 404 } diff --git a/mcp-stack.compose.yml b/mcp-stack.compose.yml index 4dfcd57..45bbddb 100644 --- a/mcp-stack.compose.yml +++ b/mcp-stack.compose.yml @@ -92,6 +92,11 @@ services: DEV_SERVER_PORT_RANGE: ${DEV_SERVER_PORT_RANGE:-5200-5219} DEV_SERVER_MAX_INSTANCES: ${DEV_SERVER_MAX_INSTANCES:-4} DEV_SERVER_MAX_WORKSPACE_BYTES: ${DEV_SERVER_MAX_WORKSPACE_BYTES:-536870912} + # Where a person reaches a preview. Unset means no preview proxy at all - the dev loop works + # without one, since the browser reaches instances over the internal network; this is only + # for the flows where a human has to look at what was built. + DEV_SERVER_PREVIEW_BASE_URL: ${DEV_SERVER_PREVIEW_BASE_URL:-} + DEV_SERVER_PREVIEW_PORT: ${DEV_SERVER_PREVIEW_PORT:-4500} volumes: - type: bind source: ${MCP_WORKSPACE_HOST_PATH} diff --git a/mcp-stack.env.example b/mcp-stack.env.example index 04f1a45..a2145cb 100644 --- a/mcp-stack.env.example +++ b/mcp-stack.env.example @@ -48,3 +48,8 @@ MCP_GATEWAY_CADDYFILE=./mcp-stack.vm.Caddyfile MCP_SITE_ADDRESS=mcp-stack.isti.cnr.it MCP_TLS_CONTACT=lucio.lelii@isti.cnr.it MCP_BIND_ADDRESS=0.0.0.0 + +# The public address a person opens a preview at - the same host the gateway serves, since the +# preview rides the /preview/ route on it. Leave unset on a laptop stack: without it the dev loop +# still works (the browser reaches instances internally) and no preview links are handed out. +DEV_SERVER_PREVIEW_BASE_URL=https://mcp.sse.cloud.isti.cnr.it diff --git a/mcp-stack.vm.Caddyfile b/mcp-stack.vm.Caddyfile index c81332b..b5c943f 100644 --- a/mcp-stack.vm.Caddyfile +++ b/mcp-stack.vm.Caddyfile @@ -40,6 +40,18 @@ } } + # The one door a person opens, as opposed to the three above, which a machine opens with an API + # key. handle_path strips the prefix, so the worker's proxy sees //... and the + # application behind it sees the path it would see at the root. + # + # Same origin as the MCP endpoints above, which is a compromise worth naming: JavaScript in a + # previewed page can reach /dev-server/mcp. It gets 401 - those endpoints authenticate by + # bearer token, never by cookie, so there is no ambient authority for a page to borrow - and + # the preview's own cookie is scoped to /preview// and travels nowhere else. + handle_path /preview/* { + reverse_proxy dev-server-worker:4500 + } + handle { respond "No MCP server is published at this path" 404 }