Skip to content

Commit bf41031

Browse files
authored
feat(mcp): add prompts to the mcp route options (#476)
1 parent d7071d9 commit bf41031

22 files changed

Lines changed: 404 additions & 11 deletions

File tree

‎docs/content/1.guide/18.hub-initiate.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Registrations are validated fail-fast: one module per type (`DF8108`), an existi
8989

9090
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.
9191

92-
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. `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.
92+
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. `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.
9393

9494
## Singular vs hub mounting
9595

‎docs/content/2.adapters/7.mcp.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,27 @@ devframeViteBridge(myDevframe, {
120120
})
121121
```
122122

123+
### Prompts
124+
125+
`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`.
126+
127+
```ts
128+
devframeViteBridge(myDevframe, {
129+
mcp: {
130+
prompts: [{
131+
name: 'explain-module',
132+
title: 'Explain a module',
133+
arguments: [{ name: 'id', description: 'The module id.', required: true }],
134+
get: ({ id }) => ({
135+
messages: [{ role: 'user', content: { type: 'text', text: `Read my-tool://module/${encodeURIComponent(id)} and explain what the module does.` } }],
136+
}),
137+
}],
138+
},
139+
})
140+
```
141+
142+
`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.
143+
123144
### Hosted bridges
124145

125146
Both bridges forward the setting to their side-car dev server, advertising the mounted endpoint in `__connection.json`:

‎docs/content/6.errors/DF0085.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
title: 'DF0085: Repeated MCP Prompt Name'
3+
description: 'MCP prompt "{name}" is listed more than once.'
4+
---
5+
6+
## Message
7+
8+
> MCP prompt "`{name}`" is listed more than once.
9+
10+
## Cause
11+
12+
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.
13+
14+
## Example
15+
16+
```ts
17+
initDevframe(myDevframe, {
18+
mcp: {
19+
prompts: [
20+
{ name: 'review', get: () => ({ messages: [] }) },
21+
{ name: 'review', get: () => ({ messages: [] }) }, // same name → DF0085
22+
],
23+
},
24+
})
25+
```
26+
27+
## Fix
28+
29+
Give each prompt its own `name`, or merge the two into one prompt with an argument that picks between them.
30+
31+
## Source
32+
33+
- [`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.

‎docs/content/6.errors/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi
9090
| [DF0081](/errors/DF0081) | error | Invalid MCP Content Blocks |
9191
| [DF0082](/errors/DF0082) | error | Addressed Client Not Connected |
9292
| [DF0084](/errors/DF0084) | error | Invalid MCP Resource Template |
93+
| [DF0085](/errors/DF0085) | error | Repeated MCP Prompt Name |
9394

9495
## Hub: context & lifecycle (DF80xx)
9596

‎packages/agentic/src/mcp/__tests__/mcp-http.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,28 @@ describe('mcp adapter (streamable http route)', () => {
130130
}
131131
})
132132

133+
it('serves mcp.prompts through prompts/list and prompts/get', async () => {
134+
const started = await boot({
135+
prompts: [{
136+
name: 'greet',
137+
arguments: [{ name: 'who', required: true }],
138+
get: ({ who }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `Greet ${who}.` } }] }),
139+
}],
140+
})
141+
const client = new Client({ name: 'test-client', version: '0.0.0' })
142+
try {
143+
await client.connect(originTransport(started))
144+
const { prompts } = await client.listPrompts()
145+
expect(prompts).toEqual([{ name: 'greet', arguments: [{ name: 'who', required: true }] }])
146+
147+
const prompt = await client.getPrompt({ name: 'greet', arguments: { who: 'the team' } })
148+
expect(prompt.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Greet the team.' } }])
149+
}
150+
finally {
151+
await client.close()
152+
}
153+
})
154+
133155
it('serves mcp.resourceTemplates through resources/templates/list and resources/read', async () => {
134156
const started = await boot({
135157
resourceTemplates: [{

‎packages/agentic/src/mcp/__tests__/mcp-server.test.ts‎

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { StandardSchemaV1 } from '@standard-schema/spec'
2-
import type { DevframeHost, McpContentBlock, McpResourceTemplate } from 'devframe/types'
2+
import type { DevframeHost, McpContentBlock, McpPrompt, McpResourceTemplate } from 'devframe/types'
33
import { Client, InMemoryTransport } from '@modelcontextprotocol/client'
44
import { withMcpContent } from 'devframe'
55
import { createHostContext } from 'devframe/node'
66
import * as v from 'valibot'
77
import { describe, expect, it, vi } from 'vitest'
8-
import { bridgeListChanged, buildMcpServerFromContext, compileResourceTemplates } from '../build-server'
8+
import { bridgeListChanged, buildMcpServerFromContext, compilePrompts, compileResourceTemplates } from '../build-server'
99

1010
/** A 1x1 PNG. */
1111
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='
@@ -32,7 +32,7 @@ function nullHost(): DevframeHost {
3232

3333
async function bootPair(
3434
exposeSharedState: boolean | ((key: string) => boolean) = true,
35-
{ instructions, resourceTemplates }: { instructions?: string, resourceTemplates?: McpResourceTemplate[] } = {},
35+
{ instructions, resourceTemplates, prompts }: { instructions?: string, resourceTemplates?: McpResourceTemplate[], prompts?: McpPrompt[] } = {},
3636
) {
3737
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
3838

@@ -42,6 +42,7 @@ async function bootPair(
4242
exposeSharedState,
4343
instructions,
4444
resourceTemplates: compileResourceTemplates(resourceTemplates),
45+
prompts: compilePrompts(prompts),
4546
})
4647

4748
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
@@ -776,6 +777,129 @@ describe('mcp adapter (in-memory)', () => {
776777
})
777778
})
778779

780+
describe('prompts', () => {
781+
const review: McpPrompt = {
782+
name: 'review',
783+
title: 'Review a file',
784+
description: 'Ask for a review of one file.',
785+
arguments: [
786+
{ name: 'file', description: 'The file to review.', required: true },
787+
{ name: 'focus' },
788+
],
789+
get: ({ file, focus }) => ({
790+
description: `Review ${file}`,
791+
messages: [{ role: 'user', content: { type: 'text', text: `Review ${file}${focus ? ` for ${focus}` : ''}.` } }],
792+
}),
793+
}
794+
const hello: McpPrompt = {
795+
name: 'hello',
796+
get: async () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'Hello.' } }] }),
797+
}
798+
799+
it('declares the prompts capability only when prompts are set', async () => {
800+
const withPrompts = await bootPair(true, { prompts: [hello] })
801+
const without = await bootPair(true)
802+
try {
803+
expect(withPrompts.client.getServerCapabilities()?.prompts).toEqual({ listChanged: false })
804+
expect(without.client.getServerCapabilities()?.prompts).toBeUndefined()
805+
}
806+
finally {
807+
await withPrompts.cleanup()
808+
await without.cleanup()
809+
}
810+
})
811+
812+
it('lists each prompt with its arguments', async () => {
813+
const { client, cleanup } = await bootPair(true, { prompts: [review, hello] })
814+
try {
815+
const { prompts } = await client.listPrompts()
816+
expect(prompts).toEqual([
817+
{
818+
name: 'review',
819+
title: 'Review a file',
820+
description: 'Ask for a review of one file.',
821+
arguments: [
822+
{ name: 'file', description: 'The file to review.', required: true },
823+
{ name: 'focus' },
824+
],
825+
},
826+
{ name: 'hello' },
827+
])
828+
}
829+
finally {
830+
await cleanup()
831+
}
832+
})
833+
834+
it('builds the messages from the arguments, sync or async', async () => {
835+
const { client, cleanup } = await bootPair(true, { prompts: [review, hello] })
836+
try {
837+
expect(await client.getPrompt({ name: 'review', arguments: { file: 'src/main.ts', focus: 'errors' } })).toEqual({
838+
description: 'Review src/main.ts',
839+
messages: [{ role: 'user', content: { type: 'text', text: 'Review src/main.ts for errors.' } }],
840+
})
841+
expect(await client.getPrompt({ name: 'hello' })).toEqual({
842+
messages: [{ role: 'user', content: { type: 'text', text: 'Hello.' } }],
843+
})
844+
}
845+
finally {
846+
await cleanup()
847+
}
848+
})
849+
850+
it('answers invalid params for an unknown prompt or a missing required argument', async () => {
851+
const get = vi.fn(review.get)
852+
const { client, cleanup } = await bootPair(true, { prompts: [{ ...review, get }] })
853+
try {
854+
await expect(client.getPrompt({ name: 'nope' })).rejects.toThrow('unknown prompt "nope"')
855+
await expect(client.getPrompt({ name: 'review' })).rejects.toThrow('needs the argument(s): file')
856+
await expect(client.getPrompt({ name: 'review', arguments: { file: '' } })).rejects.toThrow('needs the argument(s): file')
857+
expect(get).not.toHaveBeenCalled()
858+
}
859+
finally {
860+
await cleanup()
861+
}
862+
})
863+
864+
it('checks a required argument named like an Object.prototype key', async () => {
865+
const proto: McpPrompt = {
866+
name: 'proto',
867+
arguments: [{ name: 'constructor', required: true }, { name: 'toString', required: true }],
868+
get: args => ({ messages: [{ role: 'user', content: { type: 'text', text: `${args.constructor} ${args.toString}` } }] }),
869+
}
870+
const { client, cleanup } = await bootPair(true, { prompts: [proto] })
871+
try {
872+
await expect(client.getPrompt({ name: 'proto' })).rejects.toThrow('needs the argument(s): constructor, toString')
873+
const prompt = await client.getPrompt({ name: 'proto', arguments: { constructor: 'a', toString: 'b' } })
874+
expect(prompt.messages[0]!.content).toEqual({ type: 'text', text: 'a b' })
875+
}
876+
finally {
877+
await cleanup()
878+
}
879+
})
880+
881+
it('sends an error thrown by get to the client', async () => {
882+
const broken: McpPrompt = {
883+
name: 'broken',
884+
get: () => {
885+
throw new Error('no ideas left')
886+
},
887+
}
888+
const { client, cleanup } = await bootPair(true, { prompts: [broken] })
889+
try {
890+
await expect(client.getPrompt({ name: 'broken' })).rejects.toThrow('no ideas left')
891+
}
892+
finally {
893+
await cleanup()
894+
}
895+
})
896+
897+
it('throws DF0085 for a repeated prompt name', () => {
898+
expect(() => compilePrompts([hello, { ...hello }])).toThrow(expect.objectContaining({ code: 'DF0085' }))
899+
expect([...compilePrompts([review, hello]).keys()]).toEqual(['review', 'hello'])
900+
})
901+
})
902+
779903
describe('onResourcesChanged', () => {
780904
it('sends resources list_changed on each notify, and unsubscribes on teardown', async () => {
781905
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })

‎packages/agentic/src/mcp/build-server.ts‎

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { Tool } from '@modelcontextprotocol/server'
22
import type { StandardSchemaV1 } from '@standard-schema/spec'
33
import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc'
4-
import type { AgentTool, CreateMcpServerOptions, DevframeDefinition, DevframeHost, DevframeNodeContext, McpContentBlock, McpResourcesChangedHook, McpResourceTemplate, McpResourceTemplateEntry, McpServerHandle } from 'devframe/types'
4+
import type { AgentTool, CreateMcpServerOptions, DevframeDefinition, DevframeHost, DevframeNodeContext, McpContentBlock, McpPrompt, McpResourcesChangedHook, McpResourceTemplate, McpResourceTemplateEntry, McpServerHandle } from 'devframe/types'
55
import { homedir } from 'node:os'
66
import process from 'node:process'
7-
import { Server } from '@modelcontextprotocol/server'
7+
import { ProtocolError, ProtocolErrorCode, Server } from '@modelcontextprotocol/server'
88
import { DEVFRAME_EVENTS } from 'devframe/constants'
99
import { argsToJsonSchema, diagnostics, formatMcpError, getMcpContent, returnToJsonSchema, stringifyForMcp } from 'devframe/internal'
1010
import { createHostContext } from 'devframe/node'
@@ -22,6 +22,11 @@ export interface BuildMcpServerOptions {
2222
* omitted or empty keeps the resource handlers unchanged.
2323
*/
2424
resourceTemplates?: CompiledResourceTemplate[]
25+
/**
26+
* Prompts by name (see {@link compilePrompts}); omitted or empty declares
27+
* no `prompts` capability.
28+
*/
29+
prompts?: Map<string, McpPrompt>
2530
}
2631

2732
/**
@@ -60,6 +65,7 @@ export function buildMcpServerFromContext(
6065
capabilities: {
6166
tools: { listChanged: true },
6267
resources: { listChanged: true },
68+
...(options.prompts?.size ? { prompts: { listChanged: false } } : {}),
6369
},
6470
...(options.instructions ? { instructions: options.instructions } : {}),
6571
},
@@ -73,6 +79,8 @@ export function buildMcpServerFromContext(
7379
resourceTemplates: templates.map(({ template: { uriTemplate, name, title, description, mimeType } }) => ({ uriTemplate, name, title, description, mimeType })),
7480
}))
7581
}
82+
if (options.prompts?.size)
83+
registerPromptHandlers(server, options.prompts)
7684

7785
return server
7886
}
@@ -127,8 +135,10 @@ export async function createMcpServer(
127135
const transport = options.transport ?? 'stdio'
128136
if (transport !== 'stdio')
129137
throw diagnostics.DF0017({ transport, reason: 'Only stdio transport is supported in this release.' })
130-
// Compile before setup, so an invalid template fails fast (DF0084).
138+
// Compile before setup, so an invalid template fails fast (DF0084), and
139+
// so does a repeated prompt name (DF0085).
131140
const resourceTemplates = compileResourceTemplates(options.resourceTemplates)
141+
const prompts = compilePrompts(options.prompts)
132142

133143
const host: DevframeHost = {
134144
mountStatic: () => { /* MCP has no static surface */ },
@@ -160,6 +170,7 @@ export async function createMcpServer(
160170
exposeSharedState: options.exposeSharedState ?? true,
161171
instructions: options.instructions,
162172
resourceTemplates,
173+
prompts,
163174
}
164175

165176
// `serveStdio` pins ONE instance for the connection's lifetime. Each pinned
@@ -432,6 +443,53 @@ function registerResourceHandlers(
432443
})
433444
}
434445

446+
/**
447+
* Index prompts by name, once per endpoint. A repeated name throws
448+
* `DF0085`, since `prompts/get` finds a prompt by its name.
449+
*
450+
* @internal
451+
*/
452+
export function compilePrompts(prompts: McpPrompt[] = []): Map<string, McpPrompt> {
453+
const byName = new Map<string, McpPrompt>()
454+
for (const prompt of prompts) {
455+
if (byName.has(prompt.name))
456+
throw diagnostics.DF0085({ name: prompt.name })
457+
byName.set(prompt.name, prompt)
458+
}
459+
return byName
460+
}
461+
462+
/**
463+
* Serve `prompts/list` and `prompts/get`. An unknown name, or a required
464+
* argument that is missing or empty, is an invalid-params error. An error
465+
* thrown by `get` goes to the MCP client.
466+
*/
467+
function registerPromptHandlers(server: Server, prompts: Map<string, McpPrompt>): void {
468+
server.setRequestHandler('prompts/list', async () => ({
469+
prompts: [...prompts.values()].map(({ name, title, description, arguments: args }) => ({
470+
name,
471+
title,
472+
description,
473+
...(args?.length ? { arguments: args.map(argument => ({ name: argument.name, description: argument.description, required: argument.required })) } : {}),
474+
})),
475+
}))
476+
477+
server.setRequestHandler('prompts/get', async (request) => {
478+
const { name, arguments: given = {} } = request.params
479+
const prompt = prompts.get(name)
480+
if (!prompt)
481+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `[devframe/mcp] unknown prompt "${name}"`)
482+
const missing = (prompt.arguments ?? [])
483+
// Own keys only, so a name such as `constructor` is not found on the prototype.
484+
.filter(argument => argument.required && !(Object.hasOwn(given, argument.name) && given[argument.name]))
485+
.map(argument => argument.name)
486+
if (missing.length)
487+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `[devframe/mcp] prompt "${name}" needs the argument(s): ${missing.join(', ')}`)
488+
const { description, messages } = await prompt.get(given)
489+
return { description, messages }
490+
})
491+
}
492+
435493
/** One `{name}` expression of a URI template. */
436494
const TEMPLATE_EXPRESSION_RE = /\{([^{}]*)\}/
437495
/** RFC 6570 `varname`: letters, digits and `_`, with single dots between them. */

0 commit comments

Comments
 (0)