diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 3394526f..3d3f787d 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 269a647c..a7852b73 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 21b7a9f0..dff7350f 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 0860f5da..5877485b 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 f8378dd0..841af22e 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 8133c92e..78277a53 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 a06f01cd..0930b62b 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 2b000d88..6fae86f3 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 719c125d..bbebcb1c 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 cdb9e003..551d9dd1 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 00c5d9c1..3f8e54b2 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 a3285c24..2af9db7f 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 cdc506b3..559f8cc3 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 834560c3..b5ea1700 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 f54ac769..d9c9a51c 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;