From 410b5c0bb7fa46166ddb63c5bc9c4f53c848befb Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 3 Sep 2026 03:12:06 +0000 Subject: [PATCH] refactor(devframe): move cli.mcp to createCac options Whether to expose an MCP route is a hosting decision made at the CLI assembly stage, not a capability of the tool. Add `mcp` to CreateCacOptions and read it as the flag default; deprecate the definition's `cli.mcp` (still honored as a fallback for now). --- docs/content/2.adapters/7.mcp.md | 20 +++++-------- examples/files-inspector/README.md | 2 +- examples/files-inspector/bin.mjs | 5 +++- examples/files-inspector/src/devframe.ts | 6 ---- packages/devframe/src/adapters/cac.ts | 28 +++++++++++++++---- packages/devframe/src/types/devframe.ts | 5 ++++ .../devframe/adapters/cac.snapshot.d.ts | 1 + 7 files changed, 40 insertions(+), 27 deletions(-) diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index 9c068c673..a81be687a 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -18,17 +18,13 @@ await createMcpServer(myDevframe, { transport: 'stdio' }) ## Route-based server -The dev server exposes the same MCP API over HTTP, live. Enable it with `cli.mcp` (or pass `mcp` to `createDevServer` / `initDevframe` / `initHub` when you host it programmatically): +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): ```ts -import { defineDevframe } from 'devframe' +import { createCac } from 'devframe/adapters/cac' +import myDevframe from './my-tool' -export default defineDevframe({ - /** … */ - cli: { - mcp: true, - }, -}) +createCac(myDevframe, { mcp: true }).parse() ``` The endpoint speaks Streamable-HTTP at `/__mcp` (`/__/__mcp` under a host framework), sharing its origin/port. `--mcp` / `--no-mcp` override; `__connection.json` advertises it. @@ -42,11 +38,9 @@ The **origin gate** guards every request: `Origin` must be loopback (or allow-li `Origin` proves nothing about *who* is calling, though: a native process on the same box can send any `Origin`. When a same-machine process isn't your trust boundary (a LAN/tunnel origin, a shared/CI host, a destructive tool surface), layer on an **identity check** with `authorization`: ```ts -export default defineDevframe({ - cli: { - mcp: { authorization: process.env.MY_TOKEN }, - }, -}) +createCac(myDevframe, { + mcp: { authorization: process.env.MY_TOKEN }, +}).parse() ``` `authorization` takes a bearer token (backed by an env var, never a literal), a `(request) => boolean` callback that governs identity only and cannot relax the origin gate, or `false` for the explicit origin-only default. diff --git a/examples/files-inspector/README.md b/examples/files-inspector/README.md index 30224f42c..a3e9a3b37 100644 --- a/examples/files-inspector/README.md +++ b/examples/files-inspector/README.md @@ -27,5 +27,5 @@ pnpm -C examples/files-inspector run test # E2E tests |------|---------| | `src/devframe.ts` | The single `DevframeDefinition` consumed by every adapter. | | `src/client/` | Preact SPA: `index.html`, `main.tsx`, `app.tsx`, `routes/*`, `vite.config.ts`. | -| `bin.mjs` | `createCac(devframe).parse()` - exposes `dev`, `build`, `mcp`. | +| `bin.mjs` | `createCac(devframe, { mcp: true }).parse()` - exposes `dev`, `build`, `mcp`. | | `tests/` | E2E tests for CLI dev server and static build. | diff --git a/examples/files-inspector/bin.mjs b/examples/files-inspector/bin.mjs index d35b5779b..c1ed8f007 100755 --- a/examples/files-inspector/bin.mjs +++ b/examples/files-inspector/bin.mjs @@ -4,7 +4,10 @@ import { createCac } from 'devframe/adapters/cac' import devframe from './src/devframe.ts' async function main() { - const cli = createCac(devframe) + // Serve the agent surface at `/__mcp` and register for `devframe connect` + // discovery. This loopback demo trusts same-machine callers (`mcp: true`); + // a network-reachable tool would harden it with `mcp: { authorization }`. + const cli = createCac(devframe, { mcp: true }) await cli.parse() } diff --git a/examples/files-inspector/src/devframe.ts b/examples/files-inspector/src/devframe.ts index adf2bba5f..49be230de 100644 --- a/examples/files-inspector/src/devframe.ts +++ b/examples/files-inspector/src/devframe.ts @@ -25,12 +25,6 @@ export default defineDevframe({ * SPA can call RPC without an OTP round-trip. */ auth: false, - /** - * Serve the agent surface at `/__mcp` and register for `devframe connect` - * discovery. This loopback demo trusts same-machine callers (`mcp: true`); - * a network-reachable tool would harden it with `mcp: { authorization }`. - */ - mcp: true, }, setup(ctx) { // A scoped context auto-namespaces every registered id with `NAMESPACE:`. diff --git a/packages/devframe/src/adapters/cac.ts b/packages/devframe/src/adapters/cac.ts index 434c4db09..70d125dd1 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 } from '../types/devframe' +import type { DevframeDefinition, McpRouteOptions } from '../types/devframe' import process from 'node:process' import cac from 'cac' import { colors as c } from 'devframe/utils/colors' @@ -23,6 +23,21 @@ export type { CliFlagsSchema, InferCliFlags } from './flags' export interface CreateCacOptions { /** Default port for `dev` (default: 9999). */ defaultPort?: number + /** + * 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. + * + * - `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. + */ + mcp?: boolean | McpRouteOptions /** * Final CAC hook invoked after devframe's built-in subcommands and * after the definition's `cli.configure`. Use this to add app-level @@ -70,7 +85,8 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) .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 `cli.mcp`), + // 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)') @@ -93,10 +109,10 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) const flags = resolveTypedFlags(d, rawFlags) as CliFlags const host = (flags.host as string | undefined) ?? defaultHost 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 `createDevServer` falls through - // to `def.cli?.mcp`. - const mcp = flags.mcp as boolean | undefined + // `--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`. + const mcp = (flags.mcp as boolean | undefined) ?? options.mcp await createDevServer(d, { host, port, diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index b8eb2ea84..80bbdf2f2 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -198,6 +198,11 @@ export interface DevframeCliOptions { * The `--mcp` / `--no-mcp` CLI flags override this per run. Whether to expose * MCP is a hosting decision, so programmatic hosts pass it to * `initDevframe` / `initHub` / `createDevServer` instead. + * + * @deprecated Whether to expose MCP is a hosting decision, not a capability + * of the tool. Pass `mcp` to `createCac` (or the programmatic host) instead. + * This field is still read as a fallback, and will be removed in a future + * release. */ mcp?: boolean | McpRouteOptions /** diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/cac.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/cac.snapshot.d.ts index 3ab6ac667..2d8ca9247 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/cac.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/cac.snapshot.d.ts @@ -8,6 +8,7 @@ export interface CacHandle { } export interface CreateCacOptions { defaultPort?: number; + mcp?: boolean | McpRouteOptions; configureCli?: (_: CAC) => void; onReady?: (_: { origin: string;