Skip to content

Commit cb79c08

Browse files
committed
feat(agent): agent-native wave phase 3 — instance registry, devframe connect, in-process Next MCP
- instance registry: registerDevframeInstance/readDevframeInstances/ probeDevframeInstance/listLiveDevframeInstances in devframe/node — atomic same-dir writes, prune-on-read, ghost dedup per (port, basePath), dialable-origin adoption for family-ambiguous localhost binds; createDevServer registers automatically and unregisters on close; DEVFRAME_INSTANCES_DIR / DEVFRAME_DISABLE_INSTANCE_REGISTRY overrides - first devframe bin: `devframe connect` runs the stdio MCP connector — devframe_index (discover instances + their tools, funnel hints for MCP-less servers) and devframe_call (proxy one tool call over Streamable-HTTP); errors carry actionable fix payloads; missing SDK peer throws coded DF0043 - @devframes/next: DevframeNextHost.mountMcp serves MCP in-process on the Next app's own origin (the /_next/mcp shape); hub example wires it, advertises it in connection meta, registers the instance, and agent-flags its ping command; catch-all route exports POST/DELETE - mcp adapter: drop non-object outputSchema projections (MCP requires type object; a v.void() returns schema broke SDK clients) - e2e: devframe-connect (files-inspector round-trip incl. gateway tool) and minimal-next-devframe-hub (in-process discovery + command call); hermetic per-suite registries; vitest keeps unit runs out of the global registry - diagnostics DF0042/DF0043 + docs pages; connect/registry docs in the MCP adapter page
1 parent b53a08d commit cb79c08

34 files changed

Lines changed: 1312 additions & 31 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ temp
1818
packages/devframe/skills
1919
test-results
2020
playwright-report
21+
tests/e2e/.registries
2122
playwright/.cache
2223
blob-report
2324
.ecosystem

docs/adapters/mcp.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,23 @@ const mcp = createMcpFetchHandler(ctx, {
7373
// route every method on /__mcp to mcp.fetch(request)
7474
```
7575

76+
## Discovery: `devframe connect`
77+
78+
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:
79+
80+
```json
81+
{
82+
"mcpServers": {
83+
"devframe": { "command": "npx", "args": ["devframe", "connect"] }
84+
}
85+
}
86+
```
87+
88+
It exposes two gateway tools:
89+
90+
- **`devframe_index`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`.
91+
- **`devframe_call`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint.
92+
93+
Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/<pid>-<port>.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 <n>` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out.
94+
7695
See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example.

docs/errors/DF0042.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0042: Instance Registry Update Failed
6+
7+
## Message
8+
9+
> Failed to update the devframe instance registry at "`{file}`": `{reason}`
10+
11+
## Cause
12+
13+
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.
14+
15+
## Fix
16+
17+
- Check that the registry directory is writable and the disk has free space.
18+
- Point `DEVFRAME_INSTANCES_DIR` at a writable directory.
19+
- Set `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` to opt out of registration entirely.
20+
21+
## Source
22+
23+
- [`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.

docs/errors/DF0043.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0043: Connector Requires the MCP SDK
6+
7+
## Message
8+
9+
> `devframe connect` requires the optional peer dependency @modelcontextprotocol/sdk: `{reason}`
10+
11+
## Cause
12+
13+
`devframe connect` was started but `@modelcontextprotocol/sdk` could not be imported. The SDK is an optional peer dependency of `devframe` — the MCP surface stays opt-in, so the SDK only needs to be installed where MCP features are used.
14+
15+
## Fix
16+
17+
Install the SDK next to devframe and run the connector again:
18+
19+
```sh
20+
npm install @modelcontextprotocol/sdk
21+
devframe connect
22+
```
23+
24+
## Source
25+
26+
- [`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.

docs/guide/agent-native.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,6 @@ Agents can act on `fix` directly and follow `docs` for detail — prefer throwin
176176

177177
| Command | Description |
178178
|---------|-------------|
179-
| `devframe mcp` | Start an MCP server on `stdio`. |
179+
| `<your-app> mcp` | Start your app's MCP server on `stdio` (from the `createCac` shell). |
180+
| `<your-app> dev --mcp` | Serve the agent surface on the dev server's `/__mcp` route. |
181+
| `devframe connect` | Run the app-independent MCP connector: discover running devframes and proxy their tools — see [MCP adapter](/adapters/mcp#discovery-devframe-connect). |

examples/files-inspector/src/devframe.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,27 @@ export default defineDevframe({
2222
// Single-user localhost demo — skip the trust handshake so the served
2323
// SPA can call RPC without an OTP round-trip.
2424
auth: false,
25+
// Serve the agent surface over the dev server's `/__mcp` route and
26+
// register the instance for `devframe connect` discovery.
27+
mcp: true,
2528
},
2629
spa: { loader: 'none' },
2730
setup(ctx) {
2831
// A scoped context auto-namespaces every registered id with `NAMESPACE:`.
2932
const my = ctx.scope(NAMESPACE)
3033
for (const fn of serverFunctions)
3134
my.rpc.register(fn)
35+
36+
// Gateway tool: returns the location of this tool's own docs instead of
37+
// proxying their content — the agent reads the files with its own tools.
38+
ctx.agent.registerTool({
39+
id: `${NAMESPACE}:docs`,
40+
description: 'Locate the Files Inspector\'s documentation on disk. Call before answering questions about how this tool works, then read the returned files directly.',
41+
safety: 'read',
42+
handler: () => ({
43+
readmePath: fileURLToPath(new URL('../README.md', import.meta.url)),
44+
hint: 'Read the file at readmePath with your own file tools; do not rely on training-data knowledge of this example.',
45+
}),
46+
})
3247
},
3348
})

examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ export const dynamic = 'force-dynamic'
55

66
/**
77
* Catch-all for every mounted devframe SPA (`/__git/…`, `/__terminals/…`, the
8-
* a11y agent module, …) and their `<base>/__connection.json` discovery fetches.
9-
* The `@devframes/next` bridge owns all of it — static serving (with SPA
10-
* fallback, content types, and traversal guarding via devframe's shared
11-
* `serveStaticHandler`) and the connection-meta responses.
8+
* a11y agent module, …), their `<base>/__connection.json` discovery fetches,
9+
* and the in-process MCP endpoint (`/__hub/__mcp`). The `@devframes/next`
10+
* bridge owns all of it — static serving (with SPA fallback, content types,
11+
* and traversal guarding via devframe's shared `serveStaticHandler`), the
12+
* connection-meta responses, and the MCP mount.
13+
*
14+
* MCP speaks Streamable-HTTP: `POST` (requests), `GET` (the SSE stream), and
15+
* `DELETE` (session teardown) all route to the same bridge `fetch`.
1216
*/
13-
export async function GET(request: Request): Promise<Response> {
17+
async function handler(request: Request): Promise<Response> {
1418
const hub = await ensureMinimalNextDevframeHub()
1519
return hub.fetch(request)
1620
}
21+
22+
export { handler as DELETE, handler as GET, handler as POST }

examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { defineHubRpcFunction } from '@devframes/hub'
88
import { createHubContext, mountDevframe } from '@devframes/hub/node'
99
import { toJsonRenderDockEntry } from '@devframes/json-render/hub'
1010
import { createDevframeNextHost } from '@devframes/next'
11-
import { startHttpAndWs } from 'devframe/node'
11+
import { registerDevframeInstance, startHttpAndWs } from 'devframe/node'
1212
import { getPort } from 'get-port-please'
1313
import { createDashboardView } from 'minimal-json-render/dashboard'
1414
import { dirname, join } from 'pathe'
@@ -134,13 +134,14 @@ export async function minimalNextDevframeHub(
134134
): Promise<StartedMinimalNextDevframeHub> {
135135
const cwd = options.cwd ?? process.cwd()
136136
const hostName = options.host ?? 'localhost'
137+
const nextPort = Number(process.env.PORT ?? 3000)
137138

138139
// The Next host bridge: its `host` accumulates every `mountStatic` /
139140
// `mountConnectionMeta` call into a single `fetch` handler (backed by
140141
// devframe's shared `serveStaticHandler`), which the App Router routes
141142
// delegate to — no hand-rolled static serving or path matching here.
142143
const nextHost = createDevframeNextHost({
143-
resolveOrigin: () => `http://${hostName}:3000`,
144+
resolveOrigin: () => `http://${hostName}:${nextPort}`,
144145
getStorageDir(scope) {
145146
if (scope === 'workspace')
146147
return join(cwd, '.devframe')
@@ -174,6 +175,12 @@ export async function minimalNextDevframeHub(
174175
title: 'Next Hub: Ping',
175176
icon: 'ph:bell-duotone',
176177
category: 'hub',
178+
// Opt this command into the agent surface: it shows up as an MCP tool
179+
// on the in-process endpoint mounted below.
180+
agent: {
181+
description: 'Ping the hub to confirm it is alive. Returns "pong". Safe to call freely.',
182+
safety: 'read',
183+
},
177184
handler: () => 'pong',
178185
})
179186

@@ -245,14 +252,45 @@ export async function minimalNextDevframeHub(
245252
auth: false,
246253
})
247254

255+
// Serve MCP in-process on the Next app's own origin (the `/_next/mcp`
256+
// shape): the hub's agent surface — agent-flagged commands, plugin tools
257+
// (git status/log/diff, terminals), `read_state` — over the same catch-all
258+
// route as the SPAs, no side-car port involved.
259+
const mcpPath = '/__hub/__mcp'
260+
await nextHost.mountMcp(context, mcpPath, {
261+
serverName: 'minimal-next-devframe-hub',
262+
})
263+
248264
const connectionMeta = {
249265
backend: 'websocket' as const,
250266
websocket: started.port,
267+
mcp: { path: mcpPath },
251268
}
252269
// Publish the live meta to the bridge now the WS port is known, so every
253270
// registered `<base>/__connection.json` (hub + mounted devframes) resolves.
254271
nextHost.setConnectionMeta(connectionMeta)
255272

273+
// Record the instance in the global registry so `devframe connect`
274+
// discovers this hub — running inside the Next dev server — like any
275+
// standalone devframe. In-process hosts register explicitly; the origin is
276+
// the Next app's own.
277+
const registration = registerDevframeInstance({
278+
pid: process.pid,
279+
port: nextPort,
280+
origin: `http://${hostName}:${nextPort}`,
281+
basePath: '/__hub/',
282+
id: 'minimal-next-devframe-hub',
283+
name: 'Minimal Next Devframe Hub',
284+
rootDir: cwd,
285+
mcp: { path: mcpPath },
286+
startedAt: Date.now(),
287+
})
288+
const closeStarted = started.close
289+
started.close = async () => {
290+
registration.unregister()
291+
await closeStarted()
292+
}
293+
256294
return Object.assign(started, {
257295
context,
258296
connectionMeta,

examples/minimal-next-devframe-hub/tests/minimal-next-devframe-hub.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ describe('minimal-next-devframe-hub (example)', () => {
1919
server = undefined
2020
})
2121

22-
it('returns connection meta pointing at the WS backend', async () => {
22+
it('returns connection meta pointing at the WS backend and in-process MCP', async () => {
2323
server = await minimalNextDevframeHub({ host: '127.0.0.1' })
2424

2525
expect(server.connectionMeta).toEqual({
2626
backend: 'websocket',
2727
websocket: server.port,
28+
mcp: { path: '/__hub/__mcp' },
2829
})
2930
})
3031

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@antfu/eslint-config": "catalog:tooling",
3838
"@antfu/ni": "catalog:build",
3939
"@antfu/utils": "catalog:inlined",
40+
"@modelcontextprotocol/sdk": "catalog:deps",
4041
"@playwright/test": "catalog:testing",
4142
"@types/node": "catalog:types",
4243
"@types/prompts": "catalog:types",

0 commit comments

Comments
 (0)