---
title: "MCP"
description: "Exposes a devframe's agent-facing API as a Model Context Protocol server: coding agents call flagged RPCs and read resources."
canonical_url: "https://devfra.me/adapters/mcp"
---
# MCP

> Exposes a devframe's agent-facing API as a Model Context Protocol server: coding agents call flagged RPCs and read resources.

Exposes a devframe's agent-facing API as a [Model Context Protocol](https://modelcontextprotocol.io) server: coding agents call flagged RPCs and read resources.

The implementation (and the MCP SDK behind it) lives in **`@devframes/agentic`**, an optional peer of `devframe`: install it to enable the agent surface, and keep importing everything from `devframe/adapters/mcp` - the peer is loaded for you, never imported directly. A devframe without an agent surface ships with neither the peer nor the SDK installed:

```sh
npm install @devframes/agentic
```

```ts
import { createMcpServer } from 'devframe/adapters/mcp'
import myDevframe from './my-tool'

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

`createMcpServer` serves `stdio` through the MCP SDK's `serveStdio`, pinning one server instance per connection.

## Route-based server

The dev server exposes the same MCP API over HTTP, live. The default setting is **`'auto'`**: the route mounts once the devframe exposes an agent surface (an `agent`-flagged RPC, a registered tool or resource) *and* `@devframes/agentic` is installed - flag your first function, install the peer, and the agent view is on. A devframe with nothing flagged mounts no route and loads no MCP code; an agent surface without the peer warns once ([DF0078](https://devfra.me/errors/DF0078)) and mounts nothing, while an explicit `mcp` setting without the peer throws ([DF0079](https://devfra.me/errors/DF0079)). `mcp: false` stays silent either way.

Pin the behavior where you host the tool - it's a hosting decision, so pass `mcp` to `createCac` when you assemble the CLI (or to `createDevServer` / `initDevframe` / `initHub` when you host it programmatically): `true` always mounts, `false` never mounts, an object customises the route:

```ts
import { createCac } from 'devframe/adapters/cac'
import myDevframe from './my-tool'

createCac(myDevframe, { mcp: true }).parse() // force on; `false` forces off; omit for 'auto'
```

The endpoint speaks Streamable-HTTP at `/__mcp` (`/__<id>/__mcp` under a host framework), sharing its origin/port. `--mcp` / `--no-mcp` override per run; `__connection.json` advertises the mounted route.

The endpoint is **stateless**: it serves the [2026-07-28 revision](https://modelcontextprotocol.io/specification/2026-07-28) per request through the SDK's `createMcpHandler`, building a fresh MCP server for each request, so every HTTP request stands alone, with no `Mcp-Session-Id` to correlate. 2025-era clients are still served through the SDK's stateless legacy path.

### Origin gate, and opt-in identity

The **origin gate** guards every request: `Origin` must be loopback (or allow-listed), and `Origin`-less requests are rejected (a disallowed origin gets `403`). This is DNS-rebinding hardening that keeps a browser from reaching the route across origins. It is **not** a network-locality check: `Origin` is a request header, so a non-browser client (curl, a script) sends any value it likes.

Same-machine locality is instead proven from the **connected peer**: on the origin-only default (the `'auto'` default and `mcp: true`, with no widened `allowedOrigins` and no identity check), a request whose peer address is not loopback gets `403` even with a loopback `Origin`. The peer address comes from the socket, not a header, so a remote client cannot forge it. This is what makes "trusts same-machine callers" hold, and it's all a local dev tool needs. (A host that can't resolve a peer address, such as a serverless route, keeps the origin-only behavior; harden it with `authorization`.)

`Origin` still proves nothing about *who* is calling, and the peer check only proves *where from*. When same-machine isn't your trust boundary (a LAN/tunnel origin, a shared/CI host, a destructive tool surface), layer on an **identity check** with `authorization` - which also lifts the loopback-peer restriction, so authenticated callers may be remote:

```ts
createCac(myDevframe, {
  mcp: { authorization: process.env.MY_TOKEN },
}).parse()
```

`authorization` takes a bearer token (backed by an env var, never a literal), a `(request) => boolean` callback that governs identity only and cannot relax the origin gate, or `false` for the explicit origin-only default.

A request presents the bearer as `Authorization: Bearer <token>`, matched in constant time; a missing or wrong bearer gets `401` with a `WWW-Authenticate: Bearer` challenge. The origin gate always runs first, so a disallowed origin is `403` regardless of the credential. Widen the origin allow-list for a tunnel/LAN reach with `mcp: { authorization: process.env.MY_TOKEN, allowedOrigins: ['https://tunnel.example.com'] }`.

Never place the token in a URL, in `__connection.json`, in the instance registry, in logs, or on the command line; it belongs only in configuration and the `Authorization` header.

### Choosing which shared state agents see

Every shared-state key is exposed to agents by default, through the `devframe_state_read` tool and the `devframe://state/<key>` resources. `exposeSharedState` on the route options narrows that to the keys a filter accepts, or turns it off with `false`. The UI keeps using the same states over RPC.

```ts
devframeViteBridge(myDevframe, {
  mcp: {
    exposeSharedState: key => !key.startsWith('my-tool:private:')
  },
})
```

### Telling coding agents how to use the tools

`instructions` on the route options is a short text that the MCP server sends in its `initialize` result (and in the `server/discover` result of the 2026-07-28 revision). An MCP client can add it to the context of the coding agent. Use it for guidance that applies to every tool, for example which tool to call first. Without `instructions`, the handshake carries none.

```ts
devframeViteBridge(myDevframe, {
  mcp: { instructions: 'Call my-tool_list-sessions first, then pass a session id to the other tools.' },
})
```

`createMcpServer` and `createMcpFetchHandler` take the same `instructions` option.

### Resource templates

`resourceTemplates` on the route options is a list of resource templates that the MCP server serves through `resources/templates/list`. Each template has a `uriTemplate`, a `name`, an optional `title`, `description` and `mimeType`, a `read(params, uri, ctx)` function, and an optional `list(ctx)` function. Both functions can be sync or async. They get the node-side context (`ctx`), so they can read live state: the route options are resolved before `setup()` runs, so a template cannot keep `ctx` from there.

```ts
devframeViteBridge(myDevframe, {
  mcp: {
    resourceTemplates: [
      {
        uriTemplate: 'my-tool://module/{id}',
        name: 'module',
        mimeType: 'application/json',
        list: ctx => listModules(ctx).map(id => ({ uri: `my-tool://module/${encodeURIComponent(id)}`, name: id })),
        read: ({ id }, _uri, ctx) => ({
          json: findModule(ctx, id)
        }),
      }
    ],
  },
})
```

The `uriTemplate` uses RFC 6570 level 1: plain `{name}` expressions. Each one matches a non-empty value without `/`, and `read` gets its value percent-decoded, so a value with a `/` comes in as `%2F`. When one segment has several values, such as `{from}-{to}`, each value runs up to the first place the next text appears outside a `%XX` escape. Any other template syntax, or two expressions with nothing between them (`{a}{b}`), throws [DF0084](https://devfra.me/errors/DF0084) when the route mounts. `read` returns `text`, or `json` that the server sends as JSON text, and an optional `mimeType` (the default is the `mimeType` of the template, then `application/json`).

`resources/read` sends a `devframe://resource/<id>` or `devframe://state/<key>` URI to the registered resource or the shared state as before. It tries any other URI against the templates in order, and the first template that matches reads it. A URI that nothing matches gets the usual unknown-resource error, and an error thrown by `read` goes to the MCP client like the error of a registered resource. `resources/list` adds the entries that the `list` of each template returns. With an empty or omitted list, the resources stay as they are and `resources/templates/list` answers method not found. `createMcpServer` and `createMcpFetchHandler` take the same `resourceTemplates` option.

### Telling coding agents the resources changed

The route sends `notifications/resources/list_changed` when the agent surface changes or a shared-state key is added. For other changes, such as new entries from the `list` of a template, pass `onResourcesChanged`: a function that gets `notify` and the node-side context, and returns an unsubscribe function. Each `notify()` sends `notifications/resources/list_changed`, over HTTP (on the `subscriptions/listen` streams) and over `stdio`. The route calls the unsubscribe function when it closes. `createMcpServer` and `createMcpFetchHandler` take the same `onResourcesChanged` option.

```ts
import { watch } from 'node:fs'

devframeViteBridge(myDevframe, {
  mcp: {
    onResourcesChanged: (notify) => {
      const watcher = watch('src', { recursive: true }, notify)
      return () => watcher.close()
    },
  },
})
```

### Prompts

`prompts` on the route options is a list of prompt templates that the MCP server serves through `prompts/list` and `prompts/get`. A coding agent usually offers each one to the user as a slash command. Each prompt has a `name`, an optional `title`, `description` and `arguments`, and a `get(args)` function that can be sync or async. Each argument has a `name`, an optional `description`, and `required`.

```ts
devframeViteBridge(myDevframe, {
  mcp: {
    prompts: [
      {
        name: 'explain-module',
        title: 'Explain a module',
        arguments: [
          { name: 'id', description: 'The module id.', required: true }
        ],
        get: ({ id }) => ({
          messages: [
            {
              role: 'user',
              content: { type: 'text', text: `Read my-tool://module/${encodeURIComponent(id)} and explain what the module does.` }
            }
          ],
        }),
      }
    ],
  },
})
```

`get` returns the `messages`, and an optional `description`. `prompts/get` answers invalid params for an unknown name, or when a required argument is missing or empty, and then does not call `get`. An error thrown by `get` goes to the MCP client. Each `name` is used once: a repeated name throws [DF0085](https://devfra.me/errors/DF0085) when the route mounts. With an empty or omitted list, the server declares no `prompts` capability. `createMcpServer` and `createMcpFetchHandler` take the same `prompts` option.

### Hosted bridges

Both bridges forward the setting to their side-car dev server, advertising the mounted endpoint in `__connection.json`:

```ts
// Vite (@devframes/vite)
devframeViteBridge(myDevframe, { mcp: true })

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

Both honor the same contract: omitted is `'auto'`, `true` forces the origin-only route on; add `mcp: { authorization }` to harden.

## Custom host frameworks

`createMcpFetchHandler(ctx, options)` returns the endpoint as a `Request → Response` handler plus a `dispose()`; mount it on any fetch server.

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

const mcp = createMcpFetchHandler(ctx, {
  serverName: 'my-tool (devframe)',
  serverVersion: '1.0.0',
  exposeSharedState: true,
  // Optional identity check on top of the origin gate; omit for origin-only.
  // authorization: process.env.MY_TOKEN,
})
// route every method on /__mcp to mcp.fetch(request)
```

## Discovery: `devframe connect`

The `devframe` bin ships an MCP **connector** ([next-devtools-mcp](https://github.com/vercel/next-devtools-mcp)-style) that finds every running devframe. Configure once:

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

Two gateway tools (`devframe:connect:*` ids; see [tool ids and wire names](https://devfra.me/guide/agent-native#tool-ids-and-wire-names)):

- **`devframe_connect_list-instances`**: list running dev servers and their MCP tools.
- **`devframe_connect_call-tool`**: invoke one tool on a running devframe (`{ port, tool, args }`) over Streamable-HTTP.

Discovery reads the **instance registry**: every `createDevServer` writes `~/.devframe/instances/<pid>-<port>.json`, dialed with a loopback origin. In-process host frameworks register via `registerDevframeInstance` (`devframe/node`). `--port <n>` probes a port for `__connection.json` at its root; add `--base <path>` for a devframe mounted below the root (e.g. `devframe connect --port 5173 --base /__devtools/` for Vite DevTools). A probe only counts a JSON object as connection meta, so an SPA fallback page on the wrong path reads as "no instance". `DEVFRAME_INSTANCES_DIR` relocates the registry, `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts out.

The connector needs the same optional `@devframes/agentic` peer as the adapter; `devframe connect` without it throws [DF0046](https://devfra.me/errors/DF0046).

Most instances trust same-machine callers, so the connector reaches them with no credential. For an instance you *hardened* with a bearer, the connector reads `DEVFRAME_MCP_AUTH_TOKEN` and presents it (never a CLI flag, since command-line arguments are visible to other processes).

See [Agent-Native](https://devfra.me/guide/agent-native) for the API and safety model.


## Sitemap

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