diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index 212112c..fc58b07 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -16,6 +16,34 @@ import devframe from './devframe' await createMcpServer(devframe, { transport: 'stdio' }) ``` -`@modelcontextprotocol/sdk` is a peer dependency — install it when shipping MCP support. The current transport is `stdio`. +`@modelcontextprotocol/sdk` is a peer dependency — install it when shipping MCP support. `createMcpServer` speaks the `stdio` transport, spawned per session by the client. + +## Route-based server + +The dev server can expose the same agent surface over HTTP, so an MCP client connects to the **running** server and sees live tool and resource changes. Enable it with `cli.mcp`: + +```ts +import { defineDevframe } from 'devframe' + +export default defineDevframe({ + // … + cli: { + mcp: true, + }, +}) +``` + +The endpoint speaks the MCP Streamable-HTTP transport at `/__mcp` (relative to the base path — `/__/__mcp` under a host), sharing the dev server's origin and port. The `--mcp` and `--no-mcp` flags override the definition per run. `__connection.json` advertises the route so in-browser tooling can discover it. + +Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The endpoint binds to the same loopback host as the dev server and applies the shared loopback origin gate; widen it for a tunnel or LAN origin: + +```ts +defineDevframe({ + // … + cli: { + mcp: { allowedOrigins: ['https://tunnel.example.com'] }, + }, +}) +``` See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example. diff --git a/docs/errors/DF0017.md b/docs/errors/DF0017.md index bb6f5c7..49743e2 100644 --- a/docs/errors/DF0017.md +++ b/docs/errors/DF0017.md @@ -14,18 +14,18 @@ The agent-native surface is experimental and may change without a major version ## Cause -`createMcpServer()` failed while initializing. Common reasons: +The MCP server failed while initializing. Common reasons: - `@modelcontextprotocol/sdk` is not installed. This is a peer dependency — add it to your devtool's dependencies. -- The selected transport is not yet implemented (the first devframe MCP release ships with `stdio` only; `http` is planned). -- The underlying transport threw during `connect()` (e.g. stdin/stdout is not available). +- The stdio transport threw during `connect()` (e.g. stdin/stdout is not available). +- The route-based MCP server (`cli.mcp`) could not load its transport module — usually the missing SDK peer dependency. ## Fix -- **Missing SDK**: `pnpm add @modelcontextprotocol/sdk` (or npm/yarn equivalent) in the package that imports `devframe/adapters/mcp`. -- **Unsupported transport**: pass `{ transport: 'stdio' }` until HTTP support lands. +- **Missing SDK**: `pnpm add @modelcontextprotocol/sdk` (or npm/yarn equivalent) in the package that imports `devframe/adapters/mcp` or enables `cli.mcp`. - **Transport init failure**: check the underlying error (attached as `cause`) for specifics. ## Source -- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — `createMcpServer()` throws `DF0017` when the requested transport is unsupported or when the underlying transport fails to `connect()`. +- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — `createMcpServer()` throws `DF0017` when the stdio transport fails to `connect()`. +- [`packages/devframe/src/adapters/dev.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/dev.ts) — `createDevServer()` throws `DF0017` (transport `http`) when the route-based MCP server can't load its transport module. diff --git a/packages/devframe/src/adapters/cli.ts b/packages/devframe/src/adapters/cli.ts index 729c91b..a1f0a79 100644 --- a/packages/devframe/src/adapters/cli.ts +++ b/packages/devframe/src/adapters/cli.ts @@ -49,6 +49,11 @@ export function createCli(d: DevframeDefinition, options: CreateCliOptions = {}) .option('--host ', 'Host to bind to', { default: defaultHost }) .option('--open', 'Open the browser on start') .option('--no-open', 'Do not open the browser') + // 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`), + // `--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) [experimental]') // Register typed flags from the definition ahead of `cli.configure` // so authors can still override or augment via the escape hatch. @@ -69,10 +74,15 @@ export function createCli(d: DevframeDefinition, options: CreateCliOptions = {}) 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 await createDevServer(d, { host, port, flags, + mcp, onReady: options.onReady, }) }) diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index bde0931..752d8e5 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,6 +1,6 @@ import type { StartedServer } from '../node/server' import type { ConnectionMeta } from '../types/context' -import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions } from '../types/devframe' +import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe' import process from 'node:process' import { open } from 'devframe/utils/open' import { mountStaticHandler } from 'devframe/utils/serve-static' @@ -8,8 +8,9 @@ import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash } from 'ufo' -import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants' +import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUTE } from '../constants' import { createHostContext } from '../node/context' +import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { startHttpAndWs } from '../node/server' import { normalizeBasePath, resolveBasePath } from './_shared' @@ -64,6 +65,13 @@ export interface CreateDevServerOptions { * sources; a string opens that relative path. */ openBrowser?: boolean | string + /** + * 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}. + */ + mcp?: boolean | McpRouteOptions /** * Called once the WS server is bound. Devframe stays headless * otherwise — wire this if you want a startup banner. @@ -149,6 +157,35 @@ export async function createDevServer( const setupInfo: DevframeSetupInfo = { flags } await def.setup(ctx, setupInfo) + // Route-based MCP server (opt-in via `cli.mcp` / the `mcp` option). Mounted + // before the SPA static catch-all so the exact `/__mcp` route wins, and + // advertised in `__connection.json` so in-browser tooling can discover it. + // The MCP SDK is an optional peer dep, so its code is only pulled in + // (dynamically) when the route is enabled. + const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp) + let mcpDispose: (() => Promise) | undefined + let mcpMeta: ConnectionMeta['mcp'] + if (mcpConfig) { + const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE) + const mcpPath = joinURL(basePath, mcpRoute) + let mountMcpHttp: typeof import('./mcp/http').mountMcpHttp + try { + ;({ mountMcpHttp } = await import('./mcp/http')) + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw diagnostics.DF0017({ transport: 'http', reason, cause: error }) + } + const mounted = mountMcpHttp(app, ctx, mcpPath, { + serverName: `${def.id} (devframe)`, + serverVersion: def.version ?? '0.0.0', + exposeSharedState: true, + allowedOrigins: mcpConfig.allowedOrigins, + }) + mcpDispose = mounted.dispose + mcpMeta = { path: mcpRoute } + } + // Connection meta — the SPA fetches this to discover the RPC backend. How // the WS endpoint is bound and advertised follows the resolved ws config: // a same-origin route (default, proxy-safe), a dedicated port, or a remote @@ -159,12 +196,13 @@ export async function createDevServer( app.use(connectionMetaPath, () => ({ backend: 'websocket', websocket: meta, + ...(mcpMeta ? { mcp: mcpMeta } : {}), })) if (distDir) mountStaticHandler(app, basePath, resolve(distDir)) - return startHttpAndWs({ + const started = await startHttpAndWs({ context: ctx, host, port, @@ -177,6 +215,28 @@ export async function createDevServer( await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser) }, }) + + // Fold MCP session teardown into the server's close so callers get a single + // graceful-shutdown handle. + if (mcpDispose) { + const closeServer = started.close + started.close = async () => { + await mcpDispose!() + await closeServer() + } + } + + return started +} + +/** + * Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into + * concrete options, or `undefined` when the MCP route is disabled. + */ +function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteOptions | undefined { + if (!mcp) + return undefined + return mcp === true ? {} : mcp } /** diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts new file mode 100644 index 0000000..014bb66 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -0,0 +1,142 @@ +import type { StartedServer } from '../../../node/server' +import type { DevframeDefinition } from '../../../types/devframe' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { afterEach, describe, expect, it } from 'vitest' +import { createDevServer } from '../../dev' + +function defineTestDef(overrides?: Partial): DevframeDefinition { + return { + id: 'mcp-http-test', + name: 'MCP HTTP Test', + version: '1.2.3', + packageName: '@devframe/mcp-http-test', + homepage: 'https://example.com', + description: 'Test fixture for the route-based MCP server.', + setup(ctx) { + ctx.agent.registerTool({ + id: 'greet', + description: 'Say hello.', + safety: 'read', + handler: (args: { name?: string }) => ({ greeting: `hi ${args.name ?? 'there'}` }), + }) + }, + ...overrides, + } +} + +describe('mcp adapter (streamable http route)', () => { + let server: StartedServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + async function boot(def = defineTestDef()): Promise { + server = await createDevServer(def, { host: '127.0.0.1', mcp: true }) + return server + } + + it('advertises the mcp endpoint in __connection.json', async () => { + const started = await boot() + const res = await fetch(`${started.origin}/__connection.json`) + const meta = await res.json() as { backend: string, mcp?: { path: string } } + expect(meta.backend).toBe('websocket') + expect(meta.mcp).toEqual({ path: '__mcp' }) + }) + + it('omits the mcp block when the route is disabled', async () => { + server = await createDevServer(defineTestDef(), { host: '127.0.0.1', mcp: false }) + const res = await fetch(`${server.origin}/__connection.json`) + const meta = await res.json() as { mcp?: unknown } + expect(meta.mcp).toBeUndefined() + }) + + it('establishes a stateful session and lists agent tools', async () => { + const started = await boot() + const transport = new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`)) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + try { + await client.connect(transport) + // Stateful mode issues an Mcp-Session-Id on initialize. + expect(transport.sessionId).toBeTypeOf('string') + expect(transport.sessionId!.length).toBeGreaterThan(0) + + const tools = await client.listTools() + expect(tools.tools.map(t => t.name)).toContain('greet') + + const result = await client.callTool({ name: 'greet', arguments: { name: 'devframe' } }) + const content = result.content as Array<{ type: string, text: string }> + expect(JSON.parse(content[0]!.text)).toEqual({ greeting: 'hi devframe' }) + } + finally { + await client.close() + } + }) + + it('tears the session down on DELETE and rejects reuse of the id', async () => { + const started = await boot() + const url = `${started.origin}/__mcp` + + // Initialize over raw HTTP to capture the issued session id from the + // response header (the body is an SSE stream we can discard). + const init = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, + }), + }) + const sessionId = init.headers.get('mcp-session-id') + await init.body?.cancel() + expect(sessionId).toBeTruthy() + + // DELETE ends the session. + const del = await fetch(url, { + method: 'DELETE', + headers: { 'mcp-session-id': sessionId! }, + }) + await del.body?.cancel() + expect(del.status).toBeLessThan(300) + + // Reusing the terminated id is no longer a known session — the server + // answers 404 rather than falling through to the SPA static catch-all. + const stale = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + 'mcp-session-id': sessionId!, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), + }) + await stale.body?.cancel() + expect(stale.status).toBe(404) + }) + + it('rejects a disallowed cross-origin request', async () => { + const started = await boot() + const res = await fetch(`${started.origin}/__mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + 'origin': 'http://evil.example.com', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, + }), + }) + expect(res.status).toBe(403) + }) +}) diff --git a/packages/devframe/src/adapters/mcp/http.ts b/packages/devframe/src/adapters/mcp/http.ts new file mode 100644 index 0000000..7ea2495 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/http.ts @@ -0,0 +1,186 @@ +import type { DevframeNodeContext } from 'devframe/types' +import type { H3, H3Event } from 'h3' +import { randomUUID } from 'node:crypto' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' +import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import { defineHandler } from 'h3' +import { buildMcpServerFromContext } from './build-server' + +export interface MountMcpHttpOptions { + /** Name reported in the MCP handshake. */ + serverName: string + /** Version reported in the MCP handshake. */ + serverVersion: string + /** Expose shared-state keys as MCP resources — see `buildMcpServerFromContext`. */ + exposeSharedState: boolean | ((key: string) => boolean) + /** + * Origin allow-list beyond the loopback default. `false` disables the + * origin gate entirely. Default: loopback-only (mirrors the WS transport). + */ + allowedOrigins?: readonly string[] | false +} + +export interface MountedMcpHttp { + /** Tear down every live MCP session (closes servers, drops subscriptions). */ + dispose: () => Promise +} + +interface McpSession { + transport: WebStandardStreamableHTTPServerTransport + dispose: () => Promise +} + +/** + * Mount an MCP Streamable-HTTP endpoint on an h3 app at `path`. + * + * Each MCP session gets its own {@link WebStandardStreamableHTTPServerTransport} + * and MCP server (built from the shared, live `ctx` via + * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: + * an `initialize` POST spins up a session; later requests route to it; a + * `DELETE` (or client disconnect) tears it down. + * + * The transport is web-standard — its `handleRequest` takes the h3 event's + * web `Request` and returns a web `Response` (an SSE `ReadableStream` body + * for the server→client stream). We copy that response onto `event.res` and + * return its body rather than returning the `Response` object directly, so a + * legitimate MCP 404 (unknown session) isn't swallowed by h3's + * "Response-with-404 falls through to the next handler" rule (which would + * otherwise hand the request to the SPA static catch-all). + * + * @experimental + */ +export function mountMcpHttp( + app: H3, + ctx: DevframeNodeContext, + path: string, + options: MountMcpHttpOptions, +): MountedMcpHttp { + const sessions = new Map() + const allowedOrigins = options.allowedOrigins + + function drop(sessionId: string): void { + const session = sessions.get(sessionId) + if (!session) + return + sessions.delete(sessionId) + void session.dispose() + } + + async function createSession(): Promise { + // Declared up front so the transport's session callbacks can capture it; + // it's assigned before any of them can fire (they run during + // `handleRequest`, after `connect` below). + let session!: McpSession + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, session) + }, + onsessionclosed: (id) => { + drop(id) + }, + }) + + const { server, dispose } = buildMcpServerFromContext(ctx, { + serverName: options.serverName, + serverVersion: options.serverVersion, + exposeSharedState: options.exposeSharedState, + }) + + session = { + transport, + dispose: async () => { + dispose() + await server.close() + }, + } + + transport.onclose = () => { + if (transport.sessionId) + drop(transport.sessionId) + } + + await server.connect(transport) + return session + } + + app.use(path, defineHandler(async (event) => { + const req = event.req + + // Origin gate — identical semantics to the WS upgrade's `isAllowedOrigin` + // (loopback + `Origin`-less native clients + the configured allow-list). + // This is the endpoint's DNS-rebinding protection. + const origin = req.headers.get('origin') ?? undefined + if (allowedOrigins !== false && !isAllowedOrigin(origin, allowedOrigins ?? [])) { + event.res.status = 403 + return 'Forbidden: origin not allowed' + } + + const sessionId = req.headers.get('mcp-session-id') ?? undefined + let session = sessionId ? sessions.get(sessionId) : undefined + + // A POST may carry an `initialize` request that opens a brand-new + // session. Parse the body once and hand it to the transport as + // `parsedBody` (the web Request body can only be consumed once). + if (!session && req.method === 'POST') { + let body: unknown + try { + body = await req.json() + } + catch { + body = undefined + } + + if (!sessionId && isInitializeRequest(body)) { + session = await createSession() + } + else { + event.res.status = sessionId ? 404 : 400 + return sessionId + ? 'Not Found: unknown MCP session' + : 'Bad Request: no valid session ID and not an initialize request' + } + + return respond(event, await session.transport.handleRequest(req, { parsedBody: body })) + } + + if (!session) { + // GET (open the SSE stream) / DELETE (end the session) require a + // known session id. + event.res.status = sessionId ? 404 : 400 + return sessionId + ? 'Not Found: unknown MCP session' + : 'Bad Request: missing MCP session ID' + } + + return respond(event, await session.transport.handleRequest(req)) + })) + + return { + dispose: async () => { + const live = [...sessions.values()] + sessions.clear() + await Promise.all(live.map(session => session.dispose())) + }, + } +} + +/** + * Copy a web `Response` from the MCP transport onto the h3 event's response + * and return its body. Returning the body (a `ReadableStream` or `null`) + * rather than the `Response` object avoids h3's 404-fall-through behavior. + */ +function respond(event: H3Event, response: Response): ReadableStream | string { + event.res.status = response.status + event.res.statusText = response.statusText + response.headers.forEach((value, key) => { + event.res.headers.set(key, value) + }) + // h3 middleware only falls through on `undefined`; return `''` (not + // `null`) for empty bodies so the response terminates the chain with the + // status/headers we set above rather than continuing to the SPA static + // catch-all. + return response.body ?? '' +} diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index 7b91d49..21f9d64 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -13,6 +13,14 @@ export const DEVFRAME_CONNECTION_META_FILENAME = '__connection.json' * handler here without colliding with its own routes (HMR, asset serving). */ export const DEVFRAME_WS_ROUTE = '__devframe_ws' + +/** + * Route the Streamable-HTTP MCP endpoint is bound to, relative to a + * devframe's base path. Sits next to `__connection.json` and the WS route + * so an MCP client reaches it on the same origin the SPA loaded from — the + * dev server shares one port for HTTP, WS, and MCP. Opt-in via `cli.mcp`. + */ +export const DEVFRAME_MCP_ROUTE = '__mcp' export const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME = '__rpc-dump/index.json' export const DEVFRAME_DOCK_IMPORTS_FILENAME = '__client-imports.js' export const DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID = '/__devframe-client-imports.js' diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index 50caf5c..2b6de57 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -103,6 +103,14 @@ export interface ConnectionMeta { * URL with its protocol swapped, or a path resolved same-origin. */ websocket?: number | string | ConnectionMetaWebsocket + /** + * Present when the dev server exposes a route-based MCP endpoint + * (`cli.mcp`). Advertises the MCP Streamable-HTTP route so in-browser + * tooling (e.g. an MCP inspector) can discover it without guessing the + * path. `path` is relative to `__connection.json`'s location, like the + * WebSocket `path`. + */ + mcp?: { path: string } /** * Names of RPC functions that have declared `jsonSerializable: true`. * Used by the WS / static client to dispatch the per-call wire diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 7bceb60..e401547 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -59,6 +59,37 @@ export interface DevframeWsOptions { url?: string } +/** + * Configuration for the route-based MCP server mounted alongside the dev + * server (opt-in 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. + * + * @experimental The agent-native surface is experimental and may change + * without a major version bump until it stabilizes. + */ +export interface McpRouteOptions { + /** + * Route segment the MCP endpoint binds to, relative to the SPA base. + * Default: `__mcp` (i.e. `/__mcp` standalone, `/__/__mcp` hosted). + */ + path?: string + /** + * Extra `Origin` header values to accept beyond the loopback default + * (`localhost`/`127.0.0.1`/`::1` and any `Origin`-less native client). + * Add your LAN/tunnel origin here when reaching the endpoint from another + * host, mirroring the WS transport's origin gate. Pass `false` to disable + * origin checking entirely (not recommended). Default: loopback-only. + * + * This is the endpoint's DNS-rebinding protection — the shared + * `isAllowedOrigin` gate the WS upgrade already uses, applied as external + * middleware (the approach the MCP SDK now recommends over its own + * deprecated `allowedHosts`/`allowedOrigins` transport flags). + */ + allowedOrigins?: readonly string[] | false +} + export interface DevframeCliOptions { /** Binary name; default: the devframe's `id`. */ command?: string @@ -85,6 +116,22 @@ export interface DevframeCliOptions { * `devtools.clientAuth` today. */ auth?: boolean + /** + * Expose a route-based MCP server alongside the dev server, speaking the + * MCP Streamable-HTTP transport at `/__mcp` (relative to the base path). + * It surfaces the same `ctx.agent` tools + shared-state resources as the + * stdio `mcp` command, but against the live, running server. + * + * - `false` / omitted (default) — no MCP route is mounted. + * - `true` — mount at the default `__mcp` route with the loopback-only + * origin gate. + * - {@link McpRouteOptions} — customise the route path / allowed origins. + * + * The `--mcp` / `--no-mcp` CLI flags override this per run. + * + * @experimental + */ + mcp?: boolean | McpRouteOptions /** Author's SPA dist directory (served as the devframe's UI). */ distDir?: string /** diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index 60ed9a7..17c29a5 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -11,6 +11,7 @@ export interface CreateDevServerOptions { ws?: DevframeWsOptions; app?: H3; openBrowser?: boolean | string; + mcp?: boolean | McpRouteOptions; onReady?: (_: { origin: string; port: number; diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js index fbf2285..57441c3 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js @@ -1,6 +1,6 @@ /** * Generated by tsnapi — public API snapshot of `devframe/adapters/mcp` */ -// #region Functions -export async function createMcpServer(_, _) {} +// #region Other +export { createMcpServer } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index 5229899..134ff96 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -12,6 +12,7 @@ export declare const DEVFRAME_CONNECTION_META_FILENAME: string; export declare const DEVFRAME_DIRNAME: string; export declare const DEVFRAME_DOCK_IMPORTS_FILENAME: string; export declare const DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID: string; +export declare const DEVFRAME_MCP_ROUTE: string; export declare const DEVFRAME_MOUNT_PATH: string; export declare const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH: string; export declare const DEVFRAME_OTP_URL_PARAM: string; diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index 0005a46..9f16fe5 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -12,6 +12,7 @@ export var DEVFRAME_CONNECTION_META_FILENAME /* const */ export var DEVFRAME_DIRNAME /* const */ export var DEVFRAME_DOCK_IMPORTS_FILENAME /* const */ export var DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID /* const */ +export var DEVFRAME_MCP_ROUTE /* const */ export var DEVFRAME_MOUNT_PATH /* const */ export var DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH /* const */ export var DEVFRAME_OTP_URL_PARAM /* const */ diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index de33719..606fd0c 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -50,6 +50,7 @@ export { EntriesToObject } export { EventEmitter } export { EventsMap } export { EventUnsubscribe } +export { McpRouteOptions } export { PartialWithoutId } export { RpcBroadcastOptions } export { RpcFunctionAgentOptions } diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index b484092..92dd021 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -46,6 +46,7 @@ export { EntriesToObject } export { EventEmitter } export { EventsMap } export { EventUnsubscribe } +export { McpRouteOptions } export { PartialWithoutId } export { RpcBroadcastOptions } export { RpcFunctionAgentOptions }