Skip to content

Commit 7b7aba4

Browse files
committed
fix(devframe): authenticate HTTP MCP requests
The route-based MCP endpoint treated a caller-provided Origin as authorization, so any local process (or a native client spoofing an Origin) could invoke privileged agent tools. Origin is DNS-rebinding hardening, not identity. Add an independent identity gate to the MCP route, checked after the origin gate: - McpRouteOptions.authorization: a bearer token string (constant-time compared), a (request) => boolean callback, or false for an origin-only local opt-out. - mcp: true is shorthand for the bearer read from DEVFRAME_MCP_AUTH_TOKEN; a missing token or an object without authorization fails startup with new diagnostic DF0077 rather than mounting an unauthenticated route. - Missing/invalid bearer -> 401 + WWW-Authenticate: Bearer; disallowed origin stays 403. A callback governs identity only and cannot relax the origin gate. - @devframes/next/hub now defaults MCP to disabled; callers opt in with an explicit policy. - devframe connect reads DEVFRAME_MCP_AUTH_TOKEN and presents it as the bearer; ConnectServerOptions.authToken accepts one token or a per-instance resolver. Credentials live only in configuration and the Authorization header. Created with the help of an agent.
1 parent 1c9f789 commit 7b7aba4

35 files changed

Lines changed: 627 additions & 93 deletions

File tree

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

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

7474
- **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do.
7575
- **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.
76-
- **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).
76+
- **The MCP route authenticates the caller.** `Origin` hardens the route-based MCP server against DNS-rebinding, but proves nothing about identity — a native client can send any `Origin`. So the route also requires a bearer: `mcp: true` reads it from `DEVFRAME_MCP_AUTH_TOKEN`, and the route refuses to mount ([`DF0077`](/errors/DF0077)) without a policy. Treat the two checks as separate defenses — see [MCP](/adapters/mcp).
7777
- **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output.
7878
- **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them.
7979
- **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 carries its **own** identity gate independent of this RPC Auth, since it grants agent clients privileged tool access: `mcp: true` requires the `DEVFRAME_MCP_AUTH_TOKEN` bearer (startup fails with [`DF0077`](/errors/DF0077) without it), or pass `mcp: { authorization }` explicitly. `Origin` remains request hardening, not identity.
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: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,38 @@ import { defineDevframe } from 'devframe'
2626
export default defineDevframe({
2727
//
2828
cli: {
29+
// Reads the bearer from DEVFRAME_MCP_AUTH_TOKEN.
2930
mcp: true,
3031
},
3132
})
3233
```
3334

3435
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.
3536

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 — 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'] } }`.
37+
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 — 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.
38+
39+
### Two gates: origin hardening and identity
40+
41+
The route exposes privileged agent tools, so every request clears two independent gates. The **origin gate** requires `Origin` be loopback (or allow-listed) and rejects `Origin`-less requests — DNS-rebinding hardening that proves nothing about *who* is calling, since a native client can send any `Origin`. Widen it for a tunnel/LAN origin with `cli: { mcp: { authorization: process.env.MY_TOKEN, allowedOrigins: ['https://tunnel.example.com'] } }`.
42+
43+
The **identity gate** then proves the caller. `mcp: true` reads its bearer from the `DEVFRAME_MCP_AUTH_TOKEN` environment variable; a request presents it as `Authorization: Bearer <token>` and it is matched in constant time. A missing or wrong bearer gets `401` with a `WWW-Authenticate: Bearer` challenge; a disallowed origin gets `403`. Startup fails with [`DF0077`](/errors/DF0077) — the route is never mounted — when `mcp: true` finds no environment token, or an object config omits `authorization`.
44+
45+
An object config sets the policy explicitly:
46+
47+
```ts
48+
export default defineDevframe({
49+
cli: {
50+
// A bearer from your own environment variable:
51+
mcp: { authorization: process.env.MY_TOKEN },
52+
// — or a callback identity check (governs identity only; it cannot relax the origin gate):
53+
// mcp: { authorization: request => isTrusted(request) },
54+
// — or an origin-only opt-out for a loopback-bound local tool that owns its trust boundary another way:
55+
// mcp: { authorization: false },
56+
},
57+
})
58+
```
59+
60+
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.
3761

3862
### Hosted bridges
3963

@@ -47,6 +71,8 @@ devframeViteBridge(myDevframe, { mcp: true })
4771
createDevframeNextHandler(myDevframe, { mcp: true })
4872
```
4973

74+
Both honor the same authorization contract: `mcp: true` requires the `DEVFRAME_MCP_AUTH_TOKEN` bearer, or pass `mcp: { authorization }` explicitly.
75+
5076
## Custom host frameworks
5177

5278
`createMcpFetchHandler(ctx, options)` returns the endpoint as a `Request → Response` handler plus a `dispose()` — mount on any fetch server.
@@ -58,6 +84,8 @@ const mcp = createMcpFetchHandler(ctx, {
5884
serverName: 'my-tool (devframe)',
5985
serverVersion: '1.0.0',
6086
exposeSharedState: true,
87+
// Required: the identity policy — a bearer token, a callback, or `false`.
88+
authorization: process.env.DEVFRAME_MCP_AUTH_TOKEN!,
6189
})
6290
// route every method on /__mcp to mcp.fetch(request)
6391
```
@@ -81,4 +109,6 @@ Two gateway tools (`devframe:connect:*` ids — see [tool ids and wire names](/g
81109

82110
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.
83111

112+
The connector reads `DEVFRAME_MCP_AUTH_TOKEN` and presents it as the bearer to each instance's authenticated route (never a CLI flag — command-line arguments are visible to other processes). An instance whose route requires a different bearer reports auth-required rather than being reached; connect to a fleet with distinct credentials by driving `startConnectServer` with a per-instance `authToken` resolver.
113+
84114
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` || 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` requires the `DEVFRAME_MCP_AUTH_TOKEN` bearer; `McpRouteOptions` carries an explicit `authorization`. |
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` || 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` requires the `DEVFRAME_MCP_AUTH_TOKEN` bearer; `McpRouteOptions` carries an explicit `authorization`. |
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 — it exposes privileged agent tools. Opt in with an authorization policy: `mcp: true` (requiring the `DEVFRAME_MCP_AUTH_TOKEN` bearer) or `mcp: { authorization }`.
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/DF0077.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
title: 'DF0077: MCP Authorization Required'
3+
description: 'The route-based MCP server needs an authorization policy, but none is configured.'
4+
---
5+
6+
## Message
7+
8+
> The route-based MCP server needs an authorization policy, but none is configured — refusing to mount an unauthenticated agent endpoint.
9+
10+
## Cause
11+
12+
The route-based MCP endpoint exposes privileged agent tools to any process that can reach it. `Origin` hardens the request against DNS-rebinding but proves nothing about *who* is calling, so the route also requires an identity policy. This diagnostic fires when that policy is absent:
13+
14+
- `mcp: true` (the shorthand) reads its bearer from the `DEVFRAME_MCP_AUTH_TOKEN` environment variable, and the variable is missing or empty.
15+
- An object MCP config omits the required `authorization` field (or sets it to an empty string).
16+
17+
Startup fails and the route is never mounted, rather than exposing the endpoint unauthenticated.
18+
19+
## Example
20+
21+
```ts
22+
// ✗ throws DF0077 when DEVFRAME_MCP_AUTH_TOKEN is unset
23+
await createDevServer(def, { mcp: true })
24+
25+
// ✗ throws DF0077 — object config with no authorization
26+
await createDevServer(def, { mcp: { path: '__mcp' } })
27+
28+
// ✓ shorthand, with the environment token set
29+
process.env.DEVFRAME_MCP_AUTH_TOKEN = 'a-high-entropy-secret'
30+
await createDevServer(def, { mcp: true })
31+
32+
// ✓ explicit bearer token
33+
await createDevServer(def, { mcp: { authorization: process.env.MY_TOKEN! } })
34+
35+
// ✓ callback identity check
36+
await createDevServer(def, { mcp: { authorization: req => isTrusted(req) } })
37+
38+
// ✓ origin-only opt-out for a loopback-bound local tool
39+
await createDevServer(def, { mcp: { authorization: false } })
40+
```
41+
42+
## Fix
43+
44+
- Set the `DEVFRAME_MCP_AUTH_TOKEN` environment variable to the bearer the `mcp: true` shorthand requires.
45+
- Or pass an explicit `authorization` on the MCP options — a non-empty bearer token string, a `(request) => boolean` callback, or `false` for an origin-only local opt-out.
46+
47+
## Source
48+
49+
- [`packages/devframe/src/adapters/_shared.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/_shared.ts)`resolveMcpConfig()` throws this when the `mcp: true` shorthand has no environment token, or an object config omits `authorization`.

examples/files-inspector/src/devframe.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,13 @@ export default defineDevframe({
2424
// SPA can call RPC without an OTP round-trip.
2525
auth: false,
2626
// Serve the agent surface over the dev server's `/__mcp` route and
27-
// register the instance for `devframe connect` discovery.
28-
mcp: true,
27+
// register the instance for `devframe connect` discovery. This demo binds
28+
// to loopback (`localhost:9876`), so it takes the origin-only opt-out
29+
// (`authorization: false`) rather than requiring a bearer - the MCP route
30+
// stays reachable to `devframe connect` on the same machine without token
31+
// plumbing. A network-reachable tool would set a real bearer instead
32+
// (e.g. `authorization: process.env.DEVFRAME_MCP_AUTH_TOKEN`).
33+
mcp: { authorization: false },
2934
},
3035
setup(ctx) {
3136
// A scoped context auto-namespaces every registered id with `NAMESPACE:`.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,11 @@ export async function nextDevframeHub(
217217
// for a bearer token. See `docs/content/1.guide/13.security.md`.
218218
// The aggregate MCP endpoint at `/__devframes/__mcp` - the hub's agent
219219
// surface (agent-flagged commands, plugin tools, `devframe:state:read`)
220-
// over the same catch-all route as the SPAs.
220+
// over the same catch-all route as the SPAs. `mcp: true` is the
221+
// environment-backed policy: it reads the required bearer from
222+
// `DEVFRAME_MCP_AUTH_TOKEN`, so startup fails (DF0077) unless that is set -
223+
// the route is never mounted unauthenticated. An MCP client presents that
224+
// token as `Authorization: Bearer <token>` alongside a loopback Origin.
221225
mcp: true,
222226
// This host renders its own React UI in `app/page.tsx`, so skip the
223227
// default `@devframes/hub-ui` standalone/embedded slot.

examples/hub-next/tests/next-devframe-hub.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,23 @@ import { getTempAuthCode } from 'devframe/node/auth'
33
import { createRpcClient } from 'devframe/rpc/client'
44
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
55
import { getPort } from 'get-port-please'
6-
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
import { WebSocket } from 'ws'
88
import { nextDevframeHub } from '../src/client/devframe/next-devframe-hub'
99

1010
vi.stubGlobal('WebSocket', WebSocket)
1111

12+
// The example enables its aggregate MCP route with the environment-backed
13+
// `mcp: true` policy, so a bearer must be configured or the hub refuses to
14+
// start (DF0077). Provide it for the duration of each test.
15+
beforeEach(() => {
16+
vi.stubEnv('DEVFRAME_MCP_AUTH_TOKEN', 'a-high-entropy-example-test-token')
17+
})
18+
19+
afterEach(() => {
20+
vi.unstubAllEnvs()
21+
})
22+
1223
/** The side-car WS port advertised by the hub's connection meta. */
1324
function wsPortOf(hub: HubInstance): number {
1425
const ws = hub.connectionMeta().websocket

packages/devframe/src/adapters/__tests__/dev.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,9 @@ describe('adapters/dev', () => {
782782
host: '127.0.0.1',
783783
port: 0,
784784
auth: false,
785-
mcp: true,
785+
// Origin-only opt-out keeps this loopback-bound registry test free of
786+
// bearer plumbing; the identity gate is covered in mcp-http.test.ts.
787+
mcp: { authorization: false },
786788
})
787789

788790
const { readDevframeInstances } = await import('../../node/instance-registry')

0 commit comments

Comments
 (0)