Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ temp
packages/devframe/skills
test-results
playwright-report
tests/e2e/.registries
playwright/.cache
blob-report
.ecosystem
Expand Down
46 changes: 46 additions & 0 deletions docs/adapters/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,50 @@ defineDevframe({
})
```

### Hosted bridges

Both hosted bridges forward the same option to their side-car dev server and advertise the endpoint (with its port) in the `__connection.json` they serve:

```ts
// Vite
viteDevBridge(devframe, { devMiddleware: true, mcp: true })

// Next.js (@devframes/next)
createDevframeNextHandler(devframe, { mcp: true })
```

## Custom hosts

`createMcpFetchHandler(ctx, options)` returns the endpoint as a web-standard `Request → Response` handler plus a `dispose()` for session teardown — mount it on any fetch-shaped server (a Next.js App Router route, a custom Node server). The h3 `mountMcpHttp` used by the dev server is a thin wrapper over it.

```ts
import { createMcpFetchHandler } from 'devframe/adapters/mcp'

const mcp = createMcpFetchHandler(ctx, {
serverName: 'my-tool (devframe)',
serverVersion: '1.0.0',
exposeSharedState: true,
})
// route every method on /__mcp to mcp.fetch(request)
```

## Discovery: `devframe connect`

The `devframe` bin ships an MCP **connector** — a thin discovery + proxy server in the shape [next-devtools-mcp](https://github.com/vercel/next-devtools-mcp) validated. Configure it once in an agent client and it finds every running devframe:

```json
{
"mcpServers": {
"devframe": { "command": "npx", "args": ["devframe", "connect"] }
}
}
```

It exposes two gateway tools:

- **`devframe:connect:list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`.
- **`devframe:connect:call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint.

Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/<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.

See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example.
23 changes: 23 additions & 0 deletions docs/errors/DF0042.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
outline: deep
---

# DF0042: Instance Registry Update Failed

## Message

> Failed to update the devframe instance registry at "`{file}`": `{reason}`

## Cause

A dev server (or an in-process host calling `registerDevframeInstance`) could not write or remove its record under the instance registry directory — `~/.devframe/instances/` by default, or `$DEVFRAME_INSTANCES_DIR`. Typical causes are a read-only home directory, missing permissions, or a full disk. The server keeps running; only discovery is affected — `devframe connect` will not see this instance.

## Fix

- Check that the registry directory is writable and the disk has free space.
- Point `DEVFRAME_INSTANCES_DIR` at a writable directory.
- Set `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` to opt out of registration entirely.

## Source

- [`packages/devframe/src/node/instance-registry.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/instance-registry.ts) — `registerDevframeInstance()` reports this on a failed write and its `unregister()` on a failed removal.
26 changes: 26 additions & 0 deletions docs/errors/DF0043.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
outline: deep
---

# DF0043: Connector Requires the MCP SDK

## Message

> `devframe connect` requires the optional peer dependency @modelcontextprotocol/sdk: `{reason}`

## Cause

`devframe connect` was started but `@modelcontextprotocol/sdk` could not be imported. The SDK is an optional peer dependency of `devframe` — the MCP surface stays opt-in, so the SDK only needs to be installed where MCP features are used.

## Fix

Install the SDK next to devframe and run the connector again:

```sh
npm install @modelcontextprotocol/sdk
devframe connect
```

## Source

- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `startConnectServer()` throws this when the dynamic SDK import fails.
48 changes: 48 additions & 0 deletions docs/errors/DF8404.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 69 additions & 2 deletions docs/guide/agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Three building blocks:

1. **An `agent` field on `defineRpcFunction`.** Add `agent: { description, ... }` to opt a function in. Functions without the field stay private.
2. **`ctx.agent`** — a host exposed on `DevframeNodeContext`. Plugins register tools that aren't backed by an RPC, and expose readable resources (e.g. a Markdown build summary).
3. **The MCP adapter** (`devframe/adapters/mcp`) — translates the agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server, currently over `stdio`.
3. **The MCP adapter** (`devframe/adapters/mcp`) — translates the agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server, over `stdio` (`devframe mcp`) or as a Streamable-HTTP route on the dev server (`--mcp`, advertised in `__connection.json`).

## Exposing an RPC function

Expand Down Expand Up @@ -63,6 +63,23 @@ export default defineDevframe({
})
```

## Deriving tools from other state

When tools derive from state you already maintain — a command registry, a plugin catalog — register a **provider** instead of mirroring registrations. The host queries it at list/invoke time (the same lazy projection it applies to `agent`-flagged RPCs), so your source of truth stays the only copy:

```ts
const handle = ctx.agent.registerToolProvider(() =>
currentCommands()
.filter(command => command.agent)
.map(command => toAgentTool(command)),
)

// After the underlying state changes, nudge connected MCP clients:
handle.notifyChanged() // fires tools/list_changed
```

The hub's commands host uses exactly this to project agent-flagged palette commands.

## Registering a resource

Resources surface readable snapshots of state, identified by URI:
Expand All @@ -79,6 +96,8 @@ ctx.agent.registerResource({

Every `ctx.rpc.sharedState` key is also automatically exposed to MCP as `devframe://state/<key>`. Pass `exposeSharedState: false` (or a filter function) to `createMcpServer` to opt out.

Shared state is additionally reachable through the built-in **`devframe:state:read` tool** — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection.

## Starting the MCP server

The simplest path is the CLI:
Expand Down Expand Up @@ -118,6 +137,52 @@ Add an entry to `claude_desktop_config.json`:

Restart Claude Desktop. The tools you flagged with `agent: { ... }` (plus any `registerTool` calls) show up in the MCP tool drawer. Resources are reachable as `devframe://resource/<id>` and `devframe://state/<key>` URIs.

## Writing descriptions agents act on

A tool description is a prompt, not documentation. The agent decides *when* to call your tool from the description alone, so tell it — state when to reach for the tool, not just what it returns:

<!-- eslint-skip -->

```ts
// ✗ Bad: describes the mechanism
agent: { description: 'Returns the session summary object.' }
// ✓ Good: tells the agent when and why
agent: { description: 'Summarize the current build session — durations, chunk counts, warnings. Call this before proposing any build-config change.' }
```

Two conventions:

- **Lead with the action and the trigger.** "Call this before/after/when …" steers proactive use; a bare noun phrase gets ignored.
- **State freshness and cost.** "Safe to call freely" / "expensive, call once per session" lets the agent budget calls.

## Gateway tools

A gateway tool returns *instructions and locations* instead of doing the work — the pattern for anything the agent can do better directly (reading bundled docs, running a CLI it has shell access to):

```ts
ctx.agent.registerTool({
id: 'my-plugin:docs',
description: 'Locate the version-accurate docs for this tool. Call before answering questions about its config format.',
safety: 'read',
handler: () => ({
docsPath: resolveInstalledDocsDir(),
hint: 'Read the file matching your topic; do not rely on training-data knowledge of this config format.',
}),
})
```

The agent gets a path and a next step; the actual reading happens with its own tools, which are faster and keep large content out of the MCP payload.

## Structured errors

A coded devframe diagnostic thrown from a tool handler crosses the MCP boundary as structured JSON rather than a flattened message:

```json
{ "error": { "code": "DF0017", "message": "…", "fix": "…", "docs": "https://devfra.me/errors/df0017" } }
```

Agents can act on `fix` directly and follow `docs` for detail — prefer throwing coded diagnostics from anything agent-reachable.

## Safety model

- **Opt-in exposure.** Functions opt in via the `agent` field; everything else stays private.
Expand All @@ -128,4 +193,6 @@ Restart Claude Desktop. The tools you flagged with `agent: { ... }` (plus any `r

| Command | Description |
|---------|-------------|
| `devframe mcp` | Start an MCP server on `stdio`. |
| `<your-app> mcp` | Start your app's MCP server on `stdio` (from the `createCac` shell). |
| `<your-app> 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). |
18 changes: 18 additions & 0 deletions docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ Every hub context auto-registers these RPC functions so framework kits don't rei

Host-specific capabilities (open in editor, reveal in finder, …) ship as kit-registered RPC functions rather than as part of the hub surface.

## Commands as agent tools

A server command opts into the [agent surface](./agent-native) with an `agent` field — the same default-deny convention as `defineRpcFunction`. Agent-flagged, handler-bearing commands are projected into `ctx.agent` as callable tools and reach MCP clients through the devframe MCP adapter:

```ts
ctx.commands.register({
id: 'app:build',
title: 'Run build',
agent: {
description: 'Run the production build. Call after config or dependency changes to verify the app still builds.',
args: [v.object({ configFile: v.optional(v.string()) })],
},
handler: (opts?: { configFile?: string }) => runBuild(opts),
})
```

`args` takes positional valibot schemas (a single `v.object(...)` is unwrapped into the tool's input object); omit it for a zero-argument tool. `safety` defaults to `'action'`. `when` clauses evaluate client-side only and are not enforced for agent calls — opt in a `when`-gated command only if running it outside its UI context is safe.

## Cross-iframe dock activation

The viewer's active dock is client-local state — which dock is on screen lives in the shell page, not in shared state. A mounted devframe runs in its own iframe on its own RPC client, so it can't reach that selection directly. `hub:docks:activate` bridges the gap: any connected client asks the hub to switch the active dock, and the hub relays the request to the shell.
Expand Down
15 changes: 15 additions & 0 deletions examples/files-inspector/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,27 @@ 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) {
// A scoped context auto-namespaces every registered id with `NAMESPACE:`.
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.',
}),
})
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ export const dynamic = 'force-dynamic'

/**
* Catch-all for every mounted devframe SPA (`/__git/…`, `/__terminals/…`, the
* a11y agent module, …) and their `<base>/__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 `<base>/__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<Response> {
async function handler(request: Request): Promise<Response> {
const hub = await ensureMinimalNextDevframeHub()
return hub.fetch(request)
}

export { handler as DELETE, handler as GET, handler as POST }
Loading