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
4 changes: 2 additions & 2 deletions docs/adapters/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default defineDevframe({

The endpoint speaks the MCP Streamable-HTTP transport at `/__mcp` (relative to the base path — `/__<id>/__mcp` under a host), sharing the dev server's origin and port. The `--mcp` and `--no-mcp` flags override the definition per run. `__connection.json` advertises the route so in-browser tooling can discover it.

Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The endpoint binds to the same loopback host as the dev server and applies the shared loopback origin gate; widen it for a tunnel or LAN origin:
Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The endpoint binds to the same loopback host as the dev server and applies an origin gate: a request must carry an `Origin` that is loopback (or on the configured allow-list). Unlike the WS transport it rejects `Origin`-less requests, so a route-based endpoint isn't reachable by an arbitrary local process — native clients (like `devframe connect`) send their loopback origin explicitly. Widen the gate for a tunnel or LAN origin:

```ts
defineDevframe({
Expand Down Expand Up @@ -90,6 +90,6 @@ It exposes two gateway tools (the wire names of the `devframe:connect:*` ids —
- **`devframe_connect_list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`.
- **`devframe_connect_call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint.

Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/<pid>-<port>.json` on boot and removes it on close; readers prune records whose liveness probe fails. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port <n>` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out.
Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/<pid>-<port>.json` on boot and removes it on close; readers prune records whose liveness probe fails. The connector dials each instance's endpoint with the instance's own loopback origin, so it clears the route's origin gate without any configuration. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port <n>` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out.

See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example.
2 changes: 1 addition & 1 deletion docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const ok = await rpc.requestTrustWithCode('047204')

The code is single-use, expires after five minutes, and is rotated after repeated wrong attempts, so re-display the current code if an exchange fails.

To authenticate without typing, a host can print a link embedding the code (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` query parameter, exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive authentication yourself with the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities.
To authenticate without typing, a host can print a link embedding the code (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` fragment parameter (`#devframe_otp=…`, kept out of server logs and `Referer`), exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive authentication yourself with the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities.

### Re-using an existing token

Expand Down
9 changes: 5 additions & 4 deletions docs/guide/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,19 @@ Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a cod
To skip typing, a host can print a link that embeds the code and open the browser straight into an authenticated session. The standalone CLI (`createCac` / `createDevServer`) does this automatically for `--open`: when the server is auth-gated, the browser it launches already carries the current code, so the tab lands authenticated with no prompt at all. Build the link yourself from the current code with `buildOtpAuthUrl(origin)` (devframe stays headless, so the host prints its own banner):

```
Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456
Devtools ready — authenticate this browser: http://localhost:3000/#devframe_otp=123456
```

`connectDevframe` reads the `devframe_otp` parameter, exchanges it, and removes it from the URL before anything else. Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code.
The code rides the URL **fragment** (`#devframe_otp=…`), which the browser never sends to the server — so the single-use code stays out of access logs and `Referer` headers. `connectDevframe` reads the `devframe_otp` fragment parameter, exchanges it, and removes it from the URL before anything else. Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code.

Higher-level integrations can drive their own authentication UI instead: disable the built-in handling with the `otpParam: false` client option, then call the exposed `authenticateWithUrlOtp(rpc)` (consume the code from the URL and exchange it) or `consumeOtpFromUrl()` (read and strip the code) from `devframe/client`.

## Practices for tools built on devframe

- **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do.
- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere.
- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere. The hosted bridges (`viteDevBridge`, `@devframes/next`'s handler) gate their side-car by default too — a host that owns the trust boundary another way opts out with `auth: false` explicitly.
- **The MCP route requires an origin.** Unlike the WS transport, the route-based MCP server rejects `Origin`-less requests (a request must carry a loopback or allow-listed `Origin`), so a route-based endpoint isn't reachable by an arbitrary local process — see [MCP](/adapters/mcp).
- **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output.
- **Authorize every handler.** A registered function is callable by any trusted client. 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, enable `originLock` so a dock token is only honored from its expected origin.
- **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 — the connect-time gate verifies the token against the recorded origin before the connection is trusted.
- **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`.
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ export async function nextDevframeHub(
},
})

// Single-user localhost demo: the side-car is reachable only on loopback, so
// it opts out of the gate for a no-friction dev experience. A hub reachable
// beyond localhost should gate (see `docs/guide/security.md`).
const started = await startHttpAndWs({
context,
host: hostName,
Expand Down
4 changes: 4 additions & 0 deletions examples/vite-devframe-hub/src/vite-devframe-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin {
started = await startHttpAndWs({
context,
port,
// Single-user localhost demo: the side-car is reachable only on
// loopback, so it opts out of the gate for a no-friction dev
// experience. A hub reachable beyond localhost should gate (see
// `docs/guide/security.md`).
auth: false,
})

Expand Down
2 changes: 1 addition & 1 deletion packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ describe('adapters/dev', () => {
try {
expect(mockedOpen).toHaveBeenCalledTimes(1)
const [target] = mockedOpen.mock.calls[0]
expect(target).toBe(`http://localhost:${port}/?devframe_otp=${code}`)
expect(target).toBe(`http://localhost:${port}/#devframe_otp=${code}`)
}
finally {
spy.mockRestore()
Expand Down
37 changes: 35 additions & 2 deletions packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,17 @@ describe('mcp adapter (streamable http route)', () => {
expect(meta.mcp).toBeUndefined()
})

// A native MCP client must send a (loopback) Origin so the route's gate —
// which rejects Origin-less requests — accepts it.
function originTransport(started: StartedServer): StreamableHTTPClientTransport {
return new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`), {
requestInit: { headers: { origin: started.origin } },
})
}

it('establishes a stateful session and lists agent tools', async () => {
const started = await boot()
const transport = new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`))
const transport = originTransport(started)
const client = new Client({ name: 'test-client', version: '0.0.0' })
try {
await client.connect(transport)
Expand All @@ -88,11 +96,13 @@ describe('mcp adapter (streamable http route)', () => {

// Initialize over raw HTTP to capture the issued session id from the
// response header (the body is an SSE stream we can discard).
const originHeader = { origin: started.origin }
const init = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
...originHeader,
},
body: JSON.stringify({
jsonrpc: '2.0',
Expand All @@ -108,7 +118,7 @@ describe('mcp adapter (streamable http route)', () => {
// DELETE ends the session.
const del = await fetch(url, {
method: 'DELETE',
headers: { 'mcp-session-id': sessionId! },
headers: { 'mcp-session-id': sessionId!, ...originHeader },
})
await del.body?.cancel()
expect(del.status).toBeLessThan(300)
Expand All @@ -121,13 +131,36 @@ describe('mcp adapter (streamable http route)', () => {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
'mcp-session-id': sessionId!,
...originHeader,
},
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
})
await stale.body?.cancel()
expect(stale.status).toBe(404)
})

it('rejects an Origin-less request', async () => {
const started = await boot()
// Unlike the WS transport, the MCP route does not allow Origin-less
// requests — a route-based endpoint would otherwise be reachable by any
// local process.
const res = await fetch(`${started.origin}/__mcp`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
}),
})
await res.body?.cancel()
expect(res.status).toBe(403)
})

it('rejects a disallowed cross-origin request', async () => {
const started = await boot()
const res = await fetch(`${started.origin}/__mcp`, {
Expand Down
26 changes: 17 additions & 9 deletions packages/devframe/src/adapters/mcp/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ export interface CreateMcpFetchHandlerOptions {
exposeSharedState: boolean | ((key: string) => boolean)
/**
* Origin allow-list beyond the loopback default. `false` disables the
* origin gate entirely. Default: loopback-only (mirrors the WS transport).
* origin gate entirely. Default: loopback-only.
*
* Unlike the WS transport, the MCP route does **not** allow `Origin`-less
* requests: a route-based endpoint is reachable by any local process, so a
* request must carry an `Origin` that passes the gate. Native clients
* (e.g. `devframe connect`) send their loopback origin explicitly.
*/
allowedOrigins?: readonly string[] | false
}
Expand Down Expand Up @@ -44,9 +49,10 @@ interface McpSession {
* and MCP server (built from the shared, live `ctx` via
* `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: an
* `initialize` POST spins up a session; later requests route to it; a `DELETE`
* (or client disconnect) tears it down. The origin gate applies devframe's
* loopback-default DNS-rebinding protection (identical semantics to the WS
* upgrade's `isAllowedOrigin`).
* (or client disconnect) tears it down. The origin gate guards every request:
* loopback-default DNS-rebinding protection that — unlike the WS upgrade's
* `isAllowedOrigin` — also rejects `Origin`-less requests, so a route-based
* endpoint isn't reachable by an arbitrary local process.
*
* @experimental
*/
Expand Down Expand Up @@ -105,12 +111,14 @@ export function createMcpFetchHandler(
}

async function handle(req: Request): Promise<Response> {
// Origin gate — identical semantics to the WS upgrade's `isAllowedOrigin`
// (loopback + `Origin`-less native clients + the configured allow-list).
// This is the endpoint's DNS-rebinding protection.
// Origin gate — the endpoint's DNS-rebinding protection and its guard
// against arbitrary local processes. Unlike the WS transport, an
// `Origin`-less request is rejected: a route-based MCP endpoint would
// otherwise be reachable by any local process. A request must carry an
// `Origin` that is loopback or on the configured allow-list.
const origin = req.headers.get('origin') ?? undefined
if (allowedOrigins !== false && !isAllowedOrigin(origin, allowedOrigins ?? []))
return new Response('Forbidden: origin not allowed', { status: 403 })
if (allowedOrigins !== false && (origin === undefined || !isAllowedOrigin(origin, allowedOrigins ?? [])))
return new Response('Forbidden: origin required', { status: 403 })

const sessionId = req.headers.get('mcp-session-id') ?? undefined
let session = sessionId ? sessions.get(sessionId) : undefined
Expand Down
8 changes: 7 additions & 1 deletion packages/devframe/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,13 @@ async function withInstanceClient<T>(
url: string,
fn: (client: InstanceType<ConnectSdk['Client']>) => Promise<T>,
): Promise<T> {
const transport = new sdk.StreamableHTTPClientTransport(new URL(url))
// Send the instance's own (loopback) origin so the MCP route's origin gate,
// which rejects `Origin`-less requests, accepts this native client.
const origin = new URL(url).origin
const transport = new sdk.StreamableHTTPClientTransport(
new URL(url),
{ requestInit: { headers: { origin } } },
)
const client = new sdk.Client({ name: 'devframe-connect', version: '0.0.0' })
await client.connect(transport)
try {
Expand Down
Loading
Loading