Skip to content

Commit 322f7d1

Browse files
committed
feat(devframe): add request-bound tool progress
1 parent 703d0cf commit 322f7d1

15 files changed

Lines changed: 487 additions & 14 deletions

File tree

docs/content/1.guide/15.agent-native.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,32 @@ const handle = ctx.agent.registerToolProvider(() =>
8282
handle.notifyChanged() // fires tools/list_changed
8383
```
8484

85+
## Reporting tool progress
86+
87+
Registered and provider tool handlers receive an optional request-bound invocation context:
88+
89+
```ts
90+
ctx.agent.registerTool({
91+
id: 'my-plugin:build',
92+
description: 'Build the current project.',
93+
handler: async (_args, invocation) => {
94+
await invocation?.reportProgress({
95+
progress: 1,
96+
total: 2,
97+
message: 'Compiling',
98+
})
99+
await compileProject()
100+
await invocation?.reportProgress({
101+
progress: 2,
102+
total: 2,
103+
message: 'Complete',
104+
})
105+
},
106+
})
107+
```
108+
109+
`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.
110+
85111
## Registering a resource
86112

87113
Readable snapshots by URI:

docs/content/6.errors/DF0071.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
title: 'DF0071: Invalid Agent Tool Progress'
3+
description: 'Invalid agent tool progress: {reason}.'
4+
---
5+
6+
## Message
7+
> Invalid agent tool progress: `{reason}`.
8+
9+
## Cause
10+
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.
11+
12+
## Example
13+
14+
```ts
15+
handler: async (_args, invocation) => {
16+
await invocation?.reportProgress({ progress: 1, total: 2 })
17+
await invocation?.reportProgress({ progress: 1, total: 2 })
18+
}
19+
```
20+
21+
## Fix
22+
23+
Use finite numbers and increase `progress` on every report within one tool invocation.
24+
25+
```ts
26+
handler: async (_args, invocation) => {
27+
await invocation?.reportProgress({ progress: 1, total: 2 })
28+
await invocation?.reportProgress({ progress: 2, total: 2 })
29+
}
30+
```
31+
32+
## Source
33+
34+
- [`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.

docs/content/6.errors/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Emitted by `devframe` — the framework-neutral host, RPC, streaming, assets, se
7878
| [DF0068](/errors/DF0068) | error | Required Service Version Range Not Satisfied |
7979
| [DF0069](/errors/DF0069) | warn | Service Version Range Not Satisfied |
8080
| [DF0070](/errors/DF0070) | error | Invalid Service |
81+
| [DF0071](/errors/DF0071) | error | Invalid Agent Tool Progress |
8182
| [DF0072](/errors/DF0072) | warn | Snapshot Names Unknown RPC Method |
8283
| [DF0073](/errors/DF0073) | error | JSON-Render Spec Does Not Match Its Schema |
8384
| [DF0074](/errors/DF0074) | error | JSON-Render Schema Is Asynchronous |
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { DevframeDefinition } from '../../../../types/devframe'
2+
import { createMcpServer } from '../../build-server'
3+
4+
const definition: DevframeDefinition = {
5+
id: 'progress-stdio-test',
6+
name: 'Progress stdio test',
7+
version: '1.0.0',
8+
packageName: '@devframe/progress-stdio-test',
9+
homepage: 'https://example.com',
10+
description: 'Stdio progress test fixture.',
11+
setup(ctx) {
12+
ctx.agent.registerTool({
13+
id: 'build',
14+
description: 'Build the project.',
15+
handler: async (_args, invocation) => {
16+
await invocation?.reportProgress({ progress: 1, total: 2, message: 'Compiling' })
17+
await invocation?.reportProgress({ progress: 2, total: 2, message: 'Testing' })
18+
return { status: 'complete' }
19+
},
20+
})
21+
},
22+
}
23+
24+
await createMcpServer(definition, { transport: 'stdio' })

packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,45 @@ describe('mcp adapter (streamable http route)', () => {
9696
}
9797
})
9898

99+
it('delivers request-bound tool progress before the route-based result', async () => {
100+
expect.assertions(1)
101+
const started = await boot(defineTestDef({
102+
setup(ctx) {
103+
ctx.agent.registerTool({
104+
id: 'build',
105+
description: 'Build the project.',
106+
handler: async (_args, invocation) => {
107+
await invocation?.reportProgress({ progress: 1, total: 2, message: 'Compiling' })
108+
await invocation?.reportProgress({ progress: 2, total: 2, message: 'Testing' })
109+
return { status: 'complete' }
110+
},
111+
})
112+
},
113+
}))
114+
const client = new Client(
115+
{ name: 'progress-test-client', version: '0.0.0' },
116+
{ versionNegotiation: { mode: 'auto' } },
117+
)
118+
const events: unknown[] = []
119+
try {
120+
await client.connect(originTransport(started))
121+
const result = await client.callTool(
122+
{ name: 'build', arguments: {} },
123+
{ onprogress: progress => events.push({ type: 'progress', ...progress }) },
124+
)
125+
events.push({ type: 'result', structuredContent: result.structuredContent })
126+
127+
expect(events).toEqual([
128+
{ type: 'progress', progress: 1, total: 2, message: 'Compiling' },
129+
{ type: 'progress', progress: 2, total: 2, message: 'Testing' },
130+
{ type: 'result', structuredContent: undefined },
131+
])
132+
}
133+
finally {
134+
await client.close()
135+
}
136+
})
137+
99138
it('delivers filtered resource updates through modern subscriptions/listen', async () => {
100139
let notifyBuildUpdated!: () => void
101140
let notifyIgnoredUpdated!: () => void

packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,52 @@ describe('mcp adapter (in-memory)', () => {
209209
}
210210
})
211211

212+
it('uses a no-op progress reporter when the caller provides no token', async () => {
213+
expect.assertions(1)
214+
const { ctx, client, cleanup } = await bootPair()
215+
try {
216+
ctx.agent.registerTool({
217+
id: 'quiet-progress',
218+
description: 'Report progress without a listening caller.',
219+
handler: async (_args, invocation) => {
220+
await invocation?.reportProgress({ progress: 1, message: 'Running' })
221+
return 'complete'
222+
},
223+
})
224+
225+
const result = await client.callTool({ name: 'quiet-progress', arguments: {} })
226+
expect((result.content[0] as { text: string }).text).toBe('complete')
227+
}
228+
finally {
229+
await cleanup()
230+
}
231+
})
232+
233+
it('returns DF0071 when tool progress does not increase', async () => {
234+
expect.assertions(2)
235+
const { ctx, client, cleanup } = await bootPair()
236+
try {
237+
ctx.agent.registerTool({
238+
id: 'invalid-progress',
239+
description: 'Report invalid progress.',
240+
handler: async (_args, invocation) => {
241+
await invocation?.reportProgress({ progress: 1 })
242+
await invocation?.reportProgress({ progress: 1 })
243+
},
244+
})
245+
246+
const result = await client.callTool(
247+
{ name: 'invalid-progress', arguments: {} },
248+
{ onprogress: () => {} },
249+
)
250+
expect(result.isError).toBe(true)
251+
expect((result.content[0] as { text: string }).text).toContain('DF0071')
252+
}
253+
finally {
254+
await cleanup()
255+
}
256+
})
257+
212258
it('lists and reads registered resources', async () => {
213259
const { ctx, client, cleanup } = await bootPair()
214260
try {
@@ -459,6 +505,40 @@ describe('mcp adapter (in-memory)', () => {
459505
})
460506

461507
describe('mcp adapter (stdio)', () => {
508+
it('delivers request-bound tool progress before the stdio result', async () => {
509+
expect.assertions(1)
510+
const fixture = fileURLToPath(new URL('./fixtures/progress-stdio-server.ts', import.meta.url))
511+
const transport = new StdioClientTransport({
512+
command: process.execPath,
513+
args: ['--import', 'tsx', fixture],
514+
cwd: process.cwd(),
515+
stderr: 'pipe',
516+
})
517+
const client = new Client(
518+
{ name: 'stdio-progress-test-client', version: '0.0.0' },
519+
{ versionNegotiation: { mode: 'auto' } },
520+
)
521+
const events: unknown[] = []
522+
523+
try {
524+
await client.connect(transport)
525+
const result = await client.callTool(
526+
{ name: 'build', arguments: {} },
527+
{ onprogress: progress => events.push({ type: 'progress', ...progress }) },
528+
)
529+
events.push({ type: 'result', text: (result.content[0] as { text: string }).text })
530+
531+
expect(events).toEqual([
532+
{ type: 'progress', progress: 1, total: 2, message: 'Compiling' },
533+
{ type: 'progress', progress: 2, total: 2, message: 'Testing' },
534+
{ type: 'result', text: '{\n "status": "complete"\n}' },
535+
])
536+
}
537+
finally {
538+
await client.close()
539+
}
540+
})
541+
462542
it('lists, reads, and receives modern updates for registered, template, and shared-state resources', async () => {
463543
const fixture = fileURLToPath(new URL('./fixtures/resource-stdio-server.ts', import.meta.url))
464544
const transport = new StdioClientTransport({

packages/devframe/src/adapters/mcp/build-server.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { McpRequestContext, Resource, Tool, Variables } from '@modelcontextprotocol/server'
1+
import type { McpRequestContext, Resource, ServerContext, Tool, Variables } from '@modelcontextprotocol/server'
22
import type { StandardSchemaV1 } from '@standard-schema/spec'
33
import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc'
4-
import type { AgentTool, DevframeDefinition, DevframeHost, DevframeNodeContext } from 'devframe/types'
4+
import type { AgentTool, AgentToolInvocationContext, DevframeDefinition, DevframeHost, DevframeNodeContext } from 'devframe/types'
55
import { homedir } from 'node:os'
66
import process from 'node:process'
77
import { Server, UriTemplate } from '@modelcontextprotocol/server'
@@ -262,6 +262,44 @@ async function readStateResult(
262262
return { key, value: state.value() }
263263
}
264264

265+
interface McpToolInvocation {
266+
context: AgentToolInvocationContext
267+
flushProgress: () => Promise<void>
268+
}
269+
270+
function createMcpToolInvocation(requestContext: ServerContext): McpToolInvocation {
271+
const progressToken = requestContext.mcpReq._meta?.progressToken
272+
if (progressToken === undefined) {
273+
return {
274+
context: { reportProgress: async () => {} },
275+
flushProgress: async () => {},
276+
}
277+
}
278+
279+
let progressReported = false
280+
return {
281+
context: {
282+
async reportProgress(update) {
283+
progressReported = true
284+
await requestContext.mcpReq.notify({
285+
method: 'notifications/progress',
286+
params: {
287+
progressToken,
288+
progress: update.progress,
289+
...(update.total === undefined ? {} : { total: update.total }),
290+
...(update.message === undefined ? {} : { message: update.message }),
291+
},
292+
})
293+
},
294+
},
295+
async flushProgress() {
296+
/** Let the SDK transport flush progress before the terminal response. */
297+
if (progressReported)
298+
await new Promise<void>(resolve => setImmediate(resolve))
299+
},
300+
}
301+
}
302+
265303
function registerToolHandlers(
266304
server: Server,
267305
ctx: DevframeNodeContext,
@@ -305,8 +343,9 @@ function registerToolHandlers(
305343
return { tools }
306344
})
307345

308-
server.setRequestHandler('tools/call', async (request) => {
346+
server.setRequestHandler('tools/call', async (request, requestContext) => {
309347
const { name, arguments: args } = request.params
348+
const invocation = createMcpToolInvocation(requestContext)
310349
try {
311350
const tool = resolveTool(name)
312351
// Built-in shared-state read. A registered agent tool resolving to
@@ -323,7 +362,12 @@ function registerToolHandlers(
323362
const outputSchema = tool
324363
? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx))
325364
: undefined
326-
const result = await ctx.agent.invoke(tool?.id ?? name, args ?? {})
365+
const result = await ctx.agent.invoke(
366+
tool?.id ?? name,
367+
args ?? {},
368+
invocation.context,
369+
)
370+
await invocation.flushProgress()
327371
return {
328372
content: [
329373
{
@@ -335,6 +379,7 @@ function registerToolHandlers(
335379
}
336380
}
337381
catch (error) {
382+
await invocation.flushProgress()
338383
return {
339384
isError: true,
340385
content: [

0 commit comments

Comments
 (0)