From 3d8f73a4244ee417811506cb8c03d13a8e4a1e34 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 08:33:58 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat(agent):=20agent-native=20wave=20phase?= =?UTF-8?q?=202=20=E2=80=94=20fetch=20handler,=20read=5Fstate,=20commands?= =?UTF-8?q?=20bridge,=20git=20agent=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createMcpFetchHandler: framework-agnostic web-standard MCP endpoint extracted from mountMcpHttp (now a thin h3 wrapper); exported from devframe/adapters/mcp for custom hosts (Next App Router, etc.) - built-in read_state(key?) MCP tool over shared state, honoring the exposeSharedState filter alongside the resource projection - hub commands gain opt-in agent exposure: agent field (description, safety, valibot args) projects handler-bearing commands into ctx.agent; DF8404 rejects agent exposure on group-only commands - valibot→JSON-Schema conversion moved to devframe/utils/valibot-json-schema (public) so SDK-free hosts can convert schemas - rpc: schema-typed handlers may be async — Thenable> in the schema-typed definition branch - git plugin: status/log/show/branches/diff agent-flagged with valibot args/returns schemas (read-only surface; writes stay private) - docs: hub commands-as-tools, read_state, custom-host mounting; DF8404 page --- docs/adapters/mcp.md | 27 +++ docs/errors/DF8404.md | 48 +++++ docs/guide/agent-native.md | 2 + docs/guide/hub.md | 18 ++ .../adapters/mcp/__tests__/mcp-server.test.ts | 80 +++++++++ .../devframe/src/adapters/mcp/build-server.ts | 74 +++++++- packages/devframe/src/adapters/mcp/fetch.ts | 167 ++++++++++++++++++ packages/devframe/src/adapters/mcp/http.ts | 151 ++-------------- packages/devframe/src/adapters/mcp/index.ts | 6 + packages/devframe/src/rpc/types.ts | 16 +- packages/hub/package.json | 1 + .../src/node/__tests__/host-commands.test.ts | 121 +++++++++++++ packages/hub/src/node/diagnostics.ts | 4 + packages/hub/src/node/host-commands.ts | 82 ++++++++- packages/hub/src/types/commands.ts | 45 +++++ plugins/git/package.json | 3 +- plugins/git/src/rpc/functions/branches.ts | 4 + plugins/git/src/rpc/functions/diff.ts | 28 +++ plugins/git/src/rpc/functions/log.ts | 33 +++- plugins/git/src/rpc/functions/show.ts | 53 +++++- plugins/git/src/rpc/functions/status.ts | 4 + pnpm-lock.yaml | 6 + .../tsnapi/@devframes/hub/index.snapshot.d.ts | 8 + .../tsnapi/@devframes/hub/node.snapshot.d.ts | 4 + .../tsnapi/@devframes/hub/node.snapshot.js | 4 + .../tsnapi/@devframes/hub/types.snapshot.d.ts | 1 + .../@devframes/plugin-og/rpc.snapshot.d.ts | 20 +-- .../devframe/adapters/mcp.snapshot.d.ts | 11 ++ .../tsnapi/devframe/adapters/mcp.snapshot.js | 1 + 29 files changed, 862 insertions(+), 160 deletions(-) create mode 100644 docs/errors/DF8404.md create mode 100644 packages/devframe/src/adapters/mcp/fetch.ts diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index 1772282f..1c830201 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -46,4 +46,31 @@ defineDevframe({ }) ``` +### Hosted bridges + +Both hosted bridges forward the same option to their side-car dev server and advertise the endpoint (with its port) in the `__connection.json` they serve: + +```ts +// Vite +viteDevBridge(devframe, { devMiddleware: true, mcp: true }) + +// Next.js (@devframes/next) +createDevframeNextHandler(devframe, { mcp: true }) +``` + +## Custom hosts + +`createMcpFetchHandler(ctx, options)` returns the endpoint as a web-standard `Request → Response` handler plus a `dispose()` for session teardown — mount it on any fetch-shaped server (a Next.js App Router route, a custom Node server). The h3 `mountMcpHttp` used by the dev server is a thin wrapper over it. + +```ts +import { createMcpFetchHandler } from 'devframe/adapters/mcp' + +const mcp = createMcpFetchHandler(ctx, { + serverName: 'my-tool (devframe)', + serverVersion: '1.0.0', + exposeSharedState: true, +}) +// route every method on /__mcp to mcp.fetch(request) +``` + See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example. diff --git a/docs/errors/DF8404.md b/docs/errors/DF8404.md new file mode 100644 index 00000000..3d457a76 --- /dev/null +++ b/docs/errors/DF8404.md @@ -0,0 +1,48 @@ +--- +outline: deep +--- + +# DF8404: Agent Exposure Without Handler + +## Message + +> Command "`{id}`" declares agent exposure but has no handler + +## Cause + +`ctx.commands.register(command)` or a command handle `update()` received a command carrying an `agent` field but no `handler`. Agent-exposed commands are projected into `ctx.agent` as callable tools (reaching MCP clients through the devframe MCP adapter), so they must be executable server-side — a handler-less command is a palette group and cannot run. + +## Example + +```ts +// ✗ Bad: group-only command opting into the agent surface +ctx.commands.register({ + id: 'my-tool:group', + title: 'My tool', + agent: { description: 'Run my tool.' }, + children: [/* … */], +}) + +// ✓ Good: the executable child carries the agent field +ctx.commands.register({ + id: 'my-tool:group', + title: 'My tool', + children: [ + { + id: 'my-tool:reload', + title: 'Reload', + agent: { description: 'Reload my tool\'s state. Call after changing its config.' }, + handler: () => reload(), + }, + ], +}) +``` + +## Fix + +- Add a `handler` to the command carrying the `agent` field. +- Or move the `agent` field to an executable child command. + +## Source + +- [`packages/hub/src/node/host-commands.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-commands.ts) — `DevframeCommandsHost.register()` and command handle `update()` validate agent exposure across the command tree. diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md index f1646322..b15720d8 100644 --- a/docs/guide/agent-native.md +++ b/docs/guide/agent-native.md @@ -79,6 +79,8 @@ ctx.agent.registerResource({ Every `ctx.rpc.sharedState` key is also automatically exposed to MCP as `devframe://state/`. Pass `exposeSharedState: false` (or a filter function) to `createMcpServer` to opt out. +Shared state is additionally reachable through the built-in **`read_state` tool** — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection. + ## Starting the MCP server The simplest path is the CLI: diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 2dac8605..48c52388 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -35,6 +35,24 @@ Every hub context auto-registers these RPC functions so framework kits don't rei Host-specific capabilities (open in editor, reveal in finder, …) ship as kit-registered RPC functions rather than as part of the hub surface. +## Commands as agent tools + +A server command opts into the [agent surface](./agent-native) with an `agent` field — the same default-deny convention as `defineRpcFunction`. Agent-flagged, handler-bearing commands are projected into `ctx.agent` as callable tools and reach MCP clients through the devframe MCP adapter: + +```ts +ctx.commands.register({ + id: 'app:build', + title: 'Run build', + agent: { + description: 'Run the production build. Call after config or dependency changes to verify the app still builds.', + args: [v.object({ configFile: v.optional(v.string()) })], + }, + handler: (opts?: { configFile?: string }) => runBuild(opts), +}) +``` + +`args` takes positional valibot schemas (a single `v.object(...)` is unwrapped into the tool's input object); omit it for a zero-argument tool. `safety` defaults to `'action'`. `when` clauses evaluate client-side only and are not enforced for agent calls — opt in a `when`-gated command only if running it outside its UI context is safe. + ## Cross-iframe dock activation The viewer's active dock is client-local state — which dock is on screen lives in the shell page, not in shared state. A mounted devframe runs in its own iframe on its own RPC client, so it can't reach that selection directly. `hub:docks:activate` bridges the gap: any connected client asks the hub to switch the active dock, and the hub relays the request to the shell. diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index 445f543e..99b24363 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -175,4 +175,84 @@ describe('mcp adapter (in-memory)', () => { await cleanup() } }) + + it('exposes shared state through the built-in read_state tool', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + await ctx.rpc.sharedState.get('my-plugin:counter', { + initialValue: { count: 7 }, + }) + + const listed = await client.listTools() + const tool = listed.tools.find(t => t.name === 'read_state') + expect(tool).toBeDefined() + expect(tool!.annotations?.readOnlyHint).toBe(true) + + // No key → key list. + const keys = await client.callTool({ name: 'read_state', arguments: {} }) + expect(keys.structuredContent).toEqual({ keys: ['my-plugin:counter'] }) + + // With key → the value. + const value = await client.callTool({ name: 'read_state', arguments: { key: 'my-plugin:counter' } }) + expect(value.structuredContent).toEqual({ key: 'my-plugin:counter', value: { count: 7 } }) + + // Unknown key → agent-actionable error. + const missing = await client.callTool({ name: 'read_state', arguments: { key: 'nope' } }) + expect(missing.isError).toBe(true) + const content = missing.content as Array<{ text: string }> + expect(content[0]!.text).toContain('unknown shared-state key') + } + finally { + await cleanup() + } + }) + + it('hides read_state when shared-state exposure is disabled', async () => { + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) + const { server, dispose } = buildMcpServerFromContext(ctx, { + serverName: 'test', + serverVersion: '0.0.0-test', + exposeSharedState: false, + }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await client.connect(clientTransport) + try { + const listed = await client.listTools() + expect(listed.tools.map(t => t.name)).not.toContain('read_state') + } + finally { + dispose() + await client.close() + await server.close() + } + }) + + it('respects the shared-state filter in read_state', async () => { + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) + await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } }) + await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } }) + const { server, dispose } = buildMcpServerFromContext(ctx, { + serverName: 'test', + serverVersion: '0.0.0-test', + exposeSharedState: key => key.startsWith('visible:'), + }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await client.connect(clientTransport) + try { + const keys = await client.callTool({ name: 'read_state', arguments: {} }) + expect(keys.structuredContent).toEqual({ keys: ['visible:key'] }) + + const hidden = await client.callTool({ name: 'read_state', arguments: { key: 'hidden:key' } }) + expect(hidden.isError).toBe(true) + } + finally { + dispose() + await client.close() + await server.close() + } + }) }) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index b2aac021..23169e6d 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -63,7 +63,7 @@ export function buildMcpServerFromContext( }, ) - registerToolHandlers(server, ctx) + registerToolHandlers(server, ctx, options.exposeSharedState) registerResourceHandlers(server, ctx, options.exposeSharedState) const notify = (method: string): void => { @@ -146,15 +146,85 @@ export async function createMcpServer( } } -function registerToolHandlers(server: Server, ctx: DevframeNodeContext): void { +/** + * Name of the built-in shared-state read tool. Tool-shaped access matters + * because many MCP clients only consume tools — the parallel + * `devframe://state/` resource projection stays for the clients that do + * read resources. + */ +const READ_STATE_TOOL = 'devframe:state:read' + +function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolean)): ((key: string) => boolean) | undefined { + if (exposeSharedState === false) + return undefined + return typeof exposeSharedState === 'function' ? exposeSharedState : () => true +} + +function readStateToolProjection(): Record { + return { + name: READ_STATE_TOOL, + title: 'Read shared state', + description: 'Read this devtool\'s live shared state. Call without arguments to list the available keys, then with a key to get that value as JSON. Safe to call freely.', + inputSchema: { + type: 'object', + properties: { + key: { + type: 'string', + description: 'A shared-state key from the key list. Omit to list all keys.', + }, + }, + }, + annotations: { + title: 'Read shared state', + readOnlyHint: true, + destructiveHint: false, + }, + } +} + +async function readStateResult( + ctx: DevframeNodeContext, + filter: (key: string) => boolean, + key: string | undefined, +): Promise { + const keys = ctx.rpc.sharedState.keys().filter(filter) + if (key === undefined) + return { keys } + if (!keys.includes(key)) + throw new Error(`unknown shared-state key "${key}" — call ${READ_STATE_TOOL} without arguments to list the available keys`) + const state = await ctx.rpc.sharedState.get(key) + return { key, value: state.value() } +} + +function registerToolHandlers( + server: Server, + ctx: DevframeNodeContext, + exposeSharedState: boolean | ((key: string) => boolean), +): void { + const stateFilter = sharedStateFilter(exposeSharedState) + server.setRequestHandler('tools/list', async () => { const tools = ctx.agent.list().tools.map(tool => projectTool(tool, ctx)) + // A registered agent tool of the same name wins over the built-in. + if (stateFilter && !ctx.agent.getTool(READ_STATE_TOOL)) + tools.push(readStateToolProjection()) return { tools } }) server.setRequestHandler('tools/call', async (request) => { const { name, arguments: args } = request.params try { + // Built-in shared-state read. A registered agent tool of the same + // name wins (mirroring the list projection above); plugin tools keep + // namespaced ids (`:`), so collisions are deliberate. + if (stateFilter && name === READ_STATE_TOOL && !ctx.agent.getTool(READ_STATE_TOOL)) { + const key = (args as { key?: string } | undefined)?.key + const result = await readStateResult(ctx, stateFilter, key) + return { + content: [{ type: 'text', text: stringifyForMcp(result) }], + structuredContent: result as Record, + } + } const tool = ctx.agent.getTool(name) const outputSchema = tool ? tool.outputSchema ?? computeOutputSchema(tool, ctx) diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts new file mode 100644 index 00000000..e192c185 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -0,0 +1,167 @@ +import type { DevframeNodeContext } from 'devframe/types' +import { randomUUID } from 'node:crypto' +import { isInitializeRequest, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server' +import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import { buildMcpServerFromContext } from './build-server' + +export interface CreateMcpFetchHandlerOptions { + /** 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 McpFetchHandler { + /** + * WHATWG-`fetch` handler for the MCP Streamable-HTTP endpoint. Hand every + * method (POST/GET/DELETE) on the endpoint's path to it — routing by path + * is the host's job. + */ + fetch: (request: Request) => Promise + /** Tear down every live MCP session (closes servers, drops subscriptions). */ + dispose: () => Promise +} + +interface McpSession { + transport: WebStandardStreamableHTTPServerTransport + dispose: () => Promise +} + +/** + * Build a framework-agnostic MCP Streamable-HTTP endpoint over a devframe + * context: a web-standard `Request → Response` handler any host can mount — + * h3 (see `mountMcpHttp`), a Next.js App Router route, or any other + * fetch-shaped server. + * + * 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 origin gate applies devframe's + * loopback-default DNS-rebinding protection (identical semantics to the WS + * upgrade's `isAllowedOrigin`). + * + * @experimental + */ +export function createMcpFetchHandler( + ctx: DevframeNodeContext, + options: CreateMcpFetchHandlerOptions, +): McpFetchHandler { + 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 + } + + async function handle(req: Request): Promise { + // 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 ?? [])) + return new Response('Forbidden: origin not allowed', { status: 403 }) + + 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 { + return new Response( + sessionId + ? 'Not Found: unknown MCP session' + : 'Bad Request: no valid session ID and not an initialize request', + { status: sessionId ? 404 : 400 }, + ) + } + + return session.transport.handleRequest(req, { parsedBody: body }) + } + + if (!session) { + // GET (open the SSE stream) / DELETE (end the session) require a + // known session id. + return new Response( + sessionId + ? 'Not Found: unknown MCP session' + : 'Bad Request: missing MCP session ID', + { status: sessionId ? 404 : 400 }, + ) + } + + return session.transport.handleRequest(req) + } + + return { + fetch: handle, + dispose: async () => { + const live = [...sessions.values()] + sessions.clear() + await Promise.all(live.map(session => session.dispose())) + }, + } +} diff --git a/packages/devframe/src/adapters/mcp/http.ts b/packages/devframe/src/adapters/mcp/http.ts index 4ad2022f..89c5e464 100644 --- a/packages/devframe/src/adapters/mcp/http.ts +++ b/packages/devframe/src/adapters/mcp/http.ts @@ -1,48 +1,25 @@ import type { DevframeNodeContext } from 'devframe/types' import type { H3, H3Event } from 'h3' -import { randomUUID } from 'node:crypto' -import { isInitializeRequest, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server' -import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import type { CreateMcpFetchHandlerOptions } from './fetch' import { defineHandler } from 'h3' -import { buildMcpServerFromContext } from './build-server' +import { createMcpFetchHandler } from './fetch' -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 MountMcpHttpOptions extends CreateMcpFetchHandlerOptions {} 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. + * Mount an MCP Streamable-HTTP endpoint on an h3 app at `path` — the h3 + * binding over {@link createMcpFetchHandler}, which owns the sessions, the + * origin gate, and the transport plumbing. * - * 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 + * The handler is web-standard — it 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). @@ -55,114 +32,12 @@ export function mountMcpHttp( 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' - } + const handler = createMcpFetchHandler(ctx, options) - return respond(event, await session.transport.handleRequest(req)) - })) + app.use(path, defineHandler(async event => respond(event, await handler.fetch(event.req)))) return { - dispose: async () => { - const live = [...sessions.values()] - sessions.clear() - await Promise.all(live.map(session => session.dispose())) - }, + dispose: handler.dispose, } } diff --git a/packages/devframe/src/adapters/mcp/index.ts b/packages/devframe/src/adapters/mcp/index.ts index 1623eff9..485e083e 100644 --- a/packages/devframe/src/adapters/mcp/index.ts +++ b/packages/devframe/src/adapters/mcp/index.ts @@ -17,3 +17,9 @@ export { type CreateMcpServerOptions, type McpServerHandle, } from './build-server' + +export { + createMcpFetchHandler, + type CreateMcpFetchHandlerOptions, + type McpFetchHandler, +} from './fetch' diff --git a/packages/devframe/src/rpc/types.ts b/packages/devframe/src/rpc/types.ts index ed75147a..c227b6a2 100644 --- a/packages/devframe/src/rpc/types.ts +++ b/packages/devframe/src/rpc/types.ts @@ -313,11 +313,15 @@ export type RpcFunctionDefinition< */ agent?: RpcFunctionAgentOptions /** Setup function called with context to initialize handler and dump */ - setup?: (context: CONTEXT) => Thenable, InferReturnType>> - /** Function implementation (required if setup doesn't provide one) */ - handler?: (...args: InferArgsType) => InferReturnType + setup?: (context: CONTEXT) => Thenable, Thenable>>> + /** + * Function implementation (required if setup doesn't provide one). + * The declared `returns` schema describes the *resolved* value — + * async handlers return a promise of it (the runtime always awaits). + */ + handler?: (...args: InferArgsType) => Thenable> /** Dump definition (setup dump takes priority) */ - dump?: RpcDump, InferReturnType, CONTEXT> + dump?: RpcDump, Thenable>, CONTEXT> /** * Sugar for "query in dev, single baked snapshot in build": when * `true` and no `dump` is provided, the build adapter runs the @@ -328,9 +332,9 @@ export type RpcFunctionDefinition< */ snapshot?: boolean /** Per-context setup-result cache, populated by `getRpcResolvedSetupResult`. @internal */ - __cache?: WeakMap, InferReturnType>>> + __cache?: WeakMap, Thenable>>>> /** Single-slot fallback for primitive contexts. @internal */ - __promise?: Thenable, InferReturnType>> + __promise?: Thenable, Thenable>>> } export type RpcFunctionDefinitionToFunction diff --git a/packages/hub/package.json b/packages/hub/package.json index 00bf336d..2d20fab6 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -48,6 +48,7 @@ "pathe": "catalog:deps", "perfect-debounce": "catalog:deps", "tinyexec": "catalog:deps", + "valibot": "catalog:deps", "zigpty": "catalog:deps" }, "devDependencies": { diff --git a/packages/hub/src/node/__tests__/host-commands.test.ts b/packages/hub/src/node/__tests__/host-commands.test.ts index 7f9f5dae..8bd07f77 100644 --- a/packages/hub/src/node/__tests__/host-commands.test.ts +++ b/packages/hub/src/node/__tests__/host-commands.test.ts @@ -1,4 +1,6 @@ +import type { AgentToolInput } from 'devframe/types' import type { DevframeHubContext } from '../context' +import * as v from 'valibot' import { describe, expect, it } from 'vitest' import { DevframeCommandsHost } from '../host-commands' @@ -61,3 +63,122 @@ describe('devframeCommandsHost command id validation', () => { })).toThrow('Command id "other:child" is already used') }) }) + +function createAgentContext(): { context: DevframeHubContext, tools: Map } { + const tools = new Map() + const context = { + agent: { + registerTool: (input: AgentToolInput) => { + tools.set(input.id, input) + return { unregister: () => tools.delete(input.id) } + }, + }, + } as unknown as DevframeHubContext + return { context, tools } +} + +describe('devframeCommandsHost agent bridge', () => { + it('projects agent-flagged commands (incl. children) into ctx.agent', async () => { + const { context, tools } = createAgentContext() + const host = new DevframeCommandsHost(context) + const calls: unknown[][] = [] + + host.register({ + id: 'demo:parent', + title: 'Parent group', + children: [ + { + id: 'demo:greet', + title: 'Greet', + agent: { + description: 'Greet someone by name.', + args: [v.object({ name: v.optional(v.string()) })], + }, + handler: (...args: unknown[]) => { + calls.push(args) + return 'done' + }, + }, + ], + }) + + // Group-only parent stays off the agent surface; the child projects. + expect(tools.has('demo:parent')).toBe(false) + const tool = tools.get('demo:greet')! + expect(tool.description).toBe('Greet someone by name.') + expect(tool.title).toBe('Greet') + expect(tool.safety).toBe('action') + expect((tool.inputSchema as { type: string }).type).toBe('object') + + // A single object args schema is unwrapped — the MCP args object lands + // as the handler's first positional argument. + await expect(tool.handler({ name: 'devframe' })).resolves.toBe('done') + expect(calls).toEqual([[{ name: 'devframe' }]]) + }) + + it('registers zero-arg tools for commands without an args schema', async () => { + const { context, tools } = createAgentContext() + const host = new DevframeCommandsHost(context) + const calls: unknown[][] = [] + + host.register({ + id: 'demo:ping', + title: 'Ping', + agent: { description: 'Ping the hub.', safety: 'read' }, + handler: (...args: unknown[]) => { + calls.push(args) + }, + }) + + const tool = tools.get('demo:ping')! + expect(tool.safety).toBe('read') + await tool.handler({ stray: true }) + expect(calls).toEqual([[]]) + }) + + it('re-syncs the projection on update and drops it on unregister', () => { + const { context, tools } = createAgentContext() + const host = new DevframeCommandsHost(context) + + const handle = host.register({ + id: 'demo:sync', + title: 'Sync', + agent: { description: 'Initial description.' }, + handler: () => {}, + }) + expect(tools.get('demo:sync')!.description).toBe('Initial description.') + + handle.update({ agent: { description: 'Patched description.' } }) + expect(tools.get('demo:sync')!.description).toBe('Patched description.') + + handle.unregister() + expect(tools.has('demo:sync')).toBe(false) + }) + + it('rejects agent exposure on handler-less commands', () => { + const { context } = createAgentContext() + const host = new DevframeCommandsHost(context) + + expect(() => host.register({ + id: 'demo:group', + title: 'Group', + agent: { description: 'A group cannot be a tool.' }, + })).toThrow('declares agent exposure but has no handler') + }) + + it('keeps the agent field off the serializable entry', () => { + const { context } = createAgentContext() + const host = new DevframeCommandsHost(context) + + host.register({ + id: 'demo:wire', + title: 'Wire', + agent: { description: 'Not for the wire.' }, + handler: () => {}, + }) + + const entry = host.list().find(cmd => cmd.id === 'demo:wire')! + expect('agent' in entry).toBe(false) + expect('handler' in entry).toBe(false) + }) +}) diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index e18fd931..839e856c 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -80,5 +80,9 @@ export const diagnostics = defineDiagnostics({ why: (p: { id: string }) => `Command id "${p.id}" is already used by another command or child command`, fix: 'Use globally unique command ids for top-level commands and all child commands.', }, + DF8404: { + why: (p: { id: string }) => `Command "${p.id}" declares agent exposure but has no handler`, + fix: 'Agent-exposed commands must be executable server-side. Add a `handler` to the command, or move the `agent` field to an executable child command.', + }, }, }) diff --git a/packages/hub/src/node/host-commands.ts b/packages/hub/src/node/host-commands.ts index 2668f398..c40de9f2 100644 --- a/packages/hub/src/node/host-commands.ts +++ b/packages/hub/src/node/host-commands.ts @@ -1,3 +1,4 @@ +import type { AgentHandle } from 'devframe/types' import type { DevframeCommandHandle, DevframeCommandsHost as DevframeCommandsHostType, @@ -6,6 +7,7 @@ import type { } from '../types/commands' import type { DevframeHubContext } from './context' import { createEventEmitter } from 'devframe/utils/events' +import { valibotArgsToJsonSchema } from 'devframe/utils/valibot-json-schema' import { diagnostics } from './diagnostics' function findChildCommand(command: DevframeServerCommandInput, id: string): DevframeServerCommandInput | undefined { @@ -54,6 +56,9 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { public readonly commands: DevframeCommandsHostType['commands'] = new Map() public readonly events: DevframeCommandsHostType['events'] = createEventEmitter() + /** Agent-tool handles per command id (incl. children), for teardown/re-sync. */ + private readonly agentHandles = new Map() + constructor( public readonly context: DevframeHubContext, ) {} @@ -63,8 +68,10 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { throw diagnostics.DF8400({ id: command.id }) } validateCommandIds(this.commands, command) + this.validateAgentExposure(command) this.commands.set(command.id, command) this.events.emit('command:registered', this.toSerializable(command)) + this.registerAgentTools(command) return { id: command.id, @@ -82,16 +89,24 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { id: existing.id, } validateCommandIds(this.commands, next, existing.id) + this.validateAgentExposure(next) + // Re-sync the agent projection: drop the old tree's tools before the + // patch lands, re-register from the patched command below. + this.unregisterAgentTools(existing) Object.assign(existing, patch) this.events.emit('command:registered', this.toSerializable(existing)) + this.registerAgentTools(existing) }, unregister: () => this.unregister(command.id), } } unregister(id: string): boolean { + const command = this.commands.get(id) const deleted = this.commands.delete(id) if (deleted) { + if (command) + this.unregisterAgentTools(command) this.events.emit('command:unregistered', id) } return deleted @@ -129,7 +144,9 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { } private toSerializable(cmd: DevframeServerCommandInput): DevframeServerCommandEntry { - const { handler: _, children, ...rest } = cmd + // `agent` stays server-side: it carries valibot schemas (not wire-safe) + // and only concerns the agent projection, not the palette. + const { handler: _, agent: __, children, ...rest } = cmd return { ...rest, source: 'server', @@ -139,4 +156,67 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { ), } } + + /** Reject `agent` on handler-less commands anywhere in the tree, up front. */ + private validateAgentExposure(command: DevframeServerCommandInput): void { + if (command.agent && !command.handler) + throw diagnostics.DF8404({ id: command.id }) + for (const child of command.children ?? []) + this.validateAgentExposure(child) + } + + /** + * Project every agent-flagged command in the tree into `ctx.agent` as a + * callable tool. `when` clauses evaluate client-side only and are not + * enforced here — opting in a `when`-gated command is a deliberate author + * decision (documented on `DevframeCommandAgentOptions`). + */ + private registerAgentTools(command: DevframeServerCommandInput): void { + const agent = command.agent + if (agent && command.handler) { + const { schema, unwrapped } = valibotArgsToJsonSchema(agent.args) + const handle = this.context.agent.registerTool({ + id: command.id, + title: agent.title ?? command.title, + description: agent.description, + safety: agent.safety ?? 'action', + tags: agent.tags, + inputSchema: schema, + handler: async (args: unknown) => + this.execute(command.id, ...coercePositionalArgs(args, agent.args, unwrapped)), + }) + this.agentHandles.set(command.id, handle) + } + for (const child of command.children ?? []) + this.registerAgentTools(child) + } + + private unregisterAgentTools(command: DevframeServerCommandInput): void { + for (const id of collectCommandIds(command)) { + const handle = this.agentHandles.get(id) + if (handle) { + this.agentHandles.delete(id) + handle.unregister() + } + } + } +} + +/** + * Map the single-object args an MCP client sends onto the command handler's + * positional parameters, mirroring the agent host's RPC coercion: no declared + * schemas → zero-arg call; a single unwrapped object schema → the object + * itself; positional schemas → `arg0..argN` keys in order. + */ +function coercePositionalArgs( + args: unknown, + schemas: readonly unknown[] | undefined, + unwrapped: boolean, +): unknown[] { + if (!schemas || schemas.length === 0) + return [] + if (unwrapped) + return [args ?? {}] + const obj = (args ?? {}) as Record + return schemas.map((_, i) => obj[`arg${i}`]) } diff --git a/packages/hub/src/types/commands.ts b/packages/hub/src/types/commands.ts index 28eff6cc..e2f6f186 100644 --- a/packages/hub/src/types/commands.ts +++ b/packages/hub/src/types/commands.ts @@ -1,4 +1,5 @@ import type { EventEmitter } from 'devframe/types' +import type { GenericSchema } from 'valibot' import type { DevframeDockEntryIcon } from './docks' export interface DevframeCommandKeybinding { @@ -43,6 +44,43 @@ export interface DevframeCommandBase { keybindings?: DevframeCommandKeybinding[] } +/** + * Opt-in agent exposure for a server command — mirrors the `agent` field on + * `defineRpcFunction`. A command carrying this field (and a `handler`) is + * projected into `ctx.agent` as a callable tool, reaching MCP clients through + * the devframe MCP adapter. + * + * `when` clauses are evaluated client-side only and are **not** enforced for + * agent calls — opt in a `when`-gated command only if running it outside its + * UI context is safe. + * + * @experimental The agent-native surface is experimental and may change + * without a major version bump until it stabilizes. + */ +export interface DevframeCommandAgentOptions { + /** + * Description shown to the agent. Write it as a prompt: state when to call + * the command, not just what it does. + */ + description: string + /** Display title (falls back to the command's `title`). */ + title?: string + /** + * Safety classification — drives MCP hint annotations. + * @default 'action' + */ + safety?: 'read' | 'action' | 'destructive' + /** Free-form tags for grouping/filtering. */ + tags?: readonly string[] + /** + * Positional valibot schemas for the handler's arguments, converted to the + * tool's JSON-Schema input (a single `v.object(...)` schema is unwrapped — + * the friendliest shape at the agent boundary). Omitted: the tool takes no + * arguments. + */ + args?: readonly GenericSchema[] +} + /** * Server command input — what plugins pass to `ctx.commands.register()`. */ @@ -51,6 +89,13 @@ export interface DevframeServerCommandInput extends DevframeCommandBase { * Handler for this command. Optional if the command only serves as a group for children. */ handler?: (...args: any[]) => any | Promise + /** + * Opt this command in to the agent surface (`ctx.agent` → MCP). Requires a + * `handler`. See {@link DevframeCommandAgentOptions}. + * + * @experimental + */ + agent?: DevframeCommandAgentOptions /** * Static sub-commands. Two levels max (parent → children). * Each child must have a globally unique `id`. diff --git a/plugins/git/package.json b/plugins/git/package.json index c1e5dad0..166746a1 100644 --- a/plugins/git/package.json +++ b/plugins/git/package.json @@ -49,7 +49,8 @@ "dependencies": { "cac": "catalog:deps", "devframe": "workspace:*", - "pathe": "catalog:deps" + "pathe": "catalog:deps", + "valibot": "catalog:deps" }, "devDependencies": { "@antfu/design": "catalog:frontend", diff --git a/plugins/git/src/rpc/functions/branches.ts b/plugins/git/src/rpc/functions/branches.ts index 979a57f4..fdafc724 100644 --- a/plugins/git/src/rpc/functions/branches.ts +++ b/plugins/git/src/rpc/functions/branches.ts @@ -46,6 +46,10 @@ export const branches = defineRpcFunction({ type: 'query', snapshot: true, jsonSerializable: true, + agent: { + description: 'List local and remote branches of the inspected repository with tracking state (ahead/behind, gone upstreams) and the current branch. Safe to call freely.', + title: 'Git branches', + }, setup: (ctx) => { const git = getGitContext(ctx) return { diff --git a/plugins/git/src/rpc/functions/diff.ts b/plugins/git/src/rpc/functions/diff.ts index 8e44fdc1..f703901f 100644 --- a/plugins/git/src/rpc/functions/diff.ts +++ b/plugins/git/src/rpc/functions/diff.ts @@ -1,4 +1,5 @@ import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' import { runGit, splitClean, tryGit } from '../../node/git.ts' import { getGitContext } from '../context.ts' @@ -25,6 +26,24 @@ export interface GitDiff { truncated: boolean } +const diffFileSchema = v.object({ + path: v.string(), + additions: v.number(), + deletions: v.number(), + binary: v.boolean(), +}) + +const gitDiffSchema = v.object({ + isRepo: v.boolean(), + staged: v.boolean(), + path: v.nullable(v.string()), + files: v.array(diffFileSchema), + totalAdditions: v.number(), + totalDeletions: v.number(), + patch: v.nullable(v.string()), + truncated: v.boolean(), +}) + export interface DiffArgs { /** Limit the diff to a single path; omit for the whole tree. */ path?: string @@ -50,6 +69,15 @@ export const diff = defineRpcFunction({ type: 'query', snapshot: true, jsonSerializable: true, + args: [v.object({ + path: v.optional(v.string()), + staged: v.optional(v.boolean()), + })], + returns: gitDiffSchema, + agent: { + description: 'Unified diff of uncommitted changes in the inspected repository — the working tree by default, the index with staged: true, one file with path. Call before summarizing or reviewing in-progress work. Safe to call freely.', + title: 'Git diff', + }, setup: (ctx) => { const git = getGitContext(ctx) return { diff --git a/plugins/git/src/rpc/functions/log.ts b/plugins/git/src/rpc/functions/log.ts index 372bf300..cf070d37 100644 --- a/plugins/git/src/rpc/functions/log.ts +++ b/plugins/git/src/rpc/functions/log.ts @@ -1,4 +1,5 @@ import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' import { isSafeRevision, RECORD, splitClean, tryGit, UNIT } from '../../node/git.ts' import { getGitContext } from '../context.ts' @@ -26,6 +27,26 @@ export interface GitLog { hasMore: boolean } +const commitSchema = v.object({ + hash: v.string(), + shortHash: v.string(), + author: v.string(), + email: v.string(), + date: v.number(), + subject: v.string(), + body: v.string(), + refs: v.array(v.string()), + parents: v.array(v.string()), +}) + +const gitLogSchema = v.object({ + isRepo: v.boolean(), + commits: v.array(commitSchema), + limit: v.number(), + skip: v.number(), + hasMore: v.boolean(), +}) + export interface LogArgs { /** Number of commits to return (clamped to 1–200, default 30). */ limit?: number @@ -79,11 +100,21 @@ export const log = defineRpcFunction({ name: 'devframes:plugin:git:log', type: 'query', jsonSerializable: true, + args: [v.object({ + limit: v.optional(v.number()), + skip: v.optional(v.number()), + ref: v.optional(v.string()), + })], + returns: gitLogSchema, + agent: { + description: 'Commit history of the inspected repository, newest first. Paginate with limit (1-200, default 30) and skip; pass ref to read another branch. Call before reasoning about recent changes. Safe to call freely.', + title: 'Git log', + }, // A static build can't run git on demand, so bake the head of history (up to // `SNAPSHOT_LIMIT`) as the snapshot. Every client call resolves to this baked // page via the fallback; since a static bundle has no further page to fetch, // it reports `hasMore: false` so the UI shows everything it has in one shot. - dump: async (_ctx, handler: (args?: LogArgs) => Promise) => { + dump: async (_ctx, handler: (args: LogArgs) => GitLog | Promise) => { const output = await handler({ limit: SNAPSHOT_LIMIT, skip: 0 }) const baked: GitLog = { ...output, hasMore: false } // `RETURN` carries the handler's `Promise`, while dump records hold diff --git a/plugins/git/src/rpc/functions/show.ts b/plugins/git/src/rpc/functions/show.ts index 50be2ba3..498a0427 100644 --- a/plugins/git/src/rpc/functions/show.ts +++ b/plugins/git/src/rpc/functions/show.ts @@ -1,6 +1,7 @@ import type { GitContext } from '../context.ts' import type { FileStatusCode } from './status.ts' import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' import { isSafeRevision, splitClean, tryGit, UNIT } from '../../node/git.ts' import { getGitContext } from '../context.ts' @@ -50,6 +51,47 @@ export interface CommitDetail { truncated: boolean } +const fileStatusCodeSchema = v.picklist([ + 'modified', + 'added', + 'deleted', + 'renamed', + 'copied', + 'type-changed', + 'unmerged', + 'unknown', +]) + +const commitFileSchema = v.object({ + path: v.string(), + additions: v.number(), + deletions: v.number(), + binary: v.boolean(), + status: fileStatusCodeSchema, +}) + +const commitDetailSchema = v.object({ + isRepo: v.boolean(), + found: v.boolean(), + hash: v.string(), + shortHash: v.string(), + author: v.string(), + email: v.string(), + date: v.number(), + committer: v.string(), + committerEmail: v.string(), + commitDate: v.number(), + subject: v.string(), + body: v.string(), + parents: v.array(v.string()), + refs: v.array(v.string()), + files: v.array(commitFileSchema), + totalAdditions: v.number(), + totalDeletions: v.number(), + patch: v.nullable(v.string()), + truncated: v.boolean(), +}) + export interface ShowArgs { /** Commit-ish to inspect (full or short hash). */ hash: string @@ -210,10 +252,19 @@ export const show = defineRpcFunction({ name: 'devframes:plugin:git:show', type: 'query', jsonSerializable: true, + args: [v.object({ + hash: v.string(), + patch: v.optional(v.boolean()), + })], + returns: commitDetailSchema, + agent: { + description: 'Full detail of one commit by hash (from the git log tool): metadata, changed files, and the unified patch (pass patch: false to skip it for large commits). Safe to call freely.', + title: 'Git show', + }, // Static builds can't run git per click, so bake one record per commit in the // same window `devframes:plugin:git:log` snapshots. Patches are omitted from the baked records // to keep the bundle bounded — static detail panels show metadata + files. - dump: async (ctx, _handler: (args: ShowArgs) => Promise) => { + dump: async (ctx, _handler: (args: ShowArgs) => CommitDetail | Promise) => { const git = getGitContext(ctx) const root = await git.resolveRoot() if (!root) diff --git a/plugins/git/src/rpc/functions/status.ts b/plugins/git/src/rpc/functions/status.ts index e99dcbb6..a79005f3 100644 --- a/plugins/git/src/rpc/functions/status.ts +++ b/plugins/git/src/rpc/functions/status.ts @@ -168,6 +168,10 @@ export const status = defineRpcFunction({ type: 'query', snapshot: true, jsonSerializable: true, + agent: { + description: 'Working-tree status of the inspected repository: current branch, ahead/behind counts, and every staged/unstaged/untracked file. Call this first to orient before reading diffs or history. Safe to call freely.', + title: 'Git status', + }, setup: (ctx) => { const git = getGitContext(ctx) return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9957fc33..beeb98b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -909,6 +909,9 @@ importers: tinyexec: specifier: catalog:deps version: 1.2.4 + valibot: + specifier: catalog:deps + version: 1.4.2(typescript@6.0.3) zigpty: specifier: catalog:deps version: 0.2.1 @@ -1325,6 +1328,9 @@ importers: pathe: specifier: catalog:deps version: 2.0.3 + valibot: + specifier: catalog:deps + version: 1.4.2(typescript@6.0.3) devDependencies: '@antfu/design': specifier: catalog:frontend diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 3bdb57f1..9fb9f1dd 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -37,6 +37,13 @@ export interface DevframeClientCommand extends DevframeCommandBase { action?: (..._: any[]) => void | DevframeClientCommand[] | Promise; children?: DevframeClientCommand[]; } +export interface DevframeCommandAgentOptions { + description: string; + title?: string; + safety?: 'read' | 'action' | 'destructive'; + tags?: readonly string[]; + args?: readonly GenericSchema[]; +} export interface DevframeCommandBase { id: string; title: string; @@ -230,6 +237,7 @@ export interface DevframeServerCommandEntry extends DevframeCommandBase { } export interface DevframeServerCommandInput extends DevframeCommandBase { handler?: (..._: any[]) => any | Promise; + agent?: DevframeCommandAgentOptions; children?: DevframeServerCommandInput[]; } export interface DevframeTerminalSession extends DevframeTerminalSessionBase { diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 1bb6d739..2268c3f2 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -13,6 +13,7 @@ export declare class DevframeCommandsHost implements DevframeCommandsHost$1 { readonly context: DevframeHubContext; readonly commands: DevframeCommandsHost$1['commands']; readonly events: DevframeCommandsHost$1['events']; + private readonly agentHandles; constructor(_: DevframeHubContext); register(_: DevframeServerCommandInput): DevframeCommandHandle; unregister(_: string): boolean; @@ -20,6 +21,9 @@ export declare class DevframeCommandsHost implements DevframeCommandsHost$1 { list(): DevframeServerCommandEntry[]; private findCommand; private toSerializable; + private validateAgentExposure; + private registerAgentTools; + private unregisterAgentTools; } export declare class DevframeDocksHost implements DevframeDocksHost$1 { readonly context: DevframeHubContext; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index 4677d5b7..46363d1a 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -6,6 +6,7 @@ export class DevframeCommandsHost { context commands events + agentHandles constructor(_) {} register(_) {} unregister(_) {} @@ -13,6 +14,9 @@ export class DevframeCommandsHost { list() {} findCommand(_) {} toSerializable(_) {} + validateAgentExposure(_) {} + registerAgentTools(_) {} + unregisterAgentTools(_) {} } export class DevframeDocksHost { context diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 698b63c0..099e839c 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -11,6 +11,7 @@ export { DevframeChildProcessOutput } export { DevframeChildProcessResult } export { DevframeChildProcessTerminalSession } export { DevframeClientCommand } +export { DevframeCommandAgentOptions } export { DevframeCommandBase } export { DevframeCommandEntry } export { DevframeCommandHandle } diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts index 00ffa441..2e344fa4 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts @@ -36,7 +36,7 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { url?: string | undefined; - }) => { + }) => import("devframe/rpc").Thenable<{ requestedUrl: string; url: string; status: number; @@ -59,10 +59,10 @@ export declare const serverFunctions: readonly [{ name: string; value: string; }[]; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ url?: string | undefined; - }], { + }], import("devframe/rpc").Thenable<{ requestedUrl: string; url: string; status: number; @@ -72,11 +72,11 @@ export declare const serverFunctions: readonly [{ name: string; value: string; }[]; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }]; // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts index ce7dfb37..9c610d2b 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts @@ -2,6 +2,12 @@ * Generated by tsnapi — public API snapshot of `devframe/adapters/mcp` */ // #region Interfaces +export interface CreateMcpFetchHandlerOptions { + serverName: string; + serverVersion: string; + exposeSharedState: boolean | ((_: string) => boolean); + allowedOrigins?: readonly string[] | false; +} export interface CreateMcpServerOptions { transport?: 'stdio'; exposeSharedState?: boolean | ((_: string) => boolean); @@ -11,11 +17,16 @@ export interface CreateMcpServerOptions { transport: 'stdio'; }) => void; } +export interface McpFetchHandler { + fetch: (_: Request) => Promise; + dispose: () => Promise; +} export interface McpServerHandle { stop: () => Promise; } // #endregion // #region Functions +export declare function createMcpFetchHandler(_: DevframeNodeContext, _: CreateMcpFetchHandlerOptions): McpFetchHandler; export declare function createMcpServer(_: DevframeDefinition, _?: CreateMcpServerOptions): Promise; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js index 57441c35..8743467b 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js @@ -2,5 +2,6 @@ * Generated by tsnapi — public API snapshot of `devframe/adapters/mcp` */ // #region Other +export { createMcpFetchHandler } export { createMcpServer } // #endregion \ No newline at end of file From 71294cac62b76712ccababef1a5fd93dd373f36f Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 10:36:02 +0000 Subject: [PATCH 2/9] =?UTF-8?q?feat(agent):=20agent-native=20wave=20phase?= =?UTF-8?q?=203=20=E2=80=94=20instance=20registry,=20devframe=20connect,?= =?UTF-8?q?=20in-process=20Next=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - instance registry: registerDevframeInstance/readDevframeInstances/ probeDevframeInstance/listLiveDevframeInstances in devframe/node — atomic same-dir writes, prune-on-read, ghost dedup per (port, basePath), dialable-origin adoption for family-ambiguous localhost binds; createDevServer registers automatically and unregisters on close; DEVFRAME_INSTANCES_DIR / DEVFRAME_DISABLE_INSTANCE_REGISTRY overrides - first devframe bin: `devframe connect` runs the stdio MCP connector — devframe_index (discover instances + their tools, funnel hints for MCP-less servers) and devframe_call (proxy one tool call over Streamable-HTTP); errors carry actionable fix payloads; missing SDK peer throws coded DF0043 - @devframes/next: DevframeNextHost.mountMcp serves MCP in-process on the Next app's own origin (the /_next/mcp shape); hub example wires it, advertises it in connection meta, registers the instance, and agent-flags its ping command; catch-all route exports POST/DELETE - mcp adapter: drop non-object outputSchema projections (MCP requires type object; a v.void() returns schema broke SDK clients) - e2e: devframe-connect (files-inspector round-trip incl. gateway tool) and minimal-next-devframe-hub (in-process discovery + command call); hermetic per-suite registries; vitest keeps unit runs out of the global registry - diagnostics DF0042/DF0043 + docs pages; connect/registry docs in the MCP adapter page --- .gitignore | 1 + docs/adapters/mcp.md | 19 ++ docs/errors/DF0045.md | 23 ++ docs/errors/DF0046.md | 26 ++ docs/guide/agent-native.md | 4 +- examples/files-inspector/src/devframe.ts | 15 + .../client/app/%5F_[id]/[[...path]]/route.ts | 16 +- .../src/client/devframe/next-devframe-hub.ts | 42 ++- .../tests/next-devframe-hub.test.ts | 3 +- package.json | 1 + packages/devframe/bin/devframe.mjs | 8 + packages/devframe/package.json | 4 + .../src/adapters/__tests__/dev.test.ts | 41 +++ packages/devframe/src/adapters/dev.ts | 31 +- .../adapters/mcp/__tests__/mcp-server.test.ts | 25 ++ .../devframe/src/adapters/mcp/build-server.ts | 16 +- packages/devframe/src/cli/connect.ts | 310 ++++++++++++++++++ packages/devframe/src/cli/main.ts | 32 ++ packages/devframe/src/node/diagnostics.ts | 11 + packages/devframe/src/node/index.ts | 1 + .../src/node/instance-registry.test.ts | 144 ++++++++ .../devframe/src/node/instance-registry.ts | 264 +++++++++++++++ packages/devframe/tsdown.config.ts | 1 + packages/next/src/host.ts | 56 +++- plans/031-agent-native-mcp-wave.md | 20 +- playwright.config.ts | 25 +- pnpm-lock.yaml | 3 + .../@devframes/next/index.snapshot.d.ts | 3 + .../tsnapi/devframe/node.snapshot.d.ts | 39 +++ .../tsnapi/devframe/node.snapshot.js | 7 + tests/e2e/_support/mcp-connect.ts | 33 ++ tests/e2e/devframe-connect.spec.ts | 52 +++ .../e2e/minimal-next-devframe-hub-dev.spec.ts | 57 ++++ vitest.config.ts | 6 + 34 files changed, 1308 insertions(+), 31 deletions(-) create mode 100644 docs/errors/DF0045.md create mode 100644 docs/errors/DF0046.md create mode 100755 packages/devframe/bin/devframe.mjs create mode 100644 packages/devframe/src/cli/connect.ts create mode 100644 packages/devframe/src/cli/main.ts create mode 100644 packages/devframe/src/node/instance-registry.test.ts create mode 100644 packages/devframe/src/node/instance-registry.ts create mode 100644 tests/e2e/_support/mcp-connect.ts create mode 100644 tests/e2e/devframe-connect.spec.ts create mode 100644 tests/e2e/minimal-next-devframe-hub-dev.spec.ts diff --git a/.gitignore b/.gitignore index 16643ade..e6c8fee2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ temp packages/devframe/skills test-results playwright-report +tests/e2e/.registries playwright/.cache blob-report .ecosystem diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index 1c830201..e45ced0d 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -73,4 +73,23 @@ const mcp = createMcpFetchHandler(ctx, { // route every method on /__mcp to mcp.fetch(request) ``` +## Discovery: `devframe connect` + +The `devframe` bin ships an MCP **connector** — a thin discovery + proxy server in the shape [next-devtools-mcp](https://github.com/vercel/next-devtools-mcp) validated. Configure it once in an agent client and it finds every running devframe: + +```json +{ + "mcpServers": { + "devframe": { "command": "npx", "args": ["devframe", "connect"] } + } +} +``` + +It exposes two gateway tools: + +- **`devframe_index`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`. +- **`devframe_call`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint. + +Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. + See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example. diff --git a/docs/errors/DF0045.md b/docs/errors/DF0045.md new file mode 100644 index 00000000..8ab0e9be --- /dev/null +++ b/docs/errors/DF0045.md @@ -0,0 +1,23 @@ +--- +outline: deep +--- + +# DF0045: Instance Registry Update Failed + +## Message + +> Failed to update the devframe instance registry at "`{file}`": `{reason}` + +## Cause + +A dev server (or an in-process host calling `registerDevframeInstance`) could not write or remove its record under the instance registry directory — `~/.devframe/instances/` by default, or `$DEVFRAME_INSTANCES_DIR`. Typical causes are a read-only home directory, missing permissions, or a full disk. The server keeps running; only discovery is affected — `devframe connect` will not see this instance. + +## Fix + +- Check that the registry directory is writable and the disk has free space. +- Point `DEVFRAME_INSTANCES_DIR` at a writable directory. +- Set `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` to opt out of registration entirely. + +## Source + +- [`packages/devframe/src/node/instance-registry.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/instance-registry.ts) — `registerDevframeInstance()` reports this on a failed write and its `unregister()` on a failed removal. diff --git a/docs/errors/DF0046.md b/docs/errors/DF0046.md new file mode 100644 index 00000000..66768a5e --- /dev/null +++ b/docs/errors/DF0046.md @@ -0,0 +1,26 @@ +--- +outline: deep +--- + +# DF0046: Connector Requires the MCP SDK + +## Message + +> `devframe connect` requires the optional peer dependency @modelcontextprotocol/server: `{reason}` + +## Cause + +`devframe connect` was started but `@modelcontextprotocol/server` could not be imported. The SDK is an optional peer dependency of `devframe` — the MCP surface stays opt-in, so it only needs to be installed where MCP features are used. + +## Fix + +Install the SDK next to devframe and run the connector again: + +```sh +npm install @modelcontextprotocol/server +devframe connect +``` + +## Source + +- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `startConnectServer()` throws this when the dynamic SDK import fails. diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md index b15720d8..d89b782d 100644 --- a/docs/guide/agent-native.md +++ b/docs/guide/agent-native.md @@ -176,4 +176,6 @@ Agents can act on `fix` directly and follow `docs` for detail — prefer throwin | Command | Description | |---------|-------------| -| `devframe mcp` | Start an MCP server on `stdio`. | +| ` mcp` | Start your app's MCP server on `stdio` (from the `createCac` shell). | +| ` dev --mcp` | Serve the agent surface on the dev server's `/__mcp` route. | +| `devframe connect` | Run the app-independent MCP connector: discover running devframes and proxy their tools — see [MCP adapter](/adapters/mcp#discovery-devframe-connect). | diff --git a/examples/files-inspector/src/devframe.ts b/examples/files-inspector/src/devframe.ts index 144bb379..9fd7b56a 100644 --- a/examples/files-inspector/src/devframe.ts +++ b/examples/files-inspector/src/devframe.ts @@ -22,6 +22,9 @@ export default defineDevframe({ // Single-user localhost demo — skip the trust handshake so the served // SPA can call RPC without an OTP round-trip. auth: false, + // Serve the agent surface over the dev server's `/__mcp` route and + // register the instance for `devframe connect` discovery. + mcp: true, }, spa: { loader: 'none' }, setup(ctx) { @@ -29,5 +32,17 @@ export default defineDevframe({ const my = ctx.scope(NAMESPACE) for (const fn of serverFunctions) my.rpc.register(fn) + + // Gateway tool: returns the location of this tool's own docs instead of + // proxying their content — the agent reads the files with its own tools. + ctx.agent.registerTool({ + id: `${NAMESPACE}:docs`, + description: 'Locate the Files Inspector\'s documentation on disk. Call before answering questions about how this tool works, then read the returned files directly.', + safety: 'read', + handler: () => ({ + readmePath: fileURLToPath(new URL('../README.md', import.meta.url)), + hint: 'Read the file at readmePath with your own file tools; do not rely on training-data knowledge of this example.', + }), + }) }, }) diff --git a/examples/next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts b/examples/next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts index ce601989..c99bdcc7 100644 --- a/examples/next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts +++ b/examples/next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts @@ -5,12 +5,18 @@ export const dynamic = 'force-dynamic' /** * Catch-all for every mounted devframe SPA (`/__git/…`, `/__terminals/…`, the - * a11y agent module, …) and their `/__connection.json` discovery fetches. - * The `@devframes/next` bridge owns all of it — static serving (with SPA - * fallback, content types, and traversal guarding via devframe's shared - * `serveStaticHandler`) and the connection-meta responses. + * a11y agent module, …), their `/__connection.json` discovery fetches, + * and the in-process MCP endpoint (`/__hub/__mcp`). The `@devframes/next` + * bridge owns all of it — static serving (with SPA fallback, content types, + * and traversal guarding via devframe's shared `serveStaticHandler`), the + * connection-meta responses, and the MCP mount. + * + * MCP speaks Streamable-HTTP: `POST` (requests), `GET` (the SSE stream), and + * `DELETE` (session teardown) all route to the same bridge `fetch`. */ -export async function GET(request: Request): Promise { +async function handler(request: Request): Promise { const hub = await ensureNextDevframeHub() return hub.fetch(request) } + +export { handler as DELETE, handler as GET, handler as POST } diff --git a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts index 1d9bc74d..2ab7e69d 100644 --- a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts +++ b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts @@ -9,7 +9,7 @@ import { defineHubRpcFunction } from '@devframes/hub' import { createHubContext, mountDevframe } from '@devframes/hub/node' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import { createDevframeNextHost } from '@devframes/next' -import { startHttpAndWs } from 'devframe/node' +import { registerDevframeInstance, startHttpAndWs } from 'devframe/node' import { getPort } from 'get-port-please' import { createDashboardView } from 'json-render/dashboard' import { dirname, join } from 'pathe' @@ -153,13 +153,14 @@ export async function nextDevframeHub( ): Promise { const cwd = options.cwd ?? process.cwd() const hostName = options.host ?? 'localhost' + const nextPort = Number(process.env.PORT ?? 3000) // The Next host bridge: its `host` accumulates every `mountStatic` / // `mountConnectionMeta` call into a single `fetch` handler (backed by // devframe's shared `serveStaticHandler`), which the App Router routes // delegate to — no hand-rolled static serving or path matching here. const nextHost = createDevframeNextHost({ - resolveOrigin: () => `http://${hostName}:3000`, + resolveOrigin: () => `http://${hostName}:${nextPort}`, getStorageDir(scope) { if (scope === 'workspace') return join(cwd, '.devframe') @@ -193,6 +194,12 @@ export async function nextDevframeHub( title: 'Next Hub: Ping', icon: 'ph:bell-duotone', category: 'hub', + // Opt this command into the agent surface: it shows up as an MCP tool + // on the in-process endpoint mounted below. + agent: { + description: 'Ping the hub to confirm it is alive. Returns "pong". Safe to call freely.', + safety: 'read', + }, handler: () => 'pong', }) @@ -264,14 +271,45 @@ export async function nextDevframeHub( auth: false, }) + // Serve MCP in-process on the Next app's own origin (the `/_next/mcp` + // shape): the hub's agent surface — agent-flagged commands, plugin tools + // (git status/log/diff, terminals), `read_state` — over the same catch-all + // route as the SPAs, no side-car port involved. + const mcpPath = '/__hub/__mcp' + await nextHost.mountMcp(context, mcpPath, { + serverName: 'minimal-next-devframe-hub', + }) + const connectionMeta = { backend: 'websocket' as const, websocket: started.port, + mcp: { path: mcpPath }, } // Publish the live meta to the bridge now the WS port is known, so every // registered `/__connection.json` (hub + mounted devframes) resolves. nextHost.setConnectionMeta(connectionMeta) + // Record the instance in the global registry so `devframe connect` + // discovers this hub — running inside the Next dev server — like any + // standalone devframe. In-process hosts register explicitly; the origin is + // the Next app's own. + const registration = registerDevframeInstance({ + pid: process.pid, + port: nextPort, + origin: `http://${hostName}:${nextPort}`, + basePath: '/__hub/', + id: 'minimal-next-devframe-hub', + name: 'Minimal Next Devframe Hub', + rootDir: cwd, + mcp: { path: mcpPath }, + startedAt: Date.now(), + }) + const closeStarted = started.close + started.close = async () => { + registration.unregister() + await closeStarted() + } + return Object.assign(started, { context, connectionMeta, diff --git a/examples/next-devframe-hub/tests/next-devframe-hub.test.ts b/examples/next-devframe-hub/tests/next-devframe-hub.test.ts index 45edfc61..3975ccfe 100644 --- a/examples/next-devframe-hub/tests/next-devframe-hub.test.ts +++ b/examples/next-devframe-hub/tests/next-devframe-hub.test.ts @@ -19,12 +19,13 @@ describe('next-devframe-hub (example)', () => { server = undefined }) - it('returns connection meta pointing at the WS backend', async () => { + it('returns connection meta pointing at the WS backend and in-process MCP', async () => { server = await nextDevframeHub({ host: '127.0.0.1' }) expect(server.connectionMeta).toEqual({ backend: 'websocket', websocket: server.port, + mcp: { path: '/__hub/__mcp' }, }) }) diff --git a/package.json b/package.json index f21b3952..be873b30 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@antfu/eslint-config": "catalog:tooling", "@antfu/ni": "catalog:build", "@antfu/utils": "catalog:inlined", + "@modelcontextprotocol/sdk": "catalog:deps", "@playwright/test": "catalog:testing", "@types/node": "catalog:types", "@types/prompts": "catalog:types", diff --git a/packages/devframe/bin/devframe.mjs b/packages/devframe/bin/devframe.mjs new file mode 100755 index 00000000..17362378 --- /dev/null +++ b/packages/devframe/bin/devframe.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import process from 'node:process' +import { runDevframeCli } from '../dist/cli/main.mjs' + +runDevframeCli().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 29680312..02b69d5c 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -60,7 +60,11 @@ "./package.json": "./package.json" }, "types": "./dist/index.d.mts", + "bin": { + "devframe": "./bin/devframe.mjs" + }, "files": [ + "bin", "dist", "skills" ], diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 744f9234..ac621278 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -569,4 +569,45 @@ describe('adapters/dev', () => { }) expect(port).toBe(override) }) + + it('registers the instance in the registry and unregisters on close', async () => { + const registryDir = mkdtempSync(join(tmpdir(), 'devframe-registry-')) + vi.stubEnv('DEVFRAME_INSTANCES_DIR', registryDir) + // The global vitest setup disables registration for every other test. + vi.stubEnv('DEVFRAME_DISABLE_INSTANCE_REGISTRY', '0') + try { + const devframe = defineDevframe({ + id: 'devframe-test-registry', + name: 'Registry Test', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + const server = await createDevServer(devframe, { + host: '127.0.0.1', + port: 0, + auth: false, + mcp: true, + }) + + const { readDevframeInstances } = await import('../../node/instance-registry') + const records = readDevframeInstances({ instancesDir: registryDir }) + expect(records).toHaveLength(1) + expect(records[0]).toMatchObject({ + id: 'devframe-test-registry', + port: server.port, + basePath: '/', + mcp: { path: '/__mcp' }, + }) + expect(records[0]!.origin).toContain(`:${server.port}`) + + await server.close() + expect(readDevframeInstances({ instancesDir: registryDir })).toEqual([]) + } + finally { + vi.unstubAllEnvs() + } + }) }) diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 78ceaf7f..2f55b369 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -13,6 +13,7 @@ import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUT import { createHostContext } from '../node/context' import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' +import { registerDevframeInstance } from '../node/instance-registry' import { startHttpAndWs } from '../node/server' import { normalizeHttpServerUrl } from '../node/utils' import { createInteractiveAuth } from '../recipes/interactive-auth' @@ -263,14 +264,28 @@ export async function createDevServer( }, }) - // 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() - } + // Record the instance in the global registry so discovery tooling + // (`devframe connect`) finds it without port guessing. Registration never + // throws; a crash-orphaned record is pruned by readers on a failed probe. + const registration = registerDevframeInstance({ + pid: process.pid, + port: started.port, + origin: normalizeHttpServerUrl(host, started.port), + basePath, + id: def.id, + name: def.name, + rootDir: process.cwd(), + mcp: mcpConfig ? { path: joinURL(basePath, withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE)) } : null, + startedAt: Date.now(), + }) + + // Fold MCP session teardown and registry removal into the server's close so + // callers get a single graceful-shutdown handle. + const closeServer = started.close + started.close = async () => { + registration.unregister() + await mcpDispose?.() + await closeServer() } return started diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index 99b24363..abc375bd 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -176,6 +176,31 @@ describe('mcp adapter (in-memory)', () => { } }) + it('omits non-object output schemas (MCP requires type: "object")', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'void-tool', + description: 'Returns nothing.', + // What a valibot `v.void()` returns schema converts to. + outputSchema: { type: 'null' }, + handler: () => undefined, + }) + + const listed = await client.listTools() + const tool = listed.tools.find(t => t.name === 'void-tool')! + expect(tool.outputSchema).toBeUndefined() + + // The call still succeeds with plain text content. + const result = await client.callTool({ name: 'void-tool', arguments: {} }) + expect(result.isError).toBeFalsy() + expect(result.structuredContent).toBeUndefined() + } + finally { + await cleanup() + } + }) + it('exposes shared state through the built-in read_state tool', async () => { const { ctx, client, cleanup } = await bootPair() try { diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 23169e6d..7aeeddd0 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -227,7 +227,7 @@ function registerToolHandlers( } const tool = ctx.agent.getTool(name) const outputSchema = tool - ? tool.outputSchema ?? computeOutputSchema(tool, ctx) + ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) : undefined const result = await ctx.agent.invoke(name, args ?? {}) return { @@ -318,9 +318,21 @@ function registerResourceHandlers( }) } +/** + * MCP constrains a tool's `outputSchema` to a JSON Schema of `type: + * "object"` — clients (the SDK included) reject anything else. Non-object + * return schemas (e.g. a schema for `void` / a bare string) simply project + * no output schema; the text content still carries the result. + */ +function usableOutputSchema(schema: unknown): unknown { + return schema && typeof schema === 'object' && (schema as { type?: unknown }).type === 'object' + ? schema + : undefined +} + function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Tool { const inputSchema = tool.inputSchema ?? computeInputSchema(tool, ctx) - const outputSchema = tool.outputSchema ?? computeOutputSchema(tool, ctx) + const outputSchema = usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) return { name: tool.id, title: tool.title, diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts new file mode 100644 index 00000000..5976f49a --- /dev/null +++ b/packages/devframe/src/cli/connect.ts @@ -0,0 +1,310 @@ +import type { DevframeInstanceRecord } from '../node/instance-registry' +import process from 'node:process' +import { joinURL } from 'ufo' +import { diagnostics } from '../node/diagnostics' +import { listLiveDevframeInstances } from '../node/instance-registry' + +export interface ConnectServerOptions { + /** + * Explicit ports to probe besides the registry — for instances started + * before the registry existed, or reachable only by convention. Each port + * is probed at `/` (`http://localhost:/__connection.json`). + */ + ports?: number[] + /** Override the registry directory (`DEVFRAME_INSTANCES_DIR` also applies). */ + instancesDir?: string + /** Probe timeout per instance, ms. Default 1000. */ + timeoutMs?: number +} + +export interface ConnectServerHandle { + stop: () => Promise +} + +interface IndexedInstance { + id: string + name?: string + pid: number + port: number + origin: string + basePath: string + rootDir: string + startedAt: number + mcp: { + url: string + tools?: { name: string, description?: string }[] + error?: string + } | null + hint?: string +} + +const INDEX_TOOL = 'devframe_index' +const CALL_TOOL = 'devframe_call' + +const MCP_DISABLED_HINT + = 'This instance runs without an MCP route. Restart it with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then call devframe_index again.' + +/** + * Start the devframe MCP connector on stdio: a thin discovery + proxy server + * in the shape next-devtools-mcp validated. It exposes two gateway tools — + * `devframe_index` (discover running devframe instances via the instance + * registry and list each one's MCP tools) and `devframe_call` (invoke one + * tool on one instance over its Streamable-HTTP endpoint) — and holds no + * domain knowledge of its own. + * + * @experimental + */ +export async function startConnectServer(options: ConnectServerOptions = {}): Promise { + const sdk = await importSdk() + + const server = new sdk.Server( + { name: 'devframe-connect', version: '0.0.0' }, + { capabilities: { tools: {} } }, + ) + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: INDEX_TOOL, + title: 'Discover running devframes', + description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available — the result names the instance (id, project root, origin) and the port to pass to devframe_call. Safe to call freely.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true, destructiveHint: false }, + }, + { + name: CALL_TOOL, + title: 'Call a devframe tool', + description: 'Invoke one MCP tool on one running devframe instance discovered via devframe_index. Pass the instance\'s port, the tool name, and the tool\'s arguments object.', + inputSchema: { + type: 'object', + properties: { + port: { type: 'number', description: 'The instance\'s port, from devframe_index.' }, + tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' }, + args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' }, + }, + required: ['port', 'tool'], + additionalProperties: false, + }, + }, + ], + })) + + server.setRequestHandler('tools/call', async (request: any) => { + const { name, arguments: args } = request.params + try { + if (name === INDEX_TOOL) + return textResult(await index(sdk, options)) + if (name === CALL_TOOL) + return textResult(await call(sdk, options, args ?? {})) + return errorResult({ message: `unknown tool "${name}"`, fix: `Call ${INDEX_TOOL} or ${CALL_TOOL}.` }) + } + catch (error) { + return errorResult({ + message: error instanceof Error ? error.message : String(error), + ...(error && typeof error === 'object' && 'fix' in error && typeof error.fix === 'string' ? { fix: error.fix } : {}), + }) + } + }) + + const transport = new sdk.StdioServerTransport() + await server.connect(transport) + + return { + stop: async () => { + await server.close() + }, + } +} + +async function importSdk(): Promise { + try { + const [serverMod, stdioMod, clientMod] = await Promise.all([ + import('@modelcontextprotocol/server'), + import('@modelcontextprotocol/server/stdio'), + import('@modelcontextprotocol/client'), + ]) + return { + Server: serverMod.Server, + StdioServerTransport: stdioMod.StdioServerTransport, + Client: clientMod.Client, + StreamableHTTPClientTransport: clientMod.StreamableHTTPClientTransport, + } + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw diagnostics.DF0046({ reason, cause: error }) + } +} + +/** Discover instances: registry (prune-on-read) + explicit port probes. */ +async function index(sdk: any, options: ConnectServerOptions): Promise { + const { live } = await listLiveDevframeInstances({ + instancesDir: options.instancesDir, + timeoutMs: options.timeoutMs, + }) + + const records = [...live] + for (const port of options.ports ?? []) { + if (records.some(r => r.port === port)) + continue + const probed = await probePort(port, options.timeoutMs) + if (probed) + records.push(probed) + } + + const instances: IndexedInstance[] = await Promise.all(records.map(async (record) => { + const entry: IndexedInstance = { + id: record.id, + name: record.name, + pid: record.pid, + port: record.port, + origin: record.origin, + basePath: record.basePath, + rootDir: record.rootDir, + startedAt: record.startedAt, + mcp: null, + } + if (!record.mcp) { + entry.hint = MCP_DISABLED_HINT + return entry + } + const url = `${record.origin}${record.mcp.path}` + try { + entry.mcp = { url, tools: await listInstanceTools(sdk, url) } + } + catch (error) { + entry.mcp = { url, error: error instanceof Error ? error.message : String(error) } + } + return entry + })) + + return { + instances, + ...(instances.length === 0 + ? { hint: 'No running devframe instances found. Start a devframe dev server (with --mcp for tools), or pass --port to devframe connect if the instance predates the registry.' } + : {}), + } +} + +/** + * Probe an explicit port for a devframe serving `__connection.json` at `/`. + * Tries the explicit address families too — a `localhost`-bound server may + * listen on either. + */ +async function probePort(port: number, timeoutMs?: number): Promise { + for (const origin of [`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`]) { + try { + const response = await fetch(`${origin}/__connection.json`, { + signal: AbortSignal.timeout(timeoutMs ?? 1000), + }) + if (!response.ok) + continue + const meta = await response.json() as { mcp?: { path: string, port?: number } } + const mcpPath = meta.mcp ? joinURL('/', meta.mcp.path) : null + return { + pid: -1, + port, + origin, + basePath: '/', + id: `port-${port}`, + rootDir: '', + mcp: mcpPath ? { path: mcpPath } : null, + startedAt: 0, + } + } + catch { + // Try the next candidate. + } + } + return null +} + +async function listInstanceTools(sdk: any, url: string): Promise<{ name: string, description?: string }[]> { + return withInstanceClient(sdk, url, async (client) => { + const listed = await client.listTools() + return listed.tools.map((tool: { name: string, description?: string }) => ({ + name: tool.name, + description: tool.description, + })) + }) +} + +async function call( + sdk: any, + options: ConnectServerOptions, + args: { port?: number, tool?: string, args?: Record }, +): Promise { + if (typeof args.port !== 'number' || typeof args.tool !== 'string') { + throw Object.assign(new Error('devframe_call requires { port: number, tool: string }'), { + fix: `Call ${INDEX_TOOL} to get the port and tool names, then retry.`, + }) + } + + const { live } = await listLiveDevframeInstances({ + instancesDir: options.instancesDir, + timeoutMs: options.timeoutMs, + }) + const record = live.find(r => r.port === args.port) ?? await probePort(args.port, options.timeoutMs) + if (!record) { + throw Object.assign(new Error(`no running devframe instance on port ${args.port}`), { + fix: `Call ${INDEX_TOOL} for the current instance list — the instance may have stopped or changed port.`, + }) + } + if (!record.mcp) { + throw Object.assign(new Error(`the devframe instance on port ${args.port} has no MCP endpoint`), { + fix: MCP_DISABLED_HINT, + }) + } + + const url = `${record.origin}${record.mcp.path}` + return withInstanceClient(sdk, url, async (client) => { + const result = await client.callTool({ name: args.tool, arguments: args.args ?? {} }) + return { + instance: { id: record.id, port: record.port }, + tool: args.tool, + isError: result.isError ?? false, + content: result.content, + ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}), + } + }) +} + +async function withInstanceClient(sdk: any, url: string, fn: (client: any) => Promise): Promise { + const transport = new sdk.StreamableHTTPClientTransport(new URL(url)) + const client = new sdk.Client({ name: 'devframe-connect', version: '0.0.0' }) + await client.connect(transport) + try { + return await fn(client) + } + finally { + await client.close().catch(() => {}) + } +} + +function textResult(value: unknown): { content: { type: 'text', text: string }[] } { + return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] } +} + +function errorResult(error: { message: string, fix?: string }): { + isError: true + content: { type: 'text', text: string }[] +} { + return { + isError: true, + content: [{ type: 'text', text: JSON.stringify({ error }, null, 2) }], + } +} + +/** Parse the repeatable `--port` flag value(s) from cac into numbers. */ +export function parsePortsFlag(value: unknown): number[] { + const values = Array.isArray(value) ? value : value === undefined ? [] : [value] + return values + .map(v => Number(v)) + .filter(n => Number.isInteger(n) && n > 0 && n < 65536) +} + +/** Keep the connector process alive until the stdio transport closes it. */ +export function keepAlive(): void { + // stdin stays open while the MCP client holds the pipe; nothing else to do. + process.stdin.resume() +} diff --git a/packages/devframe/src/cli/main.ts b/packages/devframe/src/cli/main.ts new file mode 100644 index 00000000..aa76b292 --- /dev/null +++ b/packages/devframe/src/cli/main.ts @@ -0,0 +1,32 @@ +import process from 'node:process' +import { cac } from 'cac' +import { keepAlive, parsePortsFlag, startConnectServer } from './connect' + +/** + * The `devframe` bin — the framework's own CLI, distinct from the per-app + * CLI shells authors build with `createCac(definition)`. It hosts the + * app-independent commands; today that is `connect`, the MCP connector. + * + * @experimental + */ +export async function runDevframeCli(argv: string[] = process.argv): Promise { + const cli = cac('devframe') + + cli + .command('connect', 'Run the devframe MCP connector on stdio (discovers running devframe dev servers and proxies their tools)') + .option('--port ', 'Probe an explicit port besides the instance registry (repeatable)') + .option('--instances-dir ', 'Override the instance registry directory (default: ~/.devframe/instances, or $DEVFRAME_INSTANCES_DIR)') + .option('--timeout ', 'Probe timeout per instance in milliseconds', { default: 1000 }) + .action(async (options: { port?: unknown, instancesDir?: string, timeout?: number }) => { + await startConnectServer({ + ports: parsePortsFlag(options.port), + instancesDir: options.instancesDir, + timeoutMs: options.timeout, + }) + keepAlive() + }) + + cli.help() + cli.parse(argv, { run: false }) + await cli.runMatchedCommand() +} diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 000247ea..dcab1f53 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -1,6 +1,9 @@ import { defineDiagnostics } from 'nostics' import { devframeReporter } from '../utils/diagnostics-reporter' +// DF00xx codes are allocated across packages (e.g. @devframes/json-render +// owns DF0037–DF0041), so this file alone doesn't show the next free +// number — check `docs/errors/` for the full allocation before adding one. export const diagnostics = defineDiagnostics({ docsBase: 'https://devfra.me/errors', reporters: [devframeReporter], @@ -80,5 +83,13 @@ export const diagnostics = defineDiagnostics({ why: (p: { id: string }) => `"${p.id}" declares \`capabilities.build: false\` — its static export is not meaningful (writes are excluded and any live-served data won't be there).`, fix: 'Pass `{ force: true }` to `createBuild()` if the degraded export is still useful to you, or drop `capabilities.build: false` on the definition.', }, + DF0045: { + why: (p: { file: string, reason: string }) => `Failed to update the devframe instance registry at "${p.file}": ${p.reason}`, + 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/sdk: ${p.reason}`, + fix: 'Install it next to devframe (e.g. `npm install @modelcontextprotocol/sdk`) and run `devframe connect` again.', + }, }, }) diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index 69867f47..c3904307 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -9,6 +9,7 @@ export type { RpcFunctionsHost } from './host-functions' export * from './host-h3' export * from './host-services' export * from './host-views' +export * from './instance-registry' export * from './rpc-shared-state' export * from './rpc-streaming' export * from './scope' diff --git a/packages/devframe/src/node/instance-registry.test.ts b/packages/devframe/src/node/instance-registry.test.ts new file mode 100644 index 00000000..4334c746 --- /dev/null +++ b/packages/devframe/src/node/instance-registry.test.ts @@ -0,0 +1,144 @@ +import type { AddressInfo } from 'node:net' +import type { DevframeInstanceRecord } from './instance-registry' +import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + listLiveDevframeInstances, + readDevframeInstances, + registerDevframeInstance, +} from './instance-registry' + +beforeEach(() => { + // The global vitest setup disables registration for every other test. + vi.stubEnv('DEVFRAME_DISABLE_INSTANCE_REGISTRY', '0') + return () => vi.unstubAllEnvs() +}) + +function makeRecord(overrides: Partial = {}): DevframeInstanceRecord { + return { + pid: 12345, + port: 4242, + origin: 'http://127.0.0.1:4242', + basePath: '/', + id: 'test-devframe', + name: 'Test Devframe', + rootDir: '/tmp/project', + mcp: { path: '/__mcp' }, + startedAt: Date.now(), + ...overrides, + } +} + +describe('instance registry', () => { + it('registers atomically and unregisters idempotently', () => { + const dir = mkdtempSync(join(tmpdir(), 'devframe-registry-')) + const record = makeRecord() + + const registration = registerDevframeInstance(record, { instancesDir: dir }) + expect(registration.file).toBe(join(dir, '12345-4242.json')) + expect(existsSync(registration.file)).toBe(true) + + const read = readDevframeInstances({ instancesDir: dir }) + expect(read).toHaveLength(1) + expect(read[0]).toMatchObject({ id: 'test-devframe', port: 4242, mcp: { path: '/__mcp' } }) + + registration.unregister() + expect(existsSync(registration.file)).toBe(false) + // Idempotent. + registration.unregister() + expect(readDevframeInstances({ instancesDir: dir })).toEqual([]) + }) + + it('skips unparseable records', () => { + const dir = mkdtempSync(join(tmpdir(), 'devframe-registry-')) + registerDevframeInstance(makeRecord(), { instancesDir: dir }) + // A partial write from a crashed process. + writeFileSync(join(dir, '999-1.json'), '{ not json') + + const read = readDevframeInstances({ instancesDir: dir }) + expect(read).toHaveLength(1) + }) + + it('dedups ghost records on the same port, keeping the newest', async () => { + const dir = mkdtempSync(join(tmpdir(), 'devframe-registry-')) + + const server = createServer((req, res) => { + res.writeHead(req.url === '/__connection.json' ? 200 : 404, { 'content-type': 'application/json' }) + res.end('{}') + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + + try { + // A ghost from a killed process, and the current server, same port. + registerDevframeInstance(makeRecord({ + pid: 2000, + port, + origin: `http://127.0.0.1:${port}`, + startedAt: 1000, + }), { instancesDir: dir }) + registerDevframeInstance(makeRecord({ + pid: 2001, + port, + origin: `http://127.0.0.1:${port}`, + startedAt: 2000, + }), { instancesDir: dir }) + + const { live, pruned } = await listLiveDevframeInstances({ instancesDir: dir, timeoutMs: 2000 }) + expect(live.map(r => r.pid)).toEqual([2001]) + expect(pruned.map(r => r.pid)).toEqual([2000]) + expect(readdirSync(dir)).toEqual([`2001-${port}.json`]) + } + finally { + await new Promise(resolve => server.close(() => resolve())) + } + }) + + it('prunes dead records and keeps live ones', async () => { + const dir = mkdtempSync(join(tmpdir(), 'devframe-registry-')) + + // A live instance: a real HTTP server answering __connection.json. + const server = createServer((req, res) => { + if (req.url === '/__connection.json') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"backend":"websocket"}') + return + } + res.writeHead(404) + res.end() + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + + try { + registerDevframeInstance(makeRecord({ + pid: 1000, + port, + origin: `http://127.0.0.1:${port}`, + }), { instancesDir: dir }) + + // A dead instance: nothing listens on this port (bound then closed). + const deadServer = createServer() + await new Promise(resolve => deadServer.listen(0, '127.0.0.1', resolve)) + const deadPort = (deadServer.address() as AddressInfo).port + await new Promise(resolve => deadServer.close(() => resolve())) + registerDevframeInstance(makeRecord({ + pid: 1001, + port: deadPort, + origin: `http://127.0.0.1:${deadPort}`, + }), { instancesDir: dir }) + + const { live, pruned } = await listLiveDevframeInstances({ instancesDir: dir, timeoutMs: 2000 }) + expect(live.map(r => r.pid)).toEqual([1000]) + expect(pruned.map(r => r.pid)).toEqual([1001]) + // The dead record's file is gone (prune-on-read). + expect(readdirSync(dir)).toEqual([`1000-${port}.json`]) + } + finally { + await new Promise(resolve => server.close(() => resolve())) + } + }) +}) diff --git a/packages/devframe/src/node/instance-registry.ts b/packages/devframe/src/node/instance-registry.ts new file mode 100644 index 00000000..8aaf9f30 --- /dev/null +++ b/packages/devframe/src/node/instance-registry.ts @@ -0,0 +1,264 @@ +import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import process from 'node:process' +import { join } from 'pathe' +import { diagnostics } from './diagnostics' + +/** + * One running devframe instance, as recorded in the instance registry. + * Records are self-describing JSON — additive fields are safe. + * + * @experimental The agent-native surface is experimental and may change + * without a major version bump until it stabilizes. + */ +export interface DevframeInstanceRecord { + /** Process id of the dev server. */ + pid: number + /** Listening port. */ + port: number + /** Dialable HTTP origin, e.g. `http://127.0.0.1:9876`. */ + origin: string + /** Base path the devframe is mounted at (trailing slash). */ + basePath: string + /** Definition id. */ + id: string + /** Definition display name. */ + name?: string + /** Working directory the instance was started from. */ + rootDir: string + /** + * Absolute URL path of the MCP Streamable-HTTP endpoint on `origin`, or + * `null` when the instance runs without an MCP route. + */ + mcp: { path: string } | null + /** Epoch-ms timestamp of registration. */ + startedAt: number +} + +/** + * Handle returned by {@link registerDevframeInstance}. + * + * @experimental + */ +export interface DevframeInstanceRegistration { + /** The registry file backing this registration. */ + readonly file: string + /** Remove the record (idempotent). Call on server close. */ + unregister: () => void +} + +/** Environment variable overriding the registry directory (tests, CI). */ +export const DEVFRAME_INSTANCES_DIR_ENV = 'DEVFRAME_INSTANCES_DIR' +/** Environment variable disabling instance registration entirely. */ +export const DEVFRAME_DISABLE_INSTANCE_REGISTRY_ENV = 'DEVFRAME_DISABLE_INSTANCE_REGISTRY' + +/** + * Resolve the registry directory: `~/.devframe/instances/` by default — + * the framework's own global dir, deliberately outside the per-app + * `~/./devframe/` storage convention since the registry spans apps — + * overridable via `DEVFRAME_INSTANCES_DIR`. + * + * @experimental + */ +export function resolveInstancesDir(override?: string): string { + return override + ?? process.env[DEVFRAME_INSTANCES_DIR_ENV] + ?? join(homedir(), '.devframe', 'instances') +} + +function isRegistryDisabled(): boolean { + const value = process.env[DEVFRAME_DISABLE_INSTANCE_REGISTRY_ENV] + return value === '1' || value === 'true' +} + +/** + * Record a running devframe instance in the global instance registry so + * discovery tooling (`devframe connect`, editor integrations) can find it + * without port guessing. + * + * `createDevServer` registers automatically; custom hosts that serve a + * devframe in-process (e.g. `@devframes/next`'s host inside a Next dev + * server) call this explicitly with the origin they are reachable at. + * + * The record is written atomically to `/-.json` and removed + * by {@link DevframeInstanceRegistration.unregister}. Records surviving a + * crash are pruned by readers whose liveness probe fails. Registration never + * throws — a write failure degrades to a coded warning (`DF0045`), since a + * dev server must not die over discovery metadata. + * + * @experimental + */ +export function registerDevframeInstance( + record: DevframeInstanceRecord, + options: { instancesDir?: string } = {}, +): DevframeInstanceRegistration { + const dir = resolveInstancesDir(options.instancesDir) + const file = join(dir, `${record.pid}-${record.port}.json`) + + if (!isRegistryDisabled()) { + try { + mkdirSync(dir, { recursive: true }) + // Atomic publish: write a temp file *in the same directory* (a rename + // is only atomic — and only possible — within one filesystem), then + // rename into place. + const tmp = join(dir, `.${record.pid}-${record.port}.${Date.now()}.tmp`) + writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`) + renameSync(tmp, file) + } + catch (error) { + diagnostics.DF0045({ file, reason: error instanceof Error ? error.message : String(error), cause: error }) + } + } + + return { + file, + unregister: () => { + try { + rmSync(file, { force: true }) + } + catch (error) { + diagnostics.DF0045({ file, reason: error instanceof Error ? error.message : String(error), cause: error }) + } + }, + } +} + +/** + * Read every record in the registry directory, dropping unparseable files. + * Liveness is the caller's concern — see {@link probeDevframeInstance}. + * + * @experimental + */ +export function readDevframeInstances(options: { instancesDir?: string } = {}): DevframeInstanceRecord[] { + const dir = resolveInstancesDir(options.instancesDir) + let files: string[] + try { + files = readdirSync(dir).filter(f => f.endsWith('.json')) + } + catch { + return [] + } + const records: DevframeInstanceRecord[] = [] + for (const file of files) { + try { + const parsed = JSON.parse(readFileSync(join(dir, file), 'utf8')) as DevframeInstanceRecord + if (typeof parsed?.origin === 'string' && typeof parsed?.pid === 'number') + records.push(parsed) + } + catch { + // Unparseable record (partial write from a crashed process) — skip; + // the prune pass below removes it once its liveness probe fails. + } + } + return records +} + +/** + * Dialable-origin candidates for a recorded origin. A `localhost` bind is + * ambiguous — the server may listen on `127.0.0.1`, `::1`, or both, and + * HTTP clients differ in which family they try — so probe the explicit + * addresses too and adopt whichever answers. + */ +function originCandidates(origin: string): string[] { + try { + const url = new URL(origin) + if (url.hostname !== 'localhost') + return [origin] + const port = url.port ? `:${url.port}` : '' + return [ + origin, + `${url.protocol}//127.0.0.1${port}`, + `${url.protocol}//[::1]${port}`, + ] + } + catch { + return [origin] + } +} + +/** + * Probe a record's `__connection.json` to check the instance is alive. + * Returns the **dialable origin** that answered (for `localhost` records + * this may be an explicit `127.0.0.1` / `[::1]` origin), or `null` when + * unreachable. + * + * @experimental + */ +export async function probeDevframeInstance( + record: DevframeInstanceRecord, + options: { timeoutMs?: number } = {}, +): Promise { + const base = record.basePath.endsWith('/') ? record.basePath : `${record.basePath}/` + for (const origin of originCandidates(record.origin)) { + try { + const response = await fetch(`${origin}${base}__connection.json`, { + signal: AbortSignal.timeout(options.timeoutMs ?? 1000), + }) + if (response.ok) + return origin + } + catch { + // Try the next candidate. + } + } + return null +} + +/** + * Read the registry and split records into live and dead by probing each + * one's `__connection.json`, deleting dead records (prune-on-read). Live + * records carry the dialable origin the probe confirmed (a `localhost` + * record may come back as `127.0.0.1` / `[::1]`). + * + * A liveness probe only proves *something* answers on the record's port, so + * records left behind by killed processes shadow the server currently bound + * there: per `(port, basePath)` only the newest record survives, older + * ghosts are pruned with the dead. + * + * @experimental + */ +export async function listLiveDevframeInstances( + options: { instancesDir?: string, timeoutMs?: number } = {}, +): Promise<{ live: DevframeInstanceRecord[], pruned: DevframeInstanceRecord[] }> { + const dir = resolveInstancesDir(options.instancesDir) + const records = readDevframeInstances({ instancesDir: dir }) + const pruned: DevframeInstanceRecord[] = [] + + const prune = (record: DevframeInstanceRecord): void => { + pruned.push(record) + try { + rmSync(join(dir, `${record.pid}-${record.port}.json`), { force: true }) + } + catch { + // Best-effort prune; a leftover file is re-pruned on the next read. + } + } + + // Dedup ghosts first: one record per (port, basePath), newest wins. + const newest = new Map() + for (const record of records) { + const key = `${record.port}|${record.basePath}` + const existing = newest.get(key) + if (!existing) { + newest.set(key, record) + } + else if (record.startedAt > existing.startedAt) { + prune(existing) + newest.set(key, record) + } + else { + prune(record) + } + } + + const live: DevframeInstanceRecord[] = [] + await Promise.all([...newest.values()].map(async (record) => { + const origin = await probeDevframeInstance(record, options) + if (origin) + live.push(origin === record.origin ? record : { ...record, origin }) + else + prune(record) + })) + live.sort((a, b) => a.startedAt - b.startedAt) + return { live, pruned } +} diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 6f1ffe4f..a3c2b230 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -106,6 +106,7 @@ const serverEntries = { 'adapters/build': 'src/adapters/build.ts', 'adapters/embedded': 'src/adapters/embedded.ts', 'adapters/mcp': 'src/adapters/mcp/index.ts', + 'cli/main': 'src/cli/main.ts', 'helpers/vite': 'src/helpers/vite.ts', 'recipes/common-rpc-functions': 'src/recipes/common-rpc-functions.ts', 'recipes/open-helpers': 'src/recipes/open-helpers.ts', diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index 4ea0d613..7ae3b356 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -1,4 +1,4 @@ -import type { ConnectionMeta, DevframeHost, DevframeStorageScope } from 'devframe/types' +import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope } from 'devframe/types' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' import { serveStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' @@ -24,6 +24,20 @@ export interface CreateDevframeNextHostOptions { connectionMeta?: ConnectionMeta } +export interface DevframeNextHostMcpOptions { + /** Name reported in the MCP handshake. Default: `'devframe (next)'`. */ + serverName?: string + /** Version reported in the MCP handshake. Default: `'0.0.0'`. */ + serverVersion?: string + /** Expose shared-state keys as MCP resources / `read_state`. Default: `true`. */ + exposeSharedState?: boolean | ((key: string) => boolean) + /** + * Origin allow-list beyond the loopback default. `false` disables the + * origin gate entirely. + */ + allowedOrigins?: readonly string[] | false +} + export interface DevframeNextHost { /** * The {@link DevframeHost} to hand to `createHubContext` / `createHostContext`. @@ -51,6 +65,22 @@ export interface DevframeNextHost { * `503` so a racing client retries rather than caching a wrong endpoint. */ setConnectionMeta: (meta: ConnectionMeta) => void + /** + * 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/sdk` + * stays an optional peer). 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. + * + * @experimental + */ + mountMcp: ( + ctx: DevframeNodeContext, + path: string, + options?: DevframeNextHostMcpOptions, + ) => Promise<{ dispose: () => Promise }> } const META_SUFFIX = `/${DEVFRAME_CONNECTION_META_FILENAME}` @@ -80,6 +110,7 @@ export function createDevframeNextHost( ): DevframeNextHost { const app = new H3() const metaBases = new Set() + const mcpMounts = new Map Promise }>() let connectionMeta = options.connectionMeta const host: DevframeHost = { @@ -101,6 +132,12 @@ export function createDevframeNextHost( async function fetch(request: Request): Promise { const { pathname } = new URL(request.url) + // MCP endpoints answer before the static handler for the same reason as + // the connection meta below: SPA fallback must not swallow them. + const mcp = mcpMounts.get(stripTrailingSlash(pathname)) + if (mcp) + return mcp.fetch(request) + // Answer `/__connection.json` before the static handler runs — a // mounted SPA's SPA-fallback would otherwise resolve the miss to // `index.html` and swallow the discovery request. @@ -126,5 +163,22 @@ export function createDevframeNextHost( setConnectionMeta(meta) { connectionMeta = meta }, + async mountMcp(ctx, path, mcpOptions = {}) { + const { createMcpFetchHandler } = await import('devframe/adapters/mcp') + const handler = createMcpFetchHandler(ctx, { + serverName: mcpOptions.serverName ?? 'devframe (next)', + serverVersion: mcpOptions.serverVersion ?? '0.0.0', + exposeSharedState: mcpOptions.exposeSharedState ?? true, + allowedOrigins: mcpOptions.allowedOrigins, + }) + const key = stripTrailingSlash(path) + mcpMounts.set(key, handler) + return { + dispose: async () => { + mcpMounts.delete(key) + await handler.dispose() + }, + } + }, } } diff --git a/plans/031-agent-native-mcp-wave.md b/plans/031-agent-native-mcp-wave.md index c1d3ce8b..4e89c810 100644 --- a/plans/031-agent-native-mcp-wave.md +++ b/plans/031-agent-native-mcp-wave.md @@ -123,18 +123,20 @@ literal "/_next/mcp" shape on devframe primitives. ## Done criteria -- [ ] Phase 1: both bridges forward + advertise MCP; `formatMcpError` emits +- [x] 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; + comment gone; conventions documented. (PR 1) +- [x] 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 + agent-visible with schemas. (PR 2) +- [x] 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. + e2e gates green in CI. (PR 3) +- [x] Every phase: full gate green, API snapshots updated deliberately, new + node-side errors use coded diagnostics with docs pages (`DF0042`, + `DF0043`, `DF8404` — note: DF00xx numbers are allocated across + packages; check `docs/errors/` for the next free code). +- [ ] `plans/README.md` row set to DONE once the three PRs merge. ## STOP conditions diff --git a/playwright.config.ts b/playwright.config.ts index 6720bcab..eab7069b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,6 +5,12 @@ import { defineConfig, devices } from '@playwright/test' const fixtureCwd = fileURLToPath(new URL('./tests/e2e/fixtures', import.meta.url)) const serveStatic = fileURLToPath(new URL('./tests/e2e/_support/serve-static.mjs', import.meta.url)) +// Hermetic per-suite instance-registry dirs so the `devframe connect` specs +// see exactly the instance they booted (and local runs never touch +// `~/.devframe/instances`). Servers without a connect spec opt out entirely. +const filesInspectorRegistry = fileURLToPath(new URL('./tests/e2e/.registries/files-inspector', import.meta.url)) +const nextHubRegistry = fileURLToPath(new URL('./tests/e2e/.registries/next-hub', import.meta.url)) + export default defineConfig({ testDir: './tests/e2e', testIgnore: ['_support/**'], @@ -23,9 +29,12 @@ export default defineConfig({ ], webServer: [ { - command: 'node bin.mjs', + // Explicit IPv4 bind: the connect spec's registry probe and MCP client + // dial the recorded origin directly, and a bare `localhost` bind is + // family-ambiguous across environments. + command: 'node bin.mjs --host 127.0.0.1', cwd: 'examples/files-inspector', - env: { DEVFRAME_E2E_CWD: fixtureCwd }, + env: { DEVFRAME_E2E_CWD: fixtureCwd, DEVFRAME_INSTANCES_DIR: filesInspectorRegistry }, url: 'http://localhost:9876/__devframe-files-inspector/', timeout: 60_000, reuseExistingServer: !process.env.CI, @@ -35,6 +44,7 @@ export default defineConfig({ { command: 'node bin.mjs', cwd: 'examples/streaming-chat', + env: { DEVFRAME_DISABLE_INSTANCE_REGISTRY: '1' }, url: 'http://localhost:9897/__devframe-streaming-chat/', timeout: 60_000, reuseExistingServer: !process.env.CI, @@ -63,12 +73,23 @@ export default defineConfig({ { command: 'node bin.mjs', cwd: 'examples/next-runtime-snapshot', + env: { DEVFRAME_DISABLE_INSTANCE_REGISTRY: '1' }, url: 'http://localhost:9899/__next-runtime-snapshot/', timeout: 60_000, reuseExistingServer: !process.env.CI, stdout: 'pipe', stderr: 'pipe', }, + { + command: 'pnpm exec next dev src/client -p 9878', + cwd: 'examples/minimal-next-devframe-hub', + env: { PORT: '9878', DEVFRAME_INSTANCES_DIR: nextHubRegistry }, + url: 'http://localhost:9878/', + timeout: 120_000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, { command: `node bin.mjs build --out-dir dist/static && node ${JSON.stringify(serveStatic)} dist/static 9889`, cwd: 'examples/next-runtime-snapshot', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beeb98b7..3dc4d0e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -379,6 +379,9 @@ importers: '@antfu/utils': specifier: catalog:inlined version: 9.3.0 + '@modelcontextprotocol/sdk': + specifier: catalog:deps + version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) '@playwright/test': specifier: catalog:testing version: 1.62.0 diff --git a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts index aeb6f943..fecc64ed 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts @@ -30,6 +30,9 @@ export interface DevframeNextHost { host: DevframeHost; fetch: (_: Request) => Promise; setConnectionMeta: (_: ConnectionMeta) => void; + mountMcp: (_: DevframeNodeContext, _: string, _?: DevframeNextHostMcpOptions) => Promise<{ + dispose: () => Promise; + }>; } // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 64e82a9e..2b1b26b5 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -22,6 +22,23 @@ export interface CreateStorageOptions { mergeInitialValue?: false | ((_: T, _: T) => T); debounce?: number; } +export interface DevframeInstanceRecord { + pid: number; + port: number; + origin: string; + basePath: string; + id: string; + name?: string; + rootDir: string; + mcp: { + path: string; + } | null; + startedAt: number; +} +export interface DevframeInstanceRegistration { + readonly file: string; + unregister: () => void; +} // #endregion // #region Classes @@ -86,10 +103,32 @@ export declare function createScopedNodeContext(_: D export declare function createStorage(_: CreateStorageOptions): SharedState; export declare function formatHostForUrl(_: string): string; export declare function isObject(_: unknown): value is Record; +export declare function listLiveDevframeInstances(_?: { + instancesDir?: string; + timeoutMs?: number; +}): Promise<{ + live: DevframeInstanceRecord[]; + pruned: DevframeInstanceRecord[]; +}>; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; +export declare function probeDevframeInstance(_: DevframeInstanceRecord, _?: { + timeoutMs?: number; +}): Promise; +export declare function readDevframeInstances(_?: { + instancesDir?: string; +}): DevframeInstanceRecord[]; +export declare function registerDevframeInstance(_: DevframeInstanceRecord, _?: { + instancesDir?: string; +}): DevframeInstanceRegistration; +export declare function resolveInstancesDir(_?: string): string; export declare function toDialableHost(_: string): string; // #endregion +// #region Variables +export declare const DEVFRAME_DISABLE_INSTANCE_REGISTRY_ENV: string; +export declare const DEVFRAME_INSTANCES_DIR_ENV: string; +// #endregion + // #region Other export { RpcFunctionsHost } export { StartedServer } diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index a580513e..eb306dc3 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -9,13 +9,20 @@ export { createRpcSharedStateServerHost } export { createRpcStreamingServerHost } export { createScopedNodeContext } export { createStorage } +export { DEVFRAME_DISABLE_INSTANCE_REGISTRY_ENV } +export { DEVFRAME_INSTANCES_DIR_ENV } export { DevframeAgentHost } export { DevframeDiagnosticsHost } export { DevframeServicesHostImpl } export { DevframeViewHost } export { formatHostForUrl } export { isObject } +export { listLiveDevframeInstances } export { normalizeHttpServerUrl } +export { probeDevframeInstance } +export { readDevframeInstances } +export { registerDevframeInstance } +export { resolveInstancesDir } export { startHttpAndWs } export { toDialableHost } // #endregion \ No newline at end of file diff --git a/tests/e2e/_support/mcp-connect.ts b/tests/e2e/_support/mcp-connect.ts new file mode 100644 index 00000000..73bac716 --- /dev/null +++ b/tests/e2e/_support/mcp-connect.ts @@ -0,0 +1,33 @@ +import { fileURLToPath } from 'node:url' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' + +const BIN = fileURLToPath(new URL('../../../packages/devframe/bin/devframe.mjs', import.meta.url)) + +/** + * Spawn `devframe connect` over stdio against a hermetic registry dir and + * hand a connected MCP client to `fn`, tearing the process down after. + */ +export async function withConnectClient( + instancesDir: string, + fn: (client: Client) => Promise, +): Promise { + const transport = new StdioClientTransport({ + command: 'node', + args: [BIN, 'connect', '--instances-dir', instancesDir], + }) + const client = new Client({ name: 'devframe-e2e', version: '0.0.0' }) + await client.connect(transport) + try { + return await fn(client) + } + finally { + await client.close() + } +} + +/** Parse the JSON payload the connector returns in its single text block. */ +export function parseToolText(result: unknown): any { + const content = (result as { content: Array<{ type: string, text: string }> }).content + return JSON.parse(content[0]!.text) +} diff --git a/tests/e2e/devframe-connect.spec.ts b/tests/e2e/devframe-connect.spec.ts new file mode 100644 index 00000000..9afc5d43 --- /dev/null +++ b/tests/e2e/devframe-connect.spec.ts @@ -0,0 +1,52 @@ +import { fileURLToPath } from 'node:url' +import { expect, test } from '@playwright/test' +import { parseToolText, withConnectClient } from './_support/mcp-connect' + +const REGISTRY = fileURLToPath(new URL('./.registries/files-inspector', import.meta.url)) + +test.describe('devframe connect (files-inspector)', () => { + test('discovers the instance and round-trips a tool call', async () => { + await withConnectClient(REGISTRY, async (client) => { + // The connector exposes exactly the two gateway tools. + const tools = await client.listTools() + expect(tools.tools.map(t => t.name).sort()).toEqual(['devframe_call', 'devframe_index']) + + // Index: the registry-registered dev server is discovered with its + // MCP endpoint and tool list. + const index = parseToolText(await client.callTool({ name: 'devframe_index', arguments: {} })) + const instance = index.instances.find( + (entry: any) => entry.id === 'devframe-files-inspector' && entry.port === 9876, + ) + expect(instance).toBeDefined() + // The probe may adopt an explicit address family for a `localhost` + // origin — accept either spelling. + expect(instance.mcp.url).toMatch(/^http:\/\/(?:localhost|127\.0\.0\.1):9876\/__devframe-files-inspector\/__mcp$/) + const toolNames = instance.mcp.tools.map((t: any) => t.name) + expect(toolNames).toContain('read_state') + expect(toolNames).toContain('devframe-files-inspector:docs') + + // Call: proxy the gateway tool through the connector. + const call = parseToolText(await client.callTool({ + name: 'devframe_call', + arguments: { port: 9876, tool: 'devframe-files-inspector:docs' }, + })) + expect(call.isError).toBe(false) + const inner = JSON.parse(call.content[0].text) + expect(inner.readmePath).toMatch(/README\.md$/) + expect(inner.hint).toContain('Read the file') + }) + }) + + test('devframe_call reports actionable errors for unknown targets', async () => { + await withConnectClient(REGISTRY, async (client) => { + const result = await client.callTool({ + name: 'devframe_call', + arguments: { port: 1, tool: 'anything' }, + }) + expect(result.isError).toBe(true) + const payload = parseToolText(result) + expect(payload.error.message).toContain('no running devframe instance on port 1') + expect(payload.error.fix).toContain('devframe_index') + }) + }) +}) diff --git a/tests/e2e/minimal-next-devframe-hub-dev.spec.ts b/tests/e2e/minimal-next-devframe-hub-dev.spec.ts new file mode 100644 index 00000000..e1840bfb --- /dev/null +++ b/tests/e2e/minimal-next-devframe-hub-dev.spec.ts @@ -0,0 +1,57 @@ +import { fileURLToPath } from 'node:url' +import { expect, test } from '@playwright/test' +import { parseToolText, withConnectClient } from './_support/mcp-connect' + +const ORIGIN = 'http://localhost:9878' +const REGISTRY = fileURLToPath(new URL('./.registries/next-hub', import.meta.url)) + +test.describe('devframe connect (minimal-next-devframe-hub)', () => { + test('discovers the in-process hub endpoint and calls an agent-flagged command', async () => { + test.setTimeout(180_000) + + // The hub boots lazily on the first route hit; the connection meta + // answers 503 until the side-car WS is live and the meta is published. + await expect.poll(async () => { + try { + const response = await fetch(`${ORIGIN}/__hub/__connection.json`) + return response.status + } + catch { + return 0 + } + }, { timeout: 150_000, intervals: [1000] }).toBe(200) + + // The meta advertises the in-process MCP endpoint — same origin as the + // Next app, no side-car port (the `/_next/mcp` shape). + const meta = await (await fetch(`${ORIGIN}/__hub/__connection.json`)).json() as { + mcp?: { path: string, port?: number } + } + expect(meta.mcp).toEqual({ path: '/__hub/__mcp' }) + + await withConnectClient(REGISTRY, async (client) => { + // Index: the hub registered itself (explicitly — it runs in-process, + // not via createDevServer) with the Next server's own origin. + const index = parseToolText(await client.callTool({ name: 'devframe_index', arguments: {} })) + const hub = index.instances.find((entry: any) => entry.id === 'minimal-next-devframe-hub') + expect(hub).toBeDefined() + // The probe may adopt an explicit address family for the recorded + // `localhost` origin — accept either spelling. + expect(hub.mcp.url).toMatch(/^http:\/\/(?:localhost|127\.0\.0\.1):9878\/__hub\/__mcp$/) + + // The hub's agent surface flows through: the agent-flagged hub command, + // the built-in read_state, and the git plugin's agent-flagged reads. + const toolNames = hub.mcp.tools.map((t: any) => t.name) + expect(toolNames).toContain('minimal-next-devframe-hub:ping') + expect(toolNames).toContain('read_state') + expect(toolNames).toContain('devframes:plugin:git:status') + + // Call the agent-flagged hub command through the connector. + const ping = parseToolText(await client.callTool({ + name: 'devframe_call', + arguments: { port: 9878, tool: 'minimal-next-devframe-hub:ping' }, + })) + expect(ping.isError).toBe(false) + expect(ping.content[0].text).toBe('pong') + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index c12a73d5..7497088b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ +import process from 'node:process' import { defineConfig } from 'vitest/config' import { alias } from './alias' +// Unit tests boot real dev servers; keep them out of the user's global +// devframe instance registry. Registry-specific tests re-enable it with +// `vi.stubEnv` + an explicit `instancesDir`. +process.env.DEVFRAME_DISABLE_INSTANCE_REGISTRY ??= '1' + export default defineConfig({ resolve: { alias, From 3a7adf9a082c87933eb7e6b5c4e639e5abd2d26c Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 29 Jul 2026 07:21:02 +0000 Subject: [PATCH 3/9] refactor(agent): lazy tool providers + convention-following tool names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ctx.agent.registerToolProvider(() => AgentToolInput[]): a lazy tool source queried at list/getTool/invoke time — the same on-demand projection applied to agent-flagged RPCs; earlier sources win on id collision; handle.notifyChanged() drives tools/list_changed - hub commands host derives its agent projection through one provider: the commands map is the single source of truth, replacing the registerAgentTools/unregisterAgentTools mirror and its handle map - built-in and connector tool names follow the devframe:: convention: read_state -> devframe:state:read, devframe_index -> devframe:connect:list-instances, devframe_call -> devframe:connect:call-tool --- docs/adapters/mcp.md | 4 +- docs/guide/agent-native.md | 19 ++++- .../src/client/devframe/next-devframe-hub.ts | 2 +- .../adapters/mcp/__tests__/mcp-server.test.ts | 20 ++--- .../devframe/src/adapters/mcp/build-server.ts | 12 +-- packages/devframe/src/cli/connect.ts | 22 +++--- .../src/node/__tests__/host-agent.test.ts | 53 +++++++++++++ packages/devframe/src/node/host-agent.ts | 53 ++++++++++++- packages/devframe/src/types/agent.ts | 35 +++++++++ .../src/node/__tests__/host-commands.test.ts | 57 +++++++------- packages/hub/src/node/host-commands.ts | 78 +++++++++---------- packages/next/src/host.ts | 2 +- plans/031-agent-native-mcp-wave.md | 18 ++--- .../tsnapi/@devframes/hub/node.snapshot.d.ts | 5 +- .../tsnapi/@devframes/hub/node.snapshot.js | 5 +- .../tsnapi/devframe/index.snapshot.d.ts | 5 ++ .../tsnapi/devframe/node.snapshot.d.ts | 3 + .../tsnapi/devframe/types.snapshot.d.ts | 2 + tests/e2e/devframe-connect.spec.ts | 14 ++-- .../e2e/minimal-next-devframe-hub-dev.spec.ts | 8 +- 20 files changed, 291 insertions(+), 126 deletions(-) diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index e45ced0d..901121a9 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -87,8 +87,8 @@ The `devframe` bin ships an MCP **connector** — a thin discovery + proxy serve It exposes two gateway tools: -- **`devframe_index`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`. -- **`devframe_call`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint. +- **`devframe:connect:list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`. +- **`devframe:connect:call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint. Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md index d89b782d..7cf737d8 100644 --- a/docs/guide/agent-native.md +++ b/docs/guide/agent-native.md @@ -63,6 +63,23 @@ export default defineDevframe({ }) ``` +## Deriving tools from other state + +When tools derive from state you already maintain — a command registry, a plugin catalog — register a **provider** instead of mirroring registrations. The host queries it at list/invoke time (the same lazy projection it applies to `agent`-flagged RPCs), so your source of truth stays the only copy: + +```ts +const handle = ctx.agent.registerToolProvider(() => + currentCommands() + .filter(command => command.agent) + .map(command => toAgentTool(command)), +) + +// After the underlying state changes, nudge connected MCP clients: +handle.notifyChanged() // fires tools/list_changed +``` + +The hub's commands host uses exactly this to project agent-flagged palette commands. + ## Registering a resource Resources surface readable snapshots of state, identified by URI: @@ -79,7 +96,7 @@ ctx.agent.registerResource({ Every `ctx.rpc.sharedState` key is also automatically exposed to MCP as `devframe://state/`. Pass `exposeSharedState: false` (or a filter function) to `createMcpServer` to opt out. -Shared state is additionally reachable through the built-in **`read_state` tool** — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection. +Shared state is additionally reachable through the built-in **`devframe:state:read` tool** — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection. ## Starting the MCP server diff --git a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts index 2ab7e69d..94a25482 100644 --- a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts +++ b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts @@ -273,7 +273,7 @@ export async function nextDevframeHub( // Serve MCP in-process on the Next app's own origin (the `/_next/mcp` // shape): the hub's agent surface — agent-flagged commands, plugin tools - // (git status/log/diff, terminals), `read_state` — over the same catch-all + // (git status/log/diff, terminals), `devframe:state:read` — over the same catch-all // route as the SPAs, no side-car port involved. const mcpPath = '/__hub/__mcp' await nextHost.mountMcp(context, mcpPath, { diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index abc375bd..95f015d8 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -201,7 +201,7 @@ describe('mcp adapter (in-memory)', () => { } }) - it('exposes shared state through the built-in read_state tool', async () => { + it('exposes shared state through the built-in devframe:state:read tool', async () => { const { ctx, client, cleanup } = await bootPair() try { await ctx.rpc.sharedState.get('my-plugin:counter', { @@ -209,20 +209,20 @@ describe('mcp adapter (in-memory)', () => { }) const listed = await client.listTools() - const tool = listed.tools.find(t => t.name === 'read_state') + const tool = listed.tools.find(t => t.name === 'devframe:state:read') expect(tool).toBeDefined() expect(tool!.annotations?.readOnlyHint).toBe(true) // No key → key list. - const keys = await client.callTool({ name: 'read_state', arguments: {} }) + const keys = await client.callTool({ name: 'devframe:state:read', arguments: {} }) expect(keys.structuredContent).toEqual({ keys: ['my-plugin:counter'] }) // With key → the value. - const value = await client.callTool({ name: 'read_state', arguments: { key: 'my-plugin:counter' } }) + const value = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'my-plugin:counter' } }) expect(value.structuredContent).toEqual({ key: 'my-plugin:counter', value: { count: 7 } }) // Unknown key → agent-actionable error. - const missing = await client.callTool({ name: 'read_state', arguments: { key: 'nope' } }) + const missing = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'nope' } }) expect(missing.isError).toBe(true) const content = missing.content as Array<{ text: string }> expect(content[0]!.text).toContain('unknown shared-state key') @@ -232,7 +232,7 @@ describe('mcp adapter (in-memory)', () => { } }) - it('hides read_state when shared-state exposure is disabled', async () => { + it('hides devframe:state:read when shared-state exposure is disabled', async () => { const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) const { server, dispose } = buildMcpServerFromContext(ctx, { serverName: 'test', @@ -245,7 +245,7 @@ describe('mcp adapter (in-memory)', () => { await client.connect(clientTransport) try { const listed = await client.listTools() - expect(listed.tools.map(t => t.name)).not.toContain('read_state') + expect(listed.tools.map(t => t.name)).not.toContain('devframe:state:read') } finally { dispose() @@ -254,7 +254,7 @@ describe('mcp adapter (in-memory)', () => { } }) - it('respects the shared-state filter in read_state', async () => { + it('respects the shared-state filter in devframe:state:read', async () => { const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } }) await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } }) @@ -268,10 +268,10 @@ describe('mcp adapter (in-memory)', () => { const client = new Client({ name: 'test-client', version: '0.0.0' }) await client.connect(clientTransport) try { - const keys = await client.callTool({ name: 'read_state', arguments: {} }) + const keys = await client.callTool({ name: 'devframe:state:read', arguments: {} }) expect(keys.structuredContent).toEqual({ keys: ['visible:key'] }) - const hidden = await client.callTool({ name: 'read_state', arguments: { key: 'hidden:key' } }) + const hidden = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'hidden:key' } }) expect(hidden.isError).toBe(true) } finally { diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 7aeeddd0..85614964 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -147,10 +147,10 @@ export async function createMcpServer( } /** - * Name of the built-in shared-state read tool. Tool-shaped access matters - * because many MCP clients only consume tools — the parallel - * `devframe://state/` resource projection stays for the clients that do - * read resources. + * Name of the built-in shared-state read tool — namespaced like every other + * built-in (`devframe::`). Tool-shaped access matters because many + * MCP clients only consume tools — the parallel `devframe://state/` + * resource projection stays for the clients that do read resources. */ const READ_STATE_TOOL = 'devframe:state:read' @@ -215,8 +215,8 @@ function registerToolHandlers( const { name, arguments: args } = request.params try { // Built-in shared-state read. A registered agent tool of the same - // name wins (mirroring the list projection above); plugin tools keep - // namespaced ids (`:`), so collisions are deliberate. + // name wins (mirroring the list projection above) — ids are + // namespaced, so a collision is a deliberate override. if (stateFilter && name === READ_STATE_TOOL && !ctx.agent.getTool(READ_STATE_TOOL)) { const key = (args as { key?: string } | undefined)?.key const result = await readStateResult(ctx, stateFilter, key) diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts index 5976f49a..d54bb69d 100644 --- a/packages/devframe/src/cli/connect.ts +++ b/packages/devframe/src/cli/connect.ts @@ -38,19 +38,19 @@ interface IndexedInstance { hint?: string } -const INDEX_TOOL = 'devframe_index' -const CALL_TOOL = 'devframe_call' +const INDEX_TOOL = 'devframe:connect:list-instances' +const CALL_TOOL = 'devframe:connect:call-tool' const MCP_DISABLED_HINT - = 'This instance runs without an MCP route. Restart it with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then call devframe_index again.' + = 'This instance runs without an MCP route. Restart it with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.' /** * Start the devframe MCP connector on stdio: a thin discovery + proxy server * in the shape next-devtools-mcp validated. It exposes two gateway tools — - * `devframe_index` (discover running devframe instances via the instance - * registry and list each one's MCP tools) and `devframe_call` (invoke one - * tool on one instance over its Streamable-HTTP endpoint) — and holds no - * domain knowledge of its own. + * `devframe:connect:list-instances` (discover running devframe instances via + * the instance registry and list each one's MCP tools) and + * `devframe:connect:call-tool` (invoke one tool on one instance over its + * Streamable-HTTP endpoint) — and holds no domain knowledge of its own. * * @experimental */ @@ -67,18 +67,18 @@ export async function startConnectServer(options: ConnectServerOptions = {}): Pr { name: INDEX_TOOL, title: 'Discover running devframes', - description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available — the result names the instance (id, project root, origin) and the port to pass to devframe_call. Safe to call freely.', + description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available — the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: CALL_TOOL, title: 'Call a devframe tool', - description: 'Invoke one MCP tool on one running devframe instance discovered via devframe_index. Pass the instance\'s port, the tool name, and the tool\'s arguments object.', + description: 'Invoke one MCP tool on one running devframe instance discovered via the list-instances tool. Pass the instance\'s port, the tool name, and the tool\'s arguments object.', inputSchema: { type: 'object', properties: { - port: { type: 'number', description: 'The instance\'s port, from devframe_index.' }, + port: { type: 'number', description: 'The instance\'s port, from the list-instances tool.' }, tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' }, args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' }, }, @@ -235,7 +235,7 @@ async function call( args: { port?: number, tool?: string, args?: Record }, ): Promise { if (typeof args.port !== 'number' || typeof args.tool !== 'string') { - throw Object.assign(new Error('devframe_call requires { port: number, tool: string }'), { + throw Object.assign(new Error(`${CALL_TOOL} requires { port: number, tool: string }`), { fix: `Call ${INDEX_TOOL} to get the port and tool names, then retry.`, }) } diff --git a/packages/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts index 90379e23..285a9ed6 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -269,4 +269,57 @@ describe('devToolsAgentHost', () => { await expect(ctx.agent.read('ghost')).rejects.toThrow(/ghost/) }) }) + + describe('registerToolProvider()', () => { + it('queries the provider lazily on list/getTool/invoke', async () => { + const ctx = createContext() + const handler = vi.fn(async (args: unknown) => args) + let exposed = false + ctx.agent.registerToolProvider(() => exposed + ? [{ id: 'derived:tool', description: 'Derived.', safety: 'read', handler }] + : []) + + // The provider's source of truth changes; no re-registration needed. + expect(ctx.agent.getTool('derived:tool')).toBeUndefined() + exposed = true + expect(ctx.agent.getTool('derived:tool')).toMatchObject({ + id: 'derived:tool', + kind: 'tool', + safety: 'read', + }) + expect(ctx.agent.list().tools.map(t => t.id)).toEqual(['derived:tool']) + + await expect(ctx.agent.invoke('derived:tool', { a: 1 })).resolves.toEqual({ a: 1 }) + expect(handler).toHaveBeenCalledWith({ a: 1 }) + }) + + it('earlier sources win on id collision', () => { + const ctx = createContext() + ctx.agent.registerTool({ id: 'shared:id', description: 'Registered.', handler: () => 'plain' }) + ctx.agent.registerToolProvider(() => [ + { id: 'shared:id', description: 'Provided.', handler: () => 'provided' }, + ]) + + expect(ctx.agent.getTool('shared:id')!.description).toBe('Registered.') + expect(ctx.agent.list().tools.filter(t => t.id === 'shared:id')).toHaveLength(1) + }) + + it('notifyChanged and unregister fire agent:manifest:changed', () => { + const ctx = createContext() + const manifestHandler = vi.fn() + const handle = ctx.agent.registerToolProvider(() => []) + ctx.agent.events.on('agent:manifest:changed', manifestHandler) + + handle.notifyChanged() + expect(manifestHandler).toHaveBeenCalledTimes(1) + + handle.unregister() + expect(manifestHandler).toHaveBeenCalledTimes(2) + + // After unregistration the handle goes quiet. + handle.notifyChanged() + handle.unregister() + expect(manifestHandler).toHaveBeenCalledTimes(2) + }) + }) }) diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index 12245734..2b20ae88 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -7,6 +7,8 @@ import type { AgentResourceInput, AgentTool, AgentToolInput, + AgentToolProvider, + AgentToolProviderHandle, DevframeAgentHostEvents, DevframeAgentHost as DevframeAgentHostType, DevframeNodeContext, @@ -39,6 +41,7 @@ export class DevframeAgentHost implements DevframeAgentHostType { private readonly tools = new Map() private readonly resources = new Map() + private readonly providers = new Set() private _rpcUnsubscribe: (() => void) | undefined constructor( @@ -72,6 +75,23 @@ export class DevframeAgentHost implements DevframeAgentHostType { return existed } + registerToolProvider(provider: AgentToolProvider): AgentToolProviderHandle { + this.providers.add(provider) + this.events.emit('agent:manifest:changed') + + const notifyChanged = (): void => { + if (this.providers.has(provider)) + this.events.emit('agent:manifest:changed') + } + return { + notifyChanged, + unregister: () => { + if (this.providers.delete(provider)) + this.events.emit('agent:manifest:changed') + }, + } + } + registerResource(input: AgentResourceInput): AgentHandle { if (this.resources.has(input.id)) throw diagnostics.DF0016({ id: input.id }) @@ -105,8 +125,19 @@ export class DevframeAgentHost implements DevframeAgentHostType { const rpcTools = this._collectRpcTools() const plainTools = Array.from(this.tools.values()).map(t => t.tool) const resources = Array.from(this.resources.values()).map(r => r.resource) + + // Provider tools are queried lazily; earlier sources win on id collision. + const seen = new Set([...rpcTools, ...plainTools].map(t => t.id)) + const providerTools: AgentTool[] = [] + for (const { tool } of this._collectProviderTools()) { + if (seen.has(tool.id)) + continue + seen.add(tool.id) + providerTools.push(tool) + } + return { - tools: [...rpcTools, ...plainTools], + tools: [...rpcTools, ...plainTools, ...providerTools], resources, } } @@ -115,7 +146,10 @@ export class DevframeAgentHost implements DevframeAgentHostType { const plain = this.tools.get(id) if (plain) return plain.tool - return this._collectRpcTools().find(t => t.id === id) + const rpc = this._collectRpcTools().find(t => t.id === id) + if (rpc) + return rpc + return this._collectProviderTools().find(t => t.tool.id === id)?.tool } getResource(id: string): AgentResource | undefined { @@ -136,6 +170,11 @@ export class DevframeAgentHost implements DevframeAgentHostType { return await this.context.rpc.invokeLocal(id as any, ...(positional as any)) } + const provided = this._collectProviderTools().find(t => t.tool.id === id) + if (provided) { + return await provided.input.handler(args) + } + throw new Error(`[devframe/agent] tool "${id}" not found`) } @@ -178,6 +217,16 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } + /** Query every registered provider, projecting inputs to serializable tools. */ + private _collectProviderTools(): { input: AgentToolInput, tool: AgentTool }[] { + const out: { input: AgentToolInput, tool: AgentTool }[] = [] + for (const provider of this.providers) { + for (const input of provider()) + out.push({ input, tool: this._projectTool(input) }) + } + return out + } + private _collectRpcTools(): AgentTool[] { const out: AgentTool[] = [] for (const [name, def] of this.context.rpc.definitions) { diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index bb559bb6..3558baff 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -114,6 +114,35 @@ export interface AgentHandle { unregister: () => void } +/** + * A lazy source of agent tools, queried at `list()` / `getTool()` / + * `invoke()` time — the same on-demand projection the host applies to + * `agent`-flagged RPC definitions. Use a provider when tools *derive from* + * other state (a command registry, a plugin catalog): the underlying state + * stays the single source of truth and nothing needs to be kept in sync. + * + * Providers should namespace tool ids like any other tool; on an id + * collision the earlier source wins (registered tools, then RPC tools, + * then providers in registration order). + * + * @experimental + */ +export type AgentToolProvider = () => readonly AgentToolInput[] + +/** + * Handle returned by `registerToolProvider`. + * + * @experimental + */ +export interface AgentToolProviderHandle extends AgentHandle { + /** + * Signal that the provider's tool set changed. Fires + * `agent:manifest:changed` so protocol adapters (e.g. MCP) emit + * `tools/list_changed`. + */ + notifyChanged: () => void +} + /** * Events emitted by `DevframeAgentHost`. * @@ -152,6 +181,12 @@ export interface DevframeAgentHost { /** Unregister a previously registered tool by id. */ unregisterTool: (id: string) => boolean + /** + * Register a lazy tool source, queried on demand — see + * {@link AgentToolProvider}. + */ + registerToolProvider: (provider: AgentToolProvider) => AgentToolProviderHandle + /** Register a readable resource. */ registerResource: (resource: AgentResourceInput) => AgentHandle /** Unregister a previously registered resource by id. */ diff --git a/packages/hub/src/node/__tests__/host-commands.test.ts b/packages/hub/src/node/__tests__/host-commands.test.ts index 8bd07f77..9509e591 100644 --- a/packages/hub/src/node/__tests__/host-commands.test.ts +++ b/packages/hub/src/node/__tests__/host-commands.test.ts @@ -1,5 +1,6 @@ -import type { AgentToolInput } from 'devframe/types' +import type { DevframeNodeContext } from 'devframe/types' import type { DevframeHubContext } from '../context' +import { DevframeAgentHost } from 'devframe/node' import * as v from 'valibot' import { describe, expect, it } from 'vitest' import { DevframeCommandsHost } from '../host-commands' @@ -64,22 +65,20 @@ describe('devframeCommandsHost command id validation', () => { }) }) -function createAgentContext(): { context: DevframeHubContext, tools: Map } { - const tools = new Map() - const context = { - agent: { - registerTool: (input: AgentToolInput) => { - tools.set(input.id, input) - return { unregister: () => tools.delete(input.id) } - }, - }, - } as unknown as DevframeHubContext - return { context, tools } +function createAgentContext(): { context: DevframeHubContext, agent: DevframeAgentHost } { + // A real agent host over a minimal base context — the bridge is a lazy + // provider, so the test exercises the actual list/getTool/invoke paths. + const base = { + rpc: { onChanged: () => () => {}, definitions: new Map() }, + } as unknown as DevframeNodeContext + const agent = new DevframeAgentHost(base) + const context = { rpc: base.rpc, agent } as unknown as DevframeHubContext + return { context, agent } } describe('devframeCommandsHost agent bridge', () => { it('projects agent-flagged commands (incl. children) into ctx.agent', async () => { - const { context, tools } = createAgentContext() + const { context, agent } = createAgentContext() const host = new DevframeCommandsHost(context) const calls: unknown[][] = [] @@ -103,21 +102,22 @@ describe('devframeCommandsHost agent bridge', () => { }) // Group-only parent stays off the agent surface; the child projects. - expect(tools.has('demo:parent')).toBe(false) - const tool = tools.get('demo:greet')! + expect(agent.getTool('demo:parent')).toBeUndefined() + const tool = agent.getTool('demo:greet')! expect(tool.description).toBe('Greet someone by name.') expect(tool.title).toBe('Greet') expect(tool.safety).toBe('action') expect((tool.inputSchema as { type: string }).type).toBe('object') + expect(agent.list().tools.map(t => t.id)).toEqual(['demo:greet']) // A single object args schema is unwrapped — the MCP args object lands // as the handler's first positional argument. - await expect(tool.handler({ name: 'devframe' })).resolves.toBe('done') + await expect(agent.invoke('demo:greet', { name: 'devframe' })).resolves.toBe('done') expect(calls).toEqual([[{ name: 'devframe' }]]) }) - it('registers zero-arg tools for commands without an args schema', async () => { - const { context, tools } = createAgentContext() + it('projects zero-arg tools for commands without an args schema', async () => { + const { context, agent } = createAgentContext() const host = new DevframeCommandsHost(context) const calls: unknown[][] = [] @@ -130,15 +130,16 @@ describe('devframeCommandsHost agent bridge', () => { }, }) - const tool = tools.get('demo:ping')! - expect(tool.safety).toBe('read') - await tool.handler({ stray: true }) + expect(agent.getTool('demo:ping')!.safety).toBe('read') + await agent.invoke('demo:ping', { stray: true }) expect(calls).toEqual([[]]) }) - it('re-syncs the projection on update and drops it on unregister', () => { - const { context, tools } = createAgentContext() + it('reflects updates and unregistration without any re-sync bookkeeping', () => { + const { context, agent } = createAgentContext() const host = new DevframeCommandsHost(context) + let manifestChanges = 0 + agent.events.on('agent:manifest:changed', () => manifestChanges++) const handle = host.register({ id: 'demo:sync', @@ -146,13 +147,17 @@ describe('devframeCommandsHost agent bridge', () => { agent: { description: 'Initial description.' }, handler: () => {}, }) - expect(tools.get('demo:sync')!.description).toBe('Initial description.') + expect(agent.getTool('demo:sync')!.description).toBe('Initial description.') handle.update({ agent: { description: 'Patched description.' } }) - expect(tools.get('demo:sync')!.description).toBe('Patched description.') + expect(agent.getTool('demo:sync')!.description).toBe('Patched description.') handle.unregister() - expect(tools.has('demo:sync')).toBe(false) + expect(agent.getTool('demo:sync')).toBeUndefined() + + // register + update + unregister each notified the manifest listeners + // (drives MCP tools/list_changed). + expect(manifestChanges).toBe(3) }) it('rejects agent exposure on handler-less commands', () => { diff --git a/packages/hub/src/node/host-commands.ts b/packages/hub/src/node/host-commands.ts index c40de9f2..017430a4 100644 --- a/packages/hub/src/node/host-commands.ts +++ b/packages/hub/src/node/host-commands.ts @@ -1,4 +1,4 @@ -import type { AgentHandle } from 'devframe/types' +import type { AgentToolInput, AgentToolProviderHandle } from 'devframe/types' import type { DevframeCommandHandle, DevframeCommandsHost as DevframeCommandsHostType, @@ -56,12 +56,18 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { public readonly commands: DevframeCommandsHostType['commands'] = new Map() public readonly events: DevframeCommandsHostType['events'] = createEventEmitter() - /** Agent-tool handles per command id (incl. children), for teardown/re-sync. */ - private readonly agentHandles = new Map() + /** + * Lazy agent projection: `ctx.agent` queries this provider at list/invoke + * time, deriving tools from {@link commands} on demand — the commands map + * stays the single source of truth, nothing is mirrored or kept in sync. + */ + private readonly agentProvider: AgentToolProviderHandle | undefined constructor( public readonly context: DevframeHubContext, - ) {} + ) { + this.agentProvider = context.agent?.registerToolProvider(() => this.collectAgentTools()) + } register(command: DevframeServerCommandInput): DevframeCommandHandle { if (this.commands.has(command.id)) { @@ -71,7 +77,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { this.validateAgentExposure(command) this.commands.set(command.id, command) this.events.emit('command:registered', this.toSerializable(command)) - this.registerAgentTools(command) + this.agentProvider?.notifyChanged() return { id: command.id, @@ -90,24 +96,19 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { } validateCommandIds(this.commands, next, existing.id) this.validateAgentExposure(next) - // Re-sync the agent projection: drop the old tree's tools before the - // patch lands, re-register from the patched command below. - this.unregisterAgentTools(existing) Object.assign(existing, patch) this.events.emit('command:registered', this.toSerializable(existing)) - this.registerAgentTools(existing) + this.agentProvider?.notifyChanged() }, unregister: () => this.unregister(command.id), } } unregister(id: string): boolean { - const command = this.commands.get(id) const deleted = this.commands.delete(id) if (deleted) { - if (command) - this.unregisterAgentTools(command) this.events.emit('command:unregistered', id) + this.agentProvider?.notifyChanged() } return deleted } @@ -166,39 +167,36 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { } /** - * Project every agent-flagged command in the tree into `ctx.agent` as a - * callable tool. `when` clauses evaluate client-side only and are not + * Derive the agent-tool projection of the current command trees: every + * agent-flagged, handler-bearing command (children included) becomes a + * callable tool. Queried lazily by the provider registered in the + * constructor. `when` clauses evaluate client-side only and are not * enforced here — opting in a `when`-gated command is a deliberate author * decision (documented on `DevframeCommandAgentOptions`). */ - private registerAgentTools(command: DevframeServerCommandInput): void { - const agent = command.agent - if (agent && command.handler) { - const { schema, unwrapped } = valibotArgsToJsonSchema(agent.args) - const handle = this.context.agent.registerTool({ - id: command.id, - title: agent.title ?? command.title, - description: agent.description, - safety: agent.safety ?? 'action', - tags: agent.tags, - inputSchema: schema, - handler: async (args: unknown) => - this.execute(command.id, ...coercePositionalArgs(args, agent.args, unwrapped)), - }) - this.agentHandles.set(command.id, handle) - } - for (const child of command.children ?? []) - this.registerAgentTools(child) - } - - private unregisterAgentTools(command: DevframeServerCommandInput): void { - for (const id of collectCommandIds(command)) { - const handle = this.agentHandles.get(id) - if (handle) { - this.agentHandles.delete(id) - handle.unregister() + private collectAgentTools(): AgentToolInput[] { + const tools: AgentToolInput[] = [] + const walk = (command: DevframeServerCommandInput): void => { + const agent = command.agent + if (agent && command.handler) { + const { schema, unwrapped } = valibotArgsToJsonSchema(agent.args) + tools.push({ + id: command.id, + title: agent.title ?? command.title, + description: agent.description, + safety: agent.safety ?? 'action', + tags: agent.tags, + inputSchema: schema, + handler: async (args: unknown) => + this.execute(command.id, ...coercePositionalArgs(args, agent.args, unwrapped)), + }) } + for (const child of command.children ?? []) + walk(child) } + for (const command of this.commands.values()) + walk(command) + return tools } } diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index 7ae3b356..d60511cb 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -29,7 +29,7 @@ export interface DevframeNextHostMcpOptions { serverName?: string /** Version reported in the MCP handshake. Default: `'0.0.0'`. */ serverVersion?: string - /** Expose shared-state keys as MCP resources / `read_state`. Default: `true`. */ + /** Expose shared-state keys as MCP resources / `devframe:state:read`. Default: `true`. */ exposeSharedState?: boolean | ((key: string) => boolean) /** * Origin allow-list beyond the loopback default. `false` disables the diff --git a/plans/031-agent-native-mcp-wave.md b/plans/031-agent-native-mcp-wave.md index 4e89c810..d6ca95c0 100644 --- a/plans/031-agent-native-mcp-wave.md +++ b/plans/031-agent-native-mcp-wave.md @@ -63,8 +63,8 @@ literal "/_next/mcp" shape on devframe primitives. `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 +6. **Core `devframe:state:read` tool** — one built-in MCP tool in + `buildMcpServerFromContext`: `devframe:state:read` (`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?, @@ -91,11 +91,11 @@ literal "/_next/mcp" shape on devframe primitives. `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). + - `devframe:connect:list-instances` — 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:connect:call-tool` — 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. @@ -106,7 +106,7 @@ literal "/_next/mcp" shape on devframe primitives. 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. + asserts the connector discovers it and a proxied 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 @@ -126,7 +126,7 @@ literal "/_next/mcp" shape on devframe primitives. - [x] Phase 1: both bridges forward + advertise MCP; `formatMcpError` emits `{ error: { code, message, fix?, docs? } }` for diagnostics; stale comment gone; conventions documented. (PR 1) -- [x] Phase 2: `createMcpFetchHandler` public; `read_state` tool live; +- [x] Phase 2: `createMcpFetchHandler` public; `devframe:state:read` tool live; agent-flagged hub commands appear as MCP tools; git read-only five are agent-visible with schemas. (PR 2) - [x] Phase 3: instances self-register and prune; `devframe connect` indexes diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 2268c3f2..9279a3e3 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -13,7 +13,7 @@ export declare class DevframeCommandsHost implements DevframeCommandsHost$1 { readonly context: DevframeHubContext; readonly commands: DevframeCommandsHost$1['commands']; readonly events: DevframeCommandsHost$1['events']; - private readonly agentHandles; + private readonly agentProvider; constructor(_: DevframeHubContext); register(_: DevframeServerCommandInput): DevframeCommandHandle; unregister(_: string): boolean; @@ -22,8 +22,7 @@ export declare class DevframeCommandsHost implements DevframeCommandsHost$1 { private findCommand; private toSerializable; private validateAgentExposure; - private registerAgentTools; - private unregisterAgentTools; + private collectAgentTools; } export declare class DevframeDocksHost implements DevframeDocksHost$1 { readonly context: DevframeHubContext; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index 46363d1a..a76d3e1f 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -6,7 +6,7 @@ export class DevframeCommandsHost { context commands events - agentHandles + agentProvider constructor(_) {} register(_) {} unregister(_) {} @@ -15,8 +15,7 @@ export class DevframeCommandsHost { findCommand(_) {} toSerializable(_) {} validateAgentExposure(_) {} - registerAgentTools(_) {} - unregisterAgentTools(_) {} + collectAgentTools() {} } export class DevframeDocksHost { context diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 3fbcfaf1..4861ef24 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -58,6 +58,9 @@ export interface AgentToolInput { }[]; handler: (_: any) => unknown | Promise; } +export interface AgentToolProviderHandle extends AgentHandle { + notifyChanged: () => void; +} export interface ConnectionMeta { backend: 'websocket' | 'static'; websocket?: number | string | ConnectionMetaWebsocket; @@ -78,6 +81,7 @@ export interface DevframeAgentHost { readonly events: EventEmitter; registerTool: (_: AgentToolInput) => AgentHandle; unregisterTool: (_: string) => boolean; + registerToolProvider: (_: AgentToolProvider) => AgentToolProviderHandle; registerResource: (_: AgentResourceInput) => AgentHandle; unregisterResource: (_: string) => boolean; list: () => AgentManifest; @@ -389,6 +393,7 @@ export interface ScopedBroadcastOptions { // #endregion // #region Types +export type AgentToolProvider = () => readonly AgentToolInput[]; export type DevframeDeploymentKind = 'standalone' | 'hosted'; export type DevframeDiagnosticsDefinition = ReturnType>; export type DevframeDiagnosticsLogger = Record; diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 2b1b26b5..ad8ff3b4 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -47,10 +47,12 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { readonly events: EventEmitter; private readonly tools; private readonly resources; + private readonly providers; private _rpcUnsubscribe; constructor(_: DevframeNodeContext); registerTool(_: AgentToolInput): AgentHandle; unregisterTool(_: string): boolean; + registerToolProvider(_: AgentToolProvider): AgentToolProviderHandle; registerResource(_: AgentResourceInput): AgentHandle; unregisterResource(_: string): boolean; list(): AgentManifest; @@ -61,6 +63,7 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { _dispose(): void; private _validateToolId; private _projectTool; + private _collectProviderTools; private _collectRpcTools; private _findRpcDefinition; private _coercePositionalArgs; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 613431e9..7c8bc3f2 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -9,6 +9,8 @@ export { AgentResourceContent } export { AgentResourceInput } export { AgentTool } export { AgentToolInput } +export { AgentToolProvider } +export { AgentToolProviderHandle } export { ConnectionMeta } export { ConnectionMetaWebsocket } export { defineDevframe } diff --git a/tests/e2e/devframe-connect.spec.ts b/tests/e2e/devframe-connect.spec.ts index 9afc5d43..72d0a553 100644 --- a/tests/e2e/devframe-connect.spec.ts +++ b/tests/e2e/devframe-connect.spec.ts @@ -9,11 +9,11 @@ test.describe('devframe connect (files-inspector)', () => { await withConnectClient(REGISTRY, async (client) => { // The connector exposes exactly the two gateway tools. const tools = await client.listTools() - expect(tools.tools.map(t => t.name).sort()).toEqual(['devframe_call', 'devframe_index']) + expect(tools.tools.map(t => t.name).sort()).toEqual(['devframe:connect:call-tool', 'devframe:connect:list-instances']) // Index: the registry-registered dev server is discovered with its // MCP endpoint and tool list. - const index = parseToolText(await client.callTool({ name: 'devframe_index', arguments: {} })) + const index = parseToolText(await client.callTool({ name: 'devframe:connect:list-instances', arguments: {} })) const instance = index.instances.find( (entry: any) => entry.id === 'devframe-files-inspector' && entry.port === 9876, ) @@ -22,12 +22,12 @@ test.describe('devframe connect (files-inspector)', () => { // origin — accept either spelling. expect(instance.mcp.url).toMatch(/^http:\/\/(?:localhost|127\.0\.0\.1):9876\/__devframe-files-inspector\/__mcp$/) const toolNames = instance.mcp.tools.map((t: any) => t.name) - expect(toolNames).toContain('read_state') + expect(toolNames).toContain('devframe:state:read') expect(toolNames).toContain('devframe-files-inspector:docs') // Call: proxy the gateway tool through the connector. const call = parseToolText(await client.callTool({ - name: 'devframe_call', + name: 'devframe:connect:call-tool', arguments: { port: 9876, tool: 'devframe-files-inspector:docs' }, })) expect(call.isError).toBe(false) @@ -37,16 +37,16 @@ test.describe('devframe connect (files-inspector)', () => { }) }) - test('devframe_call reports actionable errors for unknown targets', async () => { + test('the call tool reports actionable errors for unknown targets', async () => { await withConnectClient(REGISTRY, async (client) => { const result = await client.callTool({ - name: 'devframe_call', + name: 'devframe:connect:call-tool', arguments: { port: 1, tool: 'anything' }, }) expect(result.isError).toBe(true) const payload = parseToolText(result) expect(payload.error.message).toContain('no running devframe instance on port 1') - expect(payload.error.fix).toContain('devframe_index') + expect(payload.error.fix).toContain('devframe:connect:list-instances') }) }) }) diff --git a/tests/e2e/minimal-next-devframe-hub-dev.spec.ts b/tests/e2e/minimal-next-devframe-hub-dev.spec.ts index e1840bfb..3eda9a05 100644 --- a/tests/e2e/minimal-next-devframe-hub-dev.spec.ts +++ b/tests/e2e/minimal-next-devframe-hub-dev.spec.ts @@ -31,7 +31,7 @@ test.describe('devframe connect (minimal-next-devframe-hub)', () => { await withConnectClient(REGISTRY, async (client) => { // Index: the hub registered itself (explicitly — it runs in-process, // not via createDevServer) with the Next server's own origin. - const index = parseToolText(await client.callTool({ name: 'devframe_index', arguments: {} })) + const index = parseToolText(await client.callTool({ name: 'devframe:connect:list-instances', arguments: {} })) const hub = index.instances.find((entry: any) => entry.id === 'minimal-next-devframe-hub') expect(hub).toBeDefined() // The probe may adopt an explicit address family for the recorded @@ -39,15 +39,15 @@ test.describe('devframe connect (minimal-next-devframe-hub)', () => { expect(hub.mcp.url).toMatch(/^http:\/\/(?:localhost|127\.0\.0\.1):9878\/__hub\/__mcp$/) // The hub's agent surface flows through: the agent-flagged hub command, - // the built-in read_state, and the git plugin's agent-flagged reads. + // the built-in devframe:state:read, and the git plugin's agent-flagged reads. const toolNames = hub.mcp.tools.map((t: any) => t.name) expect(toolNames).toContain('minimal-next-devframe-hub:ping') - expect(toolNames).toContain('read_state') + expect(toolNames).toContain('devframe:state:read') expect(toolNames).toContain('devframes:plugin:git:status') // Call the agent-flagged hub command through the connector. const ping = parseToolText(await client.callTool({ - name: 'devframe_call', + name: 'devframe:connect:call-tool', arguments: { port: 9878, tool: 'minimal-next-devframe-hub:ping' }, })) expect(ping.isError).toBe(false) From c25594f911c04cc7ea317b88e776a867341cbafc Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 29 Jul 2026 09:11:55 +0000 Subject: [PATCH 4/9] refactor(agent): shrink the public API surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop the devframe/utils/valibot-json-schema subpath: AgentToolInput gains valibot args (the same shape RPC definitions carry); the agent host derives the JSON-Schema input internally, and the hub passes schemas through untouched — conversion is an implementation detail again - devframe/node exports only registerDevframeInstance (+ its two types) from the instance registry; the read/probe/prune helpers stay internal to the connector --- .../devframe/src/adapters/mcp/build-server.ts | 2 ++ .../src/node/__tests__/host-agent.test.ts | 33 +++++++++++++++++++ packages/devframe/src/node/host-agent.ts | 5 +++ packages/devframe/src/node/index.ts | 5 ++- packages/devframe/src/types/agent.ts | 20 +++++++++++ packages/hub/package.json | 5 +-- .../src/node/__tests__/host-commands.test.ts | 7 ++-- packages/hub/src/node/host-commands.ts | 23 ++++++------- packages/hub/src/types/commands.ts | 13 ++++---- plans/031-agent-native-mcp-wave.md | 5 +-- .../tsnapi/devframe/index.snapshot.d.ts | 1 + .../tsnapi/devframe/node.snapshot.d.ts | 19 ----------- .../tsnapi/devframe/node.snapshot.js | 6 ---- 13 files changed, 92 insertions(+), 52 deletions(-) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 85614964..0b457021 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -348,6 +348,8 @@ function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Tool { } function computeInputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown { + if (tool.kind === 'tool') + return argsToJsonSchema(tool.args).schema if (tool.kind !== 'rpc' || !tool.rpcName) return { type: 'object', properties: {} } const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext | undefined diff --git a/packages/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts index 285a9ed6..bd197bb2 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -270,6 +270,39 @@ describe('devToolsAgentHost', () => { }) }) + describe('valibot args on tool inputs', () => { + it('derives the JSON-Schema input from a single object schema (unwrapped)', async () => { + const v = await import('valibot') + const ctx = createContext() + ctx.agent.registerTool({ + id: 'schema:tool', + description: 'Schema-typed.', + args: [v.object({ name: v.optional(v.string()) })], + handler: args => args, + }) + + const tool = ctx.agent.getTool('schema:tool')! + const schema = tool.inputSchema as { type: string, properties: Record } + expect(schema.type).toBe('object') + expect(Object.keys(schema.properties)).toEqual(['name']) + }) + + it('an explicit inputSchema override wins over args', async () => { + const v = await import('valibot') + const ctx = createContext() + ctx.agent.registerTool({ + id: 'override:tool', + description: 'Override.', + args: [v.object({ ignored: v.string() })], + inputSchema: { type: 'object', properties: { custom: { type: 'string' } } }, + handler: () => {}, + }) + + const schema = ctx.agent.getTool('override:tool')!.inputSchema as { properties: Record } + expect(Object.keys(schema.properties)).toEqual(['custom']) + }) + }) + describe('registerToolProvider()', () => { it('queries the provider lazily on list/getTool/invoke', async () => { const ctx = createContext() diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index 2b20ae88..4fc08004 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -211,6 +211,11 @@ export class DevframeAgentHost implements DevframeAgentHostType { description: input.description, safety: input.safety ?? 'action', tags: input.tags, + // Standard Schema `args` are carried raw (mirroring how an RPC-backed + // tool defers to `ctx.rpc.definitions`) — consumers (the MCP adapter) + // convert to JSON Schema on demand. An explicit `inputSchema` override + // wins when given. + args: input.args, inputSchema: input.inputSchema, outputSchema: input.outputSchema, examples: input.examples, diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index c3904307..02050e8e 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -9,7 +9,10 @@ export type { RpcFunctionsHost } from './host-functions' export * from './host-h3' export * from './host-services' export * from './host-views' -export * from './instance-registry' +// Only registration is public — custom hosts (e.g. @devframes/next) record +// themselves; the read/probe/prune helpers stay internal to the connector. +export { registerDevframeInstance } from './instance-registry' +export type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry' export * from './rpc-shared-state' export * from './rpc-streaming' export * from './scope' diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index 3558baff..82c55a2a 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -1,3 +1,4 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' import type { RpcFunctionAgentOptions } from '../rpc/types' import type { EventEmitter } from './events' @@ -24,6 +25,14 @@ export interface AgentTool { tags?: readonly string[] /** Present for `kind === 'rpc'` — points to the RPC function name. */ rpcName?: string + /** + * Positional Standard Schemas describing a `kind: 'tool'` entry's + * arguments — carried on the tool itself (mirroring how `rpcName` defers + * an RPC-backed tool's schemas to `ctx.rpc.definitions`) so consumers + * (e.g. the MCP adapter) convert Standard Schema → JSON Schema on demand, + * same as RPC `args`. + */ + args?: readonly StandardSchemaV1[] /** JSON Schema describing the input (positional args synthesized to an object). */ inputSchema?: unknown /** JSON Schema describing the output. */ @@ -44,6 +53,17 @@ export interface AgentToolInput { description: string safety?: 'read' | 'action' | 'destructive' tags?: readonly string[] + /** + * Positional Standard Schemas describing the tool's arguments — the same + * shape RPC definitions carry (any [Standard Schema](https://standardschema.dev/) + * validator: valibot, zod, arktype, devframe's built-in `s` builder, …). + * Each is advertised under `arg0` / `arg1` / … on the tool's JSON-Schema + * input, matching how the agent bridge coerces the incoming payload back + * into positional arguments. Purely descriptive: the handler still + * receives the caller's args object as-is. + */ + args?: readonly StandardSchemaV1[] + /** Raw JSON-Schema input override. Prefer {@link args}. */ inputSchema?: unknown outputSchema?: unknown examples?: readonly { args: unknown[], description?: string }[] diff --git a/packages/hub/package.json b/packages/hub/package.json index 2d20fab6..75d6e1a7 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -42,19 +42,20 @@ "devframe": "workspace:*" }, "dependencies": { + "@standard-schema/spec": "catalog:deps", "birpc": "catalog:deps", "destr": "catalog:deps", "nostics": "catalog:deps", "pathe": "catalog:deps", "perfect-debounce": "catalog:deps", "tinyexec": "catalog:deps", - "valibot": "catalog:deps", "zigpty": "catalog:deps" }, "devDependencies": { "@types/node": "catalog:types", "devframe": "workspace:*", "mlly": "catalog:build", - "tsdown": "catalog:build" + "tsdown": "catalog:build", + "valibot": "catalog:deps" } } diff --git a/packages/hub/src/node/__tests__/host-commands.test.ts b/packages/hub/src/node/__tests__/host-commands.test.ts index 9509e591..5325d24d 100644 --- a/packages/hub/src/node/__tests__/host-commands.test.ts +++ b/packages/hub/src/node/__tests__/host-commands.test.ts @@ -110,9 +110,10 @@ describe('devframeCommandsHost agent bridge', () => { expect((tool.inputSchema as { type: string }).type).toBe('object') expect(agent.list().tools.map(t => t.id)).toEqual(['demo:greet']) - // A single object args schema is unwrapped — the MCP args object lands - // as the handler's first positional argument. - await expect(agent.invoke('demo:greet', { name: 'devframe' })).resolves.toBe('done') + // Each declared arg schema is advertised (and read back) under its own + // `argN` key — the MCP args object's `arg0` becomes the handler's first + // positional argument. + await expect(agent.invoke('demo:greet', { arg0: { name: 'devframe' } })).resolves.toBe('done') expect(calls).toEqual([[{ name: 'devframe' }]]) }) diff --git a/packages/hub/src/node/host-commands.ts b/packages/hub/src/node/host-commands.ts index 017430a4..65ded83b 100644 --- a/packages/hub/src/node/host-commands.ts +++ b/packages/hub/src/node/host-commands.ts @@ -1,5 +1,6 @@ import type { AgentToolInput, AgentToolProviderHandle } from 'devframe/types' import type { + DevframeCommandAgentOptions, DevframeCommandHandle, DevframeCommandsHost as DevframeCommandsHostType, DevframeServerCommandEntry, @@ -7,7 +8,6 @@ import type { } from '../types/commands' import type { DevframeHubContext } from './context' import { createEventEmitter } from 'devframe/utils/events' -import { valibotArgsToJsonSchema } from 'devframe/utils/valibot-json-schema' import { diagnostics } from './diagnostics' function findChildCommand(command: DevframeServerCommandInput, id: string): DevframeServerCommandInput | undefined { @@ -145,7 +145,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { } private toSerializable(cmd: DevframeServerCommandInput): DevframeServerCommandEntry { - // `agent` stays server-side: it carries valibot schemas (not wire-safe) + // `agent` stays server-side: it carries Standard Schema validators (not wire-safe) // and only concerns the agent projection, not the palette. const { handler: _, agent: __, children, ...rest } = cmd return { @@ -179,16 +179,16 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { const walk = (command: DevframeServerCommandInput): void => { const agent = command.agent if (agent && command.handler) { - const { schema, unwrapped } = valibotArgsToJsonSchema(agent.args) tools.push({ id: command.id, title: agent.title ?? command.title, description: agent.description, safety: agent.safety ?? 'action', tags: agent.tags, - inputSchema: schema, + // The agent host derives the tool's JSON-Schema input from these. + args: agent.args, handler: async (args: unknown) => - this.execute(command.id, ...coercePositionalArgs(args, agent.args, unwrapped)), + this.execute(command.id, ...coercePositionalArgs(args, agent.args)), }) } for (const child of command.children ?? []) @@ -201,20 +201,17 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { } /** - * Map the single-object args an MCP client sends onto the command handler's - * positional parameters, mirroring the agent host's RPC coercion: no declared - * schemas → zero-arg call; a single unwrapped object schema → the object - * itself; positional schemas → `arg0..argN` keys in order. + * Map the `arg0`/`arg1`/… keyed object an MCP client sends onto the command + * handler's positional parameters — mirroring the agent host's RPC + * coercion: no declared schemas → zero-arg call; each declared schema reads + * its own `argN` key, in order. */ function coercePositionalArgs( args: unknown, - schemas: readonly unknown[] | undefined, - unwrapped: boolean, + schemas: DevframeCommandAgentOptions['args'], ): unknown[] { if (!schemas || schemas.length === 0) return [] - if (unwrapped) - return [args ?? {}] const obj = (args ?? {}) as Record return schemas.map((_, i) => obj[`arg${i}`]) } diff --git a/packages/hub/src/types/commands.ts b/packages/hub/src/types/commands.ts index e2f6f186..29e57bda 100644 --- a/packages/hub/src/types/commands.ts +++ b/packages/hub/src/types/commands.ts @@ -1,5 +1,5 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' import type { EventEmitter } from 'devframe/types' -import type { GenericSchema } from 'valibot' import type { DevframeDockEntryIcon } from './docks' export interface DevframeCommandKeybinding { @@ -73,12 +73,13 @@ export interface DevframeCommandAgentOptions { /** Free-form tags for grouping/filtering. */ tags?: readonly string[] /** - * Positional valibot schemas for the handler's arguments, converted to the - * tool's JSON-Schema input (a single `v.object(...)` schema is unwrapped — - * the friendliest shape at the agent boundary). Omitted: the tool takes no - * arguments. + * Positional [Standard Schema](https://standardschema.dev/) validators for + * the handler's arguments — the same shape RPC definitions carry (valibot, + * zod, arktype, devframe's built-in `s` builder, …). Each is advertised + * under `arg0` / `arg1` / … on the tool's JSON-Schema input. Omitted: the + * tool takes no arguments. */ - args?: readonly GenericSchema[] + args?: readonly StandardSchemaV1[] } /** diff --git a/plans/031-agent-native-mcp-wave.md b/plans/031-agent-native-mcp-wave.md index d6ca95c0..e9c9819c 100644 --- a/plans/031-agent-native-mcp-wave.md +++ b/plans/031-agent-native-mcp-wave.md @@ -69,8 +69,9 @@ literal "/_next/mcp" shape on devframe primitives. 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 + description required; optional valibot args schemas carried through + `AgentToolInput.args` — JSON-Schema conversion stays an internal detail of + the agent host/MCP adapter; 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. diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 4861ef24..80618d0a 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -50,6 +50,7 @@ export interface AgentToolInput { description: string; safety?: 'read' | 'action' | 'destructive'; tags?: readonly string[]; + args?: readonly GenericSchema[]; inputSchema?: unknown; outputSchema?: unknown; examples?: readonly { diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index ad8ff3b4..eb5d24ff 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -106,32 +106,13 @@ export declare function createScopedNodeContext(_: D export declare function createStorage(_: CreateStorageOptions): SharedState; export declare function formatHostForUrl(_: string): string; export declare function isObject(_: unknown): value is Record; -export declare function listLiveDevframeInstances(_?: { - instancesDir?: string; - timeoutMs?: number; -}): Promise<{ - live: DevframeInstanceRecord[]; - pruned: DevframeInstanceRecord[]; -}>; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; -export declare function probeDevframeInstance(_: DevframeInstanceRecord, _?: { - timeoutMs?: number; -}): Promise; -export declare function readDevframeInstances(_?: { - instancesDir?: string; -}): DevframeInstanceRecord[]; export declare function registerDevframeInstance(_: DevframeInstanceRecord, _?: { instancesDir?: string; }): DevframeInstanceRegistration; -export declare function resolveInstancesDir(_?: string): string; export declare function toDialableHost(_: string): string; // #endregion -// #region Variables -export declare const DEVFRAME_DISABLE_INSTANCE_REGISTRY_ENV: string; -export declare const DEVFRAME_INSTANCES_DIR_ENV: string; -// #endregion - // #region Other export { RpcFunctionsHost } export { StartedServer } diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index eb306dc3..c5eb108a 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -9,20 +9,14 @@ export { createRpcSharedStateServerHost } export { createRpcStreamingServerHost } export { createScopedNodeContext } export { createStorage } -export { DEVFRAME_DISABLE_INSTANCE_REGISTRY_ENV } -export { DEVFRAME_INSTANCES_DIR_ENV } export { DevframeAgentHost } export { DevframeDiagnosticsHost } export { DevframeServicesHostImpl } export { DevframeViewHost } export { formatHostForUrl } export { isObject } -export { listLiveDevframeInstances } export { normalizeHttpServerUrl } -export { probeDevframeInstance } -export { readDevframeInstances } export { registerDevframeInstance } -export { resolveInstancesDir } export { startHttpAndWs } export { toDialableHost } // #endregion \ No newline at end of file From 35f3122aa2ceb3e1fa92c48506bc3dc0b8d53f81 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 30 Jul 2026 03:45:04 +0000 Subject: [PATCH 5/9] =?UTF-8?q?chore:=20rebase=20onto=20main=20=E2=80=94?= =?UTF-8?q?=20align=20with=20renamed=20example=20dir=20and=20RPC=20namespa?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main renamed examples/minimal-next-devframe-hub -> examples/next-devframe-hub and its RPC/command/instance ids to the example:next-devframe-hub convention (#143); this wave's additions (MCP serverName, registry instance id, e2e spec file + assertions, playwright cwd) now match. Same fix for files-inspector's example:files-inspector id/namespace. Also regenerates the recipes/common-rpc-functions + recipes/open-helpers dts snapshots for phase 2's Thenable> handler-return widening (non-breaking — allowed to update without --allow-breaking). --- .../src/client/devframe/next-devframe-hub.ts | 6 +-- plans/031-agent-native-mcp-wave.md | 2 +- playwright.config.ts | 2 +- pnpm-lock.yaml | 2 +- .../common-rpc-functions.snapshot.d.ts | 40 +++++++++---------- .../recipes/open-helpers.snapshot.d.ts | 20 +++++----- tests/e2e/devframe-connect.spec.ts | 6 +-- ....spec.ts => next-devframe-hub-dev.spec.ts} | 8 ++-- 8 files changed, 43 insertions(+), 43 deletions(-) rename tests/e2e/{minimal-next-devframe-hub-dev.spec.ts => next-devframe-hub-dev.spec.ts} (91%) diff --git a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts index 94a25482..26b28755 100644 --- a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts +++ b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts @@ -277,7 +277,7 @@ export async function nextDevframeHub( // route as the SPAs, no side-car port involved. const mcpPath = '/__hub/__mcp' await nextHost.mountMcp(context, mcpPath, { - serverName: 'minimal-next-devframe-hub', + serverName: 'example:next-devframe-hub', }) const connectionMeta = { @@ -298,8 +298,8 @@ export async function nextDevframeHub( port: nextPort, origin: `http://${hostName}:${nextPort}`, basePath: '/__hub/', - id: 'minimal-next-devframe-hub', - name: 'Minimal Next Devframe Hub', + id: 'example:next-devframe-hub', + name: 'Next Devframe Hub', rootDir: cwd, mcp: { path: mcpPath }, startedAt: Date.now(), diff --git a/plans/031-agent-native-mcp-wave.md b/plans/031-agent-native-mcp-wave.md index e9c9819c..78618caf 100644 --- a/plans/031-agent-native-mcp-wave.md +++ b/plans/031-agent-native-mcp-wave.md @@ -108,7 +108,7 @@ literal "/_next/mcp" shape on devframe primitives. - `examples/files-inspector`: register one gateway tool; e2e boots `dev --mcp`, runs `devframe connect` over stdio (MCP SDK client), asserts the connector discovers it and a proxied call round-trips. - - `examples/minimal-next-devframe-hub`: e2e boots the Next dev server, + - `examples/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 diff --git a/playwright.config.ts b/playwright.config.ts index eab7069b..3aa1a6a6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -82,7 +82,7 @@ export default defineConfig({ }, { command: 'pnpm exec next dev src/client -p 9878', - cwd: 'examples/minimal-next-devframe-hub', + cwd: 'examples/next-devframe-hub', env: { PORT: '9878', DEVFRAME_INSTANCES_DIR: nextHubRegistry }, url: 'http://localhost:9878/', timeout: 120_000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc4d0e6..3ac381cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,7 +381,7 @@ importers: version: 9.3.0 '@modelcontextprotocol/sdk': specifier: catalog:deps - version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) + version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) '@playwright/test': specifier: catalog:testing version: 1.62.0 diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts index 6c50a713..e16219a9 100644 --- a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts @@ -14,12 +14,12 @@ export declare const commonRpcFunctions: readonly [{ returns: SimpleSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string, args_1: KnownEditor | undefined) => void) | undefined; - dump?: RpcDump<[string, KnownEditor | undefined], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string, args_1: KnownEditor | undefined) => Thenable) | undefined; + dump?: RpcDump<[string, KnownEditor | undefined], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }, { name: "devframe:open-in-finder"; type?: "action" | undefined; @@ -28,12 +28,12 @@ export declare const commonRpcFunctions: readonly [{ returns: SimpleSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: RpcDump<[string], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string) => Thenable) | undefined; + dump?: RpcDump<[string], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }]; export declare const KNOWN_EDITORS: KnownEditor[]; export declare const openInEditor: { @@ -44,12 +44,12 @@ export declare const openInEditor: { returns: SimpleSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string, args_1: KnownEditor | undefined) => void) | undefined; - dump?: RpcDump<[string, KnownEditor | undefined], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string, args_1: KnownEditor | undefined) => Thenable) | undefined; + dump?: RpcDump<[string, KnownEditor | undefined], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }; export declare const openInFinder: { name: "devframe:open-in-finder"; @@ -59,11 +59,11 @@ export declare const openInFinder: { returns: SimpleSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: RpcDump<[string], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string) => Thenable) | undefined; + dump?: RpcDump<[string], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts index b1198606..f798add0 100644 --- a/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts @@ -12,12 +12,12 @@ export declare const openInEditor: { returns: SimpleSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string, args_1: KnownEditor | undefined) => void) | undefined; - dump?: RpcDump<[string, KnownEditor | undefined], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string, args_1: KnownEditor | undefined) => Thenable) | undefined; + dump?: RpcDump<[string, KnownEditor | undefined], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }; export declare const openInFinder: { name: "devframe:open-in-finder"; @@ -27,11 +27,11 @@ export declare const openInFinder: { returns: SimpleSchema; jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; - setup?: ((context: undefined) => Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: RpcDump<[string], void, undefined> | undefined; + setup?: ((context: undefined) => Thenable>>) | undefined; + handler?: ((args_0: string) => Thenable) | undefined; + dump?: RpcDump<[string], Thenable, undefined> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: Thenable>> | undefined; }; // #endregion \ No newline at end of file diff --git a/tests/e2e/devframe-connect.spec.ts b/tests/e2e/devframe-connect.spec.ts index 72d0a553..9ca92ed9 100644 --- a/tests/e2e/devframe-connect.spec.ts +++ b/tests/e2e/devframe-connect.spec.ts @@ -15,7 +15,7 @@ test.describe('devframe connect (files-inspector)', () => { // MCP endpoint and tool list. const index = parseToolText(await client.callTool({ name: 'devframe:connect:list-instances', arguments: {} })) const instance = index.instances.find( - (entry: any) => entry.id === 'devframe-files-inspector' && entry.port === 9876, + (entry: any) => entry.id === 'example:files-inspector' && entry.port === 9876, ) expect(instance).toBeDefined() // The probe may adopt an explicit address family for a `localhost` @@ -23,12 +23,12 @@ test.describe('devframe connect (files-inspector)', () => { expect(instance.mcp.url).toMatch(/^http:\/\/(?:localhost|127\.0\.0\.1):9876\/__devframe-files-inspector\/__mcp$/) const toolNames = instance.mcp.tools.map((t: any) => t.name) expect(toolNames).toContain('devframe:state:read') - expect(toolNames).toContain('devframe-files-inspector:docs') + expect(toolNames).toContain('example:files-inspector:docs') // Call: proxy the gateway tool through the connector. const call = parseToolText(await client.callTool({ name: 'devframe:connect:call-tool', - arguments: { port: 9876, tool: 'devframe-files-inspector:docs' }, + arguments: { port: 9876, tool: 'example:files-inspector:docs' }, })) expect(call.isError).toBe(false) const inner = JSON.parse(call.content[0].text) diff --git a/tests/e2e/minimal-next-devframe-hub-dev.spec.ts b/tests/e2e/next-devframe-hub-dev.spec.ts similarity index 91% rename from tests/e2e/minimal-next-devframe-hub-dev.spec.ts rename to tests/e2e/next-devframe-hub-dev.spec.ts index 3eda9a05..7b3490ed 100644 --- a/tests/e2e/minimal-next-devframe-hub-dev.spec.ts +++ b/tests/e2e/next-devframe-hub-dev.spec.ts @@ -5,7 +5,7 @@ import { parseToolText, withConnectClient } from './_support/mcp-connect' const ORIGIN = 'http://localhost:9878' const REGISTRY = fileURLToPath(new URL('./.registries/next-hub', import.meta.url)) -test.describe('devframe connect (minimal-next-devframe-hub)', () => { +test.describe('devframe connect (next-devframe-hub)', () => { test('discovers the in-process hub endpoint and calls an agent-flagged command', async () => { test.setTimeout(180_000) @@ -32,7 +32,7 @@ test.describe('devframe connect (minimal-next-devframe-hub)', () => { // Index: the hub registered itself (explicitly — it runs in-process, // not via createDevServer) with the Next server's own origin. const index = parseToolText(await client.callTool({ name: 'devframe:connect:list-instances', arguments: {} })) - const hub = index.instances.find((entry: any) => entry.id === 'minimal-next-devframe-hub') + const hub = index.instances.find((entry: any) => entry.id === 'example:next-devframe-hub') expect(hub).toBeDefined() // The probe may adopt an explicit address family for the recorded // `localhost` origin — accept either spelling. @@ -41,14 +41,14 @@ test.describe('devframe connect (minimal-next-devframe-hub)', () => { // The hub's agent surface flows through: the agent-flagged hub command, // the built-in devframe:state:read, and the git plugin's agent-flagged reads. const toolNames = hub.mcp.tools.map((t: any) => t.name) - expect(toolNames).toContain('minimal-next-devframe-hub:ping') + expect(toolNames).toContain('example:next-devframe-hub:ping') expect(toolNames).toContain('devframe:state:read') expect(toolNames).toContain('devframes:plugin:git:status') // Call the agent-flagged hub command through the connector. const ping = parseToolText(await client.callTool({ name: 'devframe:connect:call-tool', - arguments: { port: 9878, tool: 'minimal-next-devframe-hub:ping' }, + arguments: { port: 9878, tool: 'example:next-devframe-hub:ping' }, })) expect(ping.isError).toBe(false) expect(ping.content[0].text).toBe('pong') From 17e10623fa4fdf50de15d265705eb29aa24018a4 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 31 Jul 2026 02:37:36 +0000 Subject: [PATCH 6/9] fix(cli): show help for a bare devframe invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare `devframe` (no subcommand) silently exited 0 — cac only shows help automatically for an explicit -h/--help flag, not an unmatched command. Check matchedCommand after parse() and fall back to outputHelp(), guarding against the already-handled --help case so it doesn't print twice. --- packages/devframe/src/cli/main.test.ts | 37 ++++++++++++++++++++++++++ packages/devframe/src/cli/main.ts | 9 +++++++ 2 files changed, 46 insertions(+) create mode 100644 packages/devframe/src/cli/main.test.ts diff --git a/packages/devframe/src/cli/main.test.ts b/packages/devframe/src/cli/main.test.ts new file mode 100644 index 00000000..dc07a4f2 --- /dev/null +++ b/packages/devframe/src/cli/main.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runDevframeCli } from './main' + +describe('runDevframeCli', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows help for a bare invocation (no subcommand)', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + await runDevframeCli(['node', 'devframe']) + expect(info).toHaveBeenCalledTimes(1) + expect(info.mock.calls[0]![0]).toContain('connect') + }) + + it('shows help exactly once for --help (not doubled by the bare-invocation fallback)', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + await runDevframeCli(['node', 'devframe', '--help']) + expect(info).toHaveBeenCalledTimes(1) + }) + + it('shows help for an unrecognized subcommand', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + await runDevframeCli(['node', 'devframe', 'bogus']) + expect(info).toHaveBeenCalledTimes(1) + }) + + it('does not show help when a real subcommand matches', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + // `connect --help` matches the `connect` command and prints *its* help + // (cac's built-in per-command path) rather than the bare-invocation + // fallback — still exactly once. + await runDevframeCli(['node', 'devframe', 'connect', '--help']) + expect(info).toHaveBeenCalledTimes(1) + expect(info.mock.calls[0]![0]).toContain('--port') + }) +}) diff --git a/packages/devframe/src/cli/main.ts b/packages/devframe/src/cli/main.ts index aa76b292..2cf83d82 100644 --- a/packages/devframe/src/cli/main.ts +++ b/packages/devframe/src/cli/main.ts @@ -28,5 +28,14 @@ export async function runDevframeCli(argv: string[] = process.argv): Promise Date: Mon, 3 Aug 2026 02:47:55 +0000 Subject: [PATCH 7/9] fix: adapt to MCP SDK v2 split and Standard Schema RPC (post-rebase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main landed two breaking changes this branch didn't know about: - deps!: migrate MCP adapter to @modelcontextprotocol/sdk v2 (#156) — the monolithic package split into @modelcontextprotocol/server + @modelcontextprotocol/client; setRequestHandler moved from imported schema constants to method-string form. - feat(rpc)!: support Standard Schema for RPC definitions (#157) — RpcArgsSchema/RpcReturnSchema key off StandardSchemaV1 instead of valibot's GenericSchema; @valibot/to-json-schema dropped; single-arg JSON-Schema unwrapping removed (always arg0/arg1 now). Adaptations: - connect.ts: import from @modelcontextprotocol/server(+/stdio) and @modelcontextprotocol/client (dynamic, still peer-optional); handler registration uses 'tools/list'/'tools/call' method strings. - devframe's package.json: @modelcontextprotocol/client added as an optional peer (connect.ts uses it to dial discovered instances) and bundled correctly via tsdown's onlyBundle (client pulls in @modelcontextprotocol/core, pkce-challenge, eventsource[-parser], jose — all now declared). - Renumbered DF0042 (registry write failure) and DF0043 (missing MCP SDK) to DF0045/DF0046 — both collided with codes main allocated to unrelated diagnostics (capabilities.build:false; RPC arg/return validation) while this branch was in flight. - AgentTool/AgentToolInput.args and DevframeCommandAgentOptions.args retyped from valibot's GenericSchema[] to StandardSchemaV1[]. host-agent.ts no longer eagerly converts args to inputSchema (that module is gone); a kind: 'tool' entry now carries args raw, mirroring how an RPC-backed tool defers to ctx.rpc.definitions — the MCP adapter's computeInputSchema converts either on demand. - hub's commands→agent bridge: coercePositionalArgs no longer detects a single-object schema to unwrap (that convention is gone project- wide); always maps arg0/arg1/... positionally, matching RPC-backed tool coercion. - Tests updated for the new args-carried-raw contract, plus a new end-to-end MCP-adapter test proving Standard Schema args convert to JSON Schema over the real wire (arg0-keyed, not unwrapped). - Removed the now-fully-redundant devframe/utils/valibot-json-schema module and its registrations (superseded by the upstream to-json-schema.ts, which already covers every validator via ~standard.jsonSchema, degrading to a permissive object schema for validators without one — e.g. valibot). Verified: 1020 unit tests, typecheck (21/21), lint, full build, and 17/17 Playwright e2e (incl. both connector round-trips through the real stdio/HTTP MCP v2 pipeline) all green. --- package.json | 2 +- packages/devframe/package.json | 4 + .../adapters/mcp/__tests__/mcp-server.test.ts | 32 ++ .../devframe/src/adapters/mcp/build-server.ts | 4 +- .../src/node/__tests__/host-agent.test.ts | 16 +- packages/devframe/src/node/diagnostics.ts | 4 +- .../src/node/__tests__/host-commands.test.ts | 4 +- packages/next/src/host.ts | 2 +- plans/031-agent-native-mcp-wave.md | 4 +- pnpm-lock.yaml | 13 +- .../tsnapi/@devframes/hub/index.snapshot.d.ts | 2 +- .../plugin-assets/rpc.snapshot.d.ts | 480 +++++++++--------- .../plugin-terminals/rpc.snapshot.d.ts | 140 ++--- .../tsnapi/devframe/index.snapshot.d.ts | 3 +- tests/e2e/_support/mcp-connect.ts | 4 +- 15 files changed, 380 insertions(+), 334 deletions(-) diff --git a/package.json b/package.json index be873b30..9798e9ac 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@antfu/eslint-config": "catalog:tooling", "@antfu/ni": "catalog:build", "@antfu/utils": "catalog:inlined", - "@modelcontextprotocol/sdk": "catalog:deps", + "@modelcontextprotocol/client": "catalog:deps", "@playwright/test": "catalog:testing", "@types/node": "catalog:types", "@types/prompts": "catalog:types", diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 02b69d5c..06d934e1 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -75,10 +75,14 @@ "prepack": "pnpm build && mkdir -p ./skills && cp -r ../../skills/devframe ./skills/devframe" }, "peerDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "cac": "^7.0.0" }, "peerDependenciesMeta": { + "@modelcontextprotocol/client": { + "optional": true + }, "@modelcontextprotocol/server": { "optional": true }, diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index 95f015d8..ce5727a1 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -60,6 +60,38 @@ describe('mcp adapter (in-memory)', () => { } }) + it('converts a registered tool\'s Standard Schema args to JSON Schema over the wire', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + const v = await import('valibot') + ctx.agent.registerTool({ + id: 'schema-tool', + description: 'Takes a schema-typed arg.', + args: [v.object({ name: v.optional(v.string()) })], + handler: args => args, + }) + + const listed = await client.listTools() + const tool = listed.tools.find(t => t.name === 'schema-tool')! + // Each positional arg is advertised under `arg0`/`arg1`/… — the + // project-wide Standard Schema convention (no single-arg unwrapping). + const schema = tool.inputSchema as { type: string, properties: Record } + expect(schema.type).toBe('object') + expect(Object.keys(schema.properties)).toEqual(['arg0']) + + // `args` is purely descriptive for a plain registered tool — the + // handler receives the caller's payload as-is, unlike RPC-backed + // tools (or hub commands) which coerce `arg0`/`arg1`/… into + // positional parameters. + const result = await client.callTool({ name: 'schema-tool', arguments: { arg0: { name: 'devframe' } } }) + const content = result.content as Array<{ type: string, text: string }> + expect(JSON.parse(content[0]!.text)).toEqual({ arg0: { name: 'devframe' } }) + } + finally { + await cleanup() + } + }) + it('returns text and structured content for a tool with an output schema', async () => { const { ctx, client, cleanup } = await bootPair() try { diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 0b457021..23551706 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -160,7 +160,7 @@ function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolea return typeof exposeSharedState === 'function' ? exposeSharedState : () => true } -function readStateToolProjection(): Record { +function readStateToolProjection(): Tool { return { name: READ_STATE_TOOL, title: 'Read shared state', @@ -179,7 +179,7 @@ function readStateToolProjection(): Record { readOnlyHint: true, destructiveHint: false, }, - } + } as Tool } async function readStateResult( diff --git a/packages/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts index bd197bb2..5b94babe 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -270,21 +270,25 @@ describe('devToolsAgentHost', () => { }) }) - describe('valibot args on tool inputs', () => { - it('derives the JSON-Schema input from a single object schema (unwrapped)', async () => { + describe('standard schema args on tool inputs', () => { + it('carries args raw on the projected tool — conversion is deferred to protocol adapters', async () => { const v = await import('valibot') const ctx = createContext() + const schema = v.object({ name: v.optional(v.string()) }) ctx.agent.registerTool({ id: 'schema:tool', description: 'Schema-typed.', - args: [v.object({ name: v.optional(v.string()) })], + args: [schema], handler: args => args, }) const tool = ctx.agent.getTool('schema:tool')! - const schema = tool.inputSchema as { type: string, properties: Record } - expect(schema.type).toBe('object') - expect(Object.keys(schema.properties)).toEqual(['name']) + // Mirrors how an RPC-backed tool defers to `ctx.rpc.definitions` — the + // agent host itself never converts Standard Schema → JSON Schema (that + // stays a protocol-adapter concern, e.g. the MCP adapter), so no + // eager `inputSchema` is computed here. + expect(tool.inputSchema).toBeUndefined() + expect(tool.args).toEqual([schema]) }) it('an explicit inputSchema override wins over args', async () => { diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index dcab1f53..dfc341c9 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/sdk: ${p.reason}`, - fix: 'Install it next to devframe (e.g. `npm install @modelcontextprotocol/sdk`) and run `devframe connect` again.', + 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.', }, }, }) diff --git a/packages/hub/src/node/__tests__/host-commands.test.ts b/packages/hub/src/node/__tests__/host-commands.test.ts index 5325d24d..7aa10be3 100644 --- a/packages/hub/src/node/__tests__/host-commands.test.ts +++ b/packages/hub/src/node/__tests__/host-commands.test.ts @@ -107,7 +107,9 @@ describe('devframeCommandsHost agent bridge', () => { expect(tool.description).toBe('Greet someone by name.') expect(tool.title).toBe('Greet') expect(tool.safety).toBe('action') - expect((tool.inputSchema as { type: string }).type).toBe('object') + // The Standard Schema is carried raw — protocol adapters (e.g. the MCP + // adapter) convert to JSON Schema on demand, mirroring RPC-backed tools. + expect(tool.args).toHaveLength(1) expect(agent.list().tools.map(t => t.id)).toEqual(['demo:greet']) // Each declared arg schema is advertised (and read back) under its own diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index d60511cb..ff771ebf 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -69,7 +69,7 @@ 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/sdk` + * `devframe/adapters/mcp` (imported lazily: `@modelcontextprotocol/server` * stays an optional peer). 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/plans/031-agent-native-mcp-wave.md b/plans/031-agent-native-mcp-wave.md index 78618caf..3bca67f7 100644 --- a/plans/031-agent-native-mcp-wave.md +++ b/plans/031-agent-native-mcp-wave.md @@ -98,8 +98,8 @@ literal "/_next/mcp" shape on devframe primitives. - `devframe:connect:call-tool` — 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. + optional `@modelcontextprotocol/server` 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ac381cb..68771c3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -379,9 +379,9 @@ importers: '@antfu/utils': specifier: catalog:inlined version: 9.3.0 - '@modelcontextprotocol/sdk': + '@modelcontextprotocol/client': specifier: catalog:deps - version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) + version: 2.0.0 '@playwright/test': specifier: catalog:testing version: 1.62.0 @@ -894,6 +894,9 @@ importers: packages/hub: dependencies: + '@standard-schema/spec': + specifier: catalog:deps + version: 1.1.0 birpc: specifier: catalog:deps version: 4.0.0 @@ -912,9 +915,6 @@ importers: tinyexec: specifier: catalog:deps version: 1.2.4 - valibot: - specifier: catalog:deps - version: 1.4.2(typescript@6.0.3) zigpty: specifier: catalog:deps version: 0.2.1 @@ -931,6 +931,9 @@ importers: tsdown: specifier: catalog:build version: 0.22.14(@volar/typescript@2.4.28)(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + valibot: + specifier: catalog:deps + version: 1.4.2(typescript@6.0.3) packages/json-render: dependencies: diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 9fb9f1dd..8adf8273 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -42,7 +42,7 @@ export interface DevframeCommandAgentOptions { title?: string; safety?: 'read' | 'action' | 'destructive'; tags?: readonly string[]; - args?: readonly GenericSchema[]; + args?: readonly StandardSchemaV1[]; } export interface DevframeCommandBase { id: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.d.ts index 0c20b598..ecff6104 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.d.ts @@ -21,12 +21,12 @@ export declare const alwaysFunctions: readonly [{ returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: import("devframe/rpc").RpcDump<[string], void, import("devframe").DevframeNodeContext> | undefined; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:assets:reveal-in-folder"; type?: "action" | undefined; @@ -35,12 +35,12 @@ export declare const alwaysFunctions: readonly [{ returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: import("devframe/rpc").RpcDump<[string], void, import("devframe").DevframeNodeContext> | undefined; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }]; export declare const assetInfoSchema: import("devframe/utils/simple-schema").SimpleSchema<{ path: string; @@ -69,27 +69,27 @@ export declare const capabilities: { }>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ write: boolean; uploadExtensions: string[] | "*"; - }) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ write: boolean; uploadExtensions: string[] | "*"; - }, DevframeNodeContext> | undefined; + }>, DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }; export declare const deleteAssets: { name: "devframes:plugin:assets:delete"; @@ -109,30 +109,30 @@ export declare const deleteAssets: { agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { paths: string[]; - }) => { + }) => import("devframe/rpc").Thenable<{ deleted: string[]; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ paths: string[]; - }], { + }], import("devframe/rpc").Thenable<{ deleted: string[]; - }, DevframeNodeContext> | undefined; + }>, DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }; export declare const list: { name: "devframes:plugin:assets:list"; @@ -154,42 +154,42 @@ export declare const list: { }[]>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }[]>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }[]) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }[]>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }[], DevframeNodeContext> | undefined; + }[]>, DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }[]>>> | undefined; }; export declare const mkdir: { name: "devframes:plugin:assets:mkdir"; @@ -205,20 +205,20 @@ export declare const mkdir: { agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { path: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ path: string; - }], void, DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }; export declare const openInEditor: { name: "devframes:plugin:assets:open-in-editor"; @@ -228,12 +228,12 @@ export declare const openInEditor: { returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: import("devframe/rpc").RpcDump<[string], void, DevframeNodeContext> | undefined; + setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }; export declare const readFunctions: readonly [{ name: "devframes:plugin:assets:list"; @@ -255,42 +255,42 @@ export declare const readFunctions: readonly [{ }[]>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }[]>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }[]) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }[]>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }[], import("devframe").DevframeNodeContext> | undefined; + }[]>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }[]>>> | undefined; }, { name: "devframes:plugin:assets:read-image-meta"; type?: "query" | undefined; @@ -307,32 +307,32 @@ export declare const readFunctions: readonly [{ } | null>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string) => { + } | null>>>) | undefined; + handler?: ((args_0: string) => import("devframe/rpc").Thenable<{ width?: number | undefined; height?: number | undefined; orientation?: number | undefined; - } | null) | undefined; - dump?: import("devframe/rpc").RpcDump<[string], { + } | null>) | undefined; + dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable<{ width?: number | undefined; height?: number | undefined; orientation?: number | undefined; - } | null, import("devframe").DevframeNodeContext> | undefined; + } | null>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + } | null>>> | undefined; }, { name: "devframes:plugin:assets:read-text"; type?: "query" | undefined; @@ -341,12 +341,12 @@ export declare const readFunctions: readonly [{ returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string, args_1: number | undefined) => string | null) | undefined; - dump?: import("devframe/rpc").RpcDump<[string, number | undefined], string | null, import("devframe").DevframeNodeContext> | undefined; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((args_0: string, args_1: number | undefined) => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[string, number | undefined], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:assets:capabilities"; type?: "query" | undefined; @@ -361,27 +361,27 @@ export declare const readFunctions: readonly [{ }>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ write: boolean; uploadExtensions: string[] | "*"; - }) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ write: boolean; uploadExtensions: string[] | "*"; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }]; export declare const readImageMeta: { name: "devframes:plugin:assets:read-image-meta"; @@ -399,32 +399,32 @@ export declare const readImageMeta: { } | null>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string) => { + } | null>>>) | undefined; + handler?: ((args_0: string) => import("devframe/rpc").Thenable<{ width?: number | undefined; height?: number | undefined; orientation?: number | undefined; - } | null) | undefined; - dump?: import("devframe/rpc").RpcDump<[string], { + } | null>) | undefined; + dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable<{ width?: number | undefined; height?: number | undefined; orientation?: number | undefined; - } | null, DevframeNodeContext> | undefined; + } | null>, DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + } | null>>> | undefined; }; export declare const readText: { name: "devframes:plugin:assets:read-text"; @@ -434,12 +434,12 @@ export declare const readText: { returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string, args_1: number | undefined) => string | null) | undefined; - dump?: import("devframe/rpc").RpcDump<[string, number | undefined], string | null, DevframeNodeContext> | undefined; + setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((args_0: string, args_1: number | undefined) => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[string, number | undefined], import("devframe/rpc").Thenable, DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }; export declare const rename: { name: "devframes:plugin:assets:rename"; @@ -470,54 +470,54 @@ export declare const rename: { setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { path: string; newName: string; - }) => { + }) => import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ path: string; newName: string; - }], { + }], import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }, DevframeNodeContext> | undefined; + }>, DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }; export declare const revealInFolder: { name: "devframes:plugin:assets:reveal-in-folder"; @@ -527,12 +527,12 @@ export declare const revealInFolder: { returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: import("devframe/rpc").RpcDump<[string], void, DevframeNodeContext> | undefined; + setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }; export declare const serverFunctions: readonly [{ name: "devframes:plugin:assets:list"; @@ -554,42 +554,42 @@ export declare const serverFunctions: readonly [{ }[]>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }[]>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }[]) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }[]>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }[], import("devframe").DevframeNodeContext> | undefined; + }[]>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }[]>>> | undefined; }, { name: "devframes:plugin:assets:read-image-meta"; type?: "query" | undefined; @@ -606,32 +606,32 @@ export declare const serverFunctions: readonly [{ } | null>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string) => { + } | null>>>) | undefined; + handler?: ((args_0: string) => import("devframe/rpc").Thenable<{ width?: number | undefined; height?: number | undefined; orientation?: number | undefined; - } | null) | undefined; - dump?: import("devframe/rpc").RpcDump<[string], { + } | null>) | undefined; + dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable<{ width?: number | undefined; height?: number | undefined; orientation?: number | undefined; - } | null, import("devframe").DevframeNodeContext> | undefined; + } | null>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + } | null>>> | undefined; }, { name: "devframes:plugin:assets:read-text"; type?: "query" | undefined; @@ -640,12 +640,12 @@ export declare const serverFunctions: readonly [{ returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string, args_1: number | undefined) => string | null) | undefined; - dump?: import("devframe/rpc").RpcDump<[string, number | undefined], string | null, import("devframe").DevframeNodeContext> | undefined; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((args_0: string, args_1: number | undefined) => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[string, number | undefined], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:assets:capabilities"; type?: "query" | undefined; @@ -660,27 +660,27 @@ export declare const serverFunctions: readonly [{ }>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ write: boolean; uploadExtensions: string[] | "*"; - }) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ write: boolean; uploadExtensions: string[] | "*"; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:assets:open-in-editor"; type?: "action" | undefined; @@ -689,12 +689,12 @@ export declare const serverFunctions: readonly [{ returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: import("devframe/rpc").RpcDump<[string], void, import("devframe").DevframeNodeContext> | undefined; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:assets:reveal-in-folder"; type?: "action" | undefined; @@ -703,12 +703,12 @@ export declare const serverFunctions: readonly [{ returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: ((args_0: string) => void) | undefined; - dump?: import("devframe/rpc").RpcDump<[string], void, import("devframe").DevframeNodeContext> | undefined; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:assets:upload"; type?: "action" | undefined; @@ -727,30 +727,30 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { path: string; - }) => { + }) => import("devframe/rpc").Thenable<{ uploadId: string; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ path: string; - }], { + }], import("devframe/rpc").Thenable<{ uploadId: string; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:assets:rename"; type?: "action" | undefined; @@ -780,54 +780,54 @@ export declare const serverFunctions: readonly [{ setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { path: string; newName: string; - }) => { + }) => import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ path: string; newName: string; - }], { + }], import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:assets:delete"; type?: "action" | undefined; @@ -846,30 +846,30 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { paths: string[]; - }) => { + }) => import("devframe/rpc").Thenable<{ deleted: string[]; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ paths: string[]; - }], { + }], import("devframe/rpc").Thenable<{ deleted: string[]; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:assets:mkdir"; type?: "action" | undefined; @@ -884,20 +884,20 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { path: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ path: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }]; export declare const upload: { name: "devframes:plugin:assets:upload"; @@ -917,30 +917,30 @@ export declare const upload: { agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { path: string; - }) => { + }) => import("devframe/rpc").Thenable<{ uploadId: string; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ path: string; - }], { + }], import("devframe/rpc").Thenable<{ uploadId: string; - }, DevframeNodeContext> | undefined; + }>, DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }; export declare const UPLOAD_CHANNEL: string; export declare const writeFunctions: readonly [{ @@ -961,30 +961,30 @@ export declare const writeFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { path: string; - }) => { + }) => import("devframe/rpc").Thenable<{ uploadId: string; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ path: string; - }], { + }], import("devframe/rpc").Thenable<{ uploadId: string; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:assets:rename"; type?: "action" | undefined; @@ -1014,54 +1014,54 @@ export declare const writeFunctions: readonly [{ setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { path: string; newName: string; - }) => { + }) => import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ path: string; newName: string; - }], { + }], import("devframe/rpc").Thenable<{ path: string; type: "image" | "font" | "video" | "audio" | "text" | "other"; publicPath: string; size: number; mtime: number; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:assets:delete"; type?: "action" | undefined; @@ -1080,30 +1080,30 @@ export declare const writeFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { paths: string[]; - }) => { + }) => import("devframe/rpc").Thenable<{ deleted: string[]; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ paths: string[]; - }], { + }], import("devframe/rpc").Thenable<{ deleted: string[]; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:assets:mkdir"; type?: "action" | undefined; @@ -1118,19 +1118,19 @@ export declare const writeFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { path: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ path: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }]; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts index af609084..10ebb0f9 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts @@ -48,7 +48,7 @@ export declare const serverFunctions: readonly [{ }[]>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }[]>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ id: string; title: string; mode: "interactive" | "readonly"; @@ -87,8 +87,8 @@ export declare const serverFunctions: readonly [{ icon?: string | undefined; channel?: string | undefined; presetId?: string | undefined; - }[]) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }[]>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ id: string; title: string; mode: "interactive" | "readonly"; @@ -107,9 +107,9 @@ export declare const serverFunctions: readonly [{ icon?: string | undefined; channel?: string | undefined; presetId?: string | undefined; - }[], import("devframe").DevframeNodeContext> | undefined; + }[]>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }[]>>> | undefined; }, { name: "devframes:plugin:terminals:presets"; type?: "query" | undefined; @@ -171,47 +171,47 @@ export declare const serverFunctions: readonly [{ }[]>; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable import("devframe/rpc").Thenable>) | undefined; - handler?: (() => { + }[]>>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable<{ id: string; title: string; command: string; args: string[]; mode: "interactive" | "readonly"; icon?: string | undefined; - }[]) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }[]>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ id: string; title: string; command: string; args: string[]; mode: "interactive" | "readonly"; icon?: string | undefined; - }[], import("devframe").DevframeNodeContext> | undefined; + }[]>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable>>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; + }[]>>> | undefined; }, { name: "devframes:plugin:terminals:spawn"; type?: "action" | undefined; @@ -288,7 +288,7 @@ export declare const serverFunctions: readonly [{ cols?: number | undefined; rows?: number | undefined; env?: Record | undefined; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; mode: "interactive" | "readonly"; @@ -307,7 +307,7 @@ export declare const serverFunctions: readonly [{ icon?: string | undefined; channel?: string | undefined; presetId?: string | undefined; - }>>) | undefined; + }>>>) | undefined; handler?: ((args_0: { presetId?: string | undefined; command?: string | undefined; @@ -318,7 +318,7 @@ export declare const serverFunctions: readonly [{ cols?: number | undefined; rows?: number | undefined; env?: Record | undefined; - }) => { + }) => import("devframe/rpc").Thenable<{ id: string; title: string; mode: "interactive" | "readonly"; @@ -337,7 +337,7 @@ export declare const serverFunctions: readonly [{ icon?: string | undefined; channel?: string | undefined; presetId?: string | undefined; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ presetId?: string | undefined; command?: string | undefined; @@ -348,7 +348,7 @@ export declare const serverFunctions: readonly [{ cols?: number | undefined; rows?: number | undefined; env?: Record | undefined; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; mode: "interactive" | "readonly"; @@ -367,7 +367,7 @@ export declare const serverFunctions: readonly [{ icon?: string | undefined; channel?: string | undefined; presetId?: string | undefined; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap | undefined; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; mode: "interactive" | "readonly"; @@ -398,7 +398,7 @@ export declare const serverFunctions: readonly [{ icon?: string | undefined; channel?: string | undefined; presetId?: string | undefined; - }>>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable | undefined; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; mode: "interactive" | "readonly"; @@ -428,7 +428,7 @@ export declare const serverFunctions: readonly [{ icon?: string | undefined; channel?: string | undefined; presetId?: string | undefined; - }>> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:terminals:write"; type?: "action" | undefined; @@ -446,24 +446,24 @@ export declare const serverFunctions: readonly [{ setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; data: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; data: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:resize"; type?: "action" | undefined; @@ -484,28 +484,28 @@ export declare const serverFunctions: readonly [{ id: string; cols: number; rows: number; - }], void>>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; cols: number; rows: number; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; cols: number; rows: number; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:terminate"; type?: "action" | undefined; @@ -520,20 +520,20 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:restart"; type?: "action" | undefined; @@ -586,7 +586,7 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }>>>) | undefined; handler?: ((args_0: { id: string; - }) => { + }) => import("devframe/rpc").Thenable<{ id: string; title: string; mode: "interactive" | "readonly"; @@ -627,10 +627,10 @@ export declare const serverFunctions: readonly [{ icon?: string | undefined; channel?: string | undefined; presetId?: string | undefined; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; mode: "interactive" | "readonly"; @@ -649,11 +649,11 @@ export declare const serverFunctions: readonly [{ icon?: string | undefined; channel?: string | undefined; presetId?: string | undefined; - }, import("devframe").DevframeNodeContext> | undefined; + }>, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }>>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }>>> | undefined; }, { name: "devframes:plugin:terminals:rename"; type?: "action" | undefined; @@ -712,24 +712,24 @@ export declare const serverFunctions: readonly [{ setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; title: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; title: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:remove"; type?: "action" | undefined; @@ -744,20 +744,20 @@ export declare const serverFunctions: readonly [{ agent?: import("devframe").RpcFunctionAgentOptions; setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + }], import("devframe/rpc").Thenable>>) | undefined; handler?: ((args_0: { id: string; - }) => void) | undefined; + }) => import("devframe/rpc").Thenable) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; - }], void, import("devframe").DevframeNodeContext> | undefined; + }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; __cache?: WeakMap>> | undefined; + }], import("devframe/rpc").Thenable>>> | undefined; __promise?: import("devframe/rpc").Thenable> | undefined; + }], import("devframe/rpc").Thenable>> | undefined; }, { name: "devframes:plugin:terminals:clear-exited"; type?: "action" | undefined; @@ -766,11 +766,11 @@ export declare const serverFunctions: readonly [{ returns: import("devframe/utils/simple-schema").SimpleSchema; jsonSerializable?: boolean; agent?: import("devframe").RpcFunctionAgentOptions; - setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; - handler?: (() => void) | undefined; - dump?: import("devframe/rpc").RpcDump<[], void, import("devframe").DevframeNodeContext> | undefined; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: (() => import("devframe/rpc").Thenable) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined; snapshot?: boolean; - __cache?: WeakMap>> | undefined; - __promise?: import("devframe/rpc").Thenable> | undefined; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; }]; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 80618d0a..45808a4b 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -37,6 +37,7 @@ export interface AgentTool { safety: 'read' | 'action' | 'destructive'; tags?: readonly string[]; rpcName?: string; + args?: readonly StandardSchemaV1[]; inputSchema?: unknown; outputSchema?: unknown; examples?: readonly { @@ -50,7 +51,7 @@ export interface AgentToolInput { description: string; safety?: 'read' | 'action' | 'destructive'; tags?: readonly string[]; - args?: readonly GenericSchema[]; + args?: readonly StandardSchemaV1[]; inputSchema?: unknown; outputSchema?: unknown; examples?: readonly { diff --git a/tests/e2e/_support/mcp-connect.ts b/tests/e2e/_support/mcp-connect.ts index 73bac716..4c4a93d3 100644 --- a/tests/e2e/_support/mcp-connect.ts +++ b/tests/e2e/_support/mcp-connect.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { Client } from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' const BIN = fileURLToPath(new URL('../../../packages/devframe/bin/devframe.mjs', import.meta.url)) From 5f42d444754146373aaee5586f320da184e90980 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 3 Aug 2026 04:50:58 +0000 Subject: [PATCH 8/9] fix(test): use pathe's join in instance-registry.test.ts (Windows CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerDevframeInstance builds its file path with pathe's join, which always normalizes to forward slashes; the test compared that against node:path's join, which uses the platform-native separator — a real mismatch on Windows (backslash vs forward slash), not a flake. All three windows-latest CI jobs failed on this exact assertion. Switch the test to pathe's join too, matching the implementation. --- packages/devframe/src/node/instance-registry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devframe/src/node/instance-registry.test.ts b/packages/devframe/src/node/instance-registry.test.ts index 4334c746..6134949b 100644 --- a/packages/devframe/src/node/instance-registry.test.ts +++ b/packages/devframe/src/node/instance-registry.test.ts @@ -3,7 +3,7 @@ import type { DevframeInstanceRecord } from './instance-registry' import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join } from 'pathe' import { beforeEach, describe, expect, it, vi } from 'vitest' import { listLiveDevframeInstances, From d7a2fbbe246420683ade77254e08b8e9d396affe Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 3 Aug 2026 05:43:54 +0000 Subject: [PATCH 9/9] feat(agent): derive MCP-safe wire names from tool ids; address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto tool-name convention: internal ids stay colon-namespaced (devframe::, devframes:plugin::, command ids); the MCP boundary derives the wire name via toAgentToolName (chars outside [a-zA-Z0-9_-] -> '_', <=128) so every client's tool-name pattern is satisfied. Calls resolve back to ids; collisions keep the first registration and warn (DF0047). Documented in the agent-native guide; exported from devframe/node. - Validator neutrality: the git plugin's agent schemas now use devframe/utils/simple-schema; valibot leaves its runtime deps. - Coded diagnostics for the remaining ad-hoc throws: DF0048 (unknown shared-state key), DF0049-DF0051 (connector call errors) — each with a docs page; the connector projects Diagnostic code/fix/docs into its structured error payload. - Dedupe: one __connection.json probe primitive (probeDevframeOrigin) behind registry liveness checks and the connector's --port probe; one shared coerceAgentPositionalArgs behind the agent host's RPC bridge and the hub's command tools (explicit wrap/drop fallback). - connect.ts: IndexedInstance derives from DevframeInstanceRecord; the lazy SDK seam is typed (ConnectSdk). - Plan 031 accuracy: record carries origin (not host), actual DF codes, wire-name note; plans/README.md row updated. --- docs/adapters/mcp.md | 6 +- docs/errors/DF0047.md | 28 +++ docs/errors/DF0048.md | 28 +++ docs/errors/DF0049.md | 27 +++ docs/errors/DF0050.md | 28 +++ docs/errors/DF0051.md | 28 +++ docs/guide/agent-native.md | 17 +- .../adapters/mcp/__tests__/mcp-server.test.ts | 66 +++++- .../devframe/src/adapters/mcp/build-server.ts | 59 +++-- packages/devframe/src/cli/connect.ts | 217 +++++++++--------- .../node/__tests__/agent-tool-name.test.ts | 51 ++++ packages/devframe/src/node/agent-args.ts | 55 +++++ packages/devframe/src/node/agent-tool-name.ts | 28 +++ packages/devframe/src/node/diagnostics.ts | 21 ++ packages/devframe/src/node/host-agent.ts | 36 +-- packages/devframe/src/node/index.ts | 2 + .../devframe/src/node/instance-registry.ts | 60 +++-- packages/hub/src/node/host-commands.ts | 22 +- plans/031-agent-native-mcp-wave.md | 17 +- plans/README.md | 2 +- plugins/git/package.json | 3 +- plugins/git/src/rpc/functions/diff.ts | 36 +-- plugins/git/src/rpc/functions/log.ts | 42 ++-- plugins/git/src/rpc/functions/show.ts | 60 ++--- pnpm-lock.yaml | 3 - .../tsnapi/devframe/node.snapshot.d.ts | 7 +- .../tsnapi/devframe/node.snapshot.js | 2 + tests/e2e/devframe-connect.spec.ts | 18 +- tests/e2e/next-devframe-hub-dev.spec.ts | 14 +- 29 files changed, 688 insertions(+), 295 deletions(-) create mode 100644 docs/errors/DF0047.md create mode 100644 docs/errors/DF0048.md create mode 100644 docs/errors/DF0049.md create mode 100644 docs/errors/DF0050.md create mode 100644 docs/errors/DF0051.md create mode 100644 packages/devframe/src/node/__tests__/agent-tool-name.test.ts create mode 100644 packages/devframe/src/node/agent-args.ts create mode 100644 packages/devframe/src/node/agent-tool-name.ts diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index 901121a9..8f146de5 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -85,10 +85,10 @@ The `devframe` bin ships an MCP **connector** — a thin discovery + proxy serve } ``` -It exposes two gateway tools: +It exposes two gateway tools (the wire names of the `devframe:connect:*` ids — see [tool ids and wire names](/guide/agent-native#tool-ids-and-wire-names)): -- **`devframe:connect:list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`. -- **`devframe:connect:call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint. +- **`devframe_connect_list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`. +- **`devframe_connect_call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint. Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. diff --git a/docs/errors/DF0047.md b/docs/errors/DF0047.md new file mode 100644 index 00000000..1ca9cac3 --- /dev/null +++ b/docs/errors/DF0047.md @@ -0,0 +1,28 @@ +--- +outline: deep +--- + +# DF0047: Agent Tool Wire-Name Collision + +## Message + +> Agent tool "`{id}`" is hidden from the MCP surface: its wire name "`{name}`" collides with the tool "`{existing}`". + +## Cause + +MCP clients constrain tool names to `^[a-zA-Z0-9_-]{1,128}$`, so the MCP adapter derives each tool's wire name from its id (runs of characters outside `[a-zA-Z0-9_-]` become a single `_`). Two registered ids sanitized to the same wire name — e.g. `demo:greet` and `demo_greet`. The first registration keeps the name; the later tool is hidden from `tools/list`. + +## Example + +```ts +ctx.agent.registerTool({ id: 'demo:greet', description: '…', handler }) +ctx.agent.registerTool({ id: 'demo_greet', description: '…', handler }) // hidden: same wire name +``` + +## Fix + +Rename one of the two ids so they sanitize to distinct wire names. Namespaced ids (`devframes:plugin::`, `devframe::`) collide only when they differ solely in separator characters. + +## Source + +- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — the `tools/list` handler reports this once per hidden tool when deduplicating wire names. diff --git a/docs/errors/DF0048.md b/docs/errors/DF0048.md new file mode 100644 index 00000000..4499668b --- /dev/null +++ b/docs/errors/DF0048.md @@ -0,0 +1,28 @@ +--- +outline: deep +--- + +# DF0048: Unknown Shared-State Key + +## Message + +> Unknown shared-state key "`{key}`". + +## Cause + +The built-in `devframe_state_read` MCP tool was called with a `key` that is not among the shared-state keys the host publishes (or that the `exposeSharedState` filter allows). The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct. + +## Example + +```ts +// The host publishes only `my-plugin:counter`; an agent calls: +// devframe_state_read({ key: 'my-plugin:cuonter' }) → DF0048 +``` + +## Fix + +Call the `devframe_state_read` tool without arguments to list the available keys, then retry with one of them. + +## Source + +- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — `readStateResult()` throws this when the requested key is absent from the filtered key list. diff --git a/docs/errors/DF0049.md b/docs/errors/DF0049.md new file mode 100644 index 00000000..e93ca88a --- /dev/null +++ b/docs/errors/DF0049.md @@ -0,0 +1,27 @@ +--- +outline: deep +--- + +# DF0049: Connector Call Requires Port and Tool + +## Message + +> The devframe_connect_call-tool tool requires { port: number, tool: string }. + +## Cause + +The `devframe connect` gateway tool `devframe_connect_call-tool` was invoked without a numeric `port` or a string `tool` name — the two fields that identify which instance to dial and which of its tools to call. The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct. + +## Example + +```ts +// devframe_connect_call-tool({ tool: 'devframe_state_read' }) → DF0049 (missing port) +``` + +## Fix + +Call `devframe_connect_list-instances` first — its result carries each instance's `port` and tool names — then retry with both fields. + +## Source + +- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `call()` throws this when the gateway arguments fail validation. diff --git a/docs/errors/DF0050.md b/docs/errors/DF0050.md new file mode 100644 index 00000000..7b2cdcbc --- /dev/null +++ b/docs/errors/DF0050.md @@ -0,0 +1,28 @@ +--- +outline: deep +--- + +# DF0050: No Devframe Instance on Port + +## Message + +> No running devframe instance on port `{port}`. + +## Cause + +The `devframe connect` gateway tool `devframe_connect_call-tool` targeted a port with no live devframe instance behind it — neither the instance registry nor a direct probe of the port found one serving `__connection.json`. The instance may have stopped, restarted on a different port, or never existed. The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct. + +## Example + +```ts +// No dev server on 5199: +// devframe_connect_call-tool({ port: 5199, tool: 'devframe_state_read' }) → DF0050 +``` + +## Fix + +Call `devframe_connect_list-instances` for the current instance list and retry with a live port. + +## Source + +- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `call()` throws this when neither the registry nor the port probe finds an instance. diff --git a/docs/errors/DF0051.md b/docs/errors/DF0051.md new file mode 100644 index 00000000..a5485083 --- /dev/null +++ b/docs/errors/DF0051.md @@ -0,0 +1,28 @@ +--- +outline: deep +--- + +# DF0051: Instance Has No MCP Endpoint + +## Message + +> The devframe instance on port `{port}` has no MCP endpoint. + +## Cause + +The `devframe connect` gateway tool `devframe_connect_call-tool` targeted a live devframe instance that runs without an MCP route — its `__connection.json` advertises no `mcp` entry, so there is no endpoint to proxy the tool call to. The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct. + +## Example + +```ts +// The instance on 5173 was started without --mcp: +// devframe_connect_call-tool({ port: 5173, tool: 'devframe_state_read' }) → DF0051 +``` + +## Fix + +Restart the instance with the `--mcp` flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again. + +## Source + +- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `call()` throws this when the targeted instance's record carries `mcp: null`. diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md index 7cf737d8..b10b529f 100644 --- a/docs/guide/agent-native.md +++ b/docs/guide/agent-native.md @@ -43,6 +43,21 @@ export const getSessionSummary = defineRpcFunction({ Agent tools take a single object input. The MCP adapter synthesises `arg0`, `arg1`, … from positional args (`args: [A, B]`); a single object schema (`args: [v.object({ ... })]`) reads better at the agent boundary because property names are self-describing. +## Tool ids and wire names + +Every agent tool has two names: + +- **The id** — how the tool is registered and invoked inside devframe. Ids are colon-namespaced by convention: `devframes:plugin::` for plugin RPCs, `devframe::` for built-ins, and command ids for hub-command-derived tools. +- **The wire name** — what MCP clients see and call. Clients constrain tool names to `^[a-zA-Z0-9_-]{1,128}$`, so the MCP adapter derives the wire name automatically: every run of characters outside `[a-zA-Z0-9_-]` becomes a single `_`, truncated to 128 characters. + +``` +devframe:state:read → devframe_state_read +devframes:plugin:git:status → devframes_plugin_git_status +my-plugin:summarize → my-plugin_summarize +``` + +The convention applies uniformly to `agent`-flagged RPCs, tools registered via `registerTool` / `registerToolProvider`, and the hub's command-derived tools — keep registering with namespaced ids and let the boundary derive the name. `toAgentToolName` (from `devframe/node`) computes the mapping when you need to predict a wire name (e.g. in a client config or a test). Calls resolve back to the id at the boundary; two ids that sanitize to the same wire name keep the first registration and hide the later one with a `DF0047` warning. + ## Registering a plugin tool For tools without a matching RPC — say, an on-demand narrative summary — register them directly: @@ -96,7 +111,7 @@ ctx.agent.registerResource({ Every `ctx.rpc.sharedState` key is also automatically exposed to MCP as `devframe://state/`. Pass `exposeSharedState: false` (or a filter function) to `createMcpServer` to opt out. -Shared state is additionally reachable through the built-in **`devframe:state:read` tool** — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection. +Shared state is additionally reachable through the built-in **`devframe:state:read` tool** (wire name `devframe_state_read`) — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection. ## Starting the MCP server diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index ce5727a1..fddaa92d 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -92,6 +92,54 @@ describe('mcp adapter (in-memory)', () => { } }) + it('advertises colon-namespaced ids under their derived wire name and resolves calls back', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'devframes:plugin:demo:greet', + description: 'Say hello.', + safety: 'read', + handler: () => ({ greeting: 'hi' }), + }) + + const listed = await client.listTools() + const names = listed.tools.map(t => t.name) + expect(names).toContain('devframes_plugin_demo_greet') + expect(names).not.toContain('devframes:plugin:demo:greet') + + const result = await client.callTool({ name: 'devframes_plugin_demo_greet', arguments: {} }) + const content = result.content as Array<{ type: string, text: string }> + expect(JSON.parse(content[0]!.text)).toEqual({ greeting: 'hi' }) + } + finally { + await cleanup() + } + }) + + it('hides a later tool whose wire name collides with an earlier one', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'demo:greet', + description: 'First.', + handler: () => 'first', + }) + ctx.agent.registerTool({ + id: 'demo_greet', + description: 'Second — sanitizes to the same wire name.', + handler: () => 'second', + }) + + const listed = await client.listTools() + const matches = listed.tools.filter(t => t.name === 'demo_greet') + expect(matches).toHaveLength(1) + expect(matches[0]!.description).toBe('First.') + } + finally { + await cleanup() + } + }) + it('returns text and structured content for a tool with an output schema', async () => { const { ctx, client, cleanup } = await bootPair() try { @@ -233,7 +281,7 @@ describe('mcp adapter (in-memory)', () => { } }) - it('exposes shared state through the built-in devframe:state:read tool', async () => { + it('exposes shared state through the built-in devframe_state_read tool', async () => { const { ctx, client, cleanup } = await bootPair() try { await ctx.rpc.sharedState.get('my-plugin:counter', { @@ -241,23 +289,23 @@ describe('mcp adapter (in-memory)', () => { }) const listed = await client.listTools() - const tool = listed.tools.find(t => t.name === 'devframe:state:read') + const tool = listed.tools.find(t => t.name === 'devframe_state_read') expect(tool).toBeDefined() expect(tool!.annotations?.readOnlyHint).toBe(true) // No key → key list. - const keys = await client.callTool({ name: 'devframe:state:read', arguments: {} }) + const keys = await client.callTool({ name: 'devframe_state_read', arguments: {} }) expect(keys.structuredContent).toEqual({ keys: ['my-plugin:counter'] }) // With key → the value. - const value = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'my-plugin:counter' } }) + const value = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'my-plugin:counter' } }) expect(value.structuredContent).toEqual({ key: 'my-plugin:counter', value: { count: 7 } }) // Unknown key → agent-actionable error. - const missing = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'nope' } }) + const missing = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'nope' } }) expect(missing.isError).toBe(true) const content = missing.content as Array<{ text: string }> - expect(content[0]!.text).toContain('unknown shared-state key') + expect(content[0]!.text).toContain('Unknown shared-state key') } finally { await cleanup() @@ -277,7 +325,7 @@ describe('mcp adapter (in-memory)', () => { await client.connect(clientTransport) try { const listed = await client.listTools() - expect(listed.tools.map(t => t.name)).not.toContain('devframe:state:read') + expect(listed.tools.map(t => t.name)).not.toContain('devframe_state_read') } finally { dispose() @@ -300,10 +348,10 @@ describe('mcp adapter (in-memory)', () => { const client = new Client({ name: 'test-client', version: '0.0.0' }) await client.connect(clientTransport) try { - const keys = await client.callTool({ name: 'devframe:state:read', arguments: {} }) + const keys = await client.callTool({ name: 'devframe_state_read', arguments: {} }) expect(keys.structuredContent).toEqual({ keys: ['visible:key'] }) - const hidden = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'hidden:key' } }) + const hidden = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'hidden:key' } }) expect(hidden.isError).toBe(true) } finally { diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 23551706..df32711a 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -7,6 +7,7 @@ import process from 'node:process' import { Server } from '@modelcontextprotocol/server' import { createHostContext } from 'devframe/node' import { join } from 'pathe' +import { toAgentToolName } from '../../node/agent-tool-name' import { diagnostics } from '../../node/diagnostics' import { formatMcpError, stringifyForMcp } from './stringify' import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema' @@ -147,12 +148,14 @@ export async function createMcpServer( } /** - * Name of the built-in shared-state read tool — namespaced like every other + * Id of the built-in shared-state read tool — namespaced like every other * built-in (`devframe::`). Tool-shaped access matters because many * MCP clients only consume tools — the parallel `devframe://state/` * resource projection stays for the clients that do read resources. */ const READ_STATE_TOOL = 'devframe:state:read' +/** Wire name of the built-in shared-state read tool: `devframe_state_read`. */ +const READ_STATE_NAME = toAgentToolName(READ_STATE_TOOL) function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolean)): ((key: string) => boolean) | undefined { if (exposeSharedState === false) @@ -162,7 +165,7 @@ function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolea function readStateToolProjection(): Tool { return { - name: READ_STATE_TOOL, + name: READ_STATE_NAME, title: 'Read shared state', description: 'Read this devtool\'s live shared state. Call without arguments to list the available keys, then with a key to get that value as JSON. Safe to call freely.', inputSchema: { @@ -191,7 +194,7 @@ async function readStateResult( if (key === undefined) return { keys } if (!keys.includes(key)) - throw new Error(`unknown shared-state key "${key}" — call ${READ_STATE_TOOL} without arguments to list the available keys`) + throw diagnostics.DF0048({ key }) const state = await ctx.rpc.sharedState.get(key) return { key, value: state.value() } } @@ -202,11 +205,39 @@ function registerToolHandlers( exposeSharedState: boolean | ((key: string) => boolean), ): void { const stateFilter = sharedStateFilter(exposeSharedState) + const warnedCollisions = new Set() + + /** + * Resolve a wire tool name back to the registered {@link AgentTool}. + * Wire-name matching runs first, in manifest order — the same tool the + * list projection advertises under that name — with a raw-id fallback so + * a colon-namespaced id keeps working as a call name. + */ + const resolveTool = (name: string): AgentTool | undefined => { + const byWireName = ctx.agent.list().tools.find(tool => toAgentToolName(tool.id) === name) + return byWireName ?? ctx.agent.getTool(name) + } server.setRequestHandler('tools/list', async () => { - const tools = ctx.agent.list().tools.map(tool => projectTool(tool, ctx)) - // A registered agent tool of the same name wins over the built-in. - if (stateFilter && !ctx.agent.getTool(READ_STATE_TOOL)) + // Two ids may sanitize to the same wire name — first registration wins + // and later ones are hidden with a coded warning (once per name). + const byName = new Map() + for (const tool of ctx.agent.list().tools) { + const name = toAgentToolName(tool.id) + const existing = byName.get(name) + if (existing) { + if (!warnedCollisions.has(`${name}|${tool.id}`)) { + warnedCollisions.add(`${name}|${tool.id}`) + diagnostics.DF0047({ name, id: tool.id, existing: existing.id }) + } + continue + } + byName.set(name, tool) + } + const tools = [...byName.entries()].map(([name, tool]) => projectTool(name, tool, ctx)) + // A registered agent tool projecting to the same wire name wins over + // the built-in. + if (stateFilter && !byName.has(READ_STATE_NAME)) tools.push(readStateToolProjection()) return { tools } }) @@ -214,10 +245,11 @@ function registerToolHandlers( server.setRequestHandler('tools/call', async (request) => { const { name, arguments: args } = request.params try { - // Built-in shared-state read. A registered agent tool of the same - // name wins (mirroring the list projection above) — ids are - // namespaced, so a collision is a deliberate override. - if (stateFilter && name === READ_STATE_TOOL && !ctx.agent.getTool(READ_STATE_TOOL)) { + const tool = resolveTool(name) + // Built-in shared-state read. A registered agent tool resolving to + // the same wire name wins (mirroring the list projection above) — + // ids are namespaced, so a collision is a deliberate override. + if (stateFilter && !tool && (name === READ_STATE_NAME || name === READ_STATE_TOOL)) { const key = (args as { key?: string } | undefined)?.key const result = await readStateResult(ctx, stateFilter, key) return { @@ -225,11 +257,10 @@ function registerToolHandlers( structuredContent: result as Record, } } - const tool = ctx.agent.getTool(name) const outputSchema = tool ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) : undefined - const result = await ctx.agent.invoke(name, args ?? {}) + const result = await ctx.agent.invoke(tool?.id ?? name, args ?? {}) return { content: [ { @@ -330,11 +361,11 @@ function usableOutputSchema(schema: unknown): unknown { : undefined } -function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Tool { +function projectTool(name: string, tool: AgentTool, ctx: DevframeNodeContext): Tool { const inputSchema = tool.inputSchema ?? computeInputSchema(tool, ctx) const outputSchema = usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) return { - name: tool.id, + name, title: tool.title, description: tool.description, inputSchema, diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts index d54bb69d..98f428a1 100644 --- a/packages/devframe/src/cli/connect.ts +++ b/packages/devframe/src/cli/connect.ts @@ -1,8 +1,11 @@ +import type { Tool } from '@modelcontextprotocol/server' import type { DevframeInstanceRecord } from '../node/instance-registry' import process from 'node:process' +import { Diagnostic } from 'nostics' import { joinURL } from 'ufo' +import { toAgentToolName } from '../node/agent-tool-name' import { diagnostics } from '../node/diagnostics' -import { listLiveDevframeInstances } from '../node/instance-registry' +import { listLiveDevframeInstances, probeDevframeOrigin } from '../node/instance-registry' export interface ConnectServerOptions { /** @@ -21,15 +24,8 @@ export interface ConnectServerHandle { stop: () => Promise } -interface IndexedInstance { - id: string - name?: string - pid: number - port: number - origin: string - basePath: string - rootDir: string - startedAt: number +/** One discovered instance in the `list-instances` payload: the registry record plus its probed MCP surface. */ +interface IndexedInstance extends Omit { mcp: { url: string tools?: { name: string, description?: string }[] @@ -38,18 +34,53 @@ interface IndexedInstance { hint?: string } -const INDEX_TOOL = 'devframe:connect:list-instances' -const CALL_TOOL = 'devframe:connect:call-tool' +/** The lazily imported MCP SDK surface `devframe connect` needs. */ +interface ConnectSdk { + Server: typeof import('@modelcontextprotocol/server').Server + StdioServerTransport: typeof import('@modelcontextprotocol/server/stdio').StdioServerTransport + Client: typeof import('@modelcontextprotocol/client').Client + StreamableHTTPClientTransport: typeof import('@modelcontextprotocol/client').StreamableHTTPClientTransport +} + +// Gateway tool ids follow the `devframe::` convention; the wire +// names are their sanitized forms (`devframe_connect_list-instances`, …). +const INDEX_TOOL = toAgentToolName('devframe:connect:list-instances') +const CALL_TOOL = toAgentToolName('devframe:connect:call-tool') const MCP_DISABLED_HINT = 'This instance runs without an MCP route. Restart it with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.' +const GATEWAY_TOOLS: Tool[] = [ + { + name: INDEX_TOOL, + title: 'Discover running devframes', + description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available — the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true, destructiveHint: false }, + }, + { + name: CALL_TOOL, + title: 'Call a devframe tool', + description: 'Invoke one MCP tool on one running devframe instance discovered via the list-instances tool. Pass the instance\'s port, the tool name, and the tool\'s arguments object.', + inputSchema: { + type: 'object', + properties: { + port: { type: 'number', description: 'The instance\'s port, from the list-instances tool.' }, + tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' }, + args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' }, + }, + required: ['port', 'tool'], + additionalProperties: false, + }, + }, +] + /** * Start the devframe MCP connector on stdio: a thin discovery + proxy server * in the shape next-devtools-mcp validated. It exposes two gateway tools — - * `devframe:connect:list-instances` (discover running devframe instances via + * `devframe_connect_list-instances` (discover running devframe instances via * the instance registry and list each one's MCP tools) and - * `devframe:connect:call-tool` (invoke one tool on one instance over its + * `devframe_connect_call-tool` (invoke one tool on one instance over its * Streamable-HTTP endpoint) — and holds no domain knowledge of its own. * * @experimental @@ -62,32 +93,7 @@ export async function startConnectServer(options: ConnectServerOptions = {}): Pr { capabilities: { tools: {} } }, ) - server.setRequestHandler('tools/list', async () => ({ - tools: [ - { - name: INDEX_TOOL, - title: 'Discover running devframes', - description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available — the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.', - inputSchema: { type: 'object', properties: {} }, - annotations: { readOnlyHint: true, destructiveHint: false }, - }, - { - name: CALL_TOOL, - title: 'Call a devframe tool', - description: 'Invoke one MCP tool on one running devframe instance discovered via the list-instances tool. Pass the instance\'s port, the tool name, and the tool\'s arguments object.', - inputSchema: { - type: 'object', - properties: { - port: { type: 'number', description: 'The instance\'s port, from the list-instances tool.' }, - tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' }, - args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' }, - }, - required: ['port', 'tool'], - additionalProperties: false, - }, - }, - ], - })) + server.setRequestHandler('tools/list', async () => ({ tools: GATEWAY_TOOLS })) server.setRequestHandler('tools/call', async (request: any) => { const { name, arguments: args } = request.params @@ -99,10 +105,7 @@ export async function startConnectServer(options: ConnectServerOptions = {}): Pr return errorResult({ message: `unknown tool "${name}"`, fix: `Call ${INDEX_TOOL} or ${CALL_TOOL}.` }) } catch (error) { - return errorResult({ - message: error instanceof Error ? error.message : String(error), - ...(error && typeof error === 'object' && 'fix' in error && typeof error.fix === 'string' ? { fix: error.fix } : {}), - }) + return errorResult(toErrorPayload(error)) } }) @@ -116,7 +119,7 @@ export async function startConnectServer(options: ConnectServerOptions = {}): Pr } } -async function importSdk(): Promise { +async function importSdk(): Promise { try { const [serverMod, stdioMod, clientMod] = await Promise.all([ import('@modelcontextprotocol/server'), @@ -137,7 +140,7 @@ async function importSdk(): Promise { } /** Discover instances: registry (prune-on-read) + explicit port probes. */ -async function index(sdk: any, options: ConnectServerOptions): Promise { +async function index(sdk: ConnectSdk, options: ConnectServerOptions): Promise { const { live } = await listLiveDevframeInstances({ instancesDir: options.instancesDir, timeoutMs: options.timeoutMs, @@ -153,22 +156,13 @@ async function index(sdk: any, options: ConnectServerOptions): Promise } const instances: IndexedInstance[] = await Promise.all(records.map(async (record) => { - const entry: IndexedInstance = { - id: record.id, - name: record.name, - pid: record.pid, - port: record.port, - origin: record.origin, - basePath: record.basePath, - rootDir: record.rootDir, - startedAt: record.startedAt, - mcp: null, - } - if (!record.mcp) { + const { mcp, ...rest } = record + const entry: IndexedInstance = { ...rest, mcp: null } + if (!mcp) { entry.hint = MCP_DISABLED_HINT return entry } - const url = `${record.origin}${record.mcp.path}` + const url = `${record.origin}${mcp.path}` try { entry.mcp = { url, tools: await listInstanceTools(sdk, url) } } @@ -187,39 +181,28 @@ async function index(sdk: any, options: ConnectServerOptions): Promise } /** - * Probe an explicit port for a devframe serving `__connection.json` at `/`. - * Tries the explicit address families too — a `localhost`-bound server may - * listen on either. + * Probe an explicit port for a devframe serving `__connection.json` at `/`, + * reusing the registry's origin-candidate probe (a `localhost`-bound server + * may listen on either address family). */ async function probePort(port: number, timeoutMs?: number): Promise { - for (const origin of [`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`]) { - try { - const response = await fetch(`${origin}/__connection.json`, { - signal: AbortSignal.timeout(timeoutMs ?? 1000), - }) - if (!response.ok) - continue - const meta = await response.json() as { mcp?: { path: string, port?: number } } - const mcpPath = meta.mcp ? joinURL('/', meta.mcp.path) : null - return { - pid: -1, - port, - origin, - basePath: '/', - id: `port-${port}`, - rootDir: '', - mcp: mcpPath ? { path: mcpPath } : null, - startedAt: 0, - } - } - catch { - // Try the next candidate. - } + const probed = await probeDevframeOrigin(`http://localhost:${port}`, '/', timeoutMs) + if (!probed) + return null + const mcpPath = probed.meta.mcp ? joinURL('/', probed.meta.mcp.path) : null + return { + pid: -1, + port, + origin: probed.origin, + basePath: '/', + id: `port-${port}`, + rootDir: '', + mcp: mcpPath ? { path: mcpPath } : null, + startedAt: 0, } - return null } -async function listInstanceTools(sdk: any, url: string): Promise<{ name: string, description?: string }[]> { +async function listInstanceTools(sdk: ConnectSdk, url: string): Promise<{ name: string, description?: string }[]> { return withInstanceClient(sdk, url, async (client) => { const listed = await client.listTools() return listed.tools.map((tool: { name: string, description?: string }) => ({ @@ -230,35 +213,26 @@ async function listInstanceTools(sdk: any, url: string): Promise<{ name: string, } async function call( - sdk: any, + sdk: ConnectSdk, options: ConnectServerOptions, args: { port?: number, tool?: string, args?: Record }, ): Promise { - if (typeof args.port !== 'number' || typeof args.tool !== 'string') { - throw Object.assign(new Error(`${CALL_TOOL} requires { port: number, tool: string }`), { - fix: `Call ${INDEX_TOOL} to get the port and tool names, then retry.`, - }) - } + if (typeof args.port !== 'number' || typeof args.tool !== 'string') + throw diagnostics.DF0049() const { live } = await listLiveDevframeInstances({ instancesDir: options.instancesDir, timeoutMs: options.timeoutMs, }) const record = live.find(r => r.port === args.port) ?? await probePort(args.port, options.timeoutMs) - if (!record) { - throw Object.assign(new Error(`no running devframe instance on port ${args.port}`), { - fix: `Call ${INDEX_TOOL} for the current instance list — the instance may have stopped or changed port.`, - }) - } - if (!record.mcp) { - throw Object.assign(new Error(`the devframe instance on port ${args.port} has no MCP endpoint`), { - fix: MCP_DISABLED_HINT, - }) - } + if (!record) + throw diagnostics.DF0050({ port: args.port }) + if (!record.mcp) + throw diagnostics.DF0051({ port: args.port }) const url = `${record.origin}${record.mcp.path}` return withInstanceClient(sdk, url, async (client) => { - const result = await client.callTool({ name: args.tool, arguments: args.args ?? {} }) + const result = await client.callTool({ name: args.tool!, arguments: args.args ?? {} }) return { instance: { id: record.id, port: record.port }, tool: args.tool, @@ -269,7 +243,11 @@ async function call( }) } -async function withInstanceClient(sdk: any, url: string, fn: (client: any) => Promise): Promise { +async function withInstanceClient( + sdk: ConnectSdk, + url: string, + fn: (client: InstanceType) => Promise, +): Promise { const transport = new sdk.StreamableHTTPClientTransport(new URL(url)) const client = new sdk.Client({ name: 'devframe-connect', version: '0.0.0' }) await client.connect(transport) @@ -285,7 +263,34 @@ function textResult(value: unknown): { content: { type: 'text', text: string }[] return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] } } -function errorResult(error: { message: string, fix?: string }): { +interface ConnectErrorPayload { + code?: string + message: string + fix?: string + docs?: string +} + +/** + * Project a thrown value into the connector's structured error payload. A + * nostics `Diagnostic` carries its code, `fix`, and docs URL across so the + * calling agent gets the actionable next step. + */ +function toErrorPayload(error: unknown): ConnectErrorPayload { + if (error instanceof Diagnostic) { + return { + code: error.code, + message: error.message, + ...(error.fix ? { fix: error.fix } : {}), + ...(error.docs ? { docs: error.docs } : {}), + } + } + return { + message: error instanceof Error ? error.message : String(error), + ...(error && typeof error === 'object' && 'fix' in error && typeof error.fix === 'string' ? { fix: error.fix } : {}), + } +} + +function errorResult(error: ConnectErrorPayload): { isError: true content: { type: 'text', text: string }[] } { diff --git a/packages/devframe/src/node/__tests__/agent-tool-name.test.ts b/packages/devframe/src/node/__tests__/agent-tool-name.test.ts new file mode 100644 index 00000000..7768b594 --- /dev/null +++ b/packages/devframe/src/node/__tests__/agent-tool-name.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { coerceAgentPositionalArgs } from '../agent-args' +import { toAgentToolName } from '../agent-tool-name' + +describe('toAgentToolName', () => { + it('replaces runs of unsafe characters with a single underscore', () => { + expect(toAgentToolName('devframe:state:read')).toBe('devframe_state_read') + expect(toAgentToolName('devframes:plugin:git:status')).toBe('devframes_plugin_git_status') + expect(toAgentToolName('devframe:connect:list-instances')).toBe('devframe_connect_list-instances') + expect(toAgentToolName('a::b//c d')).toBe('a_b_c_d') + }) + + it('keeps already-safe names unchanged', () => { + expect(toAgentToolName('greet')).toBe('greet') + expect(toAgentToolName('my-tool_2')).toBe('my-tool_2') + }) + + it('caps the result at 128 characters (the strictest client pattern)', () => { + const long = `ns:${'x'.repeat(200)}` + const name = toAgentToolName(long) + expect(name).toHaveLength(128) + expect(name).toMatch(/^[\w-]{1,128}$/) + }) +}) + +describe('coerceAgentPositionalArgs', () => { + const schema = {} as unknown + + it('passes arrays through and maps argN keys onto declared schemas', () => { + expect(coerceAgentPositionalArgs([1, 2], [schema, schema])).toEqual([1, 2]) + expect(coerceAgentPositionalArgs({ arg0: 'a', arg1: 'b' }, [schema, schema])).toEqual(['a', 'b']) + }) + + it('collects argN keys even without schemas', () => { + expect(coerceAgentPositionalArgs({ arg0: 1, arg1: 2 }, undefined)).toEqual([1, 2]) + }) + + it('treats null/undefined and empty objects as zero-argument calls', () => { + expect(coerceAgentPositionalArgs(undefined, undefined)).toEqual([]) + expect(coerceAgentPositionalArgs(null, [schema])).toEqual([]) + expect(coerceAgentPositionalArgs({}, undefined)).toEqual([]) + }) + + it('follows the fallback for undeclared object payload', () => { + const payload = { name: 'devframe' } + // RPC-backed tools: an untyped RPC may take one raw object. + expect(coerceAgentPositionalArgs(payload, undefined, 'wrap')).toEqual([payload]) + // Command-backed tools: positional params come solely from declared schemas. + expect(coerceAgentPositionalArgs(payload, undefined, 'drop')).toEqual([]) + }) +}) diff --git a/packages/devframe/src/node/agent-args.ts b/packages/devframe/src/node/agent-args.ts new file mode 100644 index 00000000..44022b51 --- /dev/null +++ b/packages/devframe/src/node/agent-args.ts @@ -0,0 +1,55 @@ +/** + * How {@link coerceAgentPositionalArgs} treats an args object that carries + * neither declared schemas nor `arg0`/`arg1`/… keys: + * + * - `'wrap'` — pass the object itself as the single positional argument. + * RPC-backed tools use this: an untyped RPC may take one raw object. + * - `'drop'` — call with zero arguments. Command-backed tools use this: + * a handler's positional parameters come solely from its declared + * `agent.args` schemas, so undeclared payload is ignored. + */ +export type AgentArgsFallback = 'wrap' | 'drop' + +/** + * Map the args payload an agent surface receives (MCP sends an object + * keyed `arg0`/`arg1`/…, matching the schema the adapter advertises) onto + * a handler's positional parameters. Shared by the agent host's RPC + * bridge and the hub's command-derived tools so the coercion cannot + * drift between them. + * + * - an array passes through as-is + * - `null`/`undefined` become a zero-argument call + * - with declared schemas, each schema reads its own `argN` key, in order + * - without schemas, `arg0`/`arg1`/… keys are collected when present + * - an empty object becomes a zero-argument call + * - anything else follows the {@link AgentArgsFallback} + * + * @experimental + */ +export function coerceAgentPositionalArgs( + args: unknown, + schemas: readonly unknown[] | undefined, + fallback: AgentArgsFallback = 'wrap', +): unknown[] { + if (Array.isArray(args)) + return args + if (args === undefined || args === null) + return [] + if (typeof args === 'object') { + const obj = args as Record + if (schemas && schemas.length) + return schemas.map((_, i) => obj[`arg${i}`]) + if ('arg0' in obj) { + const out: unknown[] = [] + let i = 0 + while (`arg${i}` in obj) { + out.push(obj[`arg${i}`]) + i++ + } + return out + } + if (Object.keys(obj).length === 0) + return [] + } + return fallback === 'drop' ? [] : [args] +} diff --git a/packages/devframe/src/node/agent-tool-name.ts b/packages/devframe/src/node/agent-tool-name.ts new file mode 100644 index 00000000..90750c6a --- /dev/null +++ b/packages/devframe/src/node/agent-tool-name.ts @@ -0,0 +1,28 @@ +/** + * Maximum tool-name length several MCP clients enforce (the Anthropic API + * pattern is `^[a-zA-Z0-9_-]{1,128}$`). + */ +const MAX_TOOL_NAME_LENGTH = 128 + +/** + * Derive the wire-safe agent tool name for an internal tool id. + * + * Devframe tool ids are colon-namespaced — `devframe::` for + * built-ins, `devframes:plugin::` for plugin RPCs, and hub + * command ids for command-derived tools. MCP clients constrain tool names + * to `^[a-zA-Z0-9_-]{1,128}$`, so the agent/MCP boundary derives the wire + * name automatically: every run of characters outside `[a-zA-Z0-9_-]` + * becomes a single `_`, truncated to 128 characters. Internal ids never + * change — resolution back to the id happens at the boundary. + * + * ``` + * devframe:state:read → devframe_state_read + * devframes:plugin:git:status → devframes_plugin_git_status + * ``` + * + * @experimental The agent-native surface is experimental and may change + * without a major version bump until it stabilizes. + */ +export function toAgentToolName(id: string): string { + return id.replace(/[^\w-]+/g, '_').slice(0, MAX_TOOL_NAME_LENGTH) +} diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index dfc341c9..f28dfc6e 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -91,5 +91,26 @@ export const diagnostics = defineDiagnostics({ 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.', }, + DF0047: { + why: (p: { name: string, id: string, existing: string }) => + `Agent tool "${p.id}" is hidden from the MCP surface: its wire name "${p.name}" collides with the tool "${p.existing}".`, + fix: 'Wire names derive from tool ids (characters outside [a-zA-Z0-9_-] become "_"). Rename one of the two ids so they sanitize to distinct names.', + }, + DF0048: { + why: (p: { key: string }) => `Unknown shared-state key "${p.key}".`, + fix: 'Call the devframe_state_read tool without arguments to list the available keys, then retry with one of them.', + }, + DF0049: { + why: 'The devframe_connect_call-tool tool requires { port: number, tool: string }.', + fix: 'Call devframe_connect_list-instances to get the port and tool names, then retry.', + }, + DF0050: { + why: (p: { port: number }) => `No running devframe instance on port ${p.port}.`, + fix: 'Call devframe_connect_list-instances for the current instance list — the instance may have stopped or changed port.', + }, + DF0051: { + why: (p: { port: number }) => `The devframe instance on port ${p.port} has no MCP endpoint.`, + fix: 'Restart the instance with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.', + }, }, }) diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index 4fc08004..2c99a088 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -16,6 +16,7 @@ import type { RpcFunctionAgentOptions, } from 'devframe/types' import { createEventEmitter } from 'devframe/utils/events' +import { coerceAgentPositionalArgs } from './agent-args' import { diagnostics } from './diagnostics' interface RegisteredTool { @@ -166,7 +167,9 @@ export class DevframeAgentHost implements DevframeAgentHostType { if (rpcDef) { // RPC args are positional. Accept an object keyed by `arg0..argN` // (what the MCP adapter sends after flattening), or a plain array. - const positional = this._coercePositionalArgs(args, rpcDef) + // An untyped RPC may take a single raw object, so undeclared object + // payload wraps into one positional argument. + const positional = coerceAgentPositionalArgs(args, rpcDef.args as readonly unknown[] | undefined, 'wrap') return await this.context.rpc.invokeLocal(id as any, ...(positional as any)) } @@ -266,33 +269,6 @@ export class DevframeAgentHost implements DevframeAgentHostType { return def return undefined } - - private _coercePositionalArgs( - args: unknown, - def: RpcFunctionDefinitionAnyWithContext, - ): unknown[] { - if (Array.isArray(args)) - return args - if (args === undefined || args === null) - return [] - if (args && typeof args === 'object') { - const obj = args as Record - const schemas = def.args as readonly unknown[] | undefined - if (schemas && schemas.length) - return schemas.map((_, i) => obj[`arg${i}`]) - // Fallback: detect arg0/arg1/... keys even without schemas. - if (hasPositionalKeys(obj)) { - const out: unknown[] = [] - let i = 0 - while (`arg${i}` in obj) { - out.push(obj[`arg${i}`]) - i++ - } - return out - } - } - return [args] - } } function inferSafety(type: RpcFunctionType): 'read' | 'action' | 'destructive' { @@ -300,7 +276,3 @@ function inferSafety(type: RpcFunctionType): 'read' | 'action' | 'destructive' { return 'read' return 'action' } - -function hasPositionalKeys(obj: Record): boolean { - return 'arg0' in obj -} diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index 02050e8e..f252261f 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -1,4 +1,6 @@ // Node-side public API for consumers that wire up their own runtime. +export * from './agent-args' +export * from './agent-tool-name' export * from './context' export * from './host-agent' export * from './host-diagnostics' diff --git a/packages/devframe/src/node/instance-registry.ts b/packages/devframe/src/node/instance-registry.ts index 8aaf9f30..6c289255 100644 --- a/packages/devframe/src/node/instance-registry.ts +++ b/packages/devframe/src/node/instance-registry.ts @@ -176,6 +176,50 @@ function originCandidates(origin: string): string[] { } } +/** + * A successful `__connection.json` probe: the dialable origin that + * answered plus the parsed connection meta it served. + * + * @internal + */ +export interface ProbedDevframeOrigin { + /** The origin that answered (may be an explicit address family for a `localhost` bind). */ + origin: string + /** The parsed `__connection.json` payload (`{}` when unparseable). */ + meta: { mcp?: { path: string, port?: number } } +} + +/** + * Probe `__connection.json`, trying each dialable + * candidate for the origin (see {@link originCandidates}). The single + * probe primitive behind both registry liveness checks and the + * connector's explicit `--port` probes. + * + * @internal + */ +export async function probeDevframeOrigin( + origin: string, + basePath: string, + timeoutMs?: number, +): Promise { + const base = basePath.endsWith('/') ? basePath : `${basePath}/` + for (const candidate of originCandidates(origin)) { + try { + const response = await fetch(`${candidate}${base}__connection.json`, { + signal: AbortSignal.timeout(timeoutMs ?? 1000), + }) + if (!response.ok) + continue + const meta = await response.json().catch(() => ({})) as ProbedDevframeOrigin['meta'] + return { origin: candidate, meta } + } + catch { + // Try the next candidate. + } + } + return null +} + /** * Probe a record's `__connection.json` to check the instance is alive. * Returns the **dialable origin** that answered (for `localhost` records @@ -188,20 +232,8 @@ export async function probeDevframeInstance( record: DevframeInstanceRecord, options: { timeoutMs?: number } = {}, ): Promise { - const base = record.basePath.endsWith('/') ? record.basePath : `${record.basePath}/` - for (const origin of originCandidates(record.origin)) { - try { - const response = await fetch(`${origin}${base}__connection.json`, { - signal: AbortSignal.timeout(options.timeoutMs ?? 1000), - }) - if (response.ok) - return origin - } - catch { - // Try the next candidate. - } - } - return null + const probed = await probeDevframeOrigin(record.origin, record.basePath, options.timeoutMs) + return probed?.origin ?? null } /** diff --git a/packages/hub/src/node/host-commands.ts b/packages/hub/src/node/host-commands.ts index 65ded83b..6c117fa1 100644 --- a/packages/hub/src/node/host-commands.ts +++ b/packages/hub/src/node/host-commands.ts @@ -1,12 +1,12 @@ import type { AgentToolInput, AgentToolProviderHandle } from 'devframe/types' import type { - DevframeCommandAgentOptions, DevframeCommandHandle, DevframeCommandsHost as DevframeCommandsHostType, DevframeServerCommandEntry, DevframeServerCommandInput, } from '../types/commands' import type { DevframeHubContext } from './context' +import { coerceAgentPositionalArgs } from 'devframe/node' import { createEventEmitter } from 'devframe/utils/events' import { diagnostics } from './diagnostics' @@ -187,8 +187,10 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { tags: agent.tags, // The agent host derives the tool's JSON-Schema input from these. args: agent.args, + // A command handler's positional parameters come solely from its + // declared `agent.args` schemas — undeclared payload is dropped. handler: async (args: unknown) => - this.execute(command.id, ...coercePositionalArgs(args, agent.args)), + this.execute(command.id, ...coerceAgentPositionalArgs(args, agent.args, 'drop')), }) } for (const child of command.children ?? []) @@ -199,19 +201,3 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { return tools } } - -/** - * Map the `arg0`/`arg1`/… keyed object an MCP client sends onto the command - * handler's positional parameters — mirroring the agent host's RPC - * coercion: no declared schemas → zero-arg call; each declared schema reads - * its own `argN` key, in order. - */ -function coercePositionalArgs( - args: unknown, - schemas: DevframeCommandAgentOptions['args'], -): unknown[] { - if (!schemas || schemas.length === 0) - return [] - const obj = (args ?? {}) as Record - return schemas.map((_, i) => obj[`arg${i}`]) -} diff --git a/plans/031-agent-native-mcp-wave.md b/plans/031-agent-native-mcp-wave.md index 3bca67f7..014afc34 100644 --- a/plans/031-agent-native-mcp-wave.md +++ b/plans/031-agent-native-mcp-wave.md @@ -85,13 +85,17 @@ literal "/_next/mcp" shape on devframe primitives. 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 + (`{ pid, port, origin, basePath, name, id, rootDir, mcp: { path } | null, + startedAt }` — `origin` instead of a bare `host` so records carry a + dialable URL), 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 connect` runs a stdio MCP server exposing two gateway tools + (ids `devframe:connect:*`; MCP clients see the auto-derived wire names, + `devframe_connect_list-instances` / `devframe_connect_call-tool` — the + tool-name convention documented in the agent-native guide): - `devframe:connect:list-instances` — 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`…"). @@ -134,10 +138,11 @@ literal "/_next/mcp" shape on devframe primitives. and calls a live app over stdio; Next host serves in-process MCP; both e2e gates green in CI. (PR 3) - [x] Every phase: full gate green, API snapshots updated deliberately, new - node-side errors use coded diagnostics with docs pages (`DF0042`, - `DF0043`, `DF8404` — note: DF00xx numbers are allocated across + node-side errors use coded diagnostics with docs pages (`DF0045`–`DF0051`, + `DF8404` — note: DF00xx numbers are allocated across packages; check `docs/errors/` for the next free code). -- [ ] `plans/README.md` row set to DONE once the three PRs merge. +- [x] `plans/README.md` row updated as phases landed; set to DONE once the + wave PR merges. ## STOP conditions diff --git a/plans/README.md b/plans/README.md index d753f503..191b8314 100644 --- a/plans/README.md +++ b/plans/README.md @@ -42,7 +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 | +| 031 | Agent-native MCP wave (bridges, core surface, connector) | direction | P2 | L | — | IN PROGRESS (phases 1–3 implemented on PR #145; DONE at merge) | Status values: TODO | IN PROGRESS | DONE | BLOCKED (one-line reason) | REJECTED (one-line rationale). diff --git a/plugins/git/package.json b/plugins/git/package.json index 166746a1..c1e5dad0 100644 --- a/plugins/git/package.json +++ b/plugins/git/package.json @@ -49,8 +49,7 @@ "dependencies": { "cac": "catalog:deps", "devframe": "workspace:*", - "pathe": "catalog:deps", - "valibot": "catalog:deps" + "pathe": "catalog:deps" }, "devDependencies": { "@antfu/design": "catalog:frontend", diff --git a/plugins/git/src/rpc/functions/diff.ts b/plugins/git/src/rpc/functions/diff.ts index f703901f..f1de519e 100644 --- a/plugins/git/src/rpc/functions/diff.ts +++ b/plugins/git/src/rpc/functions/diff.ts @@ -1,5 +1,5 @@ import { defineRpcFunction } from 'devframe' -import * as v from 'valibot' +import { s } from 'devframe/utils/simple-schema' import { runGit, splitClean, tryGit } from '../../node/git.ts' import { getGitContext } from '../context.ts' @@ -26,22 +26,22 @@ export interface GitDiff { truncated: boolean } -const diffFileSchema = v.object({ - path: v.string(), - additions: v.number(), - deletions: v.number(), - binary: v.boolean(), +const diffFileSchema = s.object({ + path: s.string(), + additions: s.number(), + deletions: s.number(), + binary: s.boolean(), }) -const gitDiffSchema = v.object({ - isRepo: v.boolean(), - staged: v.boolean(), - path: v.nullable(v.string()), - files: v.array(diffFileSchema), - totalAdditions: v.number(), - totalDeletions: v.number(), - patch: v.nullable(v.string()), - truncated: v.boolean(), +const gitDiffSchema = s.object({ + isRepo: s.boolean(), + staged: s.boolean(), + path: s.nullable(s.string()), + files: s.array(diffFileSchema), + totalAdditions: s.number(), + totalDeletions: s.number(), + patch: s.nullable(s.string()), + truncated: s.boolean(), }) export interface DiffArgs { @@ -69,9 +69,9 @@ export const diff = defineRpcFunction({ type: 'query', snapshot: true, jsonSerializable: true, - args: [v.object({ - path: v.optional(v.string()), - staged: v.optional(v.boolean()), + args: [s.object({ + path: s.optional(s.string()), + staged: s.optional(s.boolean()), })], returns: gitDiffSchema, agent: { diff --git a/plugins/git/src/rpc/functions/log.ts b/plugins/git/src/rpc/functions/log.ts index cf070d37..dd4360b0 100644 --- a/plugins/git/src/rpc/functions/log.ts +++ b/plugins/git/src/rpc/functions/log.ts @@ -1,5 +1,5 @@ import { defineRpcFunction } from 'devframe' -import * as v from 'valibot' +import { s } from 'devframe/utils/simple-schema' import { isSafeRevision, RECORD, splitClean, tryGit, UNIT } from '../../node/git.ts' import { getGitContext } from '../context.ts' @@ -27,24 +27,24 @@ export interface GitLog { hasMore: boolean } -const commitSchema = v.object({ - hash: v.string(), - shortHash: v.string(), - author: v.string(), - email: v.string(), - date: v.number(), - subject: v.string(), - body: v.string(), - refs: v.array(v.string()), - parents: v.array(v.string()), +const commitSchema = s.object({ + hash: s.string(), + shortHash: s.string(), + author: s.string(), + email: s.string(), + date: s.number(), + subject: s.string(), + body: s.string(), + refs: s.array(s.string()), + parents: s.array(s.string()), }) -const gitLogSchema = v.object({ - isRepo: v.boolean(), - commits: v.array(commitSchema), - limit: v.number(), - skip: v.number(), - hasMore: v.boolean(), +const gitLogSchema = s.object({ + isRepo: s.boolean(), + commits: s.array(commitSchema), + limit: s.number(), + skip: s.number(), + hasMore: s.boolean(), }) export interface LogArgs { @@ -100,10 +100,10 @@ export const log = defineRpcFunction({ name: 'devframes:plugin:git:log', type: 'query', jsonSerializable: true, - args: [v.object({ - limit: v.optional(v.number()), - skip: v.optional(v.number()), - ref: v.optional(v.string()), + args: [s.object({ + limit: s.optional(s.number()), + skip: s.optional(s.number()), + ref: s.optional(s.string()), })], returns: gitLogSchema, agent: { diff --git a/plugins/git/src/rpc/functions/show.ts b/plugins/git/src/rpc/functions/show.ts index 498a0427..d4117ff6 100644 --- a/plugins/git/src/rpc/functions/show.ts +++ b/plugins/git/src/rpc/functions/show.ts @@ -1,7 +1,7 @@ import type { GitContext } from '../context.ts' import type { FileStatusCode } from './status.ts' import { defineRpcFunction } from 'devframe' -import * as v from 'valibot' +import { s } from 'devframe/utils/simple-schema' import { isSafeRevision, splitClean, tryGit, UNIT } from '../../node/git.ts' import { getGitContext } from '../context.ts' @@ -51,7 +51,7 @@ export interface CommitDetail { truncated: boolean } -const fileStatusCodeSchema = v.picklist([ +const fileStatusCodeSchema = s.picklist([ 'modified', 'added', 'deleted', @@ -62,34 +62,34 @@ const fileStatusCodeSchema = v.picklist([ 'unknown', ]) -const commitFileSchema = v.object({ - path: v.string(), - additions: v.number(), - deletions: v.number(), - binary: v.boolean(), +const commitFileSchema = s.object({ + path: s.string(), + additions: s.number(), + deletions: s.number(), + binary: s.boolean(), status: fileStatusCodeSchema, }) -const commitDetailSchema = v.object({ - isRepo: v.boolean(), - found: v.boolean(), - hash: v.string(), - shortHash: v.string(), - author: v.string(), - email: v.string(), - date: v.number(), - committer: v.string(), - committerEmail: v.string(), - commitDate: v.number(), - subject: v.string(), - body: v.string(), - parents: v.array(v.string()), - refs: v.array(v.string()), - files: v.array(commitFileSchema), - totalAdditions: v.number(), - totalDeletions: v.number(), - patch: v.nullable(v.string()), - truncated: v.boolean(), +const commitDetailSchema = s.object({ + isRepo: s.boolean(), + found: s.boolean(), + hash: s.string(), + shortHash: s.string(), + author: s.string(), + email: s.string(), + date: s.number(), + committer: s.string(), + committerEmail: s.string(), + commitDate: s.number(), + subject: s.string(), + body: s.string(), + parents: s.array(s.string()), + refs: s.array(s.string()), + files: s.array(commitFileSchema), + totalAdditions: s.number(), + totalDeletions: s.number(), + patch: s.nullable(s.string()), + truncated: s.boolean(), }) export interface ShowArgs { @@ -252,9 +252,9 @@ export const show = defineRpcFunction({ name: 'devframes:plugin:git:show', type: 'query', jsonSerializable: true, - args: [v.object({ - hash: v.string(), - patch: v.optional(v.boolean()), + args: [s.object({ + hash: s.string(), + patch: s.optional(s.boolean()), })], returns: commitDetailSchema, agent: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68771c3f..003a585f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1334,9 +1334,6 @@ importers: pathe: specifier: catalog:deps version: 2.0.3 - valibot: - specifier: catalog:deps - version: 1.4.2(typescript@6.0.3) devDependencies: '@antfu/design': specifier: catalog:frontend diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index eb5d24ff..dd65759b 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -41,6 +41,10 @@ export interface DevframeInstanceRegistration { } // #endregion +// #region Types +export type AgentArgsFallback = 'wrap' | 'drop'; +// #endregion + // #region Classes export declare class DevframeAgentHost implements DevframeAgentHost$1 { readonly context: DevframeNodeContext; @@ -66,7 +70,6 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { private _collectProviderTools; private _collectRpcTools; private _findRpcDefinition; - private _coercePositionalArgs; } export declare class DevframeDiagnosticsHost implements DevframeDiagnosticsHost$1 { readonly context: DevframeNodeContext; @@ -97,6 +100,7 @@ export declare class DevframeViewHost implements DevframeViewHost$1 { // #endregion // #region Functions +export declare function coerceAgentPositionalArgs(_: unknown, _: readonly unknown[] | undefined, _?: AgentArgsFallback): unknown[]; export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost; export declare function createHostContext(_: CreateHostContextOptions): Promise; export declare function createNodeSettings = Record>(_: DevframeNodeContext, _: string): DevframeSettings; @@ -110,6 +114,7 @@ export declare function normalizeHttpServerUrl(_: string, _: number | string): s export declare function registerDevframeInstance(_: DevframeInstanceRecord, _?: { instancesDir?: string; }): DevframeInstanceRegistration; +export declare function toAgentToolName(_: string): string; export declare function toDialableHost(_: string): string; // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index c5eb108a..4799b948 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -2,6 +2,7 @@ * Generated by tsnapi — public API snapshot of `devframe/node` */ // #region Other +export { coerceAgentPositionalArgs } export { createH3DevframeHost } export { createHostContext } export { createNodeSettings } @@ -18,5 +19,6 @@ export { isObject } export { normalizeHttpServerUrl } export { registerDevframeInstance } export { startHttpAndWs } +export { toAgentToolName } export { toDialableHost } // #endregion \ No newline at end of file diff --git a/tests/e2e/devframe-connect.spec.ts b/tests/e2e/devframe-connect.spec.ts index 9ca92ed9..f4a60c46 100644 --- a/tests/e2e/devframe-connect.spec.ts +++ b/tests/e2e/devframe-connect.spec.ts @@ -9,11 +9,11 @@ test.describe('devframe connect (files-inspector)', () => { await withConnectClient(REGISTRY, async (client) => { // The connector exposes exactly the two gateway tools. const tools = await client.listTools() - expect(tools.tools.map(t => t.name).sort()).toEqual(['devframe:connect:call-tool', 'devframe:connect:list-instances']) + expect(tools.tools.map(t => t.name).sort()).toEqual(['devframe_connect_call-tool', 'devframe_connect_list-instances']) // Index: the registry-registered dev server is discovered with its // MCP endpoint and tool list. - const index = parseToolText(await client.callTool({ name: 'devframe:connect:list-instances', arguments: {} })) + const index = parseToolText(await client.callTool({ name: 'devframe_connect_list-instances', arguments: {} })) const instance = index.instances.find( (entry: any) => entry.id === 'example:files-inspector' && entry.port === 9876, ) @@ -22,13 +22,13 @@ test.describe('devframe connect (files-inspector)', () => { // origin — accept either spelling. expect(instance.mcp.url).toMatch(/^http:\/\/(?:localhost|127\.0\.0\.1):9876\/__devframe-files-inspector\/__mcp$/) const toolNames = instance.mcp.tools.map((t: any) => t.name) - expect(toolNames).toContain('devframe:state:read') - expect(toolNames).toContain('example:files-inspector:docs') + expect(toolNames).toContain('devframe_state_read') + expect(toolNames).toContain('example_files-inspector_docs') // Call: proxy the gateway tool through the connector. const call = parseToolText(await client.callTool({ - name: 'devframe:connect:call-tool', - arguments: { port: 9876, tool: 'example:files-inspector:docs' }, + name: 'devframe_connect_call-tool', + arguments: { port: 9876, tool: 'example_files-inspector_docs' }, })) expect(call.isError).toBe(false) const inner = JSON.parse(call.content[0].text) @@ -40,13 +40,13 @@ test.describe('devframe connect (files-inspector)', () => { test('the call tool reports actionable errors for unknown targets', async () => { await withConnectClient(REGISTRY, async (client) => { const result = await client.callTool({ - name: 'devframe:connect:call-tool', + name: 'devframe_connect_call-tool', arguments: { port: 1, tool: 'anything' }, }) expect(result.isError).toBe(true) const payload = parseToolText(result) - expect(payload.error.message).toContain('no running devframe instance on port 1') - expect(payload.error.fix).toContain('devframe:connect:list-instances') + expect(payload.error.message).toContain('No running devframe instance on port 1') + expect(payload.error.fix).toContain('devframe_connect_list-instances') }) }) }) diff --git a/tests/e2e/next-devframe-hub-dev.spec.ts b/tests/e2e/next-devframe-hub-dev.spec.ts index 7b3490ed..c0c59845 100644 --- a/tests/e2e/next-devframe-hub-dev.spec.ts +++ b/tests/e2e/next-devframe-hub-dev.spec.ts @@ -31,7 +31,7 @@ test.describe('devframe connect (next-devframe-hub)', () => { await withConnectClient(REGISTRY, async (client) => { // Index: the hub registered itself (explicitly — it runs in-process, // not via createDevServer) with the Next server's own origin. - const index = parseToolText(await client.callTool({ name: 'devframe:connect:list-instances', arguments: {} })) + const index = parseToolText(await client.callTool({ name: 'devframe_connect_list-instances', arguments: {} })) const hub = index.instances.find((entry: any) => entry.id === 'example:next-devframe-hub') expect(hub).toBeDefined() // The probe may adopt an explicit address family for the recorded @@ -39,16 +39,16 @@ test.describe('devframe connect (next-devframe-hub)', () => { expect(hub.mcp.url).toMatch(/^http:\/\/(?:localhost|127\.0\.0\.1):9878\/__hub\/__mcp$/) // The hub's agent surface flows through: the agent-flagged hub command, - // the built-in devframe:state:read, and the git plugin's agent-flagged reads. + // the built-in devframe_state_read, and the git plugin's agent-flagged reads. const toolNames = hub.mcp.tools.map((t: any) => t.name) - expect(toolNames).toContain('example:next-devframe-hub:ping') - expect(toolNames).toContain('devframe:state:read') - expect(toolNames).toContain('devframes:plugin:git:status') + expect(toolNames).toContain('example_next-devframe-hub_ping') + expect(toolNames).toContain('devframe_state_read') + expect(toolNames).toContain('devframes_plugin_git_status') // Call the agent-flagged hub command through the connector. const ping = parseToolText(await client.callTool({ - name: 'devframe:connect:call-tool', - arguments: { port: 9878, tool: 'example:next-devframe-hub:ping' }, + name: 'devframe_connect_call-tool', + arguments: { port: 9878, tool: 'example_next-devframe-hub_ping' }, })) expect(ping.isError).toBe(false) expect(ping.content[0].text).toBe('pong')