Skip to content
Merged
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 @@ -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.
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 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:
Expand Down
30 changes: 28 additions & 2 deletions docs/content/2.adapters/7.mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -33,7 +33,27 @@ export default defineDevframe({

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

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 +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.
Expand All @@ -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)
```
Expand All @@ -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/<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.

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.
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` | none | 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` is origin-only (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. |

## `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` | 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:<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. 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
Expand Down
32 changes: 32 additions & 0 deletions docs/content/6.errors/DF8005.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/content/6.errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 3 additions & 2 deletions examples/files-inspector/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
11 changes: 4 additions & 7 deletions examples/hub-next/src/client/devframe/next-devframe-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/**
Expand Down
38 changes: 33 additions & 5 deletions packages/devframe/src/adapters/_shared.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
}
}

/**
Expand Down
28 changes: 15 additions & 13 deletions packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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 `<base>__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 `<base>__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<void>) | 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<typeof import('./mcp')>('devframe/adapters/mcp'))
Expand All @@ -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
Expand Down
Loading
Loading