Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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-*
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ Framework-neutral foundation for building devframes.
</a>
</p>

## 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)
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
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 (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/<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/DF0045.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions docs/errors/DF0046.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions docs/errors/DF0047.md
Original file line number Diff line number Diff line change
@@ -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:<slug>:<fn>`, `devframe:<area>:<fn>`) 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.
28 changes: 28 additions & 0 deletions docs/errors/DF0048.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions docs/errors/DF0049.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions docs/errors/DF0050.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions docs/errors/DF0051.md
Original file line number Diff line number Diff line change
@@ -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`.
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.
38 changes: 37 additions & 1 deletion docs/guide/agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>:<fn>` for plugin RPCs, `devframe:<area>:<fn>` 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:
Expand All @@ -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:
Expand All @@ -79,6 +111,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** (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:
Expand Down Expand Up @@ -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`. |
| `<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). |
Loading
Loading