Skip to content

Commit adb25b4

Browse files
fix(mcp): project a streamed Agent.Progress fallback to notifications/progress (#448) (#498)
* fix(mcp): project a streamed Agent.Progress fallback to notifications/progress (#448) * chore: changeset PR number * fix(cli): draw the TTY progress line from a streamed Agent.Progress fallback too (#448)
1 parent 61ff1f1 commit adb25b4

16 files changed

Lines changed: 429 additions & 46 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@agent-bundle/runtime': patch
3+
'agent-bundle': patch
4+
---
5+
6+
Project an `Agent.Progress` node streamed in a `Suspense` fallback (any `shell`/`replace` document) to `notifications/progress` when the MCP request carries `_meta.progressToken`, under the same monotonic `progress` rule as `progress.report()` so a re-streamed fallback or an explicit report of the same step is never duplicated; the rendered CLI's interactive TTY draws its in-place progress line from the same streamed node. A fallback alone is now enough on both surfaces; `announce()`-style shims that repeat the fallback message through `progress.report()` are unnecessary. Fixes #448. (#498)

‎docs/framework-mode.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ The final Agent Document of a tool route lowers to one `CallToolResult`:
210210
| `Agent.Image`, `Agent.Audio`, `Agent.Resource` | Native `image`, `audio`, and `resource_link` blocks; a host without that capability fails the projection closed unless a text fallback is selected. |
211211
| `Agent.Result value` | `structuredContent` when the value is a JSON object; a non-object value emits none and is never wrapped. |
212212
| `Agent.Result metadata` | `CallToolResult._meta`. It must be a JSON object (snapshotted through the same wire boundary as `structuredContent`); anything else fails the projection closed with `McpProjectionError('invalid-result-metadata')`. Listing-level `_meta` still comes from static `config._meta`, so the MCP Apps convention stamps `_meta.ui.resourceUri` on both halves. In `config._meta.ui.resourceUri`, reference the App route instead of repeating its `ui://` literal: `appResourceUri('dashboard')` from `agent-bundle/routes` resolves at compile time to that App route's `config.resourceUri`, and a `const` string literal imported from a relative sibling module (`import { DASHBOARD_URI } from '../constants'`) is accepted too and stays available at run time for the result half. |
213+
| `Agent.Progress` | Never a `content` block. Streamed inside a `shell` or `replace` document — normally as a `Suspense` fallback — it projects to one `notifications/progress` (`progress` from `completed`, plus `message` and `total` when present) when the request carried `_meta.progressToken`; a request without a token gets none. The same monotonic rule applies as to `progress.report()`: each notification's `progress` must exceed the last, so a fallback re-streamed on the next chunk, or one an explicit report already announced with the same `completed`, is not repeated. A fallback alone is enough — an `announce()`-style helper that repeats the fallback message through `progress.report()` adds nothing (#448). A progress node in the final document is content only. The rendered CLI's interactive TTY draws its in-place progress line from the same streamed node (redrawn only when the fallback changes); piped Markdown, `--json`, and `--ndjson` never print it. |
213214
| `Agent.Error code message` | `isError: true` plus one text block `[<code>] <message>`. The wire has no error-code field, so the code is deliberately kept in the text (the routed CLI prints the same `**[code]** message` form); choose codes that read well to the model. |
214215
| `resultSchema` | `outputSchema` in `tools/list` **only when the schema describes an object** (`z.object`, `z.record`, a discriminated union of objects). The MCP specification requires every result of a tool that declares `outputSchema` to carry `structuredContent`, so a text-only route declares `resultSchema = z.undefined()` (or any non-object schema), advertises no `outputSchema`, and returns no `structuredContent`. An object schema keeps the SDK's fail-closed output validation on every call. |
215216

‎examples/audiobook-curator/README.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,15 @@ structured shelf and render an explicit unavailable notice.
138138

139139
### Suspense becomes MCP progress
140140

141-
`audit_library` first reports progress through the request's
142-
`context.progress`, then places the asynchronous `LibraryAnalysis` component
143-
behind React `Suspense`. While that component re-stats duplicate candidates and
141+
`audit_library` places the asynchronous `LibraryAnalysis` component behind
142+
React `Suspense`. While that component re-stats duplicate candidates and
144143
calculates reclaimable bytes, its fallback is an `Agent.Progress` document
145-
node. The generated MCP projector streams the progress state and then replaces
146-
it with the completed analysis without changing the final structured
147-
`LibraryAuditReceipt`. The rendered `library-audit` CLI route composes the same
148-
analysis and fallback.
144+
node — and that node is the whole progress story: the generated MCP projector
145+
turns the streamed fallback into `notifications/progress` for a client that
146+
sent a progress token, then replaces it with the completed analysis without
147+
changing the final structured `LibraryAuditReceipt`. No `progress.report()`
148+
call repeats the fallback's message. The rendered `library-audit` CLI route
149+
composes the same analysis and fallback.
149150

150151
### CLI routes have rendered and plain modes
151152

‎examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Agent, agent } from '@agent-bundle/runtime';
1+
import { Agent } from '@agent-bundle/runtime';
22
import React, { Suspense } from 'react';
33
import type { ToolRouteProps } from 'agent-bundle';
44

@@ -20,12 +20,9 @@ export const resultSchema = operation.resultSchema;
2020

2121
export default async function Route({ input, signal }: ToolRouteProps<typeof inputSchema>) {
2222
const receipt = await operation.handler(input, { signal }) as LibraryAuditReceipt;
23-
const context = await agent();
24-
await context.progress.report({
25-
completed: 0,
26-
message: 'Analyzing duplicate and multipart groups',
27-
total: 1,
28-
});
23+
// The Suspense fallback is the progress surface: the MCP projector turns the
24+
// streamed `Agent.Progress` node into `notifications/progress` for a client
25+
// that sent a progress token, so no `progress.report()` repeats the message.
2926
return (
3027
<Agent.Result value={receipt}>
3128
<Agent.Text>{libraryAuditHeadline(receipt)}</Agent.Text>

‎examples/audiobook-curator/tests/route-unit/streaming.test.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,6 @@ it('streams library analysis after the audit shell while preserving the canonica
5454
summary: { files: 2 },
5555
});
5656

57-
const completeIndex = rendered.events.findIndex((event) => event.type === 'complete');
58-
const progressIndex = rendered.events.findIndex((event) => event.type === 'progress');
5957
const projected = await projectTargetCapabilities(
6058
rendered,
6159
createTargetCapabilityFixture({
@@ -67,14 +65,14 @@ it('streams library analysis after the audit shell while preserving the canonica
6765
}),
6866
);
6967

70-
expect(progressIndex).toBeGreaterThanOrEqual(0);
71-
expect(progressIndex).toBeLessThan(completeIndex);
72-
expect(projected.progress.length).toBeGreaterThanOrEqual(1);
73-
expect(projected.progress[0]).toMatchObject({
68+
// The route never calls `progress.report()`: the streamed Suspense fallback
69+
// alone is what the MCP projector announces (agent-bundle#448).
70+
expect(rendered.events.some((event) => event.type === 'progress')).toBe(false);
71+
expect(projected.progress).toEqual([{
7472
message: 'Analyzing duplicate and multipart groups',
7573
progress: 0,
7674
progressToken: 'agent-bundle-target-capability-fixture',
77-
});
75+
}]);
7876
expect(projected.structuredContent).toEqual(rendered.document.value);
7977
} finally {
8078
await rm(directory, { force: true, recursive: true });

‎packages/agent-bundle/src/cli-entry.ts‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -482,11 +482,46 @@ export const projectCliDocumentToMarkdown = (document: CliRenderedDocument): str
482482
return blocks.length === 0 ? '' : `${blocks.join('\n\n')}\n`;
483483
};
484484

485-
const progressLine = (event: { readonly completed: number; readonly message?: string; readonly total?: number }): string => {
485+
/** The progress fields a render `progress` event and an `Agent.Progress` document node share. */
486+
interface CliProgressSource {
487+
readonly completed: number;
488+
readonly message?: string;
489+
readonly total?: number;
490+
}
491+
492+
const progressLine = (event: CliProgressSource): string => {
486493
const counter = event.total === undefined ? String(event.completed) : `${String(event.completed)}/${String(event.total)}`;
487494
return event.message === undefined ? counter : `${event.message} (${counter})`;
488495
};
489496

497+
/**
498+
* The `Agent.Progress` nodes of a streamed `shell`/`replace` document, in
499+
* document order — a `Suspense` fallback rendered as `Agent.Progress` is the
500+
* route's progress surface, so the interactive TTY shows it exactly as it
501+
* shows an explicit `progress.report()` (#448).
502+
*/
503+
const progressNodes = (node: CliRenderedDocumentNode): readonly CliProgressSource[] => {
504+
switch (node.kind) {
505+
case 'result':
506+
return node.children.flatMap(progressNodes);
507+
case 'progress':
508+
return [node];
509+
case 'audio':
510+
case 'context':
511+
case 'error':
512+
case 'image':
513+
case 'json':
514+
case 'markdown':
515+
case 'resource':
516+
case 'text':
517+
return [];
518+
default: {
519+
const unreachable: never = node;
520+
throw new TypeError(`Unsupported Agent Document node ${String((unreachable as { kind?: string }).kind)}.`);
521+
}
522+
}
523+
};
524+
490525
const clearProgressLine = '\r\u001B[2K';
491526

492527
interface RenderedRunOptions {
@@ -509,6 +544,21 @@ const runRenderedInvocation = async (options: RenderedRunOptions): Promise<numbe
509544
const reader = options.session.events().getReader();
510545
let complete: CliRenderedDocument | undefined;
511546
let progressShown = false;
547+
const showProgress = (source: CliProgressSource): void => {
548+
if (mode !== 'tty') return;
549+
writeOut(`${clearProgressLine}${progressLine(source)}`);
550+
progressShown = true;
551+
};
552+
// A fallback is re-streamed with every chunk that leaves its boundary
553+
// pending; the line is redrawn only when the fallback itself changed. A TTY
554+
// has no monotonic constraint, so an explicit report always redraws.
555+
const shownFallbacks = new Set<string>();
556+
const showFallback = (node: CliProgressSource): void => {
557+
const key = JSON.stringify([node.completed, node.message, node.total]);
558+
if (shownFallbacks.has(key)) return;
559+
shownFallbacks.add(key);
560+
showProgress(node);
561+
};
512562
const clearProgress = (): void => {
513563
if (progressShown) {
514564
writeOut(clearProgressLine);
@@ -526,12 +576,10 @@ const runRenderedInvocation = async (options: RenderedRunOptions): Promise<numbe
526576
switch (event.type) {
527577
case 'shell':
528578
case 'replace':
579+
for (const node of progressNodes(event.document.root)) showFallback(node);
529580
break;
530581
case 'progress':
531-
if (mode === 'tty') {
532-
writeOut(`${clearProgressLine}${progressLine(event)}`);
533-
progressShown = true;
534-
}
582+
showProgress(event);
535583
break;
536584
case 'error':
537585
if (mode !== 'ndjson') {

‎packages/agent-bundle/tests/projection/cli-dispatch-rendered.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@ describe('rendered commands at the CLI dispatch level', () => {
2424
expect(run.stdout.endsWith('# Report: books\n\nGenerated for books.\n\nitems: 2\n')).toBe(true);
2525
});
2626

27+
it('shows a streamed Agent.Progress Suspense fallback on the TTY without an explicit report (#448)', async () => {
28+
// The projected `harness catalog` command never calls `progress.report()`;
29+
// its only progress surface is the `<Suspense fallback={<Agent.Progress …/>}>`.
30+
const tty = await invokeCli(['harness', 'catalog', '--input', '{"genre":"mystery"}', '--yes'], { tty: true });
31+
32+
expect(tty.exitCode).toBe(0);
33+
expect(tty.stderr).toBe('');
34+
expect(tty.stdout).toContain('\r\u001B[2Kloading mystery (0/2)');
35+
// Drawn once, although the shell and the replace both carried the node.
36+
expect(tty.stdout.split('loading mystery (0/2)')).toHaveLength(2);
37+
expect(tty.stdout.endsWith('catalog: mystery\n\n## mystery\n\n- Piranesi\n- Solaris\n')).toBe(true);
38+
39+
// Piped output is the final document only: the fallback never prints.
40+
const piped = await invokeCli(['harness', 'catalog', '--input', '{"genre":"mystery"}', '--yes']);
41+
expect(piped.stdout).toBe('catalog: mystery\n\n## mystery\n\n- Piranesi\n- Solaris\n');
42+
expect(piped.stdout).not.toContain('loading mystery');
43+
});
44+
2745
describe('a projected MCP command whose route throws (#492)', () => {
2846
it('reports a root throw on stderr with exit 1 and nothing on stdout', async () => {
2947
const run = await invokeCli(['harness', 'fault', '--input', '{"mode":"throw"}']);

‎packages/agent-bundle/tests/projection/mcp-in-memory.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,38 @@ describe('the in-memory MCP projection level', () => {
202202
expect(invocation.structuredContent).toEqual({ genre: 'mystery', titles: ['Piranesi', 'Solaris'] });
203203
});
204204

205+
it('notifies the client of a streamed Agent.Progress fallback under its own progress token (#448)', async () => {
206+
await using session = await openInMemoryMcpServer();
207+
const notifications: unknown[] = [];
208+
session.client.setNotificationHandler('notifications/progress', (notification) => {
209+
notifications.push(notification.params);
210+
});
211+
212+
// Without a token the same render produces no notification at all.
213+
await session.client.callTool({ arguments: { genre: 'mystery' }, name: 'catalog' });
214+
expect(notifications).toEqual([]);
215+
216+
// The catalog route never calls `progress.report()`; the request's own
217+
// `_meta.progressToken` is what turns its streamed fallback into the wire
218+
// notification, exactly as for an explicit report.
219+
const result = await session.client.callTool({
220+
arguments: { genre: 'mystery' },
221+
name: 'catalog',
222+
_meta: { progressToken: 'tok-448' },
223+
});
224+
225+
expect(notifications).toEqual([
226+
{ message: 'loading mystery', progress: 0, progressToken: 'tok-448', total: 2 },
227+
]);
228+
expect(result).toMatchObject({
229+
content: [
230+
{ text: 'catalog: mystery', type: 'text' },
231+
{ text: '## mystery\n\n- Piranesi\n- Solaris', type: 'text' },
232+
],
233+
structuredContent: { genre: 'mystery', titles: ['Piranesi', 'Solaris'] },
234+
});
235+
});
236+
205237
it('reads a compiled resource route by its configured URI', async () => {
206238
const read = await readMcpResource('harness://notes');
207239

‎packages/agent-bundle/tests/projection/target-capabilities.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ describe('route-unit target-capability projection', () => {
6666
expect(projected.structuredContent).toEqual({ fixture: 'target-capabilities' });
6767
});
6868

69+
it('projects an Agent.Progress Suspense fallback streamed in the shell to notifications/progress (#448)', async () => {
70+
// The catalog route reports no progress itself: its only progress surface
71+
// is the `<Suspense fallback={<Agent.Progress …/>}>` the shell streams.
72+
const rendered = await renderRouteEvents('tool:harness/catalog', { input: { genre: 'mystery' } });
73+
expect(rendered.events.some((event) => event.type === 'progress')).toBe(false);
74+
75+
const projected = await projectTargetCapabilities(rendered, fixture());
76+
expect(projected.progress).toEqual([{
77+
message: 'loading mystery',
78+
progress: 0,
79+
progressToken: 'agent-bundle-target-capability-fixture',
80+
total: 2,
81+
}]);
82+
// The resolved boundary, not the fallback, is what the result carries.
83+
expect(projected.content).toEqual([
84+
{ text: 'catalog: mystery', type: 'text' },
85+
{ text: '## mystery\n\n- Piranesi\n- Solaris', type: 'text' },
86+
]);
87+
88+
const silent = await projectTargetCapabilities(rendered, fixture({ progress: false }));
89+
expect(silent.progress).toEqual([]);
90+
});
91+
6992
it('uses exact text fallbacks and leaks no denied rich block', async () => {
7093
const projected = await projectTargetCapabilities(await renderRichContent(), fixture({
7194
audio: false,

‎packages/rsc-runtime/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ elapsed time are bounded on the reconciler.
1717

1818
Generated MCP tool calls project the live render-event stream through
1919
`projectMcpRenderStream`: `notifications/progress` is emitted only when the
20-
caller supplied a progress token, shell/replace stay internal, and the
21-
request resolves to one final `CallToolResult`. Image, audio, and resource
20+
caller supplied a progress token — for `progress.report()` events and for
21+
`Agent.Progress` nodes streamed in a shell/replace document (a `Suspense`
22+
fallback), under one monotonic `progress` rule so neither source duplicates
23+
the other — shell/replace content stays internal, and the request resolves to
24+
one final `CallToolResult`. Image, audio, and resource
2225
blocks are capability-gated — unsupported rich content uses a declared
2326
fallback or a typed `McpProjectionError`, never a silent drop. The existing
2427
`lowerMcpResult` / `lowerHookResult` helpers remain synchronous compatibility

0 commit comments

Comments
 (0)