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
30 changes: 29 additions & 1 deletion docs/adapters/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ import devframe from './devframe'
await createMcpServer(devframe, { transport: 'stdio' })
```

`@modelcontextprotocol/sdk` is a peer dependency — install it when shipping MCP support. The current transport is `stdio`.
`@modelcontextprotocol/sdk` is a peer dependency — install it when shipping MCP support. `createMcpServer` speaks the `stdio` transport, spawned per session by the client.

## Route-based server

The dev server can expose the same agent surface over HTTP, so an MCP client connects to the **running** server and sees live tool and resource changes. Enable it with `cli.mcp`:

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

export default defineDevframe({
// …
cli: {
mcp: true,
},
})
```

The endpoint speaks the MCP Streamable-HTTP transport at `/__mcp` (relative to the base path — `/__<id>/__mcp` under a host), sharing the dev server's origin and port. The `--mcp` and `--no-mcp` flags override the definition per run. `__connection.json` advertises the route so in-browser tooling can discover it.

Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The endpoint binds to the same loopback host as the dev server and applies the shared loopback origin gate; widen it for a tunnel or LAN origin:

```ts
defineDevframe({
// …
cli: {
mcp: { allowedOrigins: ['https://tunnel.example.com'] },
},
})
```

See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example.
12 changes: 6 additions & 6 deletions docs/errors/DF0017.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ The agent-native surface is experimental and may change without a major version

## Cause

`createMcpServer()` failed while initializing. Common reasons:
The MCP server failed while initializing. Common reasons:

- `@modelcontextprotocol/sdk` is not installed. This is a peer dependency — add it to your devtool's dependencies.
- The selected transport is not yet implemented (the first devframe MCP release ships with `stdio` only; `http` is planned).
- The underlying transport threw during `connect()` (e.g. stdin/stdout is not available).
- The stdio transport threw during `connect()` (e.g. stdin/stdout is not available).
- The route-based MCP server (`cli.mcp`) could not load its transport module — usually the missing SDK peer dependency.

## Fix

- **Missing SDK**: `pnpm add @modelcontextprotocol/sdk` (or npm/yarn equivalent) in the package that imports `devframe/adapters/mcp`.
- **Unsupported transport**: pass `{ transport: 'stdio' }` until HTTP support lands.
- **Missing SDK**: `pnpm add @modelcontextprotocol/sdk` (or npm/yarn equivalent) in the package that imports `devframe/adapters/mcp` or enables `cli.mcp`.
- **Transport init failure**: check the underlying error (attached as `cause`) for specifics.

## Source

- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — `createMcpServer()` throws `DF0017` when the requested transport is unsupported or when the underlying transport fails to `connect()`.
- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — `createMcpServer()` throws `DF0017` when the stdio transport fails to `connect()`.
- [`packages/devframe/src/adapters/dev.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/dev.ts) — `createDevServer()` throws `DF0017` (transport `http`) when the route-based MCP server can't load its transport module.
10 changes: 10 additions & 0 deletions packages/devframe/src/adapters/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export function createCli(d: DevframeDefinition, options: CreateCliOptions = {})
.option('--host <host>', 'Host to bind to', { default: defaultHost })
.option('--open', 'Open the browser on start')
.option('--no-open', 'Do not open the browser')
// Only `--mcp` is declared: CAC's `--no-*` auto-negation would inject a
// `true` default, silently enabling MCP. Declaring just `--mcp` yields the
// opt-in tri-state — absent → `undefined` (falls through to `cli.mcp`),
// `--mcp` → `true`, `--no-mcp` → `false` (handled by CAC's `--no-` prefix).
.option('--mcp', 'Expose an MCP server over HTTP at /__mcp (use --no-mcp to disable) [experimental]')

// Register typed flags from the definition ahead of `cli.configure`
// so authors can still override or augment via the escape hatch.
Expand All @@ -69,10 +74,15 @@ export function createCli(d: DevframeDefinition, options: CreateCliOptions = {})
const flags = resolveTypedFlags(d, rawFlags) as CliFlags
const host = (flags.host as string | undefined) ?? defaultHost
const port = (flags.port as number | undefined) ?? await resolveDevServerPort(d, { host, defaultPort })
// `--mcp` / `--no-mcp` map to a boolean override; when neither is
// passed CAC leaves `mcp` undefined so `createDevServer` falls through
// to `def.cli?.mcp`.
const mcp = flags.mcp as boolean | undefined
await createDevServer(d, {
host,
port,
flags,
mcp,
onReady: options.onReady,
})
})
Expand Down
66 changes: 63 additions & 3 deletions packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { StartedServer } from '../node/server'
import type { ConnectionMeta } from '../types/context'
import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions } from '../types/devframe'
import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe'
import process from 'node:process'
import { open } from 'devframe/utils/open'
import { mountStaticHandler } from 'devframe/utils/serve-static'
import { getPort } from 'get-port-please'
import { H3 } from 'h3'
import { resolve } from 'pathe'
import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash } from 'ufo'
import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants'
import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUTE } from '../constants'
import { createHostContext } from '../node/context'
import { diagnostics } from '../node/diagnostics'
import { createH3DevframeHost } from '../node/host-h3'
import { startHttpAndWs } from '../node/server'
import { normalizeBasePath, resolveBasePath } from './_shared'
Expand Down Expand Up @@ -64,6 +65,13 @@ export interface CreateDevServerOptions {
* sources; a string opens that relative path.
*/
openBrowser?: boolean | string
/**
* Expose a route-based MCP server on the dev server (Streamable-HTTP).
* Overrides `def.cli?.mcp`; `undefined` falls through to it. `false`
* disables the route regardless of the definition default. See
* {@link McpRouteOptions}.
*/
mcp?: boolean | McpRouteOptions
/**
* Called once the WS server is bound. Devframe stays headless
* otherwise — wire this if you want a startup banner.
Expand Down Expand Up @@ -149,6 +157,35 @@ export async function createDevServer(
const setupInfo: DevframeSetupInfo = { flags }
await def.setup(ctx, setupInfo)

// Route-based MCP server (opt-in via `cli.mcp` / the `mcp` option). Mounted
// before the SPA static catch-all so the exact `/__mcp` route wins, and
// advertised in `__connection.json` so in-browser tooling can discover it.
// The MCP SDK is an optional peer dep, so its code is only pulled in
// (dynamically) when the route is enabled.
const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp)
let mcpDispose: (() => Promise<void>) | undefined
let mcpMeta: ConnectionMeta['mcp']
if (mcpConfig) {
const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE)
const mcpPath = joinURL(basePath, mcpRoute)
let mountMcpHttp: typeof import('./mcp/http').mountMcpHttp
try {
;({ mountMcpHttp } = await import('./mcp/http'))
}
catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw diagnostics.DF0017({ transport: 'http', reason, cause: error })
}
const mounted = mountMcpHttp(app, ctx, mcpPath, {
serverName: `${def.id} (devframe)`,
serverVersion: def.version ?? '0.0.0',
exposeSharedState: true,
allowedOrigins: mcpConfig.allowedOrigins,
})
mcpDispose = mounted.dispose
mcpMeta = { path: mcpRoute }
}

// Connection meta — the SPA fetches this to discover the RPC backend. How
// the WS endpoint is bound and advertised follows the resolved ws config:
// a same-origin route (default, proxy-safe), a dedicated port, or a remote
Expand All @@ -159,12 +196,13 @@ export async function createDevServer(
app.use(connectionMetaPath, () => ({
backend: 'websocket',
websocket: meta,
...(mcpMeta ? { mcp: mcpMeta } : {}),
}))

if (distDir)
mountStaticHandler(app, basePath, resolve(distDir))

return startHttpAndWs({
const started = await startHttpAndWs({
context: ctx,
host,
port,
Expand All @@ -177,6 +215,28 @@ export async function createDevServer(
await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser)
},
})

// Fold MCP session teardown into the server's close so callers get a single
// graceful-shutdown handle.
if (mcpDispose) {
const closeServer = started.close
started.close = async () => {
await mcpDispose!()
await closeServer()
}
}

return started
}

/**
* Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into
* concrete options, or `undefined` when the MCP route is disabled.
*/
function resolveMcpConfig(mcp: boolean | McpRouteOptions | undefined): McpRouteOptions | undefined {
if (!mcp)
return undefined
return mcp === true ? {} : mcp
}

/**
Expand Down
142 changes: 142 additions & 0 deletions packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { StartedServer } from '../../../node/server'
import type { DevframeDefinition } from '../../../types/devframe'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { afterEach, describe, expect, it } from 'vitest'
import { createDevServer } from '../../dev'

function defineTestDef(overrides?: Partial<DevframeDefinition>): DevframeDefinition {
return {
id: 'mcp-http-test',
name: 'MCP HTTP Test',
version: '1.2.3',
packageName: '@devframe/mcp-http-test',
homepage: 'https://example.com',
description: 'Test fixture for the route-based MCP server.',
setup(ctx) {
ctx.agent.registerTool({
id: 'greet',
description: 'Say hello.',
safety: 'read',
handler: (args: { name?: string }) => ({ greeting: `hi ${args.name ?? 'there'}` }),
})
},
...overrides,
}
}

describe('mcp adapter (streamable http route)', () => {
let server: StartedServer | undefined

afterEach(async () => {
await server?.close()
server = undefined
})

async function boot(def = defineTestDef()): Promise<StartedServer> {
server = await createDevServer(def, { host: '127.0.0.1', mcp: true })
return server
}

it('advertises the mcp endpoint in __connection.json', async () => {
const started = await boot()
const res = await fetch(`${started.origin}/__connection.json`)
const meta = await res.json() as { backend: string, mcp?: { path: string } }
expect(meta.backend).toBe('websocket')
expect(meta.mcp).toEqual({ path: '__mcp' })
})

it('omits the mcp block when the route is disabled', async () => {
server = await createDevServer(defineTestDef(), { host: '127.0.0.1', mcp: false })
const res = await fetch(`${server.origin}/__connection.json`)
const meta = await res.json() as { mcp?: unknown }
expect(meta.mcp).toBeUndefined()
})

it('establishes a stateful session and lists agent tools', async () => {
const started = await boot()
const transport = new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`))
const client = new Client({ name: 'test-client', version: '0.0.0' })
try {
await client.connect(transport)
// Stateful mode issues an Mcp-Session-Id on initialize.
expect(transport.sessionId).toBeTypeOf('string')
expect(transport.sessionId!.length).toBeGreaterThan(0)

const tools = await client.listTools()
expect(tools.tools.map(t => t.name)).toContain('greet')

const result = await client.callTool({ name: 'greet', arguments: { name: 'devframe' } })
const content = result.content as Array<{ type: string, text: string }>
expect(JSON.parse(content[0]!.text)).toEqual({ greeting: 'hi devframe' })
}
finally {
await client.close()
}
})

it('tears the session down on DELETE and rejects reuse of the id', async () => {
const started = await boot()
const url = `${started.origin}/__mcp`

// Initialize over raw HTTP to capture the issued session id from the
// response header (the body is an SSE stream we can discard).
const init = await fetch(url, {

Check failure on line 84 in packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (windows-latest, 22)

[devframe] src/adapters/mcp/__tests__/mcp-http.test.ts > mcp adapter (streamable http route) > tears the session down on DELETE and rejects reuse of the id

TypeError: fetch failed ❯ src/adapters/mcp/__tests__/mcp-http.test.ts:84:18 Caused by: Caused by: Error: read ECONNRESET ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -4077, code: 'ECONNRESET', syscall: 'read' }
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
}),
})
const sessionId = init.headers.get('mcp-session-id')
await init.body?.cancel()
expect(sessionId).toBeTruthy()

// DELETE ends the session.
const del = await fetch(url, {
method: 'DELETE',
headers: { 'mcp-session-id': sessionId! },
})
await del.body?.cancel()
expect(del.status).toBeLessThan(300)

// Reusing the terminated id is no longer a known session — the server
// answers 404 rather than falling through to the SPA static catch-all.
const stale = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
'mcp-session-id': sessionId!,
},
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
})
await stale.body?.cancel()
expect(stale.status).toBe(404)
})

it('rejects a disallowed cross-origin request', async () => {
const started = await boot()
const res = await fetch(`${started.origin}/__mcp`, {

Check failure on line 126 in packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / test (ubuntu-latest, 22)

[devframe] src/adapters/mcp/__tests__/mcp-http.test.ts > mcp adapter (streamable http route) > rejects a disallowed cross-origin request

TypeError: fetch failed ❯ src/adapters/mcp/__tests__/mcp-http.test.ts:126:17 Caused by: Caused by: SocketError: other side closed ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'UND_ERR_SOCKET', socket: { localAddress: '127.0.0.1', localPort: 37294, remoteAddress: undefined, remotePort: undefined, remoteFamily: undefined, timeout: undefined, bytesWritten: 670, bytesRead: 136 } }
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
'origin': 'http://evil.example.com',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
}),
})
expect(res.status).toBe(403)
})
})
Loading
Loading