From 703d0cf5aee6d3c2dee08280260c02a119442ad2 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 27 Aug 2026 11:12:26 +0200 Subject: [PATCH] feat(devframe): add MCP resource templates, providers, and updates --- docs/content/1.guide/15.agent-native.md | 69 +++++++- docs/content/8.references/3.events.md | 3 +- packages/devframe/src/adapters/initiate.ts | 2 +- .../fixtures/resource-stdio-server.ts | 47 +++++ .../adapters/mcp/__tests__/mcp-http.test.ts | 121 ++++++++++++- .../adapters/mcp/__tests__/mcp-server.test.ts | 163 ++++++++++++++++- .../devframe/src/adapters/mcp/build-server.ts | 164 ++++++++++++----- packages/devframe/src/adapters/mcp/fetch.ts | 17 +- .../devframe/src/client/rpc-shared-state.ts | 9 + packages/devframe/src/events.ts | 1 + .../src/node/__tests__/host-agent.test.ts | 132 ++++++++++++++ packages/devframe/src/node/host-agent.ts | 166 +++++++++++++++--- .../devframe/src/node/rpc-shared-state.ts | 9 + packages/devframe/src/types/agent.ts | 83 +++++++-- packages/devframe/src/types/devframe.ts | 6 + packages/devframe/src/types/rpc.ts | 2 + packages/hub/src/node/initiate.ts | 2 +- skills/devframe/SKILL.md | 19 +- .../tsnapi/devframe/constants.snapshot.d.ts | 1 + .../tsnapi/devframe/index.snapshot.d.ts | 52 +++++- .../tsnapi/devframe/internal.snapshot.d.ts | 13 +- .../tsnapi/devframe/types.snapshot.d.ts | 10 ++ 22 files changed, 983 insertions(+), 108 deletions(-) create mode 100644 packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts diff --git a/docs/content/1.guide/15.agent-native.md b/docs/content/1.guide/15.agent-native.md index c10cae2d..e5468f94 100644 --- a/docs/content/1.guide/15.agent-native.md +++ b/docs/content/1.guide/15.agent-native.md @@ -96,7 +96,72 @@ ctx.agent.registerResource({ }) ``` -Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/` resource and via the **`devframe:state:read` tool** (wire `devframe_state_read`): no args → key list, `key` → its value. `exposeSharedState: false` (or a filter) on `createMcpServer` opts out. +Devframe assigns `devframe://resource/` by default. Set `uri` to expose another URI. `read` runs for every MCP read and may receive the requested `URL`. + +## Registering resource templates + +Templates describe resources whose URI contains variables. Devframe uses the MCP SDK's URI-template parser and passes the parsed variables to `read`. + +```ts +const logsResource = ctx.agent.registerResource({ + id: 'process-logs', + uriTemplate: 'rolldown://logs/{process}', + name: 'Process logs', + mimeType: 'text/plain', + list: () => ({ + resources: runningProcesses().map(process => ({ + uri: `rolldown://logs/${encodeURIComponent(process.name)}`, + name: `${process.name} logs`, + mimeType: 'text/plain', + })), + }), + read: (_uri, variables) => ({ + text: readLogs(String(variables.process)), + }), +}) + +logsResource.notifyUpdated('rolldown://logs/worker') +``` + +MCP exposes templates through `resources/templates/list`. When `list` is present, its concrete entries also appear in `resources/list`. + +## Publishing resource updates + +Resource handles publish invalidations after their underlying value changes: + +```ts +const buildResource = ctx.agent.registerResource({ + id: 'live-build', + name: 'Live build', + read: () => ({ json: currentBuild() }), +}) + +buildEvents.on('changed', () => buildResource.notifyUpdated()) +``` + +`notifyUpdated()` sends no content. MCP 2026 callers receive the invalidation through `subscriptions/listen` when its `resourceSubscriptions` filter contains the URI, then call `resources/read` for the current value. Legacy MCP callers use `resources/list`, `resources/templates/list`, and `resources/read` to pull current values. + +## Deriving resources from other state + +Resource providers are queried when Devframe lists, resolves, or reads resources. Use them when another registry already owns the definitions. + +```ts +const resources = ctx.agent.registerResourceProvider(() => + currentDatasets().map(dataset => ({ + id: `dataset:${dataset.id}`, + uri: `dataset://${dataset.id}`, + name: dataset.name, + read: () => ({ json: dataset.snapshot() }), + })), +) + +resources.notifyChanged() // resources/list_changed +resources.notifyUpdated('dataset://builds/active') // resources/updated through MCP 2026 subscriptions/listen +``` + +Direct registrations win over providers. Earlier providers win over later providers, and exact resource URIs win over templates. + +Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/` resource and via the **`devframe:state:read` tool** (wire `devframe_state_read`): no args → key list, `key` → its value. MCP 2026 subscriptions receive shared-state invalidations under the same encoded URI. `exposeSharedState: false` (or a filter) on `createMcpServer` opts out. ## Starting the MCP server @@ -135,7 +200,7 @@ In `claude_desktop_config.json`: } ``` -Restart; tools appear in the drawer, resources as `devframe://resource/` / `devframe://state/` URIs. +Restart; tools appear in the drawer. Resources use their declared URI, the generated `devframe://resource/` URI, or `devframe://state/` for implicit shared state. ## Writing descriptions agents act on diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 473bc115..afa489ce 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -68,7 +68,8 @@ Emitted on `ctx.agent.events`; adapters (e.g. the MCP server) re-publish their m |---|---|---| | `agent:manifest:changed` | any tool/resource/provider change | — | | `agent:tool:registered` / `agent:tool:unregistered` | `registerTool` / `unregisterTool` | `AgentTool` / id | -| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` / id | +| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` or `AgentResourceTemplate` / id | +| `agent:resource:updated` | resource or provider handle `notifyUpdated` | concrete URI | ### RPC client connection events diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 6f2eb3c8..ac61107e 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -321,7 +321,7 @@ export function initDevframe( const mounted = mountMcpHttp(app, context, mcpPath, { serverName: `${def.id} (devframe)`, serverVersion: def.version ?? '0.0.0', - exposeSharedState: true, + exposeSharedState: mcpConfig.exposeSharedState ?? true, allowedOrigins: mcpConfig.allowedOrigins, }) mcpDispose = mounted.dispose diff --git a/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts b/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts new file mode 100644 index 00000000..5a6b33b9 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts @@ -0,0 +1,47 @@ +import type { DevframeDefinition } from '../../../../types/devframe' +import { createMcpServer } from '../../build-server' + +const definition: DevframeDefinition = { + id: 'resource-stdio-test', + name: 'Resource stdio test', + version: '1.0.0', + packageName: '@devframe/resource-stdio-test', + homepage: 'https://example.com', + description: 'Stdio resource test fixture.', + async setup(ctx) { + const state = await ctx.rpc.sharedState.get('stdio:counter', { + initialValue: { count: 0 }, + }) + const fixed = ctx.agent.registerResource({ + id: 'status', + uri: 'https://example.com/status', + name: 'Status', + read: uri => ({ json: { uri: uri.toString(), status: 'ok' } }), + }) + const ignored = ctx.agent.registerResource({ + id: 'ignored', + name: 'Ignored', + read: () => ({ json: { ignored: true } }), + }) + ctx.agent.registerTool({ + id: 'increment-state', + description: 'Increment the fixture state.', + handler: () => { + state.mutate(value => void (value.count += 1)) + fixed.notifyUpdated() + ignored.notifyUpdated() + }, + }) + ctx.agent.registerResource({ + id: 'logs', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + list: () => ({ resources: [{ uri: 'devframe://logs/app', name: 'App logs' }] }), + read: (_uri: URL, variables: Readonly>) => ({ + json: { process: variables.name }, + }), + }) + }, +} + +await createMcpServer(definition, { transport: 'stdio' }) diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts index 4f415083..a04d4a5d 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -1,7 +1,7 @@ import type { StartedServer } from '../../../node/instance-shell' import type { DevframeDefinition } from '../../../types/devframe' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createDevServer } from '../../dev' function defineTestDef(overrides?: Partial): DevframeDefinition { @@ -82,6 +82,7 @@ describe('mcp adapter (streamable http route)', () => { // `Mcp-Session-Id` — there is no session to key state on. expect(client.getProtocolEra()).toBe('modern') expect(transport.sessionId).toBeUndefined() + expect(client.getServerCapabilities()?.resources).toEqual({ listChanged: true, subscribe: true }) const tools = await client.listTools() expect(tools.tools.map(t => t.name)).toContain('greet') @@ -95,6 +96,124 @@ describe('mcp adapter (streamable http route)', () => { } }) + it('delivers filtered resource updates through modern subscriptions/listen', async () => { + let notifyBuildUpdated!: () => void + let notifyIgnoredUpdated!: () => void + let updateExistingState!: () => void + let createAndUpdateLateState!: () => Promise + const started = await boot(defineTestDef({ + async setup(ctx) { + const build = ctx.agent.registerResource({ + id: 'build', + name: 'Build', + read: () => ({ json: { status: 'ok' } }), + }) + const ignored = ctx.agent.registerResource({ + id: 'ignored', + name: 'Ignored', + read: () => ({ json: { ignored: true } }), + }) + const existingState = await ctx.rpc.sharedState.get('build:status', { + initialValue: { revision: 0 }, + }) + notifyBuildUpdated = build.notifyUpdated + notifyIgnoredUpdated = ignored.notifyUpdated + updateExistingState = () => existingState.mutate(value => void (value.revision += 1)) + createAndUpdateLateState = async () => { + const lateState = await ctx.rpc.sharedState.get('build:late', { + initialValue: { revision: 0 }, + }) + lateState.mutate(value => void (value.revision += 1)) + } + }, + })) + const client = new Client( + { name: 'test-client', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) + const updates: string[] = [] + client.setNotificationHandler('notifications/resources/updated', (notification) => { + updates.push(notification.params.uri) + }) + + await client.connect(originTransport(started)) + const subscription = await client.listen({ + resourceSubscriptions: [ + 'devframe://resource/build', + 'devframe://state/build%3Astatus', + 'devframe://state/build%3Alate', + ], + }) + try { + notifyIgnoredUpdated() + notifyBuildUpdated() + updateExistingState() + await createAndUpdateLateState() + + await vi.waitFor(() => expect(updates).toEqual([ + 'devframe://resource/build', + 'devframe://state/build%3Astatus', + 'devframe://state/build%3Alate', + ])) + } + finally { + await subscription.close() + await client.close() + } + }) + + it('keeps legacy resource access pull-only', async () => { + const started = await boot(defineTestDef({ + setup(ctx) { + ctx.agent.registerResource({ + id: 'build', + name: 'Build', + read: () => ({ json: { status: 'ok' } }), + }) + }, + })) + const client = new Client({ name: 'legacy-test-client', version: '0.0.0' }) + try { + await client.connect(originTransport(started)) + expect(client.getProtocolEra()).toBe('legacy') + expect(client.getServerCapabilities()?.resources).toEqual({}) + + const resources = await client.listResources() + expect(resources.resources.map(resource => resource.uri)).toContain('devframe://resource/build') + const result = await client.readResource({ uri: 'devframe://resource/build' }) + expect(JSON.parse((result.contents[0] as { text: string }).text)).toEqual({ status: 'ok' }) + } + finally { + await client.close() + } + }) + + it('can disable implicit shared-state MCP exposure for the HTTP route', async () => { + server = await createDevServer(defineTestDef({ + async setup(ctx) { + await ctx.rpc.sharedState.get('hidden:state', { initialValue: { value: true } }) + }, + }), { + host: '127.0.0.1', + port: 0, + mcp: { exposeSharedState: false }, + }) + const client = new Client( + { name: 'test-client', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) + try { + await client.connect(originTransport(server)) + const resources = await client.listResources() + const tools = await client.listTools() + expect(resources.resources).toEqual([]) + expect(tools.tools.map(tool => tool.name)).not.toContain('devframe_state_read') + } + finally { + await client.close() + } + }) + it('answers a bare GET with 405 (no session lifecycle)', async () => { const started = await boot() // Stateless serving has no session stream to open — the SDK answers a diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index cf9f7b4b..40aa759d 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -1,7 +1,9 @@ import type { DevframeHost } from '../../../types/host' +import { fileURLToPath } from 'node:url' import { Client, InMemoryTransport } from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' import { createHostContext } from 'devframe/node' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildMcpServerFromContext } from '../build-server' function nullHost(): DevframeHost { @@ -19,6 +21,7 @@ async function bootPair() { serverName: 'test', serverVersion: '0.0.0-test', exposeSharedState: true, + era: 'legacy', }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() @@ -217,6 +220,7 @@ describe('mcp adapter (in-memory)', () => { }) const listed = await client.listResources() + expect(client.getServerCapabilities()?.resources).toEqual({}) const resource = listed.resources.find(r => r.uri === 'devframe://resource/build-status') expect(resource).toBeDefined() expect(resource!.name).toBe('Build status') @@ -231,6 +235,99 @@ describe('mcp adapter (in-memory)', () => { } }) + it('reads resources from their explicit URI', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + const read = vi.fn((uri: URL) => ({ json: { uri: uri.toString() } })) + ctx.agent.registerResource({ + id: 'current-build', + uri: 'https://example.com/build/current', + name: 'Current build', + read, + }) + + const listed = await client.listResources() + expect(listed.resources.map(resource => resource.uri)).toContain('https://example.com/build/current') + const result = await client.readResource({ uri: 'https://example.com/build/current' }) + const content = result.contents[0] as { text: string } + expect(JSON.parse(content.text)).toEqual({ uri: 'https://example.com/build/current' }) + expect(read).toHaveBeenCalledWith(new URL('https://example.com/build/current')) + } + finally { + await cleanup() + } + }) + + it('lists templates and their concrete resources, then parses variables on read', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + const read = vi.fn((uri: URL, variables: Readonly>) => ({ + json: { uri: uri.toString(), variables }, + })) + ctx.agent.registerResource({ + id: 'logs', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + description: 'Logs by process name.', + mimeType: 'application/json', + list: () => ({ + resources: [{ uri: 'devframe://logs/app', name: 'App logs', mimeType: 'application/json' }], + }), + read, + }) + + const templates = await client.listResourceTemplates() + expect(templates.resourceTemplates).toContainEqual({ + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + description: 'Logs by process name.', + mimeType: 'application/json', + }) + const resources = await client.listResources() + expect(resources.resources).toContainEqual({ + uri: 'devframe://logs/app', + name: 'App logs', + mimeType: 'application/json', + }) + + const result = await client.readResource({ uri: 'devframe://logs/worker' }) + const content = result.contents[0] as { text: string } + expect(JSON.parse(content.text)).toEqual({ + uri: 'devframe://logs/worker', + variables: { name: 'worker' }, + }) + expect(read).toHaveBeenCalledWith(new URL('devframe://logs/worker'), { name: 'worker' }) + } + finally { + await cleanup() + } + }) + + it('resolves an exact resource URI before a matching template', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerResource({ + id: 'logs-template', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + read: () => ({ text: 'template' }), + }) + ctx.agent.registerResource({ + id: 'fixed-log', + uri: 'devframe://logs/app', + name: 'App log', + read: () => ({ text: 'fixed' }), + }) + + const result = await client.readResource({ uri: 'devframe://logs/app' }) + const content = result.contents[0] as { text: string } + expect(content.text).toBe('fixed') + } + finally { + await cleanup() + } + }) + it('surfaces shared-state keys as MCP resources', async () => { const { ctx, client, cleanup } = await bootPair() try { @@ -317,6 +414,7 @@ describe('mcp adapter (in-memory)', () => { serverName: 'test', serverVersion: '0.0.0-test', exposeSharedState: false, + era: 'legacy', }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await server.connect(serverTransport) @@ -340,6 +438,7 @@ describe('mcp adapter (in-memory)', () => { serverName: 'test', serverVersion: '0.0.0-test', exposeSharedState: key => key.startsWith('visible:'), + era: 'legacy', }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await server.connect(serverTransport) @@ -358,3 +457,65 @@ describe('mcp adapter (in-memory)', () => { } }) }) + +describe('mcp adapter (stdio)', () => { + it('lists, reads, and receives modern updates for registered, template, and shared-state resources', async () => { + const fixture = fileURLToPath(new URL('./fixtures/resource-stdio-server.ts', import.meta.url)) + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', fixture], + cwd: process.cwd(), + stderr: 'pipe', + }) + const client = new Client( + { name: 'stdio-test-client', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) + let subscription: Awaited> | undefined + const updates: string[] = [] + client.setNotificationHandler('notifications/resources/updated', (notification) => { + updates.push(notification.params.uri) + }) + + try { + await client.connect(transport) + expect(client.getProtocolEra()).toBe('modern') + const resources = await client.listResources() + expect(resources.resources.map(resource => resource.uri)).toEqual(expect.arrayContaining([ + 'https://example.com/status', + 'devframe://logs/app', + 'devframe://state/stdio%3Acounter', + ])) + const templates = await client.listResourceTemplates() + expect(templates.resourceTemplates.map(template => template.uriTemplate)).toEqual(['devframe://logs/{name}']) + + const fixed = await client.readResource({ uri: 'https://example.com/status' }) + expect(JSON.parse((fixed.contents[0] as { text: string }).text)).toEqual({ + uri: 'https://example.com/status', + status: 'ok', + }) + const template = await client.readResource({ uri: 'devframe://logs/worker' }) + expect(JSON.parse((template.contents[0] as { text: string }).text)).toEqual({ process: 'worker' }) + + subscription = await client.listen({ + resourceSubscriptions: [ + 'https://example.com/status', + 'devframe://state/stdio%3Acounter', + ], + }) + const increment = await client.callTool({ name: 'increment-state', arguments: {} }) + expect(increment.isError).toBeFalsy() + const updatedState = await client.readResource({ uri: 'devframe://state/stdio%3Acounter' }) + expect(JSON.parse((updatedState.contents[0] as { text: string }).text)).toEqual({ count: 1 }) + await vi.waitFor(() => expect(updates).toEqual(expect.arrayContaining([ + 'https://example.com/status', + 'devframe://state/stdio%3Acounter', + ]))) + expect(updates).not.toContain('devframe://resource/ignored') + } + finally { + await subscription?.close() + await client.close() + } + }) +}) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 237c8fb6..e9556336 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -1,10 +1,10 @@ -import type { Tool } from '@modelcontextprotocol/server' +import type { McpRequestContext, Resource, Tool, Variables } from '@modelcontextprotocol/server' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc' import type { AgentTool, DevframeDefinition, DevframeHost, DevframeNodeContext } from 'devframe/types' import { homedir } from 'node:os' import process from 'node:process' -import { Server } from '@modelcontextprotocol/server' +import { Server, UriTemplate } from '@modelcontextprotocol/server' import { createHostContext } from 'devframe/node' import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { join } from 'pathe' @@ -43,18 +43,15 @@ export interface McpServerHandle { export interface BuildMcpServerOptions { serverName: string serverVersion: string - exposeSharedState: boolean | ((k: string) => boolean) + exposeSharedState: boolean | ((key: string) => boolean) + era: McpRequestContext['era'] } /** * Build a fresh MCP {@link Server} over a devframe context, registering its - * tool and resource handlers. This is a pure factory — it sets up no - * long-lived subscriptions and holds no per-connection state, so it is safe - * to call once per request under `createMcpHandler` or once per connection - * under `serveStdio`. Change notifications are published separately: over - * HTTP through the handler's `notify` bus (see `createMcpFetchHandler`), and - * on stdio through the connection's own `send*ListChanged` calls (see - * {@link bridgeListChanged}, wired by `serveStdio`). + * tool and resource handlers. The protocol era controls only the advertised + * resource update capabilities; listing and reading use the same handlers in + * both eras. Long-lived update listeners are installed by the serving entry. * * @internal */ @@ -70,7 +67,9 @@ export function buildMcpServerFromContext( { capabilities: { tools: { listChanged: true }, - resources: { listChanged: true }, + resources: options.era === 'modern' + ? { listChanged: true, subscribe: true } + : {}, }, }, ) @@ -82,31 +81,43 @@ 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. - * - * The HTTP path passes the handler's `notify` bus sugar; the stdio path - * passes the pinned server's `send*ListChanged` methods, which `serveStdio` - * routes onto the connection's active `subscriptions/listen` streams. + * Publish devframe changes through the MCP 2026 update bus. Shared-state + * values use their encoded resource URI, and newly registered keys also + * invalidate the resource list. * * @internal */ -export function bridgeListChanged( +export function bridgeMcpUpdates( ctx: DevframeNodeContext, - sinks: { tools: () => void, resources: () => void }, + exposeSharedState: boolean | ((key: string) => boolean), + sinks: { + toolsChanged: () => void + resourcesChanged: () => void + resourceUpdated: (uri: string) => void + }, ): () => void { + const stateFilter = sharedStateFilter(exposeSharedState) const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { - sinks.tools() - sinks.resources() + sinks.toolsChanged() + sinks.resourcesChanged() + }) + const offResourceUpdated = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentResourceUpdated, (uri) => { + sinks.resourceUpdated(uri) }) - const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => { - sinks.resources() + const offKeyAdded = ctx.rpc.sharedState.onKeyAdded((key) => { + if (stateFilter?.(key)) + sinks.resourcesChanged() }) + const offStateUpdated = ctx.rpc.sharedState.onUpdated((key) => { + if (stateFilter?.(key)) + sinks.resourceUpdated(sharedStateResourceUri(key)) + }) + return () => { offManifest() + offResourceUpdated() offKeyAdded() + offStateUpdated() } } @@ -146,27 +157,33 @@ export async function createMcpServer( await ctx.services.ready() await definition.setup(ctx) - const buildOptions: BuildMcpServerOptions = { + const buildOptions: Omit = { serverName: options.serverName ?? `${definition.id} (devframe)`, serverVersion: options.serverVersion ?? definition.version ?? '0.0.0', exposeSharedState: options.exposeSharedState ?? true, } - // `serveStdio` owns the connection's era decision and pins ONE instance - // for its lifetime. Each pinned server sets up its own `list_changed` - // bridge over the connection's `send*ListChanged` calls (routed onto the - // active `subscriptions/listen` streams on a modern connection, sent - // unsolicited on a 2025-era one) and tears it down when that server - // closes. + // `serveStdio` owns the protocol-era decision and pins one instance for + // the connection lifetime. Modern instances bridge resource changes into + // `subscriptions/listen`; legacy instances retain tool-list invalidations + // while resources remain pull-only. let handle: import('@modelcontextprotocol/server/stdio').StdioServerHandle try { const { serveStdio } = await import('@modelcontextprotocol/server/stdio') - handle = serveStdio(() => { - const server = buildMcpServerFromContext(ctx, buildOptions) - const unbridge = bridgeListChanged(ctx, { - tools: () => { void server.sendToolListChanged().catch(() => {}) }, - resources: () => { void server.sendResourceListChanged().catch(() => {}) }, + handle = serveStdio((requestContext) => { + const server = buildMcpServerFromContext(ctx, { + ...buildOptions, + era: requestContext.era, }) + const unbridge = requestContext.era === 'modern' + ? bridgeMcpUpdates(ctx, buildOptions.exposeSharedState, { + toolsChanged: () => { void server.sendToolListChanged().catch(() => {}) }, + resourcesChanged: () => { void server.sendResourceListChanged().catch(() => {}) }, + resourceUpdated: (uri) => { void server.sendResourceUpdated({ uri }).catch(() => {}) }, + }) + : ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { + void server.sendToolListChanged().catch(() => {}) + }) const priorOnClose = server.onclose server.onclose = () => { unbridge() @@ -205,6 +222,10 @@ function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolea return typeof exposeSharedState === 'function' ? exposeSharedState : () => true } +function sharedStateResourceUri(key: string): string { + return `devframe://state/${encodeURIComponent(key)}` +} + function readStateToolProjection(): Tool { return { name: READ_STATE_NAME, @@ -332,21 +353,30 @@ function registerResourceHandlers( ctx: DevframeNodeContext, exposeSharedState: boolean | ((key: string) => boolean), ): void { + const stateFilter = sharedStateFilter(exposeSharedState) + server.setRequestHandler('resources/list', async () => { - const resources = ctx.agent.list().resources.map(resource => ({ + const manifest = ctx.agent.list() + const resources: Resource[] = manifest.resources.map(resource => ({ uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mimeType, })) - if (exposeSharedState !== false) { - const filter = typeof exposeSharedState === 'function' ? exposeSharedState : () => true + const listedTemplateResources = await Promise.all( + manifest.resourceTemplates.map(template => ctx.agent.listResourceInstances(template.id)), + ) + for (const listed of listedTemplateResources) { + resources.push(...listed.resources) + } + + if (stateFilter) { for (const key of ctx.rpc.sharedState.keys()) { - if (!filter(key)) + if (!stateFilter(key)) continue resources.push({ - uri: `devframe://state/${encodeURIComponent(key)}`, + uri: sharedStateResourceUri(key), name: key, description: `Shared state: ${key}`, mimeType: 'application/json', @@ -357,24 +387,35 @@ function registerResourceHandlers( return { resources } }) + server.setRequestHandler('resources/templates/list', async () => { + const resourceTemplates = ctx.agent.list().resourceTemplates.map(template => ({ + uriTemplate: template.uriTemplate, + name: template.name, + description: template.description, + mimeType: template.mimeType, + })) + return { resourceTemplates } + }) + server.setRequestHandler('resources/read', async (request) => { const { uri } = request.params - const parsed = parseResourceUri(uri) + const resource = resolveAgentResource(ctx, uri) - if (parsed.kind === 'resource') { - const content = await ctx.agent.read(parsed.id) + if (resource) { + const content = await ctx.agent.read(resource.id, uri, resource.variables) return { contents: [ { uri, - mimeType: content.mimeType ?? 'application/json', + mimeType: content.mimeType ?? resource.mimeType ?? 'application/json', text: content.text ?? stringifyForMcp(content.json), }, ], } } - if (parsed.kind === 'state') { + const parsed = parseResourceUri(uri) + if (parsed.kind === 'state' && stateFilter?.(parsed.key) && ctx.rpc.sharedState.keys().includes(parsed.key)) { const state = await ctx.rpc.sharedState.get(parsed.key) return { contents: [ @@ -391,6 +432,27 @@ function registerResourceHandlers( }) } +function resolveAgentResource( + ctx: DevframeNodeContext, + uri: string, +): { id: string, variables: Variables, mimeType?: string } | undefined { + const manifest = ctx.agent.list() + const resource = manifest.resources.find(candidate => candidate.uri === uri) + if (resource) + return { id: resource.id, variables: {}, mimeType: resource.mimeType } + + for (const template of manifest.resourceTemplates) { + const variables = new UriTemplate(template.uriTemplate).match(uri) + if (variables) { + return { + id: template.id, + variables, + mimeType: template.mimeType, + } + } + } +} + /** * MCP constrains a tool's `outputSchema` to a JSON Schema of `type: * "object"` — clients (the SDK included) reject anything else. Non-object @@ -446,7 +508,13 @@ function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kin if (!match) return { kind: 'unknown' } const [, kind, rest] = match - const decoded = decodeURIComponent(rest!) + let decoded: string + try { + decoded = decodeURIComponent(rest!) + } + catch { + return { kind: 'unknown' } + } if (kind === 'resource') return { kind: 'resource', id: decoded } return { kind: 'state', key: decoded } diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index 384e6def..175c1508 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -1,7 +1,7 @@ import type { DevframeNodeContext } from 'devframe/types' import { createMcpHandler } from '@modelcontextprotocol/server' import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' -import { bridgeListChanged, buildMcpServerFromContext } from './build-server' +import { bridgeMcpUpdates, buildMcpServerFromContext } from './build-server' export interface CreateMcpFetchHandlerOptions { /** Name reported in the MCP handshake. */ @@ -58,18 +58,19 @@ export function createMcpFetchHandler( ): McpFetchHandler { const allowedOrigins = options.allowedOrigins - const handler = createMcpHandler(() => buildMcpServerFromContext(ctx, { + const handler = createMcpHandler(requestContext => buildMcpServerFromContext(ctx, { serverName: options.serverName, serverVersion: options.serverVersion, exposeSharedState: options.exposeSharedState, + era: requestContext.era, })) - // A single, long-lived bridge from devframe's change events onto the - // handler's `subscriptions/listen` bus — published once for the endpoint, - // not per (ephemeral, per-request) server instance. - const unbridge = bridgeListChanged(ctx, { - tools: () => { handler.notify.toolsChanged() }, - resources: () => { handler.notify.resourcesChanged() }, + // One bridge publishes changes for every modern listen stream. The SDK + // filters resource updates by each stream's `resourceSubscriptions`. + const unbridge = bridgeMcpUpdates(ctx, options.exposeSharedState, { + toolsChanged: () => { handler.notify.toolsChanged() }, + resourcesChanged: () => { handler.notify.resourcesChanged() }, + resourceUpdated: (uri) => { handler.notify.resourceUpdated(uri) }, }) async function handle(req: Request): Promise { diff --git a/packages/devframe/src/client/rpc-shared-state.ts b/packages/devframe/src/client/rpc-shared-state.ts index ae7c6b28..a1db6abb 100644 --- a/packages/devframe/src/client/rpc-shared-state.ts +++ b/packages/devframe/src/client/rpc-shared-state.ts @@ -9,6 +9,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare const stateDisposers = new Map void>() const initialValues = new Map() const keyAddedListeners = new Set<(key: string) => void>() + const updatedListeners = new Set<(key: string) => void>() const isStaticBackend = rpc.connectionMeta.backend === 'static' function mergeWithInitialValue(key: string, serverState: any): any { @@ -45,6 +46,8 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare function registerSharedState(key: string, state: SharedState) { const offs: (() => void)[] = [] offs.push(state.on('updated', (fullState, patches, syncId) => { + for (const listener of updatedListeners) + listener(key) if (isStaticBackend) return if (patches) { @@ -70,6 +73,12 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare keyAddedListeners.delete(fn) } }, + onUpdated(fn) { + updatedListeners.add(fn) + return () => { + updatedListeners.delete(fn) + } + }, delete(key) { const dispose = stateDisposers.get(key) stateDisposers.delete(key) diff --git a/packages/devframe/src/events.ts b/packages/devframe/src/events.ts index d6dedc1c..c9a308a1 100644 --- a/packages/devframe/src/events.ts +++ b/packages/devframe/src/events.ts @@ -33,6 +33,7 @@ export const DEVFRAME_EVENTS = { agentToolUnregistered: 'agent:tool:unregistered', agentResourceRegistered: 'agent:resource:registered', agentResourceUnregistered: 'agent:resource:unregistered', + agentResourceUpdated: 'agent:resource:updated', }, /** * Client-side RPC connection `EventEmitter` events (`rpc.events`) a UI diff --git a/packages/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts index 5b94babe..b1ca8a90 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -250,6 +250,85 @@ describe('devToolsAgentHost', () => { expect(content).toEqual({ json: { hello: 'world' } }) }) + it('keeps an explicit URI and passes the requested URI to the reader', async () => { + const ctx = createContext() + const read = vi.fn((uri: URL) => ({ text: uri.toString() })) + ctx.agent.registerResource({ + id: 'custom-resource', + uri: 'https://example.com/resources/current', + name: 'Custom resource', + read, + }) + + expect(ctx.agent.list().resources[0]!.uri).toBe('https://example.com/resources/current') + expect(ctx.agent.getResource('https://example.com/resources/current')?.id).toBe('custom-resource') + await expect(ctx.agent.read('custom-resource', 'https://example.com/resources/requested')).resolves.toEqual({ + text: 'https://example.com/resources/requested', + }) + expect(read).toHaveBeenCalledWith(new URL('https://example.com/resources/requested')) + }) + + it('registers templates, enumerates instances, and forwards variables', async () => { + const ctx = createContext() + const read = vi.fn((uri: URL, variables: Readonly>) => ({ + json: { uri: uri.toString(), variables }, + })) + ctx.agent.registerResource({ + id: 'logs', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + list: () => ({ + resources: [{ uri: 'devframe://logs/app', name: 'App logs', mimeType: 'text/plain' }], + }), + read, + }) + + expect(ctx.agent.list().resources).toEqual([]) + expect(ctx.agent.list().resourceTemplates).toEqual([{ + id: 'logs', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + description: undefined, + mimeType: undefined, + }]) + await expect(ctx.agent.listResourceInstances('logs')).resolves.toEqual({ + resources: [{ uri: 'devframe://logs/app', name: 'App logs', mimeType: 'text/plain' }], + }) + await ctx.agent.read('logs', 'devframe://logs/app', { name: 'app' }) + expect(read).toHaveBeenCalledWith(new URL('devframe://logs/app'), { name: 'app' }) + }) + + it('emits updates through resource and template handles', () => { + const ctx = createContext() + const updated = vi.fn() + ctx.agent.events.on('agent:resource:updated', updated) + const fixed = ctx.agent.registerResource({ + id: 'fixed', + uri: 'https://example.com/fixed', + name: 'Fixed', + read: () => ({ text: 'fixed' }), + }) + const template = ctx.agent.registerResource({ + id: 'template', + uriTemplate: 'https://example.com/{name}', + name: 'Template', + read: () => ({ text: 'template' }), + }) + + fixed.notifyUpdated() + template.notifyUpdated('https://example.com/one') + expect(updated).toHaveBeenNthCalledWith(1, 'https://example.com/fixed') + expect(updated).toHaveBeenNthCalledWith(2, 'https://example.com/one') + + fixed.unregister() + template.unregister() + expect(ctx.agent.list().resources).toEqual([]) + expect(ctx.agent.list().resourceTemplates).toEqual([]) + fixed.notifyUpdated() + template.notifyUpdated('https://example.com/two') + expect(updated).toHaveBeenCalledTimes(2) + }) + it('throws DF0016 on duplicate id', () => { const ctx = createContext() ctx.agent.registerResource({ @@ -270,6 +349,59 @@ describe('devToolsAgentHost', () => { }) }) + describe('registerResourceProvider()', () => { + it('queries providers lazily for listing and reads', async () => { + const ctx = createContext() + let value: string | undefined + const provider = vi.fn(() => value + ? [{ id: 'provided', name: 'Provided', read: () => ({ text: value }) }] + : []) + ctx.agent.registerResourceProvider(provider) + + expect(ctx.agent.getResource('provided')).toBeUndefined() + value = 'current' + expect(ctx.agent.list().resources.map(resource => resource.id)).toEqual(['provided']) + await expect(ctx.agent.read('provided')).resolves.toEqual({ text: 'current' }) + expect(provider).toHaveBeenCalledTimes(3) + }) + + it('keeps direct registrations and earlier providers on id collisions', async () => { + const ctx = createContext() + ctx.agent.registerResource({ id: 'direct', name: 'Direct', read: () => ({ text: 'direct' }) }) + ctx.agent.registerResourceProvider(() => [ + { id: 'direct', name: 'Hidden', read: () => ({ text: 'hidden' }) }, + { id: 'provided', name: 'First', read: () => ({ text: 'first' }) }, + ]) + ctx.agent.registerResourceProvider(() => [ + { id: 'provided', name: 'Second', read: () => ({ text: 'second' }) }, + ]) + + expect(ctx.agent.list().resources.map(resource => resource.name)).toEqual(['Direct', 'First']) + await expect(ctx.agent.read('direct')).resolves.toEqual({ text: 'direct' }) + await expect(ctx.agent.read('provided')).resolves.toEqual({ text: 'first' }) + }) + + it('notifies membership and content changes only while registered', () => { + const ctx = createContext() + const manifestChanged = vi.fn() + const resourceUpdated = vi.fn() + const handle = ctx.agent.registerResourceProvider(() => []) + ctx.agent.events.on('agent:manifest:changed', manifestChanged) + ctx.agent.events.on('agent:resource:updated', resourceUpdated) + + handle.notifyChanged() + handle.notifyUpdated('devframe://resource/provided') + expect(manifestChanged).toHaveBeenCalledOnce() + expect(resourceUpdated).toHaveBeenCalledWith('devframe://resource/provided') + + handle.unregister() + handle.notifyChanged() + handle.notifyUpdated('devframe://resource/provided') + expect(manifestChanged).toHaveBeenCalledTimes(2) + expect(resourceUpdated).toHaveBeenCalledOnce() + }) + }) + describe('standard schema args on tool inputs', () => { it('carries args raw on the projected tool — conversion is deferred to protocol adapters', async () => { const v = await import('valibot') diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index fabd785b..9a7bb603 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -4,7 +4,16 @@ import type { AgentManifest, AgentResource, AgentResourceContent, + AgentResourceDefinition, + AgentResourceHandle, AgentResourceInput, + AgentResourceList, + AgentResourceProvider, + AgentResourceProviderHandle, + AgentResourceTemplate, + AgentResourceTemplateHandle, + AgentResourceTemplateInput, + AgentResourceVariables, AgentTool, AgentToolInput, AgentToolProvider, @@ -25,9 +34,12 @@ interface RegisteredTool { readonly handler?: (args: any) => unknown | Promise } -interface RegisteredResource { - readonly resource: AgentResource - readonly read: () => Promise | AgentResourceContent +function isResourceTemplate(input: AgentResourceDefinition): input is AgentResourceTemplateInput { + return 'uriTemplate' in input +} + +function resourceUri(input: AgentResourceInput): string { + return input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}` } /** @@ -40,8 +52,9 @@ export class DevframeAgentHost implements DevframeAgentHostType { public readonly events: EventEmitter = createEventEmitter() private readonly tools = new Map() - private readonly resources = new Map() - private readonly providers = new Set() + private readonly resources = new Map() + private readonly toolProviders = new Set() + private readonly resourceProviders = new Set() private _rpcUnsubscribe: (() => void) | undefined constructor( @@ -76,39 +89,72 @@ export class DevframeAgentHost implements DevframeAgentHostType { } registerToolProvider(provider: AgentToolProvider): AgentToolProviderHandle { - this.providers.add(provider) + this.toolProviders.add(provider) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) const notifyChanged = (): void => { - if (this.providers.has(provider)) + if (this.toolProviders.has(provider)) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) } return { notifyChanged, unregister: () => { - if (this.providers.delete(provider)) + if (this.toolProviders.delete(provider)) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) }, } } - registerResource(input: AgentResourceInput): AgentHandle { + registerResource(input: AgentResourceInput): AgentResourceHandle + registerResource(input: AgentResourceTemplateInput): AgentResourceTemplateHandle + registerResource(input: AgentResourceDefinition): AgentResourceHandle | AgentResourceTemplateHandle { if (this.resources.has(input.id)) throw diagnostics.DF0016({ id: input.id }) - const resource: AgentResource = { - id: input.id, - name: input.name, - description: input.description, - mimeType: input.mimeType ?? 'application/json', - uri: input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}`, + this.resources.set(input.id, input) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceRegistered, this._projectResource(input)) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) + + const isRegistered = (): boolean => this.resources.get(input.id) === input + const unregister = (): void => { + if (isRegistered()) + this.unregisterResource(input.id) + } + if (!isResourceTemplate(input)) { + return { + notifyUpdated: () => { + if (isRegistered()) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, resourceUri(input)) + }, + unregister, + } } - this.resources.set(resource.id, { resource, read: input.read }) - this.events.emit(DEVFRAME_EVENTS.bus.agentResourceRegistered, resource) + return { + notifyUpdated: (uri) => { + if (isRegistered()) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, uri) + }, + unregister, + } + } + + registerResourceProvider(provider: AgentResourceProvider): AgentResourceProviderHandle { + this.resourceProviders.add(provider) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) return { - unregister: () => this.unregisterResource(resource.id), + notifyChanged: () => { + if (this.resourceProviders.has(provider)) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) + }, + notifyUpdated: (uri) => { + if (this.resourceProviders.has(provider)) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, uri) + }, + unregister: () => { + if (this.resourceProviders.delete(provider)) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) + }, } } @@ -124,7 +170,16 @@ export class DevframeAgentHost implements DevframeAgentHostType { list(): AgentManifest { const rpcTools = this._collectRpcTools() const plainTools = Array.from(this.tools.values()).map(t => t.tool) - const resources = Array.from(this.resources.values()).map(r => r.resource) + const resourceDefinitions = this._collectResourceDefinitions() + const resources: AgentResource[] = [] + const resourceTemplates: AgentResourceTemplate[] = [] + for (const input of resourceDefinitions) { + const resource = this._projectResource(input) + if (isResourceTemplate(input)) + resourceTemplates.push(resource as AgentResourceTemplate) + else + resources.push(resource as AgentResource) + } // Provider tools are queried lazily; earlier sources win on id collision. const seen = new Set([...rpcTools, ...plainTools].map(t => t.id)) @@ -139,6 +194,7 @@ export class DevframeAgentHost implements DevframeAgentHostType { return { tools: [...rpcTools, ...plainTools, ...providerTools], resources, + resourceTemplates, } } @@ -153,7 +209,12 @@ export class DevframeAgentHost implements DevframeAgentHostType { } getResource(id: string): AgentResource | undefined { - return this.resources.get(id)?.resource + const input = this._collectResourceDefinitions().find(candidate => + !isResourceTemplate(candidate) && (candidate.id === id || resourceUri(candidate) === id), + ) + if (!input || isResourceTemplate(input)) + return undefined + return this._projectResource(input) as AgentResource } async invoke(id: string, args: unknown): Promise { @@ -180,11 +241,28 @@ export class DevframeAgentHost implements DevframeAgentHostType { throw new Error(`[devframe/agent] tool "${id}" not found`) } - async read(id: string): Promise { - const entry = this.resources.get(id) + async read( + id: string, + uri?: string | URL, + variables: AgentResourceVariables = {}, + ): Promise { + const entry = this._findResourceDefinition(id) if (!entry) throw new Error(`[devframe/agent] resource "${id}" not found`) - return await entry.read() + + if (!isResourceTemplate(entry)) + return await entry.read(uri instanceof URL ? uri : new URL(uri ?? resourceUri(entry))) + + if (!uri) + throw new Error(`[devframe/agent] resource template "${id}" requires a URI`) + return await entry.read(uri instanceof URL ? uri : new URL(uri), variables) + } + + async listResourceInstances(id: string): Promise { + const entry = this._findResourceDefinition(id) + if (!entry || !isResourceTemplate(entry)) + throw new Error(`[devframe/agent] resource template "${id}" not found`) + return await entry.list?.() ?? { resources: [] } } /** @internal */ @@ -224,10 +302,50 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } + private _projectResource(input: AgentResourceDefinition): AgentResource | AgentResourceTemplate { + if (isResourceTemplate(input)) { + return { + id: input.id, + uriTemplate: input.uriTemplate, + name: input.name, + description: input.description, + mimeType: input.mimeType, + } + } + + return { + id: input.id, + uri: resourceUri(input), + name: input.name, + description: input.description, + mimeType: input.mimeType ?? 'application/json', + } + } + + private _collectResourceDefinitions(): AgentResourceDefinition[] { + const resources = Array.from(this.resources.values()) + const seen = new Set(resources.map(resource => resource.id)) + + for (const provider of this.resourceProviders) { + for (const input of provider()) { + if (seen.has(input.id)) + continue + seen.add(input.id) + resources.push(input) + } + } + + return resources + } + + private _findResourceDefinition(id: string): AgentResourceDefinition | undefined { + return this._collectResourceDefinitions().find(resource => resource.id === id) + } + /** Query every registered provider, projecting inputs to serializable tools. */ private _collectProviderTools(): { input: AgentToolInput, tool: AgentTool }[] { const out: { input: AgentToolInput, tool: AgentTool }[] = [] - for (const provider of this.providers) { + for (const provider of this.toolProviders) { for (const input of provider()) out.push({ input, tool: this._projectTool(input) }) } diff --git a/packages/devframe/src/node/rpc-shared-state.ts b/packages/devframe/src/node/rpc-shared-state.ts index 300ecff5..4444f692 100644 --- a/packages/devframe/src/node/rpc-shared-state.ts +++ b/packages/devframe/src/node/rpc-shared-state.ts @@ -14,12 +14,15 @@ export function createRpcSharedStateServerHost( const sharedState = new Map>() const stateDisposers = new Map void>() const keyAddedListeners = new Set<(key: string) => void>() + const updatedListeners = new Set<(key: string) => void>() function registerSharedState(key: string, state: SharedState) { const offs: (() => void)[] = [] offs.push( state.on('updated', (fullState, patches, syncId) => { + for (const listener of updatedListeners) + listener(key) if (patches) { debug('patch', { key, syncId }) rpc.broadcast({ @@ -74,6 +77,12 @@ export function createRpcSharedStateServerHost( keyAddedListeners.delete(fn) } }, + onUpdated(fn) { + updatedListeners.add(fn) + return () => { + updatedListeners.delete(fn) + } + }, delete(key) { const dispose = stateDisposers.get(key) if (!dispose) diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index b897619c..fa3c3130 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -80,20 +80,52 @@ export interface AgentResource { mimeType?: string } -/** - * Input accepted by `DevframeAgentHost.registerResource()`. - */ +/** One concrete resource returned by a resource template's list callback. */ +export type AgentResourceListItem = Omit + +export interface AgentResourceList { + resources: readonly AgentResourceListItem[] +} + +export type AgentResourceVariables = Readonly> + +/** A resource accepted by `DevframeAgentHost.registerResource()`. */ export interface AgentResourceInput { id: string + /** Optional URI override — if omitted, a `devframe://resource/` URI is generated. */ + uri?: string name: string description?: string mimeType?: string - /** Optional URI override — if omitted, a `devframe://resource/` URI is generated. */ - uri?: string /** Snapshot reader. Called on each read. */ - read: () => Promise | AgentResourceContent + read: (uri: URL) => Promise | AgentResourceContent +} + +/** Serializable description of a dynamic resource URI template. */ +export interface AgentResourceTemplate { + id: string + uriTemplate: string + name: string + description?: string + mimeType?: string } +/** A URI template accepted by `DevframeAgentHost.registerResource()`. */ +export interface AgentResourceTemplateInput { + id: string + uriTemplate: string + name: string + description?: string + mimeType?: string + list?: () => AgentResourceList | Promise + read: ( + uri: URL, + variables: AgentResourceVariables, + ) => Promise | AgentResourceContent +} + +export type AgentResourceDefinition = AgentResourceInput | AgentResourceTemplateInput + /** * Payload returned by `AgentResourceInput.read`. Either `text` or `json` must be set. */ @@ -110,6 +142,7 @@ export interface AgentResourceContent { export interface AgentManifest { tools: readonly AgentTool[] resources: readonly AgentResource[] + resourceTemplates: readonly AgentResourceTemplate[] } /** @@ -119,6 +152,14 @@ export interface AgentHandle { unregister: () => void } +export interface AgentResourceHandle extends AgentHandle { + notifyUpdated: () => void +} + +export interface AgentResourceTemplateHandle extends AgentHandle { + notifyUpdated: (uri: string) => void +} + /** * A lazy source of agent tools, queried at `list()` / `getTool()` / * `invoke()` time — the same on-demand projection the host applies to @@ -144,14 +185,25 @@ export interface AgentToolProviderHandle extends AgentHandle { notifyChanged: () => void } +/** A lazy resource source, queried for listing and resolution. */ +export type AgentResourceProvider = () => readonly AgentResourceDefinition[] + +export interface AgentResourceProviderHandle extends AgentHandle { + /** Signal that the provider's resource membership or metadata changed. */ + notifyChanged: () => void + /** Signal that one concrete URI changed. */ + notifyUpdated: (uri: string) => void +} + /** * Events emitted by `DevframeAgentHost`. */ export interface DevframeAgentHostEvents { 'agent:tool:registered': (tool: AgentTool) => void 'agent:tool:unregistered': (id: string) => void - 'agent:resource:registered': (resource: AgentResource) => void + 'agent:resource:registered': (resource: AgentResource | AgentResourceTemplate) => void 'agent:resource:unregistered': (id: string) => void + 'agent:resource:updated': (uri: string) => void /** * Fires when the unified manifest changes — including when a new * RPC function with an `agent` field is registered on `ctx.rpc`. @@ -183,8 +235,13 @@ export interface DevframeAgentHost { */ registerToolProvider: (provider: AgentToolProvider) => AgentToolProviderHandle - /** Register a readable resource. */ - registerResource: (resource: AgentResourceInput) => AgentHandle + /** Register a readable resource or URI template. */ + registerResource: { + (resource: AgentResourceInput): AgentResourceHandle + (resource: AgentResourceTemplateInput): AgentResourceTemplateHandle + } + /** Register a lazy source of resources and URI templates. */ + registerResourceProvider: (provider: AgentResourceProvider) => AgentResourceProviderHandle /** Unregister a previously registered resource by id. */ unregisterResource: (id: string) => boolean @@ -201,12 +258,14 @@ export interface DevframeAgentHost { */ invoke: (id: string, args: unknown) => Promise - /** Read a resource by id. */ - read: (id: string) => Promise + /** Read a resource or resolved template by id. */ + read: (id: string, uri?: string | URL, variables?: AgentResourceVariables) => Promise + /** Enumerate the concrete resources supplied by a template. */ + listResourceInstances: (id: string) => Promise /** Look up a tool by id (returns the serializable projection). */ getTool: (id: string) => AgentTool | undefined - /** Look up a resource by id. */ + /** Look up a resource by id or exact URI. */ getResource: (id: string) => AgentResource | undefined } diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 44c5ff7f..cc5a447a 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -110,6 +110,12 @@ export interface McpRouteOptions { * deprecated `allowedHosts`/`allowedOrigins` transport flags). */ allowedOrigins?: readonly string[] | false + /** + * Expose shared-state keys as MCP resources and through the built-in + * `devframe_state_read` tool. Defaults to `true`; pass a predicate to + * expose selected keys. + */ + exposeSharedState?: boolean | ((key: string) => boolean) } export interface DevframeCliOptions { diff --git a/packages/devframe/src/types/rpc.ts b/packages/devframe/src/types/rpc.ts index 1a1e24e7..821c4bc0 100644 --- a/packages/devframe/src/types/rpc.ts +++ b/packages/devframe/src/types/rpc.ts @@ -86,6 +86,8 @@ export interface RpcSharedStateHost { * as dynamic resources. */ onKeyAdded: (fn: (key: string) => void) => () => void + /** Subscribe to updates from any registered shared-state key. */ + onUpdated: (fn: (key: string) => void) => () => void /** * Unregister a shared state and drop its broadcast listeners. Returns * `true` when a state was removed, `false` when the key was unknown. diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 1905ec37..2c405a66 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -575,7 +575,7 @@ export function initHub(options: InitHubOptions): HubInstance { const mounted = mountMcpHttp(app, ctx, joinURL(base, mcpRoute), { serverName: options.name ?? 'devframes-hub', serverVersion: options.version ?? '0.0.0', - exposeSharedState: true, + exposeSharedState: mcpConfig.exposeSharedState ?? true, allowedOrigins: mcpConfig.allowedOrigins, }) return { context: ctx, mcp: { path: mcpRoute }, dispose: mounted.dispose } diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 861a9cb0..bfae4010 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -405,7 +405,24 @@ defineRpcFunction({ }) ``` -Or register tools / resources directly on `ctx.agent.registerTool({ id, description, safety, handler })` and `ctx.agent.registerResource({ id, name, mimeType, read })`. Expose the API over MCP: +Or register tools and resources directly. Resources accept an optional custom URI. Resource templates use `uriTemplate`, may enumerate concrete entries with `list`, and receive parsed variables in `read`. Providers keep definitions lazy when another registry owns them. + +```ts +const resource = ctx.agent.registerResource({ + id: 'builds', + uriTemplate: 'build://{id}', + name: 'Build', + list: () => ({ resources: listBuilds() }), + read: (_uri, variables) => ({ json: readBuild(String(variables.id)) }), +}) + +resource.notifyUpdated('build://current') + +const provider = ctx.agent.registerResourceProvider(() => currentResourceDefinitions()) +provider.notifyChanged() +``` + +`notifyUpdated` sends an invalidation through MCP 2026 `subscriptions/listen` when the caller's `resourceSubscriptions` filter contains the URI. Legacy MCP callers pull current values through the resource list and read methods. Expose the agent surface over MCP: ```ts import { createMcpServer } from 'devframe/adapters/mcp' diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index d3f0cf78..c2efc464 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -18,6 +18,7 @@ export declare const DEVFRAME_EVENTS: { readonly agentToolUnregistered: "agent:tool:unregistered"; readonly agentResourceRegistered: "agent:resource:registered"; readonly agentResourceUnregistered: "agent:resource:unregistered"; + readonly agentResourceUpdated: "agent:resource:updated"; }; readonly client: { readonly isTrustedUpdated: "rpc:is-trusted:updated"; diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 7cab9b7e..212da6e8 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -8,6 +8,7 @@ export interface AgentHandle { export interface AgentManifest { tools: readonly AgentTool[]; resources: readonly AgentResource[]; + resourceTemplates: readonly AgentResourceTemplate[]; } export interface AgentResource { id: string; @@ -21,13 +22,42 @@ export interface AgentResourceContent { json?: unknown; mimeType?: string; } +export interface AgentResourceHandle extends AgentHandle { + notifyUpdated: () => void; +} export interface AgentResourceInput { id: string; + uri?: string; name: string; description?: string; mimeType?: string; - uri?: string; - read: () => Promise | AgentResourceContent; + read: (_: URL) => Promise | AgentResourceContent; +} +export interface AgentResourceList { + resources: readonly AgentResourceListItem[]; +} +export interface AgentResourceProviderHandle extends AgentHandle { + notifyChanged: () => void; + notifyUpdated: (_: string) => void; +} +export interface AgentResourceTemplate { + id: string; + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; +} +export interface AgentResourceTemplateHandle extends AgentHandle { + notifyUpdated: (_: string) => void; +} +export interface AgentResourceTemplateInput { + id: string; + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; + list?: () => AgentResourceList | Promise; + read: (_: URL, _: AgentResourceVariables) => Promise | AgentResourceContent; } export interface AgentTool { id: string; @@ -92,19 +122,25 @@ export interface DevframeAgentHost { registerTool: (_: AgentToolInput) => AgentHandle; unregisterTool: (_: string) => boolean; registerToolProvider: (_: AgentToolProvider) => AgentToolProviderHandle; - registerResource: (_: AgentResourceInput) => AgentHandle; + registerResource: { + (_: AgentResourceInput): AgentResourceHandle; + (_: AgentResourceTemplateInput): AgentResourceTemplateHandle; + }; + registerResourceProvider: (_: AgentResourceProvider) => AgentResourceProviderHandle; unregisterResource: (_: string) => boolean; list: () => AgentManifest; invoke: (_: string, _: unknown) => Promise; - read: (_: string) => Promise; + read: (_: string, _?: string | URL, _?: AgentResourceVariables) => Promise; + listResourceInstances: (_: string) => Promise; getTool: (_: string) => AgentTool | undefined; getResource: (_: string) => AgentResource | undefined; } export interface DevframeAgentHostEvents { 'agent:tool:registered': (_: AgentTool) => void; 'agent:tool:unregistered': (_: string) => void; - 'agent:resource:registered': (_: AgentResource) => void; + 'agent:resource:registered': (_: AgentResource | AgentResourceTemplate) => void; 'agent:resource:unregistered': (_: string) => void; + 'agent:resource:updated': (_: string) => void; 'agent:manifest:changed': () => void; } export interface DevframeCapabilities { @@ -387,6 +423,7 @@ export interface EventUnsubscribe { export interface McpRouteOptions { path?: string; allowedOrigins?: readonly string[] | false; + exposeSharedState?: boolean | ((_: string) => boolean); } export interface RemoteAssets { package: string; @@ -439,6 +476,7 @@ export interface RpcSharedStateHost { get: (_: string, _?: RpcSharedStateGetOptions) => Promise>; keys: () => string[]; onKeyAdded: (_: (_: string) => void) => () => void; + onUpdated: (_: (_: string) => void) => () => void; delete: (_: string) => boolean; } export interface RpcStreamingChannel { @@ -472,6 +510,10 @@ export interface ScopedBroadcastOptions { // #endregion // #region Types +export type AgentResourceDefinition = AgentResourceInput | AgentResourceTemplateInput; +export type AgentResourceListItem = Omit; +export type AgentResourceProvider = () => readonly AgentResourceDefinition[]; +export type AgentResourceVariables = Readonly>; export type AgentToolProvider = () => readonly AgentToolInput[]; export type DevframeDefineDiagnosticsOptions, Reporters extends readonly AnyDiagnosticReporter[] = []> = Parameters>[0]; export type DevframeDeploymentKind = 'standalone' | 'hosted'; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 23179e31..d14e0584 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -24,22 +24,29 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { readonly events: EventEmitter; private readonly tools; private readonly resources; - private readonly providers; + private readonly toolProviders; + private readonly resourceProviders; private _rpcUnsubscribe; constructor(_: DevframeNodeContext); registerTool(_: AgentToolInput): AgentHandle; unregisterTool(_: string): boolean; registerToolProvider(_: AgentToolProvider): AgentToolProviderHandle; - registerResource(_: AgentResourceInput): AgentHandle; + registerResource(_: AgentResourceInput): AgentResourceHandle; + registerResource(_: AgentResourceTemplateInput): AgentResourceTemplateHandle; + registerResourceProvider(_: AgentResourceProvider): AgentResourceProviderHandle; unregisterResource(_: string): boolean; list(): AgentManifest; getTool(_: string): AgentTool | undefined; getResource(_: string): AgentResource | undefined; invoke(_: string, _: unknown): Promise; - read(_: string): Promise; + read(_: string, _?: string | URL, _?: AgentResourceVariables): Promise; + listResourceInstances(_: string): Promise; _dispose(): void; private _validateToolId; private _projectTool; + private _projectResource; + private _collectResourceDefinitions; + private _findResourceDefinition; private _collectProviderTools; private _collectRpcTools; private _findRpcDefinition; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 99be3647..b958c0fb 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -6,7 +6,17 @@ export { AgentHandle } export { AgentManifest } export { AgentResource } export { AgentResourceContent } +export { AgentResourceDefinition } +export { AgentResourceHandle } export { AgentResourceInput } +export { AgentResourceList } +export { AgentResourceListItem } +export { AgentResourceProvider } +export { AgentResourceProviderHandle } +export { AgentResourceTemplate } +export { AgentResourceTemplateHandle } +export { AgentResourceTemplateInput } +export { AgentResourceVariables } export { AgentTool } export { AgentToolInput } export { AgentToolProvider }