Skip to content
Open
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>`. 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: { 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 @@ -69,6 +69,45 @@ devframeViteBridge(myDevframe, {
})
```

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

`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 (`{}`), 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 @@ -86,6 +86,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi
| [DF0077](/errors/DF0077) | error | In-Page Channel Function Not Registered |
| [DF0078](/errors/DF0078) | warn | Agent Surface Without @devframes/agentic |
| [DF0079](/errors/DF0079) | error | MCP Enabled Without @devframes/agentic |
| [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 @@ -2,7 +2,7 @@ import type { StartedServer } from 'devframe/internal'
import type { DevframeDefinition, McpRouteOptions } from 'devframe/types'
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
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'

Expand Down Expand Up @@ -95,6 +95,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