Skip to content
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. 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. The hub exposes one aggregate route over every mounted devframe's tools.

## Singular vs hub mounting

Expand Down
39 changes: 39 additions & 0 deletions docs/content/2.adapters/7.mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,45 @@ devframeViteBridge(myDevframe, {

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

### Resource templates

`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.

```ts
devframeViteBridge(myDevframe, {
mcp: {
resourceTemplates: [{
uriTemplate: 'my-tool://module/{id}',
name: 'module',
mimeType: 'application/json',
list: ctx => listModules(ctx).map(id => ({ uri: `my-tool://module/${encodeURIComponent(id)}`, name: id })),
read: ({ id }, _uri, ctx) => ({ json: findModule(ctx, id) }),
}],
},
})
```

The `uriTemplate` uses RFC 6570 level 1: plain `{name}` expressions. Each one matches a non-empty value without `/`, and `read` gets its value percent-decoded, so a value with a `/` comes in as `%2F`. When one segment has several values, such as `{from}-{to}`, each value runs up to the first place the next text appears outside a `%XX` escape. Any other template syntax, or two expressions with nothing between them (`{a}{b}`), 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`).

`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.

### Telling coding agents the resources changed

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.

```ts
import { watch } from 'node:fs'

devframeViteBridge(myDevframe, {
mcp: {
onResourcesChanged: (notify) => {
const watcher = watch('src', { recursive: true }, notify)
return () => watcher.close()
},
},
})
```

### Hosted bridges

Both bridges forward the setting to their side-car dev server, advertising the mounted endpoint in `__connection.json`:
Expand Down
38 changes: 38 additions & 0 deletions docs/content/6.errors/DF0084.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: 'DF0084: Invalid MCP Resource Template'
description: 'MCP resource template "{name}" has an invalid `uriTemplate` "{uriTemplate}": {reason}'
---

## Message

> MCP resource template "`{name}`" has an invalid `uriTemplate` "`{uriTemplate}`": `{reason}`

## Cause

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 (`{}`), two expressions with nothing between them (`{a}{b}`, where a URI cannot show where one value ends), 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.

## Example

```ts
initDevframe(myDevframe, {
mcp: {
resourceTemplates: [{
uriTemplate: 'my-tool://file/{+path}', // `+` is a level 2 operator → DF0084
name: 'file',
read: ({ path }) => ({ text: readFileSync(path, 'utf8') }),
}],
},
})
```

## Fix

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`):

```ts
const template = { uriTemplate: 'my-tool://file/{path}', name: 'file', read: ({ path }) => ({ text: readFileSync(path, 'utf8') }) }
```

## Source

- [`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.
1 change: 1 addition & 0 deletions docs/content/6.errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi
| [DF0080](/errors/DF0080) | error | In-Page Channel Agent Function Not JSON-Serializable |
| [DF0081](/errors/DF0081) | error | Invalid MCP Content Blocks |
| [DF0082](/errors/DF0082) | error | Addressed Client Not Connected |
| [DF0084](/errors/DF0084) | error | Invalid MCP Resource Template |

## Hub: context & lifecycle (DF80xx)

Expand Down
64 changes: 63 additions & 1 deletion packages/agentic/src/mcp/__tests__/mcp-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { DevframeDefinition, McpRouteOptions } from 'devframe/types'
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
import { withMcpContent } from 'devframe'
import { createDevServer } from 'devframe/adapters/dev'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'

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

it('serves mcp.resourceTemplates through resources/templates/list and resources/read', async () => {
const started = await boot({
resourceTemplates: [{
uriTemplate: 'test://note/{id}',
name: 'note',
mimeType: 'text/plain',
list: () => [{ uri: 'test://note/1', name: 'Note 1' }],
read: params => ({ text: `note ${params.id}` }),
}],
})
const client = new Client({ name: 'test-client', version: '0.0.0' })
try {
await client.connect(originTransport(started))
const { resourceTemplates } = await client.listResourceTemplates()
expect(resourceTemplates).toEqual([{ uriTemplate: 'test://note/{id}', name: 'note', mimeType: 'text/plain' }])

const { resources } = await client.listResources()
expect(resources).toContainEqual({ uri: 'test://note/1', name: 'Note 1', mimeType: 'text/plain' })

const read = await client.readResource({ uri: 'test://note/7' })
expect(read.contents).toEqual([{ uri: 'test://note/7', mimeType: 'text/plain', text: 'note 7' }])
}
finally {
await client.close()
}
})

it('sends resources list_changed on each mcp.onResourcesChanged notify, and unsubscribes on close', async () => {
let notify: (() => void) | undefined
const unsubscribe = vi.fn()
const started = await boot({
onResourcesChanged: (n) => {
notify = n
return unsubscribe
},
})
// The 2026-07-28 era delivers list_changed on a `subscriptions/listen` stream.
const client = new Client(
{ name: 'test-client', version: '0.0.0' },
{ versionNegotiation: { mode: 'auto' } },
)
const changed = vi.fn()
client.setNotificationHandler('notifications/resources/list_changed', changed)
try {
await client.connect(originTransport(started))
expect(client.getProtocolEra()).toBe('modern')
const subscription = await client.listen({ resourcesListChanged: true })
expect(subscription.honoredFilter.resourcesListChanged).toBe(true)

notify!()
await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1))
await subscription.close()
}
finally {
await client.close()
}
expect(unsubscribe).not.toHaveBeenCalled()
await server?.close()
server = undefined
expect(unsubscribe).toHaveBeenCalledTimes(1)
})

it('answers a bare GET with 405 (no session lifecycle)', async () => {
const started = await boot()
// Stateless serving has no session stream to open, so the SDK answers a
Expand Down
Loading
Loading