diff --git a/docs/content/1.guide/14.agent-native.md b/docs/content/1.guide/14.agent-native.md index fcf3c122..8e85cfcd 100644 --- a/docs/content/1.guide/14.agent-native.md +++ b/docs/content/1.guide/14.agent-native.md @@ -94,6 +94,71 @@ ctx.agent.registerResource({ }) ``` +Devframe assigns `devframe://resource/` by default. Set `uri` to expose another URI. `read` runs for every MCP read and may receive the requested `URL`. + +## Registering resource templates + +Templates describe resources whose URI contains variables. Devframe uses the MCP SDK's URI-template parser and passes the parsed variables to `read`. + +```ts +const logsResource = ctx.agent.registerResource({ + id: 'process-logs', + uriTemplate: 'rolldown://logs/{process}', + name: 'Process logs', + mimeType: 'text/plain', + list: () => ({ + resources: runningProcesses().map(process => ({ + uri: `rolldown://logs/${encodeURIComponent(process.name)}`, + name: `${process.name} logs`, + mimeType: 'text/plain', + })), + }), + read: (_uri, variables) => ({ + text: readLogs(String(variables.process)), + }), +}) + +logsResource.notifyUpdated('rolldown://logs/worker') +``` + +MCP exposes templates through `resources/templates/list`. When `list` is present, its concrete entries also appear in `resources/list`. + +## 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 +const buildResource = ctx.agent.registerResource({ + id: 'live-build', + name: 'Live build', + read: () => ({ json: currentBuild() }), + subscribe: uri => buildEvents.retain(uri, () => buildResource.notifyUpdated()), + unsubscribe: uri => buildEvents.release(uri), +}) +``` + +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 + +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 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. ## Starting the MCP server @@ -133,7 +198,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/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 6f2eb3c8..ac61107e 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -321,7 +321,7 @@ export function initDevframe( const mounted = mountMcpHttp(app, context, mcpPath, { serverName: `${def.id} (devframe)`, serverVersion: def.version ?? '0.0.0', - exposeSharedState: true, + exposeSharedState: mcpConfig.exposeSharedState ?? true, allowedOrigins: mcpConfig.allowedOrigins, }) mcpDispose = mounted.dispose diff --git a/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts b/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts new file mode 100644 index 00000000..9721a150 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts @@ -0,0 +1,41 @@ +import type { DevframeDefinition } from '../../../../types/devframe' +import { createMcpServer } from '../../build-server' + +const definition: DevframeDefinition = { + id: 'resource-stdio-test', + name: 'Resource stdio test', + version: '1.0.0', + packageName: '@devframe/resource-stdio-test', + homepage: 'https://example.com', + description: 'Stdio resource test fixture.', + async setup(ctx) { + const state = await ctx.rpc.sharedState.get('stdio:counter', { + initialValue: { count: 0 }, + }) + 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', + 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..2c2a995c 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,111 @@ describe('mcp adapter (streamable http route)', () => { } }) + it('keeps resource subscriptions session-local and cleans them up on disconnect', async () => { + const subscribe = 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) { + 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'])) + + 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() + }) + + 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 fddaa92d..b18ecf8a 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 { @@ -30,10 +32,11 @@ async function bootPair() { return { ctx, client, + dispose, cleanup: async () => { - dispose() await client.close() await server.close() + await dispose() }, } } @@ -232,6 +235,288 @@ describe('mcp adapter (in-memory)', () => { } }) + it('reads resources from their explicit URI', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + const read = vi.fn((uri: URL) => ({ json: { uri: uri.toString() } })) + ctx.agent.registerResource({ + id: 'current-build', + uri: 'https://example.com/build/current', + name: 'Current build', + read, + }) + + const listed = await client.listResources() + expect(listed.resources.map(resource => resource.uri)).toContain('https://example.com/build/current') + const result = await client.readResource({ uri: 'https://example.com/build/current' }) + const content = result.contents[0] as { text: string } + expect(JSON.parse(content.text)).toEqual({ uri: 'https://example.com/build/current' }) + expect(read).toHaveBeenCalledWith(new URL('https://example.com/build/current')) + } + finally { + await cleanup() + } + }) + + it('lists templates and their concrete resources, then parses variables on read', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + const read = vi.fn((uri: URL, variables: Readonly>) => ({ + json: { uri: uri.toString(), variables }, + })) + ctx.agent.registerResource({ + id: 'logs', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + description: 'Logs by process name.', + mimeType: 'application/json', + list: () => ({ + resources: [{ uri: 'devframe://logs/app', name: 'App logs', mimeType: 'application/json' }], + }), + read, + }) + + const templates = await client.listResourceTemplates() + expect(templates.resourceTemplates).toContainEqual({ + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + description: 'Logs by process name.', + mimeType: 'application/json', + }) + const resources = await client.listResources() + expect(resources.resources).toContainEqual({ + uri: 'devframe://logs/app', + name: 'App logs', + mimeType: 'application/json', + }) + + const result = await client.readResource({ uri: 'devframe://logs/worker' }) + const content = result.contents[0] as { text: string } + expect(JSON.parse(content.text)).toEqual({ + uri: 'devframe://logs/worker', + variables: { name: 'worker' }, + }) + expect(read).toHaveBeenCalledWith(new URL('devframe://logs/worker'), { name: 'worker' }) + } + finally { + await cleanup() + } + }) + + it('resolves an exact resource URI before a matching template', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerResource({ + id: 'logs-template', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + read: () => ({ text: 'template' }), + }) + ctx.agent.registerResource({ + id: 'fixed-log', + uri: 'devframe://logs/app', + name: 'App log', + read: () => ({ text: 'fixed' }), + }) + + const result = await client.readResource({ uri: 'devframe://logs/app' }) + const content = result.contents[0] as { text: string } + expect(content.text).toBe('fixed') + } + finally { + await cleanup() + } + }) + + it('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('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() + 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('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 { @@ -256,6 +541,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 { @@ -328,7 +667,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 +694,60 @@ 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 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, + 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', + 'devframe://state/stdio%3Acounter', + ])) + const templates = await client.listResourceTemplates() + expect(templates.resourceTemplates.map(template => template.uriTemplate)).toEqual(['devframe://logs/{name}']) + + const fixed = await client.readResource({ uri: 'https://example.com/status' }) + expect(JSON.parse((fixed.contents[0] as { text: string }).text)).toEqual({ + uri: 'https://example.com/status', + status: 'ok', + }) + const template = await client.readResource({ uri: 'devframe://logs/worker' }) + expect(JSON.parse((template.contents[0] as { text: string }).text)).toEqual({ process: 'worker' }) + + await client.subscribeResource({ uri: '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 69f26a39..238b8141 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -1,10 +1,10 @@ -import type { Tool } from '@modelcontextprotocol/server' +import type { 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' @@ -42,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. * @@ -51,7 +51,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 +60,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 +79,29 @@ export function buildMcpServerFromContext( notify('notifications/resources/list_changed') }) + let disposal: Promise | undefined + const dispose = (): Promise => { + if (!disposal) { + const pendingDisposal = (async () => { + offManifest() + offKeyAdded() + await disposeResourceHandlers() + })() + disposal = pendingDisposal + void pendingDisposal.catch(() => { + if (disposal === pendingDisposal) + disposal = undefined + }) + } + return disposal + } + server.onclose = () => { + void dispose().catch(() => { /* ignore resource cleanup errors after transport closure */ }) + } + return { server, - dispose: () => { - offManifest() - offKeyAdded() - }, + dispose, } } @@ -144,7 +161,7 @@ export async function createMcpServer( return { async stop() { - dispose() + await dispose() await stop() }, } @@ -292,19 +309,44 @@ function registerResourceHandlers( server: Server, ctx: DevframeNodeContext, exposeSharedState: boolean | ((key: string) => boolean), -): void { +): () => Promise { + const stateFilter = sharedStateFilter(exposeSharedState) + interface ResourceSubscription { + kind: 'agent' | 'state' + 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 + } + 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 resources = ctx.agent.list().resources.map(resource => ({ + const manifest = ctx.agent.list() + const resources: Resource[] = manifest.resources.map(resource => ({ uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mimeType, })) - if (exposeSharedState !== false) { - const filter = typeof exposeSharedState === 'function' ? exposeSharedState : () => true + const listedTemplateResources = await Promise.all( + manifest.resourceTemplates.map(template => ctx.agent.listResourceInstances(template.id)), + ) + for (const listed of listedTemplateResources) { + resources.push(...listed.resources) + } + + if (stateFilter) { for (const key of ctx.rpc.sharedState.keys()) { - if (!filter(key)) + if (!stateFilter(key)) continue resources.push({ uri: `devframe://state/${encodeURIComponent(key)}`, @@ -318,12 +360,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,7 +387,8 @@ function registerResourceHandlers( } } - if (parsed.kind === 'state') { + const parsed = parseResourceUri(uri) + if (parsed.kind === 'state' && stateFilter?.(parsed.key) && ctx.rpc.sharedState.keys().includes(parsed.key)) { const state = await ctx.rpc.sharedState.get(parsed.key) return { contents: [ @@ -350,6 +403,107 @@ 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) { + 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 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 {} + }) + }) + + server.setRequestHandler('resources/unsubscribe', async (request) => { + const { uri } = request.params + return await runSubscriptionOperation(async () => { + const subscription = subscriptions.get(uri) + if (!subscription) + return {} + + await cleanupSubscription(uri, subscription) + 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]) { + if (subscription.kind === 'state') + continue + try { + await cleanupSubscription(uri, subscription) + } + catch { + continue + } + const resource = resolveAgentResource(ctx, uri) + if (!resource) + continue + subscriptions.set(uri, { + kind: 'agent', + cleanup: await ctx.agent.subscribeResource(resource.id, uri), + }) + } + }).catch(() => { /* ignore subscription cleanup errors during reconciliation */ }) + }) + + return async () => { + offUpdated() + offManifest() + await runSubscriptionOperation(async () => { + 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 + }) + } +} + +function resolveAgentResource( + ctx: DevframeNodeContext, + uri: string, +): { id: string, variables: Variables } | undefined { + const manifest = ctx.agent.list() + 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) + if (variables) { + return { + id: template.id, + variables, + } + } + } } /** @@ -407,7 +561,13 @@ function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kin if (!match) return { kind: 'unknown' } const [, kind, rest] = match - const decoded = decodeURIComponent(rest!) + let decoded: string + try { + decoded = decodeURIComponent(rest!) + } + catch { + return { kind: 'unknown' } + } if (kind === 'resource') return { kind: 'resource', id: decoded } return { kind: 'state', key: decoded } diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index 32a8a482..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 () => { - 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 }, } } 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..2146a62b 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 resource and template handles', () => { + const ctx = createContext() + const updated = vi.fn() + ctx.agent.events.on('agent:resource:updated', updated) + const fixed = ctx.agent.registerResource({ + id: 'fixed', + uri: 'https://example.com/fixed', + name: 'Fixed', + read: () => ({ text: 'fixed' }), + }) + const template = ctx.agent.registerResource({ + id: 'template', + uriTemplate: 'https://example.com/{name}', + name: 'Template', + read: () => ({ text: 'template' }), + }) + + fixed.notifyUpdated() + template.notifyUpdated('https://example.com/one') + expect(updated).toHaveBeenNthCalledWith(1, 'https://example.com/fixed') + expect(updated).toHaveBeenNthCalledWith(2, 'https://example.com/one') + + fixed.unregister() + template.unregister() + 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..72542057 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -4,7 +4,16 @@ import type { AgentManifest, AgentResource, AgentResourceContent, + AgentResourceDefinition, + AgentResourceHandle, AgentResourceInput, + AgentResourceList, + AgentResourceProvider, + AgentResourceProviderHandle, + AgentResourceTemplate, + AgentResourceTemplateHandle, + AgentResourceTemplateInput, + AgentResourceVariables, AgentTool, AgentToolInput, AgentToolProvider, @@ -25,9 +34,12 @@ interface RegisteredTool { readonly handler?: (args: any) => unknown | Promise } -interface RegisteredResource { - readonly resource: AgentResource - readonly read: () => Promise | AgentResourceContent +function isResourceTemplate(input: AgentResourceDefinition): input is AgentResourceTemplateInput { + return 'uriTemplate' in input +} + +function resourceUri(input: AgentResourceInput): string { + return input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}` } /** @@ -40,8 +52,9 @@ export class DevframeAgentHost implements DevframeAgentHostType { public readonly events: EventEmitter = createEventEmitter() private readonly tools = new Map() - private readonly resources = new Map() - private readonly providers = new Set() + private readonly resources = new Map() + private readonly toolProviders = new Set() + private readonly resourceProviders = new Set() private _rpcUnsubscribe: (() => void) | undefined constructor( @@ -76,39 +89,72 @@ export class DevframeAgentHost implements DevframeAgentHostType { } registerToolProvider(provider: AgentToolProvider): AgentToolProviderHandle { - this.providers.add(provider) + this.toolProviders.add(provider) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) const notifyChanged = (): void => { - if (this.providers.has(provider)) + if (this.toolProviders.has(provider)) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) } return { notifyChanged, unregister: () => { - if (this.providers.delete(provider)) + if (this.toolProviders.delete(provider)) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) }, } } - registerResource(input: AgentResourceInput): AgentHandle { + registerResource(input: AgentResourceInput): AgentResourceHandle + registerResource(input: AgentResourceTemplateInput): AgentResourceTemplateHandle + registerResource(input: AgentResourceDefinition): AgentResourceHandle | AgentResourceTemplateHandle { if (this.resources.has(input.id)) throw diagnostics.DF0016({ id: input.id }) - const resource: AgentResource = { - id: input.id, - name: input.name, - description: input.description, - mimeType: input.mimeType ?? 'application/json', - uri: input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}`, + this.resources.set(input.id, input) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceRegistered, this._projectResource(input)) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) + + const isRegistered = (): boolean => this.resources.get(input.id) === input + const unregister = (): void => { + if (isRegistered()) + this.unregisterResource(input.id) + } + if (!isResourceTemplate(input)) { + return { + notifyUpdated: () => { + if (isRegistered()) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, resourceUri(input)) + }, + unregister, + } + } + return { + notifyUpdated: (uri) => { + if (isRegistered()) + 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 +170,16 @@ export class DevframeAgentHost implements DevframeAgentHostType { list(): AgentManifest { const rpcTools = this._collectRpcTools() const plainTools = Array.from(this.tools.values()).map(t => t.tool) - const resources = Array.from(this.resources.values()).map(r => r.resource) + const resourceDefinitions = this._collectResourceDefinitions() + const resources: AgentResource[] = [] + const resourceTemplates: AgentResourceTemplate[] = [] + for (const input of resourceDefinitions) { + const resource = this._projectResource(input) + if (isResourceTemplate(input)) + resourceTemplates.push(resource as AgentResourceTemplate) + else + resources.push(resource as AgentResource) + } // Provider tools are queried lazily; earlier sources win on id collision. const seen = new Set([...rpcTools, ...plainTools].map(t => t.id)) @@ -139,6 +194,7 @@ export class DevframeAgentHost implements DevframeAgentHostType { return { tools: [...rpcTools, ...plainTools, ...providerTools], resources, + resourceTemplates, } } @@ -153,7 +209,12 @@ export class DevframeAgentHost implements DevframeAgentHostType { } getResource(id: string): AgentResource | undefined { - return this.resources.get(id)?.resource + const input = this._collectResourceDefinitions().find(candidate => + !isResourceTemplate(candidate) && (candidate.id === id || resourceUri(candidate) === id), + ) + if (!input || isResourceTemplate(input)) + return undefined + return this._projectResource(input) as AgentResource } async invoke(id: string, args: unknown): Promise { @@ -180,11 +241,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 (!isResourceTemplate(entry)) + return await entry.read(uri instanceof URL ? uri : new URL(uri ?? resourceUri(entry))) + + if (!uri) + throw new Error(`[devframe/agent] resource template "${id}" requires a URI`) + return await entry.read(uri instanceof URL ? uri : new URL(uri), variables) + } + + async listResourceInstances(id: string): Promise { + const entry = this._findResourceDefinition(id) + if (!entry || !isResourceTemplate(entry)) + throw new Error(`[devframe/agent] resource template "${id}" not found`) + return await entry.list?.() ?? { resources: [] } + } + + 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 requestedUri = uri instanceof URL ? uri : new URL(uri) + await entry.subscribe?.(requestedUri) + return async () => { + await entry.unsubscribe?.(requestedUri) + } } /** @internal */ @@ -224,10 +314,50 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } + private _projectResource(input: AgentResourceDefinition): AgentResource | AgentResourceTemplate { + if (isResourceTemplate(input)) { + return { + id: input.id, + uriTemplate: input.uriTemplate, + name: input.name, + description: input.description, + mimeType: input.mimeType, + } + } + + return { + id: input.id, + uri: resourceUri(input), + name: input.name, + description: input.description, + mimeType: input.mimeType ?? 'application/json', + } + } + + private _collectResourceDefinitions(): AgentResourceDefinition[] { + const resources = Array.from(this.resources.values()) + const seen = new Set(resources.map(resource => resource.id)) + + for (const provider of this.resourceProviders) { + for (const input of provider()) { + if (seen.has(input.id)) + continue + seen.add(input.id) + resources.push(input) + } + } + + return resources + } + + private _findResourceDefinition(id: string): AgentResourceDefinition | undefined { + return this._collectResourceDefinitions().find(resource => resource.id === id) + } + /** Query every registered provider, projecting inputs to serializable tools. */ private _collectProviderTools(): { input: AgentToolInput, tool: AgentTool }[] { const out: { input: AgentToolInput, tool: AgentTool }[] = [] - for (const provider of this.providers) { + for (const provider of this.toolProviders) { for (const input of provider()) out.push({ input, tool: this._projectTool(input) }) } diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index b897619c..dd2cc8fb 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -80,20 +80,56 @@ export interface AgentResource { mimeType?: string } -/** - * Input accepted by `DevframeAgentHost.registerResource()`. - */ +/** One concrete resource returned by a resource template's list callback. */ +export type AgentResourceListItem = Omit + +export interface AgentResourceList { + resources: readonly AgentResourceListItem[] +} + +export type AgentResourceVariables = Readonly> + +/** A resource accepted by `DevframeAgentHost.registerResource()`. */ export interface AgentResourceInput { id: string + /** Optional URI override — if omitted, a `devframe://resource/` URI is generated. */ + uri?: string name: string description?: string mimeType?: string - /** Optional URI override — if omitted, a `devframe://resource/` URI is generated. */ - uri?: string /** Snapshot reader. Called on each read. */ - read: () => Promise | AgentResourceContent + read: (uri: URL) => Promise | AgentResourceContent + 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 +} + +export type AgentResourceDefinition = AgentResourceInput | AgentResourceTemplateInput + /** * Payload returned by `AgentResourceInput.read`. Either `text` or `json` must be set. */ @@ -110,6 +146,7 @@ export interface AgentResourceContent { export interface AgentManifest { tools: readonly AgentTool[] resources: readonly AgentResource[] + resourceTemplates: readonly AgentResourceTemplate[] } /** @@ -119,6 +156,14 @@ export interface AgentHandle { unregister: () => void } +export interface AgentResourceHandle extends AgentHandle { + notifyUpdated: () => void +} + +export interface AgentResourceTemplateHandle extends AgentHandle { + notifyUpdated: (uri: string) => void +} + /** * A lazy source of agent tools, queried at `list()` / `getTool()` / * `invoke()` time — the same on-demand projection the host applies to @@ -144,14 +189,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 +239,13 @@ export interface DevframeAgentHost { */ registerToolProvider: (provider: AgentToolProvider) => AgentToolProviderHandle - /** Register a readable resource. */ - registerResource: (resource: AgentResourceInput) => AgentHandle + /** Register a readable resource or URI template. */ + registerResource: { + (resource: AgentResourceInput): AgentResourceHandle + (resource: AgentResourceTemplateInput): AgentResourceTemplateHandle + } + /** Register a lazy source of resources and URI templates. */ + registerResourceProvider: (provider: AgentResourceProvider) => AgentResourceProviderHandle /** Unregister a previously registered resource by id. */ unregisterResource: (id: string) => boolean @@ -201,12 +262,16 @@ export interface DevframeAgentHost { */ invoke: (id: string, args: unknown) => Promise - /** Read a resource by id. */ - read: (id: string) => Promise + /** Read a resource or resolved template by id. */ + read: (id: string, uri?: string | URL, variables?: AgentResourceVariables) => Promise + /** Enumerate the concrete resources supplied by a template. */ + listResourceInstances: (id: string) => Promise + /** Forward one MCP subscription to a resource and capture its cleanup. */ + 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 resource by id. */ + /** Look up a resource by id or exact URI. */ getResource: (id: string) => AgentResource | undefined } diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 44c5ff7f..cc5a447a 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -110,6 +110,12 @@ export interface McpRouteOptions { * deprecated `allowedHosts`/`allowedOrigins` transport flags). */ allowedOrigins?: readonly string[] | false + /** + * Expose shared-state keys as MCP resources and through the built-in + * `devframe_state_read` tool. Defaults to `true`; pass a predicate to + * expose selected keys. + */ + exposeSharedState?: boolean | ((key: string) => boolean) } export interface DevframeCliOptions { diff --git a/packages/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/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index a474d7c2..40507dc6 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. 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..577e771e 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -8,6 +8,7 @@ export interface AgentHandle { export interface AgentManifest { tools: readonly AgentTool[]; resources: readonly AgentResource[]; + resourceTemplates: readonly AgentResourceTemplate[]; } export interface AgentResource { id: string; @@ -21,13 +22,46 @@ export interface AgentResourceContent { json?: unknown; mimeType?: string; } +export interface AgentResourceHandle extends AgentHandle { + notifyUpdated: () => void; +} export interface AgentResourceInput { id: string; + uri?: string; name: string; description?: string; mimeType?: string; - uri?: string; - read: () => Promise | AgentResourceContent; + read: (_: URL) => Promise | AgentResourceContent; + subscribe?: (_: URL) => void | Promise; + unsubscribe?: (_: URL) => void | Promise; +} +export interface AgentResourceList { + resources: readonly AgentResourceListItem[]; +} +export interface AgentResourceProviderHandle extends AgentHandle { + notifyChanged: () => void; + notifyUpdated: (_: string) => void; +} +export interface AgentResourceTemplate { + id: string; + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; +} +export interface AgentResourceTemplateHandle extends AgentHandle { + notifyUpdated: (_: string) => void; +} +export interface AgentResourceTemplateInput { + id: string; + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; + list?: () => AgentResourceList | Promise; + read: (_: URL, _: AgentResourceVariables) => Promise | AgentResourceContent; + subscribe?: (_: URL) => void | Promise; + unsubscribe?: (_: URL) => void | Promise; } export interface AgentTool { id: string; @@ -92,19 +126,26 @@ export interface DevframeAgentHost { registerTool: (_: AgentToolInput) => AgentHandle; unregisterTool: (_: string) => boolean; registerToolProvider: (_: AgentToolProvider) => AgentToolProviderHandle; - registerResource: (_: AgentResourceInput) => AgentHandle; + registerResource: { + (_: AgentResourceInput): AgentResourceHandle; + (_: AgentResourceTemplateInput): AgentResourceTemplateHandle; + }; + registerResourceProvider: (_: AgentResourceProvider) => AgentResourceProviderHandle; unregisterResource: (_: string) => boolean; list: () => AgentManifest; invoke: (_: string, _: unknown) => Promise; - read: (_: string) => Promise; + read: (_: string, _?: string | URL, _?: AgentResourceVariables) => Promise; + listResourceInstances: (_: string) => Promise; + subscribeResource: (_: string, _: string | URL) => Promise<() => void | Promise>; getTool: (_: string) => AgentTool | undefined; getResource: (_: string) => AgentResource | undefined; } export interface DevframeAgentHostEvents { 'agent:tool:registered': (_: AgentTool) => void; 'agent:tool:unregistered': (_: string) => void; - 'agent:resource:registered': (_: AgentResource) => void; + 'agent:resource:registered': (_: AgentResource | AgentResourceTemplate) => void; 'agent:resource:unregistered': (_: string) => void; + 'agent:resource:updated': (_: string) => void; 'agent:manifest:changed': () => void; } export interface DevframeCapabilities { @@ -387,6 +428,7 @@ export interface EventUnsubscribe { export interface McpRouteOptions { path?: string; allowedOrigins?: readonly string[] | false; + exposeSharedState?: boolean | ((_: string) => boolean); } export interface RemoteAssets { package: string; @@ -472,6 +514,10 @@ export interface ScopedBroadcastOptions { // #endregion // #region Types +export type AgentResourceDefinition = AgentResourceInput | AgentResourceTemplateInput; +export type AgentResourceListItem = Omit; +export type AgentResourceProvider = () => readonly AgentResourceDefinition[]; +export type AgentResourceVariables = Readonly>; export type AgentToolProvider = () => readonly AgentToolInput[]; export type DevframeDefineDiagnosticsOptions, Reporters extends readonly AnyDiagnosticReporter[] = []> = Parameters>[0]; export type DevframeDeploymentKind = 'standalone' | 'hosted'; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 23179e31..7930e1d9 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -24,22 +24,30 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { readonly events: EventEmitter; private readonly tools; private readonly resources; - private readonly providers; + private readonly toolProviders; + private readonly resourceProviders; private _rpcUnsubscribe; constructor(_: DevframeNodeContext); registerTool(_: AgentToolInput): AgentHandle; unregisterTool(_: string): boolean; registerToolProvider(_: AgentToolProvider): AgentToolProviderHandle; - registerResource(_: AgentResourceInput): AgentHandle; + registerResource(_: AgentResourceInput): AgentResourceHandle; + registerResource(_: AgentResourceTemplateInput): AgentResourceTemplateHandle; + registerResourceProvider(_: AgentResourceProvider): AgentResourceProviderHandle; unregisterResource(_: string): boolean; list(): AgentManifest; getTool(_: string): AgentTool | undefined; getResource(_: string): AgentResource | undefined; invoke(_: string, _: unknown): Promise; - read(_: string): Promise; + read(_: string, _?: string | URL, _?: AgentResourceVariables): Promise; + listResourceInstances(_: string): Promise; + subscribeResource(_: string, _: string | URL): Promise<() => void | Promise>; _dispose(): void; private _validateToolId; private _projectTool; + private _projectResource; + private _collectResourceDefinitions; + private _findResourceDefinition; private _collectProviderTools; private _collectRpcTools; private _findRpcDefinition; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 99be3647..b958c0fb 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -6,7 +6,17 @@ export { AgentHandle } export { AgentManifest } export { AgentResource } export { AgentResourceContent } +export { AgentResourceDefinition } +export { AgentResourceHandle } export { AgentResourceInput } +export { AgentResourceList } +export { AgentResourceListItem } +export { AgentResourceProvider } +export { AgentResourceProviderHandle } +export { AgentResourceTemplate } +export { AgentResourceTemplateHandle } +export { AgentResourceTemplateInput } +export { AgentResourceVariables } export { AgentTool } export { AgentToolInput } export { AgentToolProvider }