diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index 371c9737a..e8b42ea47 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -75,7 +75,7 @@ For your own auth UI, disable built-in handling with `otpParam: false`, then cal - **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do. - **Keep `auth: false` local.** The hosted bridges (`devframeViteBridge`, `@devframes/next`'s handler) gate their side-car by default; opt out with an explicit `auth: false` only when the host framework owns the trust boundary another way. -- **The MCP route requires an origin.** The route-based MCP server rejects requests without a loopback or allow-listed `Origin`, so an arbitrary local process can't reach it; see [MCP](/adapters/mcp). +- **The MCP route trusts same-machine callers, harden it when that's not your boundary.** The origin gate keeps browsers and remote hosts out (loopback-only, `Origin`-less rejected), so `mcp: true` is enough for a local dev tool. `Origin` proves nothing about *which* local process is calling, though, so when the route is reachable beyond loopback (a widened `allowedOrigins`, a hosted app) or exposes destructive tools, add an identity check with `mcp: { authorization }` (a bearer from an env var, or a callback). See [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output. - **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. - **Origin-lock remote docks.** When a hub embeds a remote-UI dock, keep `originLock` on (the default) so its session token is only honored on a connection whose `Origin` matches the dock's own. diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 461eee45a..6e69a564a 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -82,6 +82,8 @@ Registrations are validated fail-fast: one module per type (`DF8108`), an existi The hub's **single Auth** is one gate at the shared transport for every mounted devframe, built-ins, and the MCP route; one handshake (OTP, magic link, or pre-shared token) unlocks the namespace; `auth: false` disables it for localhost. +The aggregate MCP route has its own origin gate, independent of this RPC Auth: `mcp: true` trusts same-machine callers, and `mcp: { authorization }` adds an identity check when the hub is reachable beyond loopback. A mounted devframe's own `mcp` setting is ignored: the hub exposes one aggregate route over them all, and warns ([`DF8005`](/errors/DF8005)) when a devframe asks for MCP while the hub's is off. + ## Singular vs hub mounting A devframe's SPA and RPC client are byte-identical in both cases; only the environment differs: diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index 454d904de..9c068c673 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -18,7 +18,7 @@ await createMcpServer(myDevframe, { transport: 'stdio' }) ## Route-based server -The dev server exposes the same MCP API over HTTP, live. Enable with `cli.mcp`: +The dev server exposes the same MCP API over HTTP, live. Enable it with `cli.mcp` (or pass `mcp` to `createDevServer` / `initDevframe` / `initHub` when you host it programmatically): ```ts import { defineDevframe } from 'devframe' @@ -33,7 +33,27 @@ export default defineDevframe({ The endpoint speaks Streamable-HTTP at `/__mcp` (`/__/__mcp` under a host framework), sharing its origin/port. `--mcp` / `--no-mcp` override; `__connection.json` advertises it. -The endpoint is **stateless**: it serves the [2026-07-28 revision](https://modelcontextprotocol.io/specification/2026-07-28) per request through the SDK's `createMcpHandler`, building a fresh MCP server for each request, so every HTTP request stands alone, with no `Mcp-Session-Id` to correlate. 2025-era clients are still served through the SDK's stateless legacy path. An origin gate requires `Origin` be loopback (or allow-listed) and rejects `Origin`-less requests. Widen for a tunnel/LAN origin with `cli: { mcp: { allowedOrigins: ['https://tunnel.example.com'] } }`. +The endpoint is **stateless**: it serves the [2026-07-28 revision](https://modelcontextprotocol.io/specification/2026-07-28) per request through the SDK's `createMcpHandler`, building a fresh MCP server for each request, so every HTTP request stands alone, with no `Mcp-Session-Id` to correlate. 2025-era clients are still served through the SDK's stateless legacy path. + +### Origin gate, and opt-in identity + +The **origin gate** guards every request: `Origin` must be loopback (or allow-listed), and `Origin`-less requests are rejected (a disallowed origin gets `403`). This is DNS-rebinding hardening that keeps browsers and remote hosts out, and it trusts same-machine callers, so `mcp: true` is all a local dev tool needs. + +`Origin` proves nothing about *who* is calling, though: a native process on the same box can send any `Origin`. When a same-machine process isn't your trust boundary (a LAN/tunnel origin, a shared/CI host, a destructive tool surface), layer on an **identity check** with `authorization`: + +```ts +export default defineDevframe({ + cli: { + mcp: { authorization: process.env.MY_TOKEN }, + }, +}) +``` + +`authorization` takes a bearer token (backed by an env var, never a literal), a `(request) => boolean` callback that governs identity only and cannot relax the origin gate, or `false` for the explicit origin-only default. + +A request presents the bearer as `Authorization: Bearer `, matched in constant time; a missing or wrong bearer gets `401` with a `WWW-Authenticate: Bearer` challenge. The origin gate always runs first, so a disallowed origin is `403` regardless of the credential. Widen the origin allow-list for a tunnel/LAN reach with `mcp: { authorization: process.env.MY_TOKEN, allowedOrigins: ['https://tunnel.example.com'] }`. + +Never place the token in a URL, in `__connection.json`, in the instance registry, in logs, or on the command line; it belongs only in configuration and the `Authorization` header. ### Hosted bridges @@ -47,6 +67,8 @@ devframeViteBridge(myDevframe, { mcp: true }) createDevframeNextHandler(myDevframe, { mcp: true }) ``` +Both honor the same contract: `mcp: true` is origin-only; add `mcp: { authorization }` to harden. + ## Custom host frameworks `createMcpFetchHandler(ctx, options)` returns the endpoint as a `Request → Response` handler plus a `dispose()`; mount it on any fetch server. @@ -58,6 +80,8 @@ const mcp = createMcpFetchHandler(ctx, { serverName: 'my-tool (devframe)', serverVersion: '1.0.0', exposeSharedState: true, + // Optional identity check on top of the origin gate; omit for origin-only. + // authorization: process.env.MY_TOKEN, }) // route every method on /__mcp to mcp.fetch(request) ``` @@ -81,4 +105,6 @@ Two gateway tools (`devframe:connect:*` ids; see [tool ids and wire names](/guid Discovery reads the **instance registry**: every `createDevServer` writes `~/.devframe/instances/-.json`, dialed with a loopback origin. In-process host frameworks register via `registerDevframeInstance` (`devframe/node`). `--port ` probes a port; `DEVFRAME_INSTANCES_DIR` relocates the registry, `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts out. +Most instances trust same-machine callers, so the connector reaches them with no credential. For an instance you *hardened* with a bearer, the connector reads `DEVFRAME_MCP_AUTH_TOKEN` and presents it (never a CLI flag, since command-line arguments are visible to other processes). Connect to a fleet with distinct credentials by driving `startConnectServer` with a per-instance `authToken` resolver. + See [Agent-Native](/guide/agent-native) for the API and safety model. diff --git a/docs/content/3.frameworks/1.vite.md b/docs/content/3.frameworks/1.vite.md index f2f32801e..aba07f1ac 100644 --- a/docs/content/3.frameworks/1.vite.md +++ b/docs/content/3.frameworks/1.vite.md @@ -42,7 +42,7 @@ Devframe spawns a separate RPC + WS server and registers Vite middleware at `__mcp`. | +| `mcp` | `def.cli?.mcp` | Expose the MCP route at `__mcp`. `true` is origin-only (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. | ## `devframeVite`: convenience wrapper diff --git a/docs/content/3.frameworks/3.next.md b/docs/content/3.frameworks/3.next.md index 2bbad6eb4..d29af883e 100644 --- a/docs/content/3.frameworks/3.next.md +++ b/docs/content/3.frameworks/3.next.md @@ -48,6 +48,7 @@ export const GET = handler.fetch | `port` | from `def.cli?.port` | Side-car port. | | `flags` | none | Passed to `def.setup(ctx, { flags })`. | | `auth` | `false` | `true` for the OTP gate, or a handler. | +| `mcp` | `def.cli?.mcp` | Expose the MCP route. `true` is origin-only (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. | | `key` | `@devframes/next::` | `globalThis` memoization key. | ## Hosting a hub @@ -124,6 +125,8 @@ export const POST = (req: Request) => hub.handler(req) export const DELETE = (req: Request) => hub.handler(req) ``` +The aggregate MCP route is off by default. Opt in with `mcp: true` (origin-only, trusting same-machine callers), or `mcp: { authorization }` to add an identity check when the app is reachable beyond localhost. + No native hub UI provider here, so this scope stays quiet; `createDevframeNextHost()` is the low-level `DevframeHost`. ## See also diff --git a/docs/content/6.errors/DF8005.md b/docs/content/6.errors/DF8005.md new file mode 100644 index 000000000..8e7845a54 --- /dev/null +++ b/docs/content/6.errors/DF8005.md @@ -0,0 +1,32 @@ +--- +title: 'DF8005: Devframe MCP Ignored While Hub MCP Is Off' +description: 'Devframe "{id}" requests an MCP route, but the hub''s aggregate MCP is off, so its tools are not exposed over MCP.' +--- + +## Message + +> Devframe "`{id}`" requests an MCP route, but the hub's aggregate MCP is off, so its tools are not exposed over MCP. + +## Cause + +A hub exposes **one aggregate MCP endpoint** over every mounted devframe (tool ids are already namespaced per plugin), so a mounted devframe's own `mcp` setting is ignored: the hub's own `mcp` governs the route. This warning fires when a devframe is mounted with `cli.mcp` enabled while the hub itself has no `mcp` configured, so that devframe's tools are not reachable over MCP. + +## Example + +The hub below has no `mcp`, so no aggregate route is mounted, but a mounted devframe declares `cli.mcp: true`: + +```ts +initHub({ + base: DEVFRAMES_HUB_BASE, + devframes: [myDevframe], // myDevframe sets `cli.mcp: true`, so DF8005 +}) +``` + +## Fix + +- Enable the hub's own aggregate MCP so the devframe's tools are surfaced: pass `mcp` to `initHub` (`mcp: true` for the loopback origin gate, or `mcp: { authorization }` to add an identity check). +- Or drop `mcp` from the mounted devframe to silence the warning; it has no effect inside a hub. + +## Source + +- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts): `initHub()` emits this while mounting each devframe when the hub has no MCP but the devframe requests one. diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index c14717ae5..0f38e0570 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -92,6 +92,7 @@ Emitted by `@devframes/hub` while assembling and mounting the unified surface. | [DF8002](/errors/DF8002) | error | Both devframes and context Passed to initHub | | [DF8003](/errors/DF8003) | error | connectionMeta() Before Hub Instance Ready | | [DF8004](/errors/DF8004) | error | Devframe Id Is Not a Mountable URL Segment | +| [DF8005](/errors/DF8005) | warning | Devframe MCP Ignored While Hub MCP Is Off | ## Hub: docks & mounting (DF81xx) diff --git a/examples/files-inspector/src/devframe.ts b/examples/files-inspector/src/devframe.ts index 4b1e913c2..adf2bba5f 100644 --- a/examples/files-inspector/src/devframe.ts +++ b/examples/files-inspector/src/devframe.ts @@ -26,8 +26,9 @@ export default defineDevframe({ */ auth: false, /** - * Serve the agent surface over the dev server's `/__mcp` route and - * register the instance for `devframe connect` discovery. + * Serve the agent surface at `/__mcp` and register for `devframe connect` + * discovery. This loopback demo trusts same-machine callers (`mcp: true`); + * a network-reachable tool would harden it with `mcp: { authorization }`. */ mcp: true, }, diff --git a/examples/hub-next/src/client/devframe/next-devframe-hub.ts b/examples/hub-next/src/client/devframe/next-devframe-hub.ts index 140b36fc2..2ccf8c5e2 100644 --- a/examples/hub-next/src/client/devframe/next-devframe-hub.ts +++ b/examples/hub-next/src/client/devframe/next-devframe-hub.ts @@ -212,13 +212,10 @@ export async function nextDevframeHub( origin, host: hostName, /** - * Gate access with devframe's interactive OTP (the default): the hub - * prints a 6-digit code + magic link on startup, and the client shell - * (`app/page.tsx`) drives its own authorization view to exchange the code - * for a bearer token. See `docs/content/1.guide/13.security.md`. - * The aggregate MCP endpoint at `/__devframes/__mcp` - the hub's agent - * surface (agent-flagged commands, plugin tools, `devframe:state:read`) - * over the same catch-all route as the SPAs. + * Aggregate MCP at `/__devframes/__mcp` (agent-flagged commands, plugin + * tools, `devframe:state:read`). `mcp: true` uses the loopback origin gate, + * trusting same-machine callers; harden with `mcp: { authorization }` when + * the app is reachable beyond localhost. */ mcp: true, /** diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts index 134020b36..fc69289bf 100644 --- a/packages/devframe/src/adapters/_shared.ts +++ b/packages/devframe/src/adapters/_shared.ts @@ -1,5 +1,5 @@ import type { ConnectionMeta } from '../types/context' -import type { DevframeDefinition, DevframeDeploymentKind, McpRouteOptions } from '../types/devframe' +import type { DevframeDefinition, DevframeDeploymentKind, McpAuthorization, McpRouteOptions } from '../types/devframe' import { getPort } from 'get-port-please' import { cleanDoubleSlashes, withLeadingSlash, withoutLeadingSlash, withTrailingSlash } from 'ufo' import { DEVFRAME_MCP_ROUTE } from '../constants' @@ -56,13 +56,41 @@ export async function resolveDevServerPort( } /** - * Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into - * concrete options, or `undefined` when the MCP route is disabled. + * A fully-resolved MCP route configuration: the concrete authorization policy + * (never the `mcp: true` shorthand), plus the optional route path and origin + * allow-list. Every route mount consumes this shape. */ -function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteOptions | undefined { +export interface ResolvedMcpConfig { + /** Route segment, relative to the base. Default resolved by the caller. */ + path?: string + /** Origin allow-list, or `false` to disable the origin gate. */ + allowedOrigins?: readonly string[] | false + /** The resolved identity policy: a bearer token, callback, or `false`. */ + authorization: McpAuthorization +} + +/** + * Normalize the `mcp` option (`boolean | McpRouteOptions`) into a + * fully-resolved config, or `undefined` when the MCP route is disabled. + * + * An enabled route trusts same-machine callers by default: the authorization + * resolves to origin-only (`false`) unless the object config opts into a + * bearer/callback identity check. An empty-string bearer is treated as no + * bearer (origin-only) rather than a usable credential. + */ +export function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): ResolvedMcpConfig | undefined { if (!mcp) return undefined - return mcp === true ? {} : mcp + if (mcp === true) + return { authorization: false } + const authorization = typeof mcp.authorization === 'string' && mcp.authorization.length === 0 + ? false + : mcp.authorization ?? false + return { + ...(mcp.path !== undefined ? { path: mcp.path } : {}), + ...(mcp.allowedOrigins !== undefined ? { allowedOrigins: mcp.allowedOrigins } : {}), + authorization, + } } /** diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index c039d0349..7b7eeaa15 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -13,16 +13,16 @@ import { resolveStaticAssetsSource } from 'devframe/utils/remote-assets' import { mountStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' import { resolve } from 'pathe' -import { joinURL } from 'ufo' +import { joinURL, withoutLeadingSlash } from 'ufo' import { resolveClientAssets } from '../client-assets' -import { DEVFRAME_CONNECTION_META_FILENAME } from '../constants' +import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_MCP_ROUTE } from '../constants' import { createHostContext } from '../node/context' import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { importRuntimeModule } from '../node/import-runtime-module' import { createInstanceShell, resolveInstanceRegister } from '../node/instance-shell' -import { normalizeBasePath } from './_shared' -import { resolveDevServerPort, resolveMcpConnectionMeta } from './dev' +import { normalizeBasePath, resolveMcpConfig } from './_shared' +import { resolveDevServerPort } from './dev' export interface InitDevframeOptions { /** @@ -304,16 +304,17 @@ export function initDevframe( await context.services.ready() await def.setup(context, setupInfo) - // Route-based MCP server (opt-in). Mounted before the SPA static - // catch-all so the exact `__mcp` route wins, and advertised in - // `__connection.json`. The MCP SDK stays an optional peer; its code is - // only pulled in (dynamically) when the route is enabled. - const mcpOption = options.mcp ?? def.cli?.mcp - const mcpMeta = resolveMcpConnectionMeta(def, mcpOption) + // Route-based MCP server (opt-in), mounted before the SPA static + // catch-all so the exact `__mcp` route wins. The MCP SDK stays an + // optional peer, pulled in dynamically only when the route is enabled. + // The resolved config is origin-only unless it opts into a bearer/callback. + const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp) + let mcpMeta: ConnectionMeta['mcp'] let mcpDispose: (() => Promise) | undefined - if (mcpMeta) { - const mcpConfig = mcpOption === true || mcpOption === undefined ? {} : mcpOption as McpRouteOptions - const mcpPath = joinURL(base, mcpMeta.path) + if (mcpConfig) { + const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE) + mcpMeta = { path: mcpRoute } + const mcpPath = joinURL(base, mcpRoute) let mountMcpHttp: typeof import('./mcp/http').mountMcpHttp try { ;({ mountMcpHttp } = await importRuntimeModule('devframe/adapters/mcp')) @@ -326,6 +327,7 @@ export function initDevframe( serverName: `${def.id} (devframe)`, serverVersion: def.version ?? '0.0.0', exposeSharedState: true, + authorization: mcpConfig.authorization, allowedOrigins: mcpConfig.allowedOrigins, }) mcpDispose = mounted.dispose diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts index b6f506584..4a41495ce 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -1,9 +1,11 @@ import type { StartedServer } from '../../../node/instance-shell' -import type { DevframeDefinition } from '../../../types/devframe' +import type { DevframeDefinition, McpRouteOptions } from '../../../types/devframe' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' import { afterEach, describe, expect, it } from 'vitest' import { createDevServer } from '../../dev' +const TOKEN = 'a-high-entropy-test-bearer-token' + function defineTestDef(overrides?: Partial): DevframeDefinition { return { id: 'mcp-http-test', @@ -32,13 +34,13 @@ describe('mcp adapter (streamable http route)', () => { server = undefined }) - async function boot(def = defineTestDef()): Promise { + async function boot(mcp: boolean | McpRouteOptions = true, def = defineTestDef()): Promise { // `port: 0` gives each test a fresh ephemeral port. Sharing one default // port across tests lets undici's keep-alive pool (keyed per origin) hand // a later test a stale socket from an earlier torn-down server, failing // with a socket error, or hanging that server's `close()` until the // keep-alive timeout. - server = await createDevServer(def, { host: '127.0.0.1', port: 0, mcp: true }) + server = await createDevServer(def, { host: '127.0.0.1', port: 0, mcp }) return server } @@ -58,14 +60,15 @@ describe('mcp adapter (streamable http route)', () => { }) // A native MCP client must send a (loopback) Origin so the route's gate - // (which rejects Origin-less requests) accepts it. - function originTransport(started: StartedServer): StreamableHTTPClientTransport { + // (which rejects Origin-less requests) accepts it. `mcp: true` trusts + // same-machine callers, so no bearer is needed. + function originTransport(started: StartedServer, headers: Record = {}): StreamableHTTPClientTransport { return new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`), { - requestInit: { headers: { origin: started.origin } }, + requestInit: { headers: { origin: started.origin, ...headers } }, }) } - it('serves the modern era statelessly and lists agent tools', async () => { + it('trusts same-machine callers by default (origin only) and lists agent tools', async () => { const started = await boot() const transport = originTransport(started) // Negotiate the 2026-07-28 era via `server/discover`. @@ -105,16 +108,14 @@ describe('mcp adapter (streamable http route)', () => { expect(res.status).toBe(405) }) - it('rejects an Origin-less request', async () => { - const started = await boot() - // Unlike the WS transport, the MCP route does not allow Origin-less - // requests; a route-based endpoint would otherwise be reachable by any - // local process. - const res = await fetch(`${started.origin}/__mcp`, { + /** An `initialize` POST body for the origin/identity gate tests. */ + function initRequest(started: StartedServer, headers: Record): Promise { + return fetch(`${started.origin}/__mcp`, { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', + ...headers, }, body: JSON.stringify({ jsonrpc: '2.0', @@ -123,26 +124,97 @@ describe('mcp adapter (streamable http route)', () => { params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, }), }) + } + + it('rejects an Origin-less request with 403', async () => { + const started = await boot() + // Unlike the WS transport, the MCP route does not allow Origin-less + // requests; a route-based endpoint would otherwise be reachable by any + // local process. + const res = await initRequest(started, {}) await res.body?.cancel() expect(res.status).toBe(403) }) - it('rejects a disallowed cross-origin request', async () => { + it('rejects a disallowed cross-origin request with 403', async () => { const started = await boot() - const res = await fetch(`${started.origin}/__mcp`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept': 'application/json, text/event-stream', - 'origin': 'http://evil.example.com', - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, - }), - }) + const res = await initRequest(started, { origin: 'http://evil.example.com' }) + await res.body?.cancel() expect(res.status).toBe(403) }) + + describe('opt-in bearer (authorization: token)', () => { + it('accepts a request carrying the correct bearer', async () => { + const started = await boot({ authorization: TOKEN }) + const client = new Client({ name: 'test-client', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } }) + const transport = originTransport(started, { authorization: `Bearer ${TOKEN}` }) + try { + await client.connect(transport) + const tools = await client.listTools() + expect(tools.tools.map(t => t.name)).toContain('greet') + } + finally { + await client.close() + } + }) + + it('rejects an allowed-origin request with no bearer as 401 + WWW-Authenticate', async () => { + const started = await boot({ authorization: TOKEN }) + const res = await initRequest(started, { origin: started.origin }) + await res.body?.cancel() + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toBe('Bearer') + }) + + it('rejects the wrong bearer as 401', async () => { + const started = await boot({ authorization: TOKEN }) + const res = await initRequest(started, { origin: started.origin, authorization: 'Bearer not-the-token' }) + await res.body?.cancel() + expect(res.status).toBe(401) + }) + + it('rejects a malformed / multi-credential Authorization header as 401', async () => { + const started = await boot({ authorization: TOKEN }) + const res = await initRequest(started, { origin: started.origin, authorization: `Bearer ${TOKEN}, Bearer other` }) + await res.body?.cancel() + expect(res.status).toBe(401) + }) + + it('runs the origin gate before the bearer (disallowed origin still 403)', async () => { + const started = await boot({ authorization: TOKEN }) + const res = await initRequest(started, { origin: 'http://evil.example.com', authorization: `Bearer ${TOKEN}` }) + await res.body?.cancel() + expect(res.status).toBe(403) + }) + }) + + describe('opt-in callback (authorization: fn)', () => { + it('allows when the callback returns true', async () => { + const started = await boot({ authorization: req => req.headers.get('x-secret') === 'open-sesame' }) + const res = await initRequest(started, { 'origin': started.origin, 'x-secret': 'open-sesame' }) + expect(res.status).toBe(200) + await res.body?.cancel() + }) + + it('denies with 401 when the callback returns false', async () => { + const started = await boot({ authorization: req => req.headers.get('x-secret') === 'open-sesame' }) + const res = await initRequest(started, { 'origin': started.origin, 'x-secret': 'wrong' }) + await res.body?.cancel() + expect(res.status).toBe(401) + }) + + it('cannot relax the origin gate (disallowed origin still 403)', async () => { + const started = await boot({ authorization: () => true }) + const res = await initRequest(started, { origin: 'http://evil.example.com' }) + await res.body?.cancel() + expect(res.status).toBe(403) + }) + }) + + it('explicit authorization: false behaves like the origin-only default', async () => { + const started = await boot({ authorization: false }) + const res = await initRequest(started, { origin: started.origin }) + expect(res.status).toBe(200) + await res.body?.cancel() + }) }) diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index 36956cb1e..3924cdb4d 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -1,5 +1,6 @@ -import type { DevframeNodeContext } from 'devframe/types' +import type { DevframeNodeContext, McpAuthorization } from 'devframe/types' import { createMcpHandler } from '@modelcontextprotocol/server' +import { timingSafeEqual } from 'devframe/utils/crypto-token' import { isAllowedOrigin } from 'devframe/utils/origin' import { bridgeListChanged, buildMcpServerFromContext } from './build-server' @@ -10,6 +11,15 @@ export interface CreateMcpFetchHandlerOptions { serverVersion: string /** Expose shared-state keys as MCP resources; see `buildMcpServerFromContext`. */ exposeSharedState: boolean | ((key: string) => boolean) + /** + * Optional identity check, layered on top of the origin gate and checked + * **after** it: a bearer token string (matched in constant time against + * `Authorization: Bearer `), a `(request) => boolean` callback, or + * `false` (the default) for origin-only, trusting same-machine callers. A + * callback governs identity only and cannot relax the origin gate. See + * {@link McpAuthorization}. + */ + authorization?: McpAuthorization /** * Origin allow-list beyond the loopback default. `false` disables the * origin gate entirely. Default: loopback-only. @@ -22,6 +32,36 @@ export interface CreateMcpFetchHandlerOptions { allowedOrigins?: readonly string[] | false } +/** + * Parse exactly one `Authorization: Bearer ` credential, returning the + * token or `undefined` for a missing, malformed, empty, or multi-credential + * header. The token itself is never logged. `\S+` rejects the whitespace that + * a second credential (fetch merges duplicate headers as `a, b`) or an empty + * value would introduce. + */ +function parseBearerToken(header: string | null): string | undefined { + if (!header) + return undefined + const match = /^Bearer (\S+)$/i.exec(header.trim()) + return match ? match[1] : undefined +} + +/** + * Resolve the identity gate for one request. `false` is the origin-only + * opt-out; a callback delegates identity; a string requires a constant-time + * bearer match. Never reveals whether a supplied token was close to correct. + */ +async function isAuthorized(req: Request, authorization: McpAuthorization): Promise { + if (authorization === false) + return true + if (typeof authorization === 'function') + return await authorization(req) === true + const token = parseBearerToken(req.headers.get('authorization')) + if (token === undefined) + return false + return timingSafeEqual(token, authorization) +} + export interface McpFetchHandler { /** * WHATWG-`fetch` handler for the MCP endpoint. Hand every method @@ -49,14 +89,22 @@ export interface McpFetchHandler { * * The origin gate guards every request: loopback-default DNS-rebinding * protection that (unlike the WS upgrade's `isAllowedOrigin`) also rejects - * `Origin`-less requests, so a route-based endpoint isn't reachable by an - * arbitrary local process. + * `Origin`-less requests, so a route-based endpoint isn't reachable by a + * browser or a remote host (a disallowed origin gets `403`). It trusts + * same-machine callers by default. When that isn't your trust boundary, add + * an optional identity gate ({@link CreateMcpFetchHandlerOptions.authorization}), + * checked after the origin gate: a bearer/callback check that proves *who* is + * calling (a missing/invalid credential gets `401` with a + * `WWW-Authenticate: Bearer` challenge). */ export function createMcpFetchHandler( ctx: DevframeNodeContext, options: CreateMcpFetchHandlerOptions, ): McpFetchHandler { const allowedOrigins = options.allowedOrigins + // Origin-only by default: trust same-machine callers unless a bearer/callback + // identity check is configured. + const authorization = options.authorization ?? false const handler = createMcpHandler(() => buildMcpServerFromContext(ctx, { serverName: options.serverName, @@ -80,7 +128,14 @@ export function createMcpFetchHandler( // `Origin` that is loopback or on the configured allow-list. const origin = req.headers.get('origin') ?? undefined if (allowedOrigins !== false && (origin === undefined || !isAllowedOrigin(origin, allowedOrigins ?? []))) - return new Response('Forbidden: origin required', { status: 403 }) + return new Response('Forbidden', { status: 403 }) + + // Identity gate: a request that cleared the origin check still has to + // prove *who* it is. A generic 401 (with the `WWW-Authenticate` challenge) + // whether the bearer is absent, malformed, or wrong: no response reveals + // whether a supplied token was close to correct. + if (!await isAuthorized(req, authorization)) + return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } }) return handler.fetch(req) } diff --git a/packages/devframe/src/cli/connect.test.ts b/packages/devframe/src/cli/connect.test.ts new file mode 100644 index 000000000..9644247d0 --- /dev/null +++ b/packages/devframe/src/cli/connect.test.ts @@ -0,0 +1,126 @@ +import type { DevframeInstanceRecord } from '../node/instance-registry' +import type { StartedServer } from '../node/instance-shell' +import type { DevframeDefinition } from '../types/devframe' +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' +import { afterEach, describe, expect, it } from 'vitest' +import { createDevServer } from '../adapters/dev' +import { buildInstanceRequestHeaders, resolveAuthToken } from './connect' + +const TOKEN = 'a-high-entropy-connect-test-token' + +function makeRecord(overrides?: Partial): DevframeInstanceRecord { + return { + pid: 123, + port: 9999, + origin: 'http://localhost:9999', + basePath: '/', + id: 'demo', + rootDir: '/tmp/demo', + mcp: { path: '/__mcp' }, + startedAt: 0, + ...overrides, + } +} + +describe('resolveAuthToken', () => { + it('returns a shared string token for any record', () => { + expect(resolveAuthToken(TOKEN, makeRecord())).toBe(TOKEN) + }) + + it('delegates to a per-record resolver', () => { + const record = makeRecord({ port: 4242 }) + const resolved = resolveAuthToken(r => (r.port === 4242 ? 'match' : undefined), record) + expect(resolved).toBe('match') + }) + + it('is undefined when no policy is configured', () => { + expect(resolveAuthToken(undefined, makeRecord())).toBeUndefined() + }) +}) + +describe('buildInstanceRequestHeaders', () => { + it('always sends the instance origin so the origin gate accepts the native client', () => { + const headers = buildInstanceRequestHeaders('http://localhost:9999/__mcp', undefined) + expect(headers.origin).toBe('http://localhost:9999') + expect(headers.authorization).toBeUndefined() + }) + + it('adds the bearer as an Authorization header when a token is configured', () => { + const headers = buildInstanceRequestHeaders('http://localhost:9999/__mcp', TOKEN) + expect(headers.authorization).toBe(`Bearer ${TOKEN}`) + }) + + it('keeps the token out of the connection URL and the registry record', () => { + const url = 'http://localhost:9999/__mcp' + buildInstanceRequestHeaders(url, TOKEN) + // The token is never written back onto the URL… + expect(url).not.toContain(TOKEN) + // …nor does a registry record carry any credential field to leak. + expect(JSON.stringify(makeRecord())).not.toContain(TOKEN) + }) +}) + +describe('connector bearer against a live authenticated MCP route', () => { + let server: StartedServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + function defineDef(): DevframeDefinition { + return { + id: 'connect-test', + name: 'Connect Test', + version: '0.0.0', + packageName: '@devframe/connect-test', + homepage: 'https://example.com', + description: 'Fixture for the connect bearer test.', + setup(ctx) { + ctx.agent.registerTool({ + id: 'greet', + description: 'Say hello.', + safety: 'read', + handler: () => ({ greeting: 'hi' }), + }) + }, + } + } + + it('authenticates with the resolved bearer in the request headers', async () => { + server = await createDevServer(defineDef(), { host: '127.0.0.1', port: 0, auth: false, mcp: { authorization: TOKEN } }) + const url = `${server.origin}/__mcp` + const record = makeRecord({ origin: server.origin, port: server.port }) + + // Exactly what the connector does: resolve the token, build the headers, + // and dial the route through a StreamableHTTP transport. + const token = resolveAuthToken(TOKEN, record) + const client = new Client({ name: 'connect-test', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } }) + const transport = new StreamableHTTPClientTransport(new URL(url), { + requestInit: { headers: buildInstanceRequestHeaders(url, token) }, + }) + try { + await client.connect(transport) + const tools = await client.listTools() + expect(tools.tools.map(t => t.name)).toContain('greet') + } + finally { + await client.close() + } + }) + + it('is refused (never falls through unauthenticated) when no bearer is resolved', async () => { + server = await createDevServer(defineDef(), { host: '127.0.0.1', port: 0, auth: false, mcp: { authorization: TOKEN } }) + const url = `${server.origin}/__mcp` + const record = makeRecord({ origin: server.origin, port: server.port }) + + // No configured policy → no Authorization header → the route's identity + // gate rejects the request rather than serving it. + const headers = buildInstanceRequestHeaders(url, resolveAuthToken(undefined, record)) + expect(headers.authorization).toBeUndefined() + const client = new Client({ name: 'connect-test', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } }) + const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } }) + await expect(client.connect(transport)).rejects.toThrow() + await client.close().catch(() => {}) + }) +}) diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts index c4fa359fb..f79370299 100644 --- a/packages/devframe/src/cli/connect.ts +++ b/packages/devframe/src/cli/connect.ts @@ -18,6 +18,47 @@ export interface ConnectServerOptions { instancesDir?: string /** Probe timeout per instance, ms. Default 1000. */ timeoutMs?: number + /** + * The bearer credential the connector presents to each instance's + * authenticated MCP route, sent as `Authorization: Bearer `. + * + * - a **string**: one shared token for every instance; + * - a **resolver** `(record) => string | undefined`: a per-instance token, + * for connecting to a fleet with distinct credentials (return `undefined` + * to send none for that instance). + * + * The token is only ever placed in a request header: it never enters the + * instance registry records, the indexed results, connection URLs, or + * formatted errors. Left unset, no `Authorization` header is sent, so only an + * instance whose route opted out of identity (`authorization: false`) will + * answer. + */ + authToken?: string | ((record: DevframeInstanceRecord) => string | undefined) +} + +/** + * Resolve the per-record bearer from the {@link ConnectServerOptions.authToken} + * option. Exported for focused tests of the credential resolution. + */ +export function resolveAuthToken( + authToken: ConnectServerOptions['authToken'], + record: DevframeInstanceRecord, +): string | undefined { + return typeof authToken === 'function' ? authToken(record) : authToken +} + +/** + * Build the request headers the connector sends to one instance's MCP route: + * the instance's own (loopback) `origin` so the route's origin gate accepts + * this native client, plus `Authorization: Bearer ` when a bearer is + * configured. The bearer appears **only** here, never in the connection URL, + * the registry records, or the indexed results. Exported for focused tests. + */ +export function buildInstanceRequestHeaders(url: string, token: string | undefined): Record { + const headers: Record = { origin: new URL(url).origin } + if (token) + headers.authorization = `Bearer ${token}` + return headers } export interface ConnectServerHandle { @@ -164,7 +205,7 @@ async function index(sdk: ConnectSdk, options: ConnectServerOptions): Promise { - return withInstanceClient(sdk, url, async (client) => { +async function listInstanceTools(sdk: ConnectSdk, url: string, token: string | undefined): Promise<{ name: string, description?: string }[]> { + return withInstanceClient(sdk, url, token, async (client) => { const listed = await client.listTools() return listed.tools.map((tool: { name: string, description?: string }) => ({ name: tool.name, @@ -231,7 +272,7 @@ async function call( throw diagnostics.DF0051({ port: args.port }) const url = `${record.origin}${record.mcp.path}` - return withInstanceClient(sdk, url, async (client) => { + return withInstanceClient(sdk, url, resolveAuthToken(options.authToken, record), async (client) => { const result = await client.callTool({ name: args.tool!, arguments: args.args ?? {} }) return { instance: { id: record.id, port: record.port }, @@ -246,14 +287,16 @@ async function call( async function withInstanceClient( sdk: ConnectSdk, url: string, + token: string | undefined, fn: (client: InstanceType) => Promise, ): Promise { - // Send the instance's own (loopback) origin so the MCP route's origin gate, - // which rejects `Origin`-less requests, accepts this native client. - const origin = new URL(url).origin + // The instance's own (loopback) origin (so the route's origin gate, which + // rejects `Origin`-less requests, accepts this native client) plus the + // bearer (when configured), the `Authorization` header being the only place + // the credential ever appears. const transport = new sdk.StreamableHTTPClientTransport( new URL(url), - { requestInit: { headers: { origin } } }, + { requestInit: { headers: buildInstanceRequestHeaders(url, token) } }, ) // Negotiate the era with `server/discover`, falling back to the 2025 // `initialize` handshake for a 2025-only instance. Devframe's own route is diff --git a/packages/devframe/src/cli/main.ts b/packages/devframe/src/cli/main.ts index 8ebe6269e..95243c7a3 100644 --- a/packages/devframe/src/cli/main.ts +++ b/packages/devframe/src/cli/main.ts @@ -20,6 +20,12 @@ export async function runDevframeCli(argv: string[] = process.argv): Promise`, compared in constant time. Back it with + * an environment variable rather than a literal; + * - a **callback** `(request) => boolean | Promise`: a custom + * identity check (validate a signed header, an mTLS-derived claim, …). It + * only governs identity and cannot relax the origin gate; + * - `false`, the default: **origin-only**, trusting same-machine callers. + */ +export type McpAuthorization + = | string + | ((request: Request) => boolean | Promise) + | false + /** * Configuration for the route-based MCP server mounted alongside the dev * server (opt-in via {@link DevframeCliOptions.mcp}). The endpoint speaks @@ -97,6 +117,16 @@ export interface McpRouteOptions { * Default: `__mcp` (i.e. `/__mcp` standalone, `/__/__mcp` hosted). */ path?: string + /** + * Optional identity check, layered on top of the origin gate and checked + * **after** it. Defaults to origin-only (`false`): the route trusts + * same-machine callers, since the loopback origin gate already keeps + * arbitrary remote/browser callers out. Set a bearer token or a callback to + * harden the route when a same-machine process is not your trust boundary + * (LAN/tunnel origin, shared/CI host, destructive tools). See + * {@link McpAuthorization}. + */ + authorization?: McpAuthorization /** * Extra `Origin` header values to accept beyond the loopback default * (`localhost`/`127.0.0.1`/`::1` and any `Origin`-less native client). @@ -107,7 +137,9 @@ export interface McpRouteOptions { * This is the endpoint's DNS-rebinding protection: the shared * `isAllowedOrigin` gate the WS upgrade already uses, applied as external * middleware (the approach the MCP SDK now recommends over its own - * deprecated `allowedHosts`/`allowedOrigins` transport flags). + * deprecated `allowedHosts`/`allowedOrigins` transport flags). When you + * widen it past loopback, layer on {@link McpRouteOptions.authorization} to + * prove identity too. */ allowedOrigins?: readonly string[] | false } @@ -152,17 +184,20 @@ export interface DevframeCliOptions { */ auth?: boolean | DevframeAuthHandler /** - * Expose a route-based MCP server alongside the dev server, speaking the - * MCP Streamable-HTTP transport at `/__mcp` (relative to the base path). - * It surfaces the same `ctx.agent` tools + shared-state resources as the - * stdio `mcp` command, but against the live, running server. + * Expose a route-based MCP server alongside the standalone dev server, + * speaking the MCP Streamable-HTTP transport at `/__mcp` (relative to the + * base path). It surfaces the same `ctx.agent` tools + shared-state + * resources as the stdio `mcp` command, but against the live server. * * - `false` / omitted (default): no MCP route is mounted. - * - `true`: mount at the default `__mcp` route with the loopback-only - * origin gate. - * - {@link McpRouteOptions}: customise the route path / allowed origins. + * - `true`: mount at the default `__mcp` route with the loopback origin + * gate (trusting same-machine callers). + * - {@link McpRouteOptions}: customise the route path, origin allow-list, + * and opt into an {@link McpAuthorization} identity check. * - * The `--mcp` / `--no-mcp` CLI flags override this per run. + * The `--mcp` / `--no-mcp` CLI flags override this per run. Whether to expose + * MCP is a hosting decision, so programmatic hosts pass it to + * `initDevframe` / `initHub` / `createDevServer` instead. */ mcp?: boolean | McpRouteOptions /** diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 44e2d6f45..6a9829f24 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { DOCK_RENDERERS_STATE_KEY } from '../../constants' import { DEVFRAMES_HUB_BASE, initHub } from '../initiate' @@ -321,6 +321,34 @@ describe('initHub', () => { } }) + it('warns (DF8005) when a mounted devframe asks for MCP but the hub MCP is off', async () => { + const wsPort = await getPort({ port: 18235, host: '127.0.0.1' }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // The hub has no `mcp`, but `beta` declares `cli.mcp: true`, so the hub's + // single aggregate route governs MCP, so beta's request is a no-op and warns. + const hub = initHub({ + base: DEVFRAMES_HUB_BASE, + auth: false, + host: '127.0.0.1', + ws: { port: wsPort }, + devframes: [makeFrame('alpha'), { ...makeFrame('beta'), cli: { mcp: true } }], + }) + + try { + await hub.ready + expect(hub.connectionMeta().mcp).toBeUndefined() + const warned = warn.mock.calls.map((args: unknown[]) => String(args[0])).join('\n') + expect(warned).toMatch(/DF8005/) + expect(warned).toContain('beta') + // `alpha` didn't ask for MCP, so it isn't named. + expect(warned).not.toContain('"alpha"') + } + finally { + warn.mockRestore() + await hub.close() + } + }) + it('single hub Auth: one gate covers every frame on the shared socket', async () => { const wsPort = await getPort({ port: 18240, host: '127.0.0.1' }) const hub = initHub({ base: DEVFRAMES_HUB_BASE, host: '127.0.0.1', ws: { port: wsPort }, devframes: [makeFrame('alpha')] }) diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index 5ce61235c..dcaae3637 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -30,6 +30,10 @@ export const diagnostics = defineDiagnostics({ why: (p: { id: string }) => `Devframe id "${p.id}" is not a mountable URL segment, and the hub mounts each frame at \`/\`.`, fix: 'Ids become route segments, so they may only contain letters, digits, `_`, `-`, and `.`; `:` and `*` are route-pattern markers to the underlying router, and `/` would escape the segment. Set a route-safe `id` on the definition (e.g. `my_plugin` instead of `my:plugin`).', }, + DF8005: { + why: (p: { id: string }) => `Devframe "${p.id}" requests an MCP route, but the hub's aggregate MCP is off, so its tools are not exposed over MCP.`, + fix: 'A hub exposes one aggregate MCP endpoint over every mounted devframe, so per-devframe `mcp` settings are ignored. Enable the hub\'s own MCP (pass `mcp` to `initHub`) to surface this devframe\'s tools, or drop `mcp` from the devframe to silence this warning.', + }, DF8100: { why: (p: { id: string }) => `Dock with id "${p.id}" is already registered`, fix: 'Use the `force` parameter to overwrite an existing registration.', diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 4dd42e152..9d06d943f 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -12,7 +12,7 @@ import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import process from 'node:process' import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUTE } from 'devframe/constants' -import { createH3DevframeHost, createInstanceShell, importRuntimeModule, resolveInstanceRegister } from 'devframe/internal' +import { createH3DevframeHost, createInstanceShell, importRuntimeModule, resolveInstanceRegister, resolveMcpConfig } from 'devframe/internal' import { mountStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' import { resolve } from 'pathe' @@ -255,7 +255,12 @@ export interface InitHubOptions { /** * Expose the **aggregate** MCP endpoint at `__mcp`: one * Streamable-HTTP server over the shared context's whole tool registry - * (ids are already namespaced per plugin). Disabled by default. + * (ids are already namespaced per plugin). Disabled by default; `true` + * mounts it with the loopback origin gate (trusting same-machine callers), + * an object opts into an {@link McpRouteOptions.authorization} identity + * check. A mounted devframe's own `mcp` setting is ignored: the hub's + * aggregate route covers them all (`DF8005` warns when one asks for MCP + * while this is off). */ mcp?: boolean | McpRouteOptions /** @@ -463,6 +468,7 @@ async function mountDevframes( devframes: HubDevframeEntry[], base: string, frames: { id: string, base: string, title: string }[], + hubMcpEnabled: boolean, ): Promise<(() => Promise)[]> { const setups: (() => Promise)[] = [] for (const { devframe: def, dock } of devframes) { @@ -473,6 +479,12 @@ async function mountDevframes( // segment entirely. if (!/^[\w.-]+$/.test(def.id)) throw diagnostics.DF8004({ id: def.id }) + // A hub exposes one aggregate MCP route over every mounted devframe, so a + // devframe's own `mcp` request is only meaningful when the hub's own MCP is + // enabled. Warn when it isn't, rather than silently dropping the devframe's + // intended agent surface. + if (!hubMcpEnabled && def.cli?.mcp) + diagnostics.DF8005({ id: def.id }) const frameBase = withTrailingSlash(joinURL(base, def.id)) const run = await prepareDevframe(ctx, def, { base: frameBase, ...(dock ? { dock } : {}) }) if (run) @@ -554,7 +566,7 @@ export function initHub(options: InitHubOptions): HubInstance { // collection alongside every devframe's own declared services. for (const input of options.services ?? []) void ctx.services.install(input) - const setups = await mountDevframes(ctx, devframes, base, frames) + const setups = await mountDevframes(ctx, devframes, base, frames, !!options.mcp) // Construct every collected service once, then run the setups, so a // devframe's setup consumes services (its own or another devframe's) @@ -590,8 +602,10 @@ export function initHub(options: InitHubOptions): HubInstance { // Aggregate MCP: one Streamable-HTTP endpoint over the shared // context's whole registry (tool ids are namespaced per plugin, and the - // wire-name collision policy is `createMcpFetchHandler`'s own). - const mcpConfig = options.mcp === true ? {} : options.mcp + // wire-name collision policy is `createMcpFetchHandler`'s own). The + // resolved config trusts same-machine callers by default (origin-only); + // an object config opts into a bearer/callback identity check. + const mcpConfig = resolveMcpConfig(options.mcp) if (!mcpConfig) return { context: ctx } @@ -601,6 +615,7 @@ export function initHub(options: InitHubOptions): HubInstance { serverName: options.name ?? 'devframes-hub', serverVersion: options.version ?? '0.0.0', exposeSharedState: true, + authorization: mcpConfig.authorization, allowedOrigins: mcpConfig.allowedOrigins, }) return { context: ctx, mcp: { path: mcpRoute }, dispose: mounted.dispose } diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index 637b3e509..721e8f95a 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -1,4 +1,4 @@ -import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope } from 'devframe' +import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope, McpAuthorization } from 'devframe' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' import { importRuntimeModule } from 'devframe/internal' import { serveStaticHandler } from 'devframe/utils/serve-static' @@ -26,6 +26,15 @@ export interface CreateDevframeNextHostOptions { } export interface DevframeNextHostMcpOptions { + /** + * Optional identity check layered on the origin gate. Defaults to + * origin-only (`false`), trusting same-machine callers. A bearer token + * string (matched in constant time) or a `(request) => boolean` callback + * hardens the route when a same-machine process is not your trust boundary; + * see {@link McpAuthorization}. Back a bearer with an environment variable, + * never a literal. + */ + authorization?: McpAuthorization /** Name reported in the MCP handshake. Default: `'devframe (next)'`. */ serverName?: string /** Version reported in the MCP handshake. Default: `'0.0.0'`. */ @@ -35,7 +44,8 @@ export interface DevframeNextHostMcpOptions { /** * Origin allow-list beyond the loopback default. `false` disables the * origin gate entirely. Note the MCP route rejects `Origin`-less requests - * (see `createMcpFetchHandler`). + * (see `createMcpFetchHandler`). This hardens the request; `authorization` + * proves identity. */ allowedOrigins?: readonly string[] | false } @@ -169,6 +179,7 @@ export function createDevframeNextHost( serverName: mcpOptions.serverName ?? 'devframe (next)', serverVersion: mcpOptions.serverVersion ?? '0.0.0', exposeSharedState: mcpOptions.exposeSharedState ?? true, + authorization: mcpOptions.authorization, allowedOrigins: mcpOptions.allowedOrigins, }) const key = stripTrailingSlash(path) diff --git a/packages/next/src/hub.ts b/packages/next/src/hub.ts index 68f8518b1..b43b7f233 100644 --- a/packages/next/src/hub.ts +++ b/packages/next/src/hub.ts @@ -50,8 +50,9 @@ export interface NextDevframeHubOptions { /** The hub's single auth gate. Gates by default; `false` opts out. */ auth?: InitHubOptions['auth'] /** - * Expose the aggregate MCP endpoint at `__mcp`. Default: `true` - * (the Next hub's agent surface rides the same catch-all route). + * Expose the aggregate MCP endpoint at `__mcp`. Disabled by default; + * `true` mounts it with the loopback origin gate (trusting same-machine + * callers), or pass an object to opt into an `authorization` identity check. */ mcp?: InitHubOptions['mcp'] /** Public origin the Next app is reachable at. Default: derived from `PORT`. */ @@ -70,7 +71,8 @@ export interface NextDevframeHubOptions { * Build a devframes-hub for a Next.js App Router app: one `initHub()` call * mounting every devframe under `/` behind one web-standard * `handler`, with the RPC socket on a side-car (Next routes can't accept WS - * upgrades) and the aggregate MCP route on by default. The UI defaults to + * upgrades) and the aggregate MCP route opt-in (pass `mcp` to enable it). The + * UI defaults to * `@devframes/hub-ui`'s `createUi()`, loaded lazily via a bundler-ignored * dynamic `import()` so its asset lookups resolve at request time; pass `ui` * to swap it or `ui: false` for a headless hub. @@ -94,8 +96,10 @@ export async function createNextDevframeHub(options: NextDevframeHubOptions = {} auth: options.auth, /** Next route handlers can't accept WS upgrades, so always a side-car socket. */ ws: options.port != null ? { port: options.port } : { sidecar: true }, - /** The Next hub's agent surface rides the same catch-all route by default. */ - mcp: options.mcp ?? true, + // MCP is opt-in: `mcp: true` is origin-only (trusting same-machine + // callers), `mcp: { authorization }` adds an identity check. Undefined + // leaves the aggregate route unmounted. + ...(options.mcp !== undefined ? { mcp: options.mcp } : {}), ...(ui ? { ui } : {}), ...(options.renderers ? { renderers: options.renderers } : {}), ...(options.rpcDeclarations ? { rpcDeclarations: options.rpcDeclarations } : {}), diff --git a/plans/README.md b/plans/README.md index 7d0470e7a..6b0ec627b 100644 --- a/plans/README.md +++ b/plans/README.md @@ -7,7 +7,7 @@ Generated by the improve skill on 2026-09-01 at commit `2d978f84`. Execute in th | Plan | Title | Priority | Effort | Depends on | Status | |---|---|---|---|---|---| | 001 | Pin privileged GitHub Actions dependencies | P1 | S | - | TODO | -| 002 | Require authentication on route-based MCP | P1 | M | 001 | TODO | +| 002 | Require authentication on route-based MCP | P1 | M | 001 | DONE | | 003 | Enforce shared-state exposure policy on direct MCP reads | P1 | S | 002 | DONE | | 004 | Contain remote asset materialization | P1 | S | - | DONE | | 005 | Block Data Inspector prototype-chain writes | P1 | S | - | DONE | diff --git a/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts index b44dd0472..834560c33 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts @@ -16,6 +16,7 @@ export interface DevframeNextHost { }>; } export interface DevframeNextHostMcpOptions { + authorization?: McpAuthorization; serverName?: string; serverVersion?: string; exposeSharedState?: boolean | ((_: string) => boolean); diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts index a55bdd25c..41cfaa198 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts @@ -6,6 +6,7 @@ export interface CreateMcpFetchHandlerOptions { serverName: string; serverVersion: string; exposeSharedState: boolean | ((_: string) => boolean); + authorization?: McpAuthorization; allowedOrigins?: readonly string[] | false; } export interface CreateMcpServerOptions { diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 57c16eef3..1d52f01d9 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -390,6 +390,7 @@ export interface EventUnsubscribe { } export interface McpRouteOptions { path?: string; + authorization?: McpAuthorization; allowedOrigins?: readonly string[] | false; } export interface RemoteAssets { @@ -493,6 +494,7 @@ export type DevframeSnapshotRpcEntry = string | { }; export type DevframeSnapshotRpcInputs = readonly (readonly unknown[])[] | ((_: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise); export type DevframeStorageScope = 'workspace' | 'project' | 'global'; +export type McpAuthorization = string | ((_: Request) => boolean | Promise) | false; export type RemoteAssetsProvider = 'jsdelivr' | 'unpkg' | RemoteAssetsProviderCustom; export type RpcFunctionsHost = RpcFunctionsCollectorBase & { invokeLocal: >(_: T, ..._: Args) => Promise>>; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index ee600f14e..f5f0f6386 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -384,7 +384,9 @@ export { listLiveDevframeInstances } export { normalizeBasePath } export { registerDevframeInstance } export { resolveBasePath } +export { ResolvedMcpConfig } export { resolveInstanceRegister } +export { resolveMcpConfig } export { samePath } export { StartedServer } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js index 6dc2fe5e9..9b3a95de9 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js @@ -18,5 +18,6 @@ export { registerDevframeInstance } export { resolveBasePath } export { resolveClientAssets } export { resolveInstanceRegister } +export { resolveMcpConfig } export { samePath } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 99be36470..9ad670e3c 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -64,6 +64,7 @@ export { DevframeWsOptions } export { EventEmitter } export { EventsMap } export { EventUnsubscribe } +export { McpAuthorization } export { McpRouteOptions } export { RemoteAssets } export { RemoteAssetsErrorMessage }