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 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 server. The same agent field on a client RPC function surfaces it over WebMCP instead.

Exposing an RPC function

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.

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:

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:

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 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 peer is installed - one flagged function plus one install is the whole setup. See the MCP adapter for forcing it on or off and hardening the route.

For a stdio server instead, via the CLI:

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

Programmatically:

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:

{
  "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 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.

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.

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.

Writing descriptions agents act on

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

// ✗ 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:

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

Structured errors

A coded diagnostic thrown from a handler crosses the MCP boundary as 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/queryread, action/eventaction), 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). The command table is in the Node-Side API reference.