Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/1.guide/18.hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Registrations are validated fail-fast: one module per type (`DF8108`), an existi

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

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

## Singular vs hub mounting

Expand Down
21 changes: 21 additions & 0 deletions docs/content/2.adapters/7.mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
33 changes: 33 additions & 0 deletions docs/content/6.errors/DF0085.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/content/6.errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
22 changes: 22 additions & 0 deletions packages/agentic/src/mcp/__tests__/mcp-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [{
Expand Down
130 changes: 127 additions & 3 deletions packages/agentic/src/mcp/__tests__/mcp-server.test.ts
Original file line number Diff line number Diff line change
@@ -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=='
Expand All @@ -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() })

Expand All @@ -42,6 +42,7 @@ async function bootPair(
exposeSharedState,
instructions,
resourceTemplates: compileResourceTemplates(resourceTemplates),
prompts: compilePrompts(prompts),
})

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
Expand Down Expand Up @@ -776,6 +777,129 @@ 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('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',
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() })
Expand Down
64 changes: 61 additions & 3 deletions packages/agentic/src/mcp/build-server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<string, McpPrompt>
}

/**
Expand Down Expand Up @@ -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 } : {}),
},
Expand All @@ -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
}
Expand Down Expand Up @@ -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 */ },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -432,6 +443,53 @@ 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<string, McpPrompt> {
const byName = new Map<string, McpPrompt>()
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<string, McpPrompt>): 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 ?? [])
// 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(', ')}`)
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. */
Expand Down
Loading
Loading