Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/369-mcp-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agent-bundle": patch
"@agent-bundle/runtime": patch
---

Serve task-augmented MCP tool calls (the MCP `2025-11-25` Tasks utility) from generated route servers: a tool route that declares `config.execution.taskSupport: 'optional' | 'required'` (validated as `AB4836`, advertised in `tools/list`) answers a `tools/call` carrying `params.task` with a `CreateTaskResult` while the render continues behind the task; `tasks/get` reports status and the last render progress, `tasks/result` returns the same `CallToolResult` an ordinary call produces, `tasks/cancel` interrupts the render, and `tasks/list` lists the session's tasks. Clients that never ask for a task see no change; a server whose tools never opted in advertises no `tasks` capability. The Workbench MCP page runs a tool as a task, polls it, fetches its result, and cancels it; `agent-bundle/test`'s `openInMemoryMcpServer` client drives the same lifecycle. The host capability tables gain an `mcp.tasks` row recording whether each pinned host issues task-augmented calls. `@agent-bundle/runtime`'s operation-based `createRscMcpServer` is unchanged — no `tasks` capability, ordinary processing — and its README now records that instead of the lifted deferral (#550)
5 changes: 3 additions & 2 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ even when no error diagnostic was reported.
| `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). |
| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/<name>.mjs`; a host-emitted file collides with it (see below). |
| `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. |
| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), and provider conventions (see below). |
| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), and provider conventions (see below). |
| `AB5000` | General CLI and adapter failures. |
| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). |
| `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). |
Expand Down Expand Up @@ -562,7 +562,7 @@ framework-owned plugin twice by accident.
| `AB4723` | error | `tools.rspack` is not an Rspack config object, a mutator function, or an array of both. | Use one of the three Rslib `tools.rspack` forms. |
| `AB4724` | error | `tools.rsbuild.plugins` supplies a plugin whose `name` matches a framework-owned registration (`rsbuild:react` from `@rsbuild/plugin-react`). The message names the plugin and its package. | Remove the plugin from `tools.rsbuild.plugins`; agent-bundle registers it in every config it synthesizes. |

## Route graph, state, layout, and provider conventions (`AB4800`–`AB4835`, `AB4940`–`AB4942`)
## Route graph, state, layout, and provider conventions (`AB4800`–`AB4836`, `AB4940`–`AB4942`)

The route-graph compiler discovers conventional route modules
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand Down Expand Up @@ -776,6 +776,7 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4833` | error | `notices.retention` is malformed: `notices` or `retention` is not an object, carries an unknown key, `terminalTtl` is not a positive integer of milliseconds or a duration such as `"7d"`, `"12h"`, `"30m"`, or `"90s"`, `maxTerminal` / `maxJournalBytes` is not a positive integer — or the policy is declared by a project without a conventional `src/state.ts`, which has no co-mounted notice ledger to retain. Omit a field to keep the runtime default (`7d`, `500`, `16777216`). |
| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. | Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. |
| `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: <positive integer ≤ 86400000> }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. |
| `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. |
| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. |
| `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. |
| `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. |
Expand Down
16 changes: 12 additions & 4 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -981,10 +981,18 @@ top level without a default export — keep today's behavior byte for byte.

Every served tool call is one ordinary `tools/call`: optional
`notifications/progress` while the caller's progress token is live, then one
final `CallToolResult`. The shell advertises no `tasks` capability and
processes a task-augmented request as an ordinary one; task-augmented calls
are deferred until the MCP SDK ships a task runtime (see
[MCP conformance evidence](./mcp-conformance.md#task-augmented-requests-deferred-2026-09-02)).
final `CallToolResult`. The operation-based `createRscMcpServer` shell above
advertises no `tasks` capability and processes a task-augmented request as an
ordinary one, as the `2025-11-25` Tasks utility requires of a receiver without
the capability. Generated route servers (`src/mcp/<server>/tools/*.tsx`) serve
the utility for tool routes that declare `config.execution.taskSupport`: a
`tools/call` carrying `params.task` answers with a `CreateTaskResult`, the
Flight render continues behind the task, `tasks/get` reports status and the
last render progress, `tasks/result` blocks for the same `CallToolResult` the
ordinary call returns, `tasks/cancel` interrupts the render through its
`AbortSignal`, and `tasks/list` lists the session's tasks (see
[Long-running tools: tasks](https://scriptedalchemy.github.io/agent-bundle/guide/authoring/mcp#long-running-tools-tasks)
and [MCP conformance evidence](./mcp-conformance.md#task-augmented-requests-served-2026-09-04)).

The same lifecycle is public API for hand-rolled entries:

Expand Down
82 changes: 81 additions & 1 deletion docs/mcp-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,87 @@ route harness does not provide. The official runner rejects both unexpected
failures and stale baseline entries, so newly fixed scenarios must be removed
from the baseline.

## Task-augmented requests: deferred 2026-09-02
## Task-augmented requests: served 2026-09-04

Issue [#369](https://github.com/ScriptedAlchemy/agent-bundle/issues/369) (the #96
acceptance remainder) is implemented for generated route servers: a tool route
that declares `config.execution.taskSupport` (`optional` or `required`,
validated as `AB4836` and advertised in `tools/list`) may be called as a task
under the MCP `2025-11-25` Tasks utility. The `2026-09-02` deferral recorded
below was lifted after re-auditing the installed SDK.

What the audit found on `@modelcontextprotocol/server@2.0.0` /
`@modelcontextprotocol/client@2.0.0` (unchanged since the deferral):

- The task methods are outside the SDK's typed spec-method surface, but the
SDK's documented custom-method form — `setRequestHandler(method, { params },
handler)` on the server and `request(request, resultSchema)` on the client —
routes them, and the SDK exports their result schemas publicly as
`specTypeSchemas.CreateTaskResult`, `GetTaskResult`, `CancelTaskResult`,
`ListTasksResult` (the deferral note's "not exported publicly" was wrong; they
are keyed without the `Schema` suffix). On a `2025-11-25` session the SDK's
own method registry admits `tasks/*`; on `2026-07-28` it answers `-32601`
before any handler and its codec strips `execution.taskSupport` and
`capabilities.tasks`, so a modern-revision client keeps the ordinary contract.
- The SDK's `tools/call` result validation admits `CallToolResult` only and
refuses a `task` body. The lifecycle therefore lives in a `Server` subclass
(`packages/agent-bundle/src/mcp-tasks.ts`) whose `_wrapHandler` — the SDK's
documented protected seam for role-specific request handling — answers a
task-augmented request with a `CreateTaskResult` and runs the SDK-validated
handler behind the task. Nothing reaches past the SDK's public or protected
surface.
- The `2026-07-28` revision moves tasks to the `io.modelcontextprotocol/tasks`
extension (SEP-2663) with a different shape (`resultType: "task"`,
`tasks/update`, no `tasks/result`/`tasks/list`). This SDK release does not
implement that extension; the generated server serves the core `2025-11-25`
shape only and is gated on the negotiated protocol version. Serving the
extension is a follow-up that inherits the same route contract.

Behaviour (proven at the `mcp-in-memory` level by
`packages/agent-bundle/tests/projection/mcp-in-memory.test.ts`, at the unit
level by `packages/agent-bundle/tests/mcp-tasks.test.ts`, and over real stdio
framing by `packages/agent-bundle/tests/packed-stdio-projection.test.ts`):

- A server with at least one opted-in tool declares
`capabilities.tasks: { list, cancel, requests: { tools: { call } } }`; a
server with none declares nothing and processes a task-augmented request as
an ordinary one (the fallback the utility requires of a receiver without the
capability).
- `tools/call` with `params.task` on an opted-in tool answers a
`CreateTaskResult` (status `working`, honoured `ttl` ≤ 24 h, `pollInterval`
≥ 100 ms, `_meta["io.modelcontextprotocol/model-immediate-response"]`);
on a `forbidden` tool it is `-32601`; an ordinary call to a `required` tool
is `-32601`.
- `tasks/get` reports `working` with the latest render progress
(`statusMessage`, `_meta["agent-bundle/progress"]`), `completed`, `failed`
(a result with `isError: true`, as the spec requires), or `cancelled`.
- `tasks/result` blocks until the task settles and returns exactly what the
ordinary call would have returned, stamped with
`_meta["io.modelcontextprotocol/related-task"]`; a JSON-RPC error is
returned as that error.
- `tasks/cancel` transitions to `cancelled` before answering and aborts the
render through its `AbortSignal`; cancelling a settled task is `-32602`, as
is any unknown `taskId`. `tasks/list` pages the session's tasks by cursor.
- Progress notifications flow only under the client's own `progressToken`,
stamped with the related-task key; the task observes progress either way.
Records are session-scoped, retained for `ttl` after settling, bounded at
256 per server, and cancelled when the session closes.
- The operation-based `createRscMcpServer` (`@agent-bundle/runtime/plugin`) is
unchanged: no `tasks` capability, ordinary processing.

The conformance lane (`server --suite active`, specification `2025-11-25`)
does not yet exercise the Tasks utility; when the official runner adds task
scenarios, the reused route harness's `wait` and `catalog` tools already
declare `execution.taskSupport: "optional"`.

### Deferral record (2026-09-02, lifted)

The section below is the dated deferral as recorded by #394, kept for the
audit trail. Its sentinel test (`packages/rsc-runtime/tests/mcp-tasks-deferral.test.ts`)
and the `@ts-expect-error` sentinel in `mcp-in-memory.test.ts` were removed
with the implementation; the SDK pin itself is unchanged.

#### Original text

Issue [#369](https://github.com/ScriptedAlchemy/agent-bundle/issues/369) tracks
the #96 acceptance remainder: a task-augmented `tools/call` that returns a
Expand Down
16 changes: 11 additions & 5 deletions examples/host-test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,17 @@ bounded summary into the durable state kernel (`src/state.ts`,
Two MCP servers ship in the plugin:

- `host-test` (generated routes, `src/mcp/host-test/tools/`): `dump` (filter by
any conversation/session/subagent id, `full` for raw lines) and `reset`. Each
`dump` call records the request context the generated server mounted for it.
A bare `dump` returns the newest 50 matching records — a whole log of a few
hundred records overflows the tool-result document — so pass `limit` (up to
5000) for more; `matched` and `total` always count the whole log.
any conversation/session/subagent id, `full` for raw lines), `reset`, and
`slow`. Each `dump` call records the request context the generated server
mounted for it. A bare `dump` returns the newest 50 matching records — a
whole log of a few hundred records overflows the tool-result document — so
pass `limit` (up to 5000) for more; `matched` and `total` always count the
whole log. `slow` holds a call open for `holdMs` (up to 30 s) and reports
progress every `tickMs`; it declares `execution.taskSupport: "optional"`, so
a host that speaks the MCP `2025-11-25` Tasks utility may run it as a task
(`tools/call` answered by a task handle, the result through `tasks/result`)
while every other host receives the ordinary result. The recorded call and
the `host-test-raw` envelope show which path the host took.
- `host-test-raw` (hand-rolled stdio factory, `src/mcp/host-test-raw.ts`):
`probe` records the raw SDK request context — session id, JSON-RPC id,
`_meta`, lifted envelope, negotiated client info — so hook↔MCP correlation is
Expand Down
66 changes: 66 additions & 0 deletions examples/host-test/src/mcp/host-test/tools/slow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Agent, agent, type JsonValue } from '@agent-bundle/runtime';
import type { ToolConfig, ToolRouteProps } from 'agent-bundle';
import React from 'react';
import { z } from 'zod';

import { capture } from '../../../capture.js';

/** The longest hold a single call may ask for; hosts bound tool calls well above this. */
export const MAX_SLOW_HOLD_MS = 30_000;

export const config = {
annotations: { readOnlyHint: true },
description:
'Hold a tool call open for holdMs (at most 30 s), reporting progress every tickMs, to probe how the host drives a long-running tool: whether it runs it as an MCP task (tools/call answered by a task, result through tasks/result), whether it forwards progress, and when it gives up. Records the call like every other probe.',
// The 2025-11-25 Tasks utility: a task-aware host may run this call as a
// task and poll it; a host that never asks gets the ordinary result.
execution: { taskSupport: 'optional' },
} satisfies ToolConfig;

export const inputSchema = z.object({
holdMs: z.number().int().min(1).max(MAX_SLOW_HOLD_MS).default(3000)
.describe('How long the call stays open, in milliseconds (1–30000).'),
tickMs: z.number().int().min(50).max(MAX_SLOW_HOLD_MS).default(500)
.describe('Report progress every tickMs milliseconds.'),
}).strict();

export const resultSchema = z.object({
heldMs: z.number().int().nonnegative(),
log: z.string(),
ticks: z.number().int().nonnegative(),
}).strict();

const sleep = (ms: number, signal: AbortSignal): Promise<'aborted' | 'elapsed'> => new Promise((resolve) => {
if (signal.aborted) {
resolve('aborted');
return;
}
const timer = setTimeout(() => resolve('elapsed'), ms);
signal.addEventListener('abort', () => {
clearTimeout(timer);
resolve('aborted');
}, { once: true });
});

export default async function Slow({ input, signal }: ToolRouteProps<typeof inputSchema>) {
const observed = await capture({ kind: 'mcp', observed: { holdMs: input.holdMs, tickMs: input.tickMs, tool: 'slow' } });
const { progress } = await agent();
const total = Math.ceil(input.holdMs / input.tickMs);
const startedAt = Date.now();
let ticks = 0;
while (ticks < total) {
const slice = Math.min(input.tickMs, input.holdMs - ticks * input.tickMs);
if (await sleep(slice, signal) === 'aborted') {
// The host (or a tasks/cancel) gave up: end the call the way an aborted request ends.
throw new DOMException('The slow probe was aborted', 'AbortError');
}
ticks += 1;
await progress.report({ completed: ticks, message: `held ${String(ticks * input.tickMs)}ms`, total });
}
const result: z.output<typeof resultSchema> = { heldMs: Date.now() - startedAt, log: observed.log.path, ticks };
return (
<Agent.Result value={result as unknown as JsonValue}>
<Agent.Text>{`Held the call for ${String(result.heldMs)}ms across ${String(ticks)} progress ticks; recorded in ${observed.log.path}.`}</Agent.Text>
</Agent.Result>
);
}
Loading
Loading