diff --git a/docs/content/1.guide/15.agent-native.md b/docs/content/1.guide/15.agent-native.md index c10cae2d..e7856bcd 100644 --- a/docs/content/1.guide/15.agent-native.md +++ b/docs/content/1.guide/15.agent-native.md @@ -82,6 +82,32 @@ const handle = ctx.agent.registerToolProvider(() => handle.notifyChanged() // fires tools/list_changed ``` +## Reporting tool progress + +Registered and provider tool handlers receive an optional request-bound invocation context: + +```ts +ctx.agent.registerTool({ + id: 'my-plugin:build', + description: 'Build the current project.', + handler: async (_args, invocation) => { + await invocation?.reportProgress({ + progress: 1, + total: 2, + message: 'Compiling', + }) + await compileProject() + await invocation?.reportProgress({ + progress: 2, + total: 2, + message: 'Complete', + }) + }, +}) +``` + +`progress` and `total` are finite numbers, and `progress` increases with every report in one invocation. Reports are delivered in order while the handler is running. MCP callers that provide a progress token receive `notifications/progress`; the reporter is a no-op for callers without one. Agent-enabled RPC functions retain their existing handler signatures, so request-bound progress belongs on registered and provider tools. + ## Registering a resource Readable snapshots by URI: @@ -96,7 +122,72 @@ ctx.agent.registerResource({ }) ``` -Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/` resource and via the **`devframe:state:read` tool** (wire `devframe_state_read`): no args → key list, `key` → its value. `exposeSharedState: false` (or a filter) on `createMcpServer` opts out. +Devframe assigns `devframe://resource/` by default. Set `uri` to expose another URI. `read` runs for every MCP read and may receive the requested `URL`. + +## Registering resource templates + +Templates describe resources whose URI contains variables. Devframe uses the MCP SDK's URI-template parser and passes the parsed variables to `read`. + +```ts +const logsResource = ctx.agent.registerResource({ + id: 'process-logs', + uriTemplate: 'rolldown://logs/{process}', + name: 'Process logs', + mimeType: 'text/plain', + list: () => ({ + resources: runningProcesses().map(process => ({ + uri: `rolldown://logs/${encodeURIComponent(process.name)}`, + name: `${process.name} logs`, + mimeType: 'text/plain', + })), + }), + read: (_uri, variables) => ({ + text: readLogs(String(variables.process)), + }), +}) + +logsResource.notifyUpdated('rolldown://logs/worker') +``` + +MCP exposes templates through `resources/templates/list`. When `list` is present, its concrete entries also appear in `resources/list`. + +## Publishing resource updates + +Resource handles publish invalidations after their underlying value changes: + +```ts +const buildResource = ctx.agent.registerResource({ + id: 'live-build', + name: 'Live build', + read: () => ({ json: currentBuild() }), +}) + +buildEvents.on('changed', () => buildResource.notifyUpdated()) +``` + +`notifyUpdated()` sends no content. MCP 2026 callers receive the invalidation through `subscriptions/listen` when its `resourceSubscriptions` filter contains the URI, then call `resources/read` for the current value. Legacy MCP callers use `resources/list`, `resources/templates/list`, and `resources/read` to pull current values. + +## Deriving resources from other state + +Resource providers are queried when Devframe lists, resolves, or reads resources. Use them when another registry already owns the definitions. + +```ts +const resources = ctx.agent.registerResourceProvider(() => + currentDatasets().map(dataset => ({ + id: `dataset:${dataset.id}`, + uri: `dataset://${dataset.id}`, + name: dataset.name, + read: () => ({ json: dataset.snapshot() }), + })), +) + +resources.notifyChanged() // resources/list_changed +resources.notifyUpdated('dataset://builds/active') // resources/updated through MCP 2026 subscriptions/listen +``` + +Direct registrations win over providers. Earlier providers win over later providers, and exact resource URIs win over templates. + +Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/` resource and via the **`devframe:state:read` tool** (wire `devframe_state_read`): no args → key list, `key` → its value. MCP 2026 subscriptions receive shared-state invalidations under the same encoded URI. `exposeSharedState: false` (or a filter) on `createMcpServer` opts out. ## Starting the MCP server @@ -135,7 +226,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/6.errors/DF0071.md b/docs/content/6.errors/DF0071.md new file mode 100644 index 00000000..4d7b0693 --- /dev/null +++ b/docs/content/6.errors/DF0071.md @@ -0,0 +1,34 @@ +--- +title: 'DF0071: Invalid Agent Tool Progress' +description: 'Invalid agent tool progress: {reason}.' +--- + +## Message +> Invalid agent tool progress: `{reason}`. + +## Cause +A registered or provider tool reported a non-finite `progress` or `total`, or reported `progress` that did not increase from its previous value in the same invocation. + +## Example + +```ts +handler: async (_args, invocation) => { + await invocation?.reportProgress({ progress: 1, total: 2 }) + await invocation?.reportProgress({ progress: 1, total: 2 }) +} +``` + +## Fix + +Use finite numbers and increase `progress` on every report within one tool invocation. + +```ts +handler: async (_args, invocation) => { + await invocation?.reportProgress({ progress: 1, total: 2 }) + await invocation?.reportProgress({ progress: 2, total: 2 }) +} +``` + +## Source + +- [`packages/devframe/src/node/host-agent.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-agent.ts) - `invokeToolHandler()` validates progress before delivering it. diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index 12d87183..d2c46651 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -78,6 +78,7 @@ Emitted by `devframe` — the framework-neutral host, RPC, streaming, assets, se | [DF0068](/errors/DF0068) | error | Required Service Version Range Not Satisfied | | [DF0069](/errors/DF0069) | warn | Service Version Range Not Satisfied | | [DF0070](/errors/DF0070) | error | Invalid Service | +| [DF0071](/errors/DF0071) | error | Invalid Agent Tool Progress | | [DF0072](/errors/DF0072) | warn | Snapshot Names Unknown RPC Method | | [DF0073](/errors/DF0073) | error | JSON-Render Spec Does Not Match Its Schema | | [DF0074](/errors/DF0074) | error | JSON-Render Schema Is Asynchronous | diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 473bc115..afa489ce 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -68,7 +68,8 @@ Emitted on `ctx.agent.events`; adapters (e.g. the MCP server) re-publish their m |---|---|---| | `agent:manifest:changed` | any tool/resource/provider change | — | | `agent:tool:registered` / `agent:tool:unregistered` | `registerTool` / `unregisterTool` | `AgentTool` / id | -| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` / id | +| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` or `AgentResourceTemplate` / id | +| `agent:resource:updated` | resource or provider handle `notifyUpdated` | concrete URI | ### RPC client connection events diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 6f2eb3c8..ac61107e 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -321,7 +321,7 @@ export function initDevframe( const mounted = mountMcpHttp(app, context, mcpPath, { serverName: `${def.id} (devframe)`, serverVersion: def.version ?? '0.0.0', - exposeSharedState: true, + exposeSharedState: mcpConfig.exposeSharedState ?? true, allowedOrigins: mcpConfig.allowedOrigins, }) mcpDispose = mounted.dispose diff --git a/packages/devframe/src/adapters/mcp/__tests__/fixtures/progress-stdio-server.ts b/packages/devframe/src/adapters/mcp/__tests__/fixtures/progress-stdio-server.ts new file mode 100644 index 00000000..e4ee7395 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/__tests__/fixtures/progress-stdio-server.ts @@ -0,0 +1,24 @@ +import type { DevframeDefinition } from '../../../../types/devframe' +import { createMcpServer } from '../../build-server' + +const definition: DevframeDefinition = { + id: 'progress-stdio-test', + name: 'Progress stdio test', + version: '1.0.0', + packageName: '@devframe/progress-stdio-test', + homepage: 'https://example.com', + description: 'Stdio progress test fixture.', + setup(ctx) { + ctx.agent.registerTool({ + id: 'build', + description: 'Build the project.', + handler: async (_args, invocation) => { + await invocation?.reportProgress({ progress: 1, total: 2, message: 'Compiling' }) + await invocation?.reportProgress({ progress: 2, total: 2, message: 'Testing' }) + return { status: 'complete' } + }, + }) + }, +} + +await createMcpServer(definition, { transport: 'stdio' }) diff --git a/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts b/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts new file mode 100644 index 00000000..5a6b33b9 --- /dev/null +++ b/packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts @@ -0,0 +1,47 @@ +import type { DevframeDefinition } from '../../../../types/devframe' +import { createMcpServer } from '../../build-server' + +const definition: DevframeDefinition = { + id: 'resource-stdio-test', + name: 'Resource stdio test', + version: '1.0.0', + packageName: '@devframe/resource-stdio-test', + homepage: 'https://example.com', + description: 'Stdio resource test fixture.', + async setup(ctx) { + const state = await ctx.rpc.sharedState.get('stdio:counter', { + initialValue: { count: 0 }, + }) + const fixed = ctx.agent.registerResource({ + id: 'status', + uri: 'https://example.com/status', + name: 'Status', + read: uri => ({ json: { uri: uri.toString(), status: 'ok' } }), + }) + const ignored = ctx.agent.registerResource({ + id: 'ignored', + name: 'Ignored', + read: () => ({ json: { ignored: true } }), + }) + ctx.agent.registerTool({ + id: 'increment-state', + description: 'Increment the fixture state.', + handler: () => { + state.mutate(value => void (value.count += 1)) + fixed.notifyUpdated() + ignored.notifyUpdated() + }, + }) + ctx.agent.registerResource({ + id: 'logs', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + list: () => ({ resources: [{ uri: 'devframe://logs/app', name: 'App logs' }] }), + read: (_uri: URL, variables: Readonly>) => ({ + json: { process: variables.name }, + }), + }) + }, +} + +await createMcpServer(definition, { transport: 'stdio' }) diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts index 4f415083..9a567f4c 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -1,7 +1,7 @@ import type { StartedServer } from '../../../node/instance-shell' import type { DevframeDefinition } from '../../../types/devframe' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createDevServer } from '../../dev' function defineTestDef(overrides?: Partial): DevframeDefinition { @@ -82,6 +82,7 @@ describe('mcp adapter (streamable http route)', () => { // `Mcp-Session-Id` — there is no session to key state on. expect(client.getProtocolEra()).toBe('modern') expect(transport.sessionId).toBeUndefined() + expect(client.getServerCapabilities()?.resources).toEqual({ listChanged: true, subscribe: true }) const tools = await client.listTools() expect(tools.tools.map(t => t.name)).toContain('greet') @@ -95,6 +96,163 @@ describe('mcp adapter (streamable http route)', () => { } }) + it('delivers request-bound tool progress before the route-based result', async () => { + expect.assertions(1) + const started = await boot(defineTestDef({ + setup(ctx) { + ctx.agent.registerTool({ + id: 'build', + description: 'Build the project.', + handler: async (_args, invocation) => { + await invocation?.reportProgress({ progress: 1, total: 2, message: 'Compiling' }) + await invocation?.reportProgress({ progress: 2, total: 2, message: 'Testing' }) + return { status: 'complete' } + }, + }) + }, + })) + const client = new Client( + { name: 'progress-test-client', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) + const events: unknown[] = [] + try { + await client.connect(originTransport(started)) + const result = await client.callTool( + { name: 'build', arguments: {} }, + { onprogress: progress => events.push({ type: 'progress', ...progress }) }, + ) + events.push({ type: 'result', structuredContent: result.structuredContent }) + + expect(events).toEqual([ + { type: 'progress', progress: 1, total: 2, message: 'Compiling' }, + { type: 'progress', progress: 2, total: 2, message: 'Testing' }, + { type: 'result', structuredContent: undefined }, + ]) + } + finally { + await client.close() + } + }) + + it('delivers filtered resource updates through modern subscriptions/listen', async () => { + let notifyBuildUpdated!: () => void + let notifyIgnoredUpdated!: () => void + let updateExistingState!: () => void + let createAndUpdateLateState!: () => Promise + const started = await boot(defineTestDef({ + async setup(ctx) { + const build = ctx.agent.registerResource({ + id: 'build', + name: 'Build', + read: () => ({ json: { status: 'ok' } }), + }) + const ignored = ctx.agent.registerResource({ + id: 'ignored', + name: 'Ignored', + read: () => ({ json: { ignored: true } }), + }) + const existingState = await ctx.rpc.sharedState.get('build:status', { + initialValue: { revision: 0 }, + }) + notifyBuildUpdated = build.notifyUpdated + notifyIgnoredUpdated = ignored.notifyUpdated + updateExistingState = () => existingState.mutate(value => void (value.revision += 1)) + createAndUpdateLateState = async () => { + const lateState = await ctx.rpc.sharedState.get('build:late', { + initialValue: { revision: 0 }, + }) + lateState.mutate(value => void (value.revision += 1)) + } + }, + })) + const client = new Client( + { name: 'test-client', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) + const updates: string[] = [] + client.setNotificationHandler('notifications/resources/updated', (notification) => { + updates.push(notification.params.uri) + }) + + await client.connect(originTransport(started)) + const subscription = await client.listen({ + resourceSubscriptions: [ + 'devframe://resource/build', + 'devframe://state/build%3Astatus', + 'devframe://state/build%3Alate', + ], + }) + try { + notifyIgnoredUpdated() + notifyBuildUpdated() + updateExistingState() + await createAndUpdateLateState() + + await vi.waitFor(() => expect(updates).toEqual([ + 'devframe://resource/build', + 'devframe://state/build%3Astatus', + 'devframe://state/build%3Alate', + ])) + } + finally { + await subscription.close() + await client.close() + } + }) + + it('keeps legacy resource access pull-only', async () => { + const started = await boot(defineTestDef({ + setup(ctx) { + ctx.agent.registerResource({ + id: 'build', + name: 'Build', + read: () => ({ json: { status: 'ok' } }), + }) + }, + })) + const client = new Client({ name: 'legacy-test-client', version: '0.0.0' }) + try { + await client.connect(originTransport(started)) + expect(client.getProtocolEra()).toBe('legacy') + expect(client.getServerCapabilities()?.resources).toEqual({}) + + const resources = await client.listResources() + expect(resources.resources.map(resource => resource.uri)).toContain('devframe://resource/build') + const result = await client.readResource({ uri: 'devframe://resource/build' }) + expect(JSON.parse((result.contents[0] as { text: string }).text)).toEqual({ status: 'ok' }) + } + finally { + await client.close() + } + }) + + it('can disable implicit shared-state MCP exposure for the HTTP route', async () => { + server = await createDevServer(defineTestDef({ + async setup(ctx) { + await ctx.rpc.sharedState.get('hidden:state', { initialValue: { value: true } }) + }, + }), { + host: '127.0.0.1', + port: 0, + mcp: { exposeSharedState: false }, + }) + const client = new Client( + { name: 'test-client', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) + try { + await client.connect(originTransport(server)) + const resources = await client.listResources() + const tools = await client.listTools() + expect(resources.resources).toEqual([]) + expect(tools.tools.map(tool => tool.name)).not.toContain('devframe_state_read') + } + finally { + await client.close() + } + }) + it('answers a bare GET with 405 (no session lifecycle)', async () => { const started = await boot() // Stateless serving has no session stream to open — the SDK answers a diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts index cf9f7b4b..699d26bf 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts @@ -1,7 +1,9 @@ import type { DevframeHost } from '../../../types/host' +import { fileURLToPath } from 'node:url' import { Client, InMemoryTransport } from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' import { createHostContext } from 'devframe/node' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildMcpServerFromContext } from '../build-server' function nullHost(): DevframeHost { @@ -19,6 +21,7 @@ async function bootPair() { serverName: 'test', serverVersion: '0.0.0-test', exposeSharedState: true, + era: 'legacy', }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() @@ -206,6 +209,52 @@ describe('mcp adapter (in-memory)', () => { } }) + it('uses a no-op progress reporter when the caller provides no token', async () => { + expect.assertions(1) + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'quiet-progress', + description: 'Report progress without a listening caller.', + handler: async (_args, invocation) => { + await invocation?.reportProgress({ progress: 1, message: 'Running' }) + return 'complete' + }, + }) + + const result = await client.callTool({ name: 'quiet-progress', arguments: {} }) + expect((result.content[0] as { text: string }).text).toBe('complete') + } + finally { + await cleanup() + } + }) + + it('returns DF0071 when tool progress does not increase', async () => { + expect.assertions(2) + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'invalid-progress', + description: 'Report invalid progress.', + handler: async (_args, invocation) => { + await invocation?.reportProgress({ progress: 1 }) + await invocation?.reportProgress({ progress: 1 }) + }, + }) + + const result = await client.callTool( + { name: 'invalid-progress', arguments: {} }, + { onprogress: () => {} }, + ) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('DF0071') + } + finally { + await cleanup() + } + }) + it('lists and reads registered resources', async () => { const { ctx, client, cleanup } = await bootPair() try { @@ -217,6 +266,7 @@ describe('mcp adapter (in-memory)', () => { }) const listed = await client.listResources() + expect(client.getServerCapabilities()?.resources).toEqual({}) const resource = listed.resources.find(r => r.uri === 'devframe://resource/build-status') expect(resource).toBeDefined() expect(resource!.name).toBe('Build status') @@ -231,6 +281,99 @@ describe('mcp adapter (in-memory)', () => { } }) + it('reads resources from their explicit URI', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + const read = vi.fn((uri: URL) => ({ json: { uri: uri.toString() } })) + ctx.agent.registerResource({ + id: 'current-build', + uri: 'https://example.com/build/current', + name: 'Current build', + read, + }) + + const listed = await client.listResources() + expect(listed.resources.map(resource => resource.uri)).toContain('https://example.com/build/current') + const result = await client.readResource({ uri: 'https://example.com/build/current' }) + const content = result.contents[0] as { text: string } + expect(JSON.parse(content.text)).toEqual({ uri: 'https://example.com/build/current' }) + expect(read).toHaveBeenCalledWith(new URL('https://example.com/build/current')) + } + finally { + await cleanup() + } + }) + + it('lists templates and their concrete resources, then parses variables on read', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + const read = vi.fn((uri: URL, variables: Readonly>) => ({ + json: { uri: uri.toString(), variables }, + })) + ctx.agent.registerResource({ + id: 'logs', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + description: 'Logs by process name.', + mimeType: 'application/json', + list: () => ({ + resources: [{ uri: 'devframe://logs/app', name: 'App logs', mimeType: 'application/json' }], + }), + read, + }) + + const templates = await client.listResourceTemplates() + expect(templates.resourceTemplates).toContainEqual({ + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + description: 'Logs by process name.', + mimeType: 'application/json', + }) + const resources = await client.listResources() + expect(resources.resources).toContainEqual({ + uri: 'devframe://logs/app', + name: 'App logs', + mimeType: 'application/json', + }) + + const result = await client.readResource({ uri: 'devframe://logs/worker' }) + const content = result.contents[0] as { text: string } + expect(JSON.parse(content.text)).toEqual({ + uri: 'devframe://logs/worker', + variables: { name: 'worker' }, + }) + expect(read).toHaveBeenCalledWith(new URL('devframe://logs/worker'), { name: 'worker' }) + } + finally { + await cleanup() + } + }) + + it('resolves an exact resource URI before a matching template', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerResource({ + id: 'logs-template', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + read: () => ({ text: 'template' }), + }) + ctx.agent.registerResource({ + id: 'fixed-log', + uri: 'devframe://logs/app', + name: 'App log', + read: () => ({ text: 'fixed' }), + }) + + const result = await client.readResource({ uri: 'devframe://logs/app' }) + const content = result.contents[0] as { text: string } + expect(content.text).toBe('fixed') + } + finally { + await cleanup() + } + }) + it('surfaces shared-state keys as MCP resources', async () => { const { ctx, client, cleanup } = await bootPair() try { @@ -317,6 +460,7 @@ describe('mcp adapter (in-memory)', () => { serverName: 'test', serverVersion: '0.0.0-test', exposeSharedState: false, + era: 'legacy', }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await server.connect(serverTransport) @@ -340,6 +484,7 @@ describe('mcp adapter (in-memory)', () => { serverName: 'test', serverVersion: '0.0.0-test', exposeSharedState: key => key.startsWith('visible:'), + era: 'legacy', }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await server.connect(serverTransport) @@ -358,3 +503,99 @@ describe('mcp adapter (in-memory)', () => { } }) }) + +describe('mcp adapter (stdio)', () => { + it('delivers request-bound tool progress before the stdio result', async () => { + expect.assertions(1) + const fixture = fileURLToPath(new URL('./fixtures/progress-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-progress-test-client', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) + const events: unknown[] = [] + + try { + await client.connect(transport) + const result = await client.callTool( + { name: 'build', arguments: {} }, + { onprogress: progress => events.push({ type: 'progress', ...progress }) }, + ) + events.push({ type: 'result', text: (result.content[0] as { text: string }).text }) + + expect(events).toEqual([ + { type: 'progress', progress: 1, total: 2, message: 'Compiling' }, + { type: 'progress', progress: 2, total: 2, message: 'Testing' }, + { type: 'result', text: '{\n "status": "complete"\n}' }, + ]) + } + finally { + await client.close() + } + }) + + it('lists, reads, and receives modern updates for registered, template, and shared-state resources', async () => { + const fixture = fileURLToPath(new URL('./fixtures/resource-stdio-server.ts', import.meta.url)) + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', fixture], + cwd: process.cwd(), + stderr: 'pipe', + }) + const client = new Client( + { name: 'stdio-test-client', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) + let subscription: Awaited> | undefined + const updates: string[] = [] + client.setNotificationHandler('notifications/resources/updated', (notification) => { + updates.push(notification.params.uri) + }) + + try { + await client.connect(transport) + expect(client.getProtocolEra()).toBe('modern') + const resources = await client.listResources() + expect(resources.resources.map(resource => resource.uri)).toEqual(expect.arrayContaining([ + 'https://example.com/status', + 'devframe://logs/app', + 'devframe://state/stdio%3Acounter', + ])) + const templates = await client.listResourceTemplates() + expect(templates.resourceTemplates.map(template => template.uriTemplate)).toEqual(['devframe://logs/{name}']) + + const fixed = await client.readResource({ uri: 'https://example.com/status' }) + expect(JSON.parse((fixed.contents[0] as { text: string }).text)).toEqual({ + uri: 'https://example.com/status', + status: 'ok', + }) + const template = await client.readResource({ uri: 'devframe://logs/worker' }) + expect(JSON.parse((template.contents[0] as { text: string }).text)).toEqual({ process: 'worker' }) + + subscription = await client.listen({ + resourceSubscriptions: [ + 'https://example.com/status', + 'devframe://state/stdio%3Acounter', + ], + }) + const increment = await client.callTool({ name: 'increment-state', arguments: {} }) + expect(increment.isError).toBeFalsy() + const updatedState = await client.readResource({ uri: 'devframe://state/stdio%3Acounter' }) + expect(JSON.parse((updatedState.contents[0] as { text: string }).text)).toEqual({ count: 1 }) + await vi.waitFor(() => expect(updates).toEqual(expect.arrayContaining([ + 'https://example.com/status', + 'devframe://state/stdio%3Acounter', + ]))) + expect(updates).not.toContain('devframe://resource/ignored') + } + finally { + await subscription?.close() + await client.close() + } + }) +}) diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 237c8fb6..e9d23aa7 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -1,10 +1,10 @@ -import type { Tool } from '@modelcontextprotocol/server' +import type { McpRequestContext, Resource, ServerContext, 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 type { AgentTool, AgentToolInvocationContext, DevframeDefinition, DevframeHost, DevframeNodeContext } from 'devframe/types' import { homedir } from 'node:os' import process from 'node:process' -import { Server } from '@modelcontextprotocol/server' +import { Server, UriTemplate } from '@modelcontextprotocol/server' import { createHostContext } from 'devframe/node' import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { join } from 'pathe' @@ -43,18 +43,15 @@ export interface McpServerHandle { export interface BuildMcpServerOptions { serverName: string serverVersion: string - exposeSharedState: boolean | ((k: string) => boolean) + exposeSharedState: boolean | ((key: string) => boolean) + era: McpRequestContext['era'] } /** * Build a fresh MCP {@link Server} over a devframe context, registering its - * tool and resource handlers. This is a pure factory — it sets up no - * long-lived subscriptions and holds no per-connection state, so it is safe - * to call once per request under `createMcpHandler` or once per connection - * under `serveStdio`. Change notifications are published separately: over - * HTTP through the handler's `notify` bus (see `createMcpFetchHandler`), and - * on stdio through the connection's own `send*ListChanged` calls (see - * {@link bridgeListChanged}, wired by `serveStdio`). + * tool and resource handlers. The protocol era controls only the advertised + * resource update capabilities; listing and reading use the same handlers in + * both eras. Long-lived update listeners are installed by the serving entry. * * @internal */ @@ -70,7 +67,9 @@ export function buildMcpServerFromContext( { capabilities: { tools: { listChanged: true }, - resources: { listChanged: true }, + resources: options.era === 'modern' + ? { listChanged: true, subscribe: true } + : {}, }, }, ) @@ -82,31 +81,43 @@ export function buildMcpServerFromContext( } /** - * Publish devframe's `list_changed` events through a set of typed sinks: - * `tools()` for tool-list changes and `resources()` for resource-list - * changes (shared-state keys are surfaced as resources). Returns an - * unsubscribe function. - * - * The HTTP path passes the handler's `notify` bus sugar; the stdio path - * passes the pinned server's `send*ListChanged` methods, which `serveStdio` - * routes onto the connection's active `subscriptions/listen` streams. + * Publish devframe changes through the MCP 2026 update bus. Shared-state + * values use their encoded resource URI, and newly registered keys also + * invalidate the resource list. * * @internal */ -export function bridgeListChanged( +export function bridgeMcpUpdates( ctx: DevframeNodeContext, - sinks: { tools: () => void, resources: () => void }, + exposeSharedState: boolean | ((key: string) => boolean), + sinks: { + toolsChanged: () => void + resourcesChanged: () => void + resourceUpdated: (uri: string) => void + }, ): () => void { + const stateFilter = sharedStateFilter(exposeSharedState) const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { - sinks.tools() - sinks.resources() + sinks.toolsChanged() + sinks.resourcesChanged() }) - const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => { - sinks.resources() + const offResourceUpdated = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentResourceUpdated, (uri) => { + sinks.resourceUpdated(uri) }) + const offKeyAdded = ctx.rpc.sharedState.onKeyAdded((key) => { + if (stateFilter?.(key)) + sinks.resourcesChanged() + }) + const offStateUpdated = ctx.rpc.sharedState.onUpdated((key) => { + if (stateFilter?.(key)) + sinks.resourceUpdated(sharedStateResourceUri(key)) + }) + return () => { offManifest() + offResourceUpdated() offKeyAdded() + offStateUpdated() } } @@ -146,27 +157,33 @@ export async function createMcpServer( await ctx.services.ready() await definition.setup(ctx) - const buildOptions: BuildMcpServerOptions = { + const buildOptions: Omit = { serverName: options.serverName ?? `${definition.id} (devframe)`, serverVersion: options.serverVersion ?? definition.version ?? '0.0.0', exposeSharedState: options.exposeSharedState ?? true, } - // `serveStdio` owns the connection's era decision and pins ONE instance - // for its lifetime. Each pinned server sets up its own `list_changed` - // bridge over the connection's `send*ListChanged` calls (routed onto the - // active `subscriptions/listen` streams on a modern connection, sent - // unsolicited on a 2025-era one) and tears it down when that server - // closes. + // `serveStdio` owns the protocol-era decision and pins one instance for + // the connection lifetime. Modern instances bridge resource changes into + // `subscriptions/listen`; legacy instances retain tool-list invalidations + // while resources remain pull-only. let handle: import('@modelcontextprotocol/server/stdio').StdioServerHandle try { const { serveStdio } = await import('@modelcontextprotocol/server/stdio') - handle = serveStdio(() => { - const server = buildMcpServerFromContext(ctx, buildOptions) - const unbridge = bridgeListChanged(ctx, { - tools: () => { void server.sendToolListChanged().catch(() => {}) }, - resources: () => { void server.sendResourceListChanged().catch(() => {}) }, + handle = serveStdio((requestContext) => { + const server = buildMcpServerFromContext(ctx, { + ...buildOptions, + era: requestContext.era, }) + const unbridge = requestContext.era === 'modern' + ? bridgeMcpUpdates(ctx, buildOptions.exposeSharedState, { + toolsChanged: () => { void server.sendToolListChanged().catch(() => {}) }, + resourcesChanged: () => { void server.sendResourceListChanged().catch(() => {}) }, + resourceUpdated: (uri) => { void server.sendResourceUpdated({ uri }).catch(() => {}) }, + }) + : ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => { + void server.sendToolListChanged().catch(() => {}) + }) const priorOnClose = server.onclose server.onclose = () => { unbridge() @@ -205,6 +222,10 @@ function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolea return typeof exposeSharedState === 'function' ? exposeSharedState : () => true } +function sharedStateResourceUri(key: string): string { + return `devframe://state/${encodeURIComponent(key)}` +} + function readStateToolProjection(): Tool { return { name: READ_STATE_NAME, @@ -241,6 +262,44 @@ async function readStateResult( return { key, value: state.value() } } +interface McpToolInvocation { + context: AgentToolInvocationContext + flushProgress: () => Promise +} + +function createMcpToolInvocation(requestContext: ServerContext): McpToolInvocation { + const progressToken = requestContext.mcpReq._meta?.progressToken + if (progressToken === undefined) { + return { + context: { reportProgress: async () => {} }, + flushProgress: async () => {}, + } + } + + let progressReported = false + return { + context: { + async reportProgress(update) { + progressReported = true + await requestContext.mcpReq.notify({ + method: 'notifications/progress', + params: { + progressToken, + progress: update.progress, + ...(update.total === undefined ? {} : { total: update.total }), + ...(update.message === undefined ? {} : { message: update.message }), + }, + }) + }, + }, + async flushProgress() { + /** Let the SDK transport flush progress before the terminal response. */ + if (progressReported) + await new Promise(resolve => setImmediate(resolve)) + }, + } +} + function registerToolHandlers( server: Server, ctx: DevframeNodeContext, @@ -284,8 +343,9 @@ function registerToolHandlers( return { tools } }) - server.setRequestHandler('tools/call', async (request) => { + server.setRequestHandler('tools/call', async (request, requestContext) => { const { name, arguments: args } = request.params + const invocation = createMcpToolInvocation(requestContext) try { const tool = resolveTool(name) // Built-in shared-state read. A registered agent tool resolving to @@ -302,7 +362,12 @@ function registerToolHandlers( const outputSchema = tool ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) : undefined - const result = await ctx.agent.invoke(tool?.id ?? name, args ?? {}) + const result = await ctx.agent.invoke( + tool?.id ?? name, + args ?? {}, + invocation.context, + ) + await invocation.flushProgress() return { content: [ { @@ -314,6 +379,7 @@ function registerToolHandlers( } } catch (error) { + await invocation.flushProgress() return { isError: true, content: [ @@ -332,21 +398,30 @@ function registerResourceHandlers( ctx: DevframeNodeContext, exposeSharedState: boolean | ((key: string) => boolean), ): void { + const stateFilter = sharedStateFilter(exposeSharedState) + server.setRequestHandler('resources/list', async () => { - const resources = ctx.agent.list().resources.map(resource => ({ + const manifest = ctx.agent.list() + const resources: Resource[] = manifest.resources.map(resource => ({ uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mimeType, })) - if (exposeSharedState !== false) { - const filter = typeof exposeSharedState === 'function' ? exposeSharedState : () => true + const listedTemplateResources = await Promise.all( + manifest.resourceTemplates.map(template => ctx.agent.listResourceInstances(template.id)), + ) + for (const listed of listedTemplateResources) { + resources.push(...listed.resources) + } + + if (stateFilter) { for (const key of ctx.rpc.sharedState.keys()) { - if (!filter(key)) + if (!stateFilter(key)) continue resources.push({ - uri: `devframe://state/${encodeURIComponent(key)}`, + uri: sharedStateResourceUri(key), name: key, description: `Shared state: ${key}`, mimeType: 'application/json', @@ -357,24 +432,35 @@ function registerResourceHandlers( return { resources } }) + server.setRequestHandler('resources/templates/list', async () => { + const resourceTemplates = ctx.agent.list().resourceTemplates.map(template => ({ + uriTemplate: template.uriTemplate, + name: template.name, + description: template.description, + mimeType: template.mimeType, + })) + return { resourceTemplates } + }) + server.setRequestHandler('resources/read', async (request) => { const { uri } = request.params - const parsed = parseResourceUri(uri) + const resource = resolveAgentResource(ctx, uri) - if (parsed.kind === 'resource') { - const content = await ctx.agent.read(parsed.id) + if (resource) { + const content = await ctx.agent.read(resource.id, uri, resource.variables) return { contents: [ { uri, - mimeType: content.mimeType ?? 'application/json', + mimeType: content.mimeType ?? resource.mimeType ?? 'application/json', text: content.text ?? stringifyForMcp(content.json), }, ], } } - if (parsed.kind === 'state') { + const parsed = parseResourceUri(uri) + if (parsed.kind === 'state' && stateFilter?.(parsed.key) && ctx.rpc.sharedState.keys().includes(parsed.key)) { const state = await ctx.rpc.sharedState.get(parsed.key) return { contents: [ @@ -391,6 +477,27 @@ function registerResourceHandlers( }) } +function resolveAgentResource( + ctx: DevframeNodeContext, + uri: string, +): { id: string, variables: Variables, mimeType?: string } | undefined { + const manifest = ctx.agent.list() + const resource = manifest.resources.find(candidate => candidate.uri === uri) + if (resource) + return { id: resource.id, variables: {}, mimeType: resource.mimeType } + + for (const template of manifest.resourceTemplates) { + const variables = new UriTemplate(template.uriTemplate).match(uri) + if (variables) { + return { + id: template.id, + variables, + mimeType: template.mimeType, + } + } + } +} + /** * MCP constrains a tool's `outputSchema` to a JSON Schema of `type: * "object"` — clients (the SDK included) reject anything else. Non-object @@ -446,7 +553,13 @@ function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kin if (!match) return { kind: 'unknown' } const [, kind, rest] = match - const decoded = decodeURIComponent(rest!) + let decoded: string + try { + decoded = decodeURIComponent(rest!) + } + catch { + return { kind: 'unknown' } + } if (kind === 'resource') return { kind: 'resource', id: decoded } return { kind: 'state', key: decoded } diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index 384e6def..175c1508 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -1,7 +1,7 @@ import type { DevframeNodeContext } from 'devframe/types' import { createMcpHandler } from '@modelcontextprotocol/server' import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' -import { bridgeListChanged, buildMcpServerFromContext } from './build-server' +import { bridgeMcpUpdates, buildMcpServerFromContext } from './build-server' export interface CreateMcpFetchHandlerOptions { /** Name reported in the MCP handshake. */ @@ -58,18 +58,19 @@ export function createMcpFetchHandler( ): McpFetchHandler { const allowedOrigins = options.allowedOrigins - const handler = createMcpHandler(() => buildMcpServerFromContext(ctx, { + const handler = createMcpHandler(requestContext => buildMcpServerFromContext(ctx, { serverName: options.serverName, serverVersion: options.serverVersion, exposeSharedState: options.exposeSharedState, + era: requestContext.era, })) - // A single, long-lived bridge from devframe's change events onto the - // handler's `subscriptions/listen` bus — published once for the endpoint, - // not per (ephemeral, per-request) server instance. - const unbridge = bridgeListChanged(ctx, { - tools: () => { handler.notify.toolsChanged() }, - resources: () => { handler.notify.resourcesChanged() }, + // One bridge publishes changes for every modern listen stream. The SDK + // filters resource updates by each stream's `resourceSubscriptions`. + const unbridge = bridgeMcpUpdates(ctx, options.exposeSharedState, { + toolsChanged: () => { handler.notify.toolsChanged() }, + resourcesChanged: () => { handler.notify.resourcesChanged() }, + resourceUpdated: (uri) => { handler.notify.resourceUpdated(uri) }, }) async function handle(req: Request): Promise { diff --git a/packages/devframe/src/client/rpc-shared-state.ts b/packages/devframe/src/client/rpc-shared-state.ts index ae7c6b28..a1db6abb 100644 --- a/packages/devframe/src/client/rpc-shared-state.ts +++ b/packages/devframe/src/client/rpc-shared-state.ts @@ -9,6 +9,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare const stateDisposers = new Map void>() const initialValues = new Map() const keyAddedListeners = new Set<(key: string) => void>() + const updatedListeners = new Set<(key: string) => void>() const isStaticBackend = rpc.connectionMeta.backend === 'static' function mergeWithInitialValue(key: string, serverState: any): any { @@ -45,6 +46,8 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare function registerSharedState(key: string, state: SharedState) { const offs: (() => void)[] = [] offs.push(state.on('updated', (fullState, patches, syncId) => { + for (const listener of updatedListeners) + listener(key) if (isStaticBackend) return if (patches) { @@ -70,6 +73,12 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare keyAddedListeners.delete(fn) } }, + onUpdated(fn) { + updatedListeners.add(fn) + return () => { + updatedListeners.delete(fn) + } + }, delete(key) { const dispose = stateDisposers.get(key) stateDisposers.delete(key) diff --git a/packages/devframe/src/events.ts b/packages/devframe/src/events.ts index d6dedc1c..c9a308a1 100644 --- a/packages/devframe/src/events.ts +++ b/packages/devframe/src/events.ts @@ -33,6 +33,7 @@ export const DEVFRAME_EVENTS = { agentToolUnregistered: 'agent:tool:unregistered', agentResourceRegistered: 'agent:resource:registered', agentResourceUnregistered: 'agent:resource:unregistered', + agentResourceUpdated: 'agent:resource:updated', }, /** * Client-side RPC connection `EventEmitter` events (`rpc.events`) a UI diff --git a/packages/devframe/src/node/__tests__/host-agent.test.ts b/packages/devframe/src/node/__tests__/host-agent.test.ts index 5b94babe..a9aef344 100644 --- a/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -1,4 +1,5 @@ import type { RpcFunctionDefinitionAnyWithContext } from '../../rpc/types' +import type { AgentToolInvocationContext } from '../../types/agent' import type { DevframeNodeContext } from '../../types/context' import { describe, expect, it, vi } from 'vitest' import { DevframeAgentHost } from '../host-agent' @@ -211,6 +212,94 @@ describe('devToolsAgentHost', () => { expect(result).toEqual({ echoed: { ping: true } }) }) + it('passes a request-bound invocation context to registered tool handlers', async () => { + expect.assertions(4) + const ctx = createContext() + const handler = vi.fn(async (_args: unknown, context?: AgentToolInvocationContext) => { + await context?.reportProgress({ progress: 1, total: 2, message: 'Starting' }) + await context?.reportProgress({ progress: 2, total: 2, message: 'Done' }) + return 'complete' + }) + const reportProgress = vi.fn(async () => {}) + ctx.agent.registerTool({ + id: 'build', + description: 'Builds the project.', + handler, + }) + + const result = await ctx.agent.invoke('build', {}, { reportProgress }) + + expect(handler).toHaveBeenCalledWith({}, expect.objectContaining({ reportProgress: expect.any(Function) })) + expect(reportProgress).toHaveBeenNthCalledWith(1, { progress: 1, total: 2, message: 'Starting' }) + expect(reportProgress).toHaveBeenNthCalledWith(2, { progress: 2, total: 2, message: 'Done' }) + expect(result).toBe('complete') + }) + + it('serializes unawaited reports and suppresses reports after the handler settles', async () => { + expect.assertions(2) + const ctx = createContext() + const delivered: number[] = [] + let capturedContext: AgentToolInvocationContext | undefined + ctx.agent.registerTool({ + id: 'build', + description: 'Builds the project.', + handler: (_args, context) => { + capturedContext = context + void context?.reportProgress({ progress: 1 }) + void context?.reportProgress({ progress: 2 }) + return 'complete' + }, + }) + + await ctx.agent.invoke('build', {}, { + reportProgress: async update => void delivered.push(update.progress), + }) + await capturedContext!.reportProgress({ progress: 3 }) + + expect(delivered).toEqual([1, 2]) + expect(capturedContext).toBeDefined() + }) + + it.each([ + { progress: Number.NaN }, + { progress: 1, total: Number.POSITIVE_INFINITY }, + ])('rejects non-finite progress values with DF0071', async (update) => { + expect.assertions(1) + const ctx = createContext() + ctx.agent.registerTool({ + id: 'build', + description: 'Builds the project.', + handler: async (_args, context) => { + await context?.reportProgress(update) + }, + }) + + await expect( + ctx.agent.invoke('build', {}, { reportProgress: async () => {} }), + ) + .rejects + .toMatchObject({ code: 'DF0071' }) + }) + + it('rejects non-increasing progress with DF0071', async () => { + expect.assertions(1) + const ctx = createContext() + ctx.agent.registerTool({ + id: 'build', + description: 'Builds the project.', + handler: async (_args, context) => { + await context?.reportProgress({ progress: 2 }) + await context?.reportProgress({ progress: 2 }) + }, + }) + + await expect( + ctx.agent.invoke('build', {}, { reportProgress: async () => {} }), + ) + .rejects + .toMatchObject({ code: 'DF0071' }) + }) + it('dispatches to an RPC function via invokeLocal', async () => { const ctx = createContext() ctx.rpc.register(rpcDef({ @@ -227,6 +316,26 @@ describe('devToolsAgentHost', () => { expect(result).toBe(5) }) + it('keeps agent-enabled RPC handler signatures unchanged', async () => { + expect.assertions(3) + const ctx = createContext() + const rpcHandler = vi.fn(async (value: number) => value * 2) + const reportProgress = vi.fn(async () => {}) + ctx.rpc.register(rpcDef({ + name: 'my-rpc', + type: 'query', + jsonSerializable: true, + agent: { description: 'rpc' }, + setup: () => ({ handler: rpcHandler }), + })) + + const result = await ctx.agent.invoke('my-rpc', { arg0: 3 }, { reportProgress }) + + expect(result).toBe(6) + expect(rpcHandler).toHaveBeenCalledWith(3) + expect(reportProgress).not.toHaveBeenCalled() + }) + it('throws for unknown tool id', async () => { const ctx = createContext() await expect(ctx.agent.invoke('missing', {})).rejects.toThrow(/missing/) @@ -250,6 +359,85 @@ describe('devToolsAgentHost', () => { expect(content).toEqual({ json: { hello: 'world' } }) }) + it('keeps an explicit URI and passes the requested URI to the reader', async () => { + const ctx = createContext() + const read = vi.fn((uri: URL) => ({ text: uri.toString() })) + ctx.agent.registerResource({ + id: 'custom-resource', + uri: 'https://example.com/resources/current', + name: 'Custom resource', + read, + }) + + expect(ctx.agent.list().resources[0]!.uri).toBe('https://example.com/resources/current') + expect(ctx.agent.getResource('https://example.com/resources/current')?.id).toBe('custom-resource') + await expect(ctx.agent.read('custom-resource', 'https://example.com/resources/requested')).resolves.toEqual({ + text: 'https://example.com/resources/requested', + }) + expect(read).toHaveBeenCalledWith(new URL('https://example.com/resources/requested')) + }) + + it('registers templates, enumerates instances, and forwards variables', async () => { + const ctx = createContext() + const read = vi.fn((uri: URL, variables: Readonly>) => ({ + json: { uri: uri.toString(), variables }, + })) + ctx.agent.registerResource({ + id: 'logs', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + list: () => ({ + resources: [{ uri: 'devframe://logs/app', name: 'App logs', mimeType: 'text/plain' }], + }), + read, + }) + + expect(ctx.agent.list().resources).toEqual([]) + expect(ctx.agent.list().resourceTemplates).toEqual([{ + id: 'logs', + uriTemplate: 'devframe://logs/{name}', + name: 'Logs', + description: undefined, + mimeType: undefined, + }]) + await expect(ctx.agent.listResourceInstances('logs')).resolves.toEqual({ + resources: [{ uri: 'devframe://logs/app', name: 'App logs', mimeType: 'text/plain' }], + }) + await ctx.agent.read('logs', 'devframe://logs/app', { name: 'app' }) + expect(read).toHaveBeenCalledWith(new URL('devframe://logs/app'), { name: 'app' }) + }) + + it('emits updates through resource and template handles', () => { + const ctx = createContext() + const updated = vi.fn() + ctx.agent.events.on('agent:resource:updated', updated) + const fixed = ctx.agent.registerResource({ + id: 'fixed', + uri: 'https://example.com/fixed', + name: 'Fixed', + read: () => ({ text: 'fixed' }), + }) + const template = ctx.agent.registerResource({ + id: 'template', + uriTemplate: 'https://example.com/{name}', + name: 'Template', + read: () => ({ text: 'template' }), + }) + + fixed.notifyUpdated() + template.notifyUpdated('https://example.com/one') + expect(updated).toHaveBeenNthCalledWith(1, 'https://example.com/fixed') + expect(updated).toHaveBeenNthCalledWith(2, 'https://example.com/one') + + fixed.unregister() + template.unregister() + expect(ctx.agent.list().resources).toEqual([]) + expect(ctx.agent.list().resourceTemplates).toEqual([]) + fixed.notifyUpdated() + template.notifyUpdated('https://example.com/two') + expect(updated).toHaveBeenCalledTimes(2) + }) + it('throws DF0016 on duplicate id', () => { const ctx = createContext() ctx.agent.registerResource({ @@ -270,6 +458,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') @@ -330,6 +571,25 @@ describe('devToolsAgentHost', () => { expect(handler).toHaveBeenCalledWith({ a: 1 }) }) + it('passes the invocation context to provider tool handlers', async () => { + expect.assertions(2) + const ctx = createContext() + const handler = vi.fn(async (_args: unknown, context?: AgentToolInvocationContext) => { + await context?.reportProgress({ progress: 1, message: 'Provided' }) + }) + const reportProgress = vi.fn(async () => {}) + ctx.agent.registerToolProvider(() => [{ + id: 'derived:tool', + description: 'Derived.', + handler, + }]) + + await ctx.agent.invoke('derived:tool', {}, { reportProgress }) + + expect(handler).toHaveBeenCalledWith({}, expect.objectContaining({ reportProgress: expect.any(Function) })) + expect(reportProgress).toHaveBeenCalledWith({ progress: 1, message: 'Provided' }) + }) + it('earlier sources win on id collision', () => { const ctx = createContext() ctx.agent.registerTool({ id: 'shared:id', description: 'Registered.', handler: () => 'plain' }) diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index a6d30995..6f7fda3e 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -194,6 +194,10 @@ export const diagnostics = defineDiagnostics({ `Invalid service "${p.package}": ${p.reason}`, fix: 'A service package\'s default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function.', }, + DF0071: { + why: (p: { reason: string }) => `Invalid agent tool progress: ${p.reason}.`, + fix: 'Report finite numbers and increase `progress` on every call within one tool invocation.', + }, DF0072: { why: (p: { method: string }) => `\`rpc.snapshot\` names "${p.method}", but no RPC function is registered under that id — nothing to bake into the static build.`, diff --git a/packages/devframe/src/node/host-agent.ts b/packages/devframe/src/node/host-agent.ts index fabd785b..5768d2e9 100644 --- a/packages/devframe/src/node/host-agent.ts +++ b/packages/devframe/src/node/host-agent.ts @@ -4,9 +4,20 @@ import type { AgentManifest, AgentResource, AgentResourceContent, + AgentResourceDefinition, + AgentResourceHandle, AgentResourceInput, + AgentResourceList, + AgentResourceProvider, + AgentResourceProviderHandle, + AgentResourceTemplate, + AgentResourceTemplateHandle, + AgentResourceTemplateInput, + AgentResourceVariables, AgentTool, AgentToolInput, + AgentToolInvocationContext, + AgentToolProgress, AgentToolProvider, AgentToolProviderHandle, DevframeAgentHostEvents, @@ -22,12 +33,58 @@ import { diagnostics } from './diagnostics' interface RegisteredTool { readonly tool: AgentTool - readonly handler?: (args: any) => unknown | Promise + readonly handler?: AgentToolInput['handler'] } -interface RegisteredResource { - readonly resource: AgentResource - readonly read: () => Promise | AgentResourceContent +function validateToolProgress(update: AgentToolProgress, previousProgress: number | undefined): void { + if (!Number.isFinite(update.progress)) + throw diagnostics.DF0071({ reason: `\`progress\` must be finite, received ${String(update.progress)}` }) + if (update.total !== undefined && !Number.isFinite(update.total)) + throw diagnostics.DF0071({ reason: `\`total\` must be finite, received ${String(update.total)}` }) + if (previousProgress !== undefined && update.progress <= previousProgress) { + throw diagnostics.DF0071({ + reason: `\`progress\` must increase beyond ${previousProgress}, received ${update.progress}`, + }) + } +} + +async function invokeToolHandler( + handler: AgentToolInput['handler'], + args: unknown, + invocationContext?: AgentToolInvocationContext, +): Promise { + if (!invocationContext) + return await handler(args) + + let active = true + let previousProgress: number | undefined + let pendingReports = Promise.resolve() + const context: AgentToolInvocationContext = { + reportProgress(update) { + if (!active) + return Promise.resolve() + validateToolProgress(update, previousProgress) + previousProgress = update.progress + pendingReports = pendingReports.then(() => invocationContext.reportProgress(update)) + return pendingReports + }, + } + + try { + return await handler(args, context) + } + finally { + active = false + await pendingReports + } +} + +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 +97,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 +134,72 @@ export class DevframeAgentHost implements DevframeAgentHostType { } registerToolProvider(provider: AgentToolProvider): AgentToolProviderHandle { - this.providers.add(provider) + this.toolProviders.add(provider) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) const notifyChanged = (): void => { - if (this.providers.has(provider)) + if (this.toolProviders.has(provider)) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) } return { notifyChanged, unregister: () => { - if (this.providers.delete(provider)) + if (this.toolProviders.delete(provider)) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) }, } } - registerResource(input: AgentResourceInput): AgentHandle { + registerResource(input: AgentResourceInput): AgentResourceHandle + registerResource(input: AgentResourceTemplateInput): AgentResourceTemplateHandle + registerResource(input: AgentResourceDefinition): AgentResourceHandle | AgentResourceTemplateHandle { if (this.resources.has(input.id)) throw diagnostics.DF0016({ id: input.id }) - const resource: AgentResource = { - id: input.id, - name: input.name, - description: input.description, - mimeType: input.mimeType ?? 'application/json', - uri: input.uri ?? `devframe://resource/${encodeURIComponent(input.id)}`, + this.resources.set(input.id, input) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceRegistered, this._projectResource(input)) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) + + const isRegistered = (): boolean => this.resources.get(input.id) === input + const unregister = (): void => { + if (isRegistered()) + this.unregisterResource(input.id) + } + if (!isResourceTemplate(input)) { + return { + notifyUpdated: () => { + if (isRegistered()) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, resourceUri(input)) + }, + unregister, + } } - this.resources.set(resource.id, { resource, read: input.read }) - this.events.emit(DEVFRAME_EVENTS.bus.agentResourceRegistered, resource) + return { + notifyUpdated: (uri) => { + if (isRegistered()) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, uri) + }, + unregister, + } + } + + registerResourceProvider(provider: AgentResourceProvider): AgentResourceProviderHandle { + this.resourceProviders.add(provider) this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) return { - unregister: () => this.unregisterResource(resource.id), + notifyChanged: () => { + if (this.resourceProviders.has(provider)) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) + }, + notifyUpdated: (uri) => { + if (this.resourceProviders.has(provider)) + this.events.emit(DEVFRAME_EVENTS.bus.agentResourceUpdated, uri) + }, + unregister: () => { + if (this.resourceProviders.delete(provider)) + this.events.emit(DEVFRAME_EVENTS.bus.agentManifestChanged) + }, } } @@ -124,7 +215,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 +239,7 @@ export class DevframeAgentHost implements DevframeAgentHostType { return { tools: [...rpcTools, ...plainTools, ...providerTools], resources, + resourceTemplates, } } @@ -153,13 +254,18 @@ 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 { + async invoke(id: string, args: unknown, invocationContext?: AgentToolInvocationContext): Promise { const plain = this.tools.get(id) if (plain?.handler) { - return await plain.handler(args) + return await invokeToolHandler(plain.handler, args, invocationContext) } const rpcDef = this._findRpcDefinition(id) @@ -174,17 +280,34 @@ export class DevframeAgentHost implements DevframeAgentHostType { const provided = this._collectProviderTools().find(t => t.tool.id === id) if (provided) { - return await provided.input.handler(args) + return await invokeToolHandler(provided.input.handler, args, invocationContext) } throw new Error(`[devframe/agent] tool "${id}" not found`) } - async read(id: string): Promise { - const entry = this.resources.get(id) + async read( + id: string, + uri?: string | URL, + variables: AgentResourceVariables = {}, + ): Promise { + const entry = this._findResourceDefinition(id) if (!entry) throw new Error(`[devframe/agent] resource "${id}" not found`) - return await entry.read() + + if (!isResourceTemplate(entry)) + return await entry.read(uri instanceof URL ? uri : new URL(uri ?? resourceUri(entry))) + + if (!uri) + throw new Error(`[devframe/agent] resource template "${id}" requires a URI`) + return await entry.read(uri instanceof URL ? uri : new URL(uri), variables) + } + + async listResourceInstances(id: string): Promise { + const entry = this._findResourceDefinition(id) + if (!entry || !isResourceTemplate(entry)) + throw new Error(`[devframe/agent] resource template "${id}" not found`) + return await entry.list?.() ?? { resources: [] } } /** @internal */ @@ -224,10 +347,50 @@ export class DevframeAgentHost implements DevframeAgentHostType { } } + private _projectResource(input: AgentResourceDefinition): AgentResource | AgentResourceTemplate { + if (isResourceTemplate(input)) { + return { + id: input.id, + uriTemplate: input.uriTemplate, + name: input.name, + description: input.description, + mimeType: input.mimeType, + } + } + + return { + id: input.id, + uri: resourceUri(input), + name: input.name, + description: input.description, + mimeType: input.mimeType ?? 'application/json', + } + } + + private _collectResourceDefinitions(): AgentResourceDefinition[] { + const resources = Array.from(this.resources.values()) + const seen = new Set(resources.map(resource => resource.id)) + + for (const provider of this.resourceProviders) { + for (const input of provider()) { + if (seen.has(input.id)) + continue + seen.add(input.id) + resources.push(input) + } + } + + return resources + } + + private _findResourceDefinition(id: string): AgentResourceDefinition | undefined { + return this._collectResourceDefinitions().find(resource => resource.id === id) + } + /** Query every registered provider, projecting inputs to serializable tools. */ private _collectProviderTools(): { input: AgentToolInput, tool: AgentTool }[] { const out: { input: AgentToolInput, tool: AgentTool }[] = [] - for (const provider of this.providers) { + for (const provider of this.toolProviders) { for (const input of provider()) out.push({ input, tool: this._projectTool(input) }) } diff --git a/packages/devframe/src/node/rpc-shared-state.ts b/packages/devframe/src/node/rpc-shared-state.ts index 300ecff5..4444f692 100644 --- a/packages/devframe/src/node/rpc-shared-state.ts +++ b/packages/devframe/src/node/rpc-shared-state.ts @@ -14,12 +14,15 @@ export function createRpcSharedStateServerHost( const sharedState = new Map>() const stateDisposers = new Map void>() const keyAddedListeners = new Set<(key: string) => void>() + const updatedListeners = new Set<(key: string) => void>() function registerSharedState(key: string, state: SharedState) { const offs: (() => void)[] = [] offs.push( state.on('updated', (fullState, patches, syncId) => { + for (const listener of updatedListeners) + listener(key) if (patches) { debug('patch', { key, syncId }) rpc.broadcast({ @@ -74,6 +77,12 @@ export function createRpcSharedStateServerHost( keyAddedListeners.delete(fn) } }, + onUpdated(fn) { + updatedListeners.add(fn) + return () => { + updatedListeners.delete(fn) + } + }, delete(key) { const dispose = stateDisposers.get(key) if (!dispose) diff --git a/packages/devframe/src/types/agent.ts b/packages/devframe/src/types/agent.ts index b897619c..6269119d 100644 --- a/packages/devframe/src/types/agent.ts +++ b/packages/devframe/src/types/agent.ts @@ -38,6 +38,21 @@ export interface AgentTool { examples?: readonly { args: unknown[], description?: string }[] } +/** One progress update reported while an agent tool invocation is running. */ +export interface AgentToolProgress { + /** Completed work. Must be finite and increase within one invocation. */ + progress: number + /** Optional finite work estimate. */ + total?: number + /** Optional human-readable status. */ + message?: string +} + +/** Request-bound capabilities available to a registered or provider tool handler. */ +export interface AgentToolInvocationContext { + reportProgress: (update: AgentToolProgress) => Promise +} + /** * Input accepted by `DevframeAgentHost.registerTool()`. Handler is * stripped from the serializable `AgentTool` projection. @@ -62,8 +77,8 @@ export interface AgentToolInput { inputSchema?: unknown outputSchema?: unknown examples?: readonly { args: unknown[], description?: string }[] - /** Invoked when the tool is called. Receives args as provided by the caller. */ - handler: (args: any) => unknown | Promise + /** Invoked when the tool is called. The request-bound context is available to registered and provider tools. */ + handler: (args: any, context?: AgentToolInvocationContext) => unknown | Promise } /** @@ -80,20 +95,52 @@ export interface AgentResource { mimeType?: string } -/** - * Input accepted by `DevframeAgentHost.registerResource()`. - */ +/** One concrete resource returned by a resource template's list callback. */ +export type AgentResourceListItem = Omit + +export interface AgentResourceList { + resources: readonly AgentResourceListItem[] +} + +export type AgentResourceVariables = Readonly> + +/** A resource accepted by `DevframeAgentHost.registerResource()`. */ export interface AgentResourceInput { id: string + /** Optional URI override — if omitted, a `devframe://resource/` URI is generated. */ + uri?: string name: string description?: string mimeType?: string - /** Optional URI override — if omitted, a `devframe://resource/` URI is generated. */ - uri?: string /** Snapshot reader. Called on each read. */ - read: () => Promise | AgentResourceContent + read: (uri: URL) => Promise | AgentResourceContent } +/** Serializable description of a dynamic resource URI template. */ +export interface AgentResourceTemplate { + id: string + uriTemplate: string + name: string + description?: string + mimeType?: string +} + +/** A URI template accepted by `DevframeAgentHost.registerResource()`. */ +export interface AgentResourceTemplateInput { + id: string + uriTemplate: string + name: string + description?: string + mimeType?: string + list?: () => AgentResourceList | Promise + read: ( + uri: URL, + variables: AgentResourceVariables, + ) => Promise | AgentResourceContent +} + +export type AgentResourceDefinition = AgentResourceInput | AgentResourceTemplateInput + /** * Payload returned by `AgentResourceInput.read`. Either `text` or `json` must be set. */ @@ -110,6 +157,7 @@ export interface AgentResourceContent { export interface AgentManifest { tools: readonly AgentTool[] resources: readonly AgentResource[] + resourceTemplates: readonly AgentResourceTemplate[] } /** @@ -119,6 +167,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 +200,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 +250,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 @@ -199,14 +271,16 @@ export interface DevframeAgentHost { * Invoke any tool by id. Routes to the underlying RPC handler for * `kind === 'rpc'`, or to the registered handler for `kind === 'tool'`. */ - invoke: (id: string, args: unknown) => Promise + invoke: (id: string, args: unknown, invocationContext?: AgentToolInvocationContext) => Promise - /** Read a resource by id. */ - read: (id: string) => Promise + /** Read a resource or resolved template by id. */ + read: (id: string, uri?: string | URL, variables?: AgentResourceVariables) => Promise + /** Enumerate the concrete resources supplied by a template. */ + listResourceInstances: (id: string) => Promise /** Look up a tool by id (returns the serializable projection). */ getTool: (id: string) => AgentTool | undefined - /** Look up a resource by id. */ + /** Look up a resource by id or exact URI. */ getResource: (id: string) => AgentResource | undefined } diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 44c5ff7f..cc5a447a 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -110,6 +110,12 @@ export interface McpRouteOptions { * deprecated `allowedHosts`/`allowedOrigins` transport flags). */ allowedOrigins?: readonly string[] | false + /** + * Expose shared-state keys as MCP resources and through the built-in + * `devframe_state_read` tool. Defaults to `true`; pass a predicate to + * expose selected keys. + */ + exposeSharedState?: boolean | ((key: string) => boolean) } export interface DevframeCliOptions { diff --git a/packages/devframe/src/types/rpc.ts b/packages/devframe/src/types/rpc.ts index 1a1e24e7..821c4bc0 100644 --- a/packages/devframe/src/types/rpc.ts +++ b/packages/devframe/src/types/rpc.ts @@ -86,6 +86,8 @@ export interface RpcSharedStateHost { * as dynamic resources. */ onKeyAdded: (fn: (key: string) => void) => () => void + /** Subscribe to updates from any registered shared-state key. */ + onUpdated: (fn: (key: string) => void) => () => void /** * Unregister a shared state and drop its broadcast listeners. Returns * `true` when a state was removed, `false` when the key was unknown. diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 1905ec37..2c405a66 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -575,7 +575,7 @@ export function initHub(options: InitHubOptions): HubInstance { const mounted = mountMcpHttp(app, ctx, joinURL(base, mcpRoute), { serverName: options.name ?? 'devframes-hub', serverVersion: options.version ?? '0.0.0', - exposeSharedState: true, + exposeSharedState: mcpConfig.exposeSharedState ?? true, allowedOrigins: mcpConfig.allowedOrigins, }) return { context: ctx, mcp: { path: mcpRoute }, dispose: mounted.dispose } diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 861a9cb0..869dcb68 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -405,7 +405,40 @@ defineRpcFunction({ }) ``` -Or register tools / resources directly on `ctx.agent.registerTool({ id, description, safety, handler })` and `ctx.agent.registerResource({ id, name, mimeType, read })`. Expose the API over MCP: +Or register tools and resources directly. Resources accept an optional custom URI. Resource templates use `uriTemplate`, may enumerate concrete entries with `list`, and receive parsed variables in `read`. Providers keep definitions lazy when another registry owns them. + +```ts +const resource = ctx.agent.registerResource({ + id: 'builds', + uriTemplate: 'build://{id}', + name: 'Build', + list: () => ({ resources: listBuilds() }), + read: (_uri, variables) => ({ json: readBuild(String(variables.id)) }), +}) + +resource.notifyUpdated('build://current') + +const provider = ctx.agent.registerResourceProvider(() => currentResourceDefinitions()) +provider.notifyChanged() +``` + +Registered and provider tool handlers receive a request-bound invocation context. Report finite, strictly increasing progress while the handler is active: + +```ts +ctx.agent.registerTool({ + id: 'my-inspector:build', + description: 'Build the current project.', + handler: async (_args, invocation) => { + await invocation?.reportProgress({ progress: 1, total: 2, message: 'Compiling' }) + await compileProject() + await invocation?.reportProgress({ progress: 2, total: 2, message: 'Complete' }) + }, +}) +``` + +MCP callers that provide a progress token receive `notifications/progress`; MCP calls without one use a no-op reporter. Agent-enabled RPC functions keep their original signatures. + +`notifyUpdated` sends an invalidation through MCP 2026 `subscriptions/listen` when the caller's `resourceSubscriptions` filter contains the URI. Legacy MCP callers pull current values through the resource list and read methods. Expose the agent surface over MCP: ```ts import { createMcpServer } from 'devframe/adapters/mcp' diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index d3f0cf78..c2efc464 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -18,6 +18,7 @@ export declare const DEVFRAME_EVENTS: { readonly agentToolUnregistered: "agent:tool:unregistered"; readonly agentResourceRegistered: "agent:resource:registered"; readonly agentResourceUnregistered: "agent:resource:unregistered"; + readonly agentResourceUpdated: "agent:resource:updated"; }; readonly client: { readonly isTrustedUpdated: "rpc:is-trusted:updated"; diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 7cab9b7e..06743ae4 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -8,6 +8,7 @@ export interface AgentHandle { export interface AgentManifest { tools: readonly AgentTool[]; resources: readonly AgentResource[]; + resourceTemplates: readonly AgentResourceTemplate[]; } export interface AgentResource { id: string; @@ -21,13 +22,42 @@ export interface AgentResourceContent { json?: unknown; mimeType?: string; } +export interface AgentResourceHandle extends AgentHandle { + notifyUpdated: () => void; +} export interface AgentResourceInput { id: string; + uri?: string; name: string; description?: string; mimeType?: string; - uri?: string; - read: () => Promise | AgentResourceContent; + read: (_: URL) => Promise | AgentResourceContent; +} +export interface AgentResourceList { + resources: readonly AgentResourceListItem[]; +} +export interface AgentResourceProviderHandle extends AgentHandle { + notifyChanged: () => void; + notifyUpdated: (_: string) => void; +} +export interface AgentResourceTemplate { + id: string; + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; +} +export interface AgentResourceTemplateHandle extends AgentHandle { + notifyUpdated: (_: string) => void; +} +export interface AgentResourceTemplateInput { + id: string; + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; + list?: () => AgentResourceList | Promise; + read: (_: URL, _: AgentResourceVariables) => Promise | AgentResourceContent; } export interface AgentTool { id: string; @@ -58,7 +88,15 @@ export interface AgentToolInput { args: unknown[]; description?: string; }[]; - handler: (_: any) => unknown | Promise; + handler: (_: any, _?: AgentToolInvocationContext) => unknown | Promise; +} +export interface AgentToolInvocationContext { + reportProgress: (_: AgentToolProgress) => Promise; +} +export interface AgentToolProgress { + progress: number; + total?: number; + message?: string; } export interface AgentToolProviderHandle extends AgentHandle { notifyChanged: () => void; @@ -92,19 +130,25 @@ export interface DevframeAgentHost { registerTool: (_: AgentToolInput) => AgentHandle; unregisterTool: (_: string) => boolean; registerToolProvider: (_: AgentToolProvider) => AgentToolProviderHandle; - registerResource: (_: AgentResourceInput) => AgentHandle; + registerResource: { + (_: AgentResourceInput): AgentResourceHandle; + (_: AgentResourceTemplateInput): AgentResourceTemplateHandle; + }; + registerResourceProvider: (_: AgentResourceProvider) => AgentResourceProviderHandle; unregisterResource: (_: string) => boolean; list: () => AgentManifest; - invoke: (_: string, _: unknown) => Promise; - read: (_: string) => Promise; + invoke: (_: string, _: unknown, _?: AgentToolInvocationContext) => Promise; + read: (_: string, _?: string | URL, _?: AgentResourceVariables) => Promise; + listResourceInstances: (_: string) => Promise; getTool: (_: string) => AgentTool | undefined; getResource: (_: string) => AgentResource | undefined; } export interface DevframeAgentHostEvents { 'agent:tool:registered': (_: AgentTool) => void; 'agent:tool:unregistered': (_: string) => void; - 'agent:resource:registered': (_: AgentResource) => void; + 'agent:resource:registered': (_: AgentResource | AgentResourceTemplate) => void; 'agent:resource:unregistered': (_: string) => void; + 'agent:resource:updated': (_: string) => void; 'agent:manifest:changed': () => void; } export interface DevframeCapabilities { @@ -387,6 +431,7 @@ export interface EventUnsubscribe { export interface McpRouteOptions { path?: string; allowedOrigins?: readonly string[] | false; + exposeSharedState?: boolean | ((_: string) => boolean); } export interface RemoteAssets { package: string; @@ -439,6 +484,7 @@ export interface RpcSharedStateHost { get: (_: string, _?: RpcSharedStateGetOptions) => Promise>; keys: () => string[]; onKeyAdded: (_: (_: string) => void) => () => void; + onUpdated: (_: (_: string) => void) => () => void; delete: (_: string) => boolean; } export interface RpcStreamingChannel { @@ -472,6 +518,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..1fa22487 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -24,22 +24,29 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { readonly events: EventEmitter; private readonly tools; private readonly resources; - private readonly providers; + private readonly toolProviders; + private readonly resourceProviders; private _rpcUnsubscribe; constructor(_: DevframeNodeContext); registerTool(_: AgentToolInput): AgentHandle; unregisterTool(_: string): boolean; registerToolProvider(_: AgentToolProvider): AgentToolProviderHandle; - registerResource(_: AgentResourceInput): AgentHandle; + registerResource(_: AgentResourceInput): AgentResourceHandle; + registerResource(_: AgentResourceTemplateInput): AgentResourceTemplateHandle; + registerResourceProvider(_: AgentResourceProvider): AgentResourceProviderHandle; unregisterResource(_: string): boolean; list(): AgentManifest; getTool(_: string): AgentTool | undefined; getResource(_: string): AgentResource | undefined; - invoke(_: string, _: unknown): Promise; - read(_: string): Promise; + invoke(_: string, _: unknown, _?: AgentToolInvocationContext): Promise; + read(_: string, _?: string | URL, _?: AgentResourceVariables): Promise; + listResourceInstances(_: string): Promise; _dispose(): void; private _validateToolId; private _projectTool; + private _projectResource; + private _collectResourceDefinitions; + private _findResourceDefinition; private _collectProviderTools; private _collectRpcTools; private _findRpcDefinition; @@ -342,6 +349,12 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "A service package's default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function."; }; + readonly DF0071: { + readonly why: (p: { + reason: string; + }) => string; + readonly fix: "Report finite numbers and increase `progress` on every call within one tool invocation."; + }; readonly DF0072: { readonly why: (p: { method: string; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 99be3647..847c8379 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -6,9 +6,21 @@ 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 { AgentToolInvocationContext } +export { AgentToolProgress } export { AgentToolProvider } export { AgentToolProviderHandle } export { ConnectionMeta }