diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index e8b42ea47..1bf89ff5d 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -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 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). +- **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 the `'auto'` default - which mounts the route once agent tools exist - and `mcp: true` are 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), or turn the route off with `mcp: false`. 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. diff --git a/docs/content/1.guide/15.agent-native.md b/docs/content/1.guide/15.agent-native.md index 63adc378c..a85d8368c 100644 --- a/docs/content/1.guide/15.agent-native.md +++ b/docs/content/1.guide/15.agent-native.md @@ -100,7 +100,9 @@ Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/` resourc ## Starting the MCP server -CLI: +The dev server serves the agent surface over HTTP on its own: the `mcp: 'auto'` default mounts the route at `/__mcp` once anything above exists (an `agent`-flagged RPC, a registered tool or resource) - one flagged function is the whole setup. See the [MCP adapter](/adapters/mcp#route-based-server) for forcing it on or off and hardening the route. + +For a stdio server instead, via the CLI: ```sh # Run your devtool with an MCP stdio server attached. @@ -118,8 +120,6 @@ const myDevframe = defineDevframe({ /* … */ }) await createMcpServer(myDevframe, { transport: 'stdio' }) ``` -`@modelcontextprotocol/server` is a peer dependency. - ## Connecting Claude Desktop In `claude_desktop_config.json`: diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 6e69a564a..06ba2debb 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -33,7 +33,7 @@ The advertised path is hub-base-absolute (`/__devframes/__ws`). Dev-reevaluated ## The namespace -The namespace serves the hub UI at `/` (the `ui.viewer` SPA, or an index document when headless) and each devframe's SPA at `/` with its own `__connection.json` pointing at the shared socket. Hub-level endpoints sit alongside: `embedded.js` (the `ui.embedded` bootstrap), `__connection.json`, `__ws`, `__index.json`, `__client-imports.js`, and the opt-in `__mcp`. The route table is in the [Hub API reference](/references/hub-api#hub-namespace-routes). +The namespace serves the hub UI at `/` (the `ui.viewer` SPA, or an index document when headless) and each devframe's SPA at `/` with its own `__connection.json` pointing at the shared socket. Hub-level endpoints sit alongside: `embedded.js` (the `ui.embedded` bootstrap), `__connection.json`, `__ws`, `__index.json`, `__client-imports.js`, and `__mcp` (mounted by the `'auto'` default once agent tools exist). The route table is in the [Hub API reference](/references/hub-api#hub-namespace-routes). Devframe ids become URL segments, validated: reserved names throw `DF8000`, non-route-safe `DF8004`. @@ -82,7 +82,7 @@ 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. +The aggregate MCP route mounts through the `'auto'` default once any mounted devframe (or an agent-flagged hub command) exposes agent tools; `mcp: true` forces it on, `mcp: false` off. It has its own origin gate, independent of this RPC Auth: the mounted route 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 set `mcp: false`. ## Singular vs hub mounting diff --git a/docs/content/1.guide/index.md b/docs/content/1.guide/index.md index bc2fbc86a..3bb3be1de 100644 --- a/docs/content/1.guide/index.md +++ b/docs/content/1.guide/index.md @@ -114,7 +114,7 @@ The mounted devframes share one RPC registry, state store, connection, auth gate pnpm add devframe ``` -`devframe` ships ESM-only, no Vite dependency. Adapters with optional peers (the MCP adapter needs `@modelcontextprotocol/server`) surface the requirement at import time. +`devframe` ships ESM-only, no Vite dependency. The CLI adapter's optional peer (`cac`) surfaces its requirement at import time. ## Hello, Devframe diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index a81be687a..8d52b6727 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -14,26 +14,28 @@ import myDevframe from './my-tool' await createMcpServer(myDevframe, { transport: 'stdio' }) ``` -`@modelcontextprotocol/server` is a peer dependency; `createMcpServer` serves `stdio` through the SDK's `serveStdio`, pinning one server instance per connection. +`createMcpServer` serves `stdio` through the MCP SDK's `serveStdio`, pinning one server instance per connection. ## Route-based server -The dev server exposes the same MCP API over HTTP, live. Whether to expose it is a hosting decision, so pass `mcp` to `createCac` when you assemble the CLI (or to `createDevServer` / `initDevframe` / `initHub` when you host it programmatically): +The dev server exposes the same MCP API over HTTP, live. The default setting is **`'auto'`**: the route mounts once the devframe exposes an agent surface (an `agent`-flagged RPC, a registered tool or resource) - flag your first function and the agent view is on. A devframe with nothing flagged mounts no route and loads no MCP code. + +Pin the behavior where you host the tool - it's a hosting decision, so pass `mcp` to `createCac` when you assemble the CLI (or to `createDevServer` / `initDevframe` / `initHub` when you host it programmatically): `true` always mounts, `false` never mounts, an object customises the route: ```ts import { createCac } from 'devframe/adapters/cac' import myDevframe from './my-tool' -createCac(myDevframe, { mcp: true }).parse() +createCac(myDevframe, { mcp: true }).parse() // force on; `false` forces off; omit for 'auto' ``` -The endpoint speaks Streamable-HTTP at `/__mcp` (`/__/__mcp` under a host framework), sharing its origin/port. `--mcp` / `--no-mcp` override; `__connection.json` advertises it. +The endpoint speaks Streamable-HTTP at `/__mcp` (`/__/__mcp` under a host framework), sharing its origin/port. `--mcp` / `--no-mcp` override per run; `__connection.json` advertises the mounted route. 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. +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 - the `'auto'` default and `mcp: true` both mount origin-only, 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`: @@ -51,7 +53,7 @@ Never place the token in a URL, in `__connection.json`, in the instance registry ### Hosted bridges -Both bridges forward it to their side-car dev server, advertising the endpoint in `__connection.json`: +Both bridges forward the setting to their side-car dev server, advertising the mounted endpoint in `__connection.json`: ```ts // Vite (@devframes/vite) @@ -61,7 +63,7 @@ devframeViteBridge(myDevframe, { mcp: true }) createDevframeNextHandler(myDevframe, { mcp: true }) ``` -Both honor the same contract: `mcp: true` is origin-only; add `mcp: { authorization }` to harden. +Both honor the same contract: omitted is `'auto'`, `true` forces the origin-only route on; add `mcp: { authorization }` to harden. ## Custom host frameworks diff --git a/docs/content/2.adapters/index.md b/docs/content/2.adapters/index.md index 15748c657..a4a1360b8 100644 --- a/docs/content/2.adapters/index.md +++ b/docs/content/2.adapters/index.md @@ -7,7 +7,7 @@ description: 'The lowest-level path is the standard handler, initDevframe(def, { The lowest-level path is [the standard handler](/adapters/initiate), `initDevframe(def, { base })`: a Web Standard `(request: Request) => Promise` for any catch-all route. Every path below builds on it. -Adapters wrap it as `createXxx(def, options?)` at `devframe/adapters/`. `cac` and `mcp` need an optional peer ([`cac`](https://github.com/cacjs/cac), [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk)). +Adapters wrap it as `createXxx(def, options?)` at `devframe/adapters/`. `cac` needs an optional peer ([`cac`](https://github.com/cacjs/cac)). ## Comparison diff --git a/docs/content/3.frameworks/1.vite.md b/docs/content/3.frameworks/1.vite.md index aba07f1ac..ad9a564e7 100644 --- a/docs/content/3.frameworks/1.vite.md +++ b/docs/content/3.frameworks/1.vite.md @@ -42,7 +42,7 @@ Devframe spawns a separate RPC + WS server and registers Vite middleware at `__mcp`. `true` is origin-only (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. | +| `mcp` | `'auto'` | Expose the MCP route at `__mcp`. `'auto'` mounts once agent tools exist; `true` forces the origin-only route on (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. | ## `devframeVite`: convenience wrapper diff --git a/docs/content/3.frameworks/3.next.md b/docs/content/3.frameworks/3.next.md index d29af883e..ba1ec5dc6 100644 --- a/docs/content/3.frameworks/3.next.md +++ b/docs/content/3.frameworks/3.next.md @@ -48,7 +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. | +| `mcp` | `'auto'` | Expose the MCP route. `'auto'` mounts once agent tools exist; `true` forces the origin-only route on (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. | | `key` | `@devframes/next::` | `globalThis` memoization key. | ## Hosting a hub @@ -125,7 +125,7 @@ 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. +The aggregate MCP route mounts by default once any mounted devframe exposes agent tools (the `'auto'` setting). Force it on with `mcp: true` (origin-only, trusting same-machine callers), off with `mcp: false`, or add `mcp: { authorization }` for 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`. diff --git a/docs/content/6.errors/DF0017.md b/docs/content/6.errors/DF0017.md index 3737e5b0d..ec92fef1d 100644 --- a/docs/content/6.errors/DF0017.md +++ b/docs/content/6.errors/DF0017.md @@ -11,13 +11,13 @@ description: 'Failed to start MCP server ({transport}): {reason}' The MCP server failed while initializing. Common reasons: -- The `@modelcontextprotocol/server` peer dependency is missing (the stdio and route-based transports both need it). - The stdio transport threw during `connect()` (e.g. stdin/stdout unavailable). +- The MCP adapter module could not be loaded (e.g. a corrupted install missing `@modelcontextprotocol/server`). ## Fix -- **Missing SDK**: `pnpm add @modelcontextprotocol/server` in the package that imports `devframe/adapters/mcp` or enables `cli.mcp`. - **Transport failure**: inspect the underlying error attached as `cause`. +- **Broken install**: reinstall dependencies so `@modelcontextprotocol/server` (a dependency of `devframe`) resolves. ## Source diff --git a/docs/content/6.errors/DF0046.md b/docs/content/6.errors/DF0046.md index d6b624137..6cfc61519 100644 --- a/docs/content/6.errors/DF0046.md +++ b/docs/content/6.errors/DF0046.md @@ -1,22 +1,22 @@ --- title: 'DF0046: Connector Requires the MCP SDK' -description: 'devframe connect requires the optional peer dependency @modelcontextprotocol/server: {reason}' +description: 'devframe connect requires the optional peer dependency @modelcontextprotocol/client: {reason}' --- ## Message -> `devframe connect` requires the optional peer dependency @modelcontextprotocol/server: `{reason}` +> `devframe connect` requires the optional peer dependency @modelcontextprotocol/client: `{reason}` ## Cause -`devframe connect` was started but `@modelcontextprotocol/server` could not be imported. The SDK is an optional peer dependency of `devframe`, keeping the MCP surface opt-in, so it only needs to be installed where MCP features are used. +`devframe connect` was started but `@modelcontextprotocol/client` could not be imported. The client SDK is an optional peer dependency of `devframe`: only the connector dials other instances, so only it needs the package installed. ## Fix Install the SDK next to devframe and run the connector again: ```sh -npm install @modelcontextprotocol/server +npm install @modelcontextprotocol/client devframe connect ``` diff --git a/docs/content/6.errors/DF0051.md b/docs/content/6.errors/DF0051.md index deea443aa..423f16cf7 100644 --- a/docs/content/6.errors/DF0051.md +++ b/docs/content/6.errors/DF0051.md @@ -20,7 +20,7 @@ The `devframe connect` gateway tool `devframe_connect_call-tool` targeted a live ## Fix -Restart the instance with the `--mcp` flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again. +Restart the instance with the `--mcp` flag (or pass `mcp: true` to `createCac` / its programmatic host) to expose its tools, then list instances again. ## Source diff --git a/docs/content/6.errors/DF8005.md b/docs/content/6.errors/DF8005.md index 8e7845a54..31685d678 100644 --- a/docs/content/6.errors/DF8005.md +++ b/docs/content/6.errors/DF8005.md @@ -1,32 +1,33 @@ --- 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.' +description: 'Devframe "{id}" requests an MCP route, but the hub''s aggregate MCP is off (`mcp: false`), 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. +> Devframe "`{id}`" requests an MCP route, but the hub's aggregate MCP is off (`mcp: false`), 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. +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 mounted devframe's definition enables MCP through the deprecated `cli.mcp` field while the hub set `mcp: false`, 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`: +The hub below turned MCP off, but a mounted devframe's definition requests MCP: ```ts initHub({ base: DEVFRAMES_HUB_BASE, - devframes: [myDevframe], // myDevframe sets `cli.mcp: true`, so DF8005 + mcp: false, + devframes: [myDevframe], // myDevframe's definition requests MCP, 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). +- Drop `mcp: false` from `initHub`: the `'auto'` default mounts the aggregate route once agent tools exist, and `mcp: true` / `mcp: { authorization }` force or harden it. - 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. +- [`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 turned MCP off but the devframe requests one. diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index 0f38e0570..b68d769cd 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -81,6 +81,8 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi | [DF0072](/errors/DF0072) | warn | Snapshot Names Unknown RPC Method | | [DF0073](/errors/DF0073) | error | JSON-Render Spec Does Not Match Its Schema | | [DF0074](/errors/DF0074) | error | JSON-Render Schema Is Asynchronous | +| [DF0075](/errors/DF0075) | warn | No RPC Transport On This Runtime | +| [DF0076](/errors/DF0076) | error | WebSocket Upgrade Unsupported On This Runtime | ## Hub: context & lifecycle (DF80xx) diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index 24018fd35..418676578 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -82,7 +82,7 @@ What `initHub()` serves under its `base`: [The namespace](/guide/hub-initiate#th | `__ws` | WebSocket upgrade route | | `__index.json` | machine-readable index: mounted devframes, endpoints | | `__client-imports.js` | dock client-script import map for hub UI providers | -| `__mcp` | aggregate MCP endpoint over the tool registry (opt-in `mcp`) | +| `__mcp` | aggregate MCP endpoint over the tool registry (`mcp: 'auto'` default: mounted once agent tools exist) | ## Client runtime options diff --git a/examples/hub-next/src/client/devframe/next-devframe-hub.ts b/examples/hub-next/src/client/devframe/next-devframe-hub.ts index 2ccf8c5e2..e41f903a0 100644 --- a/examples/hub-next/src/client/devframe/next-devframe-hub.ts +++ b/examples/hub-next/src/client/devframe/next-devframe-hub.ts @@ -211,13 +211,11 @@ export async function nextDevframeHub( cwd, origin, host: hostName, - /** - * 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, + // Aggregate MCP at `/__devframes/__mcp` mounts on its own: the omitted + // `mcp` defaults to `'auto'` and the mounted plugins expose agent tools. + // Same-machine callers are trusted (loopback origin gate); harden with + // `mcp: { authorization }` when reachable beyond localhost, or force it + // off with `mcp: false`. /** * This host renders its own React UI in `app/page.tsx`, so skip the * default `@devframes/hub-ui` standalone/embedded slot. @@ -252,7 +250,7 @@ export async function nextDevframeHub( * Record this hub in the global registry so `devframe connect` discovers * it - running inside the Next dev server - like any standalone devframe. * The instance owns the record (written once its pinned origin resolves, - * removed on close); the aggregate MCP path is derived from `mcp: true`. + * removed on close); the aggregate MCP path reflects the mounted route. */ register: { id: 'example:next-devframe-hub', diff --git a/examples/hub-vite/README.md b/examples/hub-vite/README.md index 3523bc789..2a1098b4a 100644 --- a/examples/hub-vite/README.md +++ b/examples/hub-vite/README.md @@ -2,7 +2,7 @@ A tiny, copyable **vite-devtools-style hub**. [vite-devtools](https://github.com/vitejs/devtools) is the full hub UI provider that docks many Vite tools behind one icon rail on top of `@devframes/hub`; this example is the smallest thing shaped like it - an icon dock, an iframe stage, and a drawer of hub subsystems - so you can see the whole protocol and build your own hub UI provider from it. -`src/vite-devframe-hub.ts` is the entire host-framework integration: a small Vite plugin around one `initHub()` call (from `@devframes/hub/initiate`). The instance mounts every devframe under one namespace - `/__devframes//` - merges their RPC registries onto one WebSocket that upgrades on Vite's own dev server at `/__devframes/__ws`, and serves the discovery endpoints (`/__devframes/__connection.json`, `__index.json`, `__client-imports.js`) - all behind one connect-style middleware that self-filters by the base and hands everything else back to Vite. Mounting a hub in any host framework follows the same shape. +`src/vite-devframe-hub.ts` is the entire host-framework integration: a small Vite plugin around one `initHub()` call (from `@devframes/hub/initiate`). The instance mounts every devframe under one namespace - `/__devframes//` - merges their RPC registries onto one WebSocket that upgrades on Vite's own dev server at `/__devframes/__ws`, and serves the discovery endpoints (`/__devframes/__connection.json`, `__index.json`, `__client-imports.js`) plus the aggregate MCP endpoint at `/__devframes/__mcp` (Streamable-HTTP over the whole hub tool registry, mounted by the `'auto'` default because the built-in plugins expose agent tools) - all behind one connect-style middleware that self-filters by the base and hands everything else back to Vite. Mounting a hub in any host framework follows the same shape. ## Run it @@ -26,7 +26,7 @@ The **RPC & State Inspector** carries an **Instances** tab that lists every devf ## What the example proves -- `initHub()` boots a hub with no Vite-specific code path: `server.middlewares.use(instance.nodeMiddleware)` plus Vite's `httpServer` for the shared WebSocket upgrade is the entire host-framework integration +- `initHub()` boots a hub with no Vite-specific code path: `server.middlewares.use(instance.nodeMiddleware)` plus Vite's `httpServer` for the shared WebSocket upgrade is the entire host-framework integration - devframes, shared RPC registry, WS transport, MCP, and discovery behind one framework-agnostic handler - Every `devframes` entry is served at `/__devframes//` with its own `__connection.json`, so each embedded SPA connects straight back to the hub; `/__devframes/__index.json` lists the mounted devframes and endpoints for any external viewer - One authorization covers the whole hub: `initHub()` gates the shared transport by default, so a single OTP handshake trusts every mounted devframe, the discovery endpoints, and the built-ins. The hub UI provider drives its own authorization view (`simpleAuth: false`) and each embedded SPA inherits the stored token - Real devframes work end to end through the mount path - the inspector lists every mounted devframe's RPC functions live, terminals stream over the hub, and code-server launches an authenticated editor diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 26b37e5dd..49517b3a9 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -83,21 +83,18 @@ }, "peerDependencies": { "@modelcontextprotocol/client": "^2.0.0", - "@modelcontextprotocol/server": "^2.0.0", "cac": "^7.0.0" }, "peerDependenciesMeta": { "@modelcontextprotocol/client": { "optional": true }, - "@modelcontextprotocol/server": { - "optional": true - }, "cac": { "optional": true } }, "dependencies": { + "@modelcontextprotocol/server": "catalog:deps", "@standard-schema/spec": "catalog:deps", "birpc": "catalog:deps", "crossws": "catalog:deps", @@ -109,7 +106,6 @@ }, "devDependencies": { "@modelcontextprotocol/client": "catalog:deps", - "@modelcontextprotocol/server": "catalog:deps", "cac": "catalog:deps", "get-port-please": "catalog:deps", "immer": "catalog:deps", diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 4d3c77dfc..3155c3b10 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -262,6 +262,79 @@ describe('adapters/handler', () => { } }) + // The `'auto'` default: an omitted `mcp` mounts the route exactly when + // `setup()` left a non-empty agent surface. + function defineAgentTestDef(id: string) { + return defineDevframe({ + id, + name: 'Agent Handler Test', + version: '0.0.0', + packageName: 'devframe-handler-test', + homepage: 'https://example.test', + description: 'Test devframe with an agent surface.', + setup: (ctx: DevframeNodeContext) => { + ctx.rpc.register({ + name: 'test:agent-probe', + type: 'query', + jsonSerializable: true, + agent: { description: 'Answers ok.' }, + handler: () => 'ok', + }) + }, + }) + } + + it('mcp omitted: mounts once the agent surface is non-empty', async () => { + const wsPort = await getPort({ port: 18142, host: '127.0.0.1' }) + const devtools = initDevframe(defineAgentTestDef('handler-mcp-auto'), { base: '/__handler-mcp-auto/', auth: false, ws: { port: wsPort } }) + + try { + await devtools.ready + expect(devtools.connectionMeta().mcp).toEqual({ path: '__mcp' }) + const res = await devtools.handler(new Request('http://localhost:3000/__handler-mcp-auto/__mcp', { + headers: { origin: 'http://localhost:3000' }, + })) + expect(res.status).not.toBe(404) + } + finally { + await devtools.close() + } + }) + + it('mcp omitted: an empty agent surface mounts nothing', async () => { + const wsPort = await getPort({ port: 18144, host: '127.0.0.1' }) + const devtools = initDevframe(defineTestDef('handler-mcp-auto-empty'), { base: '/__handler-mcp-auto-empty/', auth: false, ws: { port: wsPort } }) + + try { + await devtools.ready + expect(devtools.connectionMeta().mcp).toBeUndefined() + const res = await devtools.handler(new Request('http://localhost:3000/__handler-mcp-auto-empty/__mcp', { + headers: { origin: 'http://localhost:3000' }, + })) + expect(res.status).toBe(404) + } + finally { + await devtools.close() + } + }) + + it('mcp: false keeps the route off despite an agent surface', async () => { + const wsPort = await getPort({ port: 18146, host: '127.0.0.1' }) + const devtools = initDevframe(defineAgentTestDef('handler-mcp-off'), { base: '/__handler-mcp-off/', auth: false, mcp: false, ws: { port: wsPort } }) + + try { + await devtools.ready + expect(devtools.connectionMeta().mcp).toBeUndefined() + const res = await devtools.handler(new Request('http://localhost:3000/__handler-mcp-off/__mcp', { + headers: { origin: 'http://localhost:3000' }, + })) + expect(res.status).toBe(404) + } + finally { + 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 }) diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts index fc69289bf..c68d6f8ae 100644 --- a/packages/devframe/src/adapters/_shared.ts +++ b/packages/devframe/src/adapters/_shared.ts @@ -1,8 +1,10 @@ +import type { DevframeAgentHost } from '../types/agent' import type { ConnectionMeta } from '../types/context' -import type { DevframeDefinition, DevframeDeploymentKind, McpAuthorization, McpRouteOptions } from '../types/devframe' +import type { DevframeDefinition, DevframeDeploymentKind, McpAuthorization, McpSetting } from '../types/devframe' import { getPort } from 'get-port-please' import { cleanDoubleSlashes, withLeadingSlash, withoutLeadingSlash, withTrailingSlash } from 'ufo' import { DEVFRAME_MCP_ROUTE } from '../constants' +import { importRuntimeModule } from '../node/import-runtime-module' const DEFAULT_PORT = 9999 @@ -70,16 +72,20 @@ export interface ResolvedMcpConfig { } /** - * Normalize the `mcp` option (`boolean | McpRouteOptions`) into a - * fully-resolved config, or `undefined` when the MCP route is disabled. + * Normalize an *explicit* `mcp` setting into a fully-resolved config, or + * `undefined` when the MCP route is disabled. `'auto'` also resolves to + * `undefined` here: whether it mounts depends on the live agent surface, + * which only the mounting adapter can consult (through + * {@link loadAutoMcpAdapter}) - static resolvers like + * `resolveMcpConnectionMeta` treat it as unadvertisable. * * 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) +export function resolveMcpConfig(mcp: McpSetting | undefined): ResolvedMcpConfig | undefined { + if (!mcp || mcp === 'auto') return undefined if (mcp === true) return { authorization: false } @@ -93,11 +99,33 @@ export function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): Re } } +/** + * Resolve the `mcp: 'auto'` default at mount time: import the MCP adapter + * when the devframe's agent surface is non-empty, or return `undefined` + * (mount nothing) when the surface is empty - the zero-cost path, loading + * no MCP code at all. The adapter (and the MCP SDK behind it) loads through + * `importRuntimeModule`, so it never enters a consumer's bundle graph. + * + * Generic like `importRuntimeModule`: the caller names the module type + * (`typeof import('devframe/adapters/mcp')`) so this shared helper carries + * no type-level dependency on the MCP adapter. + */ +export async function loadAutoMcpAdapter( + agent: Pick, +): Promise { + if (!agent.hasSurface()) + return undefined + return await importRuntimeModule('devframe/adapters/mcp') +} + /** * Resolve the `mcp` entry a `__connection.json` should advertise for a dev * server started with the given `mcp` option (falling back to `def.cli?.mcp`, * exactly like `createDevServer`), or `undefined` when the route is - * disabled. + * disabled. `'auto'` (the omitted default) resolves at mount time against + * the live agent surface, so hand-rolled meta advertises it only for an + * explicit setting; the adapters advertise the actually-mounted route + * themselves. * * Hosted bridges that hand-roll their connection meta pass the side-car * `port`: the advertised path becomes absolute (the side-car mounts at `/`) @@ -107,7 +135,7 @@ export function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): Re */ export function resolveMcpConnectionMeta( def: DevframeDefinition, - mcp: boolean | McpRouteOptions | undefined, + mcp: McpSetting | undefined, port?: number, ): ConnectionMeta['mcp'] { const config = resolveMcpConfig(mcp ?? def.cli?.mcp) diff --git a/packages/devframe/src/adapters/cac.ts b/packages/devframe/src/adapters/cac.ts index 70d125dd1..225f5136e 100644 --- a/packages/devframe/src/adapters/cac.ts +++ b/packages/devframe/src/adapters/cac.ts @@ -8,7 +8,7 @@ // re-exported below so they live alongside the CLI adapter. import type { CAC } from 'cac' import type { H3 } from 'h3' -import type { DevframeDefinition, McpRouteOptions } from '../types/devframe' +import type { DevframeDefinition, McpSetting } from '../types/devframe' import process from 'node:process' import cac from 'cac' import { colors as c } from 'devframe/utils/colors' @@ -27,17 +27,13 @@ export interface CreateCacOptions { * Expose a route-based MCP server alongside the dev server, speaking the * MCP Streamable-HTTP transport at `__mcp`. Whether to expose MCP is * a hosting decision made at the CLI assembly stage, so it lives here rather - * than on the definition. + * than on the definition. When unset, falls back to the definition's + * deprecated `cli.mcp`, then to the `'auto'` default (mount once the agent + * surface is non-empty). See {@link McpSetting}. * - * - `false` / omitted (default): no MCP route is mounted. - * - `true`: mount at the default `__mcp` route with the loopback origin gate. - * - {@link McpRouteOptions}: customise the route path, origin allow-list, and - * opt into an identity check. - * - * The `--mcp` / `--no-mcp` flags override this per run. Falls back to the - * definition's deprecated `cli.mcp` when unset. + * The `--mcp` / `--no-mcp` flags override this per run. */ - mcp?: boolean | McpRouteOptions + mcp?: McpSetting /** * Final CAC hook invoked after devframe's built-in subcommands and * after the definition's `cli.configure`. Use this to add app-level @@ -84,11 +80,11 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) // since the dev server only acts on an explicit `auth: false`. .option('--no-auth', 'Disable the interactive authentication gate') // Only `--mcp` is declared: CAC's `--no-*` auto-negation would inject a - // `true` default, silently enabling MCP. Declaring just `--mcp` yields the - // opt-in tri-state: absent → `undefined` (falls through to `options.mcp`, - // then `cli.mcp`), - // `--mcp` → `true`, `--no-mcp` → `false` (handled by CAC's `--no-` prefix). - .option('--mcp', 'Expose an MCP server over HTTP at /__mcp (use --no-mcp to disable)') + // `true` default, forcing the route on. Declaring just `--mcp` keeps the + // tri-state: absent → `undefined` (falls through to `options.mcp`, then + // `cli.mcp`, then the `'auto'` default), `--mcp` → `true` (mount + // unconditionally), `--no-mcp` → `false` (handled by CAC's `--no-` prefix). + .option('--mcp', 'Force the MCP route on (use --no-mcp to disable; default mounts it once agent tools exist)') // Register typed flags from the definition ahead of `cli.configure` // so authors can still override or augment via the escape hatch. @@ -111,7 +107,8 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) const port = (flags.port as number | undefined) ?? await resolveDevServerPort(d, { host, defaultPort }) // `--mcp` / `--no-mcp` map to a boolean override; when neither is passed // CAC leaves `mcp` undefined so we fall back to the assembly-stage - // `options.mcp`, and `createDevServer` falls through to `def.cli?.mcp`. + // `options.mcp`, and `createDevServer` falls through to `def.cli?.mcp`, + // then to the `'auto'` default. const mcp = (flags.mcp as boolean | undefined) ?? options.mcp await createDevServer(d, { host, diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index da68e784f..0e02914a2 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,7 +1,7 @@ import type { DevframeRpcConnection, WsOriginRegistry } from 'devframe/rpc/transports/ws-server' import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/instance-shell' -import type { DevframeDefinition, DevframeSseOptions, DevframeWsOptions, McpRouteOptions } from '../types/devframe' +import type { DevframeDefinition, DevframeSseOptions, DevframeWsOptions, McpSetting } from '../types/devframe' import type { StaticAssetsSource } from '../types/remote-assets' import type { DevframeNodeRpcSession, DevframeNodeRpcSessionMeta } from '../types/rpc' import { createServer } from 'node:http' @@ -92,11 +92,11 @@ export interface CreateDevServerOptions { auth?: boolean | DevframeAuthHandler /** * Expose a route-based MCP server on the dev server (Streamable-HTTP). - * Overrides `def.cli?.mcp`; `undefined` falls through to it. `false` - * disables the route regardless of the definition default. See - * {@link McpRouteOptions}. + * Overrides `def.cli?.mcp`; `undefined` falls through to it, then to the + * `'auto'` default (mount once the agent surface is non-empty). `false` + * disables the route regardless. See {@link McpSetting}. */ - mcp?: boolean | McpRouteOptions + mcp?: McpSetting /** * Called once per new RPC connection, right after its session is created. * Forwarded verbatim to the underlying transport binding. diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 7b7eeaa15..48062417e 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -6,8 +6,9 @@ import type { Duplex } from 'node:stream' import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeInstanceRecord } from '../node/instance-registry' import type { InstanceShellInternals, StartedServer } from '../node/instance-shell' -import type { DevframeDefinition, DevframeSetupInfo, DevframeSseOptions, DevframeWsOptions, McpRouteOptions } from '../types/devframe' +import type { DevframeDefinition, DevframeSetupInfo, DevframeSseOptions, DevframeWsOptions, McpSetting } from '../types/devframe' import type { StaticAssetsSource } from '../types/remote-assets' +import type { ResolvedMcpConfig } from './_shared' import process from 'node:process' import { resolveStaticAssetsSource } from 'devframe/utils/remote-assets' import { mountStaticHandler } from 'devframe/utils/serve-static' @@ -21,7 +22,7 @@ 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, resolveMcpConfig } from './_shared' +import { loadAutoMcpAdapter, normalizeBasePath, resolveMcpConfig } from './_shared' import { resolveDevServerPort } from './dev' export interface InitDevframeOptions { @@ -90,9 +91,10 @@ export interface InitDevframeOptions { /** * Expose a route-based MCP server (Streamable-HTTP) at `__mcp` and * advertise it in `__connection.json`. Overrides `def.cli?.mcp`; - * `undefined` falls through to it. See {@link McpRouteOptions}. + * `undefined` falls through to it, then to the `'auto'` default (mount + * once the agent surface is non-empty). See {@link McpSetting}. */ - mcp?: boolean | McpRouteOptions + mcp?: McpSetting /** * Public origin the host app is reachable at (e.g. `http://localhost:3000`), * or a getter for hosts that resolve it late. Backs the auth banner's magic @@ -304,39 +306,12 @@ 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 `__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) | undefined - 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('devframe/adapters/mcp')) - } - catch (error) { - const reason = error instanceof Error ? error.message : String(error) - throw diagnostics.DF0017({ transport: 'http', reason, cause: error }) - } - const mounted = mountMcpHttp(app, context, mcpPath, { - serverName: `${def.id} (devframe)`, - serverVersion: def.version ?? '0.0.0', - exposeSharedState: true, - authorization: mcpConfig.authorization, - allowedOrigins: mcpConfig.allowedOrigins, - }) - mcpDispose = mounted.dispose - } + const mcp = await mountMcpRoute(app, context, def, base, options.mcp ?? def.cli?.mcp ?? 'auto') return { context, - ...(mcpMeta ? { mcp: mcpMeta } : {}), - ...(mcpDispose ? { dispose: mcpDispose } : {}), + ...(mcp?.meta ? { mcp: mcp.meta } : {}), + ...(mcp?.dispose ? { dispose: mcp.dispose } : {}), } }, @@ -367,3 +342,51 @@ export function initDevframe( INSTANCE_INTERNALS.set(instance, shell.internals) return instance } + +/** + * Mount the route-based MCP server at `__mcp`, before the SPA static + * catch-all so the exact route wins. The MCP SDK is pulled in dynamically + * only when the route mounts, keeping it out of consumer bundles: `'auto'` + * mounts once `setup()` left a non-empty agent surface (an empty surface + * loads no MCP code); an explicit setting mounts unconditionally. The + * resolved config is origin-only unless it opts into a bearer/callback. + */ +async function mountMcpRoute( + app: H3, + context: DevframeNodeContext, + def: DevframeDefinition, + base: string, + setting: McpSetting, +): Promise<{ meta: ConnectionMeta['mcp'], dispose: () => Promise } | undefined> { + let module: typeof import('./mcp') | undefined + let config: ResolvedMcpConfig | undefined + if (setting === 'auto') { + module = await loadAutoMcpAdapter(context.agent) + if (module) + config = { authorization: false } + } + else { + config = resolveMcpConfig(setting) + } + if (!config) + return undefined + + const route = withoutLeadingSlash(config.path ?? DEVFRAME_MCP_ROUTE) + if (!module) { + try { + module = await importRuntimeModule('devframe/adapters/mcp') + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw diagnostics.DF0017({ transport: 'http', reason, cause: error }) + } + } + const mounted = module.mountMcpHttp(app, context, joinURL(base, route), { + serverName: `${def.id} (devframe)`, + serverVersion: def.version ?? '0.0.0', + exposeSharedState: true, + authorization: config.authorization, + allowedOrigins: config.allowedOrigins, + }) + return { meta: { path: route }, dispose: mounted.dispose } +} diff --git a/packages/devframe/src/adapters/mcp/index.ts b/packages/devframe/src/adapters/mcp/index.ts index f58347d29..7e7937903 100644 --- a/packages/devframe/src/adapters/mcp/index.ts +++ b/packages/devframe/src/adapters/mcp/index.ts @@ -5,9 +5,9 @@ // import { createMcpServer } from 'devframe/adapters/mcp' // await createMcpServer(definition, { transport: 'stdio' }) // -// Requires `@modelcontextprotocol/server` to be installed as a peer -// dependency. Importing this entry without the SDK throws at load time -// with the usual Node module-not-found error. +// The MCP SDK behind it is a regular dependency of `devframe`; first-party +// adapters still load this entry lazily (`importRuntimeModule`) so the SDK +// stays out of consumer bundle graphs. export { createMcpServer, diff --git a/packages/devframe/src/internal/index.ts b/packages/devframe/src/internal/index.ts index e1d1261ab..8ee71534e 100644 --- a/packages/devframe/src/internal/index.ts +++ b/packages/devframe/src/internal/index.ts @@ -37,7 +37,7 @@ // - `diagnostics`: devframe core's structured diagnostics instance // (`DF00xx`), so a first-party integration built outside this package can // report against the same registered codes instead of minting its own. -export { normalizeBasePath, resolveBasePath, resolveMcpConfig } from '../adapters/_shared' +export { loadAutoMcpAdapter, normalizeBasePath, resolveBasePath, resolveMcpConfig } from '../adapters/_shared' export type { ResolvedMcpConfig } from '../adapters/_shared' export { resolveClientAssets } from '../client-assets' export { coerceAgentPositionalArgs } from '../node/agent-args' diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index ad45847d7..0ca5bec8f 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -88,8 +88,8 @@ export const diagnostics = defineDiagnostics({ fix: 'Discovery tooling (`devframe connect`) will not see this instance. Check that the registry directory is writable, point `DEVFRAME_INSTANCES_DIR` at a writable directory, or set `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` to opt out of registration.', }, DF0046: { - why: (p: { reason: string }) => `\`devframe connect\` requires the optional peer dependency @modelcontextprotocol/server: ${p.reason}`, - fix: 'Install it next to devframe (e.g. `npm install @modelcontextprotocol/server`) and run `devframe connect` again.', + why: (p: { reason: string }) => `\`devframe connect\` requires the optional peer dependency @modelcontextprotocol/client: ${p.reason}`, + fix: 'Install it next to devframe (e.g. `npm install @modelcontextprotocol/client`) and run `devframe connect` again.', }, DF0047: { why: (p: { name: string, id: string, existing: string }) => diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index 9d27f8880..04236fb85 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -142,6 +142,22 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } + hasSurface(): boolean { + if (this.tools.size > 0 || this.resources.size > 0) + return true + for (const [, def] of this.context.rpc.definitions) { + if (def.agent) + return true + } + // Providers are lazy and may exist with nothing to offer (the hub's + // commands host always registers one), so an empty yield is no surface. + for (const provider of this.providers) { + if (provider().length > 0) + return true + } + return false + } + getTool(id: string): AgentTool | undefined { const plain = this.tools.get(id) if (plain) diff --git a/packages/devframe/src/node/import-runtime-module.ts b/packages/devframe/src/node/import-runtime-module.ts index daea7127e..1f056d69b 100644 --- a/packages/devframe/src/node/import-runtime-module.ts +++ b/packages/devframe/src/node/import-runtime-module.ts @@ -2,8 +2,9 @@ import { importServicePackage } from './services-install' /** * Resolve and import a package at runtime without adding it to a consumer's - * bundle graph. First-party adapters use this for optional peers whose code - * is needed only when the matching feature is enabled. + * bundle graph. First-party adapters use this for modules whose code is + * needed only when the matching feature is enabled (optional peers, and the + * MCP adapter with the SDK behind it). * * @internal */ diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index 9b992b80b..eab06080a 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -195,6 +195,14 @@ export interface DevframeAgentHost { */ list: () => AgentManifest + /** + * Whether the devframe exposes anything to agents: an `agent`-flagged RPC + * function, a registered tool or resource, or a provider currently + * yielding at least one tool. The `mcp: 'auto'` default consults this to + * decide whether a route is worth mounting. + */ + hasSurface: () => boolean + /** * Invoke any tool by id. Routes to the underlying RPC handler for * `kind === 'rpc'`, or to the registered handler for `kind === 'tool'`. diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 80bbdf2f2..bedc81e1d 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -104,9 +104,28 @@ export type McpAuthorization | ((request: Request) => boolean | Promise) | false +/** + * The route-based MCP setting accepted everywhere a host mounts a devframe + * (`cli.mcp`, `initDevframe` / `initHub` / `createDevServer` options, the + * framework kits): + * + * - `'auto'`, the default: mount the route when the devframe exposes an + * agent surface (an `agent`-flagged RPC function, or a tool / resource / + * provider registered on `ctx.agent`). An empty agent surface mounts + * nothing and loads no MCP code. + * - `true`: always mount at the default `__mcp` route. + * - `false`: never mount. + * - {@link McpRouteOptions}: always mount, with a custom route path, origin + * allow-list, or {@link McpAuthorization} identity check. + * + * A mounted route trusts same-machine callers by default (the loopback + * origin gate), exactly like `mcp: true`. + */ +export type McpSetting = boolean | 'auto' | McpRouteOptions + /** * Configuration for the route-based MCP server mounted alongside the dev - * server (opt-in via {@link DevframeCliOptions.mcp}). The endpoint speaks + * server (via {@link DevframeCliOptions.mcp}). The endpoint speaks * the MCP Streamable-HTTP transport over the same origin as the SPA, * exposing the definition's `ctx.agent` tools + shared-state resources to * external MCP clients connected to the *running* server. @@ -189,11 +208,11 @@ export interface DevframeCliOptions { * base path). It surfaces the same `ctx.agent` tools + shared-state * resources as the stdio `mcp` command, but against the live server. * - * - `false` / omitted (default): no MCP route is mounted. - * - `true`: mount at the default `__mcp` route with the loopback origin - * gate (trusting same-machine callers). - * - {@link McpRouteOptions}: customise the route path, origin allow-list, - * and opt into an {@link McpAuthorization} identity check. + * Defaults to `'auto'`: the route mounts once the devframe exposes an + * agent surface (an `agent`-flagged RPC, a registered tool / resource). + * See {@link McpSetting} for the full contract, and + * {@link McpRouteOptions} for the route path, origin allow-list, and + * {@link McpAuthorization} identity check. * * The `--mcp` / `--no-mcp` CLI flags override this per run. Whether to expose * MCP is a hosting decision, so programmatic hosts pass it to @@ -204,7 +223,7 @@ export interface DevframeCliOptions { * This field is still read as a fallback, and will be removed in a future * release. */ - mcp?: boolean | McpRouteOptions + mcp?: McpSetting /** * Author's SPA dist, served as the devframe's UI. * diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 6a9829f24..1a58e82fe 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -103,11 +103,14 @@ describe('initHub', () => { try { await hubRef.ready // One shared socket, advertised hub-base-absolute so the same meta - // resolves correctly from the hub base and from every frame base. + // resolves correctly from the hub base and from every frame base. The + // frames register agent tools, so the `'auto'` default also mounts + // the aggregate MCP route. expect(hubRef.connectionMeta()).toEqual({ backend: 'websocket', websocket: { path: '/__devframes/__ws' }, sse: { path: '/__devframes/__sse' }, + mcp: { path: '__mcp' }, }) // Frame SPAs under /. @@ -321,16 +324,44 @@ describe('initHub', () => { } }) + it('aggregate MCP omitted: mounts once a mounted frame exposes agent tools', async () => { + const wsPort = await getPort({ port: 18233, host: '127.0.0.1' }) + const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, devframes: [makeFrame('alpha')] }) + + try { + await hub.ready + expect(hub.connectionMeta().mcp).toEqual({ path: '__mcp' }) + } + finally { + await hub.close() + } + }) + + it('aggregate MCP omitted: an empty agent surface mounts nothing', async () => { + const wsPort = await getPort({ port: 18234, host: '127.0.0.1' }) + // No devframes, no agent-flagged hub commands: nothing to serve an agent. + const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort } }) + + try { + await hub.ready + expect(hub.connectionMeta().mcp).toBeUndefined() + } + finally { + await hub.close() + } + }) + it('warns (DF8005) when a mounted devframe asks for MCP but the hub MCP is off', async () => { const wsPort = await getPort({ port: 18235, host: '127.0.0.1' }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - // The hub has no `mcp`, but `beta` declares `cli.mcp: true`, so the hub's + // The hub turned MCP off, but `beta` declares `cli.mcp: true`; the hub's // single aggregate route governs MCP, so beta's request is a no-op and warns. const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, + mcp: false, devframes: [makeFrame('alpha'), { ...makeFrame('beta'), cli: { mcp: true } }], }) @@ -382,6 +413,7 @@ describe('initHub', () => { backend: 'websocket', websocket: { path: '/__devframes/__ws' }, sse: { path: '/__devframes/__sse' }, + mcp: { path: '__mcp' }, }) await new Promise(resolve => server.listen(port, host, resolve)) diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index dcaae3637..98cee412f 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -31,8 +31,8 @@ export const diagnostics = defineDiagnostics({ fix: 'Ids become route segments, so they may only contain letters, digits, `_`, `-`, and `.`; `:` and `*` are route-pattern markers to the underlying router, and `/` would escape the segment. Set a route-safe `id` on the definition (e.g. `my_plugin` instead of `my:plugin`).', }, DF8005: { - why: (p: { id: string }) => `Devframe "${p.id}" requests an MCP route, but the hub's aggregate MCP is off, so its tools are not exposed over MCP.`, - fix: 'A hub exposes one aggregate MCP endpoint over every mounted devframe, so per-devframe `mcp` settings are ignored. Enable the hub\'s own MCP (pass `mcp` to `initHub`) to surface this devframe\'s tools, or drop `mcp` from the devframe to silence this warning.', + why: (p: { id: string }) => `Devframe "${p.id}" requests an MCP route, but the hub's aggregate MCP is off (\`mcp: false\`), so its tools are not exposed over MCP.`, + fix: 'A hub exposes one aggregate MCP endpoint over every mounted devframe, so per-devframe `mcp` settings are ignored. Drop `mcp: false` from `initHub` (the `\'auto\'` default mounts the aggregate route once agent tools exist) to surface this devframe\'s tools, or drop `mcp` from the devframe to silence this warning.', }, DF8100: { why: (p: { id: string }) => `Dock with id "${p.id}" is already registered`, diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 9d06d943f..2c9f071f6 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -1,7 +1,7 @@ -import type { DevframeInstanceRecord, InstanceShellApi } from 'devframe/internal' +import type { DevframeInstanceRecord, InstanceShellApi, ResolvedMcpConfig } from 'devframe/internal' import type { DevframeAuthHandler } from 'devframe/node/auth' import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server' -import type { ConnectionMeta, DevframeDefinition, DevframeServiceInput, DevframeSseOptions, DevframeStorageScope, DevframeWsOptions, McpRouteOptions } from 'devframe/types' +import type { ConnectionMeta, DevframeDefinition, DevframeServiceInput, DevframeSseOptions, DevframeStorageScope, DevframeWsOptions, McpRouteOptions, McpSetting } from 'devframe/types' import type { Buffer } from 'node:buffer' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' import type { Duplex } from 'node:stream' @@ -12,7 +12,7 @@ import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import process from 'node:process' import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUTE } from 'devframe/constants' -import { createH3DevframeHost, createInstanceShell, importRuntimeModule, resolveInstanceRegister, resolveMcpConfig } from 'devframe/internal' +import { createH3DevframeHost, createInstanceShell, importRuntimeModule, loadAutoMcpAdapter, resolveInstanceRegister, resolveMcpConfig } from 'devframe/internal' import { mountStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' import { resolve } from 'pathe' @@ -255,14 +255,16 @@ export interface InitHubOptions { /** * Expose the **aggregate** MCP endpoint at `__mcp`: one * Streamable-HTTP server over the shared context's whole tool registry - * (ids are already namespaced per plugin). Disabled by default; `true` - * mounts it with the loopback origin gate (trusting same-machine callers), - * an object opts into an {@link McpRouteOptions.authorization} identity - * check. A mounted devframe's own `mcp` setting is ignored: the hub's - * aggregate route covers them all (`DF8005` warns when one asks for MCP - * while this is off). + * (ids are already namespaced per plugin). Defaults to `'auto'`: the route + * mounts once any mounted devframe (or an agent-flagged hub command) + * exposes an agent surface. `true` mounts it + * unconditionally with the loopback origin gate (trusting same-machine + * callers), an object opts into an {@link McpRouteOptions.authorization} + * identity check, `false` keeps it off. A mounted devframe's own `mcp` + * setting is ignored: the hub's aggregate route covers them all (`DF8005` + * warns when one asks for MCP while this is `false`). */ - mcp?: boolean | McpRouteOptions + mcp?: McpSetting /** * Public origin the host app is reachable at, or a getter. Derived lazily * from the first request when omitted. @@ -480,8 +482,9 @@ async function mountDevframes( if (!/^[\w.-]+$/.test(def.id)) throw diagnostics.DF8004({ id: def.id }) // A hub exposes one aggregate MCP route over every mounted devframe, so a - // devframe's own `mcp` request is only meaningful when the hub's own MCP is - // enabled. Warn when it isn't, rather than silently dropping the devframe's + // devframe's own `mcp` request is only meaningful when the hub's own MCP + // can mount (an explicit setting or the `'auto'` default). Warn when the + // hub turned it off, rather than silently dropping the devframe's // intended agent surface. if (!hubMcpEnabled && def.cli?.mcp) diagnostics.DF8005({ id: def.id }) @@ -566,7 +569,7 @@ export function initHub(options: InitHubOptions): HubInstance { // collection alongside every devframe's own declared services. for (const input of options.services ?? []) void ctx.services.install(input) - const setups = await mountDevframes(ctx, devframes, base, frames, !!options.mcp) + const setups = await mountDevframes(ctx, devframes, base, frames, options.mcp !== false) // Construct every collected service once, then run the setups, so a // devframe's setup consumes services (its own or another devframe's) @@ -601,17 +604,27 @@ export function initHub(options: InitHubOptions): HubInstance { manifestState.mutate(() => manifest) // Aggregate MCP: one Streamable-HTTP endpoint over the shared - // context's whole registry (tool ids are namespaced per plugin, and the - // wire-name collision policy is `createMcpFetchHandler`'s own). The - // resolved config trusts same-machine callers by default (origin-only); - // an object config opts into a bearer/callback identity check. - const mcpConfig = resolveMcpConfig(options.mcp) + // context's whole registry. The omitted `'auto'` default mounts when + // the devframes (or an agent-flagged hub command) left a non-empty + // agent surface; an empty surface loads no MCP code. Origin-only + // unless the config opts into a bearer/callback. + const mcpSetting = options.mcp ?? 'auto' + let mcpModule: typeof import('devframe/adapters/mcp') | undefined + let mcpConfig: ResolvedMcpConfig | undefined + if (mcpSetting === 'auto') { + mcpModule = await loadAutoMcpAdapter(ctx.agent) + if (mcpModule) + mcpConfig = { authorization: false } + } + else { + mcpConfig = resolveMcpConfig(mcpSetting) + } if (!mcpConfig) return { context: ctx } const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE) - const { mountMcpHttp } = await importRuntimeModule('devframe/adapters/mcp') - const mounted = mountMcpHttp(app, ctx, joinURL(base, mcpRoute), { + mcpModule ??= await importRuntimeModule('devframe/adapters/mcp') + const mounted = mcpModule.mountMcpHttp(app, ctx, joinURL(base, mcpRoute), { serverName: options.name ?? 'devframes-hub', serverVersion: options.version ?? '0.0.0', exposeSharedState: true, diff --git a/packages/next/src/handler.ts b/packages/next/src/handler.ts index 929541620..42a0206f2 100644 --- a/packages/next/src/handler.ts +++ b/packages/next/src/handler.ts @@ -35,8 +35,9 @@ export interface CreateDevframeNextHandlerOptions { * Expose the route-based MCP server (Streamable-HTTP) at `__mcp`, * on the Next app's own origin, through the same catch-all route as the * SPA, and advertise it in the handler's `__connection.json`. Overrides - * `def.cli?.mcp`, `undefined` falls through to it, `false` disables the - * route regardless. + * `def.cli?.mcp`, `undefined` falls through to it, then to the `'auto'` + * default (mount once the agent surface is non-empty); `false` disables + * the route regardless. */ mcp?: InitDevframeOptions['mcp'] /** diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index 721e8f95a..62107e9e6 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -81,8 +81,8 @@ export interface DevframeNextHost { * Serve an MCP Streamable-HTTP endpoint at `path` **in-process**, on the * Next app's own origin, through the same catch-all route as the SPAs (the * `/_next/mcp` shape). Built on `createMcpFetchHandler` from - * `devframe/adapters/mcp` (imported lazily: `@modelcontextprotocol/server` - * stays an optional peer). Advertise the path in the connection meta + * `devframe/adapters/mcp` (imported lazily, so the MCP SDK stays out of + * the app's bundle graph). Advertise the path in the connection meta * (`mcp: { path }`, same origin, no port) and register the instance via * `registerDevframeInstance` so `devframe connect` can discover it. */ diff --git a/packages/next/src/hub.ts b/packages/next/src/hub.ts index b43b7f233..cd6331558 100644 --- a/packages/next/src/hub.ts +++ b/packages/next/src/hub.ts @@ -50,9 +50,11 @@ export interface NextDevframeHubOptions { /** The hub's single auth gate. Gates by default; `false` opts out. */ auth?: InitHubOptions['auth'] /** - * Expose the aggregate MCP endpoint at `__mcp`. Disabled by default; - * `true` mounts it with the loopback origin gate (trusting same-machine - * callers), or pass an object to opt into an `authorization` identity check. + * Expose the aggregate MCP endpoint at `__mcp`. Defaults to `'auto'` + * (mount once any mounted devframe exposes an agent surface); `true` + * mounts it unconditionally with the + * loopback origin gate (trusting same-machine callers), an object opts + * into an `authorization` identity check, `false` keeps it off. */ mcp?: InitHubOptions['mcp'] /** Public origin the Next app is reachable at. Default: derived from `PORT`. */ @@ -71,8 +73,8 @@ export interface NextDevframeHubOptions { * Build a devframes-hub for a Next.js App Router app: one `initHub()` call * mounting every devframe under `/` behind one web-standard * `handler`, with the RPC socket on a side-car (Next routes can't accept WS - * upgrades) and the aggregate MCP route opt-in (pass `mcp` to enable it). The - * UI defaults to + * upgrades) and the aggregate MCP route mounted on demand (the `'auto'` + * default; pass `mcp` to force or disable it). The UI defaults to * `@devframes/hub-ui`'s `createUi()`, loaded lazily via a bundler-ignored * dynamic `import()` so its asset lookups resolve at request time; pass `ui` * to swap it or `ui: false` for a headless hub. @@ -96,9 +98,10 @@ export async function createNextDevframeHub(options: NextDevframeHubOptions = {} auth: options.auth, /** Next route handlers can't accept WS upgrades, so always a side-car socket. */ ws: options.port != null ? { port: options.port } : { sidecar: true }, - // MCP is opt-in: `mcp: true` is origin-only (trusting same-machine - // callers), `mcp: { authorization }` adds an identity check. Undefined - // leaves the aggregate route unmounted. + // `mcp: true` is origin-only (trusting same-machine callers), + // `mcp: { authorization }` adds an identity check. Undefined falls + // through to `initHub`'s `'auto'` default: the aggregate route mounts + // once the mounted devframes expose agent tools. ...(options.mcp !== undefined ? { mcp: options.mcp } : {}), ...(ui ? { ui } : {}), ...(options.renderers ? { renderers: options.renderers } : {}), diff --git a/packages/vite/src/hub.ts b/packages/vite/src/hub.ts index 101563d4c..0c15f78de 100644 --- a/packages/vite/src/hub.ts +++ b/packages/vite/src/hub.ts @@ -63,7 +63,9 @@ export interface ViteDevframeHubOptions { */ auth?: InitHubOptions['auth'] /** - * Expose the aggregate MCP endpoint at `__mcp`. + * Expose the aggregate MCP endpoint at `__mcp`. Defaults to `'auto'` + * (mount once any mounted devframe exposes an agent surface); `true` + * forces it on, `false` off. */ mcp?: InitHubOptions['mcp'] /** Publish this hub in the global instance registry. Default: off. */ diff --git a/packages/vite/src/single.ts b/packages/vite/src/single.ts index 1a5c3edf9..3e1c3fc5d 100644 --- a/packages/vite/src/single.ts +++ b/packages/vite/src/single.ts @@ -1,4 +1,4 @@ -import type { DevframeDefinition, McpRouteOptions } from 'devframe' +import type { DevframeDefinition, McpSetting } from 'devframe' import type { DevframeInstance } from 'devframe/initiate' import type { DevframeAuthHandler } from 'devframe/node/auth' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' @@ -115,9 +115,10 @@ export interface DevframeViteBridgeOptions { * Expose the bridge's route-based MCP server (Streamable-HTTP) at * `__mcp` (on the Vite app's own origin) and advertise it in the * bridge's `__connection.json`. Overrides `def.cli?.mcp`, `undefined` - * falls through to it, `false` disables the route regardless. + * falls through to it, then to the `'auto'` default (mount once the agent + * surface is non-empty); `false` disables the route regardless. */ - mcp?: boolean | McpRouteOptions + mcp?: McpSetting } /** diff --git a/packages/vite/test/single.test.ts b/packages/vite/test/single.test.ts index c1a38bf22..f991349c7 100644 --- a/packages/vite/test/single.test.ts +++ b/packages/vite/test/single.test.ts @@ -180,7 +180,9 @@ describe('devframeViteBridge (bridge mode mcp)', () => { const meta = await (await fetch(`http://${host}:${vitePort}/__vite-bridge-test/__connection.json`)).json() // Zero extra ports: a same-origin relative route on Vite's own server. expect(meta.websocket).toEqual({ path: '__ws' }) - expect(meta.mcp).toBeUndefined() + // The definition registers an agent tool, so the omitted `mcp` defaults + // to `'auto'` and mounts the route on Vite's own origin too. + expect(meta.mcp).toEqual({ path: '__mcp' }) const rpc = createRpcClient({}, { channel: createWsRpcChannel({ url: `ws://${host}:${vitePort}/__vite-bridge-test/__ws` }), diff --git a/plans/002-authenticate-mcp-http.md b/plans/002-authenticate-mcp-http.md deleted file mode 100644 index ebca15c1a..000000000 --- a/plans/002-authenticate-mcp-http.md +++ /dev/null @@ -1,204 +0,0 @@ -# Plan 002: Require authentication on route-based MCP - -> **Executor instructions**: Follow this plan step by step. Run every verification command and confirm the expected result before moving on. If a STOP condition occurs, stop and report it instead of weakening authorization. Update this plan's row in `plans/README.md` when complete unless a reviewer owns the index. -> -> **Drift check (run first)**: `git diff --stat 2d978f84..HEAD -- packages/devframe/src/adapters packages/devframe/src/types/devframe.ts packages/devframe/src/cli packages/devframe/src/node/diagnostics.ts packages/hub/src/node packages/next/src packages/next/test packages/vite/test/single.test.ts tests/optional-mcp-bundles.test.ts examples/files-inspector/src/devframe.ts examples/hub-next docs/content/1.guide/14.security.md docs/content/1.guide/18.hub-initiate.md docs/content/2.adapters/7.mcp.md docs/content/3.frameworks/1.vite.md docs/content/3.frameworks/3.next.md docs/content/6.errors tests/__snapshots__/tsnapi` -> Stop if MCP transport or route option interfaces have materially changed. - -## Status - -- **Priority**: P1 -- **Effort**: M -- **Risk**: MED -- **Depends on**: `plans/001-pin-github-actions.md` -- **Category**: security -- **Planned at**: commit `2d978f84`, 2026-09-01 - -## Why this matters - -The MCP HTTP route currently treats a caller-provided `Origin` as authorization. `Origin` is useful for browser DNS-rebinding and cross-site request protection, but native clients can supply any value. A reachable route can therefore invoke privileged agent tools without proving identity; `@devframes/next/hub` enables this route by default. - -## Current state - -- `packages/devframe/src/adapters/mcp/fetch.ts` is the web-standard HTTP boundary. -- `packages/devframe/src/adapters/mcp/http.ts` mounts that boundary into h3. -- `packages/devframe/src/adapters/initiate.ts`, `packages/hub/src/node/initiate.ts`, and `packages/next/src/host.ts` mount route-based MCP. -- `packages/devframe/src/types/devframe.ts:94-113` defines `McpRouteOptions` with only `path` and `allowedOrigins`. -- `packages/devframe/src/cli/connect.ts:246-272` creates native MCP transports with only an `Origin` header. -- `packages/devframe/src/cli/main.ts:13-24` constructs the native gateway. - -The vulnerable boundary is: - -```ts -// packages/devframe/src/adapters/mcp/fetch.ts:75-85 -const origin = req.headers.get('origin') ?? undefined -if (allowedOrigins !== false && (origin === undefined || !isAllowedOrigin(origin, allowedOrigins ?? []))) - return new Response('Forbidden: origin required', { status: 403 }) -return handler.fetch(req) -``` - -Tool invocation occurs at `packages/devframe/src/adapters/mcp/build-server.ts:287-305`. Keep the origin check as a separate defense; do not replace it with authentication. Node-side failures use coded diagnostics, and public API changes require fresh `tsnapi` snapshots after a build. - -## Target authorization contract - -Implement this exact, independent MCP authorization model: - -- Add `McpRouteOptions.authorization` with three accepted values: a non-empty bearer token string, a callback `(request: Request) => boolean | Promise`, or explicit `false` for an origin-only local opt-out. -- `mcp: true` reads its bearer from `DEVFRAME_MCP_AUTH_TOKEN`. Missing/empty configuration fails startup with a new coded diagnostic instead of mounting a route. -- An object MCP config must include `authorization`; omission fails with the same diagnostic. -- The origin gate runs first and authorization second. Missing/invalid bearer credentials return `401` plus `WWW-Authenticate: Bearer`; disallowed origins remain `403`. -- Compare configured token strings in constant time. A callback cannot disable origin checking. -- `devframe connect` reads `DEVFRAME_MCP_AUTH_TOKEN` by default. `ConnectServerOptions.authToken` accepts either one token string or `(record: DevframeInstanceRecord) => string | undefined` for callers connecting to instances with distinct credentials. -- `@devframes/next/hub` changes its omitted MCP default from enabled to disabled. Callers opt in with an explicit authorization policy. -- Never place an MCP token in URLs, connection metadata, instance registry records, logs, diagnostics, tool payloads, or command-line arguments. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---|---|---| -| MCP tests | `pnpm exec vitest run packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts packages/devframe/src/adapters/__tests__/initiate.test.ts` | all tests pass | -| Host tests | `pnpm exec vitest run packages/hub/src/node/__tests__/initiate.test.ts packages/next/test/handler.test.ts` | all tests pass | -| Compatibility tests | `pnpm exec vitest run packages/devframe/src/adapters/__tests__/dev.test.ts packages/vite/test/single.test.ts tests/optional-mcp-bundles.test.ts examples/hub-next/tests/next-devframe-hub.test.ts` | all tests pass | -| Typechecks | `pnpm --filter devframe typecheck && pnpm --filter @devframes/hub typecheck && pnpm --filter @devframes/next typecheck` | exit 0 | -| API snapshots | `pnpm build && pnpm exec vitest run tests/exports.test.ts -u` | only intended public snapshots change | -| Full verification | `pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build` | every command exits 0 | - -## Scope - -**In scope**: - -- `packages/devframe/src/adapters/mcp/fetch.ts` -- `packages/devframe/src/adapters/mcp/http.ts` -- `packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts` -- `packages/devframe/src/adapters/_shared.ts` -- `packages/devframe/src/adapters/cac.ts` -- `packages/devframe/src/adapters/initiate.ts` -- `packages/devframe/src/adapters/__tests__/initiate.test.ts` -- `packages/devframe/src/adapters/__tests__/dev.test.ts` -- `packages/devframe/src/types/devframe.ts` -- `packages/devframe/src/cli/connect.ts` -- `packages/devframe/src/cli/main.ts` -- New `packages/devframe/src/cli/connect.test.ts` -- `packages/devframe/src/node/diagnostics.ts` -- One new `docs/content/6.errors/DFxxxx.md` for missing MCP authorization -- `packages/hub/src/node/initiate.ts` -- `packages/hub/src/node/__tests__/initiate.test.ts` -- `packages/next/src/host.ts` -- `packages/next/src/hub.ts` -- `packages/next/test/handler.test.ts` -- `packages/vite/test/single.test.ts` -- `tests/optional-mcp-bundles.test.ts` -- `examples/files-inspector/src/devframe.ts` -- `examples/hub-next/src/client/devframe/next-devframe-hub.ts` -- `examples/hub-next/tests/next-devframe-hub.test.ts` -- `tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts` -- `tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts` -- `tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts` -- `tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts` -- `tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts` -- `tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts` -- `tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts` -- `tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts` -- `docs/content/1.guide/14.security.md` -- `docs/content/1.guide/18.hub-initiate.md` -- `docs/content/2.adapters/7.mcp.md` -- `docs/content/3.frameworks/1.vite.md` -- `docs/content/3.frameworks/3.next.md` - -**Out of scope**: - -- RPC/browser authentication and remote-dock tokens. -- Shared-state filtering; Plan 003 owns it. -- MCP tool argument validation and safety annotations. -- Stdio MCP's local transport. -- Compatibility code that silently preserves unauthenticated HTTP behavior. - -## Git workflow - -- Use the assigned worktree; branch if needed: `fix/authenticate-mcp-http`. -- Commit style: `fix(devframe): authenticate HTTP MCP requests`. -- Do not push/open a PR unless instructed by the operator. - -## Steps - -### Step 1: Add the MCP authorization policy - -Add `authorization` to `McpRouteOptions` and matching MCP handler options. Implement one internal authorization function in `fetch.ts`: parse exactly one `Authorization: Bearer ` credential for string policies, compare it with the configured value using the existing crypto-token utility, invoke callback policies, and bypass identity only for explicit `false`. Reject malformed, empty, or multiple credentials without logging them. - -Define `mcp: true` as shorthand for `authorization: process.env.DEVFRAME_MCP_AUTH_TOKEN`. Add the next sequential `DF` diagnostic and required error page when the shorthand has no token or an object omits authorization. - -**Verify**: `pnpm --filter devframe typecheck` -> exit 0. - -### Step 2: Enforce both HTTP gates - -In `createMcpFetchHandler.handle`, retain origin validation, then authorize before calling `handler.fetch(req)`. Add tests for allowed Origin with no/wrong/correct bearer, disallowed Origin with correct bearer, callback allow/deny, and explicit `authorization: false`. - -Use generic response bodies. No response may reveal whether a supplied token was close to correct. - -**Verify**: `pnpm exec vitest run packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts` -> all tests pass. - -### Step 3: Wire every route and disable the Next default - -Propagate the MCP authorization policy through `initDevframe`, `initHub`, and the Next host. The behavior matrix is: - -| MCP setting | HTTP behavior | -|---|---| -| omitted/`false` | route absent | -| `true` + non-empty environment token | requires that bearer | -| `true` + missing token | coded startup failure; route absent | -| object + token | requires that bearer | -| object + callback | delegates identity to callback | -| object + `authorization: false` | explicit origin-only opt-out | - -Change `createNextDevframeHub` from `mcp: options.mcp ?? true` to the secure disabled default. Update existing hub/Next tests that currently expect Origin-only success. - -**Verify**: `pnpm exec vitest run packages/devframe/src/adapters/__tests__/initiate.test.ts packages/hub/src/node/__tests__/initiate.test.ts packages/next/test/handler.test.ts` -> all tests pass. - -### Step 4: Preserve the native gateway through explicit credentials - -Add `ConnectServerOptions.authToken?: string | ((record: DevframeInstanceRecord) => string | undefined)`. `main.ts` passes `process.env.DEVFRAME_MCP_AUTH_TOKEN`; do not add a CLI flag because command-line secrets are process-visible. Resolve the token for each record and pass it into `withInstanceClient`, which sets the Authorization header. An unauthorized instance reports auth-required and never retries without authentication. - -Add focused tests with fake SDK transports or the smallest extracted header helper. Prove the token is in request headers but absent from indexed results and formatted errors. - -**Verify**: `pnpm exec vitest run packages/devframe/src/cli/connect.test.ts` -> all tests pass. - -### Step 5: Update docs and API snapshots - -Update the scoped docs to distinguish origin validation from identity, explain `DEVFRAME_MCP_AUTH_TOKEN`, document callback/explicit-false policies, and state that the Next hub no longer enables MCP by default. Update runnable examples: use an explicit environment-backed authorization policy where they demonstrate MCP; use explicit `authorization: false` only in test fixtures that are provably loopback-bound. Follow repository terminology: use “node side”, “RPC client”, and “host framework”; avoid bare “client”, “server”, and “host” in prose. - -Run `pnpm build && pnpm exec vitest run tests/exports.test.ts -u`, inspect the diff, and keep only listed snapshots whose public types actually changed. - -**Verify**: `pnpm test` -> build, tests, and API snapshots pass. - -## Test plan - -- Allowed Origin + no/invalid bearer -> 401. -- Disallowed Origin + valid bearer -> 403. -- Valid configured bearer -> initialize/list/call succeeds. -- Callback policy allow/deny -> success/401. -- Explicit `authorization: false` + allowed Origin -> succeeds. -- `mcp: true` without environment token -> coded startup failure. -- Next hub omitted default -> no route. -- Native gateway forwards the selected per-instance bearer and never serializes it. - -## Done criteria - -- [ ] No route reaches `handler.fetch(req)` without passing both applicable gates. -- [ ] Every route mount uses an explicit MCP authorization policy. -- [ ] `Origin` is documented and tested as request hardening, not identity. -- [ ] The Next hub defaults MCP to disabled. -- [ ] Credentials occur only in configuration and Authorization headers. -- [ ] Targeted tests, listed typechecks, API snapshots, and full verification pass. -- [ ] Only in-scope files and `plans/README.md` changed. - -## STOP conditions - -- A supported connector can be preserved only by publishing a bearer in metadata, URLs, registry data, logs, or command arguments. -- Route authorization cannot be wired without coupling it to browser/RPC token storage. -- A host framework bypasses `createMcpFetchHandler` and would remain unauthenticated. -- The token resolver would need to expose credentials through MCP tool arguments/results. -- API snapshot changes include unrelated exports. - -## Maintenance notes - -Every future HTTP transport must keep identity authorization separate from Origin/Host validation. Reviewers should trace all `mountMcpHttp` and `createMcpFetchHandler` call sites and verify credentials never enter diagnostics. Multi-instance callers should use the resolver form rather than sharing one token unless shared configuration is intentional. diff --git a/plans/003-enforce-mcp-state-policy.md b/plans/003-enforce-mcp-state-policy.md deleted file mode 100644 index cd3c9e133..000000000 --- a/plans/003-enforce-mcp-state-policy.md +++ /dev/null @@ -1,107 +0,0 @@ -# Plan 003: Enforce shared-state exposure policy on direct MCP reads - -> **Executor instructions**: Follow this plan step by step and run each verification command. Stop on a listed STOP condition. Update this plan's status row in `plans/README.md` when complete unless a reviewer owns the index. -> -> **Drift check (run first)**: `git diff --stat 2d978f84..HEAD -- packages/devframe/src/adapters/mcp/build-server.ts packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts` -> Stop if shared-state resource registration has materially changed. - -## Status - -- **Priority**: P1 -- **Effort**: S -- **Risk**: LOW -- **Depends on**: `plans/002-authenticate-mcp-http.md` -- **Category**: security -- **Planned at**: commit `2d978f84`, 2026-09-01 - -## Why this matters - -MCP resource listing honors `exposeSharedState`, but direct `devframe://state/` reads do not. A caller that knows a filtered key can bypass the policy. One shared predicate must govern listing, the built-in read tool, and direct resource reads. - -## Current state - -`packages/devframe/src/adapters/mcp/build-server.ts:202-205` already centralizes policy conversion: - -```ts -function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolean)) { - if (exposeSharedState === false) - return undefined - return typeof exposeSharedState === 'function' ? exposeSharedState : () => true -} -``` - -The list path applies the predicate at lines 343-355, while the direct read at lines 377-385 calls `ctx.rpc.sharedState.get(parsed.key)` without checking it. `readStateResult` at lines 230-241 demonstrates the existing deny behavior and coded diagnostic `DF0048`. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---|---|---| -| Targeted test | `pnpm exec vitest run packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts` | all tests pass | -| Typecheck | `pnpm --filter devframe typecheck` | exit 0 | -| Full verification | `pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build` | every command exits 0 | - -## Scope - -**In scope**: - -- `packages/devframe/src/adapters/mcp/build-server.ts` -- `packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts` - -**Out of scope**: - -- MCP HTTP authentication from Plan 002. -- Changing default `exposeSharedState` values at adapter call sites. -- Filtering registered agent resources; this finding concerns shared-state projections only. -- New diagnostics unless existing `DF0048` cannot represent the denial. - -## Git workflow - -- Work in the assigned worktree; branch if needed: `fix/mcp-state-policy`. -- Commit style: `fix(devframe): enforce MCP state exposure policy`. -- Do not push/open a PR unless instructed. - -## Steps - -### Step 1: Reuse one predicate in resource handlers - -Resolve `sharedStateFilter(exposeSharedState)` once inside `registerResourceHandlers`. Use it for both list and read. For `parsed.kind === 'state'`, reject when the predicate is absent or returns false before calling `sharedState.get`. Match the existing `DF0048` denial used by `readStateResult`. - -Do not silently return an empty value and do not reveal whether a denied key exists. - -**Verify**: `pnpm --filter devframe typecheck` -> exit 0. - -### Step 2: Add bypass regression tests - -Generalize the `bootPair` test helper so tests can supply `exposeSharedState`. Add cases proving: - -- `false` omits state resources and rejects a direct URI read. -- A predicate lists/reads allowed keys and rejects a known denied key by direct URI. -- `true` retains current list/read behavior. -- The built-in state-read tool and resource path agree for the same policy. - -Use opaque key names; do not embed sensitive-looking values in tests. - -**Verify**: `pnpm exec vitest run packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts` -> all tests pass. - -## Test plan - -Model new tests after `mcp-server.test.ts:234-255`. Assert both listing and direct reads, since testing only the list would miss the vulnerability. - -## Done criteria - -- [ ] One predicate controls every shared-state MCP projection. -- [ ] Denied direct reads fail before storage access. -- [ ] Tests cover `false`, predicate allow/deny, and `true`. -- [ ] Targeted test and typecheck pass. -- [ ] Full repository verification passes. -- [ ] Only in-scope files and `plans/README.md` changed. - -## STOP conditions - -- Plan 002 changed the resource registration architecture enough that the excerpts no longer match. -- A denied read cannot use `DF0048` without exposing key existence; report before adding an ad-hoc error. -- Registered non-state resources unexpectedly depend on `exposeSharedState`. - -## Maintenance notes - -Any future shared-state transport must apply the exposure predicate at the read operation, not only during discovery. Reviewers should search for all `parsed.kind === 'state'` and `sharedState.get` calls in the MCP adapter. diff --git a/plans/004-contain-remote-assets.md b/plans/004-contain-remote-assets.md deleted file mode 100644 index f59cf957b..000000000 --- a/plans/004-contain-remote-assets.md +++ /dev/null @@ -1,116 +0,0 @@ -# Plan 004: Contain remote asset materialization inside its target directory - -> **Executor instructions**: Follow this plan step by step and run each verification command. Stop rather than improvising if provider path semantics differ from the assumptions below. Update `plans/README.md` when complete unless a reviewer owns the index. -> -> **Drift check (run first)**: `git diff --stat 2d978f84..HEAD -- packages/devframe/src/utils/remote-assets.ts packages/devframe/src/utils/remote-assets.test.ts` -> If either file changed, compare the materialization loop and successful fixture with the excerpts below; stop on a mismatch. - -## Status - -- **Priority**: P1 -- **Effort**: S -- **Risk**: LOW -- **Depends on**: none -- **Category**: security -- **Planned at**: commit `2d978f84`, 2026-09-01 - -## Why this matters - -`RemoteAssetsStore.materialize()` trusts provider-listed paths after checking only a string prefix. A compromised provider can list a prefixed path whose suffix traverses outside the requested build directory. Build materialization must reject unsafe list entries before fetching or writing them. - -## Current state - -- `packages/devframe/src/utils/remote-assets.ts` implements provider listing, caching, serving, and build materialization. -- `packages/devframe/src/utils/remote-assets.test.ts` has fake jsDelivr/unpkg providers and an existing successful materialization test at lines 182-189. - -Vulnerable loop: - -```ts -// packages/devframe/src/utils/remote-assets.ts:343-356 -for (const filePath of files.filter(f => f.startsWith(prefix))) { - const target = join(targetDir, filePath.slice(prefix.length)) - const url = provider.fileUrl(normalized.package, normalized.version, filePath) - // fetch, mkdir, writeFile(target, ...) -} -``` - -Use existing coded diagnostic `DF0064` through the local `fail()` helper. Do not add raw node-side errors. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---|---|---| -| Targeted test | `pnpm exec vitest run packages/devframe/src/utils/remote-assets.test.ts` | all tests pass | -| Typecheck | `pnpm --filter devframe typecheck` | exit 0 | -| Full verification | `pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build` | every command exits 0 | - -## Scope - -**In scope**: - -- `packages/devframe/src/utils/remote-assets.ts` -- `packages/devframe/src/utils/remote-assets.test.ts` - -**Out of scope**: - -- CDN integrity/signature verification. -- Cache storage permissions and cache eviction. -- Request-path handling in `serve()`; it already has separate traversal tests. -- Provider API redesign. - -## Git workflow - -- Branch if needed: `fix/remote-assets-traversal`. -- Commit style: `fix(devframe): contain remote asset materialization`. -- Do not push/open a PR unless instructed. - -## Steps - -### Step 1: Validate every listed path - -Before constructing a URL or issuing a fetch, require each listed path to: - -- be a package-relative provider path, not an absolute path or URL; -- contain only `/` separators; reject any backslash rather than normalizing it; -- either lie outside the configured `prefix` and remain ignored, or lie beneath that prefix on a segment boundary and pass the remaining checks; -- have a non-empty relative suffix; -- contain no traversal after normalization. - -Resolve the final destination against `resolve(targetDir)` and require exact containment (`target === root` is not a writable file; descendants must start with `root + sep`). Account for Windows separators by using Node path primitives for filesystem containment rather than string `/` assumptions. - -Continue ignoring ordinary package files outside the selected prefix (`package.json` is present in the existing valid fixture). Reject an entry that claims to be beneath the selected prefix but has an unsafe suffix; do not fetch it. - -**Verify**: `pnpm --filter devframe typecheck` -> exit 0. - -### Step 2: Add malicious-listing regression tests - -Extend the test fake or add a small custom `RemoteAssetsProvider` that returns controlled file names. Cover: - -- a prefixed traversal entry; -- an absolute path entry; -- a backslash traversal entry, which must be rejected on every platform; -- a prefix-confusion entry, which must be ignored as outside the selected prefix; -- an ordinary outside-prefix package file, which must remain ignored without invalidating the manifest; -- a normal nested asset still materializes. - -For each rejection, assert the fetch for that file was not attempted and an outside sentinel file was not created/modified. Do not include an operating-system sensitive path in the fixture. - -**Verify**: `pnpm exec vitest run packages/devframe/src/utils/remote-assets.test.ts` -> all tests pass. - -## Done criteria - -- [ ] Unsafe provider paths fail before network fetch and filesystem mutation. -- [ ] Final destinations are proven descendants of the resolved target directory. -- [ ] Existing jsDelivr and unpkg materialization remains functional. -- [ ] Targeted test, typecheck, and full verification pass. -- [ ] Only in-scope files and `plans/README.md` changed. - -## STOP conditions - -- Provider listings intentionally use absolute URLs rather than package-relative paths. -- Correct containment requires changing the public `RemoteAssetsProvider` contract. -- A platform-specific path behavior cannot be represented by deterministic tests. - -## Maintenance notes - -Keep validation immediately before materialization even if built-in providers sanitize listings; custom providers remain an untrusted boundary. Review future bulk extraction/materialization code for the same prefix-versus-containment mistake. diff --git a/plans/005-block-data-inspector-prototype-writes.md b/plans/005-block-data-inspector-prototype-writes.md deleted file mode 100644 index 98e863cfe..000000000 --- a/plans/005-block-data-inspector-prototype-writes.md +++ /dev/null @@ -1,112 +0,0 @@ -# Plan 005: Block prototype-chain traversal and mutation in Data Inspector writes - -> **Executor instructions**: Follow this plan step by step and run every verification command. Stop if protecting object writes would require changing Map semantics. Update `plans/README.md` when complete unless a reviewer owns the index. -> -> **Drift check (run first)**: `git diff --stat 2d978f84..HEAD -- plugins/data-inspector/src/engine/normalize.ts plugins/data-inspector/src/engine/write.ts plugins/data-inspector/test/write.test.ts` -> If any file changed, compare `navigate`, `setAt`, `addTo`, and `renameAt` with the excerpts below; stop on a mismatch. - -## Status - -- **Priority**: P1 -- **Effort**: S -- **Risk**: LOW -- **Depends on**: none -- **Category**: security -- **Planned at**: commit `2d978f84`, 2026-09-01 - -## Why this matters - -Data Inspector re-descends object paths through inherited properties and assigns caller-selected keys directly. A write path can therefore reach shared prototypes and mutate behavior outside the inspected source. Object operations must remain on own properties and reject prototype-sensitive property names while preserving Map keys as data. - -## Current state - -- `plugins/data-inspector/src/engine/normalize.ts` normalizes graphs and provides `navigate()`. -- `plugins/data-inspector/src/engine/write.ts` applies set/delete/add/rename operations. -- `plugins/data-inspector/test/write.test.ts` is the canonical operation matrix. - -Current inherited traversal: - -```ts -// normalize.ts:99-107 -for (const [kind, at] of path) { - // ... - case 'k': - cur = cur instanceof Map ? cur.get(at) : (cur as Record)[at] -} -``` - -Current direct assignments occur at `write.ts:89-94` and `write.ts:194-199`. Delete already uses `Object.hasOwn` at lines 139-144; match that ownership convention. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---|---|---| -| Targeted test | `pnpm exec vitest run plugins/data-inspector/test/write.test.ts` | all tests pass | -| Package typecheck | `pnpm --filter @devframes/plugin-data-inspector typecheck` | exit 0 | -| Full verification | `pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build` | every command exits 0 | - -## Scope - -**In scope**: - -- `plugins/data-inspector/src/engine/normalize.ts` -- `plugins/data-inspector/src/engine/write.ts` -- `plugins/data-inspector/test/write.test.ts` - -**Out of scope**: - -- Map keys named `constructor`, `prototype`, or `__proto__`; Map keys are data and must keep working. -- Query-language evaluation, RPC authentication, and normalization resource limits. -- Changing the wire shape of `WriteRequest`. -- Broad object cloning or freezing. - -## Git workflow - -- Branch if needed: `fix/data-inspector-prototype-writes`. -- Commit style: `fix(data-inspector): block prototype-chain writes`. -- Do not push/open a PR unless instructed. - -## Steps - -### Step 1: Centralize safe plain-object key checks - -Add one small internal helper for non-Map object property operations. It must reject `__proto__`, `prototype`, and `constructor` consistently with a named `WriteError` (reuse `InvalidKey` unless the current error union requires a dedicated name). Apply it to every plain-object set, add, and rename destination. Do not apply it to Map operations. - -For a set/rename source path that denotes an existing object property, require `Object.hasOwn(parent, key)` before reading or assigning. Preserve existing descriptor checks for readonly/accessor properties. For add and rename destinations, create an own data property with `Object.defineProperty(..., { configurable: true, enumerable: true, writable: true, value })` rather than bracket assignment, so an inherited setter cannot run. Continue rejecting the three prototype-sensitive names even though `defineProperty` could create them as own properties. - -**Verify**: `pnpm --filter @devframes/plugin-data-inspector typecheck` -> exit 0. - -### Step 2: Make navigation own-property-only - -In `navigate()`, retain `Map.get` behavior. For ordinary objects, return `undefined` when the requested key is not an own property before reading it. This aligns live re-navigation with the normalizer, which exposes an object's own graph rather than its prototype chain. - -Do not invoke getters solely to determine ownership. - -**Verify**: `pnpm exec vitest run plugins/data-inspector/test/engine.test.ts plugins/data-inspector/test/write.test.ts` -> all tests pass. - -### Step 3: Add regression coverage for every write shape - -Add tests proving set, add, and rename reject prototype-sensitive keys; nested inherited traversal returns `PathNotFound`; and `Object.prototype` remains unchanged after each attempt. Add a custom-prototype fixture with an inherited setter and prove add/rename creates an own data property without invoking that setter. Use `try/finally` cleanup around any prototype sentinel so a failed assertion cannot contaminate later tests. - -Add a positive test proving a Map can still use the same strings as keys. - -**Verify**: `pnpm exec vitest run plugins/data-inspector/test/write.test.ts` -> all tests pass. - -## Done criteria - -- [ ] Plain-object navigation never follows inherited properties. -- [ ] Plain-object set/add/rename reject all prototype-sensitive names. -- [ ] Maps preserve arbitrary key semantics. -- [ ] Regression tests assert global prototypes remain unchanged. -- [ ] Targeted tests, package typecheck, and full verification pass. -- [ ] Only in-scope files and `plans/README.md` changed. - -## STOP conditions - -- The normalizer deliberately exposes inherited properties elsewhere and tests rely on mutating them. -- The public `WriteError` union cannot represent rejection without a public API decision. -- A proposed fix changes Map behavior. - -## Maintenance notes - -All future write operations must use the same plain-object key helper. Reviewers should search for bracket assignment and `Object.defineProperty` in the engine before approval. diff --git a/plans/006-validate-auth-link-origin.md b/plans/006-validate-auth-link-origin.md deleted file mode 100644 index 8d09b31b8..000000000 --- a/plans/006-validate-auth-link-origin.md +++ /dev/null @@ -1,145 +0,0 @@ -# Plan 006: Validate request-derived origins before printing authentication links - -> **Executor instructions**: Follow this plan step by step. Preserve proxy/host-framework use cases only through explicit trusted configuration; never fall back to accepting an arbitrary request authority. Stop on any listed condition. Update `plans/README.md` when complete unless a reviewer owns the index. -> -> **Drift check (run first)**: `git diff --stat 2d978f84..HEAD -- packages/devframe/src/node/instance-shell.ts packages/devframe/src/adapters/initiate.ts packages/devframe/src/adapters/__tests__/initiate.test.ts packages/devframe/src/adapters/__tests__/dev.test.ts docs/content/1.guide/14.security.md docs/content/2.adapters/1.initiate.md` -> If an in-scope file changed, compare origin capture and banner timing with the excerpts below; stop on a mismatch. - -## Status - -- **Priority**: P1 -- **Effort**: M -- **Risk**: MED -- **Depends on**: none -- **Category**: security -- **Planned at**: commit `2d978f84`, 2026-09-01 - -## Why this matters - -For handler-owned hosts without an explicit public origin, the first request permanently determines the origin used in the terminal's OTP magic link. Node middleware builds that value directly from `Host`; fetch handlers trust the absolute request URL. An unauthenticated first request can redirect the credential-bearing link to another origin. - -## Current state - -- `packages/devframe/src/node/instance-shell.ts` owns late origin discovery and banner timing. -- `packages/devframe/src/adapters/__tests__/initiate.test.ts` exercises handler/middleware instances. -- `packages/devframe/src/adapters/__tests__/dev.test.ts` exercises owned listeners and wildcard binds. -- `docs/content/1.guide/14.security.md` documents the OTP fragment and trust model. - -Current origin capture: - -```ts -// instance-shell.ts:430-433 -function noteOrigin(origin: string): void { - derivedOrigin ??= origin - maybePrintBanner() - maybeRegister() -} - -// instance-shell.ts:670-674 -const host = req.headers.host -if (host) - noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`) -``` - -`handleRequest()` similarly calls `noteOrigin(new URL(request.url).origin)` at lines 640-643. `interactive-auth.ts:79-90` puts this origin into the OTP URL. - -## Target trust rule - -- Explicit `options.origin` remains authoritative. -- An owned listener derives its advertised origin from the bound address/port, not an inbound Host header. -- A handler/middleware may adopt a request-derived origin only when its parsed hostname passes `isLoopbackHostname`, or when its canonical origin exactly equals an entry in the existing `allowedOrigins` array. -- If `allowedOrigins` is `false` or a dynamic `WsOriginRegistry`, request-derived origin adoption is disabled; non-loopback deployments in those modes must provide explicit `origin`. Do not honor forwarded headers. -- A rejected candidate must not print a banner, register a poisoned origin, or prevent a later valid candidate from being adopted. - -Reuse `isLoopbackHostname` from `devframe/rpc/transports/ws-server`; do not reuse `isAllowedOrigin`, because it accepts an origin-shaped string before this plan's stricter canonical-origin validation. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---|---|---| -| Adapter tests | `pnpm exec vitest run packages/devframe/src/adapters/__tests__/initiate.test.ts packages/devframe/src/adapters/__tests__/dev.test.ts` | all tests pass | -| Core typecheck | `pnpm --filter devframe typecheck` | exit 0 | -| Full verification | `pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build` | every command exits 0 | - -## Scope - -**In scope**: - -- `packages/devframe/src/node/instance-shell.ts` -- `packages/devframe/src/adapters/initiate.ts` -- `packages/devframe/src/adapters/__tests__/initiate.test.ts` -- `packages/devframe/src/adapters/__tests__/dev.test.ts` -- `docs/content/1.guide/14.security.md` -- `docs/content/2.adapters/1.initiate.md` - -**Out of scope**: - -- General reverse-proxy support or automatic trust of `Forwarded`/`X-Forwarded-*`. -- Changes to OTP entropy, TTL, token persistence, or per-handler OTP state. -- Vite `allowedHosts` examples (finding 16 was not selected). -- Changes to WebSocket origin authorization semantics. - -## Git workflow - -- Branch if needed: `fix/auth-link-origin`. -- Commit style: `fix(devframe): validate authentication link origins`. -- Do not push/open a PR unless instructed. - -## Steps - -### Step 1: Separate candidate validation from origin adoption - -Replace unconditional `noteOrigin` with a function that canonicalizes a candidate URL and checks it against the trusted rule above. Reject credentials, paths, query strings, fragments, malformed ports, and non-HTTP(S) schemes. Compare canonical origins exactly. - -Keep the first-valid-origin behavior, not first-request behavior. Invalid candidates must be ignored without setting `derivedOrigin`. - -Ignore invalid candidates silently to avoid a request-amplified warning. Do not add a diagnostic in this plan. - -**Verify**: `pnpm --filter devframe typecheck` -> exit 0. - -### Step 2: Route both request adapters through validation - -Apply the same candidate validation to web `Request` and Node middleware paths. For owned listeners, preserve current `localhost:` behavior independently of request headers. Ensure an explicit `origin` bypasses derivation because it was supplied by the host framework. - -**Verify**: `pnpm exec vitest run packages/devframe/src/adapters/__tests__/dev.test.ts` -> all tests pass. - -### Step 3: Add first-request poisoning regression tests - -In `initiate.test.ts`, construct an instance with a banner spy and no explicit origin. Cover: - -- a first request with an untrusted authority does not print/adopt/register it; -- a later loopback request becomes the origin and prints exactly one link; -- an exactly allow-listed non-loopback origin is accepted; -- an origin that only prefix/suffix-matches an allow-listed value is rejected; -- explicit `origin` wins regardless of inbound Host; -- protocol and port are canonicalized consistently. - -Assert only the URL origin and fragment parameter presence; never snapshot a live credential value. - -**Verify**: `pnpm exec vitest run packages/devframe/src/adapters/__tests__/initiate.test.ts` -> all tests pass. - -### Step 4: Correct the public guidance - -Document that non-loopback handler deployments set `origin` explicitly and that request-derived origins are accepted only through the loopback/exact allow-list policy. Follow repository terminology and positive framing. - -**Verify**: `pnpm test` -> build, tests, and API snapshots pass. - -## Done criteria - -- [ ] No raw Host/request URL can become an OTP-link origin without validation. -- [ ] Invalid first requests do not lock out a later valid origin. -- [ ] Explicit origin and owned-listener behavior still work. -- [ ] Regression tests cover hostile-first/valid-second ordering and exact allow-list matching. -- [ ] Targeted tests, typecheck, and full verification pass. -- [ ] Only in-scope files, any required diagnostic page, and `plans/README.md` changed. - -## STOP conditions - -- A host framework requires arbitrary request-derived non-loopback origins without any explicit trusted configuration. -- Canonical origin validation would need DNS resolution in the request path. -- The change begins trusting forwarded headers implicitly. -- Existing API snapshots show an unrelated public change. - -## Maintenance notes - -The terminal magic link is a credential-delivery mechanism, so its destination must always come from trusted configuration or a strict local policy. Review future registry-origin and absolute-dock URL derivation against the same rule. diff --git a/plans/007-enforce-symlink-containment.md b/plans/007-enforce-symlink-containment.md deleted file mode 100644 index aa4419a09..000000000 --- a/plans/007-enforce-symlink-containment.md +++ /dev/null @@ -1,159 +0,0 @@ -# Plan 007: Reject pre-existing symlink escapes from filesystem roots - -> **Executor instructions**: Follow this plan step by step and verify both read and mutation paths. Do not claim race-free containment if the implementation only performs lexical checks. Stop on a listed condition. Update `plans/README.md` when complete unless a reviewer owns the index. -> -> **Drift check (run first)**: `git diff --stat 2d978f84..HEAD -- packages/devframe/src/utils/serve-static.ts packages/devframe/src/utils/serve-static.test.ts plugins/assets/src/node/paths.ts plugins/assets/src/node/scanner.ts plugins/assets/src/rpc/functions/delete.ts plugins/assets/src/rpc/functions/list.ts plugins/assets/src/rpc/functions/mkdir.ts plugins/assets/src/rpc/functions/read-image-meta.ts plugins/assets/src/rpc/functions/read-text.ts plugins/assets/src/rpc/functions/rename.ts plugins/assets/src/rpc/functions/upload.ts plugins/assets/test/assets.test.ts services/open/src/index.ts services/open/test/service.test.ts` -> If any in-scope file changed, compare its path resolution/I/O call with the excerpts below; stop on a mismatch. - -## Status - -- **Priority**: P2 -- **Effort**: M -- **Risk**: MED -- **Depends on**: none -- **Category**: security -- **Planned at**: commit `2d978f84`, 2026-09-01 - -## Why this matters - -Static serving and asset RPC operations prove containment only from normalized path strings. Filesystem operations then follow symlinks, so a symlink inside an allowed root can redirect reads, writes, deletes, renames, or editor-opening outside that root. Canonical checks must reject deterministic, pre-existing symlink escapes. This plan does not claim to defeat a concurrent local process replacing path components between validation and I/O. - -## Current state - -- `packages/devframe/src/utils/serve-static.ts` serves local SPA/static roots through h3 and Connect variants. -- `plugins/assets/src/node/paths.ts` is the common lexical resolver used by asset RPC handlers. -- `plugins/assets/test/assets.test.ts` has integration coverage for lexical `..` traversal. -- `services/open/src/index.ts:70-100` uses the same lexical allowed-root model for editor/finder actions installed by the assets devframe. - -Current lexical checks: - -```ts -// serve-static.ts:65-70 -const abs = normalize(join(absDir, cleaned)) -if (abs !== absDir && !abs.startsWith(absDir + sep)) - return null -const direct = await statFile(abs) // stat follows symlinks - -// plugins/assets/src/node/paths.ts:10-16 -const normalizedRoot = resolve(root) -const absolute = resolve(normalizedRoot, cleaned) -if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}/`)) - throw diagnostics.DP_ASSETS_0001({ path: relativePath }) -``` - -The minimal correct change may use separate helpers for async static reads and synchronous asset path resolution; do not add a broad abstraction unless it genuinely fits both call patterns. - -## Commands you will need - -| Purpose | Command | Expected on success | -|---|---|---| -| Static tests | `pnpm exec vitest run packages/devframe/src/utils/serve-static.test.ts` | all tests pass | -| Asset tests | `pnpm exec vitest run plugins/assets/test/assets.test.ts` | all tests pass | -| Open-service tests | `pnpm exec vitest run services/open/test/service.test.ts` | all tests pass | -| Typechecks | `pnpm --filter devframe typecheck && pnpm --filter @devframes/plugin-assets typecheck && pnpm --filter @devframes/service-open typecheck` | exit 0 | -| Full verification | `pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build` | every command exits 0 | - -## Scope - -**In scope**: - -- `packages/devframe/src/utils/serve-static.ts` -- `packages/devframe/src/utils/serve-static.test.ts` -- `plugins/assets/src/node/paths.ts` -- `plugins/assets/src/node/scanner.ts` -- `plugins/assets/src/rpc/functions/delete.ts` -- `plugins/assets/src/rpc/functions/list.ts` -- `plugins/assets/src/rpc/functions/mkdir.ts` -- `plugins/assets/src/rpc/functions/read-image-meta.ts` -- `plugins/assets/src/rpc/functions/read-text.ts` -- `plugins/assets/src/rpc/functions/rename.ts` -- `plugins/assets/src/rpc/functions/upload.ts` -- `plugins/assets/test/assets.test.ts` -- `services/open/src/index.ts` -- `services/open/test/service.test.ts` - -**Out of scope**: - -- Remote asset provider paths (Plan 004). -- Upload quotas, file type/content validation, and active SVG handling. -- Supporting arbitrary symlinked asset trees through a compatibility flag. -- Filesystem sandboxing outside configured roots. - -## Git workflow - -- Branch if needed: `fix/symlink-containment`. -- Commit style: `fix: enforce symlink-aware filesystem roots`. -- Do not push/open a PR unless instructed. - -## Steps - -### Step 1: Define the symlink policy in tests first - -Add Linux/macOS tests (skip only where creating symlinks is unavailable) with a managed/served root, an outside directory, and both file and directory symlinks inside the root. Tests must prove: - -- static h3 and Node middleware return 404 for a symlink escaping the served root; -- ordinary in-root files still serve; -- asset read, upload, rename, delete, mkdir, and open-service operations cannot cross an escaping ancestor symlink; -- reads/static serving allow a symlink only when its canonical target remains inside the canonical root; -- mutations reject every pre-existing symlink path component, including symlinks whose targets remain in-root; -- the open service allows canonical in-root targets and rejects canonical escapes. - -In `scanner.ts`, explicitly configure the glob not to follow symbolic links and omit symlink entries from returned `AssetInfo` values. Reuse `DP_ASSETS_0001` for all rejected RPC paths; do not add a new diagnostic in this plan. - -Use temporary directories and never reference real system files. - -**Verify**: run all three targeted test commands -> the new escape tests fail before implementation while existing tests pass. - -### Step 2: Canonicalize static read targets - -Resolve the canonical served root once per handler construction. In `resolveTarget`, canonicalize each existing candidate and require it to remain beneath that root before returning `ResolvedFile`. Apply the check to direct files, index candidates, extension candidates, and SPA fallback. - -Recheck containment as close as practical to opening the file. `O_NOFOLLOW` may add final-component defense where portable, but do not describe it as protecting ancestor replacement races. - -**Verify**: `pnpm exec vitest run packages/devframe/src/utils/serve-static.test.ts` -> all tests pass. - -### Step 3: Canonicalize asset mutation ancestors - -Keep lexical rejection in `resolveAssetPath`, then canonicalize the root and the nearest existing ancestor of the requested target. Require that ancestor to remain within the canonical root. For existing targets, validate the target's canonical path too. - -Because uploads/mkdir may create missing components, walk existing components with `lstat` and reject every symlink before creation, then repeat the walk after directory creation and immediately before opening/renaming/deleting. Apply canonical containment to the open service's allowed-root validation. - -Preserve `DP_ASSETS_0001` for outside-root rejection; if a new node-side error is required, follow the package's existing coded diagnostics convention. - -**Verify**: `pnpm exec vitest run plugins/assets/test/assets.test.ts` -> all tests pass. - -### Step 4: Run cross-package verification - -Run the three package typechecks, targeted tests, then the complete repository gate. Check Windows-specific path handling in code even if symlink tests skip on Windows CI. - -**Verify**: `pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build` -> exit 0. - -## Test plan - -- Escaping final-component file symlink. -- Escaping ancestor-directory symlink. -- Existing and not-yet-existing mutation targets. -- Both static handler implementations. -- Every assets mutation/read family and the installed open service. -- Positive ordinary nested paths and the chosen in-root symlink policy. - -## Done criteria - -- [ ] Static reads reject canonical paths outside the served root. -- [ ] Asset/open operations reject escaping symlink ancestors before I/O. -- [ ] Both final-component and ancestor symlinks have regression tests. -- [ ] Lexical `..` tests continue to pass. -- [ ] Targeted tests, all affected typechecks, and full verification pass. -- [ ] Only in-scope files and `plans/README.md` changed. - -## STOP conditions - -- Existing product behavior explicitly requires following symlinks outside configured roots. -- The accepted threat model requires protection against a concurrent local process replacing path components between validation and I/O. -- A mutation path cannot be protected without changing its public atomicity/overwrite contract. -- The open service has external consumers that require a different symlink policy from assets. -- Tests require elevated privileges or real host files. - -## Maintenance notes - -Canonical checks close pre-existing symlink escapes but do not eliminate filesystem replacement races. If the threat model includes a concurrent local attacker who can mutate the managed root, STOP and escalate to a separate design using descriptor-relative/native sandbox operations; Node's ordinary path APIs and final-component `O_NOFOLLOW` are insufficient for that claim. diff --git a/plans/README.md b/plans/README.md index 6b0ec627b..dffe78c00 100644 --- a/plans/README.md +++ b/plans/README.md @@ -1,26 +1,18 @@ # Security Implementation Plans -Generated by the improve skill on 2026-09-01 at commit `2d978f84`. Execute in the order below unless dependencies say otherwise. Each executor must read its plan fully, honor STOP conditions, run every verification gate, and update its status row. +Generated by the improve skill on 2026-09-01 at commit `2d978f84`. Each executor must read its plan fully, honor STOP conditions, run every verification gate, and update its status row. -## Execution Order And Status +Completed plans are removed from this directory once they land; git history keeps the full documents. Executed so far: 002 (authenticate route-based MCP), 003 (MCP shared-state exposure policy), 004 (remote asset containment), 005 (Data Inspector prototype-chain writes), 006 (authentication-link origin validation), 007 (symlink containment), and the product plan 008 (default-on MCP behind a non-empty agent surface). + +## Outstanding | Plan | Title | Priority | Effort | Depends on | Status | |---|---|---|---|---|---| | 001 | Pin privileged GitHub Actions dependencies | P1 | S | - | TODO | -| 002 | Require authentication on route-based MCP | P1 | M | 001 | DONE | -| 003 | Enforce shared-state exposure policy on direct MCP reads | P1 | S | 002 | DONE | -| 004 | Contain remote asset materialization | P1 | S | - | DONE | -| 005 | Block Data Inspector prototype-chain writes | P1 | S | - | DONE | -| 006 | Validate request-derived authentication-link origins | P1 | M | - | DONE | -| 007 | Reject pre-existing symlink escapes from filesystem roots | P2 | M | - | DONE | Status values: TODO | IN PROGRESS | DONE | BLOCKED (with reason) | REJECTED (with rationale) -## Dependency Notes - -- Plan 001 lands first because the release path should stop following mutable privileged workflow code before security fixes are published. -- Plan 003 follows Plan 002 so MCP's read policy is tested behind the corrected identity boundary. It may be developed in parallel but should land immediately after Plan 002. -- Plans 004-007 are independent and can execute in separate worktrees. Each executor runs its plan's drift command before editing; only `plans/README.md` overlaps. +Plan 001 has no plan document yet; the finding is that the release path should stop following mutable privileged workflow code (pin action references to commit SHAs in `.github/workflows`). ## Findings Considered And Rejected diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f0d7da48..95e302a53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1405,6 +1405,9 @@ importers: packages/devframe: dependencies: + '@modelcontextprotocol/server': + specifier: catalog:deps + version: 2.0.0 '@standard-schema/spec': specifier: catalog:deps version: 1.1.0 @@ -1433,9 +1436,6 @@ importers: '@modelcontextprotocol/client': specifier: catalog:deps version: 2.0.0 - '@modelcontextprotocol/server': - specifier: catalog:deps - version: 2.0.0 cac: specifier: catalog:deps version: 7.0.0 @@ -12924,7 +12924,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.6 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 7.8.5 @@ -16999,7 +16999,7 @@ snapshots: '@typescript-eslint/utils@8.69.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) '@typescript-eslint/scope-manager': 8.69.0 '@typescript-eslint/types': 8.69.0 '@typescript-eslint/typescript-estree': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) @@ -20940,8 +20940,8 @@ snapshots: dependencies: '@next/env': 16.3.3 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.10.43 - caniuse-lite: 1.0.30001806 + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 postcss: 8.5.23 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 861a9cb0b..46edb2435 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -405,7 +405,9 @@ defineRpcFunction({ }) ``` -Or register tools / resources directly on `ctx.agent.registerTool({ id, description, safety, handler })` and `ctx.agent.registerResource({ id, name, mimeType, read })`. Expose the API over MCP: +Or register tools / resources directly on `ctx.agent.registerTool({ id, description, safety, handler })` and `ctx.agent.registerResource({ id, name, mimeType, read })`. + +The dev server serves this surface over HTTP automatically: the `mcp: 'auto'` default mounts the Streamable-HTTP route at `__mcp` once the agent surface is non-empty (`mcp: true` forces on, `mcp: false` off). For stdio: ```ts import { createMcpServer } from 'devframe/adapters/mcp' @@ -413,7 +415,7 @@ import { createMcpServer } from 'devframe/adapters/mcp' await createMcpServer(myDevframe, { transport: 'stdio' }) ``` -`@modelcontextprotocol/server` is a peer dependency. The CLI adapter also exposes `my-tool mcp` (route node-side logs to stderr - stdout is the transport). Safety classifications (`'read' | 'action' | 'destructive'`) drive MCP hint annotations that coding agents use to prompt for confirmation. In a hub, `ctx.commands` entries opt into the same agent-facing API with an `agent` field and reach MCP through the aggregate endpoint. +The CLI adapter also exposes `my-tool mcp` (route node-side logs to stderr - stdout is the transport). Safety classifications (`'read' | 'action' | 'destructive'`) drive MCP hint annotations that coding agents use to prompt for confirmation. In a hub, `ctx.commands` entries opt into the same agent-facing API with an `agent` field and reach MCP through the aggregate endpoint. ## Author SPA diff --git a/starter/README.md b/starter/README.md index 6cd46f2fc..3af91bf65 100644 --- a/starter/README.md +++ b/starter/README.md @@ -19,6 +19,8 @@ pnpm run typecheck `pnpm run dev` and both playgrounds gate by default: opening the printed URL walks you through devframe's interactive OTP handshake (a 6-digit code) before the SPA can call RPC. That's intentional - see the `auth` comments in `src/devframe.ts` and `playground/*/vite.config.ts` before reaching for `auth: false`, which trusts every connection that can reach the port. For a one-off loopback-only session, pass `--no-auth` to the CLI instead (`pnpm run dev -- --no-auth`). +The `get-state` RPC carries an `agent` field, so the same function serves two views: the SPA for you, and an MCP tool for your coding agent. The dev server mounts the MCP route automatically at `__mcp`, and `pnpm run dev -- mcp` serves the same tools over stdio. + ## File map | Path | Purpose | diff --git a/starter/src/rpc/functions/get-state.ts b/starter/src/rpc/functions/get-state.ts index bfdf38562..701656123 100644 --- a/starter/src/rpc/functions/get-state.ts +++ b/starter/src/rpc/functions/get-state.ts @@ -18,12 +18,19 @@ export interface StarterState { * dump is baked into a static build so the SPA keeps working with no server. * The one round trip the client makes - runtime info plus the top-level * entries of the working directory. + * + * The `agent` field is the same function's second view: it becomes an MCP + * tool for coding agents, served automatically (`mcp: 'auto'`) at + * `__mcp` in dev and over stdio via `pnpm run dev -- mcp`. */ export const getState = defineRpcFunction({ name: 'get-state', type: 'query', jsonSerializable: true, snapshot: true, + agent: { + description: 'Read the devframe-starter state: the Node version and the top-level entries of the working directory.', + }, setup: ctx => ({ handler: async (): Promise => { const cwd = process.env.DEVFRAME_E2E_CWD || ctx.cwd diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts index c73858579..d7a0b1b95 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts @@ -48,7 +48,7 @@ export interface InitHubOptions { sse?: boolean | DevframeSseOptions; host?: string; auth?: boolean | DevframeAuthHandler; - mcp?: boolean | McpRouteOptions; + mcp?: McpSetting; origin?: string | (() => string); register?: boolean | Partial; clientModuleResolution?: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts index ebb183674..d6de453b0 100644 --- a/tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts @@ -8,7 +8,7 @@ export interface DevframeViteBridgeOptions { host?: string; flags?: Record; auth?: boolean | DevframeAuthHandler; - mcp?: boolean | McpRouteOptions; + mcp?: McpSetting; } export interface DevframeViteDevServerLike { middlewares: { diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/cac.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/cac.snapshot.d.ts index 2d8ca9247..aa25b4fb9 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/cac.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/cac.snapshot.d.ts @@ -8,7 +8,7 @@ export interface CacHandle { } export interface CreateCacOptions { defaultPort?: number; - mcp?: boolean | McpRouteOptions; + mcp?: McpSetting; configureCli?: (_: CAC) => void; onReady?: (_: { origin: string; diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index abe473a2f..b62676686 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -14,7 +14,7 @@ export interface CreateDevServerOptions { app?: H3; openBrowser?: boolean | string; auth?: boolean | DevframeAuthHandler; - mcp?: boolean | McpRouteOptions; + mcp?: McpSetting; onPeerConnect?: (_: DevframeRpcConnection, _: DevframeNodeRpcSession) => void; onPeerDisconnect?: (_: DevframeRpcConnection, _: DevframeNodeRpcSessionMeta) => void; onReady?: (_: { diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 1d52f01d9..9858adf5e 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -95,6 +95,7 @@ export interface DevframeAgentHost { registerResource: (_: AgentResourceInput) => AgentHandle; unregisterResource: (_: string) => boolean; list: () => AgentManifest; + hasSurface: () => boolean; invoke: (_: string, _: unknown) => Promise; read: (_: string) => Promise; getTool: (_: string) => AgentTool | undefined; @@ -119,7 +120,7 @@ export interface DevframeCliOptions { host?: string; open?: boolean | string; auth?: boolean | DevframeAuthHandler; - mcp?: boolean | McpRouteOptions; + mcp?: McpSetting; distDir?: StaticAssetsSource; ws?: DevframeWsOptions | false; sse?: boolean | DevframeSseOptions; @@ -495,6 +496,7 @@ export type DevframeSnapshotRpcEntry = string | { export type DevframeSnapshotRpcInputs = readonly (readonly unknown[])[] | ((_: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise); export type DevframeStorageScope = 'workspace' | 'project' | 'global'; export type McpAuthorization = string | ((_: Request) => boolean | Promise) | false; +export type McpSetting = boolean | 'auto' | McpRouteOptions; export type RemoteAssetsProvider = 'jsdelivr' | 'unpkg' | RemoteAssetsProviderCustom; export type RpcFunctionsHost = RpcFunctionsCollectorBase & { invokeLocal: >(_: T, ..._: Args) => Promise>>; diff --git a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts index a3893f160..eddffc56b 100644 --- a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts @@ -25,7 +25,7 @@ export interface InitDevframeOptions { sse?: boolean | DevframeSseOptions; host?: string; auth?: boolean | DevframeAuthHandler; - mcp?: boolean | McpRouteOptions; + mcp?: McpSetting; origin?: string | (() => string); register?: boolean | Partial; flags?: Record; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index f5f0f6386..3b55ac877 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -33,6 +33,7 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { registerResource(_: AgentResourceInput): AgentHandle; unregisterResource(_: string): boolean; list(): AgentManifest; + hasSurface(): boolean; getTool(_: string): AgentTool | undefined; getResource(_: string): AgentResource | undefined; invoke(_: string, _: unknown): Promise; @@ -184,7 +185,7 @@ export declare const diagnostics: import("nostics").Diagnostics<{ readonly why: (p: { reason: string; }) => string; - readonly fix: "Install it next to devframe (e.g. `npm install @modelcontextprotocol/server`) and run `devframe connect` again."; + readonly fix: "Install it next to devframe (e.g. `npm install @modelcontextprotocol/client`) and run `devframe connect` again."; }; readonly DF0047: { readonly why: (p: { @@ -381,6 +382,7 @@ export { InstanceShellInit } export { InstanceShellInternals } export { InstanceWsTier } export { listLiveDevframeInstances } +export { loadAutoMcpAdapter } export { normalizeBasePath } export { registerDevframeInstance } export { resolveBasePath } diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js index 9b3a95de9..2ca8861ea 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js @@ -11,6 +11,7 @@ export { DevframeAgentHost } export { diagnostics } export { importRuntimeModule } export { listLiveDevframeInstances } +export { loadAutoMcpAdapter } export { normalizeBasePath } export { normalizeHttpServerUrl } export { peekRpcWireFrame } diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 9ad670e3c..4d30c4f98 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -66,6 +66,7 @@ export { EventsMap } export { EventUnsubscribe } export { McpAuthorization } export { McpRouteOptions } +export { McpSetting } export { RemoteAssets } export { RemoteAssetsErrorMessage } export { RemoteAssetsProvider } diff --git a/tests/optional-mcp-bundles.test.ts b/tests/optional-mcp-bundles.test.ts index 37b21458b..9c66f9109 100644 --- a/tests/optional-mcp-bundles.test.ts +++ b/tests/optional-mcp-bundles.test.ts @@ -20,7 +20,7 @@ afterEach(() => { rmSync(directory, { recursive: true, force: true }) }) -describe('optional MCP peers in consumer bundles', () => { +describe('the MCP SDK stays out of consumer bundles', () => { it.each(entries)('bundles %s without resolving the MCP SDK', async (entry) => { const resolvedMcpImports: string[] = [] const rejectMcpSdk: Plugin = { @@ -102,6 +102,42 @@ describe('optional MCP peers in consumer bundles', () => { } }) + it('mounts nothing under the `auto` default when the agent surface is empty', async () => { + const hubDist = join(root, 'packages/hub/dist') + const outputDirectory = mkdtempSync(join(hubDist, '.mcp-bundle-test-')) + temporaryDirectories.push(outputDirectory) + const outfile = join(outputDirectory, 'hub-auto.mjs') + + await build({ + entryPoints: [join(hubDist, 'node/initiate.mjs')], + bundle: true, + format: 'esm', + outfile, + platform: 'node', + }) + + // No `mcp` option and no devframes: the `'auto'` default finds an empty + // agent surface, so no route mounts and no MCP code loads. + const bundled = await import(pathToFileURL(outfile).href) as typeof import('../packages/hub/src/node/initiate') + const hub = bundled.initHub({ + auth: false, + base: bundled.DEVFRAMES_HUB_BASE, + ws: false, + }) + + try { + await hub.ready + expect(hub.connectionMeta().mcp).toBeUndefined() + const response = await hub.handler(new Request('http://localhost:3000/__devframes/__mcp', { + headers: { origin: 'http://localhost:3000' }, + })) + expect(response.status).toBe(404) + } + finally { + await hub.close() + } + }) + it('preserves the runtime importer in Next production bundles', () => { const runtimeImportChunks = readdirSync(nextServerChunks) .filter(file => file.endsWith('.js'))