From 1c427294b9a8e943600fbd15b73a032860254b70 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Tue, 25 Aug 2026 16:59:47 +0200 Subject: [PATCH 1/5] feat(devframe): add MCP resource lifecycle --- docs/content/1.guide/14.agent-native.md | 77 ++++- docs/content/1.guide/20.events.md | 3 +- .../fixtures/resource-stdio-server.ts | 33 +++ .../adapters/mcp/__tests__/mcp-http.test.ts | 38 ++- .../adapters/mcp/__tests__/mcp-server.test.ts | 265 +++++++++++++++++- .../devframe/src/adapters/mcp/build-server.ts | 163 ++++++++++- packages/devframe/src/adapters/mcp/fetch.ts | 2 +- packages/devframe/src/events.ts | 1 + .../src/node/__tests__/host-agent.test.ts | 148 ++++++++++ packages/devframe/src/node/host-agent.ts | 204 ++++++++++++-- packages/devframe/src/types/agent.ts | 100 ++++++- skills/devframe/SKILL.md | 21 +- .../tsnapi/devframe/constants.snapshot.d.ts | 1 + .../tsnapi/devframe/index.snapshot.d.ts | 61 +++- .../tsnapi/devframe/internal.snapshot.d.ts | 15 +- .../tsnapi/devframe/types.snapshot.d.ts | 13 + 16 files changed, 1072 insertions(+), 73 deletions(-) create mode 100644 packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts diff --git a/docs/content/1.guide/14.agent-native.md b/docs/content/1.guide/14.agent-native.md index fcf3c122..c774a653 100644 --- a/docs/content/1.guide/14.agent-native.md +++ b/docs/content/1.guide/14.agent-native.md @@ -80,20 +80,89 @@ const handle = ctx.agent.registerToolProvider(() => handle.notifyChanged() // fires tools/list_changed ``` -## Registering a resource +## Registering fixed resources Readable snapshots by URI: ```ts -ctx.agent.registerResource({ +const sessionResource = ctx.agent.registerResource({ id: 'current-session', + uri: 'rolldown://session/current', // optional name: 'Current Rolldown session', description: 'Markdown snapshot of the active build session.', mimeType: 'text/markdown', - read: () => ({ text: renderMarkdown(currentSession) }), + read: uri => ({ text: renderMarkdown(currentSession, uri) }), }) + +// Notify subscribed MCP clients after the content changes. +sessionResource.notifyUpdated() ``` +Without `uri`, Devframe assigns `devframe://resource/`. `read` runs for every MCP read and receives the requested URI. A zero-argument reader remains valid. + +## 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`. + +## Resource subscriptions + +`subscribe` and `unsubscribe` follow the MCP resource lifecycle. Devframe calls them once per URI and MCP connection, and releases active subscriptions when the connection, registration, or provider goes away. + +```ts +ctx.agent.registerResource({ + id: 'live-build', + name: 'Live build', + read: () => ({ json: currentBuild() }), + subscribe: uri => buildEvents.watch(uri.toString()), + unsubscribe: uri => buildEvents.unwatch(uri.toString()), +}) +``` + +The callbacks manage the producer listener. They do not send content. Call the registration handle's `notifyUpdated()` method after a change; subscribed clients receive `resources/updated` and can read the current value. + +## 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 for subscribers +``` + +Direct registrations win over providers. Earlier providers win over later providers, and exact fixed 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. `exposeSharedState: false` (or a filter) on `createMcpServer` opts out. ## Starting the MCP server @@ -133,7 +202,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/1.guide/20.events.md b/docs/content/1.guide/20.events.md index ffa998cd..5ab31db2 100644 --- a/docs/content/1.guide/20.events.md +++ b/docs/content/1.guide/20.events.md @@ -66,7 +66,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 | ### Client connection events 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..b4e264a9 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts @@ -0,0 +1,33 @@ +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.', + setup(ctx) { + const fixed = ctx.agent.registerResource({ + id: 'status', + uri: 'https://example.com/status', + name: 'Status', + read: uri => ({ json: { uri: uri.toString(), status: 'ok' } }), + subscribe: () => { + setTimeout(() => fixed.notifyUpdated(), 20) + }, + }) + 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 15fb7385..204fe8ed 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 { @@ -90,6 +90,42 @@ describe('mcp adapter (streamable http route)', () => { } }) + it('keeps resource subscriptions session-local and cleans them up on disconnect', async () => { + const subscribe = vi.fn() + const unsubscribe = vi.fn() + let notifyUpdated: (() => void) | undefined + const started = await boot(defineTestDef({ + setup(ctx) { + const handle = ctx.agent.registerResource({ + id: 'build-status', + name: 'Build status', + read: () => ({ json: { status: 'ok' } }), + subscribe, + unsubscribe, + }) + notifyUpdated = handle.notifyUpdated + }, + })) + const transport = originTransport(started) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + const notifications: string[] = [] + client.setNotificationHandler('notifications/resources/updated', (notification) => { + notifications.push(notification.params.uri) + }) + + await client.connect(transport) + await client.subscribeResource({ uri: 'devframe://resource/build-status' }) + await client.subscribeResource({ uri: 'devframe://resource/build-status' }) + expect(subscribe).toHaveBeenCalledOnce() + + notifyUpdated!() + await vi.waitFor(() => expect(notifications).toEqual(['devframe://resource/build-status'])) + + await transport.terminateSession() + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + await client.close() + }) + it('tears the session down on DELETE and rejects reuse of the id', async () => { const started = await boot() const url = `${started.origin}/__mcp` 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 fddaa92d..9238395a 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 { @@ -31,9 +33,9 @@ async function bootPair() { ctx, client, cleanup: async () => { - dispose() await client.close() await server.close() + await dispose() }, } } @@ -232,6 +234,219 @@ describe('mcp adapter (in-memory)', () => { } }) + it('reads fixed 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 fixed 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('deduplicates subscriptions and filters update notifications by URI', async () => { + const { ctx, client, cleanup } = await bootPair() + const subscribe = vi.fn() + const unsubscribe = vi.fn() + const notifications: string[] = [] + client.setNotificationHandler('notifications/resources/updated', (notification) => { + notifications.push(notification.params.uri) + }) + try { + const first = ctx.agent.registerResource({ + id: 'first', + name: 'First', + read: () => ({ text: 'first' }), + subscribe, + unsubscribe, + }) + const second = ctx.agent.registerResource({ + id: 'second', + name: 'Second', + read: () => ({ text: 'second' }), + }) + + await Promise.all([ + client.subscribeResource({ uri: 'devframe://resource/first' }), + client.subscribeResource({ uri: 'devframe://resource/first' }), + ]) + expect(subscribe).toHaveBeenCalledOnce() + + second.notifyUpdated() + first.notifyUpdated() + await vi.waitFor(() => expect(notifications).toEqual(['devframe://resource/first'])) + + await Promise.all([ + client.unsubscribeResource({ uri: 'devframe://resource/first' }), + client.unsubscribeResource({ uri: 'devframe://resource/first' }), + ]) + expect(unsubscribe).toHaveBeenCalledOnce() + first.notifyUpdated() + await client.listResources() + expect(notifications).toEqual(['devframe://resource/first']) + } + finally { + await cleanup() + } + }) + + it('releases a subscription when its provider resource disappears', async () => { + const { ctx, client, cleanup } = await bootPair() + const subscribe = vi.fn() + const unsubscribe = vi.fn() + let exposed = true + const providerHandle = ctx.agent.registerResourceProvider(() => exposed + ? [{ + id: 'provided', + name: 'Provided', + read: () => ({ text: 'provided' }), + subscribe, + unsubscribe, + }] + : []) + try { + await client.subscribeResource({ uri: 'devframe://resource/provided' }) + expect(subscribe).toHaveBeenCalledOnce() + + exposed = false + providerHandle.notifyChanged() + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + } + finally { + await cleanup() + } + }) + + it('moves a subscription to the next provider after a collision winner leaves', async () => { + const { ctx, client, cleanup } = await bootPair() + const firstSubscribe = vi.fn() + const firstUnsubscribe = vi.fn() + const secondSubscribe = vi.fn() + const firstProvider = ctx.agent.registerResourceProvider(() => [{ + id: 'shared', + name: 'First', + read: () => ({ text: 'first' }), + subscribe: firstSubscribe, + unsubscribe: firstUnsubscribe, + }]) + ctx.agent.registerResourceProvider(() => [{ + id: 'shared', + name: 'Second', + read: () => ({ text: 'second' }), + subscribe: secondSubscribe, + }]) + try { + await client.subscribeResource({ uri: 'devframe://resource/shared' }) + expect(firstSubscribe).toHaveBeenCalledOnce() + expect(secondSubscribe).not.toHaveBeenCalled() + + firstProvider.unregister() + await vi.waitFor(() => expect(firstUnsubscribe).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(secondSubscribe).toHaveBeenCalledOnce()) + } + finally { + await cleanup() + } + }) + + it('releases remaining subscriptions when the MCP server is disposed', async () => { + const { ctx, client, cleanup } = await bootPair() + const unsubscribe = vi.fn() + ctx.agent.registerResource({ + id: 'live', + name: 'Live', + read: () => ({ text: 'live' }), + unsubscribe, + }) + + await client.subscribeResource({ uri: 'devframe://resource/live' }) + await cleanup() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + it('surfaces shared-state keys as MCP resources', async () => { const { ctx, client, cleanup } = await bootPair() try { @@ -328,7 +543,7 @@ describe('mcp adapter (in-memory)', () => { expect(listed.tools.map(t => t.name)).not.toContain('devframe_state_read') } finally { - dispose() + await dispose() await client.close() await server.close() } @@ -355,9 +570,51 @@ describe('mcp adapter (in-memory)', () => { expect(hidden.isError).toBe(true) } finally { - dispose() + await dispose() await client.close() await server.close() } }) }) + +describe('mcp adapter (stdio)', () => { + it('lists, reads, and subscribes to fixed and template 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' }) + const updates: string[] = [] + client.setNotificationHandler('notifications/resources/updated', (notification) => { + updates.push(notification.params.uri) + }) + + try { + await client.connect(transport) + const resources = await client.listResources() + expect(resources.resources.map(resource => resource.uri)).toEqual(expect.arrayContaining([ + 'https://example.com/status', + 'devframe://logs/app', + ])) + 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' }) + + await client.subscribeResource({ uri: 'https://example.com/status' }) + await vi.waitFor(() => expect(updates).toEqual(['https://example.com/status'])) + } + finally { + await client.close() + } + }) +}) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 69f26a39..a2ef8fc4 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -1,15 +1,16 @@ -import type { Tool } from '@modelcontextprotocol/server' +import type { 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' import { DEVFRAME_EVENTS } from '../../events' import { diagnostics } from '../../node/diagnostics' +import { AGENT_RESOURCE_SOURCE } from '../../node/host-agent' import { formatMcpError, stringifyForMcp } from './stringify' import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema' @@ -51,7 +52,7 @@ export interface McpServerHandle { export function buildMcpServerFromContext( ctx: DevframeNodeContext, options: { serverName: string, serverVersion: string, exposeSharedState: boolean | ((k: string) => boolean) }, -): { server: Server, dispose: () => void } { +): { server: Server, dispose: () => Promise } { const server = new Server( { name: options.serverName, @@ -60,13 +61,13 @@ export function buildMcpServerFromContext( { capabilities: { tools: { listChanged: true }, - resources: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, }, }, ) registerToolHandlers(server, ctx, options.exposeSharedState) - registerResourceHandlers(server, ctx, options.exposeSharedState) + const disposeResourceHandlers = registerResourceHandlers(server, ctx, options.exposeSharedState) const notify = (method: string): void => { server.notification({ method }).catch(() => { /* ignore transport errors */ }) @@ -79,12 +80,22 @@ export function buildMcpServerFromContext( notify('notifications/resources/list_changed') }) - return { - server, - dispose: () => { + let disposal: Promise | undefined + const dispose = (): Promise => { + disposal ??= (async () => { offManifest() offKeyAdded() - }, + await disposeResourceHandlers() + })() + return disposal + } + server.onclose = () => { + void dispose().catch(() => { /* ignore resource cleanup errors after transport closure */ }) + } + + return { + server, + dispose, } } @@ -144,7 +155,7 @@ export async function createMcpServer( return { async stop() { - dispose() + await dispose() await stop() }, } @@ -292,15 +303,35 @@ function registerResourceHandlers( server: Server, ctx: DevframeNodeContext, exposeSharedState: boolean | ((key: string) => boolean), -): void { +): () => Promise { + interface Subscription { + resourceId: string + source: object + cleanup: () => void | Promise + } + + const subscriptions = new Map() + let subscriptionOperations = Promise.resolve() + const runSubscriptionOperation = (operation: () => Promise): Promise => { + const result = subscriptionOperations.then(operation) + subscriptionOperations = result.then(() => undefined, () => undefined) + return result + } + 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, })) + for (const template of manifest.resourceTemplates) { + const listed = await ctx.agent.listResourceInstances(template.id) + resources.push(...listed.resources) + } + if (exposeSharedState !== false) { const filter = typeof exposeSharedState === 'function' ? exposeSharedState : () => true for (const key of ctx.rpc.sharedState.keys()) { @@ -318,12 +349,22 @@ 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: [ { @@ -335,6 +376,7 @@ function registerResourceHandlers( } } + const parsed = parseResourceUri(uri) if (parsed.kind === 'state') { const state = await ctx.rpc.sharedState.get(parsed.key) return { @@ -350,6 +392,97 @@ function registerResourceHandlers( throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`) }) + + server.setRequestHandler('resources/subscribe', async (request) => { + const { uri } = request.params + return await runSubscriptionOperation(async () => { + if (subscriptions.has(uri)) + return {} + + const resource = resolveAgentResource(ctx, uri) + if (!resource) + throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`) + + const cleanup = await ctx.agent.subscribeResource(resource.id, uri) + subscriptions.set(uri, { resourceId: resource.id, source: resource.source, cleanup }) + return {} + }) + }) + + server.setRequestHandler('resources/unsubscribe', async (request) => { + const { uri } = request.params + return await runSubscriptionOperation(async () => { + const subscription = subscriptions.get(uri) + if (!subscription) + return {} + + subscriptions.delete(uri) + await subscription.cleanup() + return {} + }) + }) + + const offUpdated = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentResourceUpdated, (uri) => { + if (!subscriptions.has(uri)) + return + void server.sendResourceUpdated({ uri }).catch(() => { /* ignore transport errors */ }) + }) + + const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { + void runSubscriptionOperation(async () => { + for (const [uri, subscription] of subscriptions) { + const resource = resolveAgentResource(ctx, uri) + if (resource?.id === subscription.resourceId && resource.source === subscription.source) + continue + await subscription.cleanup() + if (!resource) { + subscriptions.delete(uri) + continue + } + const cleanup = await ctx.agent.subscribeResource(resource.id, uri) + subscriptions.set(uri, { + resourceId: resource.id, + source: resource.source, + cleanup, + }) + } + }).catch(() => { /* ignore subscription cleanup errors during reconciliation */ }) + }) + + return async () => { + offUpdated() + offManifest() + await runSubscriptionOperation(async () => { + const active = [...subscriptions.values()] + subscriptions.clear() + await Promise.all(active.map(subscription => subscription.cleanup())) + }) + } +} + +function resolveAgentResource( + ctx: DevframeNodeContext, + uri: string, +): { id: string, variables: Variables, source: object } | undefined { + const manifest = ctx.agent.list() + const fixed = manifest.resources.find(resource => resource.uri === uri) + if (fixed) + return { id: fixed.id, variables: {}, source: getResourceSource(fixed) } + + for (const template of manifest.resourceTemplates) { + const variables = new UriTemplate(template.uriTemplate).match(uri) + if (variables) { + return { + id: template.id, + variables, + source: getResourceSource(template), + } + } + } +} + +function getResourceSource(resource: object): object { + return Reflect.get(resource, AGENT_RESOURCE_SOURCE) as object } /** diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index 32a8a482..c5b15a9b 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -94,7 +94,7 @@ export function createMcpFetchHandler( session = { transport, dispose: async () => { - dispose() + await dispose() await server.close() }, } diff --git a/packages/devframe/src/events.ts b/packages/devframe/src/events.ts index e55df7df..dadb6622 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..520a7abb 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -250,6 +250,101 @@ 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('forwards subscriptions and cleanup to the resource callbacks', async () => { + const ctx = createContext() + const subscribe = vi.fn() + const unsubscribe = vi.fn() + ctx.agent.registerResource({ + id: 'live', + name: 'Live', + read: () => ({ text: 'now' }), + subscribe, + unsubscribe, + }) + + const cleanup = await ctx.agent.subscribeResource('live', 'devframe://resource/live') + expect(subscribe).toHaveBeenCalledWith(new URL('devframe://resource/live')) + await cleanup() + expect(unsubscribe).toHaveBeenCalledWith(new URL('devframe://resource/live')) + }) + + it('emits updates through fixed 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() + 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 +365,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..4a9c195e 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -1,10 +1,20 @@ import type { RpcFunctionDefinitionAnyWithContext, RpcFunctionType } from 'devframe/rpc' import type { + AgentFixedResourceHandle, + AgentFixedResourceInput, AgentHandle, AgentManifest, AgentResource, AgentResourceContent, - AgentResourceInput, + AgentResourceDefinition, + AgentResourceList, + AgentResourceProvider, + AgentResourceProviderHandle, + AgentResourceSubscriptionCleanup, + AgentResourceTemplate, + AgentResourceTemplateHandle, + AgentResourceTemplateInput, + AgentResourceVariables, AgentTool, AgentToolInput, AgentToolProvider, @@ -25,9 +35,25 @@ interface RegisteredTool { readonly handler?: (args: any) => unknown | Promise } -interface RegisteredResource { - readonly resource: AgentResource - readonly read: () => Promise | AgentResourceContent +/** Opaque registration source used by protocol adapters during subscription reconciliation. */ +export const AGENT_RESOURCE_SOURCE = Symbol.for('devframe.agent.resource-source') + +interface RegisteredFixedResource { + readonly kind: 'fixed' + readonly input: AgentFixedResourceInput + readonly resource: AgentResource & { readonly [AGENT_RESOURCE_SOURCE]: object } +} + +interface RegisteredTemplateResource { + readonly kind: 'template' + readonly input: AgentResourceTemplateInput + readonly resource: AgentResourceTemplate & { readonly [AGENT_RESOURCE_SOURCE]: object } +} + +type RegisteredResource = RegisteredFixedResource | RegisteredTemplateResource + +function isResourceTemplate(input: AgentResourceDefinition): input is AgentResourceTemplateInput { + return 'uriTemplate' in input } /** @@ -41,7 +67,8 @@ export class DevframeAgentHost implements DevframeAgentHostType { private readonly tools = new Map() private readonly resources = new Map() - private readonly providers = new Set() + private readonly toolProviders = new Set() + private readonly resourceProviders = new Set() private _rpcUnsubscribe: (() => void) | undefined constructor( @@ -76,39 +103,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: AgentFixedResourceInput): AgentFixedResourceHandle + registerResource(input: AgentResourceTemplateInput): AgentResourceTemplateHandle + registerResource(input: AgentResourceDefinition): AgentFixedResourceHandle | 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)}`, + const registered = this._projectResource(input, input) + this.resources.set(input.id, registered) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceRegistered, registered.resource) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) + + const unregister = (): void => { + if (this.resources.get(input.id)?.input === input) + this.unregisterResource(input.id) + } + if (registered.kind === 'fixed') { + return { + notifyUpdated: () => { + if (this.resources.get(input.id)?.input === input) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, registered.resource.uri) + }, + unregister, + } + } + return { + notifyUpdated: (uri) => { + if (this.resources.get(input.id)?.input === input) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, uri) + }, + unregister, } - this.resources.set(resource.id, { resource, read: input.read }) - this.events.emit(DEVFRAME_EVENTS.bus.agentResourceRegistered, resource) + } + + 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 +184,9 @@ 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 = resourceDefinitions.flatMap(entry => entry.kind === 'fixed' ? [entry.resource] : []) + const resourceTemplates = resourceDefinitions.flatMap(entry => entry.kind === 'template' ? [entry.resource] : []) // Provider tools are queried lazily; earlier sources win on id collision. const seen = new Set([...rpcTools, ...plainTools].map(t => t.id)) @@ -139,6 +201,7 @@ export class DevframeAgentHost implements DevframeAgentHostType { return { tools: [...rpcTools, ...plainTools, ...providerTools], resources, + resourceTemplates, } } @@ -153,7 +216,15 @@ export class DevframeAgentHost implements DevframeAgentHostType { } getResource(id: string): AgentResource | undefined { - return this.resources.get(id)?.resource + const registered = this._collectResourceDefinitions().find(entry => + entry.kind === 'fixed' && (entry.resource.id === id || entry.resource.uri === id), + ) + return registered?.kind === 'fixed' ? registered.resource : undefined + } + + getResourceTemplate(id: string): AgentResourceTemplate | undefined { + const registered = this._findResourceDefinition(id) + return registered?.kind === 'template' ? registered.resource : undefined } async invoke(id: string, args: unknown): Promise { @@ -180,11 +251,40 @@ 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 (entry.kind === 'fixed') + return await entry.input.read(uri instanceof URL ? uri : new URL(uri ?? entry.resource.uri)) + + if (!uri) + throw new Error(`[devframe/agent] resource template "${id}" requires a URI`) + return await entry.input.read(uri instanceof URL ? uri : new URL(uri), variables) + } + + async listResourceInstances(id: string): Promise { + const entry = this._findResourceDefinition(id) + if (!entry || entry.kind !== 'template') + throw new Error(`[devframe/agent] resource template "${id}" not found`) + return await entry.input.list?.() ?? { resources: [] } + } + + async subscribeResource(id: string, uri: string | URL): Promise { + const entry = this._findResourceDefinition(id) + if (!entry) + throw new Error(`[devframe/agent] resource "${id}" not found`) + + const resourceUri = uri instanceof URL ? uri : new URL(uri) + await entry.input.subscribe?.(resourceUri) + return async () => { + await entry.input.unsubscribe?.(resourceUri) + } } /** @internal */ @@ -224,10 +324,58 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } + private _projectResource(input: AgentResourceDefinition, source: object): RegisteredResource { + if (isResourceTemplate(input)) { + return { + kind: 'template', + input, + resource: attachResourceSource({ + id: input.id, + uriTemplate: input.uriTemplate, + name: input.name, + description: input.description, + mimeType: input.mimeType, + }, source), + } + } + + return { + kind: 'fixed', + input, + resource: attachResourceSource({ + id: input.id, + uri: input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}`, + name: input.name, + description: input.description, + mimeType: input.mimeType ?? 'application/json', + }, source), + } + } + + private _collectResourceDefinitions(): RegisteredResource[] { + const resources = Array.from(this.resources.values()) + const seen = new Set(resources.map(resource => 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(this._projectResource(input, provider)) + } + } + + return resources + } + + private _findResourceDefinition(id: string): RegisteredResource | undefined { + return this._collectResourceDefinitions().find(resource => 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) }) } @@ -270,6 +418,14 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } +function attachResourceSource( + resource: Resource, + source: object, +): Resource & { readonly [AGENT_RESOURCE_SOURCE]: object } { + Object.defineProperty(resource, AGENT_RESOURCE_SOURCE, { value: source }) + return resource as Resource & { readonly [AGENT_RESOURCE_SOURCE]: object } +} + function inferSafety(type: RpcFunctionType): 'read' | 'action' | 'destructive' { if (type === 'static' || type === 'query') return 'read' diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index b897619c..0cf20565 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -80,20 +80,59 @@ export interface AgentResource { mimeType?: string } -/** - * Input accepted by `DevframeAgentHost.registerResource()`. - */ -export interface AgentResourceInput { +/** 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 fixed resource accepted by `DevframeAgentHost.registerResource()`. */ +export interface AgentFixedResourceInput { 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 + subscribe?: (uri: URL) => void | Promise + unsubscribe?: (uri: URL) => void | Promise +} + +/** 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 + subscribe?: (uri: URL) => void | Promise + unsubscribe?: (uri: URL) => void | Promise } +/** Backwards-compatible name for a fixed resource registration. */ +export type AgentResourceInput = AgentFixedResourceInput + +export type AgentResourceDefinition = AgentFixedResourceInput | AgentResourceTemplateInput + /** * Payload returned by `AgentResourceInput.read`. Either `text` or `json` must be set. */ @@ -110,6 +149,7 @@ export interface AgentResourceContent { export interface AgentManifest { tools: readonly AgentTool[] resources: readonly AgentResource[] + resourceTemplates: readonly AgentResourceTemplate[] } /** @@ -119,6 +159,18 @@ export interface AgentHandle { unregister: () => void } +export interface AgentFixedResourceHandle extends AgentHandle { + notifyUpdated: () => void +} + +export interface AgentResourceTemplateHandle extends AgentHandle { + notifyUpdated: (uri: string) => void +} + +export type AgentResourceHandle = AgentFixedResourceHandle | AgentResourceTemplateHandle + +export type AgentResourceSubscriptionCleanup = () => void | Promise + /** * A lazy source of agent tools, queried at `list()` / `getTool()` / * `invoke()` time — the same on-demand projection the host applies to @@ -144,14 +196,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 +246,13 @@ export interface DevframeAgentHost { */ registerToolProvider: (provider: AgentToolProvider) => AgentToolProviderHandle - /** Register a readable resource. */ - registerResource: (resource: AgentResourceInput) => AgentHandle + /** Register a readable fixed resource or URI template. */ + registerResource: { + (resource: AgentFixedResourceInput): AgentFixedResourceHandle + (resource: AgentResourceTemplateInput): AgentResourceTemplateHandle + } + /** Register a lazy source of fixed resources and URI templates. */ + registerResourceProvider: (provider: AgentResourceProvider) => AgentResourceProviderHandle /** Unregister a previously registered resource by id. */ unregisterResource: (id: string) => boolean @@ -201,13 +269,19 @@ export interface DevframeAgentHost { */ invoke: (id: string, args: unknown) => Promise - /** Read a resource by id. */ - read: (id: string) => Promise + /** Read a fixed 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 + /** Forward one MCP subscription to a resource and capture its cleanup. */ + subscribeResource: (id: string, uri: string | URL) => 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 fixed resource by id or exact URI. */ getResource: (id: string) => AgentResource | undefined + /** Look up a resource template by id. */ + getResourceTemplate: (id: string) => AgentResourceTemplate | undefined } // Re-export the options interface for convenience. diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index a474d7c2..d627af4c 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -405,7 +405,26 @@ defineRpcFunction({ }) ``` -Or register tools / resources directly on `ctx.agent.registerTool({ id, description, safety, handler })` and `ctx.agent.registerResource({ id, name, mimeType, read })`. Expose the surface over MCP: +Or register tools and resources directly. Fixed 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)) }), + subscribe: uri => watchBuild(uri), + unsubscribe: uri => unwatchBuild(uri), +}) + +resource.notifyUpdated('build://current') + +const provider = ctx.agent.registerResourceProvider(() => currentResourceDefinitions()) +provider.notifyChanged() +``` + +Devframe deduplicates MCP subscriptions per URI and connection. `notifyUpdated` sends only a change notification to subscribers, which then call `resources/read` for the current content. 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 60423e38..fce5aa8f 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..d429a392 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -2,12 +2,26 @@ * Generated by tsnapi — public API snapshot of `devframe` */ // #region Interfaces +export interface AgentFixedResourceHandle extends AgentHandle { + notifyUpdated: () => void; +} +export interface AgentFixedResourceInput { + id: string; + uri?: string; + name: string; + description?: string; + mimeType?: string; + read: (_: URL) => Promise | AgentResourceContent; + subscribe?: (_: URL) => void | Promise; + unsubscribe?: (_: URL) => void | Promise; +} export interface AgentHandle { unregister: () => void; } export interface AgentManifest { tools: readonly AgentTool[]; resources: readonly AgentResource[]; + resourceTemplates: readonly AgentResourceTemplate[]; } export interface AgentResource { id: string; @@ -21,13 +35,33 @@ export interface AgentResourceContent { json?: unknown; mimeType?: string; } -export interface AgentResourceInput { +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; - uri?: string; - read: () => Promise | AgentResourceContent; + list?: () => AgentResourceList | Promise; + read: (_: URL, _: AgentResourceVariables) => Promise | AgentResourceContent; + subscribe?: (_: URL) => void | Promise; + unsubscribe?: (_: URL) => void | Promise; } export interface AgentTool { id: string; @@ -92,19 +126,27 @@ export interface DevframeAgentHost { registerTool: (_: AgentToolInput) => AgentHandle; unregisterTool: (_: string) => boolean; registerToolProvider: (_: AgentToolProvider) => AgentToolProviderHandle; - registerResource: (_: AgentResourceInput) => AgentHandle; + registerResource: { + (_: AgentFixedResourceInput): AgentFixedResourceHandle; + (_: 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; + subscribeResource: (_: string, _: string | URL) => Promise; getTool: (_: string) => AgentTool | undefined; getResource: (_: string) => AgentResource | undefined; + getResourceTemplate: (_: string) => AgentResourceTemplate | 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 { @@ -472,6 +514,13 @@ export interface ScopedBroadcastOptions { // #endregion // #region Types +export type AgentResourceDefinition = AgentFixedResourceInput | AgentResourceTemplateInput; +export type AgentResourceHandle = AgentFixedResourceHandle | AgentResourceTemplateHandle; +export type AgentResourceInput = AgentFixedResourceInput; +export type AgentResourceListItem = Omit; +export type AgentResourceProvider = () => readonly AgentResourceDefinition[]; +export type AgentResourceSubscriptionCleanup = () => void | Promise; +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..69a9af09 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -24,22 +24,31 @@ 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(_: AgentFixedResourceInput): AgentFixedResourceHandle; + registerResource(_: AgentResourceTemplateInput): AgentResourceTemplateHandle; + registerResourceProvider(_: AgentResourceProvider): AgentResourceProviderHandle; unregisterResource(_: string): boolean; list(): AgentManifest; getTool(_: string): AgentTool | undefined; getResource(_: string): AgentResource | undefined; + getResourceTemplate(_: string): AgentResourceTemplate | undefined; invoke(_: string, _: unknown): Promise; - read(_: string): Promise; + read(_: string, _?: string | URL, _?: AgentResourceVariables): Promise; + listResourceInstances(_: string): Promise; + subscribeResource(_: string, _: string | URL): 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..1b42befd 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -2,11 +2,24 @@ * Generated by tsnapi — public API snapshot of `devframe/types` */ // #region Other +export { AgentFixedResourceHandle } +export { AgentFixedResourceInput } export { AgentHandle } export { AgentManifest } export { AgentResource } export { AgentResourceContent } +export { AgentResourceDefinition } +export { AgentResourceHandle } export { AgentResourceInput } +export { AgentResourceList } +export { AgentResourceListItem } +export { AgentResourceProvider } +export { AgentResourceProviderHandle } +export { AgentResourceSubscriptionCleanup } +export { AgentResourceTemplate } +export { AgentResourceTemplateHandle } +export { AgentResourceTemplateInput } +export { AgentResourceVariables } export { AgentTool } export { AgentToolInput } export { AgentToolProvider } From 4f3f4366c55361ebfd50aca31f6abc4b7c006164 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Tue, 25 Aug 2026 18:19:24 +0200 Subject: [PATCH 2/5] refactor(devframe): simplify MCP resource lifecycle --- docs/content/1.guide/14.agent-native.md | 24 ++- .../adapters/mcp/__tests__/mcp-server.test.ts | 6 +- .../devframe/src/adapters/mcp/build-server.ts | 52 ++----- .../src/node/__tests__/host-agent.test.ts | 2 +- packages/devframe/src/node/host-agent.ts | 138 +++++++----------- packages/devframe/src/types/agent.ts | 29 ++-- skills/devframe/SKILL.md | 2 +- .../tsnapi/devframe/index.snapshot.d.ts | 36 ++--- .../tsnapi/devframe/internal.snapshot.d.ts | 5 +- .../tsnapi/devframe/types.snapshot.d.ts | 3 - 10 files changed, 115 insertions(+), 182 deletions(-) diff --git a/docs/content/1.guide/14.agent-native.md b/docs/content/1.guide/14.agent-native.md index c774a653..8e85cfcd 100644 --- a/docs/content/1.guide/14.agent-native.md +++ b/docs/content/1.guide/14.agent-native.md @@ -80,25 +80,21 @@ const handle = ctx.agent.registerToolProvider(() => handle.notifyChanged() // fires tools/list_changed ``` -## Registering fixed resources +## Registering a resource Readable snapshots by URI: ```ts -const sessionResource = ctx.agent.registerResource({ +ctx.agent.registerResource({ id: 'current-session', - uri: 'rolldown://session/current', // optional name: 'Current Rolldown session', description: 'Markdown snapshot of the active build session.', mimeType: 'text/markdown', - read: uri => ({ text: renderMarkdown(currentSession, uri) }), + read: () => ({ text: renderMarkdown(currentSession) }), }) - -// Notify subscribed MCP clients after the content changes. -sessionResource.notifyUpdated() ``` -Without `uri`, Devframe assigns `devframe://resource/`. `read` runs for every MCP read and receives the requested URI. A zero-argument reader remains valid. +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 @@ -127,21 +123,21 @@ logsResource.notifyUpdated('rolldown://logs/worker') MCP exposes templates through `resources/templates/list`. When `list` is present, its concrete entries also appear in `resources/list`. -## Resource subscriptions +## Updating a subscribed resource `subscribe` and `unsubscribe` follow the MCP resource lifecycle. Devframe calls them once per URI and MCP connection, and releases active subscriptions when the connection, registration, or provider goes away. ```ts -ctx.agent.registerResource({ +const buildResource = ctx.agent.registerResource({ id: 'live-build', name: 'Live build', read: () => ({ json: currentBuild() }), - subscribe: uri => buildEvents.watch(uri.toString()), - unsubscribe: uri => buildEvents.unwatch(uri.toString()), + subscribe: uri => buildEvents.retain(uri, () => buildResource.notifyUpdated()), + unsubscribe: uri => buildEvents.release(uri), }) ``` -The callbacks manage the producer listener. They do not send content. Call the registration handle's `notifyUpdated()` method after a change; subscribed clients receive `resources/updated` and can read the current value. +The producer owns its listener and any reference counting across MCP connections. `notifyUpdated()` sends no content. It tells subscribed clients to read the current value. ## Deriving resources from other state @@ -161,7 +157,7 @@ resources.notifyChanged() // resources/list_changed resources.notifyUpdated('dataset://builds/active') // resources/updated for subscribers ``` -Direct registrations win over providers. Earlier providers win over later providers, and exact fixed URIs win over templates. +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. `exposeSharedState: false` (or a filter) on `createMcpServer` opts out. 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 9238395a..01dfe6e0 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -234,7 +234,7 @@ describe('mcp adapter (in-memory)', () => { } }) - it('reads fixed resources from their explicit URI', async () => { + 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() } })) @@ -302,7 +302,7 @@ describe('mcp adapter (in-memory)', () => { } }) - it('resolves an exact fixed URI before a matching template', async () => { + it('resolves an exact resource URI before a matching template', async () => { const { ctx, client, cleanup } = await bootPair() try { ctx.agent.registerResource({ @@ -578,7 +578,7 @@ describe('mcp adapter (in-memory)', () => { }) describe('mcp adapter (stdio)', () => { - it('lists, reads, and subscribes to fixed and template resources', async () => { + it('lists, reads, and subscribes to registered and template resources', async () => { const fixture = fileURLToPath(new URL('./fixtures/resource-stdio-server.ts', import.meta.url)) const transport = new StdioClientTransport({ command: process.execPath, diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index a2ef8fc4..d882952b 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -10,7 +10,6 @@ import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { join } from 'pathe' import { DEVFRAME_EVENTS } from '../../events' import { diagnostics } from '../../node/diagnostics' -import { AGENT_RESOURCE_SOURCE } from '../../node/host-agent' import { formatMcpError, stringifyForMcp } from './stringify' import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema' @@ -43,7 +42,7 @@ export interface McpServerHandle { /** * Wire an MCP {@link Server} to a devframe context. Returns the server - * plus a disposal function for the subscriptions it sets up. The + * plus an async disposal function for the subscriptions it sets up. The * transport is the caller's responsibility — `createMcpServer` connects * stdio; tests can connect an {@link InMemoryTransport} instead. * @@ -304,13 +303,7 @@ function registerResourceHandlers( ctx: DevframeNodeContext, exposeSharedState: boolean | ((key: string) => boolean), ): () => Promise { - interface Subscription { - resourceId: string - source: object - cleanup: () => void | Promise - } - - const subscriptions = new Map() + const subscriptions = new Map void | Promise>() let subscriptionOperations = Promise.resolve() const runSubscriptionOperation = (operation: () => Promise): Promise => { const result = subscriptionOperations.then(operation) @@ -404,7 +397,7 @@ function registerResourceHandlers( throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`) const cleanup = await ctx.agent.subscribeResource(resource.id, uri) - subscriptions.set(uri, { resourceId: resource.id, source: resource.source, cleanup }) + subscriptions.set(uri, cleanup) return {} }) }) @@ -412,12 +405,12 @@ function registerResourceHandlers( server.setRequestHandler('resources/unsubscribe', async (request) => { const { uri } = request.params return await runSubscriptionOperation(async () => { - const subscription = subscriptions.get(uri) - if (!subscription) + const cleanup = subscriptions.get(uri) + if (!cleanup) return {} subscriptions.delete(uri) - await subscription.cleanup() + await cleanup() return {} }) }) @@ -430,21 +423,13 @@ function registerResourceHandlers( const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { void runSubscriptionOperation(async () => { - for (const [uri, subscription] of subscriptions) { + for (const [uri, cleanup] of [...subscriptions]) { + subscriptions.delete(uri) + await cleanup() const resource = resolveAgentResource(ctx, uri) - if (resource?.id === subscription.resourceId && resource.source === subscription.source) + if (!resource) continue - await subscription.cleanup() - if (!resource) { - subscriptions.delete(uri) - continue - } - const cleanup = await ctx.agent.subscribeResource(resource.id, uri) - subscriptions.set(uri, { - resourceId: resource.id, - source: resource.source, - cleanup, - }) + subscriptions.set(uri, await ctx.agent.subscribeResource(resource.id, uri)) } }).catch(() => { /* ignore subscription cleanup errors during reconciliation */ }) }) @@ -455,7 +440,7 @@ function registerResourceHandlers( await runSubscriptionOperation(async () => { const active = [...subscriptions.values()] subscriptions.clear() - await Promise.all(active.map(subscription => subscription.cleanup())) + await Promise.all(active.map(cleanup => cleanup())) }) } } @@ -463,11 +448,11 @@ function registerResourceHandlers( function resolveAgentResource( ctx: DevframeNodeContext, uri: string, -): { id: string, variables: Variables, source: object } | undefined { +): { id: string, variables: Variables } | undefined { const manifest = ctx.agent.list() - const fixed = manifest.resources.find(resource => resource.uri === uri) - if (fixed) - return { id: fixed.id, variables: {}, source: getResourceSource(fixed) } + const resource = manifest.resources.find(candidate => candidate.uri === uri) + if (resource) + return { id: resource.id, variables: {} } for (const template of manifest.resourceTemplates) { const variables = new UriTemplate(template.uriTemplate).match(uri) @@ -475,16 +460,11 @@ function resolveAgentResource( return { id: template.id, variables, - source: getResourceSource(template), } } } } -function getResourceSource(resource: object): object { - return Reflect.get(resource, AGENT_RESOURCE_SOURCE) as object -} - /** * 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/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts index 520a7abb..2146a62b 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -316,7 +316,7 @@ describe('devToolsAgentHost', () => { expect(unsubscribe).toHaveBeenCalledWith(new URL('devframe://resource/live')) }) - it('emits updates through fixed and template handles', () => { + it('emits updates through resource and template handles', () => { const ctx = createContext() const updated = vi.fn() ctx.agent.events.on('agent:resource:updated', updated) diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index 4a9c195e..72542057 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -1,16 +1,15 @@ import type { RpcFunctionDefinitionAnyWithContext, RpcFunctionType } from 'devframe/rpc' import type { - AgentFixedResourceHandle, - AgentFixedResourceInput, AgentHandle, AgentManifest, AgentResource, AgentResourceContent, AgentResourceDefinition, + AgentResourceHandle, + AgentResourceInput, AgentResourceList, AgentResourceProvider, AgentResourceProviderHandle, - AgentResourceSubscriptionCleanup, AgentResourceTemplate, AgentResourceTemplateHandle, AgentResourceTemplateInput, @@ -35,27 +34,14 @@ interface RegisteredTool { readonly handler?: (args: any) => unknown | Promise } -/** Opaque registration source used by protocol adapters during subscription reconciliation. */ -export const AGENT_RESOURCE_SOURCE = Symbol.for('devframe.agent.resource-source') - -interface RegisteredFixedResource { - readonly kind: 'fixed' - readonly input: AgentFixedResourceInput - readonly resource: AgentResource & { readonly [AGENT_RESOURCE_SOURCE]: object } -} - -interface RegisteredTemplateResource { - readonly kind: 'template' - readonly input: AgentResourceTemplateInput - readonly resource: AgentResourceTemplate & { readonly [AGENT_RESOURCE_SOURCE]: object } -} - -type RegisteredResource = RegisteredFixedResource | RegisteredTemplateResource - function isResourceTemplate(input: AgentResourceDefinition): input is AgentResourceTemplateInput { return 'uriTemplate' in input } +function resourceUri(input: AgentResourceInput): string { + return input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}` +} + /** * Framework-neutral host aggregating the agent-exposed surface of a * devframe. Auto-discovers RPC functions with an `agent` field from @@ -66,7 +52,7 @@ export class DevframeAgentHost implements DevframeAgentHostType { public readonly events: EventEmitter = createEventEmitter() private readonly tools = new Map() - private readonly resources = new Map() + private readonly resources = new Map() private readonly toolProviders = new Set() private readonly resourceProviders = new Set() private _rpcUnsubscribe: (() => void) | undefined @@ -119,33 +105,33 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } - registerResource(input: AgentFixedResourceInput): AgentFixedResourceHandle + registerResource(input: AgentResourceInput): AgentResourceHandle registerResource(input: AgentResourceTemplateInput): AgentResourceTemplateHandle - registerResource(input: AgentResourceDefinition): AgentFixedResourceHandle | AgentResourceTemplateHandle { + registerResource(input: AgentResourceDefinition): AgentResourceHandle | AgentResourceTemplateHandle { if (this.resources.has(input.id)) throw diagnostics.DF0016({ id: input.id }) - const registered = this._projectResource(input, input) - this.resources.set(input.id, registered) - this.events.emit(DEVFRAME_EVENTS.bus.agentResourceRegistered, registered.resource) + 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 (this.resources.get(input.id)?.input === input) + if (isRegistered()) this.unregisterResource(input.id) } - if (registered.kind === 'fixed') { + if (!isResourceTemplate(input)) { return { notifyUpdated: () => { - if (this.resources.get(input.id)?.input === input) - this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, registered.resource.uri) + if (isRegistered()) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, resourceUri(input)) }, unregister, } } return { notifyUpdated: (uri) => { - if (this.resources.get(input.id)?.input === input) + if (isRegistered()) this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, uri) }, unregister, @@ -185,8 +171,15 @@ export class DevframeAgentHost implements DevframeAgentHostType { const rpcTools = this._collectRpcTools() const plainTools = Array.from(this.tools.values()).map(t => t.tool) const resourceDefinitions = this._collectResourceDefinitions() - const resources = resourceDefinitions.flatMap(entry => entry.kind === 'fixed' ? [entry.resource] : []) - const resourceTemplates = resourceDefinitions.flatMap(entry => entry.kind === 'template' ? [entry.resource] : []) + 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)) @@ -216,15 +209,12 @@ export class DevframeAgentHost implements DevframeAgentHostType { } getResource(id: string): AgentResource | undefined { - const registered = this._collectResourceDefinitions().find(entry => - entry.kind === 'fixed' && (entry.resource.id === id || entry.resource.uri === id), + const input = this._collectResourceDefinitions().find(candidate => + !isResourceTemplate(candidate) && (candidate.id === id || resourceUri(candidate) === id), ) - return registered?.kind === 'fixed' ? registered.resource : undefined - } - - getResourceTemplate(id: string): AgentResourceTemplate | undefined { - const registered = this._findResourceDefinition(id) - return registered?.kind === 'template' ? registered.resource : undefined + if (!input || isResourceTemplate(input)) + return undefined + return this._projectResource(input) as AgentResource } async invoke(id: string, args: unknown): Promise { @@ -260,30 +250,30 @@ export class DevframeAgentHost implements DevframeAgentHostType { if (!entry) throw new Error(`[devframe/agent] resource "${id}" not found`) - if (entry.kind === 'fixed') - return await entry.input.read(uri instanceof URL ? uri : new URL(uri ?? entry.resource.uri)) + 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.input.read(uri instanceof URL ? uri : new URL(uri), variables) + return await entry.read(uri instanceof URL ? uri : new URL(uri), variables) } async listResourceInstances(id: string): Promise { const entry = this._findResourceDefinition(id) - if (!entry || entry.kind !== 'template') + if (!entry || !isResourceTemplate(entry)) throw new Error(`[devframe/agent] resource template "${id}" not found`) - return await entry.input.list?.() ?? { resources: [] } + return await entry.list?.() ?? { resources: [] } } - async subscribeResource(id: string, uri: string | URL): Promise { + async subscribeResource(id: string, uri: string | URL): Promise<() => void | Promise> { const entry = this._findResourceDefinition(id) if (!entry) throw new Error(`[devframe/agent] resource "${id}" not found`) - const resourceUri = uri instanceof URL ? uri : new URL(uri) - await entry.input.subscribe?.(resourceUri) + const requestedUri = uri instanceof URL ? uri : new URL(uri) + await entry.subscribe?.(requestedUri) return async () => { - await entry.input.unsubscribe?.(resourceUri) + await entry.unsubscribe?.(requestedUri) } } @@ -324,52 +314,44 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } - private _projectResource(input: AgentResourceDefinition, source: object): RegisteredResource { + private _projectResource(input: AgentResourceDefinition): AgentResource | AgentResourceTemplate { if (isResourceTemplate(input)) { return { - kind: 'template', - input, - resource: attachResourceSource({ - id: input.id, - uriTemplate: input.uriTemplate, - name: input.name, - description: input.description, - mimeType: input.mimeType, - }, source), + id: input.id, + uriTemplate: input.uriTemplate, + name: input.name, + description: input.description, + mimeType: input.mimeType, } } return { - kind: 'fixed', - input, - resource: attachResourceSource({ - id: input.id, - uri: input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}`, - name: input.name, - description: input.description, - mimeType: input.mimeType ?? 'application/json', - }, source), + id: input.id, + uri: resourceUri(input), + name: input.name, + description: input.description, + mimeType: input.mimeType ?? 'application/json', } } - private _collectResourceDefinitions(): RegisteredResource[] { + private _collectResourceDefinitions(): AgentResourceDefinition[] { const resources = Array.from(this.resources.values()) - const seen = new Set(resources.map(resource => resource.resource.id)) + 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(this._projectResource(input, provider)) + resources.push(input) } } return resources } - private _findResourceDefinition(id: string): RegisteredResource | undefined { - return this._collectResourceDefinitions().find(resource => resource.resource.id === id) + private _findResourceDefinition(id: string): AgentResourceDefinition | undefined { + return this._collectResourceDefinitions().find(resource => resource.id === id) } /** Query every registered provider, projecting inputs to serializable tools. */ @@ -418,14 +400,6 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } -function attachResourceSource( - resource: Resource, - source: object, -): Resource & { readonly [AGENT_RESOURCE_SOURCE]: object } { - Object.defineProperty(resource, AGENT_RESOURCE_SOURCE, { value: source }) - return resource as Resource & { readonly [AGENT_RESOURCE_SOURCE]: object } -} - function inferSafety(type: RpcFunctionType): 'read' | 'action' | 'destructive' { if (type === 'static' || type === 'query') return 'read' diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index 0cf20565..dd2cc8fb 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -89,8 +89,8 @@ export interface AgentResourceList { export type AgentResourceVariables = Readonly> -/** A fixed resource accepted by `DevframeAgentHost.registerResource()`. */ -export interface AgentFixedResourceInput { +/** A resource accepted by `DevframeAgentHost.registerResource()`. */ +export interface AgentResourceInput { id: string /** Optional URI override — if omitted, a `devframe://resource/` URI is generated. */ uri?: string @@ -128,10 +128,7 @@ export interface AgentResourceTemplateInput { unsubscribe?: (uri: URL) => void | Promise } -/** Backwards-compatible name for a fixed resource registration. */ -export type AgentResourceInput = AgentFixedResourceInput - -export type AgentResourceDefinition = AgentFixedResourceInput | AgentResourceTemplateInput +export type AgentResourceDefinition = AgentResourceInput | AgentResourceTemplateInput /** * Payload returned by `AgentResourceInput.read`. Either `text` or `json` must be set. @@ -159,7 +156,7 @@ export interface AgentHandle { unregister: () => void } -export interface AgentFixedResourceHandle extends AgentHandle { +export interface AgentResourceHandle extends AgentHandle { notifyUpdated: () => void } @@ -167,10 +164,6 @@ export interface AgentResourceTemplateHandle extends AgentHandle { notifyUpdated: (uri: string) => void } -export type AgentResourceHandle = AgentFixedResourceHandle | AgentResourceTemplateHandle - -export type AgentResourceSubscriptionCleanup = () => void | Promise - /** * A lazy source of agent tools, queried at `list()` / `getTool()` / * `invoke()` time — the same on-demand projection the host applies to @@ -246,12 +239,12 @@ export interface DevframeAgentHost { */ registerToolProvider: (provider: AgentToolProvider) => AgentToolProviderHandle - /** Register a readable fixed resource or URI template. */ + /** Register a readable resource or URI template. */ registerResource: { - (resource: AgentFixedResourceInput): AgentFixedResourceHandle + (resource: AgentResourceInput): AgentResourceHandle (resource: AgentResourceTemplateInput): AgentResourceTemplateHandle } - /** Register a lazy source of fixed resources and URI templates. */ + /** Register a lazy source of resources and URI templates. */ registerResourceProvider: (provider: AgentResourceProvider) => AgentResourceProviderHandle /** Unregister a previously registered resource by id. */ unregisterResource: (id: string) => boolean @@ -269,19 +262,17 @@ export interface DevframeAgentHost { */ invoke: (id: string, args: unknown) => Promise - /** Read a fixed resource or resolved template by id. */ + /** 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 /** Forward one MCP subscription to a resource and capture its cleanup. */ - subscribeResource: (id: string, uri: string | URL) => Promise + subscribeResource: (id: string, uri: string | URL) => Promise<() => void | Promise> /** Look up a tool by id (returns the serializable projection). */ getTool: (id: string) => AgentTool | undefined - /** Look up a fixed resource by id or exact URI. */ + /** Look up a resource by id or exact URI. */ getResource: (id: string) => AgentResource | undefined - /** Look up a resource template by id. */ - getResourceTemplate: (id: string) => AgentResourceTemplate | undefined } // Re-export the options interface for convenience. diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index d627af4c..40507dc6 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -405,7 +405,7 @@ defineRpcFunction({ }) ``` -Or register tools and resources directly. Fixed 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. +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({ diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index d429a392..0c6da8da 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -2,19 +2,6 @@ * Generated by tsnapi — public API snapshot of `devframe` */ // #region Interfaces -export interface AgentFixedResourceHandle extends AgentHandle { - notifyUpdated: () => void; -} -export interface AgentFixedResourceInput { - id: string; - uri?: string; - name: string; - description?: string; - mimeType?: string; - read: (_: URL) => Promise | AgentResourceContent; - subscribe?: (_: URL) => void | Promise; - unsubscribe?: (_: URL) => void | Promise; -} export interface AgentHandle { unregister: () => void; } @@ -35,6 +22,19 @@ 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; + read: (_: URL) => Promise | AgentResourceContent; + subscribe?: (_: URL) => void | Promise; + unsubscribe?: (_: URL) => void | Promise; +} export interface AgentResourceList { resources: readonly AgentResourceListItem[]; } @@ -127,7 +127,7 @@ export interface DevframeAgentHost { unregisterTool: (_: string) => boolean; registerToolProvider: (_: AgentToolProvider) => AgentToolProviderHandle; registerResource: { - (_: AgentFixedResourceInput): AgentFixedResourceHandle; + (_: AgentResourceInput): AgentResourceHandle; (_: AgentResourceTemplateInput): AgentResourceTemplateHandle; }; registerResourceProvider: (_: AgentResourceProvider) => AgentResourceProviderHandle; @@ -136,10 +136,9 @@ export interface DevframeAgentHost { invoke: (_: string, _: unknown) => Promise; read: (_: string, _?: string | URL, _?: AgentResourceVariables) => Promise; listResourceInstances: (_: string) => Promise; - subscribeResource: (_: string, _: string | URL) => Promise; + subscribeResource: (_: string, _: string | URL) => Promise<() => void | Promise>; getTool: (_: string) => AgentTool | undefined; getResource: (_: string) => AgentResource | undefined; - getResourceTemplate: (_: string) => AgentResourceTemplate | undefined; } export interface DevframeAgentHostEvents { 'agent:tool:registered': (_: AgentTool) => void; @@ -514,12 +513,9 @@ export interface ScopedBroadcastOptions { // #endregion // #region Types -export type AgentResourceDefinition = AgentFixedResourceInput | AgentResourceTemplateInput; -export type AgentResourceHandle = AgentFixedResourceHandle | AgentResourceTemplateHandle; -export type AgentResourceInput = AgentFixedResourceInput; +export type AgentResourceDefinition = AgentResourceInput | AgentResourceTemplateInput; export type AgentResourceListItem = Omit; export type AgentResourceProvider = () => readonly AgentResourceDefinition[]; -export type AgentResourceSubscriptionCleanup = () => void | Promise; export type AgentResourceVariables = Readonly>; export type AgentToolProvider = () => readonly AgentToolInput[]; export type DevframeDefineDiagnosticsOptions, Reporters extends readonly AnyDiagnosticReporter[] = []> = Parameters>[0]; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 69a9af09..7930e1d9 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -31,18 +31,17 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { registerTool(_: AgentToolInput): AgentHandle; unregisterTool(_: string): boolean; registerToolProvider(_: AgentToolProvider): AgentToolProviderHandle; - registerResource(_: AgentFixedResourceInput): AgentFixedResourceHandle; + registerResource(_: AgentResourceInput): AgentResourceHandle; registerResource(_: AgentResourceTemplateInput): AgentResourceTemplateHandle; registerResourceProvider(_: AgentResourceProvider): AgentResourceProviderHandle; unregisterResource(_: string): boolean; list(): AgentManifest; getTool(_: string): AgentTool | undefined; getResource(_: string): AgentResource | undefined; - getResourceTemplate(_: string): AgentResourceTemplate | undefined; invoke(_: string, _: unknown): Promise; read(_: string, _?: string | URL, _?: AgentResourceVariables): Promise; listResourceInstances(_: string): Promise; - subscribeResource(_: string, _: string | URL): Promise; + subscribeResource(_: string, _: string | URL): Promise<() => void | Promise>; _dispose(): void; private _validateToolId; private _projectTool; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 1b42befd..b958c0fb 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -2,8 +2,6 @@ * Generated by tsnapi — public API snapshot of `devframe/types` */ // #region Other -export { AgentFixedResourceHandle } -export { AgentFixedResourceInput } export { AgentHandle } export { AgentManifest } export { AgentResource } @@ -15,7 +13,6 @@ export { AgentResourceList } export { AgentResourceListItem } export { AgentResourceProvider } export { AgentResourceProviderHandle } -export { AgentResourceSubscriptionCleanup } export { AgentResourceTemplate } export { AgentResourceTemplateHandle } export { AgentResourceTemplateInput } From c7d8767f0a335eb4336705ebd66856d0b5c2de91 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Tue, 25 Aug 2026 19:36:42 +0200 Subject: [PATCH 3/5] fix(devframe): subscribe implicit state resources --- packages/devframe/src/adapters/initiate.ts | 2 +- .../fixtures/resource-stdio-server.ts | 10 ++- .../adapters/mcp/__tests__/mcp-http.test.ts | 52 +++++++++++++++ .../adapters/mcp/__tests__/mcp-server.test.ts | 65 ++++++++++++++++++- .../devframe/src/adapters/mcp/build-server.ts | 63 +++++++++++++----- packages/devframe/src/types/devframe.ts | 6 ++ packages/hub/src/node/initiate.ts | 2 +- .../tsnapi/devframe/index.snapshot.d.ts | 1 + 8 files changed, 181 insertions(+), 20 deletions(-) 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 index b4e264a9..9721a150 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts @@ -8,7 +8,15 @@ const definition: DevframeDefinition = { packageName: '@devframe/resource-stdio-test', homepage: 'https://example.com', description: 'Stdio resource test fixture.', - setup(ctx) { + async setup(ctx) { + const state = await ctx.rpc.sharedState.get('stdio:counter', { + initialValue: { count: 0 }, + }) + ctx.agent.registerTool({ + id: 'increment-state', + description: 'Increment the fixture state.', + handler: () => state.mutate(value => void (value.count += 1)), + }) const fixed = ctx.agent.registerResource({ id: 'status', uri: 'https://example.com/status', 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 204fe8ed..57270bfd 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -126,6 +126,58 @@ describe('mcp adapter (streamable http route)', () => { await client.close() }) + it('pushes subscribed shared-state updates and cleans up on disconnect', async () => { + let updateState: (() => void) | undefined + const started = await boot(defineTestDef({ + async setup(ctx) { + const state = await ctx.rpc.sharedState.get('build:status', { + initialValue: { revision: 0 }, + }) + updateState = () => state.mutate(value => void (value.revision += 1)) + }, + })) + const transport = originTransport(started) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + const notifications: string[] = [] + client.setNotificationHandler('notifications/resources/updated', (notification) => { + notifications.push(notification.params.uri) + }) + + await client.connect(transport) + const uri = 'devframe://state/build%3Astatus' + await client.subscribeResource({ uri }) + updateState!() + await vi.waitFor(() => expect(notifications).toEqual([uri])) + + await transport.terminateSession() + updateState!() + expect(notifications).toEqual([uri]) + 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' }) + 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('tears the session down on DELETE and rejects reuse of the id', async () => { const started = await boot() const url = `${started.origin}/__mcp` 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 01dfe6e0..c8c366a3 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -471,6 +471,60 @@ describe('mcp adapter (in-memory)', () => { } }) + it('subscribes to shared-state updates and removes the listener on unsubscribe', async () => { + const { ctx, client, cleanup } = await bootPair() + const notifications: string[] = [] + client.setNotificationHandler('notifications/resources/updated', (notification) => { + notifications.push(notification.params.uri) + }) + try { + const state = await ctx.rpc.sharedState.get('my-plugin:counter', { + initialValue: { count: 0 }, + }) + const uri = `devframe://state/${encodeURIComponent('my-plugin:counter')}` + + await Promise.all([ + client.subscribeResource({ uri }), + client.subscribeResource({ uri }), + ]) + state.mutate(value => void (value.count += 1)) + await vi.waitFor(() => expect(notifications).toEqual([uri])) + + await client.unsubscribeResource({ uri }) + state.mutate(value => void (value.count += 1)) + await client.listResources() + expect(notifications).toEqual([uri]) + } + finally { + await cleanup() + } + }) + + it('rejects hidden, missing, and malformed shared-state subscriptions', async () => { + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() }) + await ctx.rpc.sharedState.get('visible:key', { initialValue: { value: true } }) + await ctx.rpc.sharedState.get('hidden:key', { initialValue: { value: false } }) + const { server, dispose } = buildMcpServerFromContext(ctx, { + serverName: 'test', + serverVersion: '0.0.0-test', + exposeSharedState: key => key.startsWith('visible:'), + }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await client.connect(clientTransport) + try { + await expect(client.subscribeResource({ uri: 'devframe://state/hidden%3Akey' })).rejects.toThrow('unknown resource URI') + await expect(client.subscribeResource({ uri: 'devframe://state/missing' })).rejects.toThrow('unknown resource URI') + await expect(client.subscribeResource({ uri: 'devframe://state/%E0%A4%A' })).rejects.toThrow('unknown resource URI') + } + finally { + await client.close() + await server.close() + await dispose() + } + }) + it('omits non-object output schemas (MCP requires type: "object")', async () => { const { ctx, client, cleanup } = await bootPair() try { @@ -598,6 +652,7 @@ describe('mcp adapter (stdio)', () => { 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}']) @@ -611,7 +666,15 @@ describe('mcp adapter (stdio)', () => { expect(JSON.parse((template.contents[0] as { text: string }).text)).toEqual({ process: 'worker' }) await client.subscribeResource({ uri: 'https://example.com/status' }) - await vi.waitFor(() => expect(updates).toEqual(['https://example.com/status'])) + await client.subscribeResource({ uri: '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', + ]))) } finally { await client.close() diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index d882952b..a4ee86a1 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -303,7 +303,11 @@ function registerResourceHandlers( ctx: DevframeNodeContext, exposeSharedState: boolean | ((key: string) => boolean), ): () => Promise { - const subscriptions = new Map void | Promise>() + const stateFilter = sharedStateFilter(exposeSharedState) + const subscriptions = new Map void | Promise + }>() let subscriptionOperations = Promise.resolve() const runSubscriptionOperation = (operation: () => Promise): Promise => { const result = subscriptionOperations.then(operation) @@ -325,10 +329,9 @@ function registerResourceHandlers( resources.push(...listed.resources) } - if (exposeSharedState !== false) { - const filter = typeof exposeSharedState === 'function' ? exposeSharedState : () => true + if (stateFilter) { for (const key of ctx.rpc.sharedState.keys()) { - if (!filter(key)) + if (!stateFilter(key)) continue resources.push({ uri: `devframe://state/${encodeURIComponent(key)}`, @@ -370,7 +373,7 @@ function registerResourceHandlers( } const parsed = parseResourceUri(uri) - if (parsed.kind === 'state') { + 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: [ @@ -393,11 +396,23 @@ function registerResourceHandlers( return {} const resource = resolveAgentResource(ctx, uri) - if (!resource) + if (resource) { + const cleanup = await ctx.agent.subscribeResource(resource.id, uri) + subscriptions.set(uri, { kind: 'agent', cleanup }) + return {} + } + + const parsed = parseResourceUri(uri) + if (parsed.kind !== 'state' || !stateFilter?.(parsed.key) || !ctx.rpc.sharedState.keys().includes(parsed.key)) throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`) - const cleanup = await ctx.agent.subscribeResource(resource.id, uri) - subscriptions.set(uri, cleanup) + const state = await ctx.rpc.sharedState.get(parsed.key) + const cleanup = state.on('updated', () => { + if (!subscriptions.has(uri)) + return + void server.sendResourceUpdated({ uri }).catch(() => { /* ignore transport errors */ }) + }) + subscriptions.set(uri, { kind: 'state', cleanup }) return {} }) }) @@ -405,12 +420,12 @@ function registerResourceHandlers( server.setRequestHandler('resources/unsubscribe', async (request) => { const { uri } = request.params return await runSubscriptionOperation(async () => { - const cleanup = subscriptions.get(uri) - if (!cleanup) + const subscription = subscriptions.get(uri) + if (!subscription) return {} subscriptions.delete(uri) - await cleanup() + await subscription.cleanup() return {} }) }) @@ -423,13 +438,23 @@ function registerResourceHandlers( const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { void runSubscriptionOperation(async () => { - for (const [uri, cleanup] of [...subscriptions]) { + for (const [uri, subscription] of [...subscriptions]) { + if (subscription.kind === 'state') + continue + try { + await subscription.cleanup() + } + catch { + continue + } subscriptions.delete(uri) - await cleanup() const resource = resolveAgentResource(ctx, uri) if (!resource) continue - subscriptions.set(uri, await ctx.agent.subscribeResource(resource.id, uri)) + subscriptions.set(uri, { + kind: 'agent', + cleanup: await ctx.agent.subscribeResource(resource.id, uri), + }) } }).catch(() => { /* ignore subscription cleanup errors during reconciliation */ }) }) @@ -440,7 +465,7 @@ function registerResourceHandlers( await runSubscriptionOperation(async () => { const active = [...subscriptions.values()] subscriptions.clear() - await Promise.all(active.map(cleanup => cleanup())) + await Promise.all(active.map(subscription => subscription.cleanup())) }) } } @@ -520,7 +545,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/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/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 7268e3c3..3373b309 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/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 0c6da8da..577e771e 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -428,6 +428,7 @@ export interface EventUnsubscribe { export interface McpRouteOptions { path?: string; allowedOrigins?: readonly string[] | false; + exposeSharedState?: boolean | ((_: string) => boolean); } export interface RemoteAssets { package: string; From 4ef39f7b4050ee746c47e7e4a0589d03b32e04eb Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Tue, 25 Aug 2026 19:40:24 +0200 Subject: [PATCH 4/5] fix(devframe): address resource review feedback --- packages/devframe/src/adapters/mcp/build-server.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index a4ee86a1..6a0c893d 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -324,8 +324,10 @@ function registerResourceHandlers( mimeType: resource.mimeType, })) - for (const template of manifest.resourceTemplates) { - const listed = await ctx.agent.listResourceInstances(template.id) + const listedTemplateResources = await Promise.all( + manifest.resourceTemplates.map(template => ctx.agent.listResourceInstances(template.id)), + ) + for (const listed of listedTemplateResources) { resources.push(...listed.resources) } @@ -424,8 +426,8 @@ function registerResourceHandlers( if (!subscription) return {} - subscriptions.delete(uri) await subscription.cleanup() + subscriptions.delete(uri) return {} }) }) From 37b544318d3014343d59528f7e80047b28112633 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Tue, 25 Aug 2026 20:11:35 +0200 Subject: [PATCH 5/5] fix(devframe): await MCP session cleanup --- .../adapters/mcp/__tests__/mcp-http.test.ts | 21 +++++- .../adapters/mcp/__tests__/mcp-server.test.ts | 70 +++++++++++++++++++ .../devframe/src/adapters/mcp/build-server.ts | 42 +++++++---- packages/devframe/src/adapters/mcp/fetch.ts | 44 +++++++++--- 4 files changed, 150 insertions(+), 27 deletions(-) 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 57270bfd..2c2a995c 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -92,7 +92,10 @@ describe('mcp adapter (streamable http route)', () => { it('keeps resource subscriptions session-local and cleans them up on disconnect', async () => { const subscribe = vi.fn() - const unsubscribe = vi.fn() + let completeCleanup!: () => void + const unsubscribe = vi.fn(() => new Promise((resolve) => { + completeCleanup = resolve + })) let notifyUpdated: (() => void) | undefined const started = await boot(defineTestDef({ setup(ctx) { @@ -121,8 +124,22 @@ describe('mcp adapter (streamable http route)', () => { notifyUpdated!() await vi.waitFor(() => expect(notifications).toEqual(['devframe://resource/build-status'])) - await transport.terminateSession() + const termination = fetch(`${started.origin}/__mcp`, { + method: 'DELETE', + headers: { + 'origin': started.origin, + 'mcp-session-id': transport.sessionId!, + }, + }) + const terminationSettled = vi.fn() + void termination.then(terminationSettled, terminationSettled) await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + await Promise.resolve() + expect(terminationSettled).not.toHaveBeenCalled() + + completeCleanup() + const terminationResponse = await termination + expect(terminationResponse.ok).toBe(true) await client.close() }) 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 c8c366a3..b18ecf8a 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -32,6 +32,7 @@ async function bootPair() { return { ctx, client, + dispose, cleanup: async () => { await client.close() await server.close() @@ -373,6 +374,30 @@ describe('mcp adapter (in-memory)', () => { } }) + it('keeps a subscription tracked when cleanup fails so unsubscribe can retry', async () => { + const { ctx, client, cleanup } = await bootPair() + const unsubscribe = vi.fn() + .mockRejectedValueOnce(new Error('cleanup failed')) + .mockResolvedValueOnce(undefined) + try { + ctx.agent.registerResource({ + id: 'retry-cleanup', + name: 'Retry cleanup', + read: () => ({ text: 'live' }), + unsubscribe, + }) + + const uri = 'devframe://resource/retry-cleanup' + await client.subscribeResource({ uri }) + await expect(client.unsubscribeResource({ uri })).rejects.toThrow('cleanup failed') + await client.unsubscribeResource({ uri }) + expect(unsubscribe).toHaveBeenCalledTimes(2) + } + finally { + await cleanup() + } + }) + it('releases a subscription when its provider resource disappears', async () => { const { ctx, client, cleanup } = await bootPair() const subscribe = vi.fn() @@ -447,6 +472,51 @@ describe('mcp adapter (in-memory)', () => { expect(unsubscribe).toHaveBeenCalledOnce() }) + it('waits for every subscription cleanup before reporting a disposal failure', async () => { + const { ctx, client, dispose, cleanup } = await bootPair() + let completeSlowCleanup!: () => void + const slowCleanup = vi.fn(() => new Promise((resolve) => { + completeSlowCleanup = resolve + })) + const failingCleanup = vi.fn() + .mockRejectedValueOnce(new Error('cleanup failed')) + .mockResolvedValueOnce(undefined) + + ctx.agent.registerResource({ + id: 'slow-cleanup', + name: 'Slow cleanup', + read: () => ({ text: 'slow' }), + unsubscribe: slowCleanup, + }) + ctx.agent.registerResource({ + id: 'failing-cleanup', + name: 'Failing cleanup', + read: () => ({ text: 'failing' }), + unsubscribe: failingCleanup, + }) + + await client.subscribeResource({ uri: 'devframe://resource/slow-cleanup' }) + await client.subscribeResource({ uri: 'devframe://resource/failing-cleanup' }) + + const firstDisposal = dispose() + const disposalSettled = vi.fn() + void firstDisposal.then(disposalSettled, disposalSettled) + await vi.waitFor(() => { + expect(slowCleanup).toHaveBeenCalledOnce() + expect(failingCleanup).toHaveBeenCalledOnce() + }) + await Promise.resolve() + expect(disposalSettled).not.toHaveBeenCalled() + + completeSlowCleanup() + await expect(firstDisposal).rejects.toThrow('cleanup failed') + await dispose() + expect(slowCleanup).toHaveBeenCalledOnce() + expect(failingCleanup).toHaveBeenCalledTimes(2) + + await cleanup() + }) + it('surfaces shared-state keys as MCP resources', async () => { const { ctx, client, cleanup } = await bootPair() try { diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 6a0c893d..238b8141 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -81,11 +81,18 @@ export function buildMcpServerFromContext( let disposal: Promise | undefined const dispose = (): Promise => { - disposal ??= (async () => { - offManifest() - offKeyAdded() - await disposeResourceHandlers() - })() + if (!disposal) { + const pendingDisposal = (async () => { + offManifest() + offKeyAdded() + await disposeResourceHandlers() + })() + disposal = pendingDisposal + void pendingDisposal.catch(() => { + if (disposal === pendingDisposal) + disposal = undefined + }) + } return disposal } server.onclose = () => { @@ -304,16 +311,22 @@ function registerResourceHandlers( exposeSharedState: boolean | ((key: string) => boolean), ): () => Promise { const stateFilter = sharedStateFilter(exposeSharedState) - const subscriptions = new Map void | Promise - }>() + } + const subscriptions = new Map() let subscriptionOperations = Promise.resolve() const runSubscriptionOperation = (operation: () => Promise): Promise => { const result = subscriptionOperations.then(operation) subscriptionOperations = result.then(() => undefined, () => undefined) return result } + const cleanupSubscription = async (uri: string, subscription: ResourceSubscription): Promise => { + await subscription.cleanup() + if (subscriptions.get(uri) === subscription) + subscriptions.delete(uri) + } server.setRequestHandler('resources/list', async () => { const manifest = ctx.agent.list() @@ -426,8 +439,7 @@ function registerResourceHandlers( if (!subscription) return {} - await subscription.cleanup() - subscriptions.delete(uri) + await cleanupSubscription(uri, subscription) return {} }) }) @@ -444,12 +456,11 @@ function registerResourceHandlers( if (subscription.kind === 'state') continue try { - await subscription.cleanup() + await cleanupSubscription(uri, subscription) } catch { continue } - subscriptions.delete(uri) const resource = resolveAgentResource(ctx, uri) if (!resource) continue @@ -465,9 +476,12 @@ function registerResourceHandlers( offUpdated() offManifest() await runSubscriptionOperation(async () => { - const active = [...subscriptions.values()] - subscriptions.clear() - await Promise.all(active.map(subscription => subscription.cleanup())) + const cleanupResults = await Promise.allSettled( + [...subscriptions].map(([uri, subscription]) => cleanupSubscription(uri, subscription)), + ) + const failedCleanup = cleanupResults.find(result => result.status === 'rejected') + if (failedCleanup) + throw failedCleanup.reason }) } } diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index c5b15a9b..24291382 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -59,14 +59,24 @@ export function createMcpFetchHandler( options: CreateMcpFetchHandlerOptions, ): McpFetchHandler { const sessions = new Map() + const sessionDisposals = new Map>() const allowedOrigins = options.allowedOrigins - function drop(sessionId: string): void { + function drop(sessionId: string): Promise { + const pendingDisposal = sessionDisposals.get(sessionId) + if (pendingDisposal) + return pendingDisposal + const session = sessions.get(sessionId) if (!session) - return + return Promise.resolve() sessions.delete(sessionId) - void session.dispose() + + const disposal = session.dispose().finally(() => { + sessionDisposals.delete(sessionId) + }) + sessionDisposals.set(sessionId, disposal) + return disposal } async function createSession(): Promise { @@ -81,7 +91,7 @@ export function createMcpFetchHandler( sessions.set(id, session) }, onsessionclosed: (id) => { - drop(id) + void drop(id).catch(() => { /* cleanup errors surface through awaited teardown paths */ }) }, }) @@ -94,14 +104,18 @@ export function createMcpFetchHandler( session = { transport, dispose: async () => { - await dispose() - await server.close() + try { + await dispose() + } + finally { + await server.close() + } }, } transport.onclose = () => { if (transport.sessionId) - drop(transport.sessionId) + void drop(transport.sessionId).catch(() => { /* cleanup errors surface through awaited teardown paths */ }) } await server.connect(transport) @@ -159,15 +173,23 @@ export function createMcpFetchHandler( ) } - return session.transport.handleRequest(req) + const response = await session.transport.handleRequest(req) + if (req.method === 'DELETE' && sessionId) + await drop(sessionId) + return response } return { fetch: handle, dispose: async () => { - const live = [...sessions.values()] - sessions.clear() - await Promise.all(live.map(session => session.dispose())) + const pendingDisposals = new Set([ + ...[...sessions.keys()].map(sessionId => drop(sessionId)), + ...sessionDisposals.values(), + ]) + const disposalResults = await Promise.allSettled(pendingDisposals) + const failedDisposal = disposalResults.find(result => result.status === 'rejected') + if (failedDisposal) + throw failedDisposal.reason }, } }