diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md index ade90e64..f1fc382c 100644 --- a/docs/guide/agent-native.md +++ b/docs/guide/agent-native.md @@ -16,7 +16,7 @@ Three building blocks: 1. **An `agent` field on `defineRpcFunction`.** Add `agent: { description, ... }` to opt a function in. Functions without the field stay private. 2. **`ctx.agent`** — a host exposed on `DevframeNodeContext`. Plugins register tools that aren't backed by an RPC, and expose readable resources (e.g. a Markdown build summary). -3. **The MCP adapter** (`devframe/adapters/mcp`) — translates the agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server, currently over `stdio`. +3. **The MCP adapter** (`devframe/adapters/mcp`) — translates the agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server, over `stdio` (`devframe mcp`) or as a Streamable-HTTP route on the dev server (`--mcp`, advertised in `__connection.json`). ## Exposing an RPC function @@ -118,6 +118,52 @@ Add an entry to `claude_desktop_config.json`: Restart Claude Desktop. The tools you flagged with `agent: { ... }` (plus any `registerTool` calls) show up in the MCP tool drawer. Resources are reachable as `devframe://resource/` and `devframe://state/` URIs. +## Writing descriptions agents act on + +A tool description is a prompt, not documentation. The agent decides *when* to call your tool from the description alone, so tell it — state when to reach for the tool, not just what it returns: + + + +```ts +// ✗ Bad: describes the mechanism +agent: { description: 'Returns the session summary object.' } +// ✓ Good: tells the agent when and why +agent: { description: 'Summarize the current build session — durations, chunk counts, warnings. Call this before proposing any build-config change.' } +``` + +Two conventions: + +- **Lead with the action and the trigger.** "Call this before/after/when …" steers proactive use; a bare noun phrase gets ignored. +- **State freshness and cost.** "Safe to call freely" / "expensive, call once per session" lets the agent budget calls. + +## Gateway tools + +A gateway tool returns *instructions and locations* instead of doing the work — the pattern for anything the agent can do better directly (reading bundled docs, running a CLI it has shell access to): + +```ts +ctx.agent.registerTool({ + id: 'my-plugin:docs', + description: 'Locate the version-accurate docs for this tool. Call before answering questions about its config format.', + safety: 'read', + handler: () => ({ + docsPath: resolveInstalledDocsDir(), + hint: 'Read the file matching your topic; do not rely on training-data knowledge of this config format.', + }), +}) +``` + +The agent gets a path and a next step; the actual reading happens with its own tools, which are faster and keep large content out of the MCP payload. + +## Structured errors + +A coded devframe diagnostic thrown from a tool handler crosses the MCP boundary as structured JSON rather than a flattened message: + +```json +{ "error": { "code": "DF0017", "message": "…", "fix": "…", "docs": "https://devfra.me/errors/df0017" } } +``` + +Agents can act on `fix` directly and follow `docs` for detail — prefer throwing coded diagnostics from anything agent-reachable. + ## Safety model - **Opt-in exposure.** Functions opt in via the `agent` field; everything else stays private. diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index a6143567..78ceaf7f 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -286,6 +286,34 @@ function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteO return mcp === true ? {} : 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 {@link createDevServer}), or `undefined` when the route is + * disabled. + * + * Hosted bridges that hand-roll their connection meta (`viteDevBridge`, + * `@devframes/next`'s handler) pass the side-car `port`: the advertised path + * becomes absolute (the side-car mounts at `/`) and the client dials + * `:`. Without `port` the path stays relative, resolved + * against `__connection.json`'s own location (the same-server default). + * + * @experimental + */ +export function resolveMcpConnectionMeta( + def: DevframeDefinition, + mcp: boolean | McpRouteOptions | undefined, + port?: number, +): ConnectionMeta['mcp'] { + const config = resolveMcpConfig(mcp ?? def.cli?.mcp) + if (!config) + return undefined + const route = withoutLeadingSlash(config.path ?? DEVFRAME_MCP_ROUTE) + return port != null + ? { path: withLeadingSlash(route), port } + : { path: route } +} + /** * Resolve the three WS connection scenarios from the definition / call-site * config into a concrete server bind path, optional dedicated port, and the diff --git a/packages/devframe/src/adapters/mcp/__tests__/stringify.test.ts b/packages/devframe/src/adapters/mcp/__tests__/stringify.test.ts index 9cdcecd7..c628f0da 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/stringify.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/stringify.test.ts @@ -1,3 +1,4 @@ +import { Diagnostic } from 'nostics' import { describe, expect, it } from 'vitest' import { formatMcpError, stringifyForMcp } from '../stringify' @@ -110,4 +111,28 @@ describe('formatMcpError', () => { const err = new Error('outer', { cause: 'bad input' }) expect(formatMcpError(err)).toBe('Error: outer (cause: bad input)') }) + + it('emits structured JSON for a nostics Diagnostic', () => { + const diagnostic = new Diagnostic({ + code: 'DF9999', + why: 'Something coded went wrong', + fix: 'Run the fixer.', + docs: 'https://devfra.me/errors/df9999', + }) + expect(JSON.parse(formatMcpError(diagnostic))).toEqual({ + error: { + code: 'DF9999', + message: 'Something coded went wrong', + fix: 'Run the fixer.', + docs: 'https://devfra.me/errors/df9999', + }, + }) + }) + + it('omits absent fix/docs from the structured diagnostic payload', () => { + const diagnostic = new Diagnostic({ code: 'DF9998', why: 'No extras' }) + expect(JSON.parse(formatMcpError(diagnostic))).toEqual({ + error: { code: 'DF9998', message: 'No extras' }, + }) + }) }) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 12de0a30..5bb0ba4f 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -18,8 +18,10 @@ import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from './to-json-sc export interface CreateMcpServerOptions { /** - * Transport to use. Only `'stdio'` is implemented today; HTTP support - * is planned in a follow-up PR. + * Transport to use. `createMcpServer` itself runs `'stdio'` (a standalone + * process with its own host context); the Streamable-HTTP transport is + * served route-based by the dev server instead — see `mountMcpHttp` and + * the `mcp` option on `createDevServer` / `createCac`'s `--mcp` flag. */ transport?: 'stdio' /** diff --git a/packages/devframe/src/adapters/mcp/stringify.ts b/packages/devframe/src/adapters/mcp/stringify.ts index 8e26035a..e8bb43dd 100644 --- a/packages/devframe/src/adapters/mcp/stringify.ts +++ b/packages/devframe/src/adapters/mcp/stringify.ts @@ -1,3 +1,5 @@ +import { Diagnostic } from 'nostics' + /** * JSON-coercing serializer for MCP text payloads. * @@ -55,11 +57,25 @@ export function stringifyForMcp(value: unknown): string { } /** - * Format a thrown value for an MCP `isError` text payload. Surfaces the - * `Error.name`/`message`, and one level of `cause.message` so context - * isn't dropped silently. + * Format a thrown value for an MCP `isError` text payload. + * + * A nostics `Diagnostic` (every coded devframe error) becomes structured + * JSON — `{ error: { code, message, fix?, docs? } }` — so an agent receives + * the actionable next step (`fix`) and the docs URL instead of a bare + * message string. Other errors surface `Error.name`/`message`, plus one + * level of `cause.message` so context isn't dropped silently. */ export function formatMcpError(error: unknown): string { + if (error instanceof Diagnostic) { + return JSON.stringify({ + error: { + code: error.code, + message: error.message, + ...(error.fix ? { fix: error.fix } : {}), + ...(error.docs ? { docs: error.docs } : {}), + }, + }, null, 2) + } if (!(error instanceof Error)) return String(error) const cause = (error as { cause?: unknown }).cause diff --git a/packages/devframe/src/helpers/__tests__/vite.test.ts b/packages/devframe/src/helpers/__tests__/vite.test.ts new file mode 100644 index 00000000..7174b9da --- /dev/null +++ b/packages/devframe/src/helpers/__tests__/vite.test.ts @@ -0,0 +1,103 @@ +import type { DevframeDefinition } from '../../types/devframe' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { getPort } from 'get-port-please' +import { afterEach, describe, expect, it } from 'vitest' +import { viteDevBridge } from '../vite' + +function defineTestDef(): DevframeDefinition { + return { + id: 'vite-bridge-test', + name: 'Vite Bridge Test', + version: '0.0.0', + packageName: '@devframe/vite-bridge-test', + homepage: '', + description: '', + setup(ctx) { + ctx.agent.registerTool({ + id: 'greet', + description: 'Say hello.', + safety: 'read', + handler: () => ({ greeting: 'hi' }), + }) + }, + } +} + +interface FakeViteServer { + middlewares: { use: (path: string, handler: any) => void } + httpServer: null + routes: Map +} + +function fakeViteServer(): FakeViteServer { + const routes = new Map() + return { + middlewares: { use: (path, handler) => routes.set(path, handler) }, + httpServer: null, + routes, + } +} + +/** Invoke a registered connect-style middleware and capture its JSON body. */ +async function readJsonMiddleware(handler: any): Promise { + return await new Promise((resolvePromise) => { + handler(undefined, { + setHeader: () => {}, + end: (body: string) => resolvePromise(JSON.parse(body)), + }) + }) +} + +describe('viteDevBridge (bridge mode mcp)', () => { + let bridge: ReturnType | undefined + + afterEach(async () => { + await bridge?.closeBundle?.() + bridge = undefined + }) + + it('forwards the mcp option and advertises the side-car endpoint in the meta', async () => { + const port = await getPort({ port: 19710, host: '127.0.0.1' }) + bridge = viteDevBridge(defineTestDef(), { + devMiddleware: { port, host: '127.0.0.1' }, + mcp: true, + }) + + const server = fakeViteServer() + await bridge.configureServer(server) + + const metaHandler = server.routes.get('/__vite-bridge-test/__connection.json') + expect(metaHandler).toBeDefined() + const meta = await readJsonMiddleware(metaHandler) + expect(meta.backend).toBe('websocket') + expect(meta.websocket).toEqual({ port, path: '/__devframe_ws' }) + expect(meta.mcp).toEqual({ port, path: '/__mcp' }) + + // The advertised endpoint is live: a real MCP client can connect and + // list the agent tools on the side-car origin. + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/__mcp`)) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + try { + await client.connect(transport) + const tools = await client.listTools() + expect(tools.tools.map(t => t.name)).toContain('greet') + } + finally { + await client.close() + } + }) + + it('omits the mcp block when the option is not set', async () => { + const port = await getPort({ port: 19720, host: '127.0.0.1' }) + bridge = viteDevBridge(defineTestDef(), { + devMiddleware: { port, host: '127.0.0.1' }, + }) + + const server = fakeViteServer() + await bridge.configureServer(server) + + const meta = await readJsonMiddleware(server.routes.get('/__vite-bridge-test/__connection.json')) + expect(meta.mcp).toBeUndefined() + }) +}) diff --git a/packages/devframe/src/helpers/vite.ts b/packages/devframe/src/helpers/vite.ts index 82accb7b..7c59ed38 100644 --- a/packages/devframe/src/helpers/vite.ts +++ b/packages/devframe/src/helpers/vite.ts @@ -1,9 +1,9 @@ import type { DevframeAuthHandler } from '../node/auth/handler' -import type { DevframeDefinition } from '../types/devframe' +import type { DevframeDefinition, McpRouteOptions } from '../types/devframe' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { resolve } from 'pathe' import { normalizeBasePath, resolveBasePath } from '../adapters/_shared' -import { createDevServer, resolveDevServerPort } from '../adapters/dev' +import { createDevServer, resolveDevServerPort, resolveMcpConnectionMeta } from '../adapters/dev' import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants' import { diagnostics } from '../node/diagnostics' @@ -50,6 +50,19 @@ export interface ViteDevBridgeOptions { * @default false */ auth?: boolean | DevframeAuthHandler + /** + * Expose the side-car's route-based MCP server (Streamable-HTTP) and + * advertise it in the bridge's `__connection.json`. Forwarded to + * {@link createDevServer}: overrides `def.cli?.mcp`, `undefined` falls + * through to it, `false` disables the route regardless. Only applies in + * bridge mode (`devMiddleware`); the static-mount mode starts no server. + * + * The endpoint lives on the side-car's own port, so the advertised meta + * carries `{ port, path }` — see `ConnectionMeta['mcp']`. + * + * @experimental + */ + mcp?: boolean | McpRouteOptions } export interface DevframeVitePlugin { @@ -126,6 +139,7 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio // Hosted adapter: the host owns auth, so the bridged devframe's own // gate stays off unless the caller explicitly opts back in. auth: options.auth ?? false, + mcp: options.mcp, }) } catch (e) { @@ -136,13 +150,16 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio // The side-car listens on its own port, so the browser must target that // port explicitly (it can't reach the WS on Vite's origin). The route is // `/__devframe_ws` — the bridge `createDevServer` mounts the SPA at `/`, so its WS - // upgrade handler is bound there. + // upgrade handler is bound there. The MCP route (when enabled) lives on + // the same side-car origin, advertised with the same explicit port. + const mcpMeta = resolveMcpConnectionMeta(d, options.mcp, port) const metaPath = `${base}${DEVFRAME_CONNECTION_META_FILENAME}` server.middlewares.use(metaPath, (_req: unknown, res: any) => { res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ backend: 'websocket', websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` }, + ...(mcpMeta ? { mcp: mcpMeta } : {}), })) }) diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index b2ed32c5..57fca826 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -120,9 +120,13 @@ export interface ConnectionMeta { * (`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`. + * WebSocket `path`. `port` is set when the endpoint lives on a side-car + * server on its own port (bridge mode — `viteDevBridge`, + * `@devframes/next`): the client combines the page hostname with `port` + * and resolves `path` against that origin, mirroring + * {@link ConnectionMetaWebsocket.port}. */ - mcp?: { path: string } + mcp?: { path: string, port?: number } /** * 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/next/src/handler.ts b/packages/next/src/handler.ts index c264352f..7856f4d4 100644 --- a/packages/next/src/handler.ts +++ b/packages/next/src/handler.ts @@ -4,7 +4,7 @@ import type { DevframeDefinition, DevframeStorageScope } from 'devframe/types' import { homedir } from 'node:os' import { join } from 'node:path' import process from 'node:process' -import { createDevServer, resolveDevServerPort } from 'devframe/adapters/dev' +import { createDevServer, resolveDevServerPort, resolveMcpConnectionMeta } from 'devframe/adapters/dev' import { DEVFRAME_WS_ROUTE } from 'devframe/constants' import { createDevframeNextHost } from './host' @@ -31,6 +31,16 @@ export interface CreateDevframeNextHandlerOptions { resolveOrigin?: () => string /** Override where persisted devframe state lives (defaults under the cwd / home). */ getStorageDir?: (scope: DevframeStorageScope) => string + /** + * Expose the side-car's route-based MCP server (Streamable-HTTP) and + * advertise it in the handler's `__connection.json`. Forwarded to + * `createDevServer`: overrides `def.cli?.mcp`, `undefined` falls through to + * it, `false` disables the route regardless. The endpoint lives on the + * side-car's own port, so the advertised meta carries `{ port, path }`. + * + * @experimental + */ + mcp?: CreateDevServerOptions['mcp'] } export interface DevframeNextHandler { @@ -118,10 +128,13 @@ export function createDevframeNextHandler( flags: options.flags, openBrowser: false, auth: options.auth ?? false, + mcp: options.mcp, }) + const mcpMeta = resolveMcpConnectionMeta(def, options.mcp, port) nextHost.setConnectionMeta({ backend: 'websocket', websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` }, + ...(mcpMeta ? { mcp: mcpMeta } : {}), }) })() diff --git a/packages/next/test/handler.test.ts b/packages/next/test/handler.test.ts index 27aae05f..00147755 100644 --- a/packages/next/test/handler.test.ts +++ b/packages/next/test/handler.test.ts @@ -63,4 +63,37 @@ describe('createDevframeNextHandler', () => { def.cli = undefined expect(() => createDevframeNextHandler(def)).toThrow(/cli\.distDir/) }) + + it('forwards the mcp option and advertises the side-car endpoint', async () => { + const dist = mkdtempSync(join(tmpdir(), 'df-next-mcp-')) + writeFileSync(join(dist, 'index.html'), 'ok') + + handler = createDevframeNextHandler(makeDef(dist), { host: '127.0.0.1', mcp: true }) + await handler.ready + + const meta = await handler.fetch(new Request('http://localhost:3000/__test-next/__connection.json')) + const body = await meta.json() as { + websocket: { port: number, path: string } + mcp?: { port: number, path: string } + } + expect(body.mcp).toEqual({ port: body.websocket.port, path: '/__mcp' }) + + // The advertised endpoint answers MCP initialize on the side-car origin. + const init = await fetch(`http://127.0.0.1:${body.mcp!.port}${body.mcp!.path}`, { + 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' } }, + }), + }) + expect(init.status).toBe(200) + expect(init.headers.get('mcp-session-id')).toBeTruthy() + await init.body?.cancel() + }) }) diff --git a/plans/031-agent-native-mcp-wave.md b/plans/031-agent-native-mcp-wave.md new file mode 100644 index 00000000..c1d3ce8b --- /dev/null +++ b/plans/031-agent-native-mcp-wave.md @@ -0,0 +1,156 @@ +# Plan 031: Agent-native MCP wave — bridges, core surface, connector + +> **Executor instructions**: This is an **umbrella plan** executed in three +> phase-batched PRs. Each phase is independently shippable and gated by +> `pnpm lint && pnpm test && pnpm typecheck && pnpm build`. Honor the STOP +> conditions; update this plan's row in `plans/README.md` as phases land. + +## Status + +- **Priority**: P2 +- **Effort**: L (three phases) +- **Risk**: LOW-MED (additive, experimental surface) +- **Depends on**: — (PR #140 / `@devframes/next` already merged) +- **Category**: direction +- **Planned at**: commit `51c8b29`, 2026-07-28 + +## Why this matters + +Devframe already made the architectural bet Vercel's `next-devtools-mcp` +validated in its 0.4.0 rewrite: the real MCP endpoint belongs **inside the +framework** (Next 16's `/_next/mcp` ⇔ devframe's `/__mcp` + `devframe mcp`), +with a thin external connector doing discovery + proxy + gateways. Devframe has +the embedded half; this wave builds the connector half and adopts the +conventions Vercel proved out (agent-steering errors, gateway tools, +tools-first surface), demonstrable end-to-end via `@devframes/next` — the +literal "/_next/mcp" shape on devframe primitives. + +## Non-goals + +- **Telemetry** — rejected, not deferred: devframe is a headless OSS primitive; + usage reporting is a downstream product decision. +- **Transparent tool re-projection** in the connector (each discovered tool as + a first-class namespaced tool with `list_changed` forwarding) — documented + follow-up; v1 ships the two-tool index/call gateway. +- **git write ops** (`stage`/`unstage`/`commit`) stay agent-invisible. +- **MCP resources/prompts expansion** — tools-first, matching client reality. + +## Phase 1 — quick wins (PR 1) + +1. **Bridge MCP forwarding** — both hosted bridges gain an `mcp` option + (`boolean | McpRouteOptions`, forwarded to `createDevServer`) and advertise + the endpoint in their hand-rolled connection meta: + - `viteDevBridge` (`packages/devframe/src/helpers/vite.ts`) + - `createDevframeNextHandler` (`packages/next/src/handler.ts`) + `ConnectionMeta['mcp']` gains an optional `port` (side-car servers live on + their own origin, mirroring `ConnectionMetaWebsocket.port`). +2. **nostics → MCP structured errors** — `formatMcpError` + (`packages/devframe/src/adapters/mcp/stringify.ts`) detects a nostics + `Diagnostic` and emits structured JSON + `{ error: { code, message, fix?, docs? } }` so agents receive the coded + next-step (`fix`) and the docs URL instead of a bare message string. +3. **Stale comment** — `build-server.ts` header still says HTTP transport is + "planned"; it shipped in `http.ts`. +4. **Docs** — `docs/guide/agent-native.md` gains the conventions this wave + standardizes: agent-steering tool descriptions (description-as-mini-prompt), + the gateway-tool pattern (tools that return paths/instructions instead of + proxying work), and the structured-error contract. + +## Phase 2 — core surface (PR 2) + +5. **Fetch-handler extraction** — split `mountMcpHttp` + (`packages/devframe/src/adapters/mcp/http.ts`) into a framework-agnostic + `createMcpFetchHandler(ctx, options)` (web `Request` → `Response`, owns the + session map + origin gate) and a thin h3 wrapper. Enables non-h3 hosts — + `@devframes/next`'s host serves via `app.fetch` already. +6. **Core `read_state` tool** — one built-in MCP tool in + `buildMcpServerFromContext`: `read_state(key?)`; no key → key list, with + key → JSON value. Honors the same `exposeSharedState` filter as the + resource projection (which stays — many clients only consume tools). +7. **Hub commands → agent bridge** — opt-in `agent?: { description, safety?, + args? }` on `DevframeServerCommandInput` (mirrors the RPC convention; + description required; optional valibot args schema reusing + `valibotArgsToJsonSchema`, zero-arg default). `createHubContext` projects + agent-flagged, handler-bearing server commands into `ctx.agent` tools, + tracking register/update/unregister. `when` clauses evaluate client-side + only and are **not** enforced for agent calls — documented caveat. +8. **git read-only five** — `status`/`log`/`show`/`branches`/`diff` gain + valibot args schemas + `agent` descriptions (`safety: 'read'`). + Cross-reference plan 029 (also touches `plugins/git`) — serialize if both + are in flight. + +## Phase 3 — flagship: registry + connector + proof (PR 3) + +9. **Instance registry** — `registerDevframeInstance(record)` exported from + `devframe/node`: atomic per-instance JSON at + `~/.devframe/instances/-.json` + (`{ pid, port, host, basePath, name, id, rootDir, mcp: { path } | null, + startedAt }`), removed on close, pruned on read by failed + `__connection.json` probes. `createDevServer` registers automatically + (covers CLI dev, vite bridge, and the Next side-car); + `createDevframeNextHost` calls it explicitly for the in-process path. +10. **`devframe` bin + `connect`** — first real bin on the `devframe` package. + `devframe connect` runs a stdio MCP server exposing two gateway tools: + - `devframe_index` — list live instances (registry read + liveness probe + + prune) and each MCP-enabled instance's tools; instances with `mcp: null` + carry a funnel hint ("restart with `--mcp`…"). + - `devframe_call` — invoke one tool on one instance (SDK client over + Streamable-HTTP to the instance's advertised `mcp` endpoint). + `--port ` probes an explicit port besides the registry. Requires the + optional `@modelcontextprotocol/sdk` peer; a missing peer produces a coded + diagnostic with install instructions. +11. **In-process MCP for `@devframes/next`** — `createDevframeNextHost` gains + `mountMcp(ctx, base, options?)` built on `createMcpFetchHandler`, so a + Next app serves MCP from its own origin (the `/_next/mcp` shape). The hub + example wires it and advertises it in its connection meta. +12. **Proof (CI e2e, both gates)**: + - `examples/files-inspector`: register one gateway tool; e2e boots + `dev --mcp`, runs `devframe connect` over stdio (MCP SDK client), + asserts `devframe_index` discovers it and `devframe_call` round-trips. + - `examples/minimal-next-devframe-hub`: e2e boots the Next dev server, + asserts the connector discovers the in-process hub endpoint. +13. **Docs** — `docs/adapters/mcp.md` (+ agent-native guide): `devframe + connect` client config, the registry contract, `mountMcp` for custom + hosts. + +## Commands you will need + +| Purpose | Command | Expected | +|---|---|---| +| Full gate | `pnpm lint && pnpm test && pnpm typecheck && pnpm build` | exit 0 | +| One package's tests | `pnpm vitest run ` | exit 0 | +| API snapshots refresh | `pnpm test` (tsnapi compares against fresh `dist/`) | snapshots updated intentionally | + +## Done criteria + +- [ ] Phase 1: both bridges forward + advertise MCP; `formatMcpError` emits + `{ error: { code, message, fix?, docs? } }` for diagnostics; stale + comment gone; conventions documented. +- [ ] Phase 2: `createMcpFetchHandler` public; `read_state` tool live; + agent-flagged hub commands appear as MCP tools; git read-only five are + agent-visible with schemas. +- [ ] Phase 3: instances self-register and prune; `devframe connect` indexes + and calls a live app over stdio; Next host serves in-process MCP; both + e2e gates green in CI. +- [ ] Every phase: full gate green, API snapshots updated deliberately, new + node-side errors use coded diagnostics with docs pages. +- [ ] `plans/README.md` row updated per phase. + +## STOP conditions + +Stop and report if: +- The MCP SDK's Streamable-HTTP client cannot talk to the mounted endpoint + (version skew) — pin/document the working SDK range instead of hand-rolling + a JSON-RPC client. +- The commands bridge requires evaluating `when` server-side to be safe — + descope to excluding `when`-gated commands rather than porting the evaluator. +- Booting Next in CI proves flaky — demote the Next e2e to a manually-verified + walkthrough and note it here. + +## Maintenance notes + +- Everything ships `@experimental`, consistent with the existing agent/MCP + surface. +- The connector's two-tool surface is the compatibility contract; transparent + re-projection can layer on top without breaking it. +- Registry records are self-describing JSON — additive fields are safe. diff --git a/plans/README.md b/plans/README.md index 68e0b559..d753f503 100644 --- a/plans/README.md +++ b/plans/README.md @@ -42,6 +42,7 @@ changes are allowed as long as they're marked). | 027 | Spike: `@devframes/next` host-integration package | direction | P3 | M | — | DONE (shipped `@devframes/next`, experimental — see `docs/helpers/next.md`; hub + next-runtime-snapshot examples adopt it) | | 029 | Bring `@devframes/plugin-git` to the host baseline | direction/dx | P3 | S-M | — | TODO | | 030 | Spike: server-side auth enforcement ⚠️ | security | P2 | L | 003, 007, 015 | DONE | +| 031 | Agent-native MCP wave (bridges, core surface, connector) | direction | P2 | L | — | IN PROGRESS | Status values: TODO | IN PROGRESS | DONE | BLOCKED (one-line reason) | REJECTED (one-line rationale). diff --git a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts index 2fd71f27..aeb6f943 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts @@ -10,6 +10,7 @@ export interface CreateDevframeNextHandlerOptions { auth?: CreateDevServerOptions['auth']; resolveOrigin?: () => string; getStorageDir?: (_: DevframeStorageScope) => string; + mcp?: CreateDevServerOptions['mcp']; } export interface CreateDevframeNextHostOptions { resolveOrigin: () => string; diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index 45f23a19..21b409af 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -28,4 +28,5 @@ export interface ResolveDevServerPortOptions { // #region Functions export declare function createDevServer(_: DevframeDefinition, _?: CreateDevServerOptions): Promise; export declare function resolveDevServerPort(_: DevframeDefinition, _?: ResolveDevServerPortOptions): Promise; +export declare function resolveMcpConnectionMeta(_: DevframeDefinition, _: boolean | McpRouteOptions | undefined, _?: number): ConnectionMeta['mcp']; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js index 6d6b4945..5e896c65 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js @@ -4,4 +4,5 @@ // #region Other export { createDevServer } export { resolveDevServerPort } +export { resolveMcpConnectionMeta } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts index bfdaa265..9649aac8 100644 --- a/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts @@ -23,6 +23,7 @@ export interface ViteDevBridgeOptions { flags?: Record; }; auth?: boolean | DevframeAuthHandler; + mcp?: boolean | McpRouteOptions; } // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index bc24eb81..3fbcfaf1 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -63,6 +63,7 @@ export interface ConnectionMeta { websocket?: number | string | ConnectionMetaWebsocket; mcp?: { path: string; + port?: number; }; jsonSerializableMethods?: string[]; baseUrl?: string;