Skip to content

Commit 56f387d

Browse files
committed
feat(mcp): add resource templates and a resources changed hook
A devframe can pass `mcp.resourceTemplates` to serve resources whose URI holds parameters, such as one page's data, and `mcp.onResourcesChanged` to tell coding agents the resource list changed. An invalid template fails at mount with DF0084. Without them the server is unchanged.
1 parent 44566b0 commit 56f387d

23 files changed

Lines changed: 822 additions & 33 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. 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. 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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,45 @@ devframeViteBridge(myDevframe, {
8181

8282
`createMcpServer` and `createMcpFetchHandler` take the same `instructions` option.
8383

84+
### Resource templates
85+
86+
`resourceTemplates` on the route options is a list of resource templates that the MCP server serves through `resources/templates/list`. Each template has a `uriTemplate`, a `name`, an optional `title`, `description` and `mimeType`, a `read(params, uri, ctx)` function, and an optional `list(ctx)` function. Both functions can be sync or async. They get the node-side context (`ctx`), so they can read live state: the route options are resolved before `setup()` runs, so a template cannot keep `ctx` from there.
87+
88+
```ts
89+
devframeViteBridge(myDevframe, {
90+
mcp: {
91+
resourceTemplates: [{
92+
uriTemplate: 'my-tool://module/{id}',
93+
name: 'module',
94+
mimeType: 'application/json',
95+
list: ctx => listModules(ctx).map(id => ({ uri: `my-tool://module/${encodeURIComponent(id)}`, name: id })),
96+
read: ({ id }, _uri, ctx) => ({ json: findModule(ctx, id) }),
97+
}],
98+
},
99+
})
100+
```
101+
102+
The `uriTemplate` uses RFC 6570 level 1: plain `{name}` expressions. Each one matches one non-empty URI segment, without `/`, and `read` gets its value percent-decoded, so a value with a `/` comes in as `%2F`. Any other template syntax throws [DF0084](/errors/DF0084) when the route mounts. `read` returns `text`, or `json` that the server sends as JSON text, and an optional `mimeType` (the default is the `mimeType` of the template, then `application/json`).
103+
104+
`resources/read` sends a `devframe://resource/<id>` or `devframe://state/<key>` URI to the registered resource or the shared state as before. It tries any other URI against the templates in order, and the first template that matches reads it. A URI that nothing matches gets the usual unknown-resource error, and an error thrown by `read` goes to the MCP client like the error of a registered resource. `resources/list` adds the entries that the `list` of each template returns. With an empty or omitted list, the resources stay as they are and `resources/templates/list` answers method not found. `createMcpServer` and `createMcpFetchHandler` take the same `resourceTemplates` option.
105+
106+
### Telling coding agents the resources changed
107+
108+
The route sends `notifications/resources/list_changed` when the agent surface changes or a shared-state key is added. For other changes, such as new entries from the `list` of a template, pass `onResourcesChanged`: a function that gets `notify` and the node-side context, and returns an unsubscribe function. Each `notify()` sends `notifications/resources/list_changed`, over HTTP (on the `subscriptions/listen` streams) and over `stdio`. The route calls the unsubscribe function when it closes. `createMcpServer` and `createMcpFetchHandler` take the same `onResourcesChanged` option.
109+
110+
```ts
111+
import { watch } from 'node:fs'
112+
113+
devframeViteBridge(myDevframe, {
114+
mcp: {
115+
onResourcesChanged: (notify) => {
116+
const watcher = watch('src', { recursive: true }, notify)
117+
return () => watcher.close()
118+
},
119+
},
120+
})
121+
```
122+
84123
### Hosted bridges
85124

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

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
title: 'DF0084: Invalid MCP Resource Template'
3+
description: 'MCP resource template "{name}" has an invalid `uriTemplate` "{uriTemplate}": {reason}'
4+
---
5+
6+
## Message
7+
8+
> MCP resource template "`{name}`" has an invalid `uriTemplate` "`{uriTemplate}`": `{reason}`
9+
10+
## Cause
11+
12+
An entry in the `resourceTemplates` list of the MCP route options (or of `createMcpServer` / `createMcpFetchHandler`) has a `uriTemplate` that goes beyond RFC 6570 level 1. The MCP server supports plain `{name}` expressions only. A name is made of letters, digits, `_` and `.`, and each name appears once. An operator (`{+path}`, `{?q}`, `{/id}`), a modifier (`{path*}`, `{id:3}`), a list of names (`{a,b}`), an empty expression (`{}`), or a `{` or `}` outside an expression throws this. The check runs once, when the MCP route mounts or `createMcpServer` starts, so no request is served with a broken template.
13+
14+
## Example
15+
16+
```ts
17+
initDevframe(myDevframe, {
18+
mcp: {
19+
resourceTemplates: [{
20+
uriTemplate: 'my-tool://file/{+path}', // `+` is a level 2 operator → DF0084
21+
name: 'file',
22+
read: ({ path }) => ({ text: readFileSync(path, 'utf8') }),
23+
}],
24+
},
25+
})
26+
```
27+
28+
## Fix
29+
30+
Use a plain `{name}` expression for each value. A value matches one URI segment, so a value with a `/` (such as a file path) comes in percent-encoded (`my-tool://file/src%2Fmain.ts`) and reaches `read` decoded (`src/main.ts`):
31+
32+
```ts
33+
const template = { uriTemplate: 'my-tool://file/{path}', name: 'file', read: ({ path }) => ({ text: readFileSync(path, 'utf8') }) }
34+
```
35+
36+
## Source
37+
38+
- [`packages/agentic/src/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/agentic/src/mcp/build-server.ts): `compileResourceTemplates()` throws this for a `uriTemplate` beyond RFC 6570 level 1. `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
@@ -87,6 +87,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi
8787
| [DF0078](/errors/DF0078) | warn | Agent Surface Without @devframes/agentic |
8888
| [DF0079](/errors/DF0079) | error | MCP Enabled Without @devframes/agentic |
8989
| [DF0081](/errors/DF0081) | error | Invalid MCP Content Blocks |
90+
| [DF0084](/errors/DF0084) | error | Invalid MCP Resource Template |
9091

9192
## Hub: context & lifecycle (DF80xx)
9293

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { DevframeDefinition, McpRouteOptions } from 'devframe/types'
33
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
44
import { withMcpContent } from 'devframe'
55
import { createDevServer } from 'devframe/adapters/dev'
6-
import { afterEach, describe, expect, it } from 'vitest'
6+
import { afterEach, describe, expect, it, vi } from 'vitest'
77

88
const TOKEN = 'a-high-entropy-test-bearer-token'
99
/** A 1x1 PNG. */
@@ -130,6 +130,68 @@ describe('mcp adapter (streamable http route)', () => {
130130
}
131131
})
132132

133+
it('serves mcp.resourceTemplates through resources/templates/list and resources/read', async () => {
134+
const started = await boot({
135+
resourceTemplates: [{
136+
uriTemplate: 'test://note/{id}',
137+
name: 'note',
138+
mimeType: 'text/plain',
139+
list: () => [{ uri: 'test://note/1', name: 'Note 1' }],
140+
read: params => ({ text: `note ${params.id}` }),
141+
}],
142+
})
143+
const client = new Client({ name: 'test-client', version: '0.0.0' })
144+
try {
145+
await client.connect(originTransport(started))
146+
const { resourceTemplates } = await client.listResourceTemplates()
147+
expect(resourceTemplates).toEqual([{ uriTemplate: 'test://note/{id}', name: 'note', mimeType: 'text/plain' }])
148+
149+
const { resources } = await client.listResources()
150+
expect(resources).toContainEqual({ uri: 'test://note/1', name: 'Note 1', mimeType: 'text/plain' })
151+
152+
const read = await client.readResource({ uri: 'test://note/7' })
153+
expect(read.contents).toEqual([{ uri: 'test://note/7', mimeType: 'text/plain', text: 'note 7' }])
154+
}
155+
finally {
156+
await client.close()
157+
}
158+
})
159+
160+
it('sends resources list_changed on each mcp.onResourcesChanged notify, and unsubscribes on close', async () => {
161+
let notify: (() => void) | undefined
162+
const unsubscribe = vi.fn()
163+
const started = await boot({
164+
onResourcesChanged: (n) => {
165+
notify = n
166+
return unsubscribe
167+
},
168+
})
169+
// The 2026-07-28 era delivers list_changed on a `subscriptions/listen` stream.
170+
const client = new Client(
171+
{ name: 'test-client', version: '0.0.0' },
172+
{ versionNegotiation: { mode: 'auto' } },
173+
)
174+
const changed = vi.fn()
175+
client.setNotificationHandler('notifications/resources/list_changed', changed)
176+
try {
177+
await client.connect(originTransport(started))
178+
expect(client.getProtocolEra()).toBe('modern')
179+
const subscription = await client.listen({ resourcesListChanged: true })
180+
expect(subscription.honoredFilter.resourcesListChanged).toBe(true)
181+
182+
notify!()
183+
await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1))
184+
await subscription.close()
185+
}
186+
finally {
187+
await client.close()
188+
}
189+
expect(unsubscribe).not.toHaveBeenCalled()
190+
await server?.close()
191+
server = undefined
192+
expect(unsubscribe).toHaveBeenCalledTimes(1)
193+
})
194+
133195
it('answers a bare GET with 405 (no session lifecycle)', async () => {
134196
const started = await boot()
135197
// Stateless serving has no session stream to open, so the SDK answers a

0 commit comments

Comments
 (0)