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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions docs/content/2.adapters/7.mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`/__<id>/__mcp` under a host framework), sharing its origin/port. `--mcp` / `--no-mcp` override; `__connection.json` advertises it.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion examples/files-inspector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
5 changes: 4 additions & 1 deletion examples/files-inspector/bin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
6 changes: 0 additions & 6 deletions examples/files-inspector/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:`.
Expand Down
28 changes: 22 additions & 6 deletions packages/devframe/src/adapters/cac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 `<base>__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
Expand Down Expand Up @@ -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)')

Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/devframe/src/types/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface CacHandle {
}
export interface CreateCacOptions {
defaultPort?: number;
mcp?: boolean | McpRouteOptions;
configureCli?: (_: CAC) => void;
onReady?: (_: {
origin: string;
Expand Down
Loading