Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/1.guide/14.security.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,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 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).
- **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.
Expand Down
2 changes: 2 additions & 0 deletions docs/content/1.guide/18.hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

## Singular vs hub mounting

A devframe's SPA and RPC client are byte-identical in both cases; only the environment differs:
Expand Down
32 changes: 31 additions & 1 deletion docs/content/2.adapters/7.mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,38 @@ import { defineDevframe } from 'devframe'
export default defineDevframe({
// …
cli: {
// Reads the bearer from DEVFRAME_MCP_AUTH_TOKEN.
mcp: true,
},
})
```

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.

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

### Two gates: origin hardening and identity

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

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

An object config sets the policy explicitly:

```ts
export default defineDevframe({
cli: {
// A bearer from your own environment variable:
mcp: { authorization: process.env.MY_TOKEN },
// — or a callback identity check (governs identity only; it cannot relax the origin gate):
// mcp: { authorization: request => isTrusted(request) },
// — or an origin-only opt-out for a loopback-bound local tool that owns its trust boundary another way:
// mcp: { authorization: false },
},
})
```

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

Expand All @@ -47,6 +71,8 @@ devframeViteBridge(myDevframe, { mcp: true })
createDevframeNextHandler(myDevframe, { mcp: true })
```

Both honor the same authorization contract: `mcp: true` requires the `DEVFRAME_MCP_AUTH_TOKEN` bearer, or pass `mcp: { authorization }` explicitly.

## Custom host frameworks

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

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.

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.

See [Agent-Native](/guide/agent-native) for the API and safety model.
2 changes: 1 addition & 1 deletion docs/content/3.frameworks/1.vite.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Devframe spawns a separate RPC + WS server and registers Vite middleware at `<ba
| `host` | `def.cli?.host ?? 'localhost'` | Bind host for a pinned side-car. |
| `flags` | — | To `def.setup(ctx, { flags })`. |
| `auth` | gated (interactive OTP) | `false` to opt out, or a `DevframeAuthHandler` for a custom scheme. |
| `mcp` | `def.cli?.mcp` | `true` or `McpRouteOptions` to expose the MCP route at `<base>__mcp`. |
| `mcp` | `def.cli?.mcp` | Expose the MCP route at `<base>__mcp`. `true` requires the `DEVFRAME_MCP_AUTH_TOKEN` bearer; `McpRouteOptions` carries an explicit `authorization`. |

## `devframeVite` — convenience wrapper

Expand Down
3 changes: 3 additions & 0 deletions docs/content/3.frameworks/3.next.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const GET = handler.fetch
| `port` | from `def.cli?.port` | Side-car port. |
| `flags` | — | 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` requires the `DEVFRAME_MCP_AUTH_TOKEN` bearer; `McpRouteOptions` carries an explicit `authorization`. |
| `key` | `@devframes/next:<id>:<base>` | `globalThis` memoization key. |

## Hosting a hub
Expand Down Expand Up @@ -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 — it exposes privileged agent tools. Opt in with an authorization policy: `mcp: true` (requiring the `DEVFRAME_MCP_AUTH_TOKEN` bearer) or `mcp: { authorization }`.

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

## See also
Expand Down
49 changes: 49 additions & 0 deletions docs/content/6.errors/DF0077.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: 'DF0077: MCP Authorization Required'
description: 'The route-based MCP server needs an authorization policy, but none is configured.'
---

## Message

> The route-based MCP server needs an authorization policy, but none is configured — refusing to mount an unauthenticated agent endpoint.

## Cause

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:

- `mcp: true` (the shorthand) reads its bearer from the `DEVFRAME_MCP_AUTH_TOKEN` environment variable, and the variable is missing or empty.
- An object MCP config omits the required `authorization` field (or sets it to an empty string).

Startup fails and the route is never mounted, rather than exposing the endpoint unauthenticated.

## Example

```ts
// ✗ throws DF0077 when DEVFRAME_MCP_AUTH_TOKEN is unset
await createDevServer(def, { mcp: true })

// ✗ throws DF0077 — object config with no authorization
await createDevServer(def, { mcp: { path: '__mcp' } })

// ✓ shorthand, with the environment token set
process.env.DEVFRAME_MCP_AUTH_TOKEN = 'a-high-entropy-secret'
await createDevServer(def, { mcp: true })

// ✓ explicit bearer token
await createDevServer(def, { mcp: { authorization: process.env.MY_TOKEN! } })

// ✓ callback identity check
await createDevServer(def, { mcp: { authorization: req => isTrusted(req) } })

// ✓ origin-only opt-out for a loopback-bound local tool
await createDevServer(def, { mcp: { authorization: false } })
```

## Fix

- Set the `DEVFRAME_MCP_AUTH_TOKEN` environment variable to the bearer the `mcp: true` shorthand requires.
- 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.

## Source

- [`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`.
9 changes: 7 additions & 2 deletions examples/files-inspector/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ export default defineDevframe({
// SPA can call RPC without an OTP round-trip.
auth: false,
// Serve the agent surface over the dev server's `/__mcp` route and
// register the instance for `devframe connect` discovery.
mcp: true,
// register the instance for `devframe connect` discovery. This demo binds
// to loopback (`localhost:9876`), so it takes the origin-only opt-out
// (`authorization: false`) rather than requiring a bearer - the MCP route
// stays reachable to `devframe connect` on the same machine without token
// plumbing. A network-reachable tool would set a real bearer instead
// (e.g. `authorization: process.env.DEVFRAME_MCP_AUTH_TOKEN`).
mcp: { authorization: false },
},
setup(ctx) {
// A scoped context auto-namespaces every registered id with `NAMESPACE:`.
Expand Down
6 changes: 5 additions & 1 deletion examples/hub-next/src/client/devframe/next-devframe-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,11 @@ export async function nextDevframeHub(
// 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.
// over the same catch-all route as the SPAs. `mcp: true` is the
// environment-backed policy: it reads the required bearer from
// `DEVFRAME_MCP_AUTH_TOKEN`, so startup fails (DF0077) unless that is set -
// the route is never mounted unauthenticated. An MCP client presents that
// token as `Authorization: Bearer <token>` alongside a loopback Origin.
mcp: true,
// This host renders its own React UI in `app/page.tsx`, so skip the
// default `@devframes/hub-ui` standalone/embedded slot.
Expand Down
13 changes: 12 additions & 1 deletion examples/hub-next/tests/next-devframe-hub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@ import { getTempAuthCode } from 'devframe/node/auth'
import { createRpcClient } from 'devframe/rpc/client'
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
import { getPort } from 'get-port-please'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WebSocket } from 'ws'
import { nextDevframeHub } from '../src/client/devframe/next-devframe-hub'

vi.stubGlobal('WebSocket', WebSocket)

// The example enables its aggregate MCP route with the environment-backed
// `mcp: true` policy, so a bearer must be configured or the hub refuses to
// start (DF0077). Provide it for the duration of each test.
beforeEach(() => {
vi.stubEnv('DEVFRAME_MCP_AUTH_TOKEN', 'a-high-entropy-example-test-token')
})

afterEach(() => {
vi.unstubAllEnvs()
})

/** The side-car WS port advertised by the hub's connection meta. */
function wsPortOf(hub: HubInstance): number {
const ws = hub.connectionMeta().websocket
Expand Down
4 changes: 3 additions & 1 deletion packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,9 @@ describe('adapters/dev', () => {
host: '127.0.0.1',
port: 0,
auth: false,
mcp: true,
// Origin-only opt-out keeps this loopback-bound registry test free of
// bearer plumbing; the identity gate is covered in mcp-http.test.ts.
mcp: { authorization: false },
})

const { readDevframeInstances } = await import('../../node/instance-registry')
Expand Down
11 changes: 10 additions & 1 deletion packages/devframe/src/adapters/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,9 @@ describe('adapters/handler', () => {

it('mcp: mounts <base>__mcp and advertises it in the meta', async () => {
const wsPort = await getPort({ port: 18140, host: '127.0.0.1' })
const devtools = initDevframe(defineTestDef('handler-mcp'), { base: '/__handler-mcp/', auth: false, mcp: true, ws: { port: wsPort } })
// An explicit origin-only opt-out keeps this loopback-bound fixture free of
// bearer plumbing; the identity gate itself is covered in mcp-http.test.ts.
const devtools = initDevframe(defineTestDef('handler-mcp'), { base: '/__handler-mcp/', auth: false, mcp: { authorization: false }, ws: { port: wsPort } })

try {
await devtools.ready
Expand All @@ -262,6 +264,13 @@ describe('adapters/handler', () => {
}
})

it('mcp: true without DEVFRAME_MCP_AUTH_TOKEN fails startup (DF0077), route absent', async () => {
const wsPort = await getPort({ port: 18145, host: '127.0.0.1' })
const devtools = initDevframe(defineTestDef('handler-mcp-noauth'), { base: '/__handler-mcp-noauth/', auth: false, mcp: true, ws: { port: wsPort } })
await expect(devtools.ready).rejects.toThrow(/DF0077|authorization policy/)
await devtools.close()
})

it('default tier: binds nothing until the host attaches its own server', async () => {
const host = '127.0.0.1'
const port = await getPort({ port: 18150, host })
Expand Down
46 changes: 41 additions & 5 deletions packages/devframe/src/adapters/_shared.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ConnectionMeta } from '../types/context'
import type { DevframeDefinition, DevframeDeploymentKind, McpRouteOptions } from '../types/devframe'
import type { DevframeDefinition, DevframeDeploymentKind, McpAuthorization, McpRouteOptions } from '../types/devframe'
import process from 'node:process'
import { getPort } from 'get-port-please'
import { cleanDoubleSlashes, withLeadingSlash, withoutLeadingSlash, withTrailingSlash } from 'ufo'
import { DEVFRAME_MCP_ROUTE } from '../constants'
import { diagnostics } from '../node/diagnostics'

const DEFAULT_PORT = 9999

Expand Down Expand Up @@ -56,13 +58,47 @@ 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 `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into a
* fully-resolved config, or `undefined` when the MCP route is disabled.
*
* The route grants access to privileged agent tools, so an enabled route
* always resolves to a concrete authorization policy. `mcp: true` is shorthand
* for the bearer read from `DEVFRAME_MCP_AUTH_TOKEN`; an object config must
* carry an explicit `authorization`. A missing/empty token or absent
* `authorization` throws {@link diagnostics.DF0077} so the route is never
* mounted unauthenticated.
*/
export function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): ResolvedMcpConfig | undefined {
if (!mcp)
return undefined
return mcp === true ? {} : mcp
if (mcp === true) {
const token = process.env.DEVFRAME_MCP_AUTH_TOKEN
if (!token)
throw diagnostics.DF0077()
return { authorization: token }
}
const { authorization } = mcp
if (authorization === undefined || (typeof authorization === 'string' && authorization.length === 0))
throw diagnostics.DF0077()
return {
...(mcp.path !== undefined ? { path: mcp.path } : {}),
...(mcp.allowedOrigins !== undefined ? { allowedOrigins: mcp.allowedOrigins } : {}),
authorization,
}
}

/**
Expand Down
Loading
Loading