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
48 changes: 47 additions & 1 deletion 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 @@ -118,6 +118,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 Down
28 changes: 28 additions & 0 deletions packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,34 @@ function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteO
return mcp === true ? {} : mcp
}

/**
* Resolve the `mcp` entry a `__connection.json` should advertise for a dev
* server started with the given `mcp` option (falling back to `def.cli?.mcp`,
* exactly like {@link createDevServer}), or `undefined` when the route is
* disabled.
*
* Hosted bridges that hand-roll their connection meta (`viteDevBridge`,
* `@devframes/next`'s handler) pass the side-car `port`: the advertised path
* becomes absolute (the side-car mounts at `/`) and the client dials
* `<page-host>:<port><path>`. Without `port` the path stays relative, resolved
* against `__connection.json`'s own location (the same-server default).
*
* @experimental
*/
export function resolveMcpConnectionMeta(
def: DevframeDefinition,
mcp: boolean | McpRouteOptions | undefined,
port?: number,
): ConnectionMeta['mcp'] {
const config = resolveMcpConfig(mcp ?? def.cli?.mcp)
if (!config)
return undefined
const route = withoutLeadingSlash(config.path ?? DEVFRAME_MCP_ROUTE)
return port != null
? { path: withLeadingSlash(route), port }
: { path: route }
}

/**
* Resolve the three WS connection scenarios from the definition / call-site
* config into a concrete server bind path, optional dedicated port, and the
Expand Down
25 changes: 25 additions & 0 deletions packages/devframe/src/adapters/mcp/__tests__/stringify.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Diagnostic } from 'nostics'
import { describe, expect, it } from 'vitest'
import { formatMcpError, stringifyForMcp } from '../stringify'

Expand Down Expand Up @@ -110,4 +111,28 @@ describe('formatMcpError', () => {
const err = new Error('outer', { cause: 'bad input' })
expect(formatMcpError(err)).toBe('Error: outer (cause: bad input)')
})

it('emits structured JSON for a nostics Diagnostic', () => {
const diagnostic = new Diagnostic({
code: 'DF9999',
why: 'Something coded went wrong',
fix: 'Run the fixer.',
docs: 'https://devfra.me/errors/df9999',
})
expect(JSON.parse(formatMcpError(diagnostic))).toEqual({
error: {
code: 'DF9999',
message: 'Something coded went wrong',
fix: 'Run the fixer.',
docs: 'https://devfra.me/errors/df9999',
},
})
})

it('omits absent fix/docs from the structured diagnostic payload', () => {
const diagnostic = new Diagnostic({ code: 'DF9998', why: 'No extras' })
expect(JSON.parse(formatMcpError(diagnostic))).toEqual({
error: { code: 'DF9998', message: 'No extras' },
})
})
})
6 changes: 4 additions & 2 deletions packages/devframe/src/adapters/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from './to-json-sc

export interface CreateMcpServerOptions {
/**
* Transport to use. Only `'stdio'` is implemented today; HTTP support
* is planned in a follow-up PR.
* Transport to use. `createMcpServer` itself runs `'stdio'` (a standalone
* process with its own host context); the Streamable-HTTP transport is
* served route-based by the dev server instead — see `mountMcpHttp` and
* the `mcp` option on `createDevServer` / `createCac`'s `--mcp` flag.
*/
transport?: 'stdio'
/**
Expand Down
22 changes: 19 additions & 3 deletions packages/devframe/src/adapters/mcp/stringify.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Diagnostic } from 'nostics'

/**
* JSON-coercing serializer for MCP text payloads.
*
Expand Down Expand Up @@ -55,11 +57,25 @@ export function stringifyForMcp(value: unknown): string {
}

/**
* Format a thrown value for an MCP `isError` text payload. Surfaces the
* `Error.name`/`message`, and one level of `cause.message` so context
* isn't dropped silently.
* Format a thrown value for an MCP `isError` text payload.
*
* A nostics `Diagnostic` (every coded devframe error) becomes structured
* JSON — `{ error: { code, message, fix?, docs? } }` — so an agent receives
* the actionable next step (`fix`) and the docs URL instead of a bare
* message string. Other errors surface `Error.name`/`message`, plus one
* level of `cause.message` so context isn't dropped silently.
*/
export function formatMcpError(error: unknown): string {
if (error instanceof Diagnostic) {
return JSON.stringify({
error: {
code: error.code,
message: error.message,
...(error.fix ? { fix: error.fix } : {}),
...(error.docs ? { docs: error.docs } : {}),
},
}, null, 2)
}
if (!(error instanceof Error))
return String(error)
const cause = (error as { cause?: unknown }).cause
Expand Down
103 changes: 103 additions & 0 deletions packages/devframe/src/helpers/__tests__/vite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { DevframeDefinition } from '../../types/devframe'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { getPort } from 'get-port-please'
import { afterEach, describe, expect, it } from 'vitest'
import { viteDevBridge } from '../vite'

function defineTestDef(): DevframeDefinition {
return {
id: 'vite-bridge-test',
name: 'Vite Bridge Test',
version: '0.0.0',
packageName: '@devframe/vite-bridge-test',
homepage: '',
description: '',
setup(ctx) {
ctx.agent.registerTool({
id: 'greet',
description: 'Say hello.',
safety: 'read',
handler: () => ({ greeting: 'hi' }),
})
},
}
}

interface FakeViteServer {
middlewares: { use: (path: string, handler: any) => void }
httpServer: null
routes: Map<string, any>
}

function fakeViteServer(): FakeViteServer {
const routes = new Map<string, any>()
return {
middlewares: { use: (path, handler) => routes.set(path, handler) },
httpServer: null,
routes,
}
}

/** Invoke a registered connect-style middleware and capture its JSON body. */
async function readJsonMiddleware(handler: any): Promise<any> {
return await new Promise((resolvePromise) => {
handler(undefined, {
setHeader: () => {},
end: (body: string) => resolvePromise(JSON.parse(body)),
})
})
}

describe('viteDevBridge (bridge mode mcp)', () => {
let bridge: ReturnType<typeof viteDevBridge> | undefined

afterEach(async () => {
await bridge?.closeBundle?.()
bridge = undefined
})

it('forwards the mcp option and advertises the side-car endpoint in the meta', async () => {
const port = await getPort({ port: 19710, host: '127.0.0.1' })
bridge = viteDevBridge(defineTestDef(), {
devMiddleware: { port, host: '127.0.0.1' },
mcp: true,
})

const server = fakeViteServer()
await bridge.configureServer(server)

const metaHandler = server.routes.get('/__vite-bridge-test/__connection.json')
expect(metaHandler).toBeDefined()
const meta = await readJsonMiddleware(metaHandler)
expect(meta.backend).toBe('websocket')
expect(meta.websocket).toEqual({ port, path: '/__devframe_ws' })
expect(meta.mcp).toEqual({ port, path: '/__mcp' })

// The advertised endpoint is live: a real MCP client can connect and
// list the agent tools on the side-car origin.
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/__mcp`))
const client = new Client({ name: 'test-client', version: '0.0.0' })
try {
await client.connect(transport)
const tools = await client.listTools()
expect(tools.tools.map(t => t.name)).toContain('greet')
}
finally {
await client.close()
}
})

it('omits the mcp block when the option is not set', async () => {
const port = await getPort({ port: 19720, host: '127.0.0.1' })
bridge = viteDevBridge(defineTestDef(), {
devMiddleware: { port, host: '127.0.0.1' },
})

const server = fakeViteServer()
await bridge.configureServer(server)

const meta = await readJsonMiddleware(server.routes.get('/__vite-bridge-test/__connection.json'))
expect(meta.mcp).toBeUndefined()
})
})
23 changes: 20 additions & 3 deletions packages/devframe/src/helpers/vite.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { DevframeAuthHandler } from '../node/auth/handler'
import type { DevframeDefinition } from '../types/devframe'
import type { DevframeDefinition, McpRouteOptions } from '../types/devframe'
import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static'
import { resolve } from 'pathe'
import { normalizeBasePath, resolveBasePath } from '../adapters/_shared'
import { createDevServer, resolveDevServerPort } from '../adapters/dev'
import { createDevServer, resolveDevServerPort, resolveMcpConnectionMeta } from '../adapters/dev'
import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants'
import { diagnostics } from '../node/diagnostics'

Expand Down Expand Up @@ -50,6 +50,19 @@ export interface ViteDevBridgeOptions {
* @default false
*/
auth?: boolean | DevframeAuthHandler
/**
* Expose the side-car's route-based MCP server (Streamable-HTTP) and
* advertise it in the bridge's `__connection.json`. Forwarded to
* {@link createDevServer}: overrides `def.cli?.mcp`, `undefined` falls
* through to it, `false` disables the route regardless. Only applies in
* bridge mode (`devMiddleware`); the static-mount mode starts no server.
*
* The endpoint lives on the side-car's own port, so the advertised meta
* carries `{ port, path }` — see `ConnectionMeta['mcp']`.
*
* @experimental
*/
mcp?: boolean | McpRouteOptions
}

export interface DevframeVitePlugin {
Expand Down Expand Up @@ -126,6 +139,7 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio
// Hosted adapter: the host owns auth, so the bridged devframe's own
// gate stays off unless the caller explicitly opts back in.
auth: options.auth ?? false,
mcp: options.mcp,
})
}
catch (e) {
Expand All @@ -136,13 +150,16 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio
// The side-car listens on its own port, so the browser must target that
// port explicitly (it can't reach the WS on Vite's origin). The route is
// `/__devframe_ws` — the bridge `createDevServer` mounts the SPA at `/`, so its WS
// upgrade handler is bound there.
// upgrade handler is bound there. The MCP route (when enabled) lives on
// the same side-car origin, advertised with the same explicit port.
const mcpMeta = resolveMcpConnectionMeta(d, options.mcp, port)
const metaPath = `${base}${DEVFRAME_CONNECTION_META_FILENAME}`
server.middlewares.use(metaPath, (_req: unknown, res: any) => {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({
backend: 'websocket',
websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` },
...(mcpMeta ? { mcp: mcpMeta } : {}),
}))
})

Expand Down
8 changes: 6 additions & 2 deletions packages/devframe/src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,13 @@ export interface ConnectionMeta {
* (`cli.mcp`). Advertises the MCP Streamable-HTTP route so in-browser
* tooling (e.g. an MCP inspector) can discover it without guessing the
* path. `path` is relative to `__connection.json`'s location, like the
* WebSocket `path`.
* WebSocket `path`. `port` is set when the endpoint lives on a side-car
* server on its own port (bridge mode — `viteDevBridge`,
* `@devframes/next`): the client combines the page hostname with `port`
* and resolves `path` against that origin, mirroring
* {@link ConnectionMetaWebsocket.port}.
*/
mcp?: { path: string }
mcp?: { path: string, port?: number }
/**
* Names of RPC functions that have declared `jsonSerializable: true`.
* Used by the WS / static client to dispatch the per-call wire
Expand Down
15 changes: 14 additions & 1 deletion packages/next/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { DevframeDefinition, DevframeStorageScope } from 'devframe/types'
import { homedir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
import { createDevServer, resolveDevServerPort } from 'devframe/adapters/dev'
import { createDevServer, resolveDevServerPort, resolveMcpConnectionMeta } from 'devframe/adapters/dev'
import { DEVFRAME_WS_ROUTE } from 'devframe/constants'
import { createDevframeNextHost } from './host'

Expand All @@ -31,6 +31,16 @@ export interface CreateDevframeNextHandlerOptions {
resolveOrigin?: () => string
/** Override where persisted devframe state lives (defaults under the cwd / home). */
getStorageDir?: (scope: DevframeStorageScope) => string
/**
* Expose the side-car's route-based MCP server (Streamable-HTTP) and
* advertise it in the handler's `__connection.json`. Forwarded to
* `createDevServer`: overrides `def.cli?.mcp`, `undefined` falls through to
* it, `false` disables the route regardless. The endpoint lives on the
* side-car's own port, so the advertised meta carries `{ port, path }`.
*
* @experimental
*/
mcp?: CreateDevServerOptions['mcp']
}

export interface DevframeNextHandler {
Expand Down Expand Up @@ -118,10 +128,13 @@ export function createDevframeNextHandler(
flags: options.flags,
openBrowser: false,
auth: options.auth ?? false,
mcp: options.mcp,
})
const mcpMeta = resolveMcpConnectionMeta(def, options.mcp, port)
nextHost.setConnectionMeta({
backend: 'websocket',
websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` },
...(mcpMeta ? { mcp: mcpMeta } : {}),
})
})()

Expand Down
Loading
Loading