From 5fdebd236fb6ac84f0b85d69edc1fc7ada5cad3a Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 31 Oct 2026 20:57:24 +0000 Subject: [PATCH 1/2] feat(mcp): add prompts to the mcp route options The route options, `createMcpServer` and `createMcpFetchHandler` take a `prompts` list. The MCP server declares the `prompts` capability for a non-empty list and serves `prompts/list` and `prompts/get`. An unknown name, or a required argument that is missing or empty, gets invalid params. A repeated name throws DF0085 when the route mounts. --- docs/content/1.guide/18.hub-initiate.md | 2 +- docs/content/2.adapters/7.mcp.md | 21 ++++ docs/content/6.errors/DF0085.md | 33 +++++ docs/content/6.errors/index.md | 1 + .../src/mcp/__tests__/mcp-http.test.ts | 22 ++++ .../src/mcp/__tests__/mcp-server.test.ts | 113 +++++++++++++++++- packages/agentic/src/mcp/build-server.ts | 63 +++++++++- packages/agentic/src/mcp/fetch.ts | 5 +- packages/devframe/src/adapters/_shared.ts | 5 +- packages/devframe/src/adapters/initiate.ts | 1 + packages/devframe/src/adapters/mcp.ts | 4 + packages/devframe/src/node/diagnostics.ts | 5 + packages/devframe/src/types/devframe.ts | 7 +- packages/devframe/src/types/mcp.ts | 42 +++++++ .../hub/src/node/__tests__/initiate.test.ts | 26 ++++ packages/hub/src/node/initiate.ts | 1 + packages/next/src/host.ts | 5 +- .../tsnapi/@devframes/next/hub.snapshot.d.ts | 1 + .../devframe/adapters/mcp.snapshot.d.ts | 4 + .../tsnapi/devframe/index.snapshot.d.ts | 26 ++++ .../tsnapi/devframe/internal.snapshot.d.ts | 6 + .../tsnapi/devframe/types.snapshot.d.ts | 4 + 22 files changed, 386 insertions(+), 11 deletions(-) create mode 100644 docs/content/6.errors/DF0085.md diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index cef8d5e0..d45fbdd1 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/`. `mcp: { instructions }` sets the usage guidance that the route sends in the MCP `initialize` result. `mcp: { resourceTemplates }` adds resource templates that coding agents list through `resources/templates/list` and read through `resources/read`, and `mcp: { onResourcesChanged }` sends `notifications/resources/list_changed` each time its `notify()` runs. 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. `mcp: { resourceTemplates }` adds resource templates that coding agents list through `resources/templates/list` and read through `resources/read`, and `mcp: { onResourcesChanged }` sends `notifications/resources/list_changed` each time its `notify()` runs. `mcp: { prompts }` adds prompt templates that coding agents list through `prompts/list` and build through `prompts/get`. 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 7e9aa838..7767de6a 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -120,6 +120,27 @@ devframeViteBridge(myDevframe, { }) ``` +### Prompts + +`prompts` on the route options is a list of prompt templates that the MCP server serves through `prompts/list` and `prompts/get`. A coding agent usually offers each one to the user as a slash command. Each prompt has a `name`, an optional `title`, `description` and `arguments`, and a `get(args)` function that can be sync or async. Each argument has a `name`, an optional `description`, and `required`. + +```ts +devframeViteBridge(myDevframe, { + mcp: { + prompts: [{ + name: 'explain-module', + title: 'Explain a module', + arguments: [{ name: 'id', description: 'The module id.', required: true }], + get: ({ id }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Read my-tool://module/${encodeURIComponent(id)} and explain what the module does.` } }], + }), + }], + }, +}) +``` + +`get` returns the `messages`, and an optional `description`. `prompts/get` answers invalid params for an unknown name, or when a required argument is missing or empty, and then does not call `get`. An error thrown by `get` goes to the MCP client. Each `name` is used once: a repeated name throws [DF0085](/errors/DF0085) when the route mounts. With an empty or omitted list, the server declares no `prompts` capability. `createMcpServer` and `createMcpFetchHandler` take the same `prompts` option. + ### Hosted bridges Both bridges forward the setting to their side-car dev server, advertising the mounted endpoint in `__connection.json`: diff --git a/docs/content/6.errors/DF0085.md b/docs/content/6.errors/DF0085.md new file mode 100644 index 00000000..7e77a27c --- /dev/null +++ b/docs/content/6.errors/DF0085.md @@ -0,0 +1,33 @@ +--- +title: 'DF0085: Repeated MCP Prompt Name' +description: 'MCP prompt "{name}" is listed more than once.' +--- + +## Message + +> MCP prompt "`{name}`" is listed more than once. + +## Cause + +Two entries in the `prompts` list of the MCP route options (or of `createMcpServer` / `createMcpFetchHandler`) have the same `name`. `prompts/get` finds a prompt by its name, so the second one could never be reached. The check runs once, when the MCP route mounts or `createMcpServer` starts, so no request is served with an ambiguous list. + +## Example + +```ts +initDevframe(myDevframe, { + mcp: { + prompts: [ + { name: 'review', get: () => ({ messages: [] }) }, + { name: 'review', get: () => ({ messages: [] }) }, // same name → DF0085 + ], + }, +}) +``` + +## Fix + +Give each prompt its own `name`, or merge the two into one prompt with an argument that picks between them. + +## Source + +- [`packages/agentic/src/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/agentic/src/mcp/build-server.ts): `compilePrompts()` throws this for a repeated name. `createMcpFetchHandler()` (which `mountMcpHttp()` and the MCP route use) and `createMcpServer()` call it once, before they serve any request. diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index 6a158387..95790b42 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -90,6 +90,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi | [DF0081](/errors/DF0081) | error | Invalid MCP Content Blocks | | [DF0082](/errors/DF0082) | error | Addressed Client Not Connected | | [DF0084](/errors/DF0084) | error | Invalid MCP Resource Template | +| [DF0085](/errors/DF0085) | error | Repeated MCP Prompt Name | ## Hub: context & lifecycle (DF80xx) diff --git a/packages/agentic/src/mcp/__tests__/mcp-http.test.ts b/packages/agentic/src/mcp/__tests__/mcp-http.test.ts index ba56976b..c525df07 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-http.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-http.test.ts @@ -130,6 +130,28 @@ describe('mcp adapter (streamable http route)', () => { } }) + it('serves mcp.prompts through prompts/list and prompts/get', async () => { + const started = await boot({ + prompts: [{ + name: 'greet', + arguments: [{ name: 'who', required: true }], + get: ({ who }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `Greet ${who}.` } }] }), + }], + }) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + try { + await client.connect(originTransport(started)) + const { prompts } = await client.listPrompts() + expect(prompts).toEqual([{ name: 'greet', arguments: [{ name: 'who', required: true }] }]) + + const prompt = await client.getPrompt({ name: 'greet', arguments: { who: 'the team' } }) + expect(prompt.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Greet the team.' } }]) + } + finally { + await client.close() + } + }) + it('serves mcp.resourceTemplates through resources/templates/list and resources/read', async () => { const started = await boot({ resourceTemplates: [{ diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index 6dfac6f4..f996e0f9 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -1,11 +1,11 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' -import type { DevframeHost, McpContentBlock, McpResourceTemplate } from 'devframe/types' +import type { DevframeHost, McpContentBlock, McpPrompt, McpResourceTemplate } from 'devframe/types' import { Client, InMemoryTransport } from '@modelcontextprotocol/client' import { withMcpContent } from 'devframe' import { createHostContext } from 'devframe/node' import * as v from 'valibot' import { describe, expect, it, vi } from 'vitest' -import { bridgeListChanged, buildMcpServerFromContext, compileResourceTemplates } from '../build-server' +import { bridgeListChanged, buildMcpServerFromContext, compilePrompts, compileResourceTemplates } from '../build-server' /** A 1x1 PNG. */ const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' @@ -32,7 +32,7 @@ function nullHost(): DevframeHost { async function bootPair( exposeSharedState: boolean | ((key: string) => boolean) = true, - { instructions, resourceTemplates }: { instructions?: string, resourceTemplates?: McpResourceTemplate[] } = {}, + { instructions, resourceTemplates, prompts }: { instructions?: string, resourceTemplates?: McpResourceTemplate[], prompts?: McpPrompt[] } = {}, ) { const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) @@ -42,6 +42,7 @@ async function bootPair( exposeSharedState, instructions, resourceTemplates: compileResourceTemplates(resourceTemplates), + prompts: compilePrompts(prompts), }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() @@ -776,6 +777,112 @@ describe('mcp adapter (in-memory)', () => { }) }) + describe('prompts', () => { + const review: McpPrompt = { + name: 'review', + title: 'Review a file', + description: 'Ask for a review of one file.', + arguments: [ + { name: 'file', description: 'The file to review.', required: true }, + { name: 'focus' }, + ], + get: ({ file, focus }) => ({ + description: `Review ${file}`, + messages: [{ role: 'user', content: { type: 'text', text: `Review ${file}${focus ? ` for ${focus}` : ''}.` } }], + }), + } + const hello: McpPrompt = { + name: 'hello', + get: async () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'Hello.' } }] }), + } + + it('declares the prompts capability only when prompts are set', async () => { + const withPrompts = await bootPair(true, { prompts: [hello] }) + const without = await bootPair(true) + try { + expect(withPrompts.client.getServerCapabilities()?.prompts).toEqual({ listChanged: false }) + expect(without.client.getServerCapabilities()?.prompts).toBeUndefined() + } + finally { + await withPrompts.cleanup() + await without.cleanup() + } + }) + + it('lists each prompt with its arguments', async () => { + const { client, cleanup } = await bootPair(true, { prompts: [review, hello] }) + try { + const { prompts } = await client.listPrompts() + expect(prompts).toEqual([ + { + name: 'review', + title: 'Review a file', + description: 'Ask for a review of one file.', + arguments: [ + { name: 'file', description: 'The file to review.', required: true }, + { name: 'focus' }, + ], + }, + { name: 'hello' }, + ]) + } + finally { + await cleanup() + } + }) + + it('builds the messages from the arguments, sync or async', async () => { + const { client, cleanup } = await bootPair(true, { prompts: [review, hello] }) + try { + expect(await client.getPrompt({ name: 'review', arguments: { file: 'src/main.ts', focus: 'errors' } })).toEqual({ + description: 'Review src/main.ts', + messages: [{ role: 'user', content: { type: 'text', text: 'Review src/main.ts for errors.' } }], + }) + expect(await client.getPrompt({ name: 'hello' })).toEqual({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello.' } }], + }) + } + finally { + await cleanup() + } + }) + + it('answers invalid params for an unknown prompt or a missing required argument', async () => { + const get = vi.fn(review.get) + const { client, cleanup } = await bootPair(true, { prompts: [{ ...review, get }] }) + try { + await expect(client.getPrompt({ name: 'nope' })).rejects.toThrow('unknown prompt "nope"') + await expect(client.getPrompt({ name: 'review' })).rejects.toThrow('needs the argument(s): file') + await expect(client.getPrompt({ name: 'review', arguments: { file: '' } })).rejects.toThrow('needs the argument(s): file') + expect(get).not.toHaveBeenCalled() + } + finally { + await cleanup() + } + }) + + it('sends an error thrown by get to the client', async () => { + const broken: McpPrompt = { + name: 'broken', + get: () => { + throw new Error('no ideas left') + }, + } + const { client, cleanup } = await bootPair(true, { prompts: [broken] }) + try { + await expect(client.getPrompt({ name: 'broken' })).rejects.toThrow('no ideas left') + } + finally { + await cleanup() + } + }) + + it('throws DF0085 for a repeated prompt name', () => { + expect(() => compilePrompts([hello, { ...hello }])).toThrow(expect.objectContaining({ code: 'DF0085' })) + expect([...compilePrompts([review, hello]).keys()]).toEqual(['review', 'hello']) + }) + }) + describe('onResourcesChanged', () => { it('sends resources list_changed on each notify, and unsubscribes on teardown', async () => { const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) diff --git a/packages/agentic/src/mcp/build-server.ts b/packages/agentic/src/mcp/build-server.ts index eb644c34..54808516 100644 --- a/packages/agentic/src/mcp/build-server.ts +++ b/packages/agentic/src/mcp/build-server.ts @@ -1,10 +1,10 @@ import type { Tool } from '@modelcontextprotocol/server' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc' -import type { AgentTool, CreateMcpServerOptions, DevframeDefinition, DevframeHost, DevframeNodeContext, McpContentBlock, McpResourcesChangedHook, McpResourceTemplate, McpResourceTemplateEntry, McpServerHandle } from 'devframe/types' +import type { AgentTool, CreateMcpServerOptions, DevframeDefinition, DevframeHost, DevframeNodeContext, McpContentBlock, McpPrompt, McpResourcesChangedHook, McpResourceTemplate, McpResourceTemplateEntry, McpServerHandle } from 'devframe/types' import { homedir } from 'node:os' import process from 'node:process' -import { Server } from '@modelcontextprotocol/server' +import { ProtocolError, ProtocolErrorCode, Server } from '@modelcontextprotocol/server' import { DEVFRAME_EVENTS } from 'devframe/constants' import { argsToJsonSchema, diagnostics, formatMcpError, getMcpContent, returnToJsonSchema, stringifyForMcp } from 'devframe/internal' import { createHostContext } from 'devframe/node' @@ -22,6 +22,11 @@ export interface BuildMcpServerOptions { * omitted or empty keeps the resource handlers unchanged. */ resourceTemplates?: CompiledResourceTemplate[] + /** + * Prompts by name (see {@link compilePrompts}); omitted or empty declares + * no `prompts` capability. + */ + prompts?: Map } /** @@ -60,6 +65,7 @@ export function buildMcpServerFromContext( capabilities: { tools: { listChanged: true }, resources: { listChanged: true }, + ...(options.prompts?.size ? { prompts: { listChanged: false } } : {}), }, ...(options.instructions ? { instructions: options.instructions } : {}), }, @@ -73,6 +79,8 @@ export function buildMcpServerFromContext( resourceTemplates: templates.map(({ template: { uriTemplate, name, title, description, mimeType } }) => ({ uriTemplate, name, title, description, mimeType })), })) } + if (options.prompts?.size) + registerPromptHandlers(server, options.prompts) return server } @@ -127,8 +135,10 @@ export async function createMcpServer( const transport = options.transport ?? 'stdio' if (transport !== 'stdio') throw diagnostics.DF0017({ transport, reason: 'Only stdio transport is supported in this release.' }) - // Compile before setup, so an invalid template fails fast (DF0084). + // Compile before setup, so an invalid template fails fast (DF0084), and + // so does a repeated prompt name (DF0085). const resourceTemplates = compileResourceTemplates(options.resourceTemplates) + const prompts = compilePrompts(options.prompts) const host: DevframeHost = { mountStatic: () => { /* MCP has no static surface */ }, @@ -160,6 +170,7 @@ export async function createMcpServer( exposeSharedState: options.exposeSharedState ?? true, instructions: options.instructions, resourceTemplates, + prompts, } // `serveStdio` pins ONE instance for the connection's lifetime. Each pinned @@ -432,6 +443,52 @@ function registerResourceHandlers( }) } +/** + * Index prompts by name, once per endpoint. A repeated name throws + * `DF0085`, since `prompts/get` finds a prompt by its name. + * + * @internal + */ +export function compilePrompts(prompts: McpPrompt[] = []): Map { + const byName = new Map() + for (const prompt of prompts) { + if (byName.has(prompt.name)) + throw diagnostics.DF0085({ name: prompt.name }) + byName.set(prompt.name, prompt) + } + return byName +} + +/** + * Serve `prompts/list` and `prompts/get`. An unknown name, or a required + * argument that is missing or empty, is an invalid-params error. An error + * thrown by `get` goes to the MCP client. + */ +function registerPromptHandlers(server: Server, prompts: Map): void { + server.setRequestHandler('prompts/list', async () => ({ + prompts: [...prompts.values()].map(({ name, title, description, arguments: args }) => ({ + name, + title, + description, + ...(args?.length ? { arguments: args.map(argument => ({ name: argument.name, description: argument.description, required: argument.required })) } : {}), + })), + })) + + server.setRequestHandler('prompts/get', async (request) => { + const { name, arguments: given = {} } = request.params + const prompt = prompts.get(name) + if (!prompt) + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `[devframe/mcp] unknown prompt "${name}"`) + const missing = (prompt.arguments ?? []) + .filter(argument => argument.required && !given[argument.name]) + .map(argument => argument.name) + if (missing.length) + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `[devframe/mcp] prompt "${name}" needs the argument(s): ${missing.join(', ')}`) + const { description, messages } = await prompt.get(given) + return { description, messages } + }) +} + /** One `{name}` expression of a URI template. */ const TEMPLATE_EXPRESSION_RE = /\{([^{}]*)\}/ /** RFC 6570 `varname`: letters, digits and `_`, with single dots between them. */ diff --git a/packages/agentic/src/mcp/fetch.ts b/packages/agentic/src/mcp/fetch.ts index dc78f84a..d25fcb7e 100644 --- a/packages/agentic/src/mcp/fetch.ts +++ b/packages/agentic/src/mcp/fetch.ts @@ -2,7 +2,7 @@ import type { CreateMcpFetchHandlerOptions, DevframeNodeContext, McpAuthorizatio import { createMcpHandler } from '@modelcontextprotocol/server' import { timingSafeEqual } from 'devframe/utils/crypto-token' import { isAllowedOrigin, isLoopbackAddress } from 'devframe/utils/origin' -import { bridgeListChanged, buildMcpServerFromContext, compileResourceTemplates } from './build-server' +import { bridgeListChanged, buildMcpServerFromContext, compilePrompts, compileResourceTemplates } from './build-server' /** * Parse exactly one `Authorization: Bearer ` credential, returning the @@ -76,6 +76,8 @@ export function createMcpFetchHandler( // Compiled once for the endpoint, not per request; an invalid template // fails here, at mount time (DF0084). const resourceTemplates = compileResourceTemplates(options.resourceTemplates) + // A repeated prompt name fails here too (DF0085). + const prompts = compilePrompts(options.prompts) const handler = createMcpHandler(() => buildMcpServerFromContext(ctx, { serverName: options.serverName, @@ -83,6 +85,7 @@ export function createMcpFetchHandler( exposeSharedState: options.exposeSharedState, instructions: options.instructions, resourceTemplates, + prompts, })) // A single, long-lived bridge from devframe's change events onto the diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts index 730c372a..9017f32f 100644 --- a/packages/devframe/src/adapters/_shared.ts +++ b/packages/devframe/src/adapters/_shared.ts @@ -2,7 +2,7 @@ import type { AgenticMcpModule } from '../node/agentic' import type { DevframeAgentHost } from '../types/agent' import type { ConnectionMeta } from '../types/context' import type { DevframeDefinition, DevframeDeploymentKind, McpAuthorization, McpSetting } from '../types/devframe' -import type { McpResourcesChangedHook, McpResourceTemplate } from '../types/mcp' +import type { McpPrompt, McpResourcesChangedHook, McpResourceTemplate } from '../types/mcp' import { cleanDoubleSlashes, withLeadingSlash, withoutLeadingSlash, withTrailingSlash } from 'devframe/utils/url' import { getPort } from 'get-port-please' import { DEVFRAME_MCP_ROUTE } from '../constants' @@ -77,6 +77,8 @@ export interface ResolvedMcpConfig { resourceTemplates?: McpResourceTemplate[] /** Resource-list change subscription from the route options; omitted means none. */ onResourcesChanged?: McpResourcesChangedHook + /** Prompt templates from the route options; omitted means none. */ + prompts?: McpPrompt[] /** The resolved identity policy: a bearer token, callback, or `false`. */ authorization: McpAuthorization } @@ -109,6 +111,7 @@ export function resolveMcpConfig(mcp: McpSetting | undefined): ResolvedMcpConfig ...(mcp.instructions !== undefined ? { instructions: mcp.instructions } : {}), ...(mcp.resourceTemplates !== undefined ? { resourceTemplates: mcp.resourceTemplates } : {}), ...(mcp.onResourcesChanged !== undefined ? { onResourcesChanged: mcp.onResourcesChanged } : {}), + ...(mcp.prompts !== undefined ? { prompts: mcp.prompts } : {}), authorization, } } diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 591f9573..8b554e11 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -383,6 +383,7 @@ async function mountMcpRoute( instructions: config.instructions, resourceTemplates: config.resourceTemplates, onResourcesChanged: config.onResourcesChanged, + prompts: config.prompts, authorization: config.authorization, allowedOrigins: config.allowedOrigins, }) diff --git a/packages/devframe/src/adapters/mcp.ts b/packages/devframe/src/adapters/mcp.ts index 27531d04..be43d17d 100644 --- a/packages/devframe/src/adapters/mcp.ts +++ b/packages/devframe/src/adapters/mcp.ts @@ -9,6 +9,10 @@ export type { CreateMcpServerOptions, McpConnectionInfo, McpFetchHandler, + McpPrompt, + McpPromptArgument, + McpPromptMessage, + McpPromptResult, McpResourcesChangedHook, McpResourceTemplate, McpResourceTemplateContent, diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 3feec17d..981cf2c7 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -235,5 +235,10 @@ export const diagnostics = defineDiagnostics({ `MCP resource template "${p.name}" has an invalid \`uriTemplate\` "${p.uriTemplate}": ${p.reason}`, fix: 'Use RFC 6570 level 1 only: plain `{name}` expressions made of letters, digits, `_` and `.`, with no operator (`+`, `#`, `/`, `?`, …), modifier (`:n`, `*`) or list of names, and no `{` or `}` outside an expression.', }, + DF0085: { + why: (p: { name: string }) => + `MCP prompt "${p.name}" is listed more than once.`, + fix: 'Give each entry in `prompts` its own `name`. `prompts/get` finds a prompt by its name, so two prompts cannot share one.', + }, }, }) diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index f6eea2d8..e845be68 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -2,7 +2,7 @@ import type { CAC } from 'cac' import type { CliFlagsSchema } from '../adapters/flags' import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeNodeContext } from './context' -import type { McpResourcesChangedHook, McpResourceTemplate } from './mcp' +import type { McpPrompt, McpResourcesChangedHook, McpResourceTemplate } from './mcp' import type { StaticAssetsSource } from './remote-assets' import type { DevframeServiceInput } from './services' @@ -199,6 +199,11 @@ export interface McpRouteOptions { * function that removes the subscription; the route calls it on teardown. */ onResourcesChanged?: McpResourcesChangedHook + /** + * Prompt templates the route serves through MCP `prompts/list` and + * `prompts/get`. Omitted or empty declares no `prompts` capability. + */ + prompts?: McpPrompt[] } export interface DevframeCliOptions { diff --git a/packages/devframe/src/types/mcp.ts b/packages/devframe/src/types/mcp.ts index ff1827b7..5440b15b 100644 --- a/packages/devframe/src/types/mcp.ts +++ b/packages/devframe/src/types/mcp.ts @@ -41,6 +41,11 @@ export interface CreateMcpServerOptions { * `notifications/resources/list_changed`. See {@link McpResourcesChangedHook}. */ onResourcesChanged?: McpResourcesChangedHook + /** + * Prompt templates served through `prompts/list` and `prompts/get`. + * Omitted or empty declares no `prompts` capability. + */ + prompts?: McpPrompt[] /** 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'`. */ @@ -76,6 +81,8 @@ export interface CreateMcpFetchHandlerOptions { resourceTemplates?: McpResourceTemplate[] /** Host subscription for resource-list changes; see {@link CreateMcpServerOptions.onResourcesChanged}. */ onResourcesChanged?: McpResourcesChangedHook + /** Prompt templates; see {@link CreateMcpServerOptions.prompts}. */ + prompts?: McpPrompt[] /** * Optional identity check, layered on top of the origin gate and checked * **after** it: a bearer token string (matched in constant time against @@ -179,3 +186,38 @@ export interface McpResourceTemplate { * subscription; the MCP server calls it on teardown. */ export type McpResourcesChangedHook = (notify: () => void, ctx: DevframeNodeContext) => () => void + +/** One argument of an {@link McpPrompt}. */ +export interface McpPromptArgument { + name: string + description?: string + /** `prompts/get` answers invalid params when a required argument is missing or empty. */ + required?: boolean +} + +/** One message of the answer to `prompts/get`. */ +export interface McpPromptMessage { + role: 'user' | 'assistant' + content: { type: 'text', text: string } +} + +/** What an {@link McpPrompt} returns from `get`, sent as the `prompts/get` result. */ +export interface McpPromptResult { + description?: string + messages: McpPromptMessage[] +} + +/** + * A prompt template the MCP server lists in `prompts/list` and builds in + * `prompts/get`. A coding agent usually offers it to the user as a slash + * command. Each `name` is used once; a repeated name throws `DF0085`. + */ +export interface McpPrompt { + name: string + /** Human-readable name for display. */ + title?: string + description?: string + arguments?: McpPromptArgument[] + /** Build the messages from the arguments the coding agent sent. */ + get: (args: Record) => McpPromptResult | Promise +} diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 44502d69..42b80976 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -422,6 +422,32 @@ describe('initHub', () => { } }) + it('serves mcp.prompts on the aggregate route', async () => { + const wsPort = await getPort({ port: 18243, host: '127.0.0.1' }) + const hub = initHub({ + base: DEVFRAMES_HUB_BASE, + auth: false, + host: '127.0.0.1', + ws: { port: wsPort }, + mcp: { + prompts: [{ + name: 'tour', + get: () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'Show me around.' } }] }), + }], + }, + devframes: [makeFrame('alpha')], + }) + + try { + await hub.ready + expect(await callMcp(hub, 'prompts/list')).toContain('"name":"tour"') + expect(await callMcp(hub, 'prompts/get', { name: 'tour' })).toContain('Show me around.') + } + finally { + await hub.close() + } + }) + it('serves mcp.resourceTemplates and wires mcp.onResourcesChanged to the hub context', async () => { const wsPort = await getPort({ port: 18242, host: '127.0.0.1' }) const unsubscribe = vi.fn() diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 84105080..71b27ed4 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -526,6 +526,7 @@ export function initHub(options: InitHubOptions): HubInstance { instructions: mcpConfig.instructions, resourceTemplates: mcpConfig.resourceTemplates, onResourcesChanged: mcpConfig.onResourcesChanged, + prompts: mcpConfig.prompts, authorization: mcpConfig.authorization, allowedOrigins: mcpConfig.allowedOrigins, }) diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index 005f5f9a..4b8c2e41 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -1,4 +1,4 @@ -import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope, McpAuthorization, McpResourcesChangedHook, McpResourceTemplate } from 'devframe' +import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope, McpAuthorization, McpPrompt, McpResourcesChangedHook, McpResourceTemplate } from 'devframe' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' import { importAgenticMcp } from 'devframe/internal' import { serveStaticHandler } from 'devframe/utils/serve-static' @@ -47,6 +47,8 @@ export interface DevframeNextHostMcpOptions { resourceTemplates?: McpResourceTemplate[] /** Host subscription; each `notify()` sends `notifications/resources/list_changed`. Default: none. */ onResourcesChanged?: McpResourcesChangedHook + /** Prompt templates served through `prompts/list` and `prompts/get`. Default: none. */ + prompts?: McpPrompt[] /** * Origin allow-list beyond the loopback default. `false` disables the * origin gate entirely. Note the MCP route rejects `Origin`-less requests @@ -189,6 +191,7 @@ export function createDevframeNextHost( instructions: mcpOptions.instructions, resourceTemplates: mcpOptions.resourceTemplates, onResourcesChanged: mcpOptions.onResourcesChanged, + prompts: mcpOptions.prompts, 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 1a299482..1464d15f 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts @@ -23,6 +23,7 @@ export interface DevframeNextHostMcpOptions { instructions?: string; resourceTemplates?: McpResourceTemplate[]; onResourcesChanged?: McpResourcesChangedHook; + prompts?: McpPrompt[]; allowedOrigins?: readonly string[] | false; } export interface NextDevframeHubHandle { diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts index d00dd3ae..f77ce904 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts @@ -8,6 +8,10 @@ export { createMcpServer } export { CreateMcpServerOptions } export { McpConnectionInfo } export { McpFetchHandler } +export { McpPrompt } +export { McpPromptArgument } +export { McpPromptMessage } +export { McpPromptResult } export { McpResourcesChangedHook } export { McpResourceTemplate } export { McpResourceTemplateContent } diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index ce2ea077..92841944 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -94,6 +94,7 @@ export interface CreateMcpFetchHandlerOptions { instructions?: string; resourceTemplates?: McpResourceTemplate[]; onResourcesChanged?: McpResourcesChangedHook; + prompts?: McpPrompt[]; authorization?: McpAuthorization; allowedOrigins?: readonly string[] | false; } @@ -103,6 +104,7 @@ export interface CreateMcpServerOptions { instructions?: string; resourceTemplates?: McpResourceTemplate[]; onResourcesChanged?: McpResourcesChangedHook; + prompts?: McpPrompt[]; serverName?: string; serverVersion?: string; onReady?: (_: { @@ -448,6 +450,29 @@ export interface McpFetchHandler { fetch: (_: Request, _?: McpConnectionInfo) => Promise; dispose: () => Promise; } +export interface McpPrompt { + name: string; + title?: string; + description?: string; + arguments?: McpPromptArgument[]; + get: (_: Record) => McpPromptResult | Promise; +} +export interface McpPromptArgument { + name: string; + description?: string; + required?: boolean; +} +export interface McpPromptMessage { + role: 'user' | 'assistant'; + content: { + type: 'text'; + text: string; + }; +} +export interface McpPromptResult { + description?: string; + messages: McpPromptMessage[]; +} export interface McpResourceTemplate { uriTemplate: string; name: string; @@ -477,6 +502,7 @@ export interface McpRouteOptions { instructions?: string; resourceTemplates?: McpResourceTemplate[]; onResourcesChanged?: McpResourcesChangedHook; + prompts?: McpPrompt[]; } export interface McpServerHandle { stop: () => Promise; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 2a3db790..c904cc8c 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -399,6 +399,12 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "Use RFC 6570 level 1 only: plain `{name}` expressions made of letters, digits, `_` and `.`, with no operator (`+`, `#`, `/`, `?`, …), modifier (`:n`, `*`) or list of names, and no `{` or `}` outside an expression."; }; + readonly DF0085: { + readonly why: (p: { + name: string; + }) => string; + readonly fix: "Give each entry in `prompts` its own `name`. `prompts/get` finds a prompt by its name, so two prompts cannot share one."; + }; }, readonly [(d: import("nostics").Diagnostic, { method }?: { method?: "log" | "warn" | "error"; }) => void], never>; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index dea4bec5..d9ffc5b8 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -71,6 +71,10 @@ export { McpAuthorization } export { McpConnectionInfo } export { McpContentBlock } export { McpFetchHandler } +export { McpPrompt } +export { McpPromptArgument } +export { McpPromptMessage } +export { McpPromptResult } export { McpResourcesChangedHook } export { McpResourceTemplate } export { McpResourceTemplateContent } From 54e17111c7fbda512a28eab51f040be5dc1172c3 Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 1 Nov 2026 12:18:05 +0000 Subject: [PATCH 2/2] fix(mcp): check required prompt arguments by own keys `prompts/get` read each required argument with `given[name]`, so a required argument named like an `Object.prototype` key, such as `constructor`, counted as sent when the client left it out. It now checks own keys only. --- .../src/mcp/__tests__/mcp-server.test.ts | 17 +++++++++++++++++ packages/agentic/src/mcp/build-server.ts | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index f996e0f9..4c668d86 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -861,6 +861,23 @@ describe('mcp adapter (in-memory)', () => { } }) + it('checks a required argument named like an Object.prototype key', async () => { + const proto: McpPrompt = { + name: 'proto', + arguments: [{ name: 'constructor', required: true }, { name: 'toString', required: true }], + get: args => ({ messages: [{ role: 'user', content: { type: 'text', text: `${args.constructor} ${args.toString}` } }] }), + } + const { client, cleanup } = await bootPair(true, { prompts: [proto] }) + try { + await expect(client.getPrompt({ name: 'proto' })).rejects.toThrow('needs the argument(s): constructor, toString') + const prompt = await client.getPrompt({ name: 'proto', arguments: { constructor: 'a', toString: 'b' } }) + expect(prompt.messages[0]!.content).toEqual({ type: 'text', text: 'a b' }) + } + finally { + await cleanup() + } + }) + it('sends an error thrown by get to the client', async () => { const broken: McpPrompt = { name: 'broken', diff --git a/packages/agentic/src/mcp/build-server.ts b/packages/agentic/src/mcp/build-server.ts index 54808516..cebba164 100644 --- a/packages/agentic/src/mcp/build-server.ts +++ b/packages/agentic/src/mcp/build-server.ts @@ -480,7 +480,8 @@ function registerPromptHandlers(server: Server, prompts: Map) if (!prompt) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `[devframe/mcp] unknown prompt "${name}"`) const missing = (prompt.arguments ?? []) - .filter(argument => argument.required && !given[argument.name]) + // Own keys only, so a name such as `constructor` is not found on the prototype. + .filter(argument => argument.required && !(Object.hasOwn(given, argument.name) && given[argument.name])) .map(argument => argument.name) if (missing.length) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `[devframe/mcp] prompt "${name}" needs the argument(s): ${missing.join(', ')}`)