Skip to content

Commit f92c2f8

Browse files
authored
fix(devframe): authenticate HTTP MCP requests (#327)
1 parent 51827b7 commit f92c2f8

30 files changed

Lines changed: 595 additions & 95 deletions

File tree

docs/content/1.guide/14.security.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ For your own auth UI, disable built-in handling with `otpParam: false`, then cal
7575

7676
- **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do.
7777
- **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.
78-
- **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).
78+
- **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).
7979
- **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output.
8080
- **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them.
8181
- **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.

docs/content/1.guide/18.hub-initiate.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ Registrations are validated fail-fast: one module per type (`DF8108`), an existi
8282

8383
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.
8484

85+
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.
86+
8587
## Singular vs hub mounting
8688

8789
A devframe's SPA and RPC client are byte-identical in both cases; only the environment differs:

docs/content/2.adapters/7.mcp.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ await createMcpServer(myDevframe, { transport: 'stdio' })
1818

1919
## Route-based server
2020

21-
The dev server exposes the same MCP API over HTTP, live. Enable with `cli.mcp`:
21+
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):
2222

2323
```ts
2424
import { defineDevframe } from 'devframe'
@@ -33,7 +33,27 @@ export default defineDevframe({
3333

3434
The endpoint speaks Streamable-HTTP at `/__mcp` (`/__<id>/__mcp` under a host framework), sharing its origin/port. `--mcp` / `--no-mcp` override; `__connection.json` advertises it.
3535

36-
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'] } }`.
36+
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.
37+
38+
### Origin gate, and opt-in identity
39+
40+
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.
41+
42+
`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`:
43+
44+
```ts
45+
export default defineDevframe({
46+
cli: {
47+
mcp: { authorization: process.env.MY_TOKEN },
48+
},
49+
})
50+
```
51+
52+
`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.
53+
54+
A request presents the bearer as `Authorization: Bearer <token>`, 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'] }`.
55+
56+
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.
3757

3858
### Hosted bridges
3959

@@ -47,6 +67,8 @@ devframeViteBridge(myDevframe, { mcp: true })
4767
createDevframeNextHandler(myDevframe, { mcp: true })
4868
```
4969

70+
Both honor the same contract: `mcp: true` is origin-only; add `mcp: { authorization }` to harden.
71+
5072
## Custom host frameworks
5173

5274
`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, {
5880
serverName: 'my-tool (devframe)',
5981
serverVersion: '1.0.0',
6082
exposeSharedState: true,
83+
// Optional identity check on top of the origin gate; omit for origin-only.
84+
// authorization: process.env.MY_TOKEN,
6185
})
6286
// route every method on /__mcp to mcp.fetch(request)
6387
```
@@ -81,4 +105,6 @@ Two gateway tools (`devframe:connect:*` ids; see [tool ids and wire names](/guid
81105

82106
Discovery reads the **instance registry**: every `createDevServer` writes `~/.devframe/instances/<pid>-<port>.json`, dialed with a loopback origin. In-process host frameworks register via `registerDevframeInstance` (`devframe/node`). `--port <n>` probes a port; `DEVFRAME_INSTANCES_DIR` relocates the registry, `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts out.
83107

108+
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.
109+
84110
See [Agent-Native](/guide/agent-native) for the API and safety model.

docs/content/3.frameworks/1.vite.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Devframe spawns a separate RPC + WS server and registers Vite middleware at `<ba
4242
| `host` | `def.cli?.host ?? 'localhost'` | Bind host for a pinned side-car. |
4343
| `flags` | none | To `def.setup(ctx, { flags })`. |
4444
| `auth` | gated (interactive OTP) | `false` to opt out, or a `DevframeAuthHandler` for a custom scheme. |
45-
| `mcp` | `def.cli?.mcp` | `true` or `McpRouteOptions` to expose the MCP route at `<base>__mcp`. |
45+
| `mcp` | `def.cli?.mcp` | Expose the MCP route at `<base>__mcp`. `true` is origin-only (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. |
4646

4747
## `devframeVite`: convenience wrapper
4848

docs/content/3.frameworks/3.next.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const GET = handler.fetch
4848
| `port` | from `def.cli?.port` | Side-car port. |
4949
| `flags` | none | Passed to `def.setup(ctx, { flags })`. |
5050
| `auth` | `false` | `true` for the OTP gate, or a handler. |
51+
| `mcp` | `def.cli?.mcp` | Expose the MCP route. `true` is origin-only (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. |
5152
| `key` | `@devframes/next:<id>:<base>` | `globalThis` memoization key. |
5253

5354
## Hosting a hub
@@ -124,6 +125,8 @@ export const POST = (req: Request) => hub.handler(req)
124125
export const DELETE = (req: Request) => hub.handler(req)
125126
```
126127

128+
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.
129+
127130
No native hub UI provider here, so this scope stays quiet; `createDevframeNextHost()` is the low-level `DevframeHost`.
128131

129132
## See also

docs/content/6.errors/DF8005.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
title: 'DF8005: Devframe MCP Ignored While Hub MCP Is Off'
3+
description: 'Devframe "{id}" requests an MCP route, but the hub''s aggregate MCP is off, so its tools are not exposed over MCP.'
4+
---
5+
6+
## Message
7+
8+
> Devframe "`{id}`" requests an MCP route, but the hub's aggregate MCP is off, so its tools are not exposed over MCP.
9+
10+
## Cause
11+
12+
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.
13+
14+
## Example
15+
16+
The hub below has no `mcp`, so no aggregate route is mounted, but a mounted devframe declares `cli.mcp: true`:
17+
18+
```ts
19+
initHub({
20+
base: DEVFRAMES_HUB_BASE,
21+
devframes: [myDevframe], // myDevframe sets `cli.mcp: true`, so DF8005
22+
})
23+
```
24+
25+
## Fix
26+
27+
- 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).
28+
- Or drop `mcp` from the mounted devframe to silence the warning; it has no effect inside a hub.
29+
30+
## Source
31+
32+
- [`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.

docs/content/6.errors/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Emitted by `@devframes/hub` while assembling and mounting the unified surface.
9292
| [DF8002](/errors/DF8002) | error | Both devframes and context Passed to initHub |
9393
| [DF8003](/errors/DF8003) | error | connectionMeta() Before Hub Instance Ready |
9494
| [DF8004](/errors/DF8004) | error | Devframe Id Is Not a Mountable URL Segment |
95+
| [DF8005](/errors/DF8005) | warning | Devframe MCP Ignored While Hub MCP Is Off |
9596

9697
## Hub: docks & mounting (DF81xx)
9798

examples/files-inspector/src/devframe.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ export default defineDevframe({
2626
*/
2727
auth: false,
2828
/**
29-
* Serve the agent surface over the dev server's `/__mcp` route and
30-
* register the instance for `devframe connect` discovery.
29+
* Serve the agent surface at `/__mcp` and register for `devframe connect`
30+
* discovery. This loopback demo trusts same-machine callers (`mcp: true`);
31+
* a network-reachable tool would harden it with `mcp: { authorization }`.
3132
*/
3233
mcp: true,
3334
},

examples/hub-next/src/client/devframe/next-devframe-hub.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,10 @@ export async function nextDevframeHub(
212212
origin,
213213
host: hostName,
214214
/**
215-
* Gate access with devframe's interactive OTP (the default): the hub
216-
* prints a 6-digit code + magic link on startup, and the client shell
217-
* (`app/page.tsx`) drives its own authorization view to exchange the code
218-
* for a bearer token. See `docs/content/1.guide/13.security.md`.
219-
* The aggregate MCP endpoint at `/__devframes/__mcp` - the hub's agent
220-
* surface (agent-flagged commands, plugin tools, `devframe:state:read`)
221-
* over the same catch-all route as the SPAs.
215+
* Aggregate MCP at `/__devframes/__mcp` (agent-flagged commands, plugin
216+
* tools, `devframe:state:read`). `mcp: true` uses the loopback origin gate,
217+
* trusting same-machine callers; harden with `mcp: { authorization }` when
218+
* the app is reachable beyond localhost.
222219
*/
223220
mcp: true,
224221
/**

packages/devframe/src/adapters/_shared.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ConnectionMeta } from '../types/context'
2-
import type { DevframeDefinition, DevframeDeploymentKind, McpRouteOptions } from '../types/devframe'
2+
import type { DevframeDefinition, DevframeDeploymentKind, McpAuthorization, McpRouteOptions } from '../types/devframe'
33
import { getPort } from 'get-port-please'
44
import { cleanDoubleSlashes, withLeadingSlash, withoutLeadingSlash, withTrailingSlash } from 'ufo'
55
import { DEVFRAME_MCP_ROUTE } from '../constants'
@@ -56,13 +56,41 @@ export async function resolveDevServerPort(
5656
}
5757

5858
/**
59-
* Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into
60-
* concrete options, or `undefined` when the MCP route is disabled.
59+
* A fully-resolved MCP route configuration: the concrete authorization policy
60+
* (never the `mcp: true` shorthand), plus the optional route path and origin
61+
* allow-list. Every route mount consumes this shape.
6162
*/
62-
function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteOptions | undefined {
63+
export interface ResolvedMcpConfig {
64+
/** Route segment, relative to the base. Default resolved by the caller. */
65+
path?: string
66+
/** Origin allow-list, or `false` to disable the origin gate. */
67+
allowedOrigins?: readonly string[] | false
68+
/** The resolved identity policy: a bearer token, callback, or `false`. */
69+
authorization: McpAuthorization
70+
}
71+
72+
/**
73+
* Normalize the `mcp` option (`boolean | McpRouteOptions`) into a
74+
* fully-resolved config, or `undefined` when the MCP route is disabled.
75+
*
76+
* An enabled route trusts same-machine callers by default: the authorization
77+
* resolves to origin-only (`false`) unless the object config opts into a
78+
* bearer/callback identity check. An empty-string bearer is treated as no
79+
* bearer (origin-only) rather than a usable credential.
80+
*/
81+
export function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): ResolvedMcpConfig | undefined {
6382
if (!mcp)
6483
return undefined
65-
return mcp === true ? {} : mcp
84+
if (mcp === true)
85+
return { authorization: false }
86+
const authorization = typeof mcp.authorization === 'string' && mcp.authorization.length === 0
87+
? false
88+
: mcp.authorization ?? false
89+
return {
90+
...(mcp.path !== undefined ? { path: mcp.path } : {}),
91+
...(mcp.allowedOrigins !== undefined ? { allowedOrigins: mcp.allowedOrigins } : {}),
92+
authorization,
93+
}
6694
}
6795

6896
/**

0 commit comments

Comments
 (0)