---
title: "Agent-Native Devframe"
description: "Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and WebMCP on the browser side, opt-in per function."
canonical_url: "https://devfra.me/guide/agent-native"
---
# Agent-Native Devframe

> Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and WebMCP on the browser side, opt-in per function.

Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and [WebMCP](#browser-side-tools-over-webmcp) on the browser side, opt-in per function.

## How it works

Three pieces: the **`agent` field** on `defineRpcFunction`, **`ctx.agent`** (non-RPC tools + resources), and the **MCP adapter** (`devframe/adapters/mcp`) serving an [MCP](https://modelcontextprotocol.io) server. The same `agent` field on a *client* RPC function surfaces it [over WebMCP](#browser-side-tools-over-webmcp) instead.

## Exposing an RPC function

```ts
import { defineRpcFunction } from 'devframe'

export const getSessionSummary = defineRpcFunction({
  name: 'rolldown-get-session-summary',
  type: 'query',
  args: [
    v.object({
      sessionId: v.string()
    })
  ],
  returns: v.object({
    durationMs: v.number(),
    chunkCount: v.number()
  }),
  agent: {
    description: 'Summarize a Rolldown build session. Safe to call freely.',
    title: 'Build summary',
    // safety inferred from `type: 'query'` → 'read'
  },
  setup: ctx => ({
    handler: async ({ sessionId }) => {
      // ...
    },
  }),
})
```

## Tool ids and wire names

- **The id** registers/invokes in devframe, colon-namespaced: `devframes:plugin:<slug>:<fn>` (built-in devframe RPCs), `devframe:<area>:<fn>` (built-ins), command ids.
- **The wire name** is what MCP clients call, constrained to `^[a-zA-Z0-9_-]{1,128}$`; runs outside that set collapse to `_`, truncated to 128.

```
devframe:state:read          → devframe_state_read
devframes:plugin:git:status  → devframes_plugin_git_status
my-plugin:summarize          → my-plugin_summarize
```

`toAgentToolName` (`devframe/utils/agent-tool-name`, client-safe) predicts a wire name; two ids sanitizing alike keep the first, the later hidden with `DF0047`.

## Registering a devframe tool

Tools without a matching RPC register directly.

```ts
export default defineDevframe({
  id: 'my-plugin',
  setup(ctx) {
    ctx.agent.registerTool({
      id: 'my-plugin:summarize',
      description: 'Plain-text summary of the current build state.',
      safety: 'read',
      handler: async () => ({
        markdown: buildSummary(),
      }),
    })
  },
})
```

## Deriving tools from other state

Register a **provider** for tools derived from state, queried at list/invoke time:

```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
```

## Registering a resource

Readable snapshots by URI:

```ts
ctx.agent.registerResource({
  id: 'current-session',
  name: 'Current Rolldown session',
  description: 'Markdown snapshot of the active build session.',
  mimeType: 'text/markdown',
  read: () => ({
    text: renderMarkdown(currentSession)
  }),
})
```

Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/<key>` resource and via the **`devframe:state:read` tool** (wire `devframe_state_read`): no args → key list, `key` → its value. `exposeSharedState: false` (or a filter) on `createMcpServer`, or inside the `mcp` route options of the dev server and the hub, opts out.

## Starting the MCP server

The dev server serves the agent surface over HTTP on its own: the `mcp: 'auto'` default mounts the route at `/__mcp` once anything above exists (an `agent`-flagged RPC, a registered tool or resource) and the optional [`@devframes/agentic`](https://devfra.me/adapters/mcp) peer is installed - one flagged function plus one install is the whole setup. See the [MCP adapter](https://devfra.me/adapters/mcp#route-based-server) for forcing it on or off and hardening the route.

For a stdio server instead, via the CLI:

```sh
# Run your devtool with an MCP stdio server attached.
devframe mcp
```

Programmatically:

```ts
import { defineDevframe } from 'devframe'
import { createMcpServer } from 'devframe/adapters/mcp'

const myDevframe = defineDevframe({ /* … */ })

await createMcpServer(myDevframe, { transport: 'stdio' })
```

## Connecting Claude Desktop

In `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "my-tool": {
      "command": "pnpm",
      "args": ["--filter", "my-tool", "exec", "devframe", "mcp"]
    }
  }
}
```

Restart; tools appear in the drawer, resources as `devframe://resource/<id>` / `devframe://state/<key>` URIs.

## Browser-side tools over WebMCP

The same `agent` signature works on the browser side: a client RPC function (a function the node side calls on the browser, registered on `rpc.client` or through a scoped `client.scope('my-plugin').rpc.register(...)`) carrying an `agent` field is mirrored onto the page's [WebMCP](https://github.com/webmachinelearning/webmcp) model context (`document.modelContext` / `navigator.modelContext`) as a callable tool, so in-page and browser-integrated agents can drive browser-side functionality directly. Wire names, `arg0`/`arg1`/… input schemas, and safety annotations match the MCP projection above.

```ts
const rpc = await connectDevframe()

rpc.client.register({
  name: 'my-plugin:highlight-node',
  type: 'action',
  jsonSerializable: true,
  agent: {
    description: 'Highlight a node in the open inspector view. Use it to point the user at a finding.',
  },
  handler: (id: string) => highlightNode(id),
})
```

`connectDevframe()` wires this on its own when the browser provides a model context; `webmcp: false` keeps the browser side off the WebMCP surface. `registerWebMcpTools(collector)` (from `devframe/client`) applies the same projection to a hand-built collector and returns a dispose that unregisters every tool.

> [!WARNING]
> WebMCP is an experimental proposal; `registerWebMcpTools` tracks the current draft (`AbortSignal`-based unregistration) and earlier handle-returning drafts, but the browser API may still change.

## In-page tools over MCP

An [in-page channel](https://devfra.me/guide/in-page-channel) function carrying an `agent` field is forwarded to the node side over the page's RPC connection and served from the same MCP endpoint as node-side tools, under the id `<channel name>:<function name>`. The page keeps executing the handler; the node relays the call and the result.

```ts
const channel = createPageScriptChannel({
  name: 'my-plugin',
  functions: {
    'selected-node': {
      type: 'query',
      agent: { description: 'Return the node the user selected in the page. Call it before proposing an edit.' },
      handler: () => getSelectedNode(),
    },
  },
})
```

Every browser tab exposing such tools syncs its own manifest, so the same app open in several tabs exposes each tool once, plus a built-in `devframe:agent:list-clients` tool (wire name `devframe_agent_list-clients`) listing the connected tabs: a per-tab `id` (stable across reloads), `url`, `title`, `visible`/`focused`, `connectedAt`, and the tool ids that tab exposes. `devframe connect` nests the same list under `mcp.clients` for each instance.

A forwarded tool accepts a reserved `client_id` argument to run on one tab. Without it, the call goes to the most recently focused visible tab, falling back to the tab that synced last; an unknown `client_id` fails with [DF0082](https://devfra.me/errors/DF0082), which lists the live ids. Tabs re-sync on focus and visibility changes, so an unaddressed call follows the tab the user looked at last.

## Writing descriptions agents act on

Describe *when* to use a tool, not just its return:

<!-- 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.' }
```

## Gateway tools

A gateway tool returns *instructions and locations*, not work agents do better:

```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.',
  }),
})
```

## Returning images and other content

The MCP adapter sends a tool result as one text block that holds the result as JSON. To send other MCP content, such as a screenshot next to its description, attach content blocks to the result with `withMcpContent`:

```ts
import { defineRpcFunction, withMcpContent } from 'devframe'

export const captureComponent = defineRpcFunction({
  name: 'my-plugin:capture-component',
  type: 'query',
  agent: { description: 'Take a screenshot of one component. Call it when the layout of the component matters.' },
  handler: async () => {
    const { markdown, png } = await capture()
    return withMcpContent({ markdown }, [
      { type: 'text', text: markdown },
      { type: 'image', data: png, mimeType: 'image/png' },
    ])
  },
})
```

The adapter sends the blocks in place of the JSON text block. A block is `text`, `image` or `audio`, and `data` is base64. The blocks are stored under a hidden key, so the returned value does not change: the SPA gets `{ markdown }` over RPC, without the image data. If the tool has an output schema, `structuredContent` is also that plain value. Invalid blocks make the call fail with [`DF0081`](https://devfra.me/errors/DF0081).

## Structured errors

A coded diagnostic thrown from a handler crosses the MCP boundary as JSON:

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

Prefer coded diagnostics anywhere agent-reachable: agents act on `fix` and follow `docs`.

## Safety model

- **`safety`**: `'read'`, `'action'`, or `'destructive'`. Inferred from the RPC `type` (`static`/`query` → `read`, `action`/`event` → `action`), overridable.
- The adapter maps `safety` to tool annotations (`readOnlyHint`, `destructiveHint`).

## CLI

`<your-app> mcp` starts the MCP server on `stdio`; `<your-app> dev --mcp` serves the agent-consumable API on `/__mcp`; `devframe connect` discovers running devframes and proxies their tools ([MCP adapter](https://devfra.me/adapters/mcp#discovery-devframe-connect)). The command table is in the [Node-Side API reference](https://devfra.me/references/node-api#mcp-cli-commands).


## Sitemap

See the full [sitemap](https://devfra.me/sitemap.md) for all pages.
