Skip to content

Commit e6792d2

Browse files
committed
refactor(devframe): trust same-machine MCP callers, make identity opt-in
Reshape the route-based MCP auth model per review: the origin gate already keeps browsers and remote hosts out, so a same-machine caller is trusted by default. mcp: true is now origin-only (no bearer, no env var), and authorization (bearer or callback) is opt-in hardening for when a same-machine process is not the trust boundary (LAN/tunnel origin, shared/CI host, destructive tools). - McpRouteOptions.authorization is optional, defaulting to origin-only; drop the DEVFRAME_MCP_AUTH_TOKEN server shorthand and the DF0077 startup failure. - Promote mcp to a top-level DevframeDefinition.mcp option; cli.mcp stays as a deprecated fallback (mirrors clientAssets/cli.distDir). - createMcpFetchHandler / DevframeNextHost.mountMcp authorization is optional again (reverts the breaking narrowing). - Hub: warn (DF8005) when a mounted devframe requests MCP but the hub's aggregate MCP is off, since the hub's single route governs it. - Revert the now-unneeded e2e/example bearer plumbing; the connector keeps authToken as an opt-in for hardened instances. Created with the help of an agent.
1 parent e3271d8 commit e6792d2

38 files changed

Lines changed: 306 additions & 338 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 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).
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ 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.
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.
8686

8787
## Singular vs hub mounting
8888

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

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,45 +18,40 @@ 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 with the top-level `mcp`:
2222

2323
```ts
2424
import { defineDevframe } from 'devframe'
2525

2626
export default defineDevframe({
2727
// …
28-
cli: {
29-
// Reads the bearer from DEVFRAME_MCP_AUTH_TOKEN.
30-
mcp: true,
31-
},
28+
mcp: true,
3229
})
3330
```
3431

3532
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.
3633

3734
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.
3835

39-
### Two gates: origin hardening and identity
36+
### Origin gate, and opt-in identity
4037

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'] } }`.
38+
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.
4239

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:
40+
`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`:
4641

4742
```ts
4843
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-
},
44+
// A bearer token, backed by your own environment variable (never a literal):
45+
mcp: { authorization: process.env.MY_TOKEN },
46+
// — or a callback identity check (governs identity only; it cannot relax the origin gate):
47+
// mcp: { authorization: request => isTrusted(request) },
48+
// — or the explicit default, origin-only:
49+
// mcp: { authorization: false },
5750
})
5851
```
5952

53+
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'] }`.
54+
6055
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.
6156

6257
### Hosted bridges
@@ -71,7 +66,7 @@ devframeViteBridge(myDevframe, { mcp: true })
7166
createDevframeNextHandler(myDevframe, { mcp: true })
7267
```
7368

74-
Both honor the same authorization contract: `mcp: true` requires the `DEVFRAME_MCP_AUTH_TOKEN` bearer, or pass `mcp: { authorization }` explicitly.
69+
Both honor the same contract: `mcp: true` is origin-only; add `mcp: { authorization }` to harden.
7570

7671
## Custom host frameworks
7772

@@ -84,8 +79,8 @@ const mcp = createMcpFetchHandler(ctx, {
8479
serverName: 'my-tool (devframe)',
8580
serverVersion: '1.0.0',
8681
exposeSharedState: true,
87-
// Required: the identity policy — a bearer token, a callback, or `false`.
88-
authorization: process.env.DEVFRAME_MCP_AUTH_TOKEN!,
82+
// Optional identity check on top of the origin gate; omit for origin-only.
83+
// authorization: process.env.MY_TOKEN,
8984
})
9085
// route every method on /__mcp to mcp.fetch(request)
9186
```
@@ -109,6 +104,6 @@ Two gateway tools (`devframe:connect:*` ids — see [tool ids and wire names](/g
109104

110105
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.
111106

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.
107+
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 — command-line arguments are visible to other processes). Connect to a fleet with distinct credentials by driving `startConnectServer` with a per-instance `authToken` resolver.
113108

114109
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` | Expose the MCP route at `<base>__mcp`. `true` requires the `DEVFRAME_MCP_AUTH_TOKEN` bearer; `McpRouteOptions` carries an explicit `authorization`. |
45+
| `mcp` | `def.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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +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`. |
51+
| `mcp` | `def.mcp` | Expose the MCP route. `true` is origin-only (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. |
5252
| `key` | `@devframes/next:<id>:<base>` | `globalThis` memoization key. |
5353

5454
## Hosting a hub
@@ -125,7 +125,7 @@ export const POST = (req: Request) => hub.handler(req)
125125
export const DELETE = (req: Request) => hub.handler(req)
126126
```
127127

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 }`.
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.
129129

130130
No native hub UI provider here, so this scope stays quiet; `createDevframeNextHost()` is the low-level `DevframeHost`.
131131

‎docs/content/6.errors/DF0077.md‎

Lines changed: 0 additions & 49 deletions
This file was deleted.

‎docs/content/6.errors/DF8005.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 — 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 — 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 `mcp` enabled (top-level `mcp`, or the deprecated `cli.mcp`) while the hub itself has no `mcp` configured, so that devframe's tools are not reachable over MCP.
13+
14+
## Example
15+
16+
```ts
17+
initHub({
18+
base: DEVFRAMES_HUB_BASE,
19+
// No `mcp` here → no aggregate route…
20+
devframes: [
21+
myDevframe, // …but this devframe declares `mcp: true` → DF8005
22+
],
23+
})
24+
```
25+
26+
## Fix
27+
28+
- 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).
29+
- Or drop `mcp` from the mounted devframe to silence the warning — it has no effect inside a hub.
30+
31+
## Source
32+
33+
- [`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: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,18 @@ export default defineDevframe({
1717
icon: 'ph:folder-open-duotone',
1818
basePath: BASE_PATH,
1919
clientAssets: distDir,
20+
// Serve the agent surface over the dev server's `/__mcp` route and register
21+
// the instance for `devframe connect` discovery. This demo binds to loopback
22+
// (`localhost:9876`), so `mcp: true` (the loopback origin gate, trusting
23+
// same-machine callers) is enough - no bearer plumbing. A network-reachable
24+
// tool would harden it with `mcp: { authorization: process.env.MY_TOKEN }`.
25+
mcp: true,
2026
cli: {
2127
command: 'devframe-files-inspector',
2228
port: 9876,
2329
// Single-user localhost demo - skip the trust handshake so the served
2430
// SPA can call RPC without an OTP round-trip.
2531
auth: false,
26-
// Serve the agent surface over the dev server's `/__mcp` route and
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 },
3432
},
3533
setup(ctx) {
3634
// A scoped context auto-namespaces every registered id with `NAMESPACE:`.

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,9 @@ 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. `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.
220+
// over the same catch-all route as the SPAs. `mcp: true` mounts it with
221+
// the loopback origin gate (trusting same-machine callers); harden it with
222+
// `mcp: { authorization }` when the app is reachable beyond localhost.
225223
mcp: true,
226224
// This host renders its own React UI in `app/page.tsx`, so skip the
227225
// default `@devframes/hub-ui` standalone/embedded slot.

0 commit comments

Comments
 (0)