From 778184e9e09ebec189b0113bfb2a1460eb531da8 Mon Sep 17 00:00:00 2001 From: erKam Date: Wed, 14 Oct 2026 08:15:33 +0000 Subject: [PATCH] feat(mcp): add instructions to the mcp route options The MCP initialize result can carry instructions that an MCP client adds to the context of the coding agent. The mcp route options, createMcpServer, createMcpFetchHandler and the Next.js host now take instructions, and both mounts (initDevframe and initHub) send them in the initialize result. Omitted or empty instructions send nothing. --- docs/content/1.guide/18.hub-initiate.md | 2 +- docs/content/2.adapters/7.mcp.md | 12 ++++ .../src/mcp/__tests__/mcp-server.test.ts | 23 +++++++- packages/agentic/src/mcp/build-server.ts | 4 ++ packages/agentic/src/mcp/fetch.ts | 1 + .../src/adapters/__tests__/initiate.test.ts | 33 +++++++++++ packages/devframe/src/adapters/_shared.ts | 3 + packages/devframe/src/adapters/initiate.ts | 1 + packages/devframe/src/types/devframe.ts | 7 +++ packages/devframe/src/types/mcp.ts | 7 +++ .../hub/src/node/__tests__/initiate.test.ts | 58 +++++++++++++++++++ packages/hub/src/node/initiate.ts | 1 + packages/next/src/host.ts | 3 + .../tsnapi/@devframes/next/hub.snapshot.d.ts | 1 + .../tsnapi/devframe/index.snapshot.d.ts | 3 + 15 files changed, 157 insertions(+), 2 deletions(-) diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 3394526f3..3d3f787de 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -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/`. 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/`. `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 diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index 269a647c8..a7852b739 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -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`: diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index 21b7a9f0b..dff7350f5 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -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() @@ -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 { diff --git a/packages/agentic/src/mcp/build-server.ts b/packages/agentic/src/mcp/build-server.ts index 0860f5da1..5877485b8 100644 --- a/packages/agentic/src/mcp/build-server.ts +++ b/packages/agentic/src/mcp/build-server.ts @@ -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 } /** @@ -43,6 +45,7 @@ export function buildMcpServerFromContext( tools: { listChanged: true }, resources: { listChanged: true }, }, + ...(options.instructions ? { instructions: options.instructions } : {}), }, ) @@ -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 diff --git a/packages/agentic/src/mcp/fetch.ts b/packages/agentic/src/mcp/fetch.ts index f8378dd05..841af22e6 100644 --- a/packages/agentic/src/mcp/fetch.ts +++ b/packages/agentic/src/mcp/fetch.ts @@ -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 diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 8133c92ec..78277a534 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -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) { diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts index a06f01cdf..0930b62bc 100644 --- a/packages/devframe/src/adapters/_shared.ts +++ b/packages/devframe/src/adapters/_shared.ts @@ -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 } @@ -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, } } diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 2b000d88c..6fae86f3f 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -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, }) diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 719c125dd..bbebcb1c1 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -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 { diff --git a/packages/devframe/src/types/mcp.ts b/packages/devframe/src/types/mcp.ts index cdb9e003a..551d9dd11 100644 --- a/packages/devframe/src/types/mcp.ts +++ b/packages/devframe/src/types/mcp.ts @@ -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'`. */ @@ -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 diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 00c5d9c14..3f8e54b27 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -423,6 +423,64 @@ describe('initHub', () => { }) }) + describe('aggregate MCP instructions', () => { + const origin = 'http://localhost:3000' + + async function initialize(hub: ReturnType): Promise { + 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')] }) diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index a3285c240..2af9db7f7 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -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, }) diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index cdc506b35..559f8cc35 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -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 @@ -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, }) diff --git a/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts index 834560c33..b5ea1700b 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts @@ -20,6 +20,7 @@ export interface DevframeNextHostMcpOptions { serverName?: string; serverVersion?: string; exposeSharedState?: boolean | ((_: string) => boolean); + instructions?: string; allowedOrigins?: readonly string[] | false; } export interface NextDevframeHubHandle { diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index f54ac7698..d9c9a51cd 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -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?: (_: { @@ -447,6 +449,7 @@ export interface McpRouteOptions { authorization?: McpAuthorization; allowedOrigins?: readonly string[] | false; exposeSharedState?: boolean | ((_: string) => boolean); + instructions?: string; } export interface McpServerHandle { stop: () => Promise;