Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/content/1.guide/18.hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Registrations are validated fail-fast: one module per type (`DF8108`), an existi

The hub's **single Auth** is one gate at the shared transport for every mounted devframe, built-ins, and the MCP route; one handshake (OTP, magic link, or pre-shared token) unlocks the namespace; `auth: false` disables it for localhost.

The aggregate MCP route mounts through the `'auto'` default once any mounted devframe (or an agent-flagged hub command) exposes agent tools; `mcp: true` forces it on, `mcp: false` off. It has its own origin gate, independent of this RPC Auth: the mounted route trusts same-machine callers, and `mcp: { authorization }` adds an identity check when the hub is reachable beyond loopback. `mcp: { exposeSharedState }` takes `false` or a `(key) => boolean` filter to choose which shared-state keys agents can read through `devframe_state_read` and `devframe://state/<key>`. The hub exposes one aggregate route over every mounted devframe's tools.
The aggregate MCP route mounts through the `'auto'` default once any mounted devframe (or an agent-flagged hub command) exposes agent tools; `mcp: true` forces it on, `mcp: false` off. It has its own origin gate, independent of this RPC Auth: the mounted route trusts same-machine callers, and `mcp: { authorization }` adds an identity check when the hub is reachable beyond loopback. `mcp: { exposeSharedState }` takes `false` or a `(key) => boolean` filter to choose which shared-state keys agents can read through `devframe_state_read` and `devframe://state/<key>`. `mcp: { instructions }` sets the usage guidance that the route sends in the MCP `initialize` result. The hub exposes one aggregate route over every mounted devframe's tools.

## Singular vs hub mounting

Expand Down
12 changes: 12 additions & 0 deletions docs/content/2.adapters/7.mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ devframeViteBridge(myDevframe, {
})
```

### 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.

### Hosted bridges

Both bridges forward the setting to their side-car dev server, advertising the mounted endpoint in `__connection.json`:
Expand Down
23 changes: 22 additions & 1 deletion packages/agentic/src/mcp/__tests__/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ function nullHost(): DevframeHost {
}
}

async function bootPair(exposeSharedState: boolean | ((key: string) => boolean) = true) {
async function bootPair(exposeSharedState: boolean | ((key: string) => boolean) = true, instructions?: string) {
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })

const server = buildMcpServerFromContext(ctx, {
serverName: 'test',
serverVersion: '0.0.0-test',
exposeSharedState,
instructions,
})

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
Expand All @@ -39,6 +40,26 @@ async function bootPair(exposeSharedState: boolean | ((key: string) => boolean)
}

describe('mcp adapter (in-memory)', () => {
it('sends the server instructions in the handshake', async () => {
const { client, cleanup } = await bootPair(true, 'Call greet first.')
try {
expect(client.getInstructions()).toBe('Call greet first.')
}
finally {
await cleanup()
}
})

it('sends no instructions when none are set', async () => {
const { client, cleanup } = await bootPair()
try {
expect(client.getInstructions()).toBeUndefined()
}
finally {
await cleanup()
}
})

it('lists tools registered via ctx.agent.registerTool', async () => {
const { ctx, client, cleanup } = await bootPair()
try {
Expand Down
4 changes: 4 additions & 0 deletions packages/agentic/src/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface BuildMcpServerOptions {
serverName: string
serverVersion: string
exposeSharedState: boolean | ((k: string) => boolean)
/** Sent as `instructions` in the `initialize` / `server/discover` result. */
instructions?: string
}

/**
Expand Down Expand Up @@ -43,6 +45,7 @@ export function buildMcpServerFromContext(
tools: { listChanged: true },
resources: { listChanged: true },
},
...(options.instructions ? { instructions: options.instructions } : {}),
},
)

Expand Down Expand Up @@ -121,6 +124,7 @@ export async function createMcpServer(
serverName: options.serverName ?? `${definition.id} (devframe)`,
serverVersion: options.serverVersion ?? definition.version ?? '0.0.0',
exposeSharedState: options.exposeSharedState ?? true,
instructions: options.instructions,
}

// `serveStdio` pins ONE instance for the connection's lifetime. Each pinned
Expand Down
1 change: 1 addition & 0 deletions packages/agentic/src/mcp/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function createMcpFetchHandler(
serverName: options.serverName,
serverVersion: options.serverVersion,
exposeSharedState: options.exposeSharedState,
instructions: options.instructions,
}))

// A single, long-lived bridge from devframe's change events onto the
Expand Down
33 changes: 33 additions & 0 deletions packages/devframe/src/adapters/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,39 @@ describe('adapters/handler', () => {
}
})

it('mcp: instructions reach the client in the initialize result', async () => {
const wsPort = await getPort({ port: 18145, host: '127.0.0.1' })
const devtools = initDevframe(defineTestDef('handler-mcp-instructions'), {
base: '/__handler-mcp-instructions/',
auth: false,
mcp: { instructions: 'Call list-pages first.' },
ws: { port: wsPort },
})

try {
await devtools.ready
const res = await devtools.handler(new Request('http://localhost:3000/__handler-mcp-instructions/__mcp', {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
'origin': 'http://localhost:3000',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '0.0.0' } },
}),
}))
expect(res.status).toBe(200)
expect(await res.text()).toContain('"instructions":"Call list-pages first."')
}
finally {
await devtools.close()
}
})

// The `'auto'` default: an omitted `mcp` mounts the route exactly when
// `setup()` left a non-empty agent surface.
function defineAgentTestDef(id: string) {
Expand Down
3 changes: 3 additions & 0 deletions packages/devframe/src/adapters/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface ResolvedMcpConfig {
allowedOrigins?: readonly string[] | false
/** Shared-state exposure from the route options; omitted means every key. */
exposeSharedState?: boolean | ((key: string) => boolean)
/** Handshake `instructions` from the route options; omitted means none. */
instructions?: string
/** The resolved identity policy: a bearer token, callback, or `false`. */
authorization: McpAuthorization
}
Expand Down Expand Up @@ -99,6 +101,7 @@ export function resolveMcpConfig(mcp: McpSetting | undefined): ResolvedMcpConfig
...(mcp.path !== undefined ? { path: mcp.path } : {}),
...(mcp.allowedOrigins !== undefined ? { allowedOrigins: mcp.allowedOrigins } : {}),
...(mcp.exposeSharedState !== undefined ? { exposeSharedState: mcp.exposeSharedState } : {}),
...(mcp.instructions !== undefined ? { instructions: mcp.instructions } : {}),
authorization,
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ async function mountMcpRoute(
serverName: `${def.id} (devframe)`,
serverVersion: def.version ?? '0.0.0',
exposeSharedState: config.exposeSharedState ?? true,
instructions: config.instructions,
authorization: config.authorization,
allowedOrigins: config.allowedOrigins,
})
Expand Down
7 changes: 7 additions & 0 deletions packages/devframe/src/types/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ export interface McpRouteOptions {
* The states themselves stay available to the UI over RPC.
*/
exposeSharedState?: boolean | ((key: string) => boolean)
/**
* Usage guidance for the route, sent as `instructions` in the MCP
* `initialize` (and `server/discover`) result. An MCP client can add it to
* the coding agent's context, for example to say which tool to call first.
* Omitted or empty sends no instructions.
*/
instructions?: string
}

export interface DevframeCliOptions {
Expand Down
7 changes: 7 additions & 0 deletions packages/devframe/src/types/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export interface CreateMcpServerOptions {
* - `(key) => boolean`: filter
*/
exposeSharedState?: boolean | ((key: string) => boolean)
/**
* Usage guidance sent as `instructions` in the MCP `initialize` (and
* `server/discover`) result. Omitted or empty sends no instructions.
*/
instructions?: string
/** Override the name reported in the MCP handshake. */
serverName?: string
/** Override the version reported in the MCP handshake. Defaults to `definition.version ?? '0.0.0'`. */
Expand All @@ -44,6 +49,8 @@ export interface CreateMcpFetchHandlerOptions {
serverVersion: string
/** Expose shared-state keys as MCP resources; see {@link CreateMcpServerOptions.exposeSharedState}. */
exposeSharedState: boolean | ((key: string) => boolean)
/** Usage guidance for the handshake; see {@link CreateMcpServerOptions.instructions}. */
instructions?: string
/**
* Optional identity check, layered on top of the origin gate and checked
* **after** it: a bearer token string (matched in constant time against
Expand Down
58 changes: 58 additions & 0 deletions packages/hub/src/node/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,64 @@ describe('initHub', () => {
})
})

describe('aggregate MCP instructions', () => {
const origin = 'http://localhost:3000'

async function initialize(hub: ReturnType<typeof initHub>): Promise<string> {
const res = await hub.handler(new Request(`${origin}/__devframes/__mcp`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
origin,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '0.0.0' } },
}),
}))
expect(res.status).toBe(200)
return await res.text()
}

it('sends no instructions by default', async () => {
const wsPort = await getPort({ port: 18238, host: '127.0.0.1' })
const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, mcp: true, devframes: [makeFrame('alpha')] })

try {
await hub.ready
const raw = await initialize(hub)
expect(raw).toContain('"serverInfo"')
expect(raw).not.toContain('"instructions"')
}
finally {
await hub.close()
}
})

it('sends mcp.instructions in the initialize result', async () => {
const wsPort = await getPort({ port: 18239, host: '127.0.0.1' })
const hub = initHub({
base: DEVFRAMES_HUB_BASE,
auth: false,
host: '127.0.0.1',
ws: { port: wsPort },
mcp: { instructions: 'Call alpha-tool first.' },
devframes: [makeFrame('alpha')],
})

try {
await hub.ready
expect(await initialize(hub)).toContain('"instructions":"Call alpha-tool first."')
}
finally {
await hub.close()
}
})
})

it('aggregate MCP omitted: mounts once a mounted frame exposes agent tools', async () => {
const wsPort = await getPort({ port: 18233, host: '127.0.0.1' })
const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, devframes: [makeFrame('alpha')] })
Expand Down
1 change: 1 addition & 0 deletions packages/hub/src/node/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ export function initHub(options: InitHubOptions): HubInstance {
serverName: options.name ?? 'devframes-hub',
serverVersion: options.version ?? '0.0.0',
exposeSharedState: mcpConfig.exposeSharedState ?? true,
instructions: mcpConfig.instructions,
authorization: mcpConfig.authorization,
allowedOrigins: mcpConfig.allowedOrigins,
})
Expand Down
3 changes: 3 additions & 0 deletions packages/next/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface DevframeNextHostMcpOptions {
serverVersion?: string
/** Expose shared-state keys as MCP resources / `devframe:state:read`. Default: `true`. */
exposeSharedState?: boolean | ((key: string) => boolean)
/** Usage guidance sent as `instructions` in the MCP handshake. Default: none. */
instructions?: string
/**
* Origin allow-list beyond the loopback default. `false` disables the
* origin gate entirely. Note the MCP route rejects `Origin`-less requests
Expand Down Expand Up @@ -180,6 +182,7 @@ export function createDevframeNextHost(
serverName: mcpOptions.serverName ?? 'devframe (next)',
serverVersion: mcpOptions.serverVersion ?? '0.0.0',
exposeSharedState: mcpOptions.exposeSharedState ?? true,
instructions: mcpOptions.instructions,
authorization: mcpOptions.authorization,
allowedOrigins: mcpOptions.allowedOrigins,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface DevframeNextHostMcpOptions {
serverName?: string;
serverVersion?: string;
exposeSharedState?: boolean | ((_: string) => boolean);
instructions?: string;
allowedOrigins?: readonly string[] | false;
}
export interface NextDevframeHubHandle {
Expand Down
3 changes: 3 additions & 0 deletions tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,14 @@ export interface CreateMcpFetchHandlerOptions {
serverName: string;
serverVersion: string;
exposeSharedState: boolean | ((_: string) => boolean);
instructions?: string;
authorization?: McpAuthorization;
allowedOrigins?: readonly string[] | false;
}
export interface CreateMcpServerOptions {
transport?: 'stdio';
exposeSharedState?: boolean | ((_: string) => boolean);
instructions?: string;
serverName?: string;
serverVersion?: string;
onReady?: (_: {
Expand Down Expand Up @@ -447,6 +449,7 @@ export interface McpRouteOptions {
authorization?: McpAuthorization;
allowedOrigins?: readonly string[] | false;
exposeSharedState?: boolean | ((_: string) => boolean);
instructions?: string;
}
export interface McpServerHandle {
stop: () => Promise<void>;
Expand Down
Loading