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 fc58b07d..846589ef 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -46,4 +46,50 @@ 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) +``` + +## 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: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. + See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example. diff --git a/docs/errors/DF0042.md b/docs/errors/DF0042.md new file mode 100644 index 00000000..36530def --- /dev/null +++ b/docs/errors/DF0042.md @@ -0,0 +1,23 @@ +--- +outline: deep +--- + +# DF0042: 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/DF0043.md b/docs/errors/DF0043.md new file mode 100644 index 00000000..b07c6e12 --- /dev/null +++ b/docs/errors/DF0043.md @@ -0,0 +1,26 @@ +--- +outline: deep +--- + +# DF0043: Connector Requires the MCP SDK + +## Message + +> `devframe connect` requires the optional peer dependency @modelcontextprotocol/sdk: `{reason}` + +## Cause + +`devframe connect` was started but `@modelcontextprotocol/sdk` could not be imported. The SDK is an optional peer dependency of `devframe` — the MCP surface stays opt-in, so the SDK 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/sdk +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/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 f1fc382c..0a070e9d 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,6 +96,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 **`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 The simplest path is the CLI: @@ -174,4 +193,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/docs/guide/hub.md b/docs/guide/hub.md index 1c5059bc..47ab2026 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -30,6 +30,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/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 6d9b1049..0237be3d 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 @@ -8,7 +8,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' @@ -134,13 +134,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') @@ -174,6 +175,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', }) @@ -245,14 +252,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), `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, { + serverName: 'example: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: 'example:next-devframe-hub', + name: '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 549cec9a..7573d5c8 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 cef95414..c54b3d39 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 d12de2e5..a3fae20d 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -59,7 +59,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 c6751aaf..521c5ede 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -176,4 +176,109 @@ describe('mcp adapter (in-memory)', () => { await cleanup() } }) + + 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 devframe:state:read 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 === '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: {} }) + 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' } }) + 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' } }) + 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 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', + 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('devframe:state:read') + } + finally { + dispose() + await client.close() + await server.close() + } + }) + + 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 } }) + 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: 'devframe:state:read', arguments: {} }) + expect(keys.structuredContent).toEqual({ keys: ['visible:key'] }) + + const hidden = await client.callTool({ name: 'devframe:state:read', 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 5bb0ba4f..cac446d1 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -68,7 +68,7 @@ export function buildMcpServerFromContext( }, ) - registerToolHandlers(server, ctx) + registerToolHandlers(server, ctx, options.exposeSharedState) registerResourceHandlers(server, ctx, options.exposeSharedState) const notify = (method: string): void => { @@ -151,18 +151,88 @@ export async function createMcpServer( } } -function registerToolHandlers(server: Server, ctx: DevframeNodeContext): void { +/** + * 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' + +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(ListToolsRequestSchema, 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(CallToolRequestSchema, 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 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) + ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) : undefined const result = await ctx.agent.invoke(name, args ?? {}) return { @@ -253,9 +323,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 valibot `v.void()` / 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): Record { 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/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts new file mode 100644 index 00000000..5aa91ff3 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -0,0 +1,168 @@ +import type { DevframeNodeContext } from 'devframe/types' +import { randomUUID } from 'node:crypto' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' +import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import { 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 7ea2495e..89c5e464 100644 --- a/packages/devframe/src/adapters/mcp/http.ts +++ b/packages/devframe/src/adapters/mcp/http.ts @@ -1,49 +1,25 @@ import type { DevframeNodeContext } from 'devframe/types' import type { H3, H3Event } from 'h3' -import { randomUUID } from 'node:crypto' -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' -import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import 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). @@ -56,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 3dcce657..de4d672d 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/adapters/mcp/to-json-schema.ts b/packages/devframe/src/adapters/mcp/to-json-schema.ts index ccf1b144..1032a04a 100644 --- a/packages/devframe/src/adapters/mcp/to-json-schema.ts +++ b/packages/devframe/src/adapters/mcp/to-json-schema.ts @@ -1,81 +1,4 @@ -import type { GenericSchema } from 'valibot' -import { toJsonSchema } from '@valibot/to-json-schema' - -const FALLBACK_OBJECT_SCHEMA = Object.freeze({ type: 'object', additionalProperties: true }) - -/** - * Convert a valibot return schema to JSON Schema. - * @internal - */ -export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): unknown { - if (!schema) - return undefined - try { - return toJsonSchema(schema as any) - } - catch { - return FALLBACK_OBJECT_SCHEMA - } -} - -/** - * Convert positional RPC args schemas to a single MCP-friendly object - * schema. When the RPC declares `args: [v.object(...)]`, unwrap the - * single-object schema directly (nicer agent UX than `{ arg0: {...} }`). - * - * Returns `undefined` when there are no args (the MCP SDK treats this - * as `{ type: 'object', properties: {} }`). - * @internal - */ -export function valibotArgsToJsonSchema( - args: readonly GenericSchema[] | undefined, -): { schema: unknown, unwrapped: boolean } { - if (!args || args.length === 0) - return { schema: { type: 'object', properties: {} }, unwrapped: false } - - // Single-object arg: unwrap. - if (args.length === 1) { - const inner = safeToJsonSchema(args[0]!) - if (isObjectJsonSchema(inner)) - return { schema: inner, unwrapped: true } - // Non-object single arg (e.g. a string): fall through to arg0 shape. - } - - const properties: Record = {} - const required: string[] = [] - for (let i = 0; i < args.length; i++) { - const key = `arg${i}` - const s = safeToJsonSchema(args[i]!) - properties[key] = s - // Conservatively mark every positional arg as required — the RPC - // layer validates against valibot anyway. - required.push(key) - } - - return { - schema: { - type: 'object', - properties, - required, - additionalProperties: false, - }, - unwrapped: false, - } -} - -function safeToJsonSchema(schema: GenericSchema): unknown { - try { - return toJsonSchema(schema as any) - } - catch { - return FALLBACK_OBJECT_SCHEMA - } -} - -function isObjectJsonSchema(value: unknown): boolean { - return ( - !!value - && typeof value === 'object' - && (value as { type?: unknown }).type === 'object' - ) -} +// Shared internal conversion (also used by the agent host to project +// `AgentToolInput.args`); this module keeps the adapter-local import path +// stable. +export { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from '../../utils/valibot-json-schema' diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts new file mode 100644 index 00000000..c71e9a3c --- /dev/null +++ b/packages/devframe/src/cli/connect.ts @@ -0,0 +1,314 @@ +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: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 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: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 + */ +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(sdk.ListToolsRequestSchema, 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(sdk.CallToolRequestSchema, 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, typesMod, clientMod, streamableMod] = await Promise.all([ + import('@modelcontextprotocol/sdk/server/index.js'), + import('@modelcontextprotocol/sdk/server/stdio.js'), + import('@modelcontextprotocol/sdk/types.js'), + import('@modelcontextprotocol/sdk/client/index.js'), + import('@modelcontextprotocol/sdk/client/streamableHttp.js'), + ]) + return { + Server: serverMod.Server, + StdioServerTransport: stdioMod.StdioServerTransport, + ListToolsRequestSchema: typesMod.ListToolsRequestSchema, + CallToolRequestSchema: typesMod.CallToolRequestSchema, + Client: clientMod.Client, + StreamableHTTPClientTransport: streamableMod.StreamableHTTPClientTransport, + } + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw diagnostics.DF0043({ 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(`${CALL_TOOL} 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/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts index 90379e23..bd197bb2 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -269,4 +269,90 @@ describe('devToolsAgentHost', () => { await expect(ctx.agent.read('ghost')).rejects.toThrow(/ghost/) }) }) + + 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() + 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/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 58d4be24..88efa972 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], @@ -76,5 +79,13 @@ export const diagnostics = defineDiagnostics({ why: (p: { id: string }) => `A service is already provided under "${p.id}".`, fix: 'Service ids are unique per context. Revoke the existing provider first (the `provide()` call returns a revoke function), or namespace the id with your plugin id to avoid collisions.', }, + DF0042: { + 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.', + }, + DF0043: { + 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/host-agent.ts b/packages/devframe/src/node/host-agent.ts index 5ccb6441..3c69b506 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, @@ -14,6 +16,7 @@ import type { RpcFunctionAgentOptions, } from 'devframe/types' import { createEventEmitter } from 'devframe/utils/events' +import { valibotArgsToJsonSchema } from '../utils/valibot-json-schema' import { diagnostics } from './diagnostics' interface RegisteredTool { @@ -39,6 +42,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 +76,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 +126,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 +147,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 +171,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`) } @@ -172,12 +212,25 @@ export class DevframeAgentHost implements DevframeAgentHostType { description: input.description, safety: input.safety ?? 'action', tags: input.tags, - inputSchema: input.inputSchema, + // Valibot args are the source of truth (mirroring RPC definitions); + // an explicit JSON-Schema override wins when given. + inputSchema: input.inputSchema + ?? (input.args?.length ? valibotArgsToJsonSchema(input.args).schema : undefined), outputSchema: input.outputSchema, examples: input.examples, } } + /** 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/node/index.ts b/packages/devframe/src/node/index.ts index 69867f47..02050e8e 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -9,6 +9,10 @@ export type { RpcFunctionsHost } from './host-functions' export * from './host-h3' export * from './host-services' export * from './host-views' +// 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/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..025c3f75 --- /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 (`DF0042`), 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.DF0042({ file, reason: error instanceof Error ? error.message : String(error), cause: error }) + } + } + + return { + file, + unregister: () => { + try { + rmSync(file, { force: true }) + } + catch (error) { + diagnostics.DF0042({ 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/src/rpc/types.ts b/packages/devframe/src/rpc/types.ts index 370c08e6..0682bdb3 100644 --- a/packages/devframe/src/rpc/types.ts +++ b/packages/devframe/src/rpc/types.ts @@ -303,11 +303,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 @@ -318,9 +322,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/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index bb559bb6..1a54868c 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -1,3 +1,4 @@ +import type { GenericSchema } from 'valibot' import type { RpcFunctionAgentOptions } from '../rpc/types' import type { EventEmitter } from './events' @@ -44,6 +45,15 @@ export interface AgentToolInput { description: string safety?: 'read' | 'action' | 'destructive' tags?: readonly string[] + /** + * Positional valibot schemas describing the tool's arguments — the same + * shape RPC definitions carry. The host derives the tool's JSON-Schema + * input from them (a single `v.object(...)` schema is unwrapped — the + * friendliest shape at the agent boundary). Purely descriptive: the + * handler still receives the caller's args object as-is. + */ + args?: readonly GenericSchema[] + /** Raw JSON-Schema input override. Prefer {@link args}. */ inputSchema?: unknown outputSchema?: unknown examples?: readonly { args: unknown[], description?: string }[] @@ -114,6 +124,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 +191,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/devframe/src/utils/valibot-json-schema.ts b/packages/devframe/src/utils/valibot-json-schema.ts new file mode 100644 index 00000000..52ba8005 --- /dev/null +++ b/packages/devframe/src/utils/valibot-json-schema.ts @@ -0,0 +1,83 @@ +import type { GenericSchema } from 'valibot' +import { toJsonSchema } from '@valibot/to-json-schema' + +const FALLBACK_OBJECT_SCHEMA = Object.freeze({ type: 'object', additionalProperties: true }) + +/** + * Convert a valibot return schema to JSON Schema. + * + * @experimental + */ +export function valibotReturnToJsonSchema(schema: GenericSchema | undefined): unknown { + if (!schema) + return undefined + try { + return toJsonSchema(schema as any) + } + catch { + return FALLBACK_OBJECT_SCHEMA + } +} + +/** + * Convert positional RPC args schemas to a single MCP-friendly object + * schema. When the RPC declares `args: [v.object(...)]`, unwrap the + * single-object schema directly (nicer agent UX than `{ arg0: {...} }`). + * + * Returns `undefined` when there are no args (the MCP SDK treats this + * as `{ type: 'object', properties: {} }`). + * + * @experimental + */ +export function valibotArgsToJsonSchema( + args: readonly GenericSchema[] | undefined, +): { schema: unknown, unwrapped: boolean } { + if (!args || args.length === 0) + return { schema: { type: 'object', properties: {} }, unwrapped: false } + + // Single-object arg: unwrap. + if (args.length === 1) { + const inner = safeToJsonSchema(args[0]!) + if (isObjectJsonSchema(inner)) + return { schema: inner, unwrapped: true } + // Non-object single arg (e.g. a string): fall through to arg0 shape. + } + + const properties: Record = {} + const required: string[] = [] + for (let i = 0; i < args.length; i++) { + const key = `arg${i}` + const s = safeToJsonSchema(args[i]!) + properties[key] = s + // Conservatively mark every positional arg as required — the RPC + // layer validates against valibot anyway. + required.push(key) + } + + return { + schema: { + type: 'object', + properties, + required, + additionalProperties: false, + }, + unwrapped: false, + } +} + +function safeToJsonSchema(schema: GenericSchema): unknown { + try { + return toJsonSchema(schema as any) + } + catch { + return FALLBACK_OBJECT_SCHEMA + } +} + +function isObjectJsonSchema(value: unknown): boolean { + return ( + !!value + && typeof value === 'object' + && (value as { type?: unknown }).type === 'object' + ) +} diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index ee003592..8b67f58e 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -105,6 +105,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/hub/package.json b/packages/hub/package.json index 29557abb..cab9c511 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..9509e591 100644 --- a/packages/hub/src/node/__tests__/host-commands.test.ts +++ b/packages/hub/src/node/__tests__/host-commands.test.ts @@ -1,4 +1,7 @@ +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' @@ -61,3 +64,126 @@ describe('devframeCommandsHost command id validation', () => { })).toThrow('Command id "other:child" is already used') }) }) + +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, agent } = 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(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(agent.invoke('demo:greet', { name: 'devframe' })).resolves.toBe('done') + expect(calls).toEqual([[{ name: 'devframe' }]]) + }) + + it('projects zero-arg tools for commands without an args schema', async () => { + const { context, agent } = 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) + }, + }) + + expect(agent.getTool('demo:ping')!.safety).toBe('read') + await agent.invoke('demo:ping', { stray: true }) + expect(calls).toEqual([[]]) + }) + + 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', + title: 'Sync', + agent: { description: 'Initial description.' }, + handler: () => {}, + }) + expect(agent.getTool('demo:sync')!.description).toBe('Initial description.') + + handle.update({ agent: { description: 'Patched description.' } }) + expect(agent.getTool('demo:sync')!.description).toBe('Patched description.') + + handle.unregister() + 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', () => { + 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..56e15703 100644 --- a/packages/hub/src/node/host-commands.ts +++ b/packages/hub/src/node/host-commands.ts @@ -1,4 +1,6 @@ +import type { AgentToolInput, AgentToolProviderHandle } from 'devframe/types' import type { + DevframeCommandAgentOptions, DevframeCommandHandle, DevframeCommandsHost as DevframeCommandsHostType, DevframeServerCommandEntry, @@ -54,17 +56,28 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { public readonly commands: DevframeCommandsHostType['commands'] = new Map() public readonly events: DevframeCommandsHostType['events'] = createEventEmitter() + /** + * 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)) { 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.agentProvider?.notifyChanged() return { id: command.id, @@ -82,8 +95,10 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { id: existing.id, } validateCommandIds(this.commands, next, existing.id) + this.validateAgentExposure(next) Object.assign(existing, patch) this.events.emit('command:registered', this.toSerializable(existing)) + this.agentProvider?.notifyChanged() }, unregister: () => this.unregister(command.id), } @@ -93,6 +108,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType { const deleted = this.commands.delete(id) if (deleted) { this.events.emit('command:unregistered', id) + this.agentProvider?.notifyChanged() } return deleted } @@ -129,7 +145,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 +157,64 @@ 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) + } + + /** + * 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 collectAgentTools(): AgentToolInput[] { + const tools: AgentToolInput[] = [] + const walk = (command: DevframeServerCommandInput): void => { + const agent = command.agent + if (agent && command.handler) { + tools.push({ + id: command.id, + title: agent.title ?? command.title, + description: agent.description, + safety: agent.safety ?? 'action', + tags: agent.tags, + // 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)), + }) + } + for (const child of command.children ?? []) + walk(child) + } + for (const command of this.commands.values()) + walk(command) + return tools + } +} + +/** + * 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 `v.object(...)` schema (unwrapped at the + * tool boundary) → the object itself; positional schemas → `arg0..argN` keys + * in order. + */ +function coercePositionalArgs( + args: unknown, + schemas: DevframeCommandAgentOptions['args'], +): unknown[] { + if (!schemas || schemas.length === 0) + return [] + if (schemas.length === 1 && schemas[0]!.type === 'object') + 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/packages/next/src/host.ts b/packages/next/src/host.ts index 4ea0d613..d60511cb 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 / `devframe:state:read`. 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..78618caf 100644 --- a/plans/031-agent-native-mcp-wave.md +++ b/plans/031-agent-native-mcp-wave.md @@ -63,14 +63,15 @@ 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?, 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. @@ -91,11 +92,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,8 +107,8 @@ 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. - - `examples/minimal-next-devframe-hub`: e2e boots the Next dev server, + asserts the connector discovers it and a proxied call round-trips. + - `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 @@ -123,18 +124,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; `devframe:state:read` 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..3aa1a6a6 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/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/plugins/git/package.json b/plugins/git/package.json index c9edc000..d0bb3f1d 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 145b5361..50302eb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,6 +355,9 @@ importers: '@antfu/utils': specifier: catalog:inlined version: 9.3.0 + '@modelcontextprotocol/sdk': + specifier: catalog:deps + version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) '@playwright/test': specifier: catalog:testing version: 1.62.0 @@ -876,6 +879,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 @@ -1204,6 +1210,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 d08cc714..33a08112 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..9279a3e3 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 agentProvider; constructor(_: DevframeHubContext); register(_: DevframeServerCommandInput): DevframeCommandHandle; unregister(_: string): boolean; @@ -20,6 +21,8 @@ export declare class DevframeCommandsHost implements DevframeCommandsHost$1 { list(): DevframeServerCommandEntry[]; private findCommand; private toSerializable; + private validateAgentExposure; + 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 4677d5b7..a76d3e1f 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 + agentProvider constructor(_) {} register(_) {} unregister(_) {} @@ -13,6 +14,8 @@ export class DevframeCommandsHost { list() {} findCommand(_) {} toSerializable(_) {} + validateAgentExposure(_) {} + collectAgentTools() {} } 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/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/@devframes/plugin-og/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts index 25339d5a..e02b51cd 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts @@ -24,7 +24,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; @@ -47,10 +47,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; @@ -60,11 +60,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/@devframes/plugin-terminals/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts index ef57bad9..d2323780 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts @@ -29,7 +29,7 @@ export declare const serverFunctions: readonly [{ }, undefined>, undefined>; 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; processName?: string | undefined; @@ -68,8 +68,8 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }[]) | undefined; - dump?: import("devframe/rpc").RpcDump<[], { + }[]>) | undefined; + dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -88,9 +88,9 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: 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:terminals:presets"; type?: "query" | undefined; @@ -145,47 +145,47 @@ export declare const serverFunctions: readonly [{ }, undefined>, undefined>; 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; @@ -235,7 +235,7 @@ export declare const serverFunctions: readonly [{ env?: { [x: string]: string; } | undefined; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -254,7 +254,7 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }>>) | undefined; + }>>>) | undefined; handler?: ((args_0: { presetId?: string | undefined; command?: string | undefined; @@ -267,7 +267,7 @@ export declare const serverFunctions: readonly [{ env?: { [x: string]: string; } | undefined; - }) => { + }) => import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -286,7 +286,7 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ presetId?: string | undefined; command?: string | undefined; @@ -299,7 +299,7 @@ export declare const serverFunctions: readonly [{ env?: { [x: string]: string; } | undefined; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -318,7 +318,7 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: 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:terminals:write"; type?: "action" | undefined; @@ -398,24 +398,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; @@ -432,28 +432,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; @@ -466,20 +466,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; @@ -511,7 +511,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; processName?: string | undefined; @@ -552,10 +552,10 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: number; - }) | undefined; + }>) | undefined; dump?: import("devframe/rpc").RpcDump<[{ id: string; - }], { + }], import("devframe/rpc").Thenable<{ id: string; title: string; processName?: string | undefined; @@ -574,11 +574,11 @@ export declare const serverFunctions: readonly [{ channel?: string | undefined; presetId?: string | undefined; createdAt: 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:terminals:rename"; type?: "action" | undefined; @@ -634,24 +634,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; @@ -664,20 +664,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; @@ -686,11 +686,11 @@ export declare const serverFunctions: readonly [{ returns: import("valibot").VoidSchema; 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/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 diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 3fbcfaf1..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 { @@ -58,6 +59,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 +82,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 +394,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 64e82a9e..eb5d24ff 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 @@ -30,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; @@ -44,6 +63,7 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { _dispose(): void; private _validateToolId; private _projectTool; + private _collectProviderTools; private _collectRpcTools; private _findRpcDefinition; private _coercePositionalArgs; @@ -87,6 +107,9 @@ export declare function createStorage(_: CreateStorageOptions< export declare function formatHostForUrl(_: string): string; export declare function isObject(_: unknown): value is Record; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; +export declare function registerDevframeInstance(_: DevframeInstanceRecord, _?: { + instancesDir?: string; +}): DevframeInstanceRegistration; 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 a580513e..c5eb108a 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -16,6 +16,7 @@ export { DevframeViewHost } export { formatHostForUrl } export { isObject } export { normalizeHttpServerUrl } +export { registerDevframeInstance } export { startHttpAndWs } export { toDialableHost } // #endregion \ No newline at end of file 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 7114a897..d6f9bc72 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: v.VoidSchema; 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: v.VoidSchema; 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: v.VoidSchema; 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: v.VoidSchema; 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 436d011f..3c1e0e3f 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: v.VoidSchema; 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: v.VoidSchema; 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/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/_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..9ca92ed9 --- /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: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 instance = index.instances.find( + (entry: any) => entry.id === 'example: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('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' }, + })) + 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('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', + 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') + }) + }) +}) diff --git a/tests/e2e/next-devframe-hub-dev.spec.ts b/tests/e2e/next-devframe-hub-dev.spec.ts new file mode 100644 index 00000000..7b3490ed --- /dev/null +++ b/tests/e2e/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 (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: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 + // `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 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') + + // 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' }, + })) + expect(ping.isError).toBe(false) + expect(ping.content[0].text).toBe('pong') + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 1015dfc3..7acfa779 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,