From 4102443d76b64be02ccf4c19986c470a05e487b7 Mon Sep 17 00:00:00 2001 From: erKam Date: Fri, 16 Oct 2026 05:39:31 +0000 Subject: [PATCH 1/2] 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 | 236 +++++++++++++++++- 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, 818 insertions(+), 32 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 3394526f3..e833b37ba 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/`. 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: { 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 269a647c8..a74179e52 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -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/` 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 000000000..7800ebf96 --- /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 eee53bd30..2fd67496d 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -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) diff --git a/packages/agentic/src/mcp/__tests__/mcp-http.test.ts b/packages/agentic/src/mcp/__tests__/mcp-http.test.ts index 709109b90..95926d33f 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-http.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-http.test.ts @@ -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' @@ -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 diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index 21b7a9f0b..0a9ad2ed6 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -1,9 +1,9 @@ -import type { DevframeHost } from 'devframe/types' +import type { DevframeHost, McpResourceTemplate } from 'devframe/types' import { Client, InMemoryTransport } from '@modelcontextprotocol/client' 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' function nullHost(): DevframeHost { return { @@ -13,13 +13,14 @@ function nullHost(): DevframeHost { } } -async function bootPair(exposeSharedState: boolean | ((key: string) => boolean) = true) { +async function bootPair(exposeSharedState: boolean | ((key: string) => boolean) = true, resourceTemplates?: McpResourceTemplate[]) { const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) const server = buildMcpServerFromContext(ctx, { serverName: 'test', serverVersion: '0.0.0-test', exposeSharedState, + resourceTemplates: compileResourceTemplates(resourceTemplates), }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() @@ -436,4 +437,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, 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, [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, [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, [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, [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, [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, [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, [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, [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, [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 0860f5da1..4317f44bb 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, McpServerHandle } from 'devframe/types' +import type { AgentTool, CreateMcpServerOptions, DevframeDefinition, DevframeHost, DevframeNodeContext, McpResourcesChangedHook, McpResourceTemplate, McpServerHandle } from 'devframe/types' import { homedir } from 'node:os' import process from 'node:process' import { Server } from '@modelcontextprotocol/server' @@ -15,6 +15,22 @@ export interface BuildMcpServerOptions { serverName: string serverVersion: string exposeSharedState: boolean | ((k: string) => boolean) + /** + * 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 } /** @@ -47,7 +63,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 } @@ -55,8 +74,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` @@ -67,6 +88,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() @@ -75,9 +97,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?.() } } @@ -92,6 +121,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 */ }, @@ -121,6 +152,7 @@ export async function createMcpServer( serverName: options.serverName ?? `${definition.id} (devframe)`, serverVersion: options.serverVersion ?? definition.version ?? '0.0.0', exposeSharedState: options.exposeSharedState ?? true, + resourceTemplates, } // `serveStdio` pins ONE instance for the connection's lifetime. Each pinned @@ -136,7 +168,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() @@ -301,11 +333,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, @@ -325,6 +358,8 @@ function registerResourceHandlers( } } + resources.push(...await listTemplateResources(ctx, templates)) + return { resources } }) @@ -364,10 +399,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 + }, + } +} + /** * MCP constrains a tool's `outputSchema` to a JSON Schema of `type: * "object"`; clients (the SDK included) reject anything else. Non-object diff --git a/packages/agentic/src/mcp/fetch.ts b/packages/agentic/src/mcp/fetch.ts index f8378dd05..cb4df6004 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,10 +73,15 @@ 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, + resourceTemplates, })) // A single, long-lived bridge from devframe's change events onto the @@ -85,7 +90,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 8133c92ec..3ae7c1e70 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -360,6 +360,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 a06f01cdf..b77296998 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' @@ -70,6 +71,10 @@ export interface ResolvedMcpConfig { allowedOrigins?: readonly string[] | false /** Shared-state exposure from the route options; omitted means every key. */ exposeSharedState?: boolean | ((key: string) => boolean) + /** 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 } @@ -99,6 +104,8 @@ export function resolveMcpConfig(mcp: McpSetting | undefined): ResolvedMcpConfig ...(mcp.path !== undefined ? { path: mcp.path } : {}), ...(mcp.allowedOrigins !== undefined ? { allowedOrigins: mcp.allowedOrigins } : {}), ...(mcp.exposeSharedState !== undefined ? { exposeSharedState: mcp.exposeSharedState } : {}), + ...(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 2b000d88c..5e354bfdb 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -380,6 +380,8 @@ async function mountMcpRoute( serverName: `${def.id} (devframe)`, serverVersion: def.version ?? '0.0.0', exposeSharedState: config.exposeSharedState ?? true, + 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 437f5b843..27531d04b 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 7a5b9a956..2e9712911 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -220,5 +220,10 @@ export const diagnostics = defineDiagnostics({ `The \`mcp\` option is enabled, but the optional peer "@devframes/agentic" could not be loaded: ${p.reason}`, fix: 'Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting.', }, + 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 719c125dd..191fc22a2 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' @@ -174,6 +175,23 @@ export interface McpRouteOptions { * The states themselves stay available to the UI over RPC. */ exposeSharedState?: boolean | ((key: string) => boolean) + /** + * 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 cdb9e003a..c92aced2e 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' /** @@ -25,6 +26,16 @@ export interface CreateMcpServerOptions { * - `(key) => boolean`: filter */ exposeSharedState?: boolean | ((key: string) => boolean) + /** + * 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'`. */ @@ -44,6 +55,10 @@ export interface CreateMcpFetchHandlerOptions { serverVersion: string /** Expose shared-state keys as MCP resources; see {@link CreateMcpServerOptions.exposeSharedState}. */ exposeSharedState: boolean | ((key: string) => boolean) + /** 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 @@ -89,3 +104,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 00c5d9c14..08d698a8d 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()] }) @@ -423,6 +423,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: 18238, 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: 18239, 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 a3285c240..e97f31c1a 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -523,6 +523,8 @@ export function initHub(options: InitHubOptions): HubInstance { serverName: options.name ?? 'devframes-hub', serverVersion: options.version ?? '0.0.0', exposeSharedState: mcpConfig.exposeSharedState ?? true, + 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 cdc506b35..048d27fc9 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' @@ -41,6 +41,10 @@ export interface DevframeNextHostMcpOptions { serverVersion?: string /** Expose shared-state keys as MCP resources / `devframe:state:read`. Default: `true`. */ exposeSharedState?: boolean | ((key: string) => boolean) + /** 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 @@ -180,6 +184,8 @@ export function createDevframeNextHost( serverName: mcpOptions.serverName ?? 'devframe (next)', serverVersion: mcpOptions.serverVersion ?? '0.0.0', exposeSharedState: mcpOptions.exposeSharedState ?? true, + 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 834560c33..959493ad0 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/hub.snapshot.d.ts @@ -20,6 +20,8 @@ export interface DevframeNextHostMcpOptions { serverName?: string; serverVersion?: string; exposeSharedState?: boolean | ((_: string) => boolean); + 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 6ab424823..d00dd3aec 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 f54ac7698..2dd20682b 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -91,12 +91,16 @@ export interface CreateMcpFetchHandlerOptions { serverName: string; serverVersion: string; exposeSharedState: boolean | ((_: string) => boolean); + resourceTemplates?: McpResourceTemplate[]; + onResourcesChanged?: McpResourcesChangedHook; authorization?: McpAuthorization; allowedOrigins?: readonly string[] | false; } export interface CreateMcpServerOptions { transport?: 'stdio'; exposeSharedState?: boolean | ((_: string) => boolean); + resourceTemplates?: McpResourceTemplate[]; + onResourcesChanged?: McpResourcesChangedHook; serverName?: string; serverVersion?: string; onReady?: (_: { @@ -442,11 +446,34 @@ 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); + resourceTemplates?: McpResourceTemplate[]; + onResourcesChanged?: McpResourcesChangedHook; } export interface McpServerHandle { stop: () => Promise; @@ -553,6 +580,7 @@ export type DevframeSnapshotRpcEntry = string | { export type DevframeSnapshotRpcInputs = readonly (readonly unknown[])[] | ((_: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise); export type DevframeStorageScope = 'workspace' | 'project' | 'global'; export type McpAuthorization = string | ((_: Request) => boolean | Promise) | false; +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 4c08e1e91..8bd84d06e 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -371,6 +371,14 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting."; }; + 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 2604d37fd..5d9266750 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -70,6 +70,10 @@ export { EventUnsubscribe } export { McpAuthorization } export { McpConnectionInfo } export { McpFetchHandler } +export { McpResourcesChangedHook } +export { McpResourceTemplate } +export { McpResourceTemplateContent } +export { McpResourceTemplateEntry } export { McpRouteOptions } export { McpServerHandle } export { McpSetting } From 1a683788f0267c945f62b75405a8c9bc9a36c832 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 20 Oct 2026 05:22:01 +0000 Subject: [PATCH 2/2] 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 9b3ae527f..e1032112d 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 ac9694650..78277a534 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 bfcf6c30a..92c3992fb 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()