From 56f387d45d75d7e3aae9854be5d3648071ce7e30 Mon Sep 17 00:00:00 2001 From: erKam Date: Fri, 16 Oct 2026 05:39:31 +0000 Subject: [PATCH 1/4] 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. --- docs/content/1.guide/18.hub-initiate.md | 2 +- docs/content/2.adapters/7.mcp.md | 39 +++ docs/content/6.errors/DF0084.md | 38 +++ docs/content/6.errors/index.md | 1 + .../src/mcp/__tests__/mcp-http.test.ts | 64 ++++- .../src/mcp/__tests__/mcp-server.test.ts | 241 +++++++++++++++++- packages/agentic/src/mcp/build-server.ts | 174 ++++++++++++- packages/agentic/src/mcp/fetch.ts | 9 +- .../src/adapters/__tests__/initiate.test.ts | 40 +++ packages/devframe/src/adapters/_shared.ts | 7 + packages/devframe/src/adapters/initiate.ts | 2 + packages/devframe/src/adapters/mcp.ts | 4 + packages/devframe/src/node/diagnostics.ts | 5 + packages/devframe/src/types/devframe.ts | 18 ++ packages/devframe/src/types/mcp.ts | 72 ++++++ .../hub/src/node/__tests__/initiate.test.ts | 83 ++++-- packages/hub/src/node/initiate.ts | 2 + packages/next/src/host.ts | 8 +- .../tsnapi/@devframes/next/hub.snapshot.d.ts | 2 + .../devframe/adapters/mcp.snapshot.d.ts | 4 + .../tsnapi/devframe/index.snapshot.d.ts | 28 ++ .../tsnapi/devframe/internal.snapshot.d.ts | 8 + .../tsnapi/devframe/types.snapshot.d.ts | 4 + 23 files changed, 822 insertions(+), 33 deletions(-) create mode 100644 docs/content/6.errors/DF0084.md diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 3d3f787d..cef8d5e0 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -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/`. `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/`. `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 diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index a7852b73..42b07179 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -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 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/` or `devframe://state/` 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`: diff --git a/docs/content/6.errors/DF0084.md b/docs/content/6.errors/DF0084.md new file mode 100644 index 00000000..7800ebf9 --- /dev/null +++ b/docs/content/6.errors/DF0084.md @@ -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. diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index 684cce83..3b5b3cd4 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -87,6 +87,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi | [DF0078](/errors/DF0078) | warn | Agent Surface Without @devframes/agentic | | [DF0079](/errors/DF0079) | error | MCP Enabled Without @devframes/agentic | | [DF0081](/errors/DF0081) | error | Invalid MCP Content Blocks | +| [DF0084](/errors/DF0084) | error | Invalid MCP Resource Template | ## Hub: context & lifecycle (DF80xx) diff --git a/packages/agentic/src/mcp/__tests__/mcp-http.test.ts b/packages/agentic/src/mcp/__tests__/mcp-http.test.ts index 98b0622d..ba56976b 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-http.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-http.test.ts @@ -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. */ @@ -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 diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index 97f1e2b5..a84d08a1 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -1,11 +1,11 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' -import type { DevframeHost, McpContentBlock } from 'devframe/types' +import type { DevframeHost, McpContentBlock, 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 } from 'vitest' -import { buildMcpServerFromContext } from '../build-server' +import { describe, expect, it, vi } from 'vitest' +import { bridgeListChanged, buildMcpServerFromContext, compileResourceTemplates } from '../build-server' /** A 1x1 PNG. */ const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' @@ -30,7 +30,10 @@ function nullHost(): DevframeHost { } } -async function bootPair(exposeSharedState: boolean | ((key: string) => boolean) = true, instructions?: string) { +async function bootPair( + exposeSharedState: boolean | ((key: string) => boolean) = true, + { instructions, resourceTemplates }: { instructions?: string, resourceTemplates?: McpResourceTemplate[] } = {}, +) { const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) const server = buildMcpServerFromContext(ctx, { @@ -38,6 +41,7 @@ async function bootPair(exposeSharedState: boolean | ((key: string) => boolean) serverVersion: '0.0.0-test', exposeSharedState, instructions, + resourceTemplates: compileResourceTemplates(resourceTemplates), }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() @@ -58,7 +62,7 @@ async function bootPair(exposeSharedState: boolean | ((key: string) => boolean) describe('mcp adapter (in-memory)', () => { it('sends the server instructions in the handshake', async () => { - const { client, cleanup } = await bootPair(true, 'Call greet first.') + const { client, cleanup } = await bootPair(true, { instructions: 'Call greet first.' }) try { expect(client.getInstructions()).toBe('Call greet first.') } @@ -571,4 +575,231 @@ describe('mcp adapter (in-memory)', () => { await cleanup() } }) + + describe('resource templates', () => { + const file: McpResourceTemplate = { + uriTemplate: 'test://file/{path}', + name: 'file', + title: 'Project file', + description: 'One file of the project.', + mimeType: 'text/plain', + list: () => [ + { uri: 'test://file/a.ts', name: 'a.ts' }, + { uri: 'test://file/b.json', name: 'b.json', title: 'B', description: 'The b file.', mimeType: 'application/json' }, + ], + read: (params, uri) => ({ text: `${params.path} from ${uri}` }), + } + const issue: McpResourceTemplate = { + uriTemplate: 'test://repo/{owner}/{repo}/issue/{id}', + name: 'issue', + read: params => ({ json: { ...params } }), + } + + it('keeps the resources unchanged when templates are omitted or empty', async () => { + const lists: unknown[] = [] + for (const templates of [undefined, []]) { + const { ctx, client, cleanup } = await bootPair(true, { resourceTemplates: templates }) + try { + ctx.agent.registerResource({ id: 'status', name: 'Status', read: () => ({ json: { ok: true } }) }) + await ctx.rpc.sharedState.get('my-plugin:counter', { initialValue: { count: 1 } }) + // The client answers listResourceTemplates() itself when the + // capability is absent, so send the raw request to check that no + // handler exists. + await expect(client.request({ method: 'resources/templates/list', params: {} })).rejects.toMatchObject({ code: -32601 }) + lists.push(await client.listResources()) + await expect(client.readResource({ uri: 'test://file/a.ts' })).rejects.toThrow('unknown resource URI "test://file/a.ts"') + } + finally { + await cleanup() + } + } + expect(lists[1]).toEqual(lists[0]) + expect(lists[0]).toEqual({ + resources: [ + { uri: 'devframe://resource/status', name: 'Status', mimeType: 'application/json' }, + { uri: 'devframe://state/my-plugin%3Acounter', name: 'my-plugin:counter', description: 'Shared state: my-plugin:counter', mimeType: 'application/json' }, + ], + }) + }) + + it('lists the templates through resources/templates/list', async () => { + const { client, cleanup } = await bootPair(true, { resourceTemplates: [file, issue] }) + try { + const { resourceTemplates } = await client.listResourceTemplates() + expect(resourceTemplates).toEqual([ + { uriTemplate: 'test://file/{path}', name: 'file', title: 'Project file', description: 'One file of the project.', mimeType: 'text/plain' }, + { uriTemplate: 'test://repo/{owner}/{repo}/issue/{id}', name: 'issue' }, + ]) + } + finally { + await cleanup() + } + }) + + it('reads a URI through the template it matches', async () => { + const { client, cleanup } = await bootPair(true, { resourceTemplates: [file, issue] }) + try { + const a = await client.readResource({ uri: 'test://file/a.ts' }) + expect(a.contents).toEqual([{ uri: 'test://file/a.ts', mimeType: 'text/plain', text: 'a.ts from test://file/a.ts' }]) + const b = await client.readResource({ uri: 'test://file/b.json' }) + expect(b.contents).toEqual([{ uri: 'test://file/b.json', mimeType: 'text/plain', text: 'b.json from test://file/b.json' }]) + + const read = await client.readResource({ uri: 'test://repo/devframes/devframe/issue/42' }) + const c = read.contents[0] as { text: string, mimeType?: string } + expect(c.mimeType).toBe('application/json') + expect(JSON.parse(c.text)).toEqual({ owner: 'devframes', repo: 'devframe', id: '42' }) + } + finally { + await cleanup() + } + }) + + it('percent-decodes the values', async () => { + const { client, cleanup } = await bootPair(true, { resourceTemplates: [file] }) + try { + const read = await client.readResource({ uri: 'test://file/src%2Fmy%20file.ts' }) + expect(read.contents[0]).toMatchObject({ text: 'src/my file.ts from test://file/src%2Fmy%20file.ts' }) + } + finally { + await cleanup() + } + }) + + it('matches one segment per value, so an extra `/` matches no template', async () => { + const { client, cleanup } = await bootPair(true, { resourceTemplates: [file] }) + try { + for (const uri of ['test://file/src/a.ts', 'test://file/', 'test://file/%E0%A4%A']) + await expect(client.readResource({ uri })).rejects.toThrow(`unknown resource URI "${uri}"`) + } + finally { + await cleanup() + } + }) + + it('reads with the first template that matches', async () => { + const first: McpResourceTemplate = { uriTemplate: 'test://item/{id}', name: 'first', read: () => ({ text: 'first' }) } + const second: McpResourceTemplate = { uriTemplate: 'test://item/{key}', name: 'second', read: () => ({ text: 'second' }) } + const { client, cleanup } = await bootPair(true, { resourceTemplates: [first, second] }) + try { + const read = await client.readResource({ uri: 'test://item/1' }) + expect(read.contents[0]).toMatchObject({ text: 'first' }) + } + finally { + await cleanup() + } + }) + + it('reads registered resources before the templates', async () => { + const catchAll: McpResourceTemplate = { uriTemplate: 'devframe://resource/{id}', name: 'catch-all', read: () => ({ text: 'template' }) } + const { ctx, client, cleanup } = await bootPair(true, { resourceTemplates: [catchAll] }) + try { + ctx.agent.registerResource({ id: 'status', name: 'Status', read: () => ({ text: 'registered' }) }) + const read = await client.readResource({ uri: 'devframe://resource/status' }) + expect(read.contents[0]).toMatchObject({ text: 'registered' }) + } + finally { + await cleanup() + } + }) + + it('adds the entries of each template list to resources/list', async () => { + const { ctx, client, cleanup } = await bootPair(false, { resourceTemplates: [file, issue] }) + try { + ctx.agent.registerResource({ id: 'status', name: 'Status', read: () => ({ json: {} }) }) + const { resources } = await client.listResources() + expect(resources).toEqual([ + { uri: 'devframe://resource/status', name: 'Status', mimeType: 'application/json' }, + { uri: 'test://file/a.ts', name: 'a.ts', mimeType: 'text/plain' }, + { uri: 'test://file/b.json', name: 'b.json', title: 'B', description: 'The b file.', mimeType: 'application/json' }, + ]) + } + finally { + await cleanup() + } + }) + + it('passes the node context to list and read', async () => { + const counter: McpResourceTemplate = { + uriTemplate: 'test://state/{key}', + name: 'state', + list: ctx => ctx.rpc.sharedState.keys().map(key => ({ uri: `test://state/${encodeURIComponent(key)}`, name: key })), + read: async (params, _uri, ctx) => ({ json: (await ctx.rpc.sharedState.get(params.key!)).value() }), + } + const { ctx, client, cleanup } = await bootPair(false, { resourceTemplates: [counter] }) + try { + await ctx.rpc.sharedState.get('my-plugin:counter', { initialValue: { count: 3 } }) + const { resources } = await client.listResources() + expect(resources.map(r => r.uri)).toEqual(['test://state/my-plugin%3Acounter']) + const read = await client.readResource({ uri: resources[0]!.uri }) + expect(JSON.parse((read.contents[0] as { text: string }).text)).toEqual({ count: 3 }) + } + finally { + await cleanup() + } + }) + + it('reports a throwing read to the MCP client', async () => { + const broken: McpResourceTemplate = { + uriTemplate: 'test://broken/{id}', + name: 'broken', + read: () => { + throw new Error('disk on fire') + }, + } + const { client, cleanup } = await bootPair(true, { resourceTemplates: [broken] }) + try { + await expect(client.readResource({ uri: 'test://broken/1' })).rejects.toThrow('disk on fire') + } + finally { + await cleanup() + } + }) + + it('throws DF0084 for a template beyond RFC 6570 level 1', () => { + for (const uriTemplate of ['test://file/{+path}', 'test://search{?q}', 'test://pair/{a,b}', 'test://file/{path*}', 'test://file/{path', 'test://file/path}', 'test://pair/{a}/{a}', 'test://file/{}']) { + expect(() => compileResourceTemplates([{ uriTemplate, name: 'bad', read: () => ({}) }])).toThrow(expect.objectContaining({ code: 'DF0084' })) + } + expect(compileResourceTemplates([{ uriTemplate: 'test://fixed', name: 'fixed', read: () => ({}) }])[0]!.match('test://fixed')).toEqual({}) + }) + }) + + 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() }) + const server = buildMcpServerFromContext(ctx, { serverName: 'test', serverVersion: '0.0.0-test', exposeSharedState: true }) + let notify: (() => void) | undefined + const unsubscribe = vi.fn() + const hook = vi.fn((n: () => void) => { + notify = n + return unsubscribe + }) + // The same wiring as `createMcpServer` uses for stdio. + const unbridge = bridgeListChanged(ctx, { + tools: () => { void server.sendToolListChanged().catch(() => {}) }, + resources: () => { void server.sendResourceListChanged().catch(() => {}) }, + }, hook) + expect(hook).toHaveBeenCalledWith(expect.any(Function), ctx) + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + const changed = vi.fn() + client.setNotificationHandler('notifications/resources/list_changed', changed) + await client.connect(clientTransport) + try { + notify!() + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)) + + unbridge() + expect(unsubscribe).toHaveBeenCalledTimes(1) + notify!() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(changed).toHaveBeenCalledTimes(1) + } + finally { + await client.close() + await server.close() + } + }) + }) }) diff --git a/packages/agentic/src/mcp/build-server.ts b/packages/agentic/src/mcp/build-server.ts index dade8dd3..9b3ae527 100644 --- a/packages/agentic/src/mcp/build-server.ts +++ b/packages/agentic/src/mcp/build-server.ts @@ -1,7 +1,7 @@ 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, McpServerHandle } from 'devframe/types' +import type { AgentTool, CreateMcpServerOptions, DevframeDefinition, DevframeHost, DevframeNodeContext, McpContentBlock, McpResourcesChangedHook, McpResourceTemplate, McpServerHandle } from 'devframe/types' import { homedir } from 'node:os' import process from 'node:process' import { Server } from '@modelcontextprotocol/server' @@ -17,6 +17,22 @@ export interface BuildMcpServerOptions { exposeSharedState: boolean | ((k: string) => boolean) /** Sent as `instructions` in the `initialize` / `server/discover` result. */ instructions?: string + /** + * Compiled resource templates (see {@link compileResourceTemplates}); + * omitted or empty keeps the resource handlers unchanged. + */ + resourceTemplates?: CompiledResourceTemplate[] +} + +/** + * A {@link McpResourceTemplate} with its `uriTemplate` compiled to a matcher. + * + * @internal + */ +export interface CompiledResourceTemplate { + template: McpResourceTemplate + /** The decoded value of each `{name}` when `uri` matches, else `undefined`. */ + match: (uri: string) => Record | undefined } /** @@ -50,7 +66,10 @@ export function buildMcpServerFromContext( ) registerToolHandlers(server, ctx, options.exposeSharedState) - registerResourceHandlers(server, ctx, options.exposeSharedState) + const templates = options.resourceTemplates ?? [] + registerResourceHandlers(server, ctx, options.exposeSharedState, templates) + if (templates.length) + registerResourceTemplateHandlers(server, templates) return server } @@ -58,8 +77,10 @@ export function buildMcpServerFromContext( /** * Publish devframe's `list_changed` events through a set of typed sinks: * `tools()` for tool-list changes and `resources()` for resource-list - * changes (shared-state keys are surfaced as resources). Returns an - * unsubscribe function. + * changes (shared-state keys are surfaced as resources). An optional host + * subscription (`onResourcesChanged`) also publishes through `resources()` + * each time it calls `notify()`. Returns an unsubscribe function, which also + * removes the host subscription; a `notify()` after that does nothing. * * The HTTP path passes the handler's `notify` bus sugar; the stdio path * passes the pinned server's `send*ListChanged` methods, which `serveStdio` @@ -70,6 +91,7 @@ export function buildMcpServerFromContext( export function bridgeListChanged( ctx: DevframeNodeContext, sinks: { tools: () => void, resources: () => void }, + onResourcesChanged?: McpResourcesChangedHook, ): () => void { const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { sinks.tools() @@ -78,9 +100,16 @@ export function bridgeListChanged( const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => { sinks.resources() }) + let active = true + const offHost = onResourcesChanged?.(() => { + if (active) + sinks.resources() + }, ctx) return () => { + active = false offManifest() offKeyAdded() + offHost?.() } } @@ -95,6 +124,8 @@ 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). + const resourceTemplates = compileResourceTemplates(options.resourceTemplates) const host: DevframeHost = { mountStatic: () => { /* MCP has no static surface */ }, @@ -125,6 +156,7 @@ export async function createMcpServer( serverVersion: options.serverVersion ?? definition.version ?? '0.0.0', exposeSharedState: options.exposeSharedState ?? true, instructions: options.instructions, + resourceTemplates, } // `serveStdio` pins ONE instance for the connection's lifetime. Each pinned @@ -140,7 +172,7 @@ export async function createMcpServer( const unbridge = bridgeListChanged(ctx, { tools: () => { void server.sendToolListChanged().catch(() => {}) }, resources: () => { void server.sendResourceListChanged().catch(() => {}) }, - }) + }, options.onResourcesChanged) const priorOnClose = server.onclose server.onclose = () => { unbridge() @@ -304,11 +336,12 @@ function registerResourceHandlers( server: Server, ctx: DevframeNodeContext, exposeSharedState: boolean | ((key: string) => boolean), + templates: CompiledResourceTemplate[], ): void { const stateFilter = sharedStateFilter(exposeSharedState) server.setRequestHandler('resources/list', async () => { - const resources = ctx.agent.list().resources.map(resource => ({ + const resources: ResourceListing[] = ctx.agent.list().resources.map(resource => ({ uri: resource.uri, name: resource.name, description: resource.description, @@ -328,6 +361,8 @@ function registerResourceHandlers( } } + resources.push(...await listTemplateResources(ctx, templates)) + return { resources } }) @@ -367,10 +402,137 @@ function registerResourceHandlers( } } + // Templates come after the registered and shared-state resources (whose + // `devframe://` URIs return or throw above); the first one that matches + // reads the URI. A throwing `read` is reported + // like a throwing registered resource: the error goes to the MCP client. + for (const { template, match } of templates) { + const params = match(uri) + if (!params) + continue + const content = await template.read(params, uri, ctx) + return { + contents: [ + { + uri, + mimeType: content.mimeType ?? template.mimeType ?? 'application/json', + text: content.text ?? stringifyForMcp(content.json), + }, + ], + } + } + throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`) }) } +/** One entry of the `resources/list` result. */ +interface ResourceListing { + uri: string + name: string + title?: string + description?: string + mimeType?: string +} + +/** The entries the `list` of each template returns, in template order, for `resources/list`. */ +async function listTemplateResources(ctx: DevframeNodeContext, templates: CompiledResourceTemplate[]): Promise { + const resources: ResourceListing[] = [] + for (const { template } of templates) { + for (const entry of await template.list?.(ctx) ?? []) { + const mimeType = entry.mimeType ?? template.mimeType + resources.push({ + uri: entry.uri, + name: entry.name, + ...(entry.title !== undefined ? { title: entry.title } : {}), + ...(entry.description !== undefined ? { description: entry.description } : {}), + ...(mimeType !== undefined ? { mimeType } : {}), + }) + } + } + return resources +} + +/** Serve `resources/templates/list`: one entry per template, in the given order. */ +function registerResourceTemplateHandlers(server: Server, templates: CompiledResourceTemplate[]): void { + server.setRequestHandler('resources/templates/list', async () => ({ + resourceTemplates: templates.map(({ template }) => ({ + uriTemplate: template.uriTemplate, + name: template.name, + ...(template.title !== undefined ? { title: template.title } : {}), + ...(template.description !== undefined ? { description: template.description } : {}), + ...(template.mimeType !== undefined ? { mimeType: template.mimeType } : {}), + })), + })) +} + +/** One `{name}` expression of a URI template. */ +const TEMPLATE_EXPRESSION_RE = /\{([^{}]*)\}/g +/** RFC 6570 `varname`: letters, digits and `_`, with single dots between them. */ +const TEMPLATE_VARNAME_RE = /^\w+(?:\.\w+)*$/ + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Compile resource templates to URI matchers, once per endpoint. Only RFC + * 6570 level 1 is supported: plain `{name}` expressions. Each expression + * matches one non-empty URI segment (no `/`), and its value is + * percent-decoded, so an encoded `%2F` reads back as `/`. A URI with + * malformed percent-encoding matches no template. Any other template syntax + * throws `DF0084`. + * + * @internal + */ +export function compileResourceTemplates(templates: McpResourceTemplate[] = []): CompiledResourceTemplate[] { + return templates.map(compileResourceTemplate) +} + +function compileResourceTemplate(template: McpResourceTemplate): CompiledResourceTemplate { + const { name, uriTemplate } = template + const invalid = (reason: string) => diagnostics.DF0084({ name, uriTemplate, reason }) + const names: string[] = [] + let source = '^' + let last = 0 + for (const expression of uriTemplate.matchAll(TEMPLATE_EXPRESSION_RE)) { + const literal = uriTemplate.slice(last, expression.index) + if (/[{}]/.test(literal)) + throw invalid('a `{` or `}` is outside an expression.') + const varname = expression[1]! + if (!TEMPLATE_VARNAME_RE.test(varname)) + throw invalid(`the expression "{${varname}}" is not a plain \`{name}\`.`) + if (names.includes(varname)) + throw invalid(`the name "${varname}" is used more than once.`) + names.push(varname) + source += `${escapeRegExp(literal)}([^/]+)` + last = expression.index + expression[0].length + } + const tail = uriTemplate.slice(last) + if (/[{}]/.test(tail)) + throw invalid('a `{` or `}` is outside an expression.') + const pattern = new RegExp(`${source}${escapeRegExp(tail)}$`) + + return { + template, + match(uri) { + const found = pattern.exec(uri) + if (!found) + return undefined + const params: Record = {} + try { + names.forEach((varname, index) => { + params[varname] = decodeURIComponent(found[index + 1]!) + }) + } + catch { + return undefined + } + return params + }, + } +} + /** * The content blocks a tool result carries through `withMcpContent`, checked * and copied field by field, or `undefined` when it carries none. Invalid diff --git a/packages/agentic/src/mcp/fetch.ts b/packages/agentic/src/mcp/fetch.ts index 841af22e..dc78f84a 100644 --- a/packages/agentic/src/mcp/fetch.ts +++ b/packages/agentic/src/mcp/fetch.ts @@ -2,7 +2,7 @@ import type { CreateMcpFetchHandlerOptions, DevframeNodeContext, McpAuthorizatio import { createMcpHandler } from '@modelcontextprotocol/server' import { timingSafeEqual } from 'devframe/utils/crypto-token' import { isAllowedOrigin, isLoopbackAddress } from 'devframe/utils/origin' -import { bridgeListChanged, buildMcpServerFromContext } from './build-server' +import { bridgeListChanged, buildMcpServerFromContext, compileResourceTemplates } from './build-server' /** * Parse exactly one `Authorization: Bearer ` credential, returning the @@ -73,11 +73,16 @@ export function createMcpFetchHandler( // identity check is configured. const authorization = options.authorization ?? false + // Compiled once for the endpoint, not per request; an invalid template + // fails here, at mount time (DF0084). + const resourceTemplates = compileResourceTemplates(options.resourceTemplates) + const handler = createMcpHandler(() => buildMcpServerFromContext(ctx, { serverName: options.serverName, serverVersion: options.serverVersion, exposeSharedState: options.exposeSharedState, instructions: options.instructions, + resourceTemplates, })) // A single, long-lived bridge from devframe's change events onto the @@ -86,7 +91,7 @@ export function createMcpFetchHandler( const unbridge = bridgeListChanged(ctx, { tools: () => { handler.notify.toolsChanged() }, resources: () => { handler.notify.resourcesChanged() }, - }) + }, options.onResourcesChanged) // The zero-config trust boundary: no widened origin allow-list and no // identity check, so the endpoint trusts same-machine callers alone. A diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 78277a53..ac969465 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -393,6 +393,46 @@ describe('adapters/handler', () => { } }) + it('mcp: resource templates reach the route through resources/templates/list and resources/read', async () => { + const wsPort = await getPort({ port: 18148, host: '127.0.0.1' }) + const unsubscribe = vi.fn() + const devtools = initDevframe(defineTestDef('handler-mcp-templates'), { + base: '/__handler-mcp-templates/', + auth: false, + mcp: { + resourceTemplates: [{ + uriTemplate: 'handler://page/{slug}', + name: 'page', + mimeType: 'text/plain', + read: params => ({ text: `Page ${params.slug}.` }), + }], + onResourcesChanged: () => unsubscribe, + }, + ws: { port: wsPort }, + }) + + const call = (method: string, params?: Record) => devtools.handler(new Request('http://localhost:3000/__handler-mcp-templates/__mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + 'origin': 'http://localhost:3000', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + })).then(res => res.text()) + + try { + await devtools.ready + expect(await call('resources/templates/list')).toContain('"uriTemplate":"handler://page/{slug}"') + expect(await call('resources/read', { uri: 'handler://page/home' })).toContain('Page home.') + expect(await call('resources/read', { uri: 'handler://page/a/b' })).toContain('unknown resource URI') + } + finally { + await devtools.close() + } + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + // The `'auto'` default: an omitted `mcp` mounts the route exactly when // `setup()` left a non-empty agent surface. function defineAgentTestDef(id: string) { diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts index 0930b62b..730c372a 100644 --- a/packages/devframe/src/adapters/_shared.ts +++ b/packages/devframe/src/adapters/_shared.ts @@ -2,6 +2,7 @@ import type { AgenticMcpModule } from '../node/agentic' import type { DevframeAgentHost } from '../types/agent' import type { ConnectionMeta } from '../types/context' import type { DevframeDefinition, DevframeDeploymentKind, McpAuthorization, McpSetting } from '../types/devframe' +import type { McpResourcesChangedHook, McpResourceTemplate } from '../types/mcp' import { cleanDoubleSlashes, withLeadingSlash, withoutLeadingSlash, withTrailingSlash } from 'devframe/utils/url' import { getPort } from 'get-port-please' import { DEVFRAME_MCP_ROUTE } from '../constants' @@ -72,6 +73,10 @@ export interface ResolvedMcpConfig { exposeSharedState?: boolean | ((key: string) => boolean) /** Handshake `instructions` from the route options; omitted means none. */ instructions?: string + /** Resource templates from the route options; omitted means none. */ + resourceTemplates?: McpResourceTemplate[] + /** Resource-list change subscription from the route options; omitted means none. */ + onResourcesChanged?: McpResourcesChangedHook /** The resolved identity policy: a bearer token, callback, or `false`. */ authorization: McpAuthorization } @@ -102,6 +107,8 @@ export function resolveMcpConfig(mcp: McpSetting | undefined): ResolvedMcpConfig ...(mcp.allowedOrigins !== undefined ? { allowedOrigins: mcp.allowedOrigins } : {}), ...(mcp.exposeSharedState !== undefined ? { exposeSharedState: mcp.exposeSharedState } : {}), ...(mcp.instructions !== undefined ? { instructions: mcp.instructions } : {}), + ...(mcp.resourceTemplates !== undefined ? { resourceTemplates: mcp.resourceTemplates } : {}), + ...(mcp.onResourcesChanged !== undefined ? { onResourcesChanged: mcp.onResourcesChanged } : {}), authorization, } } diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 6fae86f3..591f9573 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -381,6 +381,8 @@ async function mountMcpRoute( serverVersion: def.version ?? '0.0.0', exposeSharedState: config.exposeSharedState ?? true, instructions: config.instructions, + resourceTemplates: config.resourceTemplates, + onResourcesChanged: config.onResourcesChanged, authorization: config.authorization, allowedOrigins: config.allowedOrigins, }) diff --git a/packages/devframe/src/adapters/mcp.ts b/packages/devframe/src/adapters/mcp.ts index 437f5b84..27531d04 100644 --- a/packages/devframe/src/adapters/mcp.ts +++ b/packages/devframe/src/adapters/mcp.ts @@ -9,6 +9,10 @@ export type { CreateMcpServerOptions, McpConnectionInfo, McpFetchHandler, + McpResourcesChangedHook, + McpResourceTemplate, + McpResourceTemplateContent, + McpResourceTemplateEntry, McpServerHandle, } from '../types/mcp' export { createMcpFetchHandler, createMcpServer, mountMcpHttp } from '@devframes/agentic/mcp' diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index b05c5825..6a9b787a 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -225,5 +225,10 @@ export const diagnostics = defineDiagnostics({ `Tool "${p.name}" returned invalid MCP content blocks: ${p.reason}`, fix: 'Pass `withMcpContent` a non-empty array of `{ type: \'text\', text }`, `{ type: \'image\', data, mimeType }` or `{ type: \'audio\', data, mimeType }` blocks, with string fields and base64 `data`.', }, + DF0084: { + why: (p: { name: string, uriTemplate: string, reason: string }) => + `MCP resource template "${p.name}" has an invalid \`uriTemplate\` "${p.uriTemplate}": ${p.reason}`, + fix: 'Use RFC 6570 level 1 only: plain `{name}` expressions made of letters, digits, `_` and `.`, with no operator (`+`, `#`, `/`, `?`, …), modifier (`:n`, `*`) or list of names, and no `{` or `}` outside an expression.', + }, }, }) diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index bbebcb1c..f6eea2d8 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -2,6 +2,7 @@ import type { CAC } from 'cac' import type { CliFlagsSchema } from '../adapters/flags' import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeNodeContext } from './context' +import type { McpResourcesChangedHook, McpResourceTemplate } from './mcp' import type { StaticAssetsSource } from './remote-assets' import type { DevframeServiceInput } from './services' @@ -181,6 +182,23 @@ export interface McpRouteOptions { * Omitted or empty sends no instructions. */ instructions?: string + /** + * Resource templates the route serves through MCP + * `resources/templates/list`. `resources/read` tries them after the + * registered resources and the shared-state resources (a + * `devframe://resource/` or `devframe://state/` URI never reaches them); + * the first template whose `uriTemplate` matches the URI reads it. `resources/list` adds the + * entries each template's `list` returns. Omitted or empty keeps the + * resources unchanged. + */ + resourceTemplates?: McpResourceTemplate[] + /** + * Host subscription for resource-list changes. Call `notify()` each time + * the resources change (for example, the entries of a resource template); + * the route then sends `notifications/resources/list_changed`. Return a + * function that removes the subscription; the route calls it on teardown. + */ + onResourcesChanged?: McpResourcesChangedHook } export interface DevframeCliOptions { diff --git a/packages/devframe/src/types/mcp.ts b/packages/devframe/src/types/mcp.ts index b08789aa..ab590b09 100644 --- a/packages/devframe/src/types/mcp.ts +++ b/packages/devframe/src/types/mcp.ts @@ -1,3 +1,4 @@ +import type { DevframeNodeContext } from './context' import type { McpAuthorization } from './devframe' /** @@ -30,6 +31,16 @@ export interface CreateMcpServerOptions { * `server/discover`) result. Omitted or empty sends no instructions. */ instructions?: string + /** + * Resource templates served through `resources/templates/list`, and read + * through `resources/read`. Omitted or empty keeps the resources unchanged. + */ + resourceTemplates?: McpResourceTemplate[] + /** + * Host subscription for resource-list changes; each `notify()` sends + * `notifications/resources/list_changed`. See {@link McpResourcesChangedHook}. + */ + onResourcesChanged?: McpResourcesChangedHook /** Override the name reported in the MCP handshake. */ serverName?: string /** Override the version reported in the MCP handshake. Defaults to `definition.version ?? '0.0.0'`. */ @@ -61,6 +72,10 @@ export interface CreateMcpFetchHandlerOptions { exposeSharedState: boolean | ((key: string) => boolean) /** Usage guidance for the handshake; see {@link CreateMcpServerOptions.instructions}. */ instructions?: string + /** Resource templates; see {@link CreateMcpServerOptions.resourceTemplates}. */ + resourceTemplates?: McpResourceTemplate[] + /** Host subscription for resource-list changes; see {@link CreateMcpServerOptions.onResourcesChanged}. */ + onResourcesChanged?: McpResourcesChangedHook /** * Optional identity check, layered on top of the origin gate and checked * **after** it: a bearer token string (matched in constant time against @@ -106,3 +121,60 @@ export interface McpFetchHandler { /** Tear down the handler (aborts in-flight exchanges, drops the change bridge). */ dispose: () => Promise } + +/** One concrete resource that an {@link McpResourceTemplate} lists in `resources/list`. */ +export interface McpResourceTemplateEntry { + /** The resource URI. It SHOULD match the `uriTemplate` of its template, so `resources/read` finds it. */ + uri: string + name: string + /** Human-readable name for display. */ + title?: string + description?: string + /** Defaults to the `mimeType` of the template. */ + mimeType?: string +} + +/** + * What an {@link McpResourceTemplate} returns from `read`, sent as the + * `resources/read` result: `text` as is, or else `json` as JSON text. + */ +export interface McpResourceTemplateContent { + text?: string + json?: unknown + /** Defaults to the `mimeType` of the template, then `application/json`. */ + mimeType?: string +} + +/** + * A resource template the MCP server lists in `resources/templates/list` and + * reads in `resources/read`. The `uriTemplate` uses RFC 6570 level 1 only: + * plain `{name}` expressions. Each expression matches one non-empty URI + * segment (no `/`), and its value is percent-decoded. + * + * `list` and `read` get the node context of the devframe (or of the hub), so + * they can read live state such as `ctx.rpc.sharedState` or `ctx.agent`. The + * route options are resolved before `setup()` runs, so a template cannot + * capture that context in a closure. + */ +export interface McpResourceTemplate { + /** RFC 6570 level 1 URI template, e.g. `my-tool://file/{path}`. Anything else throws `DF0084`. */ + uriTemplate: string + name: string + /** Human-readable name for display. */ + title?: string + description?: string + /** MIME type of every resource the template stands for. */ + mimeType?: string + /** The concrete resources the template stands for right now. They are added to `resources/list`. */ + list?: (ctx: DevframeNodeContext) => McpResourceTemplateEntry[] | Promise + /** Read one resource: `params` holds the decoded value of each `{name}`, `uri` the requested URI. */ + read: (params: Record, uri: string, ctx: DevframeNodeContext) => McpResourceTemplateContent | Promise +} + +/** + * A host subscription for resource-list changes. Call `notify()` each time + * the resources change; the MCP server then sends + * `notifications/resources/list_changed`. Return a function that removes the + * subscription; the MCP server calls it on teardown. + */ +export type McpResourcesChangedHook = (notify: () => void, ctx: DevframeNodeContext) => () => void diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 3f8e54b2..bfcf6c30 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { DOCK_RENDERERS_STATE_KEY } from '../../constants' import { DEVFRAMES_HUB_BASE, initHub } from '../initiate' @@ -44,6 +44,22 @@ function connectWsClient(url: string) { ) } +/** POST one JSON-RPC request to the hub's aggregate MCP route and return the raw body. */ +async function callMcp(hub: ReturnType, method: string, params?: Record): Promise { + const origin = 'http://localhost:3000' + const res = await hub.handler(new Request(`${origin}/__devframes/__mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + origin, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + })) + expect(res.status).toBe(200) + return await res.text() +} + describe('initHub', () => { it('publishes an empty renderer manifest when no renderers are registered', async () => { expect.assertions(2) @@ -325,8 +341,6 @@ describe('initHub', () => { }) describe('aggregate MCP shared-state exposure', () => { - const origin = 'http://localhost:3000' - function makeStateFrame(): DevframeDefinition { const frame = makeFrame('state') return { @@ -339,20 +353,6 @@ describe('initHub', () => { } } - async function callMcp(hub: ReturnType, method: string, params?: Record): Promise { - const res = await hub.handler(new Request(`${origin}/__devframes/__mcp`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept': 'application/json, text/event-stream', - origin, - }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - })) - expect(res.status).toBe(200) - return await res.text() - } - it('exposes every shared state by default', async () => { const wsPort = await getPort({ port: 18235, host: '127.0.0.1' }) const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, mcp: true, devframes: [makeStateFrame()] }) @@ -481,6 +481,55 @@ describe('initHub', () => { }) }) + describe('aggregate MCP resource templates', () => { + it('answers resources/templates/list with method not found by default', async () => { + const wsPort = await getPort({ port: 18241, host: '127.0.0.1' }) + const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, mcp: true, devframes: [makeFrame('alpha')] }) + + try { + await hub.ready + expect(await callMcp(hub, 'resources/templates/list')).toContain('-32601') + } + finally { + await hub.close() + } + }) + + it('serves mcp.resourceTemplates and wires mcp.onResourcesChanged to the hub context', async () => { + const wsPort = await getPort({ port: 18242, host: '127.0.0.1' }) + const unsubscribe = vi.fn() + const onResourcesChanged = vi.fn(() => unsubscribe) + const hub = initHub({ + base: DEVFRAMES_HUB_BASE, + auth: false, + host: '127.0.0.1', + ws: { port: wsPort }, + mcp: { + resourceTemplates: [{ + uriTemplate: 'hub://frame/{id}', + name: 'frame', + list: () => [{ uri: 'hub://frame/alpha', name: 'alpha' }], + read: params => ({ json: { id: params.id } }), + }], + onResourcesChanged, + }, + devframes: [makeFrame('alpha')], + }) + + try { + await hub.ready + expect(await callMcp(hub, 'resources/templates/list')).toContain('"uriTemplate":"hub://frame/{id}"') + expect(onResourcesChanged).toHaveBeenCalledWith(expect.any(Function), await hub.context) + expect(await callMcp(hub, 'resources/list')).toContain('"uri":"hub://frame/alpha"') + expect(await callMcp(hub, 'resources/read', { uri: 'hub://frame/beta' })).toContain('\\"id\\": \\"beta\\"') + } + finally { + await hub.close() + } + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + }) + it('aggregate MCP omitted: mounts once a mounted frame exposes agent tools', async () => { const wsPort = await getPort({ port: 18233, host: '127.0.0.1' }) const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, devframes: [makeFrame('alpha')] }) diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 2af9db7f..84105080 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -524,6 +524,8 @@ export function initHub(options: InitHubOptions): HubInstance { serverVersion: options.version ?? '0.0.0', exposeSharedState: mcpConfig.exposeSharedState ?? true, instructions: mcpConfig.instructions, + resourceTemplates: mcpConfig.resourceTemplates, + onResourcesChanged: mcpConfig.onResourcesChanged, authorization: mcpConfig.authorization, allowedOrigins: mcpConfig.allowedOrigins, }) diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index 559f8cc3..005f5f9a 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -1,4 +1,4 @@ -import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope, McpAuthorization } from 'devframe' +import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope, McpAuthorization, McpResourcesChangedHook, McpResourceTemplate } from 'devframe' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' import { importAgenticMcp } from 'devframe/internal' import { serveStaticHandler } from 'devframe/utils/serve-static' @@ -43,6 +43,10 @@ export interface DevframeNextHostMcpOptions { exposeSharedState?: boolean | ((key: string) => boolean) /** Usage guidance sent as `instructions` in the MCP handshake. Default: none. */ instructions?: string + /** Resource templates served through `resources/templates/list` and `resources/read`. Default: none. */ + resourceTemplates?: McpResourceTemplate[] + /** Host subscription; each `notify()` sends `notifications/resources/list_changed`. Default: none. */ + onResourcesChanged?: McpResourcesChangedHook /** * Origin allow-list beyond the loopback default. `false` disables the * origin gate entirely. Note the MCP route rejects `Origin`-less requests @@ -183,6 +187,8 @@ export function createDevframeNextHost( serverVersion: mcpOptions.serverVersion ?? '0.0.0', exposeSharedState: mcpOptions.exposeSharedState ?? true, instructions: mcpOptions.instructions, + resourceTemplates: mcpOptions.resourceTemplates, + onResourcesChanged: mcpOptions.onResourcesChanged, authorization: mcpOptions.authorization, allowedOrigins: mcpOptions.allowedOrigins, }) diff --git a/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts index b5ea1700..1a299482 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts @@ -21,6 +21,8 @@ export interface DevframeNextHostMcpOptions { serverVersion?: string; exposeSharedState?: boolean | ((_: string) => boolean); instructions?: string; + resourceTemplates?: McpResourceTemplate[]; + onResourcesChanged?: McpResourcesChangedHook; allowedOrigins?: readonly string[] | false; } export interface NextDevframeHubHandle { diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts index 6ab42482..d00dd3ae 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts @@ -8,6 +8,10 @@ export { createMcpServer } export { CreateMcpServerOptions } export { McpConnectionInfo } export { McpFetchHandler } +export { McpResourcesChangedHook } +export { McpResourceTemplate } +export { McpResourceTemplateContent } +export { McpResourceTemplateEntry } export { McpServerHandle } export { MountedMcpHttp } export { mountMcpHttp } diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 736ba0aa..44726217 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -92,6 +92,8 @@ export interface CreateMcpFetchHandlerOptions { serverVersion: string; exposeSharedState: boolean | ((_: string) => boolean); instructions?: string; + resourceTemplates?: McpResourceTemplate[]; + onResourcesChanged?: McpResourcesChangedHook; authorization?: McpAuthorization; allowedOrigins?: readonly string[] | false; } @@ -99,6 +101,8 @@ export interface CreateMcpServerOptions { transport?: 'stdio'; exposeSharedState?: boolean | ((_: string) => boolean); instructions?: string; + resourceTemplates?: McpResourceTemplate[]; + onResourcesChanged?: McpResourcesChangedHook; serverName?: string; serverVersion?: string; onReady?: (_: { @@ -444,12 +448,35 @@ export interface McpFetchHandler { fetch: (_: Request, _?: McpConnectionInfo) => Promise; dispose: () => Promise; } +export interface McpResourceTemplate { + uriTemplate: string; + name: string; + title?: string; + description?: string; + mimeType?: string; + list?: (_: DevframeNodeContext) => McpResourceTemplateEntry[] | Promise; + read: (_: Record, _: string, _: DevframeNodeContext) => McpResourceTemplateContent | Promise; +} +export interface McpResourceTemplateContent { + text?: string; + json?: unknown; + mimeType?: string; +} +export interface McpResourceTemplateEntry { + uri: string; + name: string; + title?: string; + description?: string; + mimeType?: string; +} export interface McpRouteOptions { path?: string; authorization?: McpAuthorization; allowedOrigins?: readonly string[] | false; exposeSharedState?: boolean | ((_: string) => boolean); instructions?: string; + resourceTemplates?: McpResourceTemplate[]; + onResourcesChanged?: McpResourcesChangedHook; } export interface McpServerHandle { stop: () => Promise; @@ -568,6 +595,7 @@ export type McpContentBlock = { data: string; mimeType: string; }; +export type McpResourcesChangedHook = (_: () => void, _: DevframeNodeContext) => () => void; export type McpSetting = boolean | 'auto' | McpRouteOptions; export type RemoteAssetsProvider = 'jsdelivr' | 'unpkg' | RemoteAssetsProviderCustom; export type RpcFunctionsHost = RpcFunctionsCollectorBase & { diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index eadd1d99..68e57709 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -378,6 +378,14 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "Pass `withMcpContent` a non-empty array of `{ type: 'text', text }`, `{ type: 'image', data, mimeType }` or `{ type: 'audio', data, mimeType }` blocks, with string fields and base64 `data`."; }; + readonly DF0084: { + readonly why: (p: { + name: string; + uriTemplate: string; + reason: string; + }) => string; + readonly fix: "Use RFC 6570 level 1 only: plain `{name}` expressions made of letters, digits, `_` and `.`, with no operator (`+`, `#`, `/`, `?`, …), modifier (`:n`, `*`) or list of names, and no `{` or `}` outside an expression."; + }; }, readonly [(d: import("nostics").Diagnostic, { method }?: { method?: "log" | "warn" | "error"; }) => void], never>; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 23f4eea7..dea4bec5 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -71,6 +71,10 @@ export { McpAuthorization } export { McpConnectionInfo } export { McpContentBlock } export { McpFetchHandler } +export { McpResourcesChangedHook } +export { McpResourceTemplate } +export { McpResourceTemplateContent } +export { McpResourceTemplateEntry } export { McpRouteOptions } export { McpServerHandle } export { McpSetting } From 96640ea2d602c717dc402b2f43b581bf020c9a85 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 20 Oct 2026 05:22:01 +0000 Subject: [PATCH 2/4] refactor(mcp): trim resource template listing and redundant tests Undefined fields drop out of the JSON result, so the listings pass them as is. The devframe and hub initiate tests that only repeated what the HTTP and in-memory tests already catch are removed. --- packages/agentic/src/mcp/build-server.ts | 31 +++----------- .../src/adapters/__tests__/initiate.test.ts | 40 ------------------- .../hub/src/node/__tests__/initiate.test.ts | 13 ------ 3 files changed, 5 insertions(+), 79 deletions(-) diff --git a/packages/agentic/src/mcp/build-server.ts b/packages/agentic/src/mcp/build-server.ts index 9b3ae527..e1032112 100644 --- a/packages/agentic/src/mcp/build-server.ts +++ b/packages/agentic/src/mcp/build-server.ts @@ -361,7 +361,10 @@ function registerResourceHandlers( } } - resources.push(...await listTemplateResources(ctx, templates)) + for (const { template } of templates) { + for (const entry of await template.list?.(ctx) ?? []) + resources.push({ ...entry, mimeType: entry.mimeType ?? template.mimeType }) + } return { resources } }) @@ -435,34 +438,10 @@ interface ResourceListing { mimeType?: string } -/** The entries the `list` of each template returns, in template order, for `resources/list`. */ -async function listTemplateResources(ctx: DevframeNodeContext, templates: CompiledResourceTemplate[]): Promise { - const resources: ResourceListing[] = [] - for (const { template } of templates) { - for (const entry of await template.list?.(ctx) ?? []) { - const mimeType = entry.mimeType ?? template.mimeType - resources.push({ - uri: entry.uri, - name: entry.name, - ...(entry.title !== undefined ? { title: entry.title } : {}), - ...(entry.description !== undefined ? { description: entry.description } : {}), - ...(mimeType !== undefined ? { mimeType } : {}), - }) - } - } - return resources -} - /** Serve `resources/templates/list`: one entry per template, in the given order. */ function registerResourceTemplateHandlers(server: Server, templates: CompiledResourceTemplate[]): void { server.setRequestHandler('resources/templates/list', async () => ({ - resourceTemplates: templates.map(({ template }) => ({ - uriTemplate: template.uriTemplate, - name: template.name, - ...(template.title !== undefined ? { title: template.title } : {}), - ...(template.description !== undefined ? { description: template.description } : {}), - ...(template.mimeType !== undefined ? { mimeType: template.mimeType } : {}), - })), + resourceTemplates: templates.map(({ template: { uriTemplate, name, title, description, mimeType } }) => ({ uriTemplate, name, title, description, mimeType })), })) } diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index ac969465..78277a53 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -393,46 +393,6 @@ describe('adapters/handler', () => { } }) - it('mcp: resource templates reach the route through resources/templates/list and resources/read', async () => { - const wsPort = await getPort({ port: 18148, host: '127.0.0.1' }) - const unsubscribe = vi.fn() - const devtools = initDevframe(defineTestDef('handler-mcp-templates'), { - base: '/__handler-mcp-templates/', - auth: false, - mcp: { - resourceTemplates: [{ - uriTemplate: 'handler://page/{slug}', - name: 'page', - mimeType: 'text/plain', - read: params => ({ text: `Page ${params.slug}.` }), - }], - onResourcesChanged: () => unsubscribe, - }, - ws: { port: wsPort }, - }) - - const call = (method: string, params?: Record) => devtools.handler(new Request('http://localhost:3000/__handler-mcp-templates/__mcp', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept': 'application/json, text/event-stream', - 'origin': 'http://localhost:3000', - }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - })).then(res => res.text()) - - try { - await devtools.ready - expect(await call('resources/templates/list')).toContain('"uriTemplate":"handler://page/{slug}"') - expect(await call('resources/read', { uri: 'handler://page/home' })).toContain('Page home.') - expect(await call('resources/read', { uri: 'handler://page/a/b' })).toContain('unknown resource URI') - } - finally { - await devtools.close() - } - expect(unsubscribe).toHaveBeenCalledTimes(1) - }) - // The `'auto'` default: an omitted `mcp` mounts the route exactly when // `setup()` left a non-empty agent surface. function defineAgentTestDef(id: string) { diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index bfcf6c30..92c3992f 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -482,19 +482,6 @@ describe('initHub', () => { }) describe('aggregate MCP resource templates', () => { - it('answers resources/templates/list with method not found by default', async () => { - const wsPort = await getPort({ port: 18241, host: '127.0.0.1' }) - const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, mcp: true, devframes: [makeFrame('alpha')] }) - - try { - await hub.ready - expect(await callMcp(hub, 'resources/templates/list')).toContain('-32601') - } - finally { - await hub.close() - } - }) - it('serves mcp.resourceTemplates and wires mcp.onResourcesChanged to the hub context', async () => { const wsPort = await getPort({ port: 18242, host: '127.0.0.1' }) const unsubscribe = vi.fn() From d81d1d1e2cbf3a661686e7ba5ae16c07f9aee4ff Mon Sep 17 00:00:00 2001 From: Erkam Yaman Date: Tue, 20 Oct 2026 19:07:35 +0000 Subject: [PATCH 3/4] fix(mcp): match resource templates in one pass and keep a __proto__ value A template with several values in one segment, such as `{a}-{b}-{c}`, compiled to a regular expression with overlapping `([^/]+)` groups, so a crafted URI could backtrack long enough to block the process. Templates now match in one pass from left to right with no regular expression: each value runs to the first place the next text appears. Two expressions with nothing between them (`{a}{b}`) throw DF0084, since a URI cannot show where one value ends. The params object has no prototype, so a variable named `__proto__` keeps its value. --- docs/content/2.adapters/7.mcp.md | 2 +- docs/content/6.errors/DF0084.md | 2 +- .../src/mcp/__tests__/mcp-server.test.ts | 24 +++++- packages/agentic/src/mcp/build-server.ts | 75 ++++++++++++------- packages/devframe/src/types/mcp.ts | 5 +- 5 files changed, 77 insertions(+), 31 deletions(-) diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index 42b07179..c9052cb2 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -99,7 +99,7 @@ devframeViteBridge(myDevframe, { }) ``` -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`). +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. 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/` or `devframe://state/` 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. diff --git a/docs/content/6.errors/DF0084.md b/docs/content/6.errors/DF0084.md index 7800ebf9..df6ab936 100644 --- a/docs/content/6.errors/DF0084.md +++ b/docs/content/6.errors/DF0084.md @@ -9,7 +9,7 @@ description: 'MCP resource template "{name}" has an invalid `uriTemplate` "{uriT ## 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. +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 diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index a84d08a1..a028862f 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -756,11 +756,33 @@ describe('mcp adapter (in-memory)', () => { }) it('throws DF0084 for a template beyond RFC 6570 level 1', () => { - for (const uriTemplate of ['test://file/{+path}', 'test://search{?q}', 'test://pair/{a,b}', 'test://file/{path*}', 'test://file/{path', 'test://file/path}', 'test://pair/{a}/{a}', 'test://file/{}']) { + for (const uriTemplate of ['test://file/{+path}', 'test://search{?q}', 'test://pair/{a,b}', 'test://file/{path*}', 'test://file/{path', 'test://file/path}', 'test://pair/{a}/{a}', 'test://file/{}', 'test://pair/{a}{b}']) { expect(() => compileResourceTemplates([{ uriTemplate, name: 'bad', read: () => ({}) }])).toThrow(expect.objectContaining({ code: 'DF0084' })) } expect(compileResourceTemplates([{ uriTemplate: 'test://fixed', name: 'fixed', read: () => ({}) }])[0]!.match('test://fixed')).toEqual({}) }) + + it('matches several values in one segment, each up to the next literal', () => { + const [range] = compileResourceTemplates([{ uriTemplate: 'test://range/{from}-{to}.json', name: 'range', read: () => ({}) }]) + expect(range!.match('test://range/1-2.json')).toEqual({ from: '1', to: '2' }) + expect(range!.match('test://range/1-2-3.json')).toEqual({ from: '1', to: '2-3' }) + for (const uri of ['test://range/-2.json', 'test://range/1-.json', 'test://range/1.json', 'test://range/1-2.txt', 'test://range/1-2/3.json']) + expect(range!.match(uri)).toBeUndefined() + }) + + it('keeps a value named __proto__', () => { + const [proto] = compileResourceTemplates([{ uriTemplate: 'test://proto/{__proto__}', name: 'proto', read: () => ({}) }]) + expect(Object.entries(proto!.match('test://proto/x')!)).toEqual([['__proto__', 'x']]) + }) + + it('rejects a long URI that matches no template without backtracking', () => { + const [many] = compileResourceTemplates([{ uriTemplate: 'test://many/{a}-{b}-{c}-{d}.json', name: 'many', read: () => ({}) }]) + // Ends like a match, but the `/` near the end fails it only after every value is read. + const uri = `test://many/${'x-'.repeat(25_000)}/.json` + const start = performance.now() + expect(many!.match(uri)).toBeUndefined() + expect(performance.now() - start).toBeLessThan(500) + }) }) describe('onResourcesChanged', () => { diff --git a/packages/agentic/src/mcp/build-server.ts b/packages/agentic/src/mcp/build-server.ts index e1032112..7500e06a 100644 --- a/packages/agentic/src/mcp/build-server.ts +++ b/packages/agentic/src/mcp/build-server.ts @@ -450,17 +450,13 @@ const TEMPLATE_EXPRESSION_RE = /\{([^{}]*)\}/g /** RFC 6570 `varname`: letters, digits and `_`, with single dots between them. */ const TEMPLATE_VARNAME_RE = /^\w+(?:\.\w+)*$/ -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - /** * Compile resource templates to URI matchers, once per endpoint. Only RFC * 6570 level 1 is supported: plain `{name}` expressions. Each expression - * matches one non-empty URI segment (no `/`), and its value is - * percent-decoded, so an encoded `%2F` reads back as `/`. A URI with - * malformed percent-encoding matches no template. Any other template syntax - * throws `DF0084`. + * matches a non-empty value with no `/`, and its value is percent-decoded, + * so an encoded `%2F` reads back as `/`. A URI with malformed + * percent-encoding matches no template. Any other template syntax, and two + * expressions with nothing between them, throw `DF0084`. * * @internal */ @@ -472,7 +468,8 @@ function compileResourceTemplate(template: McpResourceTemplate): CompiledResourc const { name, uriTemplate } = template const invalid = (reason: string) => diagnostics.DF0084({ name, uriTemplate, reason }) const names: string[] = [] - let source = '^' + // The text around the expressions: one more than `names`. + const literals: string[] = [] let last = 0 for (const expression of uriTemplate.matchAll(TEMPLATE_EXPRESSION_RE)) { const literal = uriTemplate.slice(last, expression.index) @@ -483,33 +480,59 @@ function compileResourceTemplate(template: McpResourceTemplate): CompiledResourc throw invalid(`the expression "{${varname}}" is not a plain \`{name}\`.`) if (names.includes(varname)) throw invalid(`the name "${varname}" is used more than once.`) + if (names.length > 0 && literal === '') + throw invalid(`"{${names.at(-1)}}" and "{${varname}}" have nothing between them, so a URI cannot show where one ends.`) + literals.push(literal) names.push(varname) - source += `${escapeRegExp(literal)}([^/]+)` last = expression.index + expression[0].length } const tail = uriTemplate.slice(last) if (/[{}]/.test(tail)) throw invalid('a `{` or `}` is outside an expression.') - const pattern = new RegExp(`${source}${escapeRegExp(tail)}$`) + literals.push(tail) return { template, - match(uri) { - const found = pattern.exec(uri) - if (!found) - return undefined - const params: Record = {} - try { - names.forEach((varname, index) => { - params[varname] = decodeURIComponent(found[index + 1]!) - }) - } - catch { - return undefined - } - return params - }, + match: uri => matchTemplate(uri, literals, names), + } +} + +/** + * Match `uri` in one pass from left to right, with no regular expression, so + * the time grows with the URI length only and a crafted URI cannot block the + * process. Each value runs to the first place the next literal appears (the + * last value runs to the tail). A value must not be empty or contain `/`. + * The params object has no prototype, so a variable such as `__proto__` + * keeps its value. + */ +function matchTemplate(uri: string, literals: string[], names: string[]): Record | undefined { + const head = literals[0]! + const tail = literals.at(-1)! + const params: Record = Object.create(null) + if (names.length === 0) + return uri === head ? params : undefined + if (!uri.startsWith(head) || !uri.endsWith(tail)) + return undefined + const end = uri.length - tail.length + let position = head.length + for (let index = 0; index < names.length; index++) { + const isLast = index === names.length - 1 + const next = literals[index + 1]! + const stop = isLast ? end : uri.indexOf(next, position + 1) + if (stop <= position || stop > end) + return undefined + const raw = uri.slice(position, stop) + if (raw.includes('/')) + return undefined + try { + params[names[index]!] = decodeURIComponent(raw) + } + catch { + return undefined + } + position = stop + (isLast ? 0 : next.length) } + return params } /** diff --git a/packages/devframe/src/types/mcp.ts b/packages/devframe/src/types/mcp.ts index ab590b09..ff1827b7 100644 --- a/packages/devframe/src/types/mcp.ts +++ b/packages/devframe/src/types/mcp.ts @@ -148,8 +148,9 @@ export interface McpResourceTemplateContent { /** * A resource template the MCP server lists in `resources/templates/list` and * reads in `resources/read`. The `uriTemplate` uses RFC 6570 level 1 only: - * plain `{name}` expressions. Each expression matches one non-empty URI - * segment (no `/`), and its value is percent-decoded. + * plain `{name}` expressions, with text between any two of them. Each + * expression matches a non-empty value with no `/`, and its value is + * percent-decoded. * * `list` and `read` get the node context of the devframe (or of the hub), so * they can read live state such as `ctx.rpc.sharedState` or `ctx.agent`. The From 61427efbcc5307381b1f1dd71c4ba4008e1d820b Mon Sep 17 00:00:00 2001 From: Erkam Yaman Date: Tue, 20 Oct 2026 19:17:05 +0000 Subject: [PATCH 4/4] fix(mcp): do not split a template value inside a percent escape A value ran to the first place the next literal appeared, even when that place was inside a `%XX` escape of the value. With `{a}D{b}`, the URI `a%2DbDc` split at the `D` of `%2D`, so `a` could not be decoded and a valid URI matched no template. The search now skips places inside an escape, so `a` reads `a-b` and `b` reads `c`. --- docs/content/2.adapters/7.mcp.md | 2 +- .../src/mcp/__tests__/mcp-server.test.ts | 8 ++++++++ packages/agentic/src/mcp/build-server.ts | 19 ++++++++++++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index c9052cb2..7e9aa838 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -99,7 +99,7 @@ devframeViteBridge(myDevframe, { }) ``` -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. 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`). +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/` or `devframe://state/` 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. diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index a028862f..f7a243ef 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -770,6 +770,14 @@ describe('mcp adapter (in-memory)', () => { expect(range!.match(uri)).toBeUndefined() }) + it('does not split a value inside a percent-encoded character', () => { + const [pair] = compileResourceTemplates([{ uriTemplate: 'test://pair/{a}D{b}', name: 'pair', read: () => ({}) }]) + expect(pair!.match('test://pair/a%2DbDc')).toEqual({ a: 'a-b', b: 'c' }) + expect(pair!.match('test://pair/a%2dbDc')).toEqual({ a: 'a-b', b: 'c' }) + const [hex] = compileResourceTemplates([{ uriTemplate: 'test://hex/{a}2D{b}', name: 'hex', read: () => ({}) }]) + expect(hex!.match('test://hex/x%2D2Dy')).toEqual({ a: 'x-', b: 'y' }) + }) + it('keeps a value named __proto__', () => { const [proto] = compileResourceTemplates([{ uriTemplate: 'test://proto/{__proto__}', name: 'proto', read: () => ({}) }]) expect(Object.entries(proto!.match('test://proto/x')!)).toEqual([['__proto__', 'x']]) diff --git a/packages/agentic/src/mcp/build-server.ts b/packages/agentic/src/mcp/build-server.ts index 7500e06a..6dfe3468 100644 --- a/packages/agentic/src/mcp/build-server.ts +++ b/packages/agentic/src/mcp/build-server.ts @@ -500,8 +500,9 @@ function compileResourceTemplate(template: McpResourceTemplate): CompiledResourc /** * Match `uri` in one pass from left to right, with no regular expression, so * the time grows with the URI length only and a crafted URI cannot block the - * process. Each value runs to the first place the next literal appears (the - * last value runs to the tail). A value must not be empty or contain `/`. + * process. Each value runs to the first place the next literal appears + * outside a `%XX` escape (the last value runs to the tail). A value must not + * be empty or contain `/`. * The params object has no prototype, so a variable such as `__proto__` * keeps its value. */ @@ -518,7 +519,7 @@ function matchTemplate(uri: string, literals: string[], names: string[]): Record for (let index = 0; index < names.length; index++) { const isLast = index === names.length - 1 const next = literals[index + 1]! - const stop = isLast ? end : uri.indexOf(next, position + 1) + const stop = isLast ? end : findLiteral(uri, next, position) if (stop <= position || stop > end) return undefined const raw = uri.slice(position, stop) @@ -535,6 +536,18 @@ function matchTemplate(uri: string, literals: string[], names: string[]): Record return params } +/** + * The first place `literal` appears after the value that starts at `start` + * (which must not be empty), or -1. A place inside a `%XX` escape of the + * value is skipped, so `{a}D{b}` reads `a%2DbDc` as `a-b` and `c`. + */ +function findLiteral(uri: string, literal: string, start: number): number { + let at = uri.indexOf(literal, start + 1) + while (at !== -1 && (uri[at - 1] === '%' || (at - 2 >= start && uri[at - 2] === '%'))) + at = uri.indexOf(literal, at + 1) + return at +} + /** * The content blocks a tool result carries through `withMcpContent`, checked * and copied field by field, or `undefined` when it carries none. Invalid