diff --git a/.gitignore b/.gitignore
index 16643ade..ebc185fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,7 +18,11 @@ temp
packages/devframe/skills
test-results
playwright-report
+tests/e2e/.registries
playwright/.cache
blob-report
.ecosystem
storybook-static
+
+# Agent skills from npm packages (managed by skills-npm)
+**/skills/npm-*
diff --git a/README.md b/README.md
index c505d820..119ae926 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,10 @@ Framework-neutral foundation for building devframes.
+## Credits
+
+The `devframe connect` MCP connector (discovery + gateway tools + agent-steering errors) follows the architecture Vercel's [`next-devtools-mcp`](https://github.com/vercel/next-devtools-mcp) validated: the real MCP endpoint lives inside the framework, and a thin external connector discovers and proxies it.
+
## License
[MIT](./LICENSE.md) License © [Anthony Fu](https://github.com/antfu)
diff --git a/alias.ts b/alias.ts
index 2ea324ee..305572d3 100644
--- a/alias.ts
+++ b/alias.ts
@@ -18,6 +18,7 @@ export const alias = {
'devframe/node/hub-internals': r('devframe/src/node/hub-internals/index.ts'),
'devframe/node': r('devframe/src/node/index.ts'),
'devframe/constants': r('devframe/src/constants.ts'),
+ 'devframe/utils/agent-tool-name': r('devframe/src/utils/agent-tool-name.ts'),
'devframe/utils/colors': r('devframe/src/utils/colors.ts'),
'devframe/utils/crypto-token': r('devframe/src/utils/crypto-token.ts'),
'devframe/utils/events': r('devframe/src/utils/events.ts'),
diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md
index 1772282f..8f146de5 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 (the wire names of the `devframe:connect:*` ids — see [tool ids and wire names](/guide/agent-native#tool-ids-and-wire-names)):
+
+- **`devframe_connect_list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`.
+- **`devframe_connect_call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint.
+
+Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out.
+
See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example.
diff --git a/docs/errors/DF0045.md b/docs/errors/DF0045.md
new file mode 100644
index 00000000..8ab0e9be
--- /dev/null
+++ b/docs/errors/DF0045.md
@@ -0,0 +1,23 @@
+---
+outline: deep
+---
+
+# DF0045: Instance Registry Update Failed
+
+## Message
+
+> Failed to update the devframe instance registry at "`{file}`": `{reason}`
+
+## Cause
+
+A dev server (or an in-process host calling `registerDevframeInstance`) could not write or remove its record under the instance registry directory — `~/.devframe/instances/` by default, or `$DEVFRAME_INSTANCES_DIR`. Typical causes are a read-only home directory, missing permissions, or a full disk. The server keeps running; only discovery is affected — `devframe connect` will not see this instance.
+
+## Fix
+
+- Check that the registry directory is writable and the disk has free space.
+- Point `DEVFRAME_INSTANCES_DIR` at a writable directory.
+- Set `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` to opt out of registration entirely.
+
+## Source
+
+- [`packages/devframe/src/node/instance-registry.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/instance-registry.ts) — `registerDevframeInstance()` reports this on a failed write and its `unregister()` on a failed removal.
diff --git a/docs/errors/DF0046.md b/docs/errors/DF0046.md
new file mode 100644
index 00000000..66768a5e
--- /dev/null
+++ b/docs/errors/DF0046.md
@@ -0,0 +1,26 @@
+---
+outline: deep
+---
+
+# DF0046: Connector Requires the MCP SDK
+
+## Message
+
+> `devframe connect` requires the optional peer dependency @modelcontextprotocol/server: `{reason}`
+
+## Cause
+
+`devframe connect` was started but `@modelcontextprotocol/server` could not be imported. The SDK is an optional peer dependency of `devframe` — the MCP surface stays opt-in, so it only needs to be installed where MCP features are used.
+
+## Fix
+
+Install the SDK next to devframe and run the connector again:
+
+```sh
+npm install @modelcontextprotocol/server
+devframe connect
+```
+
+## Source
+
+- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `startConnectServer()` throws this when the dynamic SDK import fails.
diff --git a/docs/errors/DF0047.md b/docs/errors/DF0047.md
new file mode 100644
index 00000000..1ca9cac3
--- /dev/null
+++ b/docs/errors/DF0047.md
@@ -0,0 +1,28 @@
+---
+outline: deep
+---
+
+# DF0047: Agent Tool Wire-Name Collision
+
+## Message
+
+> Agent tool "`{id}`" is hidden from the MCP surface: its wire name "`{name}`" collides with the tool "`{existing}`".
+
+## Cause
+
+MCP clients constrain tool names to `^[a-zA-Z0-9_-]{1,128}$`, so the MCP adapter derives each tool's wire name from its id (runs of characters outside `[a-zA-Z0-9_-]` become a single `_`). Two registered ids sanitized to the same wire name — e.g. `demo:greet` and `demo_greet`. The first registration keeps the name; the later tool is hidden from `tools/list`.
+
+## Example
+
+```ts
+ctx.agent.registerTool({ id: 'demo:greet', description: '…', handler })
+ctx.agent.registerTool({ id: 'demo_greet', description: '…', handler }) // hidden: same wire name
+```
+
+## Fix
+
+Rename one of the two ids so they sanitize to distinct wire names. Namespaced ids (`devframes:plugin::`, `devframe::`) collide only when they differ solely in separator characters.
+
+## Source
+
+- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — the `tools/list` handler reports this once per hidden tool when deduplicating wire names.
diff --git a/docs/errors/DF0048.md b/docs/errors/DF0048.md
new file mode 100644
index 00000000..4499668b
--- /dev/null
+++ b/docs/errors/DF0048.md
@@ -0,0 +1,28 @@
+---
+outline: deep
+---
+
+# DF0048: Unknown Shared-State Key
+
+## Message
+
+> Unknown shared-state key "`{key}`".
+
+## Cause
+
+The built-in `devframe_state_read` MCP tool was called with a `key` that is not among the shared-state keys the host publishes (or that the `exposeSharedState` filter allows). The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct.
+
+## Example
+
+```ts
+// The host publishes only `my-plugin:counter`; an agent calls:
+// devframe_state_read({ key: 'my-plugin:cuonter' }) → DF0048
+```
+
+## Fix
+
+Call the `devframe_state_read` tool without arguments to list the available keys, then retry with one of them.
+
+## Source
+
+- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — `readStateResult()` throws this when the requested key is absent from the filtered key list.
diff --git a/docs/errors/DF0049.md b/docs/errors/DF0049.md
new file mode 100644
index 00000000..e93ca88a
--- /dev/null
+++ b/docs/errors/DF0049.md
@@ -0,0 +1,27 @@
+---
+outline: deep
+---
+
+# DF0049: Connector Call Requires Port and Tool
+
+## Message
+
+> The devframe_connect_call-tool tool requires { port: number, tool: string }.
+
+## Cause
+
+The `devframe connect` gateway tool `devframe_connect_call-tool` was invoked without a numeric `port` or a string `tool` name — the two fields that identify which instance to dial and which of its tools to call. The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct.
+
+## Example
+
+```ts
+// devframe_connect_call-tool({ tool: 'devframe_state_read' }) → DF0049 (missing port)
+```
+
+## Fix
+
+Call `devframe_connect_list-instances` first — its result carries each instance's `port` and tool names — then retry with both fields.
+
+## Source
+
+- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `call()` throws this when the gateway arguments fail validation.
diff --git a/docs/errors/DF0050.md b/docs/errors/DF0050.md
new file mode 100644
index 00000000..7b2cdcbc
--- /dev/null
+++ b/docs/errors/DF0050.md
@@ -0,0 +1,28 @@
+---
+outline: deep
+---
+
+# DF0050: No Devframe Instance on Port
+
+## Message
+
+> No running devframe instance on port `{port}`.
+
+## Cause
+
+The `devframe connect` gateway tool `devframe_connect_call-tool` targeted a port with no live devframe instance behind it — neither the instance registry nor a direct probe of the port found one serving `__connection.json`. The instance may have stopped, restarted on a different port, or never existed. The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct.
+
+## Example
+
+```ts
+// No dev server on 5199:
+// devframe_connect_call-tool({ port: 5199, tool: 'devframe_state_read' }) → DF0050
+```
+
+## Fix
+
+Call `devframe_connect_list-instances` for the current instance list and retry with a live port.
+
+## Source
+
+- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `call()` throws this when neither the registry nor the port probe finds an instance.
diff --git a/docs/errors/DF0051.md b/docs/errors/DF0051.md
new file mode 100644
index 00000000..a5485083
--- /dev/null
+++ b/docs/errors/DF0051.md
@@ -0,0 +1,28 @@
+---
+outline: deep
+---
+
+# DF0051: Instance Has No MCP Endpoint
+
+## Message
+
+> The devframe instance on port `{port}` has no MCP endpoint.
+
+## Cause
+
+The `devframe connect` gateway tool `devframe_connect_call-tool` targeted a live devframe instance that runs without an MCP route — its `__connection.json` advertises no `mcp` entry, so there is no endpoint to proxy the tool call to. The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct.
+
+## Example
+
+```ts
+// The instance on 5173 was started without --mcp:
+// devframe_connect_call-tool({ port: 5173, tool: 'devframe_state_read' }) → DF0051
+```
+
+## Fix
+
+Restart the instance with the `--mcp` flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.
+
+## Source
+
+- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `call()` throws this when the targeted instance's record carries `mcp: null`.
diff --git a/docs/errors/DF8404.md b/docs/errors/DF8404.md
new file mode 100644
index 00000000..3d457a76
--- /dev/null
+++ b/docs/errors/DF8404.md
@@ -0,0 +1,48 @@
+---
+outline: deep
+---
+
+# DF8404: Agent Exposure Without Handler
+
+## Message
+
+> Command "`{id}`" declares agent exposure but has no handler
+
+## Cause
+
+`ctx.commands.register(command)` or a command handle `update()` received a command carrying an `agent` field but no `handler`. Agent-exposed commands are projected into `ctx.agent` as callable tools (reaching MCP clients through the devframe MCP adapter), so they must be executable server-side — a handler-less command is a palette group and cannot run.
+
+## Example
+
+```ts
+// ✗ Bad: group-only command opting into the agent surface
+ctx.commands.register({
+ id: 'my-tool:group',
+ title: 'My tool',
+ agent: { description: 'Run my tool.' },
+ children: [/* … */],
+})
+
+// ✓ Good: the executable child carries the agent field
+ctx.commands.register({
+ id: 'my-tool:group',
+ title: 'My tool',
+ children: [
+ {
+ id: 'my-tool:reload',
+ title: 'Reload',
+ agent: { description: 'Reload my tool\'s state. Call after changing its config.' },
+ handler: () => reload(),
+ },
+ ],
+})
+```
+
+## Fix
+
+- Add a `handler` to the command carrying the `agent` field.
+- Or move the `agent` field to an executable child command.
+
+## Source
+
+- [`packages/hub/src/node/host-commands.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-commands.ts) — `DevframeCommandsHost.register()` and command handle `update()` validate agent exposure across the command tree.
diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md
index f1646322..0e8f92af 100644
--- a/docs/guide/agent-native.md
+++ b/docs/guide/agent-native.md
@@ -43,6 +43,21 @@ export const getSessionSummary = defineRpcFunction({
Agent tools take a single object input. The MCP adapter synthesises `arg0`, `arg1`, … from positional args (`args: [A, B]`); a single object schema (`args: [v.object({ ... })]`) reads better at the agent boundary because property names are self-describing.
+## Tool ids and wire names
+
+Every agent tool has two names:
+
+- **The id** — how the tool is registered and invoked inside devframe. Ids are colon-namespaced by convention: `devframes:plugin::` for plugin RPCs, `devframe::` for built-ins, and command ids for hub-command-derived tools.
+- **The wire name** — what MCP clients see and call. Clients constrain tool names to `^[a-zA-Z0-9_-]{1,128}$`, so the MCP adapter derives the wire name automatically: every run of characters outside `[a-zA-Z0-9_-]` becomes a single `_`, truncated to 128 characters.
+
+```
+devframe:state:read → devframe_state_read
+devframes:plugin:git:status → devframes_plugin_git_status
+my-plugin:summarize → my-plugin_summarize
+```
+
+The convention applies uniformly to `agent`-flagged RPCs, tools registered via `registerTool` / `registerToolProvider`, and the hub's command-derived tools — keep registering with namespaced ids and let the boundary derive the name. `toAgentToolName` (from `devframe/utils/agent-tool-name` — a plain string transform, safe to import client-side too, e.g. from a UI that displays a tool's id) computes the mapping when you need to predict a wire name (e.g. in a client config, a test, or an inspector view). Calls resolve back to the id at the boundary; two ids that sanitize to the same wire name keep the first registration and hide the later one with a `DF0047` warning.
+
## Registering a plugin tool
For tools without a matching RPC — say, an on-demand narrative summary — register them directly:
@@ -63,6 +78,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 +111,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** (wire name `devframe_state_read`) — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection.
+
## Starting the MCP server
The simplest path is the CLI:
@@ -174,4 +208,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 2dac8605..48c52388 100644
--- a/docs/guide/hub.md
+++ b/docs/guide/hub.md
@@ -35,6 +35,24 @@ Every hub context auto-registers these RPC functions so framework kits don't rei
Host-specific capabilities (open in editor, reveal in finder, …) ship as kit-registered RPC functions rather than as part of the hub surface.
+## Commands as agent tools
+
+A server command opts into the [agent surface](./agent-native) with an `agent` field — the same default-deny convention as `defineRpcFunction`. Agent-flagged, handler-bearing commands are projected into `ctx.agent` as callable tools and reach MCP clients through the devframe MCP adapter:
+
+```ts
+ctx.commands.register({
+ id: 'app:build',
+ title: 'Run build',
+ agent: {
+ description: 'Run the production build. Call after config or dependency changes to verify the app still builds.',
+ args: [v.object({ configFile: v.optional(v.string()) })],
+ },
+ handler: (opts?: { configFile?: string }) => runBuild(opts),
+})
+```
+
+`args` takes positional valibot schemas (a single `v.object(...)` is unwrapped into the tool's input object); omit it for a zero-argument tool. `safety` defaults to `'action'`. `when` clauses evaluate client-side only and are not enforced for agent calls — opt in a `when`-gated command only if running it outside its UI context is safe.
+
## Cross-iframe dock activation
The viewer's active dock is client-local state — which dock is on screen lives in the shell page, not in shared state. A mounted devframe runs in its own iframe on its own RPC client, so it can't reach that selection directly. `hub:docks:activate` bridges the gap: any connected client asks the hub to switch the active dock, and the hub relays the request to the shell.
diff --git a/examples/files-inspector/src/devframe.ts b/examples/files-inspector/src/devframe.ts
index 144bb379..9fd7b56a 100644
--- a/examples/files-inspector/src/devframe.ts
+++ b/examples/files-inspector/src/devframe.ts
@@ -22,6 +22,9 @@ export default defineDevframe({
// Single-user localhost demo — skip the trust handshake so the served
// SPA can call RPC without an OTP round-trip.
auth: false,
+ // Serve the agent surface over the dev server's `/__mcp` route and
+ // register the instance for `devframe connect` discovery.
+ mcp: true,
},
spa: { loader: 'none' },
setup(ctx) {
@@ -29,5 +32,17 @@ export default defineDevframe({
const my = ctx.scope(NAMESPACE)
for (const fn of serverFunctions)
my.rpc.register(fn)
+
+ // Gateway tool: returns the location of this tool's own docs instead of
+ // proxying their content — the agent reads the files with its own tools.
+ ctx.agent.registerTool({
+ id: `${NAMESPACE}:docs`,
+ description: 'Locate the Files Inspector\'s documentation on disk. Call before answering questions about how this tool works, then read the returned files directly.',
+ safety: 'read',
+ handler: () => ({
+ readmePath: fileURLToPath(new URL('../README.md', import.meta.url)),
+ hint: 'Read the file at readmePath with your own file tools; do not rely on training-data knowledge of this example.',
+ }),
+ })
},
})
diff --git a/examples/next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts b/examples/next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts
index ce601989..c99bdcc7 100644
--- a/examples/next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts
+++ b/examples/next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts
@@ -5,12 +5,18 @@ export const dynamic = 'force-dynamic'
/**
* Catch-all for every mounted devframe SPA (`/__git/…`, `/__terminals/…`, the
- * a11y agent module, …) and their `/__connection.json` discovery fetches.
- * The `@devframes/next` bridge owns all of it — static serving (with SPA
- * fallback, content types, and traversal guarding via devframe's shared
- * `serveStaticHandler`) and the connection-meta responses.
+ * a11y agent module, …), their `/__connection.json` discovery fetches,
+ * and the in-process MCP endpoint (`/__hub/__mcp`). The `@devframes/next`
+ * bridge owns all of it — static serving (with SPA fallback, content types,
+ * and traversal guarding via devframe's shared `serveStaticHandler`), the
+ * connection-meta responses, and the MCP mount.
+ *
+ * MCP speaks Streamable-HTTP: `POST` (requests), `GET` (the SSE stream), and
+ * `DELETE` (session teardown) all route to the same bridge `fetch`.
*/
-export async function GET(request: Request): Promise {
+async function handler(request: Request): Promise {
const hub = await ensureNextDevframeHub()
return hub.fetch(request)
}
+
+export { handler as DELETE, handler as GET, handler as POST }
diff --git a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts
index 1d9bc74d..26b28755 100644
--- a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts
+++ b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts
@@ -9,7 +9,7 @@ import { defineHubRpcFunction } from '@devframes/hub'
import { createHubContext, mountDevframe } from '@devframes/hub/node'
import { toJsonRenderDockEntry } from '@devframes/json-render/hub'
import { createDevframeNextHost } from '@devframes/next'
-import { startHttpAndWs } from 'devframe/node'
+import { registerDevframeInstance, startHttpAndWs } from 'devframe/node'
import { getPort } from 'get-port-please'
import { createDashboardView } from 'json-render/dashboard'
import { dirname, join } from 'pathe'
@@ -153,13 +153,14 @@ export async function nextDevframeHub(
): Promise {
const cwd = options.cwd ?? process.cwd()
const hostName = options.host ?? 'localhost'
+ const nextPort = Number(process.env.PORT ?? 3000)
// The Next host bridge: its `host` accumulates every `mountStatic` /
// `mountConnectionMeta` call into a single `fetch` handler (backed by
// devframe's shared `serveStaticHandler`), which the App Router routes
// delegate to — no hand-rolled static serving or path matching here.
const nextHost = createDevframeNextHost({
- resolveOrigin: () => `http://${hostName}:3000`,
+ resolveOrigin: () => `http://${hostName}:${nextPort}`,
getStorageDir(scope) {
if (scope === 'workspace')
return join(cwd, '.devframe')
@@ -193,6 +194,12 @@ export async function nextDevframeHub(
title: 'Next Hub: Ping',
icon: 'ph:bell-duotone',
category: 'hub',
+ // Opt this command into the agent surface: it shows up as an MCP tool
+ // on the in-process endpoint mounted below.
+ agent: {
+ description: 'Ping the hub to confirm it is alive. Returns "pong". Safe to call freely.',
+ safety: 'read',
+ },
handler: () => 'pong',
})
@@ -264,14 +271,45 @@ export async function nextDevframeHub(
auth: false,
})
+ // Serve MCP in-process on the Next app's own origin (the `/_next/mcp`
+ // shape): the hub's agent surface — agent-flagged commands, plugin tools
+ // (git status/log/diff, terminals), `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 45edfc61..3975ccfe 100644
--- a/examples/next-devframe-hub/tests/next-devframe-hub.test.ts
+++ b/examples/next-devframe-hub/tests/next-devframe-hub.test.ts
@@ -19,12 +19,13 @@ describe('next-devframe-hub (example)', () => {
server = undefined
})
- it('returns connection meta pointing at the WS backend', async () => {
+ it('returns connection meta pointing at the WS backend and in-process MCP', async () => {
server = await nextDevframeHub({ host: '127.0.0.1' })
expect(server.connectionMeta).toEqual({
backend: 'websocket',
websocket: server.port,
+ mcp: { path: '/__hub/__mcp' },
})
})
diff --git a/package.json b/package.json
index a47fde51..a2bd6bfb 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"@antfu/design": "catalog:frontend",
"@antfu/eslint-config": "catalog:tooling",
"@antfu/utils": "catalog:inlined",
+ "@modelcontextprotocol/client": "catalog:deps",
"@playwright/test": "catalog:testing",
"@types/node": "catalog:types",
"@types/prompts": "catalog:types",
diff --git a/packages/devframe/bin/devframe.mjs b/packages/devframe/bin/devframe.mjs
new file mode 100755
index 00000000..17362378
--- /dev/null
+++ b/packages/devframe/bin/devframe.mjs
@@ -0,0 +1,8 @@
+#!/usr/bin/env node
+import process from 'node:process'
+import { runDevframeCli } from '../dist/cli/main.mjs'
+
+runDevframeCli().catch((error) => {
+ console.error(error)
+ process.exit(1)
+})
diff --git a/packages/devframe/package.json b/packages/devframe/package.json
index 29680312..9c2c3aa0 100644
--- a/packages/devframe/package.json
+++ b/packages/devframe/package.json
@@ -42,6 +42,7 @@
"./rpc/transports/ws-client": "./dist/rpc/transports/ws-client.mjs",
"./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs",
"./types": "./dist/types/index.mjs",
+ "./utils/agent-tool-name": "./dist/utils/agent-tool-name.mjs",
"./utils/colors": "./dist/utils/colors.mjs",
"./utils/crypto-token": "./dist/utils/crypto-token.mjs",
"./utils/events": "./dist/utils/events.mjs",
@@ -60,7 +61,11 @@
"./package.json": "./package.json"
},
"types": "./dist/index.d.mts",
+ "bin": {
+ "devframe": "./bin/devframe.mjs"
+ },
"files": [
+ "bin",
"dist",
"skills"
],
@@ -71,10 +76,14 @@
"prepack": "pnpm build && mkdir -p ./skills && cp -r ../../skills/devframe ./skills/devframe"
},
"peerDependencies": {
+ "@modelcontextprotocol/client": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0",
"cac": "^7.0.0"
},
"peerDependenciesMeta": {
+ "@modelcontextprotocol/client": {
+ "optional": true
+ },
"@modelcontextprotocol/server": {
"optional": true
},
diff --git a/packages/devframe/src/adapters/__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 445f543e..fddaa92d 100644
--- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts
+++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts
@@ -60,6 +60,86 @@ describe('mcp adapter (in-memory)', () => {
}
})
+ it('converts a registered tool\'s Standard Schema args to JSON Schema over the wire', async () => {
+ const { ctx, client, cleanup } = await bootPair()
+ try {
+ const v = await import('valibot')
+ ctx.agent.registerTool({
+ id: 'schema-tool',
+ description: 'Takes a schema-typed arg.',
+ args: [v.object({ name: v.optional(v.string()) })],
+ handler: args => args,
+ })
+
+ const listed = await client.listTools()
+ const tool = listed.tools.find(t => t.name === 'schema-tool')!
+ // Each positional arg is advertised under `arg0`/`arg1`/… — the
+ // project-wide Standard Schema convention (no single-arg unwrapping).
+ const schema = tool.inputSchema as { type: string, properties: Record }
+ expect(schema.type).toBe('object')
+ expect(Object.keys(schema.properties)).toEqual(['arg0'])
+
+ // `args` is purely descriptive for a plain registered tool — the
+ // handler receives the caller's payload as-is, unlike RPC-backed
+ // tools (or hub commands) which coerce `arg0`/`arg1`/… into
+ // positional parameters.
+ const result = await client.callTool({ name: 'schema-tool', arguments: { arg0: { name: 'devframe' } } })
+ const content = result.content as Array<{ type: string, text: string }>
+ expect(JSON.parse(content[0]!.text)).toEqual({ arg0: { name: 'devframe' } })
+ }
+ finally {
+ await cleanup()
+ }
+ })
+
+ it('advertises colon-namespaced ids under their derived wire name and resolves calls back', async () => {
+ const { ctx, client, cleanup } = await bootPair()
+ try {
+ ctx.agent.registerTool({
+ id: 'devframes:plugin:demo:greet',
+ description: 'Say hello.',
+ safety: 'read',
+ handler: () => ({ greeting: 'hi' }),
+ })
+
+ const listed = await client.listTools()
+ const names = listed.tools.map(t => t.name)
+ expect(names).toContain('devframes_plugin_demo_greet')
+ expect(names).not.toContain('devframes:plugin:demo:greet')
+
+ const result = await client.callTool({ name: 'devframes_plugin_demo_greet', arguments: {} })
+ const content = result.content as Array<{ type: string, text: string }>
+ expect(JSON.parse(content[0]!.text)).toEqual({ greeting: 'hi' })
+ }
+ finally {
+ await cleanup()
+ }
+ })
+
+ it('hides a later tool whose wire name collides with an earlier one', async () => {
+ const { ctx, client, cleanup } = await bootPair()
+ try {
+ ctx.agent.registerTool({
+ id: 'demo:greet',
+ description: 'First.',
+ handler: () => 'first',
+ })
+ ctx.agent.registerTool({
+ id: 'demo_greet',
+ description: 'Second — sanitizes to the same wire name.',
+ handler: () => 'second',
+ })
+
+ const listed = await client.listTools()
+ const matches = listed.tools.filter(t => t.name === 'demo_greet')
+ expect(matches).toHaveLength(1)
+ expect(matches[0]!.description).toBe('First.')
+ }
+ finally {
+ await cleanup()
+ }
+ })
+
it('returns text and structured content for a tool with an output schema', async () => {
const { ctx, client, cleanup } = await bootPair()
try {
@@ -175,4 +255,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 b2aac021..d3e0dfee 100644
--- a/packages/devframe/src/adapters/mcp/build-server.ts
+++ b/packages/devframe/src/adapters/mcp/build-server.ts
@@ -6,6 +6,7 @@ import { homedir } from 'node:os'
import process from 'node:process'
import { Server } from '@modelcontextprotocol/server'
import { createHostContext } from 'devframe/node'
+import { toAgentToolName } from 'devframe/utils/agent-tool-name'
import { join } from 'pathe'
import { diagnostics } from '../../node/diagnostics'
import { formatMcpError, stringifyForMcp } from './stringify'
@@ -63,7 +64,7 @@ export function buildMcpServerFromContext(
},
)
- registerToolHandlers(server, ctx)
+ registerToolHandlers(server, ctx, options.exposeSharedState)
registerResourceHandlers(server, ctx, options.exposeSharedState)
const notify = (method: string): void => {
@@ -146,20 +147,120 @@ export async function createMcpServer(
}
}
-function registerToolHandlers(server: Server, ctx: DevframeNodeContext): void {
+/**
+ * Id of the built-in shared-state read tool — namespaced like every other
+ * built-in (`devframe::`). Tool-shaped access matters because many
+ * MCP clients only consume tools — the parallel `devframe://state/`
+ * resource projection stays for the clients that do read resources.
+ */
+const READ_STATE_TOOL = 'devframe:state:read'
+/** Wire name of the built-in shared-state read tool: `devframe_state_read`. */
+const READ_STATE_NAME = toAgentToolName(READ_STATE_TOOL)
+
+function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolean)): ((key: string) => boolean) | undefined {
+ if (exposeSharedState === false)
+ return undefined
+ return typeof exposeSharedState === 'function' ? exposeSharedState : () => true
+}
+
+function readStateToolProjection(): Tool {
+ return {
+ name: READ_STATE_NAME,
+ title: 'Read shared state',
+ description: 'Read this devtool\'s live shared state. Call without arguments to list the available keys, then with a key to get that value as JSON. Safe to call freely.',
+ inputSchema: {
+ 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,
+ },
+ } as Tool
+}
+
+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 diagnostics.DF0048({ key })
+ 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)
+ const warnedCollisions = new Set()
+
+ /**
+ * Resolve a wire tool name back to the registered {@link AgentTool}.
+ * Wire-name matching runs first, in manifest order — the same tool the
+ * list projection advertises under that name — with a raw-id fallback so
+ * a colon-namespaced id keeps working as a call name.
+ */
+ const resolveTool = (name: string): AgentTool | undefined => {
+ const byWireName = ctx.agent.list().tools.find(tool => toAgentToolName(tool.id) === name)
+ return byWireName ?? ctx.agent.getTool(name)
+ }
+
server.setRequestHandler('tools/list', async () => {
- const tools = ctx.agent.list().tools.map(tool => projectTool(tool, ctx))
+ // Two ids may sanitize to the same wire name — first registration wins
+ // and later ones are hidden with a coded warning (once per name).
+ const byName = new Map()
+ for (const tool of ctx.agent.list().tools) {
+ const name = toAgentToolName(tool.id)
+ const existing = byName.get(name)
+ if (existing) {
+ if (!warnedCollisions.has(`${name}|${tool.id}`)) {
+ warnedCollisions.add(`${name}|${tool.id}`)
+ diagnostics.DF0047({ name, id: tool.id, existing: existing.id })
+ }
+ continue
+ }
+ byName.set(name, tool)
+ }
+ const tools = [...byName.entries()].map(([name, tool]) => projectTool(name, tool, ctx))
+ // A registered agent tool projecting to the same wire name wins over
+ // the built-in.
+ if (stateFilter && !byName.has(READ_STATE_NAME))
+ tools.push(readStateToolProjection())
return { tools }
})
server.setRequestHandler('tools/call', async (request) => {
const { name, arguments: args } = request.params
try {
- const tool = ctx.agent.getTool(name)
+ const tool = resolveTool(name)
+ // Built-in shared-state read. A registered agent tool resolving to
+ // the same wire name wins (mirroring the list projection above) —
+ // ids are namespaced, so a collision is a deliberate override.
+ if (stateFilter && !tool && (name === READ_STATE_NAME || name === READ_STATE_TOOL)) {
+ const key = (args as { key?: string } | undefined)?.key
+ const result = await readStateResult(ctx, stateFilter, key)
+ return {
+ content: [{ type: 'text', text: stringifyForMcp(result) }],
+ structuredContent: result as Record,
+ }
+ }
const outputSchema = tool
- ? tool.outputSchema ?? computeOutputSchema(tool, ctx)
+ ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx))
: undefined
- const result = await ctx.agent.invoke(name, args ?? {})
+ const result = await ctx.agent.invoke(tool?.id ?? name, args ?? {})
return {
content: [
{
@@ -248,11 +349,23 @@ function registerResourceHandlers(
})
}
-function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Tool {
+/**
+ * MCP constrains a tool's `outputSchema` to a JSON Schema of `type:
+ * "object"` — clients (the SDK included) reject anything else. Non-object
+ * return schemas (e.g. a schema for `void` / a bare string) simply project
+ * no output schema; the text content still carries the result.
+ */
+function usableOutputSchema(schema: unknown): unknown {
+ return schema && typeof schema === 'object' && (schema as { type?: unknown }).type === 'object'
+ ? schema
+ : undefined
+}
+
+function projectTool(name: string, tool: AgentTool, ctx: DevframeNodeContext): Tool {
const inputSchema = tool.inputSchema ?? computeInputSchema(tool, ctx)
- const outputSchema = tool.outputSchema ?? computeOutputSchema(tool, ctx)
+ const outputSchema = usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx))
return {
- name: tool.id,
+ name,
title: tool.title,
description: tool.description,
inputSchema,
@@ -266,6 +379,8 @@ function projectTool(tool: AgentTool, ctx: DevframeNodeContext): Tool {
}
function computeInputSchema(tool: AgentTool, ctx: DevframeNodeContext): unknown {
+ if (tool.kind === 'tool')
+ return argsToJsonSchema(tool.args).schema
if (tool.kind !== 'rpc' || !tool.rpcName)
return { type: 'object', properties: {} }
const def = ctx.rpc.definitions.get(tool.rpcName) as RpcFunctionDefinitionAnyWithContext | undefined
diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts
new file mode 100644
index 00000000..e192c185
--- /dev/null
+++ b/packages/devframe/src/adapters/mcp/fetch.ts
@@ -0,0 +1,167 @@
+import type { DevframeNodeContext } from 'devframe/types'
+import { randomUUID } from 'node:crypto'
+import { isInitializeRequest, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'
+import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server'
+import { buildMcpServerFromContext } from './build-server'
+
+export interface CreateMcpFetchHandlerOptions {
+ /** Name reported in the MCP handshake. */
+ serverName: string
+ /** Version reported in the MCP handshake. */
+ serverVersion: string
+ /** Expose shared-state keys as MCP resources — see `buildMcpServerFromContext`. */
+ exposeSharedState: boolean | ((key: string) => boolean)
+ /**
+ * Origin allow-list beyond the loopback default. `false` disables the
+ * origin gate entirely. Default: loopback-only (mirrors the WS transport).
+ */
+ allowedOrigins?: readonly string[] | false
+}
+
+export interface McpFetchHandler {
+ /**
+ * WHATWG-`fetch` handler for the MCP Streamable-HTTP endpoint. Hand every
+ * method (POST/GET/DELETE) on the endpoint's path to it — routing by path
+ * is the host's job.
+ */
+ fetch: (request: Request) => Promise
+ /** Tear down every live MCP session (closes servers, drops subscriptions). */
+ dispose: () => Promise
+}
+
+interface McpSession {
+ transport: WebStandardStreamableHTTPServerTransport
+ dispose: () => Promise
+}
+
+/**
+ * Build a framework-agnostic MCP Streamable-HTTP endpoint over a devframe
+ * context: a web-standard `Request → Response` handler any host can mount —
+ * h3 (see `mountMcpHttp`), a Next.js App Router route, or any other
+ * fetch-shaped server.
+ *
+ * Each MCP session gets its own {@link WebStandardStreamableHTTPServerTransport}
+ * and MCP server (built from the shared, live `ctx` via
+ * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: an
+ * `initialize` POST spins up a session; later requests route to it; a `DELETE`
+ * (or client disconnect) tears it down. The origin gate applies devframe's
+ * loopback-default DNS-rebinding protection (identical semantics to the WS
+ * upgrade's `isAllowedOrigin`).
+ *
+ * @experimental
+ */
+export function createMcpFetchHandler(
+ ctx: DevframeNodeContext,
+ options: CreateMcpFetchHandlerOptions,
+): McpFetchHandler {
+ const sessions = new Map()
+ const allowedOrigins = options.allowedOrigins
+
+ function drop(sessionId: string): void {
+ const session = sessions.get(sessionId)
+ if (!session)
+ return
+ sessions.delete(sessionId)
+ void session.dispose()
+ }
+
+ async function createSession(): Promise {
+ // Declared up front so the transport's session callbacks can capture it;
+ // it's assigned before any of them can fire (they run during
+ // `handleRequest`, after `connect` below).
+ let session!: McpSession
+
+ const transport = new WebStandardStreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: (id) => {
+ sessions.set(id, session)
+ },
+ onsessionclosed: (id) => {
+ drop(id)
+ },
+ })
+
+ const { server, dispose } = buildMcpServerFromContext(ctx, {
+ serverName: options.serverName,
+ serverVersion: options.serverVersion,
+ exposeSharedState: options.exposeSharedState,
+ })
+
+ session = {
+ transport,
+ dispose: async () => {
+ dispose()
+ await server.close()
+ },
+ }
+
+ transport.onclose = () => {
+ if (transport.sessionId)
+ drop(transport.sessionId)
+ }
+
+ await server.connect(transport)
+ return session
+ }
+
+ async function handle(req: Request): Promise {
+ // Origin gate — identical semantics to the WS upgrade's `isAllowedOrigin`
+ // (loopback + `Origin`-less native clients + the configured allow-list).
+ // This is the endpoint's DNS-rebinding protection.
+ const origin = req.headers.get('origin') ?? undefined
+ if (allowedOrigins !== false && !isAllowedOrigin(origin, allowedOrigins ?? []))
+ return new Response('Forbidden: origin not allowed', { status: 403 })
+
+ const sessionId = req.headers.get('mcp-session-id') ?? undefined
+ let session = sessionId ? sessions.get(sessionId) : undefined
+
+ // A POST may carry an `initialize` request that opens a brand-new
+ // session. Parse the body once and hand it to the transport as
+ // `parsedBody` (the web Request body can only be consumed once).
+ if (!session && req.method === 'POST') {
+ let body: unknown
+ try {
+ body = await req.json()
+ }
+ catch {
+ body = undefined
+ }
+
+ if (!sessionId && isInitializeRequest(body)) {
+ session = await createSession()
+ }
+ else {
+ return new Response(
+ sessionId
+ ? 'Not Found: unknown MCP session'
+ : 'Bad Request: no valid session ID and not an initialize request',
+ { status: sessionId ? 404 : 400 },
+ )
+ }
+
+ return session.transport.handleRequest(req, { parsedBody: body })
+ }
+
+ if (!session) {
+ // GET (open the SSE stream) / DELETE (end the session) require a
+ // known session id.
+ return new Response(
+ sessionId
+ ? 'Not Found: unknown MCP session'
+ : 'Bad Request: missing MCP session ID',
+ { status: sessionId ? 404 : 400 },
+ )
+ }
+
+ return session.transport.handleRequest(req)
+ }
+
+ return {
+ fetch: handle,
+ dispose: async () => {
+ const live = [...sessions.values()]
+ sessions.clear()
+ await Promise.all(live.map(session => session.dispose()))
+ },
+ }
+}
diff --git a/packages/devframe/src/adapters/mcp/http.ts b/packages/devframe/src/adapters/mcp/http.ts
index 4ad2022f..89c5e464 100644
--- a/packages/devframe/src/adapters/mcp/http.ts
+++ b/packages/devframe/src/adapters/mcp/http.ts
@@ -1,48 +1,25 @@
import type { DevframeNodeContext } from 'devframe/types'
import type { H3, H3Event } from 'h3'
-import { randomUUID } from 'node:crypto'
-import { isInitializeRequest, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'
-import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server'
+import type { CreateMcpFetchHandlerOptions } from './fetch'
import { defineHandler } from 'h3'
-import { buildMcpServerFromContext } from './build-server'
+import { createMcpFetchHandler } from './fetch'
-export interface MountMcpHttpOptions {
- /** Name reported in the MCP handshake. */
- serverName: string
- /** Version reported in the MCP handshake. */
- serverVersion: string
- /** Expose shared-state keys as MCP resources — see `buildMcpServerFromContext`. */
- exposeSharedState: boolean | ((key: string) => boolean)
- /**
- * Origin allow-list beyond the loopback default. `false` disables the
- * origin gate entirely. Default: loopback-only (mirrors the WS transport).
- */
- allowedOrigins?: readonly string[] | false
-}
+export interface MountMcpHttpOptions extends CreateMcpFetchHandlerOptions {}
export interface MountedMcpHttp {
/** Tear down every live MCP session (closes servers, drops subscriptions). */
dispose: () => Promise
}
-interface McpSession {
- transport: WebStandardStreamableHTTPServerTransport
- dispose: () => Promise
-}
-
/**
- * Mount an MCP Streamable-HTTP endpoint on an h3 app at `path`.
- *
- * Each MCP session gets its own {@link WebStandardStreamableHTTPServerTransport}
- * and MCP server (built from the shared, live `ctx` via
- * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header:
- * an `initialize` POST spins up a session; later requests route to it; a
- * `DELETE` (or client disconnect) tears it down.
+ * Mount an MCP Streamable-HTTP endpoint on an h3 app at `path` — the h3
+ * binding over {@link createMcpFetchHandler}, which owns the sessions, the
+ * origin gate, and the transport plumbing.
*
- * The transport is web-standard — its `handleRequest` takes the h3 event's
- * web `Request` and returns a web `Response` (an SSE `ReadableStream` body
- * for the server→client stream). We copy that response onto `event.res` and
- * return its body rather than returning the `Response` object directly, so a
+ * The handler is web-standard — it takes the h3 event's web `Request` and
+ * returns a web `Response` (an SSE `ReadableStream` body for the
+ * server→client stream). We copy that response onto `event.res` and return
+ * its body rather than returning the `Response` object directly, so a
* legitimate MCP 404 (unknown session) isn't swallowed by h3's
* "Response-with-404 falls through to the next handler" rule (which would
* otherwise hand the request to the SPA static catch-all).
@@ -55,114 +32,12 @@ export function mountMcpHttp(
path: string,
options: MountMcpHttpOptions,
): MountedMcpHttp {
- const sessions = new Map()
- const allowedOrigins = options.allowedOrigins
-
- function drop(sessionId: string): void {
- const session = sessions.get(sessionId)
- if (!session)
- return
- sessions.delete(sessionId)
- void session.dispose()
- }
-
- async function createSession(): Promise {
- // Declared up front so the transport's session callbacks can capture it;
- // it's assigned before any of them can fire (they run during
- // `handleRequest`, after `connect` below).
- let session!: McpSession
-
- const transport = new WebStandardStreamableHTTPServerTransport({
- sessionIdGenerator: () => randomUUID(),
- onsessioninitialized: (id) => {
- sessions.set(id, session)
- },
- onsessionclosed: (id) => {
- drop(id)
- },
- })
-
- const { server, dispose } = buildMcpServerFromContext(ctx, {
- serverName: options.serverName,
- serverVersion: options.serverVersion,
- exposeSharedState: options.exposeSharedState,
- })
-
- session = {
- transport,
- dispose: async () => {
- dispose()
- await server.close()
- },
- }
-
- transport.onclose = () => {
- if (transport.sessionId)
- drop(transport.sessionId)
- }
-
- await server.connect(transport)
- return session
- }
-
- app.use(path, defineHandler(async (event) => {
- const req = event.req
-
- // Origin gate — identical semantics to the WS upgrade's `isAllowedOrigin`
- // (loopback + `Origin`-less native clients + the configured allow-list).
- // This is the endpoint's DNS-rebinding protection.
- const origin = req.headers.get('origin') ?? undefined
- if (allowedOrigins !== false && !isAllowedOrigin(origin, allowedOrigins ?? [])) {
- event.res.status = 403
- return 'Forbidden: origin not allowed'
- }
-
- const sessionId = req.headers.get('mcp-session-id') ?? undefined
- let session = sessionId ? sessions.get(sessionId) : undefined
-
- // A POST may carry an `initialize` request that opens a brand-new
- // session. Parse the body once and hand it to the transport as
- // `parsedBody` (the web Request body can only be consumed once).
- if (!session && req.method === 'POST') {
- let body: unknown
- try {
- body = await req.json()
- }
- catch {
- body = undefined
- }
-
- if (!sessionId && isInitializeRequest(body)) {
- session = await createSession()
- }
- else {
- event.res.status = sessionId ? 404 : 400
- return sessionId
- ? 'Not Found: unknown MCP session'
- : 'Bad Request: no valid session ID and not an initialize request'
- }
-
- return respond(event, await session.transport.handleRequest(req, { parsedBody: body }))
- }
-
- if (!session) {
- // GET (open the SSE stream) / DELETE (end the session) require a
- // known session id.
- event.res.status = sessionId ? 404 : 400
- return sessionId
- ? 'Not Found: unknown MCP session'
- : 'Bad Request: missing MCP session ID'
- }
+ const handler = createMcpFetchHandler(ctx, options)
- return respond(event, await session.transport.handleRequest(req))
- }))
+ app.use(path, defineHandler(async event => respond(event, await handler.fetch(event.req))))
return {
- dispose: async () => {
- const live = [...sessions.values()]
- sessions.clear()
- await Promise.all(live.map(session => session.dispose()))
- },
+ dispose: handler.dispose,
}
}
diff --git a/packages/devframe/src/adapters/mcp/index.ts b/packages/devframe/src/adapters/mcp/index.ts
index 1623eff9..485e083e 100644
--- a/packages/devframe/src/adapters/mcp/index.ts
+++ b/packages/devframe/src/adapters/mcp/index.ts
@@ -17,3 +17,9 @@ export {
type CreateMcpServerOptions,
type McpServerHandle,
} from './build-server'
+
+export {
+ createMcpFetchHandler,
+ type CreateMcpFetchHandlerOptions,
+ type McpFetchHandler,
+} from './fetch'
diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts
new file mode 100644
index 00000000..0bd96a18
--- /dev/null
+++ b/packages/devframe/src/cli/connect.ts
@@ -0,0 +1,317 @@
+import type { Tool } from '@modelcontextprotocol/server'
+import type { DevframeInstanceRecord } from '../node/instance-registry'
+import process from 'node:process'
+import { toAgentToolName } from 'devframe/utils/agent-tool-name'
+import { Diagnostic } from 'nostics'
+import { joinURL } from 'ufo'
+import { diagnostics } from '../node/diagnostics'
+import { listLiveDevframeInstances, probeDevframeOrigin } 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
+}
+
+/** One discovered instance in the `list-instances` payload: the registry record plus its probed MCP surface. */
+interface IndexedInstance extends Omit {
+ mcp: {
+ url: string
+ tools?: { name: string, description?: string }[]
+ error?: string
+ } | null
+ hint?: string
+}
+
+/** The lazily imported MCP SDK surface `devframe connect` needs. */
+interface ConnectSdk {
+ Server: typeof import('@modelcontextprotocol/server').Server
+ StdioServerTransport: typeof import('@modelcontextprotocol/server/stdio').StdioServerTransport
+ Client: typeof import('@modelcontextprotocol/client').Client
+ StreamableHTTPClientTransport: typeof import('@modelcontextprotocol/client').StreamableHTTPClientTransport
+}
+
+// Gateway tool ids follow the `devframe::` convention; the wire
+// names are their sanitized forms (`devframe_connect_list-instances`, …).
+const INDEX_TOOL = toAgentToolName('devframe:connect:list-instances')
+const CALL_TOOL = toAgentToolName('devframe:connect:call-tool')
+
+const MCP_DISABLED_HINT
+ = 'This instance runs without an MCP route. Restart it with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.'
+
+const GATEWAY_TOOLS: Tool[] = [
+ {
+ name: INDEX_TOOL,
+ title: 'Discover running devframes',
+ description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available — the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.',
+ inputSchema: { type: 'object', properties: {} },
+ annotations: { readOnlyHint: true, destructiveHint: false },
+ },
+ {
+ name: CALL_TOOL,
+ title: 'Call a devframe tool',
+ description: 'Invoke one MCP tool on one running devframe instance discovered via the list-instances tool. Pass the instance\'s port, the tool name, and the tool\'s arguments object.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ port: { type: 'number', description: 'The instance\'s port, from the list-instances tool.' },
+ tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' },
+ args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' },
+ },
+ required: ['port', 'tool'],
+ additionalProperties: false,
+ },
+ },
+]
+
+/**
+ * Start the devframe MCP connector on stdio: a thin discovery + proxy server
+ * in the shape Vercel's next-devtools-mcp (https://github.com/vercel/next-devtools-mcp)
+ * validated — credit due there for the architecture this connector follows.
+ * 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('tools/list', async () => ({ tools: GATEWAY_TOOLS }))
+
+ server.setRequestHandler('tools/call', async (request: any) => {
+ const { name, arguments: args } = request.params
+ try {
+ if (name === INDEX_TOOL)
+ return textResult(await index(sdk, options))
+ if (name === CALL_TOOL)
+ return textResult(await call(sdk, options, args ?? {}))
+ return errorResult({ message: `unknown tool "${name}"`, fix: `Call ${INDEX_TOOL} or ${CALL_TOOL}.` })
+ }
+ catch (error) {
+ return errorResult(toErrorPayload(error))
+ }
+ })
+
+ const transport = new sdk.StdioServerTransport()
+ await server.connect(transport)
+
+ return {
+ stop: async () => {
+ await server.close()
+ },
+ }
+}
+
+async function importSdk(): Promise {
+ try {
+ const [serverMod, stdioMod, clientMod] = await Promise.all([
+ import('@modelcontextprotocol/server'),
+ import('@modelcontextprotocol/server/stdio'),
+ import('@modelcontextprotocol/client'),
+ ])
+ return {
+ Server: serverMod.Server,
+ StdioServerTransport: stdioMod.StdioServerTransport,
+ Client: clientMod.Client,
+ StreamableHTTPClientTransport: clientMod.StreamableHTTPClientTransport,
+ }
+ }
+ catch (error) {
+ const reason = error instanceof Error ? error.message : String(error)
+ throw diagnostics.DF0046({ reason, cause: error })
+ }
+}
+
+/** Discover instances: registry (prune-on-read) + explicit port probes. */
+async function index(sdk: ConnectSdk, 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 { mcp, ...rest } = record
+ const entry: IndexedInstance = { ...rest, mcp: null }
+ if (!mcp) {
+ entry.hint = MCP_DISABLED_HINT
+ return entry
+ }
+ const url = `${record.origin}${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 `/`,
+ * reusing the registry's origin-candidate probe (a `localhost`-bound server
+ * may listen on either address family).
+ */
+async function probePort(port: number, timeoutMs?: number): Promise {
+ const probed = await probeDevframeOrigin(`http://localhost:${port}`, '/', timeoutMs)
+ if (!probed)
+ return null
+ const mcpPath = probed.meta.mcp ? joinURL('/', probed.meta.mcp.path) : null
+ return {
+ pid: -1,
+ port,
+ origin: probed.origin,
+ basePath: '/',
+ id: `port-${port}`,
+ rootDir: '',
+ mcp: mcpPath ? { path: mcpPath } : null,
+ startedAt: 0,
+ }
+}
+
+async function listInstanceTools(sdk: ConnectSdk, url: string): Promise<{ name: string, description?: string }[]> {
+ return withInstanceClient(sdk, url, async (client) => {
+ const listed = await client.listTools()
+ return listed.tools.map((tool: { name: string, description?: string }) => ({
+ name: tool.name,
+ description: tool.description,
+ }))
+ })
+}
+
+async function call(
+ sdk: ConnectSdk,
+ options: ConnectServerOptions,
+ args: { port?: number, tool?: string, args?: Record },
+): Promise {
+ if (typeof args.port !== 'number' || typeof args.tool !== 'string')
+ throw diagnostics.DF0049()
+
+ const { live } = await listLiveDevframeInstances({
+ instancesDir: options.instancesDir,
+ timeoutMs: options.timeoutMs,
+ })
+ const record = live.find(r => r.port === args.port) ?? await probePort(args.port, options.timeoutMs)
+ if (!record)
+ throw diagnostics.DF0050({ port: args.port })
+ if (!record.mcp)
+ throw diagnostics.DF0051({ port: args.port })
+
+ const url = `${record.origin}${record.mcp.path}`
+ return withInstanceClient(sdk, url, async (client) => {
+ const result = await client.callTool({ name: args.tool!, arguments: args.args ?? {} })
+ 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: ConnectSdk,
+ url: string,
+ fn: (client: InstanceType) => Promise,
+): Promise {
+ const transport = new sdk.StreamableHTTPClientTransport(new URL(url))
+ const client = new sdk.Client({ name: 'devframe-connect', version: '0.0.0' })
+ await client.connect(transport)
+ 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) }] }
+}
+
+interface ConnectErrorPayload {
+ code?: string
+ message: string
+ fix?: string
+ docs?: string
+}
+
+/**
+ * Project a thrown value into the connector's structured error payload. A
+ * nostics `Diagnostic` carries its code, `fix`, and docs URL across so the
+ * calling agent gets the actionable next step.
+ */
+function toErrorPayload(error: unknown): ConnectErrorPayload {
+ if (error instanceof Diagnostic) {
+ return {
+ code: error.code,
+ message: error.message,
+ ...(error.fix ? { fix: error.fix } : {}),
+ ...(error.docs ? { docs: error.docs } : {}),
+ }
+ }
+ return {
+ message: error instanceof Error ? error.message : String(error),
+ ...(error && typeof error === 'object' && 'fix' in error && typeof error.fix === 'string' ? { fix: error.fix } : {}),
+ }
+}
+
+function errorResult(error: ConnectErrorPayload): {
+ isError: true
+ content: { type: 'text', text: string }[]
+} {
+ 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.test.ts b/packages/devframe/src/cli/main.test.ts
new file mode 100644
index 00000000..dc07a4f2
--- /dev/null
+++ b/packages/devframe/src/cli/main.test.ts
@@ -0,0 +1,37 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { runDevframeCli } from './main'
+
+describe('runDevframeCli', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('shows help for a bare invocation (no subcommand)', async () => {
+ const info = vi.spyOn(console, 'info').mockImplementation(() => {})
+ await runDevframeCli(['node', 'devframe'])
+ expect(info).toHaveBeenCalledTimes(1)
+ expect(info.mock.calls[0]![0]).toContain('connect')
+ })
+
+ it('shows help exactly once for --help (not doubled by the bare-invocation fallback)', async () => {
+ const info = vi.spyOn(console, 'info').mockImplementation(() => {})
+ await runDevframeCli(['node', 'devframe', '--help'])
+ expect(info).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows help for an unrecognized subcommand', async () => {
+ const info = vi.spyOn(console, 'info').mockImplementation(() => {})
+ await runDevframeCli(['node', 'devframe', 'bogus'])
+ expect(info).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not show help when a real subcommand matches', async () => {
+ const info = vi.spyOn(console, 'info').mockImplementation(() => {})
+ // `connect --help` matches the `connect` command and prints *its* help
+ // (cac's built-in per-command path) rather than the bare-invocation
+ // fallback — still exactly once.
+ await runDevframeCli(['node', 'devframe', 'connect', '--help'])
+ expect(info).toHaveBeenCalledTimes(1)
+ expect(info.mock.calls[0]![0]).toContain('--port')
+ })
+})
diff --git a/packages/devframe/src/cli/main.ts b/packages/devframe/src/cli/main.ts
new file mode 100644
index 00000000..2cf83d82
--- /dev/null
+++ b/packages/devframe/src/cli/main.ts
@@ -0,0 +1,41 @@
+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 })
+ // A bare `devframe` (no subcommand) also leaves `matchedCommand` unset —
+ // same as `-h`/`--help`, which cac already prints help for internally.
+ // Only step in for the *other* unset case (no help flag, no command) so
+ // `--help` doesn't print twice.
+ if (!cli.matchedCommand) {
+ if (!cli.options.help)
+ cli.outputHelp()
+ return
+ }
+ await cli.runMatchedCommand()
+}
diff --git a/packages/devframe/src/node/__tests__/agent-args.test.ts b/packages/devframe/src/node/__tests__/agent-args.test.ts
new file mode 100644
index 00000000..fdde8e5a
--- /dev/null
+++ b/packages/devframe/src/node/__tests__/agent-args.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { coerceAgentPositionalArgs } from '../agent-args'
+
+describe('coerceAgentPositionalArgs', () => {
+ const schema = {} as unknown
+
+ it('passes arrays through and maps argN keys onto declared schemas', () => {
+ expect(coerceAgentPositionalArgs([1, 2], [schema, schema])).toEqual([1, 2])
+ expect(coerceAgentPositionalArgs({ arg0: 'a', arg1: 'b' }, [schema, schema])).toEqual(['a', 'b'])
+ })
+
+ it('collects argN keys even without schemas', () => {
+ expect(coerceAgentPositionalArgs({ arg0: 1, arg1: 2 }, undefined)).toEqual([1, 2])
+ })
+
+ it('treats null/undefined and empty objects as zero-argument calls', () => {
+ expect(coerceAgentPositionalArgs(undefined, undefined)).toEqual([])
+ expect(coerceAgentPositionalArgs(null, [schema])).toEqual([])
+ expect(coerceAgentPositionalArgs({}, undefined)).toEqual([])
+ })
+
+ it('follows the fallback for undeclared object payload', () => {
+ const payload = { name: 'devframe' }
+ // RPC-backed tools: an untyped RPC may take one raw object.
+ expect(coerceAgentPositionalArgs(payload, undefined, 'wrap')).toEqual([payload])
+ // Command-backed tools: positional params come solely from declared schemas.
+ expect(coerceAgentPositionalArgs(payload, undefined, 'drop')).toEqual([])
+ })
+})
diff --git a/packages/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts
index 90379e23..5b94babe 100644
--- a/packages/devframe/src/node/__tests__/host-agent.test.ts
+++ b/packages/devframe/src/node/__tests__/host-agent.test.ts
@@ -269,4 +269,94 @@ describe('devToolsAgentHost', () => {
await expect(ctx.agent.read('ghost')).rejects.toThrow(/ghost/)
})
})
+
+ describe('standard schema args on tool inputs', () => {
+ it('carries args raw on the projected tool — conversion is deferred to protocol adapters', async () => {
+ const v = await import('valibot')
+ const ctx = createContext()
+ const schema = v.object({ name: v.optional(v.string()) })
+ ctx.agent.registerTool({
+ id: 'schema:tool',
+ description: 'Schema-typed.',
+ args: [schema],
+ handler: args => args,
+ })
+
+ const tool = ctx.agent.getTool('schema:tool')!
+ // Mirrors how an RPC-backed tool defers to `ctx.rpc.definitions` — the
+ // agent host itself never converts Standard Schema → JSON Schema (that
+ // stays a protocol-adapter concern, e.g. the MCP adapter), so no
+ // eager `inputSchema` is computed here.
+ expect(tool.inputSchema).toBeUndefined()
+ expect(tool.args).toEqual([schema])
+ })
+
+ it('an explicit inputSchema override wins over args', async () => {
+ 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/agent-args.ts b/packages/devframe/src/node/agent-args.ts
new file mode 100644
index 00000000..44022b51
--- /dev/null
+++ b/packages/devframe/src/node/agent-args.ts
@@ -0,0 +1,55 @@
+/**
+ * How {@link coerceAgentPositionalArgs} treats an args object that carries
+ * neither declared schemas nor `arg0`/`arg1`/… keys:
+ *
+ * - `'wrap'` — pass the object itself as the single positional argument.
+ * RPC-backed tools use this: an untyped RPC may take one raw object.
+ * - `'drop'` — call with zero arguments. Command-backed tools use this:
+ * a handler's positional parameters come solely from its declared
+ * `agent.args` schemas, so undeclared payload is ignored.
+ */
+export type AgentArgsFallback = 'wrap' | 'drop'
+
+/**
+ * Map the args payload an agent surface receives (MCP sends an object
+ * keyed `arg0`/`arg1`/…, matching the schema the adapter advertises) onto
+ * a handler's positional parameters. Shared by the agent host's RPC
+ * bridge and the hub's command-derived tools so the coercion cannot
+ * drift between them.
+ *
+ * - an array passes through as-is
+ * - `null`/`undefined` become a zero-argument call
+ * - with declared schemas, each schema reads its own `argN` key, in order
+ * - without schemas, `arg0`/`arg1`/… keys are collected when present
+ * - an empty object becomes a zero-argument call
+ * - anything else follows the {@link AgentArgsFallback}
+ *
+ * @experimental
+ */
+export function coerceAgentPositionalArgs(
+ args: unknown,
+ schemas: readonly unknown[] | undefined,
+ fallback: AgentArgsFallback = 'wrap',
+): unknown[] {
+ if (Array.isArray(args))
+ return args
+ if (args === undefined || args === null)
+ return []
+ if (typeof args === 'object') {
+ const obj = args as Record
+ if (schemas && schemas.length)
+ return schemas.map((_, i) => obj[`arg${i}`])
+ if ('arg0' in obj) {
+ const out: unknown[] = []
+ let i = 0
+ while (`arg${i}` in obj) {
+ out.push(obj[`arg${i}`])
+ i++
+ }
+ return out
+ }
+ if (Object.keys(obj).length === 0)
+ return []
+ }
+ return fallback === 'drop' ? [] : [args]
+}
diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts
index 000247ea..f28dfc6e 100644
--- a/packages/devframe/src/node/diagnostics.ts
+++ b/packages/devframe/src/node/diagnostics.ts
@@ -1,6 +1,9 @@
import { defineDiagnostics } from 'nostics'
import { devframeReporter } from '../utils/diagnostics-reporter'
+// DF00xx codes are allocated across packages (e.g. @devframes/json-render
+// owns DF0037–DF0041), so this file alone doesn't show the next free
+// number — check `docs/errors/` for the full allocation before adding one.
export const diagnostics = defineDiagnostics({
docsBase: 'https://devfra.me/errors',
reporters: [devframeReporter],
@@ -80,5 +83,34 @@ export const diagnostics = defineDiagnostics({
why: (p: { id: string }) => `"${p.id}" declares \`capabilities.build: false\` — its static export is not meaningful (writes are excluded and any live-served data won't be there).`,
fix: 'Pass `{ force: true }` to `createBuild()` if the degraded export is still useful to you, or drop `capabilities.build: false` on the definition.',
},
+ DF0045: {
+ why: (p: { file: string, reason: string }) => `Failed to update the devframe instance registry at "${p.file}": ${p.reason}`,
+ fix: 'Discovery tooling (`devframe connect`) will not see this instance. Check that the registry directory is writable, point `DEVFRAME_INSTANCES_DIR` at a writable directory, or set `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` to opt out of registration.',
+ },
+ DF0046: {
+ why: (p: { reason: string }) => `\`devframe connect\` requires the optional peer dependency @modelcontextprotocol/server: ${p.reason}`,
+ fix: 'Install it next to devframe (e.g. `npm install @modelcontextprotocol/server`) and run `devframe connect` again.',
+ },
+ DF0047: {
+ why: (p: { name: string, id: string, existing: string }) =>
+ `Agent tool "${p.id}" is hidden from the MCP surface: its wire name "${p.name}" collides with the tool "${p.existing}".`,
+ fix: 'Wire names derive from tool ids (characters outside [a-zA-Z0-9_-] become "_"). Rename one of the two ids so they sanitize to distinct names.',
+ },
+ DF0048: {
+ why: (p: { key: string }) => `Unknown shared-state key "${p.key}".`,
+ fix: 'Call the devframe_state_read tool without arguments to list the available keys, then retry with one of them.',
+ },
+ DF0049: {
+ why: 'The devframe_connect_call-tool tool requires { port: number, tool: string }.',
+ fix: 'Call devframe_connect_list-instances to get the port and tool names, then retry.',
+ },
+ DF0050: {
+ why: (p: { port: number }) => `No running devframe instance on port ${p.port}.`,
+ fix: 'Call devframe_connect_list-instances for the current instance list — the instance may have stopped or changed port.',
+ },
+ DF0051: {
+ why: (p: { port: number }) => `The devframe instance on port ${p.port} has no MCP endpoint.`,
+ fix: 'Restart the instance with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.',
+ },
},
})
diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts
index 12245734..2c99a088 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 { coerceAgentPositionalArgs } from './agent-args'
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 {
@@ -132,10 +167,17 @@ export class DevframeAgentHost implements DevframeAgentHostType {
if (rpcDef) {
// RPC args are positional. Accept an object keyed by `arg0..argN`
// (what the MCP adapter sends after flattening), or a plain array.
- const positional = this._coercePositionalArgs(args, rpcDef)
+ // An untyped RPC may take a single raw object, so undeclared object
+ // payload wraps into one positional argument.
+ const positional = coerceAgentPositionalArgs(args, rpcDef.args as readonly unknown[] | undefined, 'wrap')
return await this.context.rpc.invokeLocal(id as any, ...(positional as any))
}
+ 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 +214,27 @@ export class DevframeAgentHost implements DevframeAgentHostType {
description: input.description,
safety: input.safety ?? 'action',
tags: input.tags,
+ // Standard Schema `args` are carried raw (mirroring how an RPC-backed
+ // tool defers to `ctx.rpc.definitions`) — consumers (the MCP adapter)
+ // convert to JSON Schema on demand. An explicit `inputSchema` override
+ // wins when given.
+ args: input.args,
inputSchema: input.inputSchema,
outputSchema: input.outputSchema,
examples: input.examples,
}
}
+ /** 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) {
@@ -212,33 +269,6 @@ export class DevframeAgentHost implements DevframeAgentHostType {
return def
return undefined
}
-
- private _coercePositionalArgs(
- args: unknown,
- def: RpcFunctionDefinitionAnyWithContext,
- ): unknown[] {
- if (Array.isArray(args))
- return args
- if (args === undefined || args === null)
- return []
- if (args && typeof args === 'object') {
- const obj = args as Record
- const schemas = def.args as readonly unknown[] | undefined
- if (schemas && schemas.length)
- return schemas.map((_, i) => obj[`arg${i}`])
- // Fallback: detect arg0/arg1/... keys even without schemas.
- if (hasPositionalKeys(obj)) {
- const out: unknown[] = []
- let i = 0
- while (`arg${i}` in obj) {
- out.push(obj[`arg${i}`])
- i++
- }
- return out
- }
- }
- return [args]
- }
}
function inferSafety(type: RpcFunctionType): 'read' | 'action' | 'destructive' {
@@ -246,7 +276,3 @@ function inferSafety(type: RpcFunctionType): 'read' | 'action' | 'destructive' {
return 'read'
return 'action'
}
-
-function hasPositionalKeys(obj: Record): boolean {
- return 'arg0' in obj
-}
diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts
index 69867f47..dcdf97ed 100644
--- a/packages/devframe/src/node/index.ts
+++ b/packages/devframe/src/node/index.ts
@@ -1,4 +1,8 @@
// Node-side public API for consumers that wire up their own runtime.
+// `toAgentToolName` lives at `devframe/utils/agent-tool-name` instead — a
+// plain string transform, client-safe (the inspect plugin's UI imports it
+// too), not node-specific.
+export * from './agent-args'
export * from './context'
export * from './host-agent'
export * from './host-diagnostics'
@@ -9,6 +13,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..6134949b
--- /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 'pathe'
+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..3e793337
--- /dev/null
+++ b/packages/devframe/src/node/instance-registry.ts
@@ -0,0 +1,298 @@
+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
+}
+
+// The env var names below are documented (READMEs, `docs/adapters/mcp.md`)
+// as plain strings a user sets — nothing needs to import the constant, so
+// they (and the read/probe helpers) stay internal to this module rather
+// than joining the `devframe/node` public surface; see the barrel comment
+// in `./index.ts`.
+
+/** Environment variable overriding the registry directory (tests, CI). */
+const DEVFRAME_INSTANCES_DIR_ENV = 'DEVFRAME_INSTANCES_DIR'
+/** Environment variable disabling instance registration entirely. */
+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`.
+ */
+function resolveInstancesDir(override?: string): string {
+ return override
+ ?? process.env[DEVFRAME_INSTANCES_DIR_ENV]
+ ?? join(homedir(), '.devframe', 'instances')
+}
+
+function isRegistryDisabled(): boolean {
+ const value = process.env[DEVFRAME_DISABLE_INSTANCE_REGISTRY_ENV]
+ return value === '1' || value === 'true'
+}
+
+/**
+ * Record a running devframe instance in the global instance registry so
+ * discovery tooling (`devframe connect`, editor integrations) can find it
+ * without port guessing.
+ *
+ * `createDevServer` registers automatically; custom hosts that serve a
+ * devframe in-process (e.g. `@devframes/next`'s host inside a Next dev
+ * server) call this explicitly with the origin they are reachable at.
+ *
+ * The record is written atomically to `/-.json` and removed
+ * by {@link DevframeInstanceRegistration.unregister}. Records surviving a
+ * crash are pruned by readers whose liveness probe fails. Registration never
+ * throws — a write failure degrades to a coded warning (`DF0045`), since a
+ * dev server must not die over discovery metadata.
+ *
+ * @experimental
+ */
+export function registerDevframeInstance(
+ record: DevframeInstanceRecord,
+ options: { instancesDir?: string } = {},
+): DevframeInstanceRegistration {
+ const dir = resolveInstancesDir(options.instancesDir)
+ const file = join(dir, `${record.pid}-${record.port}.json`)
+
+ if (!isRegistryDisabled()) {
+ try {
+ mkdirSync(dir, { recursive: true })
+ // Atomic publish: write a temp file *in the same directory* (a rename
+ // is only atomic — and only possible — within one filesystem), then
+ // rename into place.
+ const tmp = join(dir, `.${record.pid}-${record.port}.${Date.now()}.tmp`)
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`)
+ renameSync(tmp, file)
+ }
+ catch (error) {
+ diagnostics.DF0045({ file, reason: error instanceof Error ? error.message : String(error), cause: error })
+ }
+ }
+
+ return {
+ file,
+ unregister: () => {
+ try {
+ rmSync(file, { force: true })
+ }
+ catch (error) {
+ diagnostics.DF0045({ file, reason: error instanceof Error ? error.message : String(error), cause: error })
+ }
+ },
+ }
+}
+
+/**
+ * Read every record in the registry directory, dropping unparseable files.
+ * Liveness is the caller's concern — see {@link probeDevframeInstance}.
+ *
+ * @experimental
+ */
+export function readDevframeInstances(options: { instancesDir?: string } = {}): DevframeInstanceRecord[] {
+ const dir = resolveInstancesDir(options.instancesDir)
+ let files: string[]
+ try {
+ files = readdirSync(dir).filter(f => f.endsWith('.json'))
+ }
+ catch {
+ return []
+ }
+ const records: DevframeInstanceRecord[] = []
+ for (const file of files) {
+ try {
+ const parsed = JSON.parse(readFileSync(join(dir, file), 'utf8')) as DevframeInstanceRecord
+ if (typeof parsed?.origin === 'string' && typeof parsed?.pid === 'number')
+ records.push(parsed)
+ }
+ catch {
+ // Unparseable record (partial write from a crashed process) — skip;
+ // the prune pass below removes it once its liveness probe fails.
+ }
+ }
+ return records
+}
+
+/**
+ * Dialable-origin candidates for a recorded origin. A `localhost` bind is
+ * ambiguous — the server may listen on `127.0.0.1`, `::1`, or both, and
+ * HTTP clients differ in which family they try — so probe the explicit
+ * addresses too and adopt whichever answers.
+ */
+function originCandidates(origin: string): string[] {
+ try {
+ const url = new URL(origin)
+ if (url.hostname !== 'localhost')
+ return [origin]
+ const port = url.port ? `:${url.port}` : ''
+ return [
+ origin,
+ `${url.protocol}//127.0.0.1${port}`,
+ `${url.protocol}//[::1]${port}`,
+ ]
+ }
+ catch {
+ return [origin]
+ }
+}
+
+/**
+ * A successful `__connection.json` probe: the dialable origin that
+ * answered plus the parsed connection meta it served.
+ *
+ * @internal
+ */
+export interface ProbedDevframeOrigin {
+ /** The origin that answered (may be an explicit address family for a `localhost` bind). */
+ origin: string
+ /** The parsed `__connection.json` payload (`{}` when unparseable). */
+ meta: { mcp?: { path: string, port?: number } }
+}
+
+/**
+ * Probe `__connection.json`, trying each dialable
+ * candidate for the origin (see {@link originCandidates}). The single
+ * probe primitive behind both registry liveness checks and the
+ * connector's explicit `--port` probes.
+ *
+ * @internal
+ */
+export async function probeDevframeOrigin(
+ origin: string,
+ basePath: string,
+ timeoutMs?: number,
+): Promise {
+ const base = basePath.endsWith('/') ? basePath : `${basePath}/`
+ for (const candidate of originCandidates(origin)) {
+ try {
+ const response = await fetch(`${candidate}${base}__connection.json`, {
+ signal: AbortSignal.timeout(timeoutMs ?? 1000),
+ })
+ if (!response.ok)
+ continue
+ const meta = await response.json().catch(() => ({})) as ProbedDevframeOrigin['meta']
+ return { origin: candidate, meta }
+ }
+ catch {
+ // Try the next candidate.
+ }
+ }
+ return null
+}
+
+/**
+ * Probe a record's `__connection.json` to check the instance is alive.
+ * Returns the **dialable origin** that answered (for `localhost` records
+ * this may be an explicit `127.0.0.1` / `[::1]` origin), or `null` when
+ * unreachable.
+ */
+async function probeDevframeInstance(
+ record: DevframeInstanceRecord,
+ options: { timeoutMs?: number } = {},
+): Promise {
+ const probed = await probeDevframeOrigin(record.origin, record.basePath, options.timeoutMs)
+ return probed?.origin ?? 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 ed75147a..c227b6a2 100644
--- a/packages/devframe/src/rpc/types.ts
+++ b/packages/devframe/src/rpc/types.ts
@@ -313,11 +313,15 @@ export type RpcFunctionDefinition<
*/
agent?: RpcFunctionAgentOptions
/** Setup function called with context to initialize handler and dump */
- setup?: (context: CONTEXT) => Thenable, InferReturnType>>
- /** Function implementation (required if setup doesn't provide one) */
- handler?: (...args: InferArgsType) => InferReturnType
+ setup?: (context: CONTEXT) => Thenable, Thenable>>>
+ /**
+ * Function implementation (required if setup doesn't provide one).
+ * The declared `returns` schema describes the *resolved* value —
+ * async handlers return a promise of it (the runtime always awaits).
+ */
+ handler?: (...args: InferArgsType) => Thenable>
/** Dump definition (setup dump takes priority) */
- dump?: RpcDump, InferReturnType, CONTEXT>
+ dump?: RpcDump, Thenable>, CONTEXT>
/**
* Sugar for "query in dev, single baked snapshot in build": when
* `true` and no `dump` is provided, the build adapter runs the
@@ -328,9 +332,9 @@ export type RpcFunctionDefinition<
*/
snapshot?: boolean
/** Per-context setup-result cache, populated by `getRpcResolvedSetupResult`. @internal */
- __cache?: WeakMap