From 7e4da48f945d559f69487da3f4850949b439e84d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:06:04 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(mcp):=20serve=20task-augmented=20tools?= =?UTF-8?q?/call=20=E2=80=94=20CreateTaskResult,=20tasks/get,=20tasks/resu?= =?UTF-8?q?lt,=20tasks/cancel,=20tasks/list=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool routes opt in with config.execution.taskSupport ('optional' | 'required', validated as AB4836 and advertised in tools/list). The generated server answers a task-augmented call with a CreateTaskResult at once and keeps the Flight render 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, 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. Replaces the dated #394 deferral sentinels with the lifecycle proofs at the unit, mcp-in-memory, and packed-stdio levels. --- docs/diagnostics.md | 5 +- .../src/mcp/harness/tools/catalog.tsx | 2 + .../src/mcp/harness/tools/wait.tsx | 2 + packages/agent-bundle/src/index.ts | 2 + .../agent-bundle/src/mcp-server-runtime.ts | 23 +- packages/agent-bundle/src/mcp-tasks.ts | 544 ++++++++++++++++++ packages/agent-bundle/src/routes/graph.ts | 4 + packages/agent-bundle/src/routes/index.ts | 4 + packages/agent-bundle/src/routes/public.ts | 21 + .../agent-bundle/src/routes/task-support.ts | 88 +++ packages/agent-bundle/tests/mcp-tasks.test.ts | 378 ++++++++++++ .../tests/packed-stdio-projection.test.ts | 27 + .../tests/projection/mcp-in-memory.test.ts | 174 +++++- .../tests/route-task-support.test.ts | 116 ++++ .../tests/mcp-tasks-deferral.test.ts | 178 ------ 15 files changed, 1354 insertions(+), 214 deletions(-) create mode 100644 packages/agent-bundle/src/mcp-tasks.ts create mode 100644 packages/agent-bundle/src/routes/task-support.ts create mode 100644 packages/agent-bundle/tests/mcp-tasks.test.ts create mode 100644 packages/agent-bundle/tests/route-task-support.test.ts delete mode 100644 packages/rsc-runtime/tests/mcp-tasks-deferral.test.ts diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 3afc75b50..95dd55d64 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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/.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). | @@ -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//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -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: }` 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. | diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/catalog.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/catalog.tsx index 880d248de..2fae4c943 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/catalog.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/catalog.tsx @@ -4,6 +4,8 @@ import { z } from 'zod'; export const config = { description: 'Streams the harness catalog behind one Suspense boundary.', + // Its streamed Agent.Progress fallback is what a task reports through tasks/get (#369). + execution: { taskSupport: 'optional' }, title: 'Catalog', }; diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx index 2f082f4ad..56cafcbfb 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx @@ -5,6 +5,8 @@ const maxHoldMs = 5000; export const config = { description: 'Waits until aborted or holdMs elapses, for cancellation contract proof.', + // A long wait a task-aware client may run behind a task (#369) and poll. + execution: { taskSupport: 'optional' }, // The long-poll shape of #454: a route whose legitimate wait outlives the // runtime's default render session declares its own budget. render: { maxElapsedMs: 120_000 }, diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index c9a83728f..3e29cce32 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -69,7 +69,9 @@ export type { RouteUiMeta, ScriptRouteProps, ToolConfig, + ToolExecutionConfig, ToolRouteProps, + ToolTaskSupport, } from './routes/public.ts'; export { compareEvals, runEvals, startDevServer } from './api.ts'; export { diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 0c72ecfcc..28dcaab93 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -16,7 +16,7 @@ */ import { Worker } from 'node:worker_threads'; -import { McpServer, ProtocolError, ProtocolErrorCode, isJSONRPCRequest, type Transport } from '@modelcontextprotocol/server'; +import { ProtocolError, ProtocolErrorCode, isJSONRPCRequest, type McpServer, type Transport } from '@modelcontextprotocol/server'; import { AgentRuntimeError, agent, @@ -31,8 +31,10 @@ import { } from '@agent-bundle/runtime'; import type { createEventRuntimeServer } from './events/ipc.ts'; import type { createCanonicalEventProps, projectEventDocument } from './events/project.ts'; +import { createTaskAugmentedMcpServer, type TaskAugmentedMcpServer } from './mcp-tasks.ts'; import { canonicalAgentEvents, type CanonicalAgentEvent } from './routes/public.ts'; import { routeRenderLimits } from './routes/render-budget.ts'; +import { routeTaskSupport } from './routes/task-support.ts'; import { noTerminal } from './terminal-capability.ts'; import type { AgentActorIdentity, @@ -371,6 +373,13 @@ export interface RegisterGeneratedRoutesOptions { readonly pluginRoot?: Observed; /** Raw `tools/call` arguments captured off the wire, for lineage correlation. */ readonly rawArguments?: RawToolArgumentsCapture; + /** + * The task lifecycle of the server (#369): every tool route's compiled + * `config.execution.taskSupport` is declared on it so `tools/list` + * advertises the value and a task-augmented call is served as a task. + * Absent on a plain `McpServer`, where every tool is an ordinary request. + */ + readonly tasks?: Pick; } /** Registers the compiled MCP routes on a server, keyed by route kind. */ @@ -385,7 +394,7 @@ export const registerGeneratedRoutes = ( switch (route.kind) { case 'tool': { const outputSchema = advertisedOutputSchema(route.module.resultSchema); - server.registerTool(route.name, { + const registered = server.registerTool(route.name, { ...selectedConfig(route.config, ['_meta', 'annotations', 'description', 'icons', 'title']), inputSchema: route.module.inputSchema, ...(outputSchema === undefined ? {} : { outputSchema }), @@ -406,6 +415,7 @@ export const registerGeneratedRoutes = ( ); return attachMcpStructuredContent(rendered.toolResult, rendered.result); }, options.afterRender)) as never); + options.tasks?.declareTool(registered, route.name, routeTaskSupport(route.config)); break; } case 'resource': { @@ -969,7 +979,8 @@ const startEventRuntime = async ( export const createGeneratedRouteMcpServer = async ( options: CreateGeneratedRouteMcpServerOptions, ): Promise => { - const server = new McpServer(options.plugin); + const tasks = createTaskAugmentedMcpServer(options.plugin); + const server = tasks.server; const dispatcher = createAgentRenderDispatcher( options.host, options.limits === undefined ? {} : { limits: options.limits }, @@ -990,10 +1001,16 @@ export const createGeneratedRouteMcpServer = async ( ...(options.events === undefined || lineageHostFor(options.events.target) === undefined ? {} : { lineageHost: lineageHostFor(options.events.target) }), + tasks, }); registerGeneratedMcpApps(server, options.apps ?? []); + // Advertised only when a tool opted in, before any transport connects. + tasks.install(); const close = server.close.bind(server); server.close = async (): Promise => { + // Tasks still rendering are cancelled first: their results can no longer + // be collected, and their renders must not outlive the host below. + tasks.tasks.abortTasks('The MCP server closed before the task settled.'); // The signaller drains any receipt still owed for a send that reached the // wire, so it must close while the ledger it commits to is still open: // the host owns (or shares) that store and closes after it. Its close diff --git a/packages/agent-bundle/src/mcp-tasks.ts b/packages/agent-bundle/src/mcp-tasks.ts new file mode 100644 index 000000000..48daa9855 --- /dev/null +++ b/packages/agent-bundle/src/mcp-tasks.ts @@ -0,0 +1,544 @@ +/** + * Task-augmented tool calls for a generated MCP server (#369): the MCP + * `2025-11-25` Tasks utility over the SDK's `Server`. + * + * A tool route that declares `config.execution.taskSupport` (`optional` or + * `required`) may be called as a task. The server then answers the + * `tools/call` with a `CreateTaskResult` at once, keeps rendering the route + * behind that task, and serves the lifecycle through `tasks/get` (status and + * progress), `tasks/result` (the final `CallToolResult`, exactly what the + * ordinary call would have returned), `tasks/cancel` (interrupts the render + * through the same `AbortSignal` a cancelled request would), and `tasks/list`. + * A client that does not ask for a task sees no change, and a server whose + * tools never opted in advertises no `tasks` capability at all. + * + * The SDK release the server is built on (`@modelcontextprotocol/server@2.0.0`) + * carries the task wire vocabulary but no task runtime, and its `tools/call` + * result validation admits `CallToolResult` only. The lifecycle therefore + * lives in a {@link Server} subclass: `_wrapHandler` is the SDK's documented + * seam for role-specific request handling, and the task request handlers use + * its custom-method form (`setRequestHandler(method, schemas, handler)`), so + * nothing here reaches past the SDK's public surface. `tasks/*` are routed by + * the SDK only on a `2025-11-25` session — the one revision whose core + * protocol defines them; the `2026-07-28` revision moves tasks to an + * extension (SEP-2663) and its wire codec removes `execution.taskSupport` + * and `capabilities.tasks` — so a client on any other revision keeps the + * ordinary contract untouched. + * + * Task records live with the server instance: the Tasks utility scopes a task + * to the session that created it, and a render is bound to the process that + * runs it, so a durable record no later session could read back would claim + * more than the runtime can honour. Records are bounded by the task `ttl` + * (retention after the terminal status) and by {@link MAX_MCP_TASKS_RETAINED}. + */ +import { randomUUID } from 'node:crypto'; + +import { + McpServer, + ProtocolError, + ProtocolErrorCode, + RELATED_TASK_META_KEY, + Server, + type CallToolResult, + type Implementation, + type JSONRPCRequest, + type RegisteredTool, + type Result, + type ServerContext, + type ServerOptions, + type StandardSchemaV1, + type Task, + type TaskStatus, +} from '@modelcontextprotocol/server'; +import type { McpProgressNotificationParams } from '@agent-bundle/runtime'; + +import type { ToolTaskSupport } from './routes/public.ts'; + +/** The one protocol revision whose core specification defines the Tasks utility. */ +export const MCP_TASKS_PROTOCOL_VERSION = '2025-11-25'; + +/** Retention of a settled task when the client requested no `ttl`: five minutes. */ +export const DEFAULT_MCP_TASK_TTL_MS = 5 * 60 * 1000; + +/** The longest retention a client may request: 24 hours, the route render ceiling. */ +export const MAX_MCP_TASK_TTL_MS = 24 * 60 * 60 * 1000; + +/** The polling interval a `CreateTaskResult` suggests when the client requested none. */ +export const DEFAULT_MCP_TASK_POLL_INTERVAL_MS = 1000; + +/** The shortest polling interval honoured from a client's `task.pollInterval`. */ +export const MIN_MCP_TASK_POLL_INTERVAL_MS = 100; + +/** Task records one server keeps at most; the oldest settled records are evicted first. */ +export const MAX_MCP_TASKS_RETAINED = 256; + +/** `_meta` key the spec reserves for the string a host may hand its model while a task runs. */ +export const MODEL_IMMEDIATE_RESPONSE_META_KEY = 'io.modelcontextprotocol/model-immediate-response'; + +/** `_meta` key under which `tasks/get` carries the last render progress of a working task. */ +export const MCP_TASK_PROGRESS_META_KEY = 'agent-bundle/progress'; + +/** The render progress a working task last reported, as `tasks/get` exposes it. */ +export interface McpTaskProgress { + readonly message?: string; + readonly progress: number; + readonly total?: number; +} + +type TaskOutcome = + | { readonly kind: 'result'; readonly result: CallToolResult } + | { readonly code: number; readonly data?: unknown; readonly kind: 'error'; readonly message: string }; + +interface TaskRecord { + readonly controller: AbortController; + expiry?: ReturnType; + outcome?: TaskOutcome; + progress?: McpTaskProgress; + readonly sequence: number; + /** Resolves once the underlying `tools/call` handler settled, however the task ended. */ + readonly settled: Promise; + task: Task; + readonly toolName: string; +} + +type RequestHandler = (request: JSONRPCRequest, ctx: ServerContext) => Promise; + +const isTerminal = (status: TaskStatus): boolean => + status === 'completed' || status === 'failed' || status === 'cancelled'; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const now = (): string => new Date().toISOString(); + +/** A Standard Schema for the params of one task request, without a schema library dependency. */ +const paramsSchema = ( + describe: string, + parse: (value: Readonly>) => T | string, +): StandardSchemaV1 => ({ + '~standard': { + validate: (value: unknown): StandardSchemaV1.Result => { + if (!isRecord(value)) return { issues: [{ message: `${describe} params must be an object.` }] }; + const parsed = parse(value); + return typeof parsed === 'string' ? { issues: [{ message: parsed }] } : { value: parsed }; + }, + vendor: 'agent-bundle', + version: 1, + }, +}); + +const taskIdParams = (method: string): StandardSchemaV1 => + paramsSchema(method, (value) => { + const taskId = value['taskId']; + return typeof taskId === 'string' && taskId !== '' ? { taskId } : `${method} requires a non-empty string taskId.`; + }); + +const listParams: StandardSchemaV1 = paramsSchema('tasks/list', (value) => { + const cursor = value['cursor']; + if (cursor === undefined) return {}; + return typeof cursor === 'string' ? { cursor } : 'tasks/list cursor must be a string.'; +}); + +const clampTtl = (requested: unknown): number => { + if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0) return DEFAULT_MCP_TASK_TTL_MS; + return Math.min(Math.floor(requested), MAX_MCP_TASK_TTL_MS); +}; + +const clampPollInterval = (requested: unknown): number => { + if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0) return DEFAULT_MCP_TASK_POLL_INTERVAL_MS; + return Math.max(Math.floor(requested), MIN_MCP_TASK_POLL_INTERVAL_MS); +}; + +const describeError = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +const errorOutcome = (error: unknown): TaskOutcome => { + if (ProtocolError.isInstance(error)) { + return { code: error.code, ...(error.data === undefined ? {} : { data: error.data }), kind: 'error', message: error.message }; + } + const code = isRecord(error) && Number.isSafeInteger(error['code']) ? (error['code'] as number) : ProtocolErrorCode.InternalError; + return { code, kind: 'error', message: describeError(error) }; +}; + +/** The page size of `tasks/list`; the cursor is the sequence of the last task returned. */ +const TASK_LIST_PAGE = 50; + +const progressStatusMessage = (progress: McpTaskProgress): string => { + if (progress.message !== undefined) return progress.message; + return progress.total === undefined + ? `progress ${String(progress.progress)}` + : `progress ${String(progress.progress)}/${String(progress.total)}`; +}; + +/** How one server's tools may be called as tasks; filled while routes register. */ +export interface McpTaskSupportRegistry { + /** Declares a tool's `execution.taskSupport`; `forbidden` tools need no declaration. */ + declare(toolName: string, taskSupport: ToolTaskSupport): void; + taskSupport(toolName: string): ToolTaskSupport; +} + +/** + * The SDK `Server` with the task lifecycle installed on its `tools/call` + * handler. Constructed by {@link createTaskAugmentedMcpServer}; the + * `McpServer` it backs registers tools exactly as before, and this class + * decides per request whether the registered handler answers directly or + * behind a task. + */ +export class TaskAugmentedServer extends Server { + readonly #records = new Map(); + readonly #support = new Map(); + #sequence = 0; + #installed = false; + + protected override _onclose(): void { + // The session is over: no task can be polled or collected any more. + this.abortTasks('The MCP session closed before the task settled.'); + super._onclose(); + } + + /** The declared task support of one tool; `forbidden` when it declared none. */ + taskSupport(toolName: string): ToolTaskSupport { + return this.#support.get(toolName) ?? 'forbidden'; + } + + /** Declares a tool's task support; `forbidden` removes an earlier declaration. */ + declareTaskSupport(toolName: string, taskSupport: ToolTaskSupport): void { + if (taskSupport === 'forbidden') this.#support.delete(toolName); + else this.#support.set(toolName, taskSupport); + } + + /** True once at least one tool may be called as a task. */ + get tasksEnabled(): boolean { + return this.#support.size > 0; + } + + /** The tasks this server currently retains, oldest first. */ + tasks(): readonly Task[] { + return [...this.#records.values()].sort((left, right) => left.sequence - right.sequence).map((record) => record.task); + } + + /** + * Advertises the `tasks` capability and installs the task request handlers. + * Must run before the server connects and only once at least one tool + * declared task support; a server without one advertises nothing and keeps + * answering `tasks/*` with the SDK's method-not-found. + */ + installTaskSupport(): void { + if (this.#installed || !this.tasksEnabled) return; + this.#installed = true; + this.registerCapabilities({ tasks: { cancel: {}, list: {}, requests: { tools: { call: {} } } } }); + this.setRequestHandler('tasks/get', { params: taskIdParams('tasks/get') }, async ({ taskId }) => { + const record = this.#require(taskId); + return { + ...(record.progress === undefined ? {} : { _meta: { [MCP_TASK_PROGRESS_META_KEY]: { ...record.progress } } }), + ...record.task, + }; + }); + this.setRequestHandler('tasks/result', { params: taskIdParams('tasks/result') }, async ({ taskId }, ctx) => { + const record = this.#require(taskId); + await this.#awaitSettled(record, ctx.mcpReq.signal); + const outcome = record.outcome; + if (outcome === undefined) { + throw new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} settled without an outcome.`); + } + switch (outcome.kind) { + case 'error': + throw new ProtocolError(outcome.code, outcome.message, outcome.data); + case 'result': + return { + ...outcome.result, + _meta: { ...outcome.result._meta, [RELATED_TASK_META_KEY]: { taskId } }, + }; + default: { + const unreachable: never = outcome; + throw new TypeError(`Unhandled task outcome ${String(unreachable)}.`); + } + } + }); + this.setRequestHandler('tasks/list', { params: listParams }, async ({ cursor }) => { + const ordered = [...this.#records.values()].sort((left, right) => left.sequence - right.sequence); + let start = 0; + if (cursor !== undefined) { + const after = Number(cursor); + if (!Number.isSafeInteger(after) || after < 0) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tasks/list cursor ${JSON.stringify(cursor)}.`); + } + start = ordered.findIndex((record) => record.sequence > after); + if (start === -1) start = ordered.length; + } + const page = ordered.slice(start, start + TASK_LIST_PAGE); + const last = page.at(-1); + return { + ...(last !== undefined && start + TASK_LIST_PAGE < ordered.length ? { nextCursor: String(last.sequence) } : {}), + tasks: page.map((record) => record.task), + }; + }); + this.setRequestHandler('tasks/cancel', { params: taskIdParams('tasks/cancel') }, async ({ taskId }) => { + const record = this.#require(taskId); + if (isTerminal(record.task.status)) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Cannot cancel task ${taskId}: already in terminal status '${record.task.status}'.`, + ); + } + this.#transition(record, 'cancelled', 'The task was cancelled by request.'); + record.controller.abort(new DOMException('The task was cancelled by request.', 'AbortError')); + return { ...record.task }; + }); + } + + /** Cancels every task still working — the session is over, so no result can be collected. */ + abortTasks(reason: string): void { + for (const record of this.#records.values()) { + if (isTerminal(record.task.status)) continue; + this.#transition(record, 'cancelled', reason); + record.controller.abort(new DOMException(reason, 'AbortError')); + } + for (const record of this.#records.values()) { + if (record.expiry !== undefined) clearTimeout(record.expiry); + } + this.#records.clear(); + } + + protected override _wrapHandler(method: string, handler: RequestHandler): RequestHandler { + const wrapped = super._wrapHandler(method, handler); + if (method !== 'tools/call') return wrapped; + return async (request, ctx) => { + const params = request.params; + const toolName = isRecord(params) && typeof params['name'] === 'string' ? params['name'] : undefined; + // 2025-11-25 Tasks: a request is task-augmented when its params carry a + // `task` object (the SDK's own guard accepts params without one). + const augmented = this.#taskSession() && isRecord(params) && isRecord(params['task']); + const support = toolName === undefined ? 'forbidden' : this.taskSupport(toolName); + if (!augmented) { + if (support === 'required') { + // 2025-11-25 Tasks: a tool with taskSupport "required" MUST be called as a task (-32601). + throw new ProtocolError( + ProtocolErrorCode.MethodNotFound, + `Tool ${String(toolName)} requires task-augmented execution (execution.taskSupport: "required"); call it with params.task.`, + ); + } + return wrapped(request, ctx); + } + if (!this.#installed) { + // No tool opted in: the server declared no task capability, so the + // request is processed normally and its task metadata ignored. + return wrapped(request, ctx); + } + if (support === 'forbidden') { + // The capability is declared for tools/call, but not by this tool. + throw new ProtocolError( + ProtocolErrorCode.MethodNotFound, + `Tool ${String(toolName)} does not support task-augmented execution (execution.taskSupport is "forbidden").`, + ); + } + return this.#createTask(String(toolName), params as Readonly>, request, ctx, wrapped); + }; + } + + #taskSession(): boolean { + return this.getNegotiatedProtocolVersion() === MCP_TASKS_PROTOCOL_VERSION; + } + + #require(taskId: string): TaskRecord { + const record = this.#records.get(taskId); + if (record === undefined) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`); + return record; + } + + #evict(): void { + if (this.#records.size < MAX_MCP_TASKS_RETAINED) return; + const settled = [...this.#records.values()] + .filter((record) => isTerminal(record.task.status)) + .sort((left, right) => left.sequence - right.sequence); + const oldest = settled[0]; + if (oldest === undefined) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `This server is already running ${String(MAX_MCP_TASKS_RETAINED)} tasks; wait for one to settle or cancel one.`, + ); + } + this.#forget(oldest); + } + + #forget(record: TaskRecord): void { + if (record.expiry !== undefined) clearTimeout(record.expiry); + this.#records.delete(record.task.taskId); + } + + #transition(record: TaskRecord, status: TaskStatus, statusMessage: string | undefined): void { + if (isTerminal(record.task.status)) return; + record.task = { + ...record.task, + lastUpdatedAt: now(), + status, + ...(statusMessage === undefined ? {} : { statusMessage }), + }; + if (statusMessage === undefined) { + const { statusMessage: _dropped, ...rest } = record.task; + record.task = rest; + } + if (!isTerminal(status)) return; + const ttl = record.task.ttl ?? DEFAULT_MCP_TASK_TTL_MS; + record.expiry = setTimeout(() => this.#forget(record), ttl); + record.expiry.unref?.(); + // Optional per spec; a client that is polling loses nothing if it fails. + void this.notification({ method: 'notifications/tasks/status', params: { ...record.task } }).catch(() => undefined); + } + + #observeProgress(record: TaskRecord, params: McpProgressNotificationParams): void { + if (isTerminal(record.task.status)) return; + record.progress = { + progress: params.progress, + ...(params.message === undefined ? {} : { message: params.message }), + ...(params.total === undefined ? {} : { total: params.total }), + }; + record.task = { ...record.task, lastUpdatedAt: now(), statusMessage: progressStatusMessage(record.progress) }; + } + + /** + * Blocks until the underlying `tools/call` settled — the terminal status of a + * cancelled task is set before the interrupted render answers, and + * `tasks/result` must return what that render produced — or until the + * `tasks/result` request itself is cancelled. + */ + async #awaitSettled(record: TaskRecord, signal: AbortSignal): Promise { + if (record.outcome !== undefined) return; + await new Promise((resolve, reject) => { + const onAbort = (): void => { + reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'The tasks/result request was cancelled before the task settled.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + void record.settled.then(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }); + }); + } + + async #createTask( + toolName: string, + params: Readonly>, + request: JSONRPCRequest, + ctx: ServerContext, + handler: RequestHandler, + ): Promise { + this.#evict(); + const creation = isRecord(params['task']) ? params['task'] : {}; + const taskId = randomUUID(); + const createdAt = now(); + const controller = new AbortController(); + let settle: () => void = () => undefined; + const settled = new Promise((resolve) => { settle = resolve; }); + const record: TaskRecord = { + controller, + sequence: ++this.#sequence, + settled, + task: { + createdAt, + lastUpdatedAt: createdAt, + pollInterval: clampPollInterval(creation['pollInterval']), + status: 'working', + taskId, + ttl: clampTtl(creation['ttl']), + }, + toolName, + }; + this.#records.set(taskId, record); + + // The render runs under the task's own signal — tasks/cancel is what + // interrupts it now, not the answered request — and every progress + // notification it emits is observed for tasks/get and stamped with the + // related-task key before it reaches the client's own progress token. + const clientToken = ctx.mcpReq._meta?.progressToken; + const notify = ctx.mcpReq.notify; + const taskContext: ServerContext = { + ...ctx, + mcpReq: { + ...ctx.mcpReq, + _meta: { ...ctx.mcpReq._meta, progressToken: clientToken ?? `agent-bundle/task/${taskId}` }, + notify: async (notification) => { + if (notification.method === 'notifications/progress' && isRecord(notification.params)) { + this.#observeProgress(record, notification.params as unknown as McpProgressNotificationParams); + if (clientToken === undefined) return; + } + await notify({ + ...notification, + params: { + ...notification.params, + _meta: { ...(isRecord(notification.params?._meta) ? notification.params._meta : {}), [RELATED_TASK_META_KEY]: { taskId } }, + }, + }); + }, + signal: controller.signal, + }, + }; + const { task: _creation, ...ordinaryParams } = params; + const ordinaryRequest: JSONRPCRequest = { ...request, params: ordinaryParams }; + void handler(ordinaryRequest, taskContext).then( + (result) => { + const toolResult = result as CallToolResult; + record.outcome = { kind: 'result', result: toolResult }; + if (toolResult.isError === true) { + // 2025-11-25 Tasks: a tool result with isError reaches "failed". + const text = toolResult.content.find((block) => block.type === 'text'); + this.#transition(record, 'failed', text !== undefined && 'text' in text ? text.text : 'The tool call failed.'); + } else { + this.#transition(record, 'completed', undefined); + } + }, + (error: unknown) => { + record.outcome = errorOutcome(error); + this.#transition(record, 'failed', describeError(error)); + }, + ).finally(settle); + + const created: Result = { + _meta: { + [MODEL_IMMEDIATE_RESPONSE_META_KEY]: + `The ${toolName} call is running as task ${taskId}. Poll tasks/get for its status and fetch the result with tasks/result.`, + }, + task: { ...record.task }, + }; + return created; + } +} + +/** + * Builds the `McpServer` a generated artifact serves with task support wired + * into its underlying `Server`. Register tools on the returned `McpServer` + * as usual, declare each tool's task support through `declareTool`, then + * call `install()` once before connecting a transport. + */ +export interface TaskAugmentedMcpServer { + readonly server: McpServer; + readonly tasks: TaskAugmentedServer; + /** + * Records a tool's `execution.taskSupport` (advertised in `tools/list`) and + * registers it with the task lifecycle. `forbidden` — the default — leaves + * the tool an ordinary request. + */ + declareTool(tool: RegisteredTool, toolName: string, taskSupport: ToolTaskSupport): void; + /** Advertises the capability and installs the task handlers when any tool opted in. */ + install(): void; +} + +export const createTaskAugmentedMcpServer = ( + serverInfo: Implementation, + options?: ServerOptions, +): TaskAugmentedMcpServer => { + const server = new McpServer(serverInfo, options); + const tasks = new TaskAugmentedServer(serverInfo, options); + // `McpServer` builds its own `Server` and exposes it read-only; the task-aware + // subclass replaces it before any handler registers, so every tool the + // `McpServer` registers is wrapped by the lifecycle above. + Object.defineProperty(server, 'server', { configurable: true, enumerable: true, value: tasks, writable: false }); + return Object.freeze({ + declareTool: (tool: RegisteredTool, toolName: string, taskSupport: ToolTaskSupport): void => { + if (taskSupport !== 'forbidden') tool.execution = { taskSupport }; + tasks.declareTaskSupport(toolName, taskSupport); + }, + install: (): void => tasks.installTaskSupport(), + server, + tasks, + }); +}; diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 6a7910dc4..0cd5fd69a 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -33,6 +33,7 @@ import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleConfig } from '../core/types.ts'; import { canonicalAgentEvents, type CanonicalAgentEvent } from './public.ts'; import { validateRouteRenderConfig } from './render-budget.ts'; +import { validateRouteExecutionConfig } from './task-support.ts'; import { emptyRouteConfig, type CompiledAgentRoute, @@ -892,6 +893,9 @@ export const compileRouteGraph = async ( // The route's render budget (#454) is read by the generated server // from this compiled config, so it is validated here, once. diagnostics.push(...validateRouteRenderConfig(route, 'MCP route').diagnostics); + // Likewise the tool's task support (#369): advertised in tools/list and + // gating the task lifecycle, both read from this compiled config. + diagnostics.push(...validateRouteExecutionConfig(route, 'MCP route').diagnostics); } } servers.push({ diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index f6e7a88cf..4af02fa8f 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -51,6 +51,8 @@ export { export type { RouteModuleExports } from './contract.ts'; export { routeRenderLimits, validateRouteRenderConfig } from './render-budget.ts'; export type { RouteRenderBudget, ValidatedRouteRenderConfig } from './render-budget.ts'; +export { routeTaskSupport, toolTaskSupportValues, validateRouteExecutionConfig } from './task-support.ts'; +export type { ValidatedRouteExecutionConfig } from './task-support.ts'; export { agentEventPayloadFieldKinds, agentEventPayloadFields, @@ -94,5 +96,7 @@ export type { RouteSchemaOutput, RouteUiMeta, ToolConfig, + ToolExecutionConfig, ToolRouteProps, + ToolTaskSupport, } from './public.ts'; diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index e14285a87..89747cb71 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -241,10 +241,31 @@ export interface RouteRenderConfig { */ export const MAX_ROUTE_RENDER_ELAPSED_MS = 24 * 60 * 60 * 1000; +/** + * How a tool may be called as an MCP task (the `2025-11-25` Tasks utility, + * `Tool.execution.taskSupport`). `forbidden` — the wire default when the + * field is absent — means every call is an ordinary request. `optional` lets + * a client that asks for task-augmented execution receive a `CreateTaskResult` + * at once and poll `tasks/get` / `tasks/result` for the final `CallToolResult` + * while the render continues; a client that does not ask sees no change. + * `required` refuses an ordinary call with JSON-RPC `-32601`. The compiler + * validates the value (`AB4836`); the generated server advertises it in + * `tools/list` and declares the `tasks` capability only when at least one + * tool opted in. + */ +export type ToolTaskSupport = 'forbidden' | 'optional' | 'required'; + +/** The `Tool.execution` block a tool route declares statically in `config.execution`. */ +export interface ToolExecutionConfig { + readonly taskSupport?: ToolTaskSupport; +} + export interface ToolConfig { readonly _meta?: RouteMeta; readonly annotations?: Readonly>; readonly description?: string; + /** Task-augmented execution of this tool (`execution.taskSupport`); see {@link ToolTaskSupport}. */ + readonly execution?: ToolExecutionConfig; /** Project a validated result's integer `exitCode` when this tool is exposed through the generated CLI. */ readonly exitCode?: 'result'; /** The render budget of one call; also inherited by the tool's projected CLI command. */ diff --git a/packages/agent-bundle/src/routes/task-support.ts b/packages/agent-bundle/src/routes/task-support.ts new file mode 100644 index 000000000..363e5fed6 --- /dev/null +++ b/packages/agent-bundle/src/routes/task-support.ts @@ -0,0 +1,88 @@ +import type { Diagnostic } from '../core/diagnostics.ts'; +import type { ToolTaskSupport } from './public.ts'; +import type { CompiledAgentRoute } from './types.ts'; + +/** The `Tool.execution.taskSupport` vocabulary of the MCP `2025-11-25` Tasks utility. */ +export const toolTaskSupportValues: readonly ToolTaskSupport[] = Object.freeze(['forbidden', 'optional', 'required']); + +const EXECUTION_KEYS: ReadonlySet = new Set(['taskSupport']); + +const isToolTaskSupport = (value: unknown): value is ToolTaskSupport => + typeof value === 'string' && (toolTaskSupportValues as readonly string[]).includes(value); + +const executionError = (message: string, sourcePath: string): Diagnostic => ({ + code: 'AB4836', + message, + recovery: `Declare config.execution on a tool route as { taskSupport: ${toolTaskSupportValues.map((value) => `'${value}'`).join(' | ')} }, or omit it: a tool without one is called as an ordinary request (forbidden).`, + severity: 'error', + sourcePath, +}); + +export interface ValidatedRouteExecutionConfig { + readonly diagnostics: readonly Diagnostic[]; + /** Present only when `config.execution.taskSupport` is declared and valid. */ + readonly taskSupport?: ToolTaskSupport; +} + +/** + * Interprets a route's statically extracted `config.execution` (#369): absent + * means ordinary requests only; declared, it must be an object whose only key + * is `taskSupport`, one of `forbidden`, `optional`, or `required`, and it may + * appear on tool routes only — the `2025-11-25` Tasks utility augments + * `tools/call` and nothing else a generated server serves. `describe` names + * the route kind in the message (`MCP route`). + */ +export const validateRouteExecutionConfig = ( + route: CompiledAgentRoute, + describe: string, +): ValidatedRouteExecutionConfig => { + const declared = route.config['execution']; + if (declared === undefined) return { diagnostics: [] }; + const relativePath = route.provenance.relativePath; + if (route.kind !== 'tool') { + return { + diagnostics: [executionError( + `${describe} ${relativePath} declares config.execution, which only tool routes accept: MCP tasks augment tools/call, not ${route.kind} reads.`, + route.source, + )], + }; + } + if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) { + return { + diagnostics: [executionError(`${describe} ${relativePath} config.execution must be an object.`, route.source)], + }; + } + const unknown = Object.keys(declared).filter((key) => !EXECUTION_KEYS.has(key)); + if (unknown.length > 0) { + return { + diagnostics: [executionError( + `${describe} ${relativePath} config.execution declares unknown key${unknown.length === 1 ? '' : 's'} ${unknown.map((key) => JSON.stringify(key)).join(', ')}; only taskSupport is accepted.`, + route.source, + )], + }; + } + const taskSupport = (declared as { readonly taskSupport?: unknown }).taskSupport; + if (taskSupport === undefined) return { diagnostics: [] }; + if (!isToolTaskSupport(taskSupport)) { + return { + diagnostics: [executionError( + `${describe} ${relativePath} config.execution.taskSupport must be one of ${toolTaskSupportValues.map((value) => JSON.stringify(value)).join(', ')}; got ${JSON.stringify(taskSupport)}.`, + route.source, + )], + }; + } + return { diagnostics: [], taskSupport }; +}; + +/** + * The task support a compiled tool config declares at run time: the generated + * MCP server reads the compiled `config`, which the build already validated, + * so this reader only picks the well-formed value and treats anything else as + * the wire default, `forbidden`. + */ +export const routeTaskSupport = (config: Readonly>): ToolTaskSupport => { + const declared = config['execution']; + if (typeof declared !== 'object' || declared === null) return 'forbidden'; + const taskSupport = (declared as { readonly taskSupport?: unknown }).taskSupport; + return isToolTaskSupport(taskSupport) ? taskSupport : 'forbidden'; +}; diff --git a/packages/agent-bundle/tests/mcp-tasks.test.ts b/packages/agent-bundle/tests/mcp-tasks.test.ts new file mode 100644 index 000000000..ece4e996d --- /dev/null +++ b/packages/agent-bundle/tests/mcp-tasks.test.ts @@ -0,0 +1,378 @@ +import { Client, InMemoryTransport, specTypeSchemas as clientSchemas } from '@modelcontextprotocol/client'; +import { describe, expect, it } from '@rstest/core'; +import { z } from 'zod'; + +import { + DEFAULT_MCP_TASK_POLL_INTERVAL_MS, + DEFAULT_MCP_TASK_TTL_MS, + MAX_MCP_TASK_TTL_MS, + MCP_TASK_PROGRESS_META_KEY, + MODEL_IMMEDIATE_RESPONSE_META_KEY, + createTaskAugmentedMcpServer, +} from '../src/mcp-tasks.ts'; + +/** + * The task lifecycle over the SDK's own `Server`, exercised by a real client + * through the in-memory transport pair and hand-registered tools, so every + * protocol rule is pinned without a compiled route graph. The generated + * server's integration of the same lifecycle is proven by the `mcp-in-memory` + * level (projection/mcp-in-memory.test.ts). + */ + +const RELATED_TASK = 'io.modelcontextprotocol/related-task'; + +interface Hold { + readonly release: (text: string) => void; + readonly fail: (message: string) => void; + readonly waited: Promise; +} + +const hold = (): Hold => { + let release: (text: string) => void = () => undefined; + let fail: (message: string) => void = () => undefined; + const waited = new Promise((resolve, reject) => { + release = resolve; + fail = (message) => reject(new Error(message)); + }); + return { fail, release, waited }; +}; + +interface Harness { + readonly client: Client; + readonly holds: Hold[]; + readonly aborted: string[]; + readonly close: () => Promise; + readonly errors: Error[]; +} + +const open = async (options: { readonly clientCapabilities?: Record; readonly declareTasks?: boolean } = {}): Promise => { + const { declareTool, install, server, tasks } = createTaskAugmentedMcpServer({ name: 'tasks-unit', version: '0.0.0' }); + const errors: Error[] = []; + tasks.onerror = (error) => errors.push(error); + const holds: Hold[] = []; + const aborted: string[] = []; + const optional = server.registerTool('slow', { + description: 'Blocks until the test releases it.', + inputSchema: z.object({ label: z.string() }), + outputSchema: z.object({ label: z.string() }), + }, async ({ label }, ctx) => { + const step = hold(); + holds.push(step); + ctx.mcpReq.signal.addEventListener('abort', () => { + aborted.push(label); + step.fail(`aborted ${label}`); + }, { once: true }); + const progressToken = ctx.mcpReq._meta?.progressToken; + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { message: 'half way', progress: 1, progressToken, total: 2 } }); + } + const text = await step.waited; + return { content: [{ text, type: 'text' }], structuredContent: { label } }; + }); + const required = server.registerTool('background-only', { + description: 'Must be called as a task.', + inputSchema: z.object({}), + }, async () => ({ content: [{ text: 'ran', type: 'text' }] })); + const plain = server.registerTool('plain', { + description: 'Never a task.', + inputSchema: z.object({ fail: z.boolean().optional() }), + }, async ({ fail }) => (fail === true + ? { content: [{ text: 'plain failed', type: 'text' }], isError: true } + : { content: [{ text: 'plain', type: 'text' }] })); + const failing = server.registerTool('failing', { + description: 'Reports a tool error.', + inputSchema: z.object({}), + }, async () => ({ content: [{ text: 'quota exceeded', type: 'text' }], isError: true })); + if (options.declareTasks !== false) { + declareTool(optional, 'slow', 'optional'); + declareTool(required, 'background-only', 'required'); + declareTool(failing, 'failing', 'optional'); + } + declareTool(plain, 'plain', 'forbidden'); + install(); + const client = new Client( + { name: 'tasks-unit-client', version: '0.0.0' }, + options.clientCapabilities === undefined ? undefined : { capabilities: options.clientCapabilities as never }, + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return { + aborted, + client, + close: async () => { + await client.close(); + await server.close(); + }, + errors, + holds, + }; +}; + +const callAsTask = (client: Client, name: string, args: Record, task: Record = {}, meta?: Record) => + client.request({ + method: 'tools/call', + params: { ...(meta === undefined ? {} : { _meta: meta }), arguments: args, name, task }, + }, clientSchemas.CreateTaskResult); + +const getTask = (client: Client, taskId: string) => + client.request({ method: 'tasks/get', params: { taskId } }, clientSchemas.GetTaskResult); + +const getResult = (client: Client, taskId: string) => + client.request({ method: 'tasks/result', params: { taskId } }, clientSchemas.CallToolResult); + +const waitFor = async (predicate: () => boolean, label: string): Promise => { + for (let attempt = 0; attempt < 400; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for ${label}`); +}; + +const rpcError = async (promise: Promise): Promise<{ readonly code: number; readonly message: string }> => { + try { + await promise; + } catch (error) { + const { code, message } = error as { code: number; message: string }; + return { code, message }; + } + throw new Error('Expected the request to fail'); +}; + +describe('task-augmented tools/call (#369)', () => { + it('advertises the tasks capability and each tool\'s execution.taskSupport only when a tool opted in', async () => { + const harness = await open(); + try { + expect(harness.client.getServerCapabilities()?.tasks).toEqual({ cancel: {}, list: {}, requests: { tools: { call: {} } } }); + const listed = await harness.client.listTools(); + const byName = new Map(listed.tools.map((tool) => [tool.name, tool])); + expect(byName.get('slow')?.execution).toEqual({ taskSupport: 'optional' }); + expect(byName.get('background-only')?.execution).toEqual({ taskSupport: 'required' }); + expect(byName.get('plain')?.execution).toBeUndefined(); + } finally { + await harness.close(); + } + + const plainOnly = await open({ declareTasks: false }); + try { + expect(Object.hasOwn(plainOnly.client.getServerCapabilities() ?? {}, 'tasks')).toBe(false); + // A receiver that declared no task capability processes the request + // normally and ignores the task metadata (2025-11-25 Tasks). + const result = await plainOnly.client.request({ + method: 'tools/call', + params: { arguments: {}, name: 'plain', task: { ttl: 1000 } }, + }, clientSchemas.CallToolResult); + expect(result).toEqual({ content: [{ text: 'plain', type: 'text' }] }); + const missing = await rpcError(getTask(plainOnly.client, 'nope')); + expect(missing.code).toBe(-32_601); + } finally { + await plainOnly.close(); + } + }); + + it('returns a CreateTaskResult at once, reports progress through tasks/get, and hands the final CallToolResult to tasks/result', async () => { + const harness = await open(); + try { + const created = await callAsTask(harness.client, 'slow', { label: 'one' }, { pollInterval: 250, ttl: 90_000 }); + expect(created.task).toMatchObject({ pollInterval: 250, status: 'working', ttl: 90_000 }); + expect(created.task.taskId).toMatch(/^[0-9a-f-]{36}$/u); + expect(created.task.createdAt).toBe(created.task.lastUpdatedAt); + expect(created._meta?.[MODEL_IMMEDIATE_RESPONSE_META_KEY]).toContain(created.task.taskId); + // The tool is still running: the response did not wait for it. + await waitFor(() => harness.holds.length === 1, 'the tool to start'); + + const working = await getTask(harness.client, created.task.taskId); + expect(working).toMatchObject({ status: 'working', statusMessage: 'half way', taskId: created.task.taskId }); + expect(working._meta?.[MCP_TASK_PROGRESS_META_KEY]).toEqual({ message: 'half way', progress: 1, total: 2 }); + + // tasks/result blocks until the task settles. + let settled = false; + const pending = getResult(harness.client, created.task.taskId).then((result) => { + settled = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(settled).toBe(false); + harness.holds[0]!.release('done one'); + const result = await pending; + expect(result).toMatchObject({ + content: [{ text: 'done one', type: 'text' }], + structuredContent: { label: 'one' }, + }); + // The result carries the related-task key the spec requires of tasks/result. + expect((result._meta as Record | undefined)?.[RELATED_TASK]).toEqual({ taskId: created.task.taskId }); + + const completed = await getTask(harness.client, created.task.taskId); + expect(completed.status).toBe('completed'); + expect(completed).not.toHaveProperty('statusMessage'); + // The last progress the render reported stays readable on the settled task. + expect(completed._meta?.[MCP_TASK_PROGRESS_META_KEY]).toEqual({ message: 'half way', progress: 1, total: 2 }); + // A settled task's result can be fetched again while it is retained. + expect(await getResult(harness.client, created.task.taskId)).toMatchObject({ structuredContent: { label: 'one' } }); + } finally { + await harness.close(); + } + }); + + it('forwards progress under the client\'s own token with the related-task key, and never invents a token', async () => { + const harness = await open(); + try { + const notifications: unknown[] = []; + harness.client.setNotificationHandler('notifications/progress', (notification) => { + notifications.push(notification.params); + }); + const silent = await callAsTask(harness.client, 'slow', { label: 'silent' }); + const loud = await callAsTask(harness.client, 'slow', { label: 'loud' }, {}, { progressToken: 'tok-369' }); + await waitFor(() => harness.holds.length === 2, 'both tools to start'); + await waitFor(() => notifications.length === 1, 'the tokened progress notification'); + expect(notifications).toEqual([{ + _meta: { [RELATED_TASK]: { taskId: loud.task.taskId } }, + message: 'half way', + progress: 1, + progressToken: 'tok-369', + total: 2, + }]); + // Both tasks observed their progress regardless of the token. + expect((await getTask(harness.client, silent.task.taskId)).statusMessage).toBe('half way'); + expect((await getTask(harness.client, loud.task.taskId)).statusMessage).toBe('half way'); + for (const step of harness.holds) step.release('ok'); + } finally { + await harness.close(); + } + }); + + it('cancels a working task: status flips before the response and the tool\'s signal aborts', async () => { + const harness = await open(); + try { + const statuses: string[] = []; + harness.client.setNotificationHandler('notifications/tasks/status', { params: clientSchemas.Task }, (params) => { + statuses.push(`${params.taskId}:${params.status}`); + }); + const created = await callAsTask(harness.client, 'slow', { label: 'cancel-me' }); + await waitFor(() => harness.holds.length === 1, 'the tool to start'); + const cancelled = await harness.client.request( + { method: 'tasks/cancel', params: { taskId: created.task.taskId } }, + clientSchemas.CancelTaskResult, + ); + expect(cancelled).toMatchObject({ status: 'cancelled', statusMessage: 'The task was cancelled by request.', taskId: created.task.taskId }); + await waitFor(() => harness.aborted.includes('cancel-me'), 'the render signal to abort'); + // Terminal for good, even once the underlying call settles. + expect((await getTask(harness.client, created.task.taskId)).status).toBe('cancelled'); + // tasks/result returns exactly what the interrupted call produced: the + // SDK's tool error for the abort. + const result = await getResult(harness.client, created.task.taskId); + expect(result).toMatchObject({ content: [{ text: 'aborted cancel-me', type: 'text' }], isError: true }); + expect(statuses).toEqual([`${created.task.taskId}:cancelled`]); + // A second cancel is rejected: the task is terminal. + const again = await rpcError(harness.client.request( + { method: 'tasks/cancel', params: { taskId: created.task.taskId } }, + clientSchemas.CancelTaskResult, + )); + expect(again.code).toBe(-32_602); + expect(again.message).toContain('terminal'); + } finally { + await harness.close(); + } + }); + + it('marks a tool error result failed with its diagnostic and still returns it from tasks/result', async () => { + const harness = await open(); + try { + const created = await callAsTask(harness.client, 'failing', {}); + await waitFor(() => false, 'nothing').catch(() => undefined); + const failed = await getTask(harness.client, created.task.taskId); + expect(failed).toMatchObject({ status: 'failed', statusMessage: 'quota exceeded' }); + expect(await getResult(harness.client, created.task.taskId)).toEqual({ + _meta: { [RELATED_TASK]: { taskId: created.task.taskId } }, + content: [{ text: 'quota exceeded', type: 'text' }], + isError: true, + }); + } finally { + await harness.close(); + } + }, 10_000); + + it('lists the session\'s tasks oldest first and pages by cursor', async () => { + const harness = await open(); + try { + const created = []; + for (const label of ['a', 'b', 'c']) created.push(await callAsTask(harness.client, 'slow', { label })); + await waitFor(() => harness.holds.length === 3, 'the tools to start'); + const listed = await harness.client.request({ method: 'tasks/list' }, clientSchemas.ListTasksResult); + expect(listed.tasks.map((task) => task.taskId)).toEqual(created.map((entry) => entry.task.taskId)); + expect(listed).not.toHaveProperty('nextCursor'); + const bad = await rpcError(harness.client.request({ method: 'tasks/list', params: { cursor: 'not-a-cursor' } }, clientSchemas.ListTasksResult)); + expect(bad.code).toBe(-32_602); + for (const step of harness.holds) step.release('ok'); + } finally { + await harness.close(); + } + }); + + it('refuses an ordinary call to a required tool and a task call to a forbidden tool with -32601', async () => { + const harness = await open(); + try { + const ordinary = await rpcError(harness.client.callTool({ arguments: {}, name: 'background-only' })); + expect(ordinary).toMatchObject({ code: -32_601 }); + expect(ordinary.message).toContain('requires task-augmented execution'); + const forbidden = await rpcError(callAsTask(harness.client, 'plain', {})); + expect(forbidden).toMatchObject({ code: -32_601 }); + expect(forbidden.message).toContain('does not support task-augmented execution'); + // The required tool runs as a task. + const created = await callAsTask(harness.client, 'background-only', {}); + expect(await getResult(harness.client, created.task.taskId)).toMatchObject({ content: [{ text: 'ran', type: 'text' }] }); + } finally { + await harness.close(); + } + }); + + it('keeps ordinary calls untouched: no task shape, no progress without a token, tool errors as before', async () => { + const harness = await open(); + try { + const notifications: unknown[] = []; + harness.client.setNotificationHandler('notifications/progress', (notification) => { + notifications.push(notification.params); + }); + const pending = harness.client.callTool({ arguments: { label: 'ordinary' }, name: 'slow' }); + await waitFor(() => harness.holds.length === 1, 'the tool to start'); + harness.holds[0]!.release('sync'); + const result = await pending; + expect(result).toEqual({ content: [{ text: 'sync', type: 'text' }], structuredContent: { label: 'ordinary' } }); + expect(notifications).toEqual([]); + expect(await harness.client.callTool({ arguments: { fail: true }, name: 'plain' })).toEqual({ + content: [{ text: 'plain failed', type: 'text' }], + isError: true, + }); + const listed = await harness.client.request({ method: 'tasks/list' }, clientSchemas.ListTasksResult); + expect(listed.tasks).toEqual([]); + } finally { + await harness.close(); + } + }); + + it('answers an unknown task with -32602 and clamps the requested ttl and poll interval', async () => { + const harness = await open(); + try { + const missing = await rpcError(getTask(harness.client, 'no-such-task')); + expect(missing).toEqual({ code: -32_602, message: expect.stringContaining('Task not found') }); + expect((await rpcError(getResult(harness.client, 'no-such-task'))).code).toBe(-32_602); + const defaults = await callAsTask(harness.client, 'slow', { label: 'defaults' }); + expect(defaults.task).toMatchObject({ pollInterval: DEFAULT_MCP_TASK_POLL_INTERVAL_MS, ttl: DEFAULT_MCP_TASK_TTL_MS }); + const clamped = await callAsTask(harness.client, 'slow', { label: 'clamped' }, { pollInterval: 1, ttl: Number.MAX_SAFE_INTEGER }); + expect(clamped.task).toMatchObject({ pollInterval: 100, ttl: MAX_MCP_TASK_TTL_MS }); + await waitFor(() => harness.holds.length === 2, 'the tools to start'); + for (const step of harness.holds) step.release('ok'); + } finally { + await harness.close(); + } + }); + + it('cancels every working task when the session closes', async () => { + const harness = await open(); + const created = await callAsTask(harness.client, 'slow', { label: 'orphan' }); + await waitFor(() => harness.holds.length === 1, 'the tool to start'); + await harness.close(); + await waitFor(() => harness.aborted.includes('orphan'), 'the orphaned render to abort'); + expect(created.task.status).toBe('working'); + expect(harness.errors).toEqual([]); + }); +}); diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 2e0984f49..c0ecc06b6 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; +import { specTypeSchemas as clientSchemas } from '@modelcontextprotocol/client'; import { expect, it } from '@rstest/core'; import { requestEventRuntime } from '../src/events/ipc.ts'; @@ -225,6 +226,32 @@ it('serves compiled routes and durable state across packed process restarts', as ], structuredContent: { genre: 'mystery', titles: ['Piranesi', 'Solaris'] }, }); + // Task-augmented tools/call over real stdio framing (#369): the same + // spawned process answers with a CreateTaskResult first and hands the + // ordinary CallToolResult to tasks/result; a tool that did not opt in + // refuses the augmentation. + expect(firstSession.client.getServerCapabilities()?.tasks).toEqual({ cancel: {}, list: {}, requests: { tools: { call: {} } } }); + const created = await firstSession.client.request({ + method: 'tools/call', + params: { arguments: { holdMs: 50 }, name: 'wait', task: { ttl: 60_000 } }, + }, clientSchemas.CreateTaskResult); + expect(created.task).toMatchObject({ status: 'working', ttl: 60_000 }); + await expect(firstSession.client.request({ + method: 'tasks/result', + params: { taskId: created.task.taskId }, + }, clientSchemas.CallToolResult)).resolves.toMatchObject({ + _meta: { 'io.modelcontextprotocol/related-task': { taskId: created.task.taskId } }, + content: [{ text: 'waited 50ms', type: 'text' }], + structuredContent: { waitedMs: 50 }, + }); + await expect(firstSession.client.request({ + method: 'tasks/get', + params: { taskId: created.task.taskId }, + }, clientSchemas.GetTaskResult)).resolves.toMatchObject({ status: 'completed', taskId: created.task.taskId }); + await expect(firstSession.client.request({ + method: 'tools/call', + params: { arguments: { message: 'no task' }, name: 'echo', task: {} }, + }, clientSchemas.CreateTaskResult)).rejects.toMatchObject({ code: -32_601 }); await expect(firstSession.client.callTool({ arguments: { note: 'packed durable proof' }, name: 'journal', diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index daf191f81..b382d1e4a 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { McpServer } from '@modelcontextprotocol/server'; +import { specTypeSchemas as clientSchemas } from '@modelcontextprotocol/client'; import { describe, expect, it } from '@rstest/core'; import { agentNoticeStateDefinition } from '@agent-bundle/runtime/notices'; import { createMemoryStateDriver, defineState, type AgentStateDriver } from '@agent-bundle/runtime/state'; @@ -554,42 +554,154 @@ describe('the in-memory MCP projection level', () => { } }); - // Issue #369: task-augmented tool calls are deferred until the MCP SDK ships - // a task runtime (docs/mcp-conformance.md). Until then the generated server - // must stay fail-closed — no `tasks` capability claim — and must process a - // task-augmented request as an ordinary one, which is what the 2025-11-25 - // Tasks utility requires of a receiver that declared no task support. - it('never advertises the MCP Tasks capability', async () => { - await using session = await openInMemoryMcpServer(); + describe('task-augmented tool calls (#369)', () => { + const RELATED_TASK = 'io.modelcontextprotocol/related-task'; + const createTask = ( + client: Awaited>['client'], + name: string, + args: Record, + meta?: Record, + ) => client.request({ + method: 'tools/call', + params: { ...(meta === undefined ? {} : { _meta: meta }), arguments: args, name, task: { pollInterval: 100, ttl: 60_000 } }, + }, clientSchemas.CreateTaskResult); + const getTask = (client: Awaited>['client'], taskId: string) => + client.request({ method: 'tasks/get', params: { taskId } }, clientSchemas.GetTaskResult); + const getResult = (client: Awaited>['client'], taskId: string) => + client.request({ method: 'tasks/result', params: { taskId } }, clientSchemas.CallToolResult); + const rpcError = async (promise: Promise): Promise<{ readonly code: number; readonly message: string }> => { + try { + await promise; + } catch (error) { + const { code, message } = error as { code: number; message: string }; + return { code, message }; + } + throw new Error('Expected the request to fail'); + }; - const capabilities = session.client.getServerCapabilities(); - expect(capabilities).toMatchObject({ tools: expect.any(Object) }); - expect(Object.hasOwn(capabilities ?? {}, 'tasks')).toBe(false); - }); + it('advertises the tasks capability and the compiled execution.taskSupport of the routes that opted in', async () => { + await using session = await openInMemoryMcpServer(); - it('processes a task-augmented tools/call as an ordinary request', async () => { - await using session = await openInMemoryMcpServer(); + expect(session.client.getServerCapabilities()?.tasks).toEqual({ cancel: {}, list: {}, requests: { tools: { call: {} } } }); + const listed = await session.client.listTools(); + const byName = new Map(listed.tools.map((tool) => [tool.name, tool])); + expect(byName.get('wait')?.execution).toEqual({ taskSupport: 'optional' }); + expect(byName.get('catalog')?.execution).toEqual({ taskSupport: 'optional' }); + expect(byName.get('echo')?.execution).toBeUndefined(); + }); - const result = await session.client.request({ - method: 'tools/call', - params: { arguments: { message: 'deferred' }, name: 'echo', task: { ttl: 60_000 } }, + it('returns a CreateTaskResult first and the same final CallToolResult through tasks/result', async () => { + await using session = await openInMemoryMcpServer(); + + const created = await createTask(session.client, 'wait', { holdMs: 200, tickMs: 100 }); + expect(created.task).toMatchObject({ pollInterval: 100, status: 'working', ttl: 60_000 }); + // The render is still running behind the task when the response lands. + const early = await getTask(session.client, created.task.taskId); + expect(early.status).toBe('working'); + + const result = await getResult(session.client, created.task.taskId); + // Content, structuredContent, and the layout `_meta` of an ordinary call, + // plus the related-task key tasks/result must carry. + const ordinary = await session.client.callTool({ arguments: { holdMs: 200 }, name: 'wait' }); + expect(result).toEqual({ + ...ordinary, + _meta: { ...ordinary._meta, [RELATED_TASK]: { taskId: created.task.taskId } }, + }); + expect(result).toMatchObject({ content: [{ text: 'waited 200ms', type: 'text' }], structuredContent: { waitedMs: 200 } }); + expect(result).not.toHaveProperty('isError'); + const completed = await getTask(session.client, created.task.taskId); + expect(completed.status).toBe('completed'); }); - expect(result).toMatchObject({ structuredContent: { message: 'deferred' } }); - for (const key of ['task', 'taskId', 'status', 'createdAt', 'ttl', 'pollInterval']) { - expect(Object.hasOwn(result, key)).toBe(false); - } - }); + it('surfaces the render\'s progress through tasks/get, from progress.report() and from a streamed Agent.Progress fallback alike', async () => { + await using session = await openInMemoryMcpServer(); + const notifications: unknown[] = []; + session.client.setNotificationHandler('notifications/progress', (notification) => { + notifications.push(notification.params); + }); + + // No progress token: the task still observes every report — the last one + // stays on the settled task under `_meta['agent-bundle/progress']` — and + // nothing reaches the wire as notifications/progress. (This level hands + // the dispatcher the whole Flight payload at once, so the reports it + // buffered before the shell arrive together with the result; the + // mid-render `statusMessage` is pinned by tests/mcp-tasks.test.ts.) + const reported = await createTask(session.client, 'wait', { holdMs: 300, tickMs: 100 }); + await getResult(session.client, reported.task.taskId); + const settled = await getTask(session.client, reported.task.taskId); + expect(settled.status).toBe('completed'); + expect(settled).not.toHaveProperty('statusMessage'); + expect(settled._meta?.['agent-bundle/progress']).toEqual({ message: 'waiting', progress: 3, total: 3 }); + expect(notifications).toEqual([]); + + // The catalog route never calls progress.report(); its Suspense fallback + // is an Agent.Progress node the projector reads (#448), and the task + // records it exactly as the ordinary call would have notified it — under + // the client's own token, stamped with the related-task key. + const streamed = await createTask(session.client, 'catalog', { genre: 'mystery' }, { progressToken: 'tok-369' }); + const result = await getResult(session.client, streamed.task.taskId); + expect(result).toMatchObject({ structuredContent: { genre: 'mystery', titles: ['Piranesi', 'Solaris'] } }); + expect(notifications).toEqual([{ + _meta: { [RELATED_TASK]: { taskId: streamed.task.taskId } }, + message: 'loading mystery', + progress: 0, + progressToken: 'tok-369', + total: 2, + }]); + expect((await getTask(session.client, streamed.task.taskId))._meta?.['agent-bundle/progress']).toEqual({ message: 'loading mystery', progress: 0, total: 2 }); + }); + + it('cancels a working task through the render\'s own AbortSignal', async () => { + await using session = await openInMemoryMcpServer(); + + const created = await createTask(session.client, 'wait', { holdMs: 5000 }); + const cancelled = await session.client.request( + { method: 'tasks/cancel', params: { taskId: created.task.taskId } }, + clientSchemas.CancelTaskResult, + ); + expect(cancelled).toMatchObject({ status: 'cancelled', taskId: created.task.taskId }); + // The interrupted render settles as the SDK's tool error for the abort, + // which is exactly what tasks/result then returns. + const result = await getResult(session.client, created.task.taskId); + expect(result).toMatchObject({ isError: true }); + expect((await getTask(session.client, created.task.taskId)).status).toBe('cancelled'); + expect((await rpcError(session.client.request( + { method: 'tasks/cancel', params: { taskId: created.task.taskId } }, + clientSchemas.CancelTaskResult, + ))).code).toBe(-32_602); + }); + + it('lists the session\'s tasks and refuses a task call to a tool that did not opt in', async () => { + await using session = await openInMemoryMcpServer(); + + expect((await session.client.request({ method: 'tasks/list' }, clientSchemas.ListTasksResult)).tasks).toEqual([]); + const first = await createTask(session.client, 'wait', { holdMs: 50 }); + const second = await createTask(session.client, 'catalog', {}); + const listed = await session.client.request({ method: 'tasks/list' }, clientSchemas.ListTasksResult); + expect(listed.tasks.map((task) => task.taskId)).toEqual([first.task.taskId, second.task.taskId]); + await Promise.all([getResult(session.client, first.task.taskId), getResult(session.client, second.task.taskId)]); - // Compile-time half of the #369 sentinel: the SDK's spec-method handler - // overload rejects task methods today. When a release admits them, this - // directive becomes unused, `pnpm typecheck` fails, and the deferral in - // docs/mcp-conformance.md must be re-audited. Never invoked at runtime. - const typedTaskSurfaceSentinel = (server: McpServer): void => { - // @ts-expect-error tasks/get is 2025-11-25 wire vocabulary without an SDK runtime. - server.server.setRequestHandler('tasks/get', async () => ({})); - }; - void typedTaskSurfaceSentinel; + const refused = await rpcError(createTask(session.client, 'echo', { message: 'not a task' })); + expect(refused.code).toBe(-32_601); + expect((await rpcError(getTask(session.client, 'no-such-task'))).code).toBe(-32_602); + }); + + it('leaves a client that never asks for a task on the ordinary contract', async () => { + await using session = await openInMemoryMcpServer(); + const notifications: unknown[] = []; + session.client.setNotificationHandler('notifications/progress', (notification) => { + notifications.push(notification.params); + }); + + const result = await session.client.callTool({ arguments: { holdMs: 50 }, name: 'wait' }); + expect(result).toMatchObject({ content: [{ text: 'waited 50ms', type: 'text' }], structuredContent: { waitedMs: 50 } }); + for (const key of ['task', 'taskId', 'status', 'createdAt', 'ttl', 'pollInterval']) { + expect(Object.hasOwn(result, key)).toBe(false); + } + expect(notifications).toEqual([]); + expect((await session.client.request({ method: 'tasks/list' }, clientSchemas.ListTasksResult)).tasks).toEqual([]); + }); + }); it('emits notifications/resources/updated for the notice inbox only to subscribed matching sessions', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-inbox-updated-')); diff --git a/packages/agent-bundle/tests/route-task-support.test.ts b/packages/agent-bundle/tests/route-task-support.test.ts new file mode 100644 index 000000000..cc461c9b8 --- /dev/null +++ b/packages/agent-bundle/tests/route-task-support.test.ts @@ -0,0 +1,116 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import type { AgentBundleConfig } from '../src/core/types.ts'; +import { compileRouteGraph } from '../src/routes/graph.ts'; +import { routeTaskSupport, toolTaskSupportValues } from '../src/routes/task-support.ts'; + +/** + * `config.execution.taskSupport` (#369): the compiler validates the value once + * per MCP tool route (`AB4836`), and the generated server reads the compiled + * config through `routeTaskSupport`, treating anything else as the wire + * default `forbidden`. + */ + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const createRoot = async (): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-task-support-'))); + roots.push(root); + return root; +}; + +const writeTree = async (root: string, files: Readonly>): Promise => { + for (const [path, contents] of Object.entries(files)) { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents); + } +}; + +const config: AgentBundleConfig = { plugin: { name: 'task-support-fixture', version: '1.0.0' } }; + +const toolModule = (routeConfig?: string): string => [ + "import { z } from 'zod';", + ...(routeConfig === undefined ? [] : [`export const config = ${routeConfig};`]), + 'export const inputSchema = z.object({});', + 'export const resultSchema = z.object({ ok: z.boolean() });', + 'export default async function Tool() { return undefined; }', + '', +].join('\n'); + +const resourceModule = (routeConfig: string): string => [ + "import { z } from 'zod';", + `export const config = ${routeConfig};`, + 'export const inputSchema = z.object({ uri: z.string() });', + 'export const resultSchema = z.string();', + 'export default async function Resource() { return undefined; }', + '', +].join('\n'); + +const codesOf = (diagnostics: readonly { readonly code: string }[]): string[] => + diagnostics.map((diagnostic) => diagnostic.code); + +describe('config.execution.taskSupport (#369)', () => { + it('compiles every accepted value and keeps it on the route config the generated server reads', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/alpha/tools/explicit.tsx': toolModule("{ execution: { taskSupport: 'forbidden' } }"), + 'src/mcp/alpha/tools/needed.tsx': toolModule("{ execution: { taskSupport: 'required' } }"), + 'src/mcp/alpha/tools/optional.tsx': toolModule("{ execution: { taskSupport: 'optional' }, title: 'Optional' }"), + 'src/mcp/alpha/tools/plain.tsx': toolModule('{ title: "Plain" }'), + 'src/mcp/alpha/tools/unset.tsx': toolModule('{ execution: {} }'), + }); + + const graph = await compileRouteGraph(root, config); + + expect(graph.diagnostics).toEqual([]); + const byId = new Map(graph.servers[0]!.routes.map((route) => [route.id, route])); + expect(routeTaskSupport(byId.get('tool:alpha/explicit')!.config)).toBe('forbidden'); + expect(routeTaskSupport(byId.get('tool:alpha/needed')!.config)).toBe('required'); + expect(routeTaskSupport(byId.get('tool:alpha/optional')!.config)).toBe('optional'); + expect(routeTaskSupport(byId.get('tool:alpha/plain')!.config)).toBe('forbidden'); + expect(routeTaskSupport(byId.get('tool:alpha/unset')!.config)).toBe('forbidden'); + expect(byId.get('tool:alpha/optional')!.config).toEqual({ execution: { taskSupport: 'optional' }, title: 'Optional' }); + }); + + it('errors with AB4836 on a malformed declaration, an unknown value, or one outside a tool route', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/alpha/resources/notes.tsx': resourceModule("{ execution: { taskSupport: 'optional' }, uri: 'alpha://notes' }"), + 'src/mcp/alpha/tools/shape.tsx': toolModule("{ execution: 'optional' }"), + 'src/mcp/alpha/tools/unknown-key.tsx': toolModule("{ execution: { taskSupport: 'optional', timeoutMs: 5 } }"), + 'src/mcp/alpha/tools/value.tsx': toolModule("{ execution: { taskSupport: 'always' } }"), + }); + + const graph = await compileRouteGraph(root, config); + + expect(codesOf(graph.diagnostics)).toEqual(['AB4836', 'AB4836', 'AB4836', 'AB4836']); + expect(graph.diagnostics.map((diagnostic) => diagnostic.message)).toEqual([ + expect.stringContaining('MCP route src/mcp/alpha/resources/notes.tsx declares config.execution, which only tool routes accept'), + expect.stringContaining('MCP route src/mcp/alpha/tools/shape.tsx config.execution must be an object'), + expect.stringContaining('MCP route src/mcp/alpha/tools/unknown-key.tsx config.execution declares unknown key "timeoutMs"; only taskSupport is accepted'), + expect.stringContaining('MCP route src/mcp/alpha/tools/value.tsx config.execution.taskSupport must be one of "forbidden", "optional", "required"; got "always"'), + ]); + for (const diagnostic of graph.diagnostics) { + expect(diagnostic.severity).toBe('error'); + expect(diagnostic.recovery).toContain("taskSupport: 'forbidden' | 'optional' | 'required'"); + } + expect(graph.diagnostics[1]!.sourcePath).toBe(join(root, 'src/mcp/alpha/tools/shape.tsx')); + }); + + it('reads only a well-formed compiled value at run time', () => { + expect(toolTaskSupportValues).toEqual(['forbidden', 'optional', 'required']); + expect(routeTaskSupport({})).toBe('forbidden'); + expect(routeTaskSupport({ execution: null })).toBe('forbidden'); + expect(routeTaskSupport({ execution: { taskSupport: 'sometimes' } })).toBe('forbidden'); + expect(routeTaskSupport({ execution: { taskSupport: 'required' } })).toBe('required'); + }); +}); diff --git a/packages/rsc-runtime/tests/mcp-tasks-deferral.test.ts b/packages/rsc-runtime/tests/mcp-tasks-deferral.test.ts deleted file mode 100644 index 6c79adab6..000000000 --- a/packages/rsc-runtime/tests/mcp-tasks-deferral.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Deferral sentinel for issue #369 (task-augmented MCP tool calls, the #96 -// acceptance remainder). The installed MCP SDK carries the 2025-11-25 Tasks -// wire vocabulary but no task runtime, and the 2026-07-28 revision moved -// tasks into the `io.modelcontextprotocol/tasks` extension (SEP-2663) with a -// redesigned lifecycle. Rather than hand-roll a protocol fork on a surface the -// SDK labels "interoperability only", the repository defers and pins the exact -// conditions of that deferral here. Every assertion below is a fact about the -// SDK as installed; the day one of them stops holding, this file fails and the -// deferral recorded in docs/mcp-conformance.md must be re-audited. -import { readFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; - -import * as clientModule from '@modelcontextprotocol/client'; -import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; -import * as serverModule from '@modelcontextprotocol/server'; -import { McpServer as ProtocolMcpServer } from '@modelcontextprotocol/server'; -import { afterAll, describe, expect, it } from '@rstest/core'; -import { createElement } from 'react'; -import { z } from 'zod'; - -import { Mcp, createRscMcpServer, defineOperation, defineRscApplication } from '../src/index.js'; - -/** The SDK revision this deferral was audited against (2026-09-02). */ -const AUDITED_SDK_VERSION = '2.0.0'; - -/** The task vocabulary the 2025-11-25 revision defines and the SDK leaves unrouted. */ -const TASK_METHODS = ['tasks/get', 'tasks/result', 'tasks/list', 'tasks/cancel'] as const; - -/** - * The only task-named exports the audited SDK exposes: a `_meta` key and a - * deprecated wire-shape guard. A `TaskStore`, `registerToolTask`, an - * `experimental.tasks` namespace, or exported task result schemas would mean - * the SDK grew a runtime and the deferral is stale. - */ -const AUDITED_TASK_EXPORTS = ['RELATED_TASK_META_KEY', 'isTaskAugmentedRequestParams']; - -const installedVersion = async (packageName: string): Promise => { - const manifestPath = fileURLToPath(new URL(`../node_modules/${packageName}/package.json`, import.meta.url)); - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { readonly version: string }; - return manifest.version; -}; - -const taskNamedExports = (module: Readonly>): readonly string[] => - Object.keys(module).filter((name) => /task/iu.test(name)).sort(); - -const slowOperation = defineOperation({ - execute: async () => ({ ok: true }), - id: 'slow', - inputSchema: z.object({}).strict(), - mcp: { - description: 'A render the Tasks utility would let a client defer.', - name: 'slow', - readOnly: true, - server: 'demo', - }, - render: (result) => createElement( - Mcp.Result, - { structuredContent: result }, - createElement(Mcp.Text, null, 'done'), - ), - resultSchema: z.object({ ok: z.boolean() }).strict(), -}); - -const application = defineRscApplication({ - name: 'tasks-deferral-demo', - operations: [slowOperation], - version: '1.0.0', -}); - -interface WireMessage { - readonly error?: { readonly code: number; readonly message: string }; - readonly id?: number | string; - readonly result?: Record; -} - -const openClients: Client[] = []; - -/** - * Connects a real client that negotiates the 2025-11-25 `tasks` capability - * and returns a raw-frame injector: the typed client refuses task methods, so - * the wire shape a task-aware peer would send is delivered straight to the - * server transport and the serialized response captured. - */ -const connectTaskNegotiatingClient = async (): Promise<{ - readonly client: Client; - readonly inject: (id: number, method: string, params: Record) => Promise; - readonly server: ProtocolMcpServer; -}> => { - const server = createRscMcpServer(application, 'demo'); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const sent: WireMessage[] = []; - const originalSend = serverTransport.send.bind(serverTransport); - serverTransport.send = async (message, options) => { - sent.push(JSON.parse(JSON.stringify(message)) as WireMessage); - return originalSend(message, options); - }; - const client = new Client( - { name: 'tasks-deferral-test', version: '0.0.0' }, - { capabilities: { tasks: { requests: { tools: { call: {} } } } } as never }, - ); - openClients.push(client); - await server.connect(serverTransport); - await client.connect(clientTransport); - const inject = async (id: number, method: string, params: Record): Promise => { - serverTransport.onmessage?.({ id, jsonrpc: '2.0', method, params }); - for (let attempt = 0; attempt < 200; attempt += 1) { - const response = sent.find((message) => message.id === id); - if (response !== undefined) return response; - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error(`No response for ${method} (id ${String(id)})`); - }; - return { client, inject, server }; -}; - -afterAll(async () => { - await Promise.allSettled(openClients.map((client) => client.close())); -}); - -describe('MCP Tasks deferral sentinel (#369)', () => { - it('pins the SDK revision the deferral was audited against', async () => { - await expect(installedVersion('@modelcontextprotocol/server')).resolves.toBe(AUDITED_SDK_VERSION); - await expect(installedVersion('@modelcontextprotocol/client')).resolves.toBe(AUDITED_SDK_VERSION); - }); - - it('exposes only task wire vocabulary, not a task runtime', () => { - expect(taskNamedExports(serverModule)).toEqual(AUDITED_TASK_EXPORTS); - expect(taskNamedExports(clientModule)).toEqual(AUDITED_TASK_EXPORTS); - const server = new ProtocolMcpServer({ name: 'probe', version: '0.0.0' }); - expect('experimental' in server).toBe(false); - expect('experimental' in server.server).toBe(false); - }); - - it('keeps task methods off the typed request surface', async () => { - const { client } = await connectTaskNegotiatingClient(); - for (const method of TASK_METHODS) { - // The audited client rejects synchronously; a later SDK might reject the - // returned promise instead, so both shapes are funnelled through one promise. - await expect(Promise.resolve().then(() => client.request({ method, params: { taskId: 'never-created' } } as never))) - .rejects.toThrow(/not a spec method/u); - } - // The compile-time half of this sentinel (a `@ts-expect-error` on the - // typed `setRequestHandler('tasks/get', …)` overload) lives in - // packages/agent-bundle/tests/projection/mcp-in-memory.test.ts, which - // `pnpm typecheck` covers; this package's tests are not type-checked. - }); - - it('never advertises tasks, even to a client that negotiated them', async () => { - const { client, server } = await connectTaskNegotiatingClient(); - expect(server.server.getClientCapabilities()).toMatchObject({ tasks: { requests: { tools: { call: {} } } } }); - expect(client.getServerCapabilities()).toBeDefined(); - expect(Object.hasOwn(client.getServerCapabilities() ?? {}, 'tasks')).toBe(false); - }); - - it('processes a task-augmented tools/call as an ordinary request', async () => { - // 2025-11-25 Tasks: a receiver that does not declare the capability MUST - // process the request normally, ignoring task-augmentation metadata. - const { inject } = await connectTaskNegotiatingClient(); - const response = await inject(101, 'tools/call', { arguments: {}, name: 'slow', task: { ttl: 60_000 } }); - expect(response.error).toBeUndefined(); - expect(response.result).toMatchObject({ - content: [{ text: 'done', type: 'text' }], - structuredContent: { ok: true }, - }); - for (const key of ['task', 'taskId', 'status', 'createdAt', 'ttl', 'pollInterval']) { - expect(Object.hasOwn(response.result ?? {}, key)).toBe(false); - } - }); - - it('answers every task operation with JSON-RPC method-not-found', async () => { - const { inject } = await connectTaskNegotiatingClient(); - for (const [index, method] of TASK_METHODS.entries()) { - const response = await inject(200 + index, method, { taskId: 'never-created' }); - expect(response.result).toBeUndefined(); - expect(response.error?.code).toBe(-32_601); - } - }); -}); From c3e68df3e33cfef81a8edcc4103b84ae59b83f6f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:35:12 +0000 Subject: [PATCH 2/8] =?UTF-8?q?feat(workbench):=20drive=20task-augmented?= =?UTF-8?q?=20tool=20calls=20from=20the=20MCP=20page=20=E2=80=94=20run=20a?= =?UTF-8?q?s=20task,=20poll=20tasks/get,=20fetch=20tasks/result,=20cancel,?= =?UTF-8?q?=20list=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser session controller, the dev session routes, and the dev McpSession gain the typed task operations (callToolTask, getTask, getTaskResult, cancelTask, listTasks) over the same epoch-bound operation vocabulary; the MCP page offers a Run-as-task toggle for tools that advertise execution.taskSupport, folds every task answer from the invocation history into a Tasks panel, and polls working tasks at the server's suggested interval. The host-test example gains a task-capable slow probe (execution.taskSupport optional) so a host's handling of long-running tools can be observed; the new desktop browser acceptance drives it through the real generated stdio server. --- examples/host-test/README.md | 16 +- .../src/mcp/host-test/tools/slow.tsx | 66 +++++ .../host-test/tests/route-unit/routes.test.ts | 18 +- .../dev/mcp-session/mcp-session-protocol.ts | 10 + .../src/dev/mcp-session/mcp-session-routes.ts | 57 ++++- .../src/dev/mcp-session/mcp-session-types.ts | 35 +++ .../src/dev/mcp-session/mcp-session.ts | 102 +++++++- .../tests/mcp-session-routes.test.ts | 70 ++++++ .../src/mcp/agent-bundle-remote-transport.ts | 30 ++- packages/workbench/src/mcp/mcp-page.css | 56 +++++ packages/workbench/src/mcp/mcp-page.tsx | 225 +++++++++++++++++- .../workbench/src/mcp/mcp-route-client.ts | 11 +- .../src/mcp/mcp-session-controller.ts | 53 ++++- packages/workbench/tests/mcp-page.test.ts | 88 +++++++ .../tests/mcp-session-controller.test.ts | 63 ++++- .../workbench/tests/mcp-tasks.e2e.test.ts | 117 +++++++++ .../tests/support/example-acceptance.ts | 2 +- rstest.integration-tests.ts | 1 + 18 files changed, 977 insertions(+), 43 deletions(-) create mode 100644 examples/host-test/src/mcp/host-test/tools/slow.tsx create mode 100644 packages/workbench/tests/mcp-tasks.e2e.test.ts diff --git a/examples/host-test/README.md b/examples/host-test/README.md index 7aeec4967..59c509dd5 100644 --- a/examples/host-test/README.md +++ b/examples/host-test/README.md @@ -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 diff --git a/examples/host-test/src/mcp/host-test/tools/slow.tsx b/examples/host-test/src/mcp/host-test/tools/slow.tsx new file mode 100644 index 000000000..7c7660f08 --- /dev/null +++ b/examples/host-test/src/mcp/host-test/tools/slow.tsx @@ -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) { + 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 = { heldMs: Date.now() - startedAt, log: observed.log.path, ticks }; + return ( + + {`Held the call for ${String(result.heldMs)}ms across ${String(ticks)} progress ticks; recorded in ${observed.log.path}.`} + + ); +} diff --git a/examples/host-test/tests/route-unit/routes.test.ts b/examples/host-test/tests/route-unit/routes.test.ts index 97235e3aa..8c55f3fad 100644 --- a/examples/host-test/tests/route-unit/routes.test.ts +++ b/examples/host-test/tests/route-unit/routes.test.ts @@ -86,7 +86,23 @@ it('compiles every canonical event family plus the MCP and CLI surfaces', () => ]) { expect(routes, family).toContain(`event:${family}`); } - expect(routes).toEqual(expect.arrayContaining(['tool:host-test/dump', 'tool:host-test/reset', 'cli:dump'])); + expect(routes).toEqual(expect.arrayContaining(['tool:host-test/dump', 'tool:host-test/reset', 'tool:host-test/slow', 'cli:dump'])); + expect(manifest.routes['tool:host-test/slow']?.config).toMatchObject({ execution: { taskSupport: 'optional' } }); +}); + +it('holds the slow probe open for the requested time, reporting one progress tick per tickMs, and records the call', async () => { + const slow = await render('tool:host-test/slow', { holdMs: 120, tickMs: 40 }); + expect(slow.document.value).toMatchObject({ ticks: 3 }); + expect((slow.document.value as { heldMs: number }).heldMs).toBeGreaterThanOrEqual(100); + expect(slow.progress.map((update) => update.completed)).toEqual([1, 2, 3]); + // The probe is recorded like every other MCP call; the dump that reads it records itself too. + const dumped = await render('tool:host-test/dump', {}); + expect(dumped.document.value).toMatchObject({ + records: [ + expect.objectContaining({ event: 'mcp:slow', kind: 'mcp', observed: { holdMs: 120, tickMs: 40, tool: 'slow' } }), + expect.objectContaining({ event: 'mcp:dump', kind: 'mcp' }), + ], + }); }); it('records the complete native envelope, the request context, and env names for every event', async () => { diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-protocol.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-protocol.ts index f71c17a8c..791d57295 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-protocol.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-protocol.ts @@ -53,12 +53,22 @@ export interface McpSessionOperationTraceEntry extends McpSessionTraceEntryBase export type McpSessionOperation = | 'callTool' + /** A task-augmented `tools/call` (#369): answered by a `CreateTaskResult`. */ + | 'callToolTask' | 'cancel' + /** `tasks/cancel` of one task the session created. */ + | 'cancelTask' | 'getPrompt' + /** `tasks/get`: the status, progress, and retention of one task. */ + | 'getTask' + /** `tasks/result`: the final `CallToolResult` of one task, blocking until it settles. */ + | 'getTaskResult' | 'initialize' | 'listPrompts' | 'listResources' | 'listResourceTemplates' + /** `tasks/list`: every task the session still retains. */ + | 'listTasks' | 'listTools' | 'readResource' | 'restart' diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts index f54422df7..173072a18 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts @@ -44,7 +44,18 @@ export interface McpSessionRouteSession { readonly id: string; readonly timeoutMs: number; callTool(options: { readonly arguments: Readonly>; readonly name: string; readonly requestId?: string }): Promise; + /** A task-augmented `tools/call` (#369): answered by a `CreateTaskResult` handle. */ + callToolTask(options: { + readonly arguments: Readonly>; + readonly name: string; + readonly requestId?: string; + readonly task: Readonly<{ readonly pollInterval?: number; readonly ttl?: number }>; + }): Promise; cancel(requestId: string): boolean; + cancelTask(options: { readonly taskId: string }): Promise; + getTask(options: { readonly taskId: string }): Promise; + getTaskResult(options: { readonly taskId: string }): Promise; + listTasks(options: { readonly cursor?: string }): Promise; getPrompt(options: { readonly arguments?: Record; readonly name: string }): Promise; inspectorConfig(): McpSessionInspectorConfig; listPrompts(): Promise; @@ -129,7 +140,27 @@ type Operation = | Readonly<{ readonly operation: 'tools/list' | 'resources/list' | 'resources/templates/list' | 'prompts/list' }> | Readonly<{ readonly arguments?: Record; readonly name: string; readonly operation: 'prompts/get' }> | Readonly<{ readonly operation: 'resources/read'; readonly uri: string }> - | Readonly<{ readonly arguments: Readonly>; readonly name: string; readonly operation: 'tools/call'; readonly requestId?: string }>; + | Readonly<{ + readonly arguments: Readonly>; + readonly name: string; + readonly operation: 'tools/call'; + readonly requestId?: string; + /** Present on a task-augmented call: the `params.task` the server receives. */ + readonly task?: Readonly<{ readonly pollInterval?: number; readonly ttl?: number }>; + }> + | Readonly<{ readonly operation: 'tasks/get' | 'tasks/result' | 'tasks/cancel'; readonly taskId: string }> + | Readonly<{ readonly cursor?: string; readonly operation: 'tasks/list' }>; + +const taskCreation = (value: unknown): Readonly<{ readonly pollInterval?: number; readonly ttl?: number }> => { + if (!isRecord(value) || !hasOnly(value, ['pollInterval', 'ttl'])) return invalidShape(); + const { pollInterval, ttl } = value; + if (ttl !== undefined && (typeof ttl !== 'number' || !Number.isFinite(ttl) || ttl <= 0)) return invalidShape(); + if (pollInterval !== undefined && (typeof pollInterval !== 'number' || !Number.isFinite(pollInterval) || pollInterval <= 0)) return invalidShape(); + return Object.freeze({ + ...(pollInterval === undefined ? {} : { pollInterval }), + ...(ttl === undefined ? {} : { ttl }), + }); +}; const operationRequest = (value: JsonObject): Operation => { const operation = value.operation; @@ -160,7 +191,7 @@ const operationRequest = (value: JsonObject): Operation => { return Object.freeze({ operation, uri }); } if (operation === 'tools/call') { - if (!hasOnly(value, ['arguments', 'name', 'operation', 'requestId'])) return invalidShape(); + if (!hasOnly(value, ['arguments', 'name', 'operation', 'requestId', 'task'])) return invalidShape(); const argumentsValue = value.arguments; const name = value.name; const requestId = value.requestId; @@ -171,8 +202,21 @@ const operationRequest = (value: JsonObject): Operation => { name, operation, ...(requestId === undefined ? {} : { requestId }), + ...(value.task === undefined ? {} : { task: taskCreation(value.task) }), }); } + if (operation === 'tasks/get' || operation === 'tasks/result' || operation === 'tasks/cancel') { + if (!hasOnly(value, ['operation', 'taskId'])) return invalidShape(); + const taskId = value.taskId; + if (!nonemptyString(taskId)) return invalidShape(); + return Object.freeze({ operation, taskId }); + } + if (operation === 'tasks/list') { + if (!hasOnly(value, ['cursor', 'operation'])) return invalidShape(); + const cursor = value.cursor; + if (cursor !== undefined && !nonemptyString(cursor)) return invalidShape(); + return Object.freeze({ ...(cursor === undefined ? {} : { cursor }), operation }); + } return invalidShape(); }; @@ -354,12 +398,17 @@ export class McpSessionRoutes { } if (operation.operation === 'resources/read') return session.readResource({ uri: operation.uri }); if (operation.operation === 'tools/call') { - return session.callTool({ + const call = { arguments: operation.arguments, name: operation.name, ...(operation.requestId === undefined ? {} : { requestId: operation.requestId }), - }); + }; + return operation.task === undefined ? session.callTool(call) : session.callToolTask({ ...call, task: operation.task }); } + if (operation.operation === 'tasks/get') return session.getTask({ taskId: operation.taskId }); + if (operation.operation === 'tasks/result') return session.getTaskResult({ taskId: operation.taskId }); + if (operation.operation === 'tasks/cancel') return session.cancelTask({ taskId: operation.taskId }); + if (operation.operation === 'tasks/list') return session.listTasks(operation.cursor === undefined ? {} : { cursor: operation.cursor }); return invalidShape(); } diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts index ee1cdab9c..74da48264 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts @@ -8,6 +8,7 @@ import type { ServerCapabilities, Tool, Transport, + StandardSchemaV1, } from '@modelcontextprotocol/client'; import type { Stream } from 'node:stream'; @@ -58,6 +59,40 @@ export interface McpClient { ): Promise<{ readonly resourceTemplates: readonly ResourceTemplateType[] }>; listTools(params?: undefined, options?: McpRequestOptions): Promise<{ readonly tools: readonly Tool[] }>; readResource(params: { readonly uri: string }, options?: McpRequestOptions): Promise<{ readonly contents: readonly unknown[] }>; + /** + * One request outside the SDK's typed method surface — the `2025-11-25` + * task methods — validated against the SDK schema of its result. Optional + * so a narrow test double stays a valid client; a session whose client + * lacks it refuses task operations. + */ + request?( + request: { readonly method: string; readonly params?: Record }, + resultSchema: T, + options?: McpRequestOptions, + ): Promise>; +} + +/** The `params.task` of a task-augmented `tools/call` (MCP `2025-11-25` Tasks). */ +export interface McpSessionTaskCreation { + readonly pollInterval?: number; + readonly ttl?: number; +} + +/** + * A task-augmented tool call (#369): the request carries `params.task` and the + * server answers with a `CreateTaskResult` handle instead of the tool result, + * which `getTaskResult` then retrieves. + */ +export interface McpSessionTaskCallOptions extends McpSessionToolCallOptions { + readonly task: McpSessionTaskCreation; +} + +export interface McpSessionTaskOptions extends McpSessionRequestOptions { + readonly taskId: string; +} + +export interface McpSessionTaskListOptions extends McpSessionRequestOptions { + readonly cursor?: string; } export interface StdioTransport extends Transport { diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts index 9ed574737..ff7ee26b9 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts @@ -1,11 +1,17 @@ -import type { - CallToolResult, - GetPromptResult, - Prompt, - Resource, - ResourceTemplateType, - Tool, - Transport, +import { + specTypeSchemas, + type CallToolResult, + type CancelTaskResult, + type CreateTaskResult, + type GetPromptResult, + type GetTaskResult, + type ListTasksResult, + type Prompt, + type Resource, + type ResourceTemplateType, + type StandardSchemaV1, + type Tool, + type Transport, } from '@modelcontextprotocol/client'; import { Effect, type Scope, Semaphore } from 'effect'; import { randomUUID } from 'node:crypto'; @@ -47,6 +53,9 @@ import { type McpSessionReplay, type McpSessionRequestOptions, type McpSessionResourceOptions, + type McpSessionTaskCallOptions, + type McpSessionTaskListOptions, + type McpSessionTaskOptions, type McpSessionToolCallOptions, type RemoteTransportOptions, type StdioOptions, @@ -316,7 +325,71 @@ export class McpSession { if (options.signal?.aborted) { throw options.signal.reason ?? new Error('MCP session tool call was aborted.'); } - return this.#operation('callTool', () => runPromise(this.#callToolEffect(options))); + return this.#operation('callTool', () => runPromise(this.#callToolEffect(options, (client, params, wire) => client.callTool(params, wire)))); + } + + /** + * A task-augmented `tools/call` (#369): the same request slot and + * cancellation as `callTool`, but the request carries `params.task` and the + * server answers with the `CreateTaskResult` handle. The call is outside the + * SDK's typed `callTool`, so it goes through `request()` with the SDK's own + * result schema. + */ + async callToolTask(options: McpSessionTaskCallOptions): Promise { + if (options.signal?.aborted) { + throw options.signal.reason ?? new Error('MCP session tool call was aborted.'); + } + return this.#operation('callToolTask', () => runPromise(this.#callToolEffect(options, (client, params, wire) => { + if (client.request === undefined) throw new TypeError('MCP session client cannot issue a task-augmented tools/call: it has no request() method.'); + return client.request({ method: 'tools/call', params: { ...params, task: { ...options.task } } }, specTypeSchemas.CreateTaskResult, wire); + }))); + } + + /** `tasks/get`: the status, progress, and retention of one task this session created. */ + async getTask(options: McpSessionTaskOptions): Promise { + return this.#operation('getTask', () => this.#taskRequest( + { method: 'tasks/get', params: { taskId: options.taskId } }, + specTypeSchemas.GetTaskResult, + options, + )); + } + + /** `tasks/result`: the final `CallToolResult`, blocking until the task settles (bounded by the request timeout). */ + async getTaskResult(options: McpSessionTaskOptions): Promise { + return this.#operation('getTaskResult', () => this.#taskRequest( + { method: 'tasks/result', params: { taskId: options.taskId } }, + specTypeSchemas.CallToolResult, + options, + )); + } + + /** `tasks/cancel`: interrupts the task's render; the server answers with the cancelled task. */ + async cancelTask(options: McpSessionTaskOptions): Promise { + return this.#operation('cancelTask', () => this.#taskRequest( + { method: 'tasks/cancel', params: { taskId: options.taskId } }, + specTypeSchemas.CancelTaskResult, + options, + )); + } + + /** `tasks/list`: every task the server still retains for this session. */ + async listTasks(options: McpSessionTaskListOptions = {}): Promise { + return this.#operation('listTasks', () => this.#taskRequest( + { method: 'tasks/list', params: options.cursor === undefined ? {} : { cursor: options.cursor } }, + specTypeSchemas.ListTasksResult, + options, + )); + } + + /** The SDK request path task methods take: outside the typed surface, validated by the SDK result schema. */ + async #taskRequest( + request: { readonly method: string; readonly params?: Record }, + resultSchema: T, + options: McpSessionRequestOptions, + ): Promise> { + const client = this.#clientFor(); + if (client.request === undefined) throw new TypeError(`MCP session client cannot issue ${request.method}: it has no request() method.`); + return client.request(request, resultSchema, requestOptions(options, this.#timeoutMs)); } /** @@ -332,7 +405,14 @@ export class McpSession { * `acquireRelease(new AbortController, abort)` shape, but it exposes only * the signal and aborts without a reason; this contract needs both. */ - #callToolEffect(options: McpSessionToolCallOptions): Effect.Effect { + #callToolEffect( + options: McpSessionToolCallOptions, + send: ( + client: McpClient, + params: { readonly _meta?: McpSessionToolCallOptions['_meta']; readonly arguments: Record; readonly name: string }, + wire: RequestOptions, + ) => Promise, + ): Effect.Effect { return this.#assertEpochCurrentEffect().pipe(Effect.andThen(Effect.suspend(() => { if (options.signal?.aborted) { return Effect.fail(options.signal.reason ?? new Error('MCP session tool call was aborted.')); @@ -344,7 +424,7 @@ export class McpSession { const call = Effect.gen({ self: this }, function* (this: McpSession) { const controller = yield* this.#admitRequest(requestId); const client = yield* liftTry(() => this.#clientFor()); - const result = yield* liftPromise(() => client.callTool({ + const result = yield* liftPromise(() => send(client, { ...(options._meta === undefined ? {} : { _meta: options._meta }), arguments: options.arguments, name: options.name, diff --git a/packages/agent-bundle/tests/mcp-session-routes.test.ts b/packages/agent-bundle/tests/mcp-session-routes.test.ts index 03730a16a..ed6851698 100644 --- a/packages/agent-bundle/tests/mcp-session-routes.test.ts +++ b/packages/agent-bundle/tests/mcp-session-routes.test.ts @@ -46,11 +46,36 @@ class RecordingSession implements McpSessionRouteSession { return Promise.resolve({ content: [{ text: 'forecast', type: 'text' }], structuredContent: { temperature: 20 } }); } + callToolTask(options: { readonly arguments: Record; readonly name: string; readonly requestId?: string; readonly task: Readonly> }): Promise { + this.calls.push({ kind: 'callToolTask', options }); + return Promise.resolve({ task: { createdAt: 't0', lastUpdatedAt: 't0', status: 'working', taskId: 'task-a', ttl: 60_000 } }); + } + cancel(requestId: string): boolean { this.calls.push({ kind: 'cancel', requestId }); return requestId === 'request-a'; } + cancelTask(options: { readonly taskId: string }): Promise { + this.calls.push({ kind: 'cancelTask', options }); + return Promise.resolve({ createdAt: 't0', lastUpdatedAt: 't1', status: 'cancelled', taskId: options.taskId, ttl: 60_000 }); + } + + getTask(options: { readonly taskId: string }): Promise { + this.calls.push({ kind: 'getTask', options }); + return Promise.resolve({ createdAt: 't0', lastUpdatedAt: 't0', status: 'working', taskId: options.taskId, ttl: 60_000 }); + } + + getTaskResult(options: { readonly taskId: string }): Promise { + this.calls.push({ kind: 'getTaskResult', options }); + return Promise.resolve({ content: [{ text: 'forecast', type: 'text' }] }); + } + + listTasks(options: { readonly cursor?: string }): Promise { + this.calls.push({ kind: 'listTasks', options }); + return Promise.resolve({ tasks: [] }); + } + getPrompt(options: { readonly arguments?: Record; readonly name: string }): Promise { this.calls.push({ kind: 'getPrompt', options }); return Promise.resolve({ messages: [] }); @@ -293,6 +318,51 @@ it('requires the foreground origin and token before every MCP session operation' } }); +it('routes the task operations (#369) as typed operations, rejecting malformed task shapes', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + const post = (body: unknown) => fetch(`${started.url}/api/mcp/sessions/session-a/operations`, { + body: JSON.stringify(body), + headers: { ...headers(), 'content-type': 'application/json' }, + method: 'POST', + }); + + try { + const created = await post({ arguments: { holdMs: 50 }, name: 'forecast', operation: 'tools/call', requestId: 'request-task', task: { ttl: 60_000 } }); + expect(created.status).toBe(200); + await expect(created.json()).resolves.toEqual({ result: { task: expect.objectContaining({ status: 'working', taskId: 'task-a' }) } }); + expect(service.session.calls).toContainEqual({ + kind: 'callToolTask', + options: { arguments: { holdMs: 50 }, name: 'forecast', requestId: 'request-task', task: { ttl: 60_000 } }, + }); + for (const [operation, kind] of [['tasks/get', 'getTask'], ['tasks/result', 'getTaskResult'], ['tasks/cancel', 'cancelTask']] as const) { + const response = await post({ operation, taskId: 'task-a' }); + expect(response.status).toBe(200); + expect(service.session.calls).toContainEqual({ kind, options: { taskId: 'task-a' } }); + } + const listed = await post({ operation: 'tasks/list' }); + expect(listed.status).toBe(200); + await expect(listed.json()).resolves.toEqual({ result: { tasks: [] } }); + expect(service.session.calls).toContainEqual({ kind: 'listTasks', options: {} }); + + for (const malformed of [ + { arguments: {}, name: 'forecast', operation: 'tools/call', task: { ttl: -1 } }, + { arguments: {}, name: 'forecast', operation: 'tools/call', task: { later: true } }, + { operation: 'tasks/get' }, + { operation: 'tasks/get', taskId: '' }, + { cursor: 5, operation: 'tasks/list' }, + ]) { + const rejected = await post(malformed); + expect(rejected.status).toBe(400); + await expect(rejected.json()).resolves.toEqual({ + diagnostic: { code: 'AB8016', message: 'MCP session request has an invalid shape.' }, + }); + } + } finally { + await started.close(); + } +}); + it('exposes the frozen operation and catalog surface without a generic launch or JSON-RPC escape hatch', async () => { const service = new RecordingService(); const started = await startRoutes(service); diff --git a/packages/workbench/src/mcp/agent-bundle-remote-transport.ts b/packages/workbench/src/mcp/agent-bundle-remote-transport.ts index a7c002b7e..11520be4a 100644 --- a/packages/workbench/src/mcp/agent-bundle-remote-transport.ts +++ b/packages/workbench/src/mcp/agent-bundle-remote-transport.ts @@ -11,7 +11,18 @@ import { } from './mcp-route-client.ts'; const maxEmptyStreamReconnects = 3; -const browserRoutedMethods = new Set(['tools/list', 'resources/list', 'tools/call', 'resources/read'] as const); +const browserRoutedMethods = new Set([ + 'tools/list', 'resources/list', 'tools/call', 'resources/read', 'tasks/get', 'tasks/result', 'tasks/cancel', 'tasks/list', +] as const); + +const taskCreation = (value: unknown): Readonly<{ readonly pollInterval?: number; readonly ttl?: number }> | 'invalid' => { + if (!isRecord(value)) return 'invalid'; + const { pollInterval, ttl, ...rest } = value; + if (Object.keys(rest).length > 0) return 'invalid'; + if (ttl !== undefined && (typeof ttl !== 'number' || !Number.isFinite(ttl) || ttl <= 0)) return 'invalid'; + if (pollInterval !== undefined && (typeof pollInterval !== 'number' || !Number.isFinite(pollInterval) || pollInterval <= 0)) return 'invalid'; + return { ...(pollInterval === undefined ? {} : { pollInterval }), ...(ttl === undefined ? {} : { ttl }) }; +}; interface JsonRpcRequest { readonly id: number | string; @@ -82,13 +93,26 @@ const operationFor = (message: JsonRpcRequest): OperationResolution => { } if (message.method === 'tools/call') { if (typeof params?.name !== 'string' || (params.arguments !== undefined && !isRecord(params.arguments))) return { kind: 'invalid' }; + // MCP 2025-11-25 Tasks (#369): `params.task` asks the server to answer with a task handle. + const task = params.task === undefined ? undefined : taskCreation(params.task); + if (task === 'invalid') return { kind: 'invalid' }; return { kind: 'operation', operation: { arguments: params.arguments ?? {}, name: params.name, operation: 'tools/call', requestId: requestKey(message.id), + ...(task === undefined ? {} : { task }), } }; } + if (message.method === 'tasks/get' || message.method === 'tasks/result' || message.method === 'tasks/cancel') { + return typeof params?.taskId === 'string' && params.taskId.length > 0 + ? { kind: 'operation', operation: { operation: message.method, taskId: params.taskId } } + : { kind: 'invalid' }; + } + if (message.method === 'tasks/list') { + if (params?.cursor !== undefined && typeof params.cursor !== 'string') return { kind: 'invalid' }; + return { kind: 'operation', operation: { operation: 'tasks/list', ...(typeof params?.cursor === 'string' ? { cursor: params.cursor } : {}) } }; + } return message.method === 'prompts/get' ? { kind: 'invalid' } : undefined; }; @@ -119,7 +143,9 @@ export interface AgentBundleMcpDispatchResult { readonly vector?: import('../../../agent-bundle/src/contracts/runtime.ts').RuntimeVector; } -type AgentBundleMcpRoutedMethod = 'tools/list' | 'resources/list' | 'tools/call' | 'resources/read'; +type AgentBundleMcpRoutedMethod = + | 'tools/list' | 'resources/list' | 'tools/call' | 'resources/read' + | 'tasks/get' | 'tasks/result' | 'tasks/cancel' | 'tasks/list'; export const dispatchAgentBundleMcpRequest = async ( message: JSONRPCMessage, diff --git a/packages/workbench/src/mcp/mcp-page.css b/packages/workbench/src/mcp/mcp-page.css index e8c1b4f0f..c680611bb 100644 --- a/packages/workbench/src/mcp/mcp-page.css +++ b/packages/workbench/src/mcp/mcp-page.css @@ -20,6 +20,7 @@ .mcp-page-launch-configuration, .mcp-page-active, .mcp-page-history, +.mcp-page-tasks, .mcp-page-trace, .mcp-page-recovery, .mcp-page-diagnostics, @@ -182,6 +183,7 @@ .mcp-page-launch-configuration, .mcp-page-active, .mcp-page-history, +.mcp-page-tasks, .mcp-page-trace, .mcp-page-recovery, .mcp-page-diagnostics, @@ -192,6 +194,60 @@ gap: 0.65rem; } +.mcp-page-tasks ol { + display: grid; + gap: 0.65rem; + margin: 0; + min-width: 0; + padding-left: 1.25rem; +} + +.mcp-page-tasks li { + display: grid; + gap: 0.4rem; + min-width: 0; +} + +.mcp-page-tasks li > div:first-child { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.mcp-page-tasks p { + margin: 0; +} + +.mcp-page-task-status { + border: 1px solid #34465d; + border-radius: 999px; + font-size: 0.8rem; + padding: 0.05rem 0.55rem; + text-transform: uppercase; +} + +.mcp-page-task-status-completed { + border-color: #3f8f5f; +} + +.mcp-page-task-status-failed, +.mcp-page-task-status-cancelled { + border-color: #b8564a; +} + +.mcp-page-task-times { + color: #9fb0c4; + font-size: 0.85rem; +} + +.mcp-page-task-toggle { + align-items: center; + display: flex; + gap: 0.5rem; + margin: 0.5rem 0; +} + .mcp-page-catalog ol, .mcp-page-launch-configuration ol, .mcp-page-launch-configuration ul, diff --git a/packages/workbench/src/mcp/mcp-page.tsx b/packages/workbench/src/mcp/mcp-page.tsx index c56c679b5..0eb74bf30 100644 --- a/packages/workbench/src/mcp/mcp-page.tsx +++ b/packages/workbench/src/mcp/mcp-page.tsx @@ -202,10 +202,131 @@ type CatalogItem = Readonly<{ readonly description?: string; readonly name: string; readonly schema?: unknown; + /** The tool's advertised `execution.taskSupport` (MCP 2025-11-25 Tasks); absent means ordinary calls only. */ + readonly taskSupport?: McpTaskSupport; readonly uri?: string; readonly uriTemplate?: string; }>; +type McpTaskSupport = 'forbidden' | 'optional' | 'required'; + +const taskSupportOf = (record: Readonly>): McpTaskSupport | undefined => { + const execution = isRecord(record.execution) ? record.execution : undefined; + const value = execution?.taskSupport; + return value === 'forbidden' || value === 'optional' || value === 'required' ? value : undefined; +}; + +/** The retention the Workbench asks of a task it creates: ten minutes after the render settles. */ +export const MCP_PAGE_TASK_TTL_MS = 10 * 60 * 1000; + +/** One task this session created or listed, folded from the invocation history. */ +export interface McpPageTask { + readonly createdBy?: string; + /** The JSON-RPC error `tasks/get` or `tasks/result` last answered with, when the task is gone or failed. */ + readonly error?: unknown; + readonly progress?: Readonly<{ readonly message?: string; readonly progress: number; readonly total?: number }>; + /** The final `CallToolResult` once `tasks/result` returned it. */ + readonly result?: unknown; + readonly task: Readonly> & { readonly status: string; readonly taskId: string }; + readonly toolName?: string; +} + +const TERMINAL_TASK_STATUSES: ReadonlySet = new Set(['cancelled', 'completed', 'failed']); + +export const isTerminalMcpTask = (task: McpPageTask): boolean => TERMINAL_TASK_STATUSES.has(task.task.status); + +const taskRecord = (value: unknown): McpPageTask['task'] | undefined => { + if (!isRecord(value) || typeof value.taskId !== 'string' || typeof value.status !== 'string') return undefined; + return value as McpPageTask['task']; +}; + +const taskProgress = (task: Readonly>): McpPageTask['progress'] => { + const meta = isRecord(task._meta) ? task._meta : undefined; + const progress = meta === undefined ? undefined : meta['agent-bundle/progress']; + if (!isRecord(progress) || typeof progress.progress !== 'number') return undefined; + return Object.freeze({ + progress: progress.progress, + ...(typeof progress.message === 'string' ? { message: progress.message } : {}), + ...(typeof progress.total === 'number' ? { total: progress.total } : {}), + }); +}; + +/** + * Folds the invocation history into the tasks this session knows about + * (#369): a `callToolTask` creates one, every later `getTask`, `cancelTask`, + * `listTasks`, and `getTaskResult` answer refreshes it. Derived, never + * stored, so the panel can never disagree with the protocol trace. + */ +export const mcpPageTasksFor = (history: readonly McpBrowserSessionInvocation[]): readonly McpPageTask[] => { + const tasks = new Map(); + const refresh = (task: McpPageTask['task'], extra: Partial = {}): void => { + const current = tasks.get(task.taskId); + const { _meta: _dropped, ...bare } = task; + tasks.set(task.taskId, Object.freeze({ + ...current, + ...extra, + progress: taskProgress(task) ?? current?.progress, + task: bare as McpPageTask['task'], + })); + }; + // Timeline order: later answers replace earlier ones. + for (const invocation of history) { + const request = isRecord(invocation.request) ? invocation.request : {}; + switch (invocation.operation) { + case 'callToolTask': { + const created = isRecord(invocation.result) ? taskRecord(invocation.result.task) : undefined; + if (created !== undefined) refresh(created, { createdBy: invocation.id, toolName: text(request.name) }); + break; + } + case 'getTask': + case 'cancelTask': { + const task = taskRecord(invocation.result); + if (task !== undefined) refresh(task); + else if (invocation.error !== undefined && typeof request.taskId === 'string') { + const current = tasks.get(request.taskId); + if (current !== undefined) tasks.set(request.taskId, Object.freeze({ ...current, error: invocation.error })); + } + break; + } + case 'listTasks': { + const listed = isRecord(invocation.result) && Array.isArray(invocation.result.tasks) ? invocation.result.tasks : []; + for (const entry of listed) { + const task = taskRecord(entry); + if (task !== undefined) refresh(task); + } + break; + } + case 'getTaskResult': { + if (typeof request.taskId !== 'string') break; + const current = tasks.get(request.taskId); + if (current === undefined) break; + tasks.set(request.taskId, Object.freeze({ + ...current, + ...(invocation.error === undefined ? { result: invocation.result } : { error: invocation.error }), + })); + break; + } + case 'callTool': + case 'cancel': + case 'close': + case 'getPrompt': + case 'initialize': + case 'listPrompts': + case 'listResources': + case 'listResourceTemplates': + case 'listTools': + case 'readResource': + case 'restart': + break; + default: { + const unreachable: never = invocation.operation; + throw new TypeError(`Unhandled MCP operation ${String(unreachable)}.`); + } + } + } + return Object.freeze([...tasks.values()]); +}; + const runtimeBindingFields = Object.freeze([ 'definitionDigest', 'registryRevision', @@ -603,10 +724,12 @@ export const mcpAppPreviewSourceFor = ( const catalogItems = (catalog: readonly unknown[], fallback: string): readonly CatalogItem[] => catalog.map((entry, index) => { const record = isRecord(entry) ? entry : {}; + const taskSupport = taskSupportOf(record); return { description: text(record.description), name: text(record.name) ?? `${fallback} ${index + 1}`, schema: record.inputSchema ?? record.argumentsSchema, + ...(taskSupport === undefined ? {} : { taskSupport }), uri: text(record.uri), uriTemplate: text(record.uriTemplate), }; @@ -1110,6 +1233,8 @@ export const McpPage = (props: McpPageProps) => { const [activeTimeoutMs, setActiveTimeoutMs] = useState(controller.session?.timeoutMs); const [toolName, setToolName] = useState(initialToolPrefill?.toolName ?? ''); const [toolArguments, setToolArguments] = useState(initialToolPrefill?.arguments ?? {}); + const [runAsTask, setRunAsTask] = useState(false); + const taskPolls = useRef(new Map>()); const [promptName, setPromptName] = useState(''); const [promptArguments, setPromptArguments] = useState({}); const [actionError, setActionError] = useState(); @@ -1249,6 +1374,39 @@ export const McpPage = (props: McpPageProps) => { const requestId = nextRequestId(); run(`invoke:${operation}`, () => controller.invoke({ id: requestId, operation, request })); }; + const tasks = mcpPageTasksFor(controller.history); + const sessionReady = model.phase === 'ready'; + // Every answered invocation re-evaluates the schedule: a poll that found the + // task still working has no other trace in the task itself. + const taskPollKey = `${String(controller.history.length)}|${tasks.map((entry) => `${entry.task.taskId}:${entry.task.status}:${entry.error === undefined ? '' : 'error'}`).join('|')}`; + // Each working task is polled through tasks/get at the interval the server + // suggested, as a task-aware host would, until it settles or answers an + // error; every poll is an ordinary invocation, so it shows in the history + // and the trace like any other protocol call. + useEffect(() => { + const polls = taskPolls.current; + for (const [taskId, timer] of polls) { + const entry = tasks.find((candidate) => candidate.task.taskId === taskId); + if (!sessionReady || entry === undefined || isTerminalMcpTask(entry) || entry.error !== undefined) { + clearTimeout(timer); + polls.delete(taskId); + } + } + if (!sessionReady) return; + for (const entry of tasks) { + if (isTerminalMcpTask(entry) || entry.error !== undefined || polls.has(entry.task.taskId)) continue; + const interval = typeof entry.task.pollInterval === 'number' && entry.task.pollInterval > 0 ? entry.task.pollInterval : 1000; + polls.set(entry.task.taskId, setTimeout(() => { + polls.delete(entry.task.taskId); + invoke('getTask', { taskId: entry.task.taskId }); + }, interval)); + } + // The poll key summarises every task field the schedule depends on. + }, [sessionReady, taskPollKey]); + useEffect(() => () => { + for (const timer of taskPolls.current.values()) clearTimeout(timer); + taskPolls.current.clear(); + }, []); const tools = catalogItems(model.catalogs.tools, 'Tool'); const prompts = catalogItems(model.catalogs.prompts, 'Prompt'); @@ -1463,16 +1621,33 @@ export const McpPage = (props: McpPageProps) => { }} type="button">{item.name} {item.description === undefined ? undefined :

{item.description}

} )}} - {selectedTool === undefined ? undefined : invoke('callTool', { arguments: argumentsValue, name: selectedTool.name })} - schema={selectedTool.schema} - submitLabel={`Call ${selectedTool.name}`} - value={toolArguments} - />} + {selectedTool === undefined ? undefined : <> + {selectedTool.taskSupport === 'optional' || selectedTool.taskSupport === 'required' ? : undefined} + (selectedTool.taskSupport === 'required' || (runAsTask && selectedTool.taskSupport === 'optional') + ? invoke('callToolTask', { arguments: argumentsValue, name: selectedTool.name, task: { ttl: MCP_PAGE_TASK_TTL_MS } }) + : invoke('callTool', { arguments: argumentsValue, name: selectedTool.name }))} + schema={selectedTool.schema} + submitLabel={selectedTool.taskSupport === 'required' || (runAsTask && selectedTool.taskSupport === 'optional') + ? `Run ${selectedTool.name} as task` + : `Call ${selectedTool.name}`} + value={toolArguments} + /> + }

Prompts

@@ -1507,7 +1682,37 @@ export const McpPage = (props: McpPageProps) => { + {model.connection?.serverCapabilities !== undefined && isRecord(model.connection.serverCapabilities) && isRecord(model.connection.serverCapabilities.tasks) + ? + : undefined} + {tasks.length === 0 ? undefined :
+

Tasks

+

Task-augmented calls this session created or listed. Working tasks are polled through tasks/get at the server’s suggested interval.

+
    {tasks.map((entry) => { + const terminal = isTerminalMcpTask(entry); + const progress = entry.progress; + return
  1. +
    + {entry.toolName ?? 'task'} + {entry.task.status} + {entry.task.taskId} +
    + {typeof entry.task.statusMessage === 'string' ?

    {entry.task.statusMessage}

    : undefined} + {progress === undefined ? undefined :

    + Progress {progress.total === undefined ? String(progress.progress) : `${String(progress.progress)} / ${String(progress.total)}`}{progress.message === undefined ? '' : ` · ${progress.message}`} +

    } +

    created {String(entry.task.createdAt)} · updated {String(entry.task.lastUpdatedAt)} · ttl {String(entry.task.ttl)} ms{typeof entry.task.pollInterval === 'number' ? ` · poll every ${String(entry.task.pollInterval)} ms` : ''}

    + {entry.error === undefined ? undefined :

    {display(entry.error)}

    } +
    + + + +
    + {entry.result === undefined ? undefined :
    {display(entry.result)}
    } +
  2. ; + })}
+
} {appPreviewClient === undefined ? undefined :

App preview

diff --git a/packages/workbench/src/mcp/mcp-route-client.ts b/packages/workbench/src/mcp/mcp-route-client.ts index c505bfc5f..5a1189a51 100644 --- a/packages/workbench/src/mcp/mcp-route-client.ts +++ b/packages/workbench/src/mcp/mcp-route-client.ts @@ -80,7 +80,16 @@ export type McpRouteOperation = | Readonly<{ readonly operation: 'initialize' | 'prompts/list' | 'resources/list' | 'resources/templates/list' | 'tools/list' }> | Readonly<{ readonly arguments?: Readonly>; readonly name: string; readonly operation: 'prompts/get' }> | Readonly<{ readonly operation: 'resources/read'; readonly uri: string }> - | Readonly<{ readonly arguments: Readonly>; readonly name: string; readonly operation: 'tools/call'; readonly requestId?: string }>; + | Readonly<{ + readonly arguments: Readonly>; + readonly name: string; + readonly operation: 'tools/call'; + readonly requestId?: string; + /** A task-augmented call (#369): the `params.task` the server receives; answered by a `CreateTaskResult`. */ + readonly task?: Readonly<{ readonly pollInterval?: number; readonly ttl?: number }>; + }> + | Readonly<{ readonly operation: 'tasks/cancel' | 'tasks/get' | 'tasks/result'; readonly taskId: string }> + | Readonly<{ readonly cursor?: string; readonly operation: 'tasks/list' }>; export interface ForegroundRouteClientOptions { readonly fetch?: typeof fetch; diff --git a/packages/workbench/src/mcp/mcp-session-controller.ts b/packages/workbench/src/mcp/mcp-session-controller.ts index 04d4d349a..f0c316f3b 100644 --- a/packages/workbench/src/mcp/mcp-session-controller.ts +++ b/packages/workbench/src/mcp/mcp-session-controller.ts @@ -1,4 +1,11 @@ -import { Client, type JSONRPCMessage, type Transport, type TransportSendOptions } from '@modelcontextprotocol/client'; +import { + Client, + specTypeSchemas, + type JSONRPCMessage, + type StandardSchemaV1, + type Transport, + type TransportSendOptions, +} from '@modelcontextprotocol/client'; import { isMcpSessionTarget } from '../../../agent-bundle/src/contracts/mcp-session.ts'; import type { @@ -98,9 +105,15 @@ export interface McpSessionControllerTransport extends Transport { export interface McpSessionControllerClient { close(): Promise; connect(transport: Transport): Promise; + /** + * Sends one request. `resultSchema` is required by the SDK for methods + * outside its typed spec surface — the `2025-11-25` task methods — and is + * the SDK's own schema for that result; spec methods pass none. + */ request( request: Readonly<{ readonly method: string; readonly params?: Readonly> }> , options?: Readonly<{ readonly signal?: AbortSignal }>, + resultSchema?: StandardSchemaV1, ): Promise; } @@ -400,7 +413,7 @@ const traceEntry = (value: unknown): McpSessionTraceEntry | McpSessionTraceRepla } if ( value.kind === 'operation' && typeof value.operation === 'string' && typeof value.phase === 'string' && - ['callTool', 'cancel', 'close', 'getPrompt', 'initialize', 'listPrompts', 'listResources', 'listResourceTemplates', 'listTools', 'readResource', 'restart'].includes(value.operation) && + ['callTool', 'callToolTask', 'cancel', 'cancelTask', 'close', 'getPrompt', 'getTask', 'getTaskResult', 'initialize', 'listPrompts', 'listResources', 'listResourceTemplates', 'listTasks', 'listTools', 'readResource', 'restart'].includes(value.operation) && ['started', 'succeeded', 'failed'].includes(value.phase) ) return { kind: 'operation', @@ -439,10 +452,17 @@ const activeRequest = (): ActiveRequest => { return { abort: new AbortController(), settle, settled }; }; +interface ControllerWireRequest { + readonly method: string; + readonly params?: Readonly>; + /** The SDK result schema a task method needs; spec methods carry none. */ + readonly resultSchema?: StandardSchemaV1; +} + const requestFor = ( operation: McpSessionControllerOperation, params: Readonly>, -): Readonly<{ readonly method: string; readonly params?: Readonly> }> => { +): ControllerWireRequest => { if (operation === 'initialize') return { method: 'initialize' }; if (operation === 'listTools') return { method: 'tools/list' }; if (operation === 'listResources') return { method: 'resources/list' }; @@ -451,6 +471,16 @@ const requestFor = ( if (operation === 'getPrompt') return { method: 'prompts/get', params }; if (operation === 'readResource') return { method: 'resources/read', params }; if (operation === 'callTool') return { method: 'tools/call', params }; + // The 2025-11-25 Tasks utility (#369): a task-augmented call carries + // `params.task`; the task operations are outside the SDK's typed method + // surface, so each names the SDK schema its result is validated against. + if (operation === 'callToolTask') { + return { method: 'tools/call', params: { ...params, task: isRecord(params.task) ? params.task : {} }, resultSchema: specTypeSchemas.CreateTaskResult }; + } + if (operation === 'getTask') return { method: 'tasks/get', params, resultSchema: specTypeSchemas.GetTaskResult }; + if (operation === 'getTaskResult') return { method: 'tasks/result', params, resultSchema: specTypeSchemas.CallToolResult }; + if (operation === 'cancelTask') return { method: 'tasks/cancel', params, resultSchema: specTypeSchemas.CancelTaskResult }; + if (operation === 'listTasks') return { method: 'tasks/list', params, resultSchema: specTypeSchemas.ListTasksResult }; throw new McpSessionControllerError(`MCP operation ${JSON.stringify(operation)} is not supported by the session controller.`); }; @@ -541,8 +571,16 @@ const invocationError = (reason: unknown): unknown => reason instanceof Error ? { message: reason.message, name: reason.name } : reason; -const defaultClient = (): McpSessionControllerClient => - new Client({ name: 'agent-bundle-workbench', version: '0.0.0' }) as unknown as McpSessionControllerClient; +const defaultClient = (): McpSessionControllerClient => { + const client = new Client({ name: 'agent-bundle-workbench', version: '0.0.0' }); + return { + close: () => client.close(), + connect: (transport) => client.connect(transport), + request: (request, options, resultSchema) => (resultSchema === undefined + ? client.request(request as never, options) + : client.request(request, resultSchema, options)), + }; +}; const defaultAppClient = (): Client => new Client({ name: 'agent-bundle-workbench', version: '0.0.0' }); @@ -1404,7 +1442,7 @@ export class McpSessionController { throw new McpSessionControllerError('MCP invocation requires a non-empty id and an object request.'); } if (this.#requests.has(input.id)) throw new McpSessionControllerError(`MCP invocation ${JSON.stringify(input.id)} is already active.`); - let operation: Readonly<{ readonly method: string; readonly params?: Readonly> }>; + let operation: ControllerWireRequest; try { operation = requestFor(input.operation, input.request); } catch (reason) { @@ -1427,7 +1465,8 @@ export class McpSessionController { type: 'request.start', }); try { - const result = await client.request(operation, { signal: active.abort.signal }); + const { resultSchema, ...wire } = operation; + const result = await client.request(wire, { signal: active.abort.signal }, resultSchema); if (!this.#closing) this.#publish({ completedAt: Date.now(), id: input.id, result, type: 'request.settled' }); return result; } catch (reason) { diff --git a/packages/workbench/tests/mcp-page.test.ts b/packages/workbench/tests/mcp-page.test.ts index 78bdb8ce7..834cdbca8 100644 --- a/packages/workbench/tests/mcp-page.test.ts +++ b/packages/workbench/tests/mcp-page.test.ts @@ -17,6 +17,7 @@ import { type McpSessionControllerTransport, } from '../src/mcp/mcp-session-controller.ts'; import { + MCP_PAGE_TASK_TTL_MS, McpPage, McpProtocolEvidence, createMcpPageActionTracker, @@ -24,8 +25,10 @@ import { mcpAppConsentDetailsSummary, downloadCurrentMcpProtocolTrace, mcpConfigDownload, + isTerminalMcpTask, mcpAppPreviewSourceFor, mcpPageControllerReplacementState, + mcpPageTasksFor, mcpPageSessionControls, supportedMcpAppPreviewProfiles, type McpPageArtifactProps, @@ -932,6 +935,91 @@ describe('MCP page', () => { })).toBeUndefined(); }); + describe('task-augmented tool calls (#369)', () => { + const at = (offset: number) => ({ completedAt: 1_700_000_000_010 + offset, durationMs: 5, startedAt: 1_700_000_000_005 + offset }); + const taskId = 'a3f0c2d1-0000-4000-8000-000000000369'; + const working = { createdAt: '2026-09-04T00:00:00.000Z', lastUpdatedAt: '2026-09-04T00:00:00.000Z', pollInterval: 250, status: 'working', taskId, ttl: 600_000 }; + const history: McpBrowserSessionInvocation[] = [ + { id: 'create', operation: 'callToolTask', request: { arguments: { holdMs: 400 }, name: 'wait', task: { ttl: 600_000 } }, result: { task: working }, timing: at(0) }, + { + id: 'poll-1', + operation: 'getTask', + request: { taskId }, + result: { ...working, _meta: { 'agent-bundle/progress': { message: 'waiting', progress: 1, total: 4 } }, lastUpdatedAt: '2026-09-04T00:00:00.100Z', statusMessage: 'waiting' }, + timing: at(100), + }, + { id: 'result', operation: 'getTaskResult', request: { taskId }, result: { content: [{ text: 'waited 400ms', type: 'text' }], structuredContent: { waitedMs: 400 } }, timing: at(400) }, + { id: 'poll-2', operation: 'getTask', request: { taskId }, result: { ...working, lastUpdatedAt: '2026-09-04T00:00:00.400Z', status: 'completed' }, timing: at(410) }, + { id: 'other', operation: 'callTool', request: { arguments: {}, name: 'echo' }, result: { content: [] }, timing: at(500) }, + { id: 'gone', operation: 'getTask', request: { taskId: 'expired' }, error: { code: -32_602, message: 'Task not found: expired' }, timing: at(600) }, + ]; + + it('folds the invocation history into the session\'s tasks, newest answers winning', () => { + const tasks = mcpPageTasksFor(history); + + expect(tasks).toEqual([{ + createdBy: 'create', + progress: { message: 'waiting', progress: 1, total: 4 }, + result: { content: [{ text: 'waited 400ms', type: 'text' }], structuredContent: { waitedMs: 400 } }, + task: { ...working, lastUpdatedAt: '2026-09-04T00:00:00.400Z', status: 'completed' }, + toolName: 'wait', + }]); + expect(isTerminalMcpTask(tasks[0]!)).toBe(true); + // Mid-flight: the latest tasks/get answer is the task, its progress meta lifted beside it. + const midway = mcpPageTasksFor(history.slice(0, 2)); + expect(midway[0]).toMatchObject({ progress: { progress: 1, total: 4 }, task: { status: 'working', statusMessage: 'waiting' } }); + expect(isTerminalMcpTask(midway[0]!)).toBe(false); + // A listed task the session did not create is still shown, without a tool name. + const listed = mcpPageTasksFor([{ id: 'list', operation: 'listTasks', request: {}, result: { tasks: [{ ...working, taskId: 'listed-1' }] }, timing: at(0) }]); + expect(listed).toEqual([{ progress: undefined, task: { ...working, taskId: 'listed-1' } }]); + // An error answer on a known task is kept; one on an unknown task adds nothing. + const failing = mcpPageTasksFor([history[0]!, { ...history[5]!, request: { taskId } }]); + expect(failing[0]).toMatchObject({ error: { code: -32_602 }, task: { status: 'working' } }); + expect(mcpPageTasksFor([history[5]!])).toEqual([]); + }); + + it('renders the task panel, the run-as-task toggle for opted-in tools, and the list control when the server declares tasks', () => { + const taskModel = { + ...model, + catalogs: { + ...model.catalogs, + tools: [ + { description: 'Waits.', execution: { taskSupport: 'optional' }, inputSchema: { properties: {}, type: 'object' }, name: 'wait' }, + { description: 'Task only.', execution: { taskSupport: 'required' }, name: 'background' }, + { description: 'Ordinary.', name: 'echo' }, + ], + }, + connection: { ...model.connection, serverCapabilities: { tasks: { cancel: {}, list: {}, requests: { tools: { call: {} } } }, tools: {} } }, + phase: 'ready', + } as unknown as McpBrowserSessionModel; + const markup = renderToStaticMarkup(createElement(McpPage, { + controller: { ...controller(), history: history.slice(0, 2), model: taskModel }, + epochOptions: ['epoch-1'], + targetOptions: ['codex'], + })); + + expect(markup).toContain('Run as task'); + expect(markup).toContain('>List tasks'); + expect(markup).toContain('aria-label="MCP tasks"'); + expect(markup).toContain(`data-task-id="${taskId}"`); + expect(markup).toContain('data-task-status="working"'); + expect(markup).toContain('Progress 1 / 4 · waiting'); + expect(markup).toContain(`Cancel ${taskId.slice(0, 8)}`); + expect(markup).toContain(`Fetch result ${taskId.slice(0, 8)}`); + expect(MCP_PAGE_TASK_TTL_MS).toBe(600_000); + + // Without a tasks capability the list control stays hidden; without task history the panel does not render. + const plain = renderToStaticMarkup(createElement(McpPage, { + controller: { ...controller(), model: { ...model, phase: 'ready' } }, + epochOptions: ['epoch-1'], + targetOptions: ['codex'], + })); + expect(plain).not.toContain('>List tasks'); + expect(plain).not.toContain('aria-label="MCP tasks"'); + expect(plain).not.toContain('Run as task'); + }); + }); + it('renders an explicit supported-profile picker and history preview entry point', () => { const markup = renderToStaticMarkup(createElement(McpPage, { appPreviewClient, diff --git a/packages/workbench/tests/mcp-session-controller.test.ts b/packages/workbench/tests/mcp-session-controller.test.ts index 9b51b8c02..9c4e95a42 100644 --- a/packages/workbench/tests/mcp-session-controller.test.ts +++ b/packages/workbench/tests/mcp-session-controller.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@rstest/core'; -import type { Client, Transport } from '@modelcontextprotocol/client'; +import { specTypeSchemas, type Client, type Transport } from '@modelcontextprotocol/client'; import type { McpAppBoundOperationResult } from '../../agent-bundle/src/dev/mcp-app-runtime-binding-service.ts'; import type { McpAppBindingOperation } from '../../agent-bundle/src/dev/mcp-app-runtime-preview-service.ts'; @@ -1776,6 +1776,67 @@ it('replays only the recorded epoch binding and carries replay provenance into i await controller.close(); }); +it('maps the task operations (#369) onto the 2025-11-25 wire with the SDK schema each result is validated against', async () => { + const stream = traceStream(); + const routes: McpSessionControllerRoutes = { + catalog: async () => ({ prompts: [], resourceTemplates: [], resources: [], tools: [] }), + config: async () => ({ launch: { args: [], command: 'node', env: {}, kind: 'stdio' }, origin: 'artifact' }), + restart: async () => connection, + stream: async () => stream.response, + trace: async () => ({ entries: [] }), + }; + const requests: { readonly request: unknown; readonly schema: unknown }[] = []; + const task = { createdAt: '2026-09-04T00:00:00.000Z', lastUpdatedAt: '2026-09-04T00:00:00.000Z', pollInterval: 250, status: 'working', taskId: 'task-1', ttl: 600_000 }; + const client: McpSessionControllerClient = { + close: async () => undefined, + connect: async (transport) => transport.start(), + request: async (request, _options, resultSchema) => { + requests.push({ request, schema: resultSchema }); + if (request.method === 'tools/call') return { task }; + if (request.method === 'tasks/list') return { tasks: [task] }; + if (request.method === 'tasks/result') return { content: [{ text: 'done', type: 'text' }] }; + return { ...task, ...(request.method === 'tasks/cancel' ? { status: 'cancelled' } : {}) }; + }, + }; + const controller = createMcpSessionController({ clientFactory: () => client, routes, transportFactory: () => fakeTransport() }); + await controller.open(binding); + + await expect(controller.invoke({ id: 'task-create', operation: 'callToolTask', request: { arguments: { holdMs: 400 }, name: 'wait', task: { ttl: 600_000 } } })) + .resolves.toEqual({ task }); + await expect(controller.invoke({ id: 'task-create-default', operation: 'callToolTask', request: { arguments: {}, name: 'wait' } })).resolves.toEqual({ task }); + await expect(controller.invoke({ id: 'task-get', operation: 'getTask', request: { taskId: 'task-1' } })).resolves.toEqual(task); + await expect(controller.invoke({ id: 'task-result', operation: 'getTaskResult', request: { taskId: 'task-1' } })).resolves.toEqual({ content: [{ text: 'done', type: 'text' }] }); + await expect(controller.invoke({ id: 'task-list', operation: 'listTasks', request: {} })).resolves.toEqual({ tasks: [task] }); + await expect(controller.invoke({ id: 'task-cancel', operation: 'cancelTask', request: { taskId: 'task-1' } })).resolves.toMatchObject({ status: 'cancelled' }); + + expect(requests.map((entry) => entry.request)).toEqual([ + { method: 'tools/call', params: { arguments: { holdMs: 400 }, name: 'wait', task: { ttl: 600_000 } } }, + // A task call always carries a `task` object: that is what makes it task-augmented. + { method: 'tools/call', params: { arguments: {}, name: 'wait', task: {} } }, + { method: 'tasks/get', params: { taskId: 'task-1' } }, + { method: 'tasks/result', params: { taskId: 'task-1' } }, + { method: 'tasks/list', params: {} }, + { method: 'tasks/cancel', params: { taskId: 'task-1' } }, + ]); + // Task methods are outside the SDK's typed surface, so each names its SDK result schema; an ordinary call passes none. + expect(requests.map((entry) => entry.schema)).toEqual([ + specTypeSchemas.CreateTaskResult, + specTypeSchemas.CreateTaskResult, + specTypeSchemas.GetTaskResult, + specTypeSchemas.CallToolResult, + specTypeSchemas.ListTasksResult, + specTypeSchemas.CancelTaskResult, + ]); + await controller.invoke({ id: 'plain', operation: 'callTool', request: { arguments: {}, name: 'echo' } }); + expect(requests.at(-1)).toEqual({ request: { method: 'tools/call', params: { arguments: {}, name: 'echo' } }, schema: undefined }); + expect(controller.history.map((entry) => entry.operation)).toEqual([ + 'callToolTask', 'callToolTask', 'getTask', 'getTaskResult', 'listTasks', 'cancelTask', 'callTool', + ]); + + stream.close(); + await controller.close(); +}); + it('surfaces unsupported client operations as one stable controller diagnostic rather than hanging', async () => { const stream = traceStream(); const routes: McpSessionControllerRoutes = { diff --git a/packages/workbench/tests/mcp-tasks.e2e.test.ts b/packages/workbench/tests/mcp-tasks.e2e.test.ts new file mode 100644 index 000000000..778e8b9d1 --- /dev/null +++ b/packages/workbench/tests/mcp-tasks.e2e.test.ts @@ -0,0 +1,117 @@ +import { writeFile } from 'node:fs/promises'; + +import { expect } from '@rstest/playwright'; + +import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench-assets.ts'; +import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; +import { copyExample, createExampleErrorLedger, waitForSettledWorkbench } from './support/example-acceptance.ts'; +import { buildWorkbench, e2e, workbenchAssets, workbenchUrl } from './support/workbench-e2e.ts'; + +const browserTimeout = 15_000 * timeScale; + +/** + * Browser acceptance for task-augmented tool calls (#369) on the desktop + * Workbench (1440×900): the MCP page runs the host-test example's `slow` probe + * as a task against the real generated stdio server, polls `tasks/get` at the + * server's interval until the task settles, fetches the final `CallToolResult` + * through `tasks/result`, and cancels a second task through `tasks/cancel`. + */ +e2e('runs, polls, collects, and cancels a task-augmented tool call in real Chrome', { timeout: 180_000 * timeScale }, async ({ page }) => { + await buildWorkbench(); + const project = await copyExample('host-test'); + const server = await startDevServer({ + assets: createWorkbenchAssetSource({ root: workbenchAssets }), + open: false, + port: 0, + root: project.root, + }); + const ledger = createExampleErrorLedger(page, server.url); + try { + await page.goto(workbenchUrl(server.url, 'mcp')); + await waitForSettledWorkbench(page); + await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); + await page.locator('#mcp-target').selectOption('portable'); + await page.locator('#mcp-server-name').fill('host-test'); + await page.locator('#mcp-session-timeout').fill(String(browserTimeout * 4)); + await page.getByRole('button', { name: 'Open MCP session' }).click(); + await expect(page.locator('.mcp-page-phase')).toContainText('Session ready', { timeout: browserTimeout * 4 }); + // The generated server negotiated the tasks capability with the browser client. + await expect(page.getByLabel('Negotiated connection')).toContainText('"tasks"', { timeout: browserTimeout }); + await expect(page.getByRole('button', { name: 'List tasks' })).toBeEnabled({ timeout: browserTimeout }); + + await page.getByRole('button', { name: 'List tools' }).click(); + await expect(page.getByRole('button', { name: 'slow', exact: true })).toBeVisible({ timeout: browserTimeout }); + await page.getByRole('button', { name: 'slow', exact: true }).click(); + // The tool advertised execution.taskSupport "optional": the toggle is offered, off by default. + const runAsTask = page.getByLabel(/^Run as task/u); + await expect(runAsTask).toBeVisible({ timeout: browserTimeout }); + await expect(runAsTask).not.toBeChecked(); + await expect(page.getByRole('button', { name: 'Call slow' })).toBeVisible({ timeout: browserTimeout }); + await runAsTask.check(); + await expect(page.getByRole('button', { name: 'Run slow as task' })).toBeVisible({ timeout: browserTimeout }); + // Arguments through the raw JSON editor: the same request shape a host sends. + const fillArguments = async (value: Readonly>): Promise => { + await page.locator('input[name="mcp-tool-arguments-mode"]').nth(1).check(); + await page.locator('#mcp-tool-arguments-raw').fill(JSON.stringify(value)); + }; + await fillArguments({ holdMs: 2000, tickMs: 500 }); + await page.getByRole('button', { name: 'Run slow as task' }).click(); + + // tools/call answered with a task: the panel shows it working before the render ends. + const tasks = page.getByLabel('MCP tasks'); + await expect(tasks).toBeVisible({ timeout: browserTimeout }); + const first = tasks.locator('li[data-task-id]').first(); + await expect(first).toHaveAttribute('data-task-status', 'working', { timeout: browserTimeout }); + await expect(first).toContainText('slow', { timeout: browserTimeout }); + const firstTaskId = await first.getAttribute('data-task-id'); + if (firstTaskId === null) throw new Error('Expected the task panel to name the created task.'); + // Polled through tasks/get until the render settled. + await expect(first).toHaveAttribute('data-task-status', 'completed', { timeout: browserTimeout * 2 }); + await expect(first).toContainText('Progress 4 / 4 · held 2000ms', { timeout: browserTimeout }); + await expect(first.getByRole('button', { name: `Cancel ${firstTaskId.slice(0, 8)}` })).toBeDisabled(); + await first.getByRole('button', { name: `Fetch result ${firstTaskId.slice(0, 8)}` }).click(); + await expect(first.locator('pre')).toContainText('Held the call for', { timeout: browserTimeout }); + await expect(first.locator('pre')).toContainText('"heldMs"', { timeout: browserTimeout }); + await expect(first.locator('pre')).toContainText(`"taskId": "${firstTaskId}"`, { timeout: browserTimeout }); + + // Every step was an ordinary invocation: creation, polls, and the result fetch are in the history. + const history = page.getByLabel('Invocation history'); + await expect(history).toContainText('callToolTask', { timeout: browserTimeout }); + await expect(history).toContainText('getTask', { timeout: browserTimeout }); + await expect(history).toContainText('getTaskResult', { timeout: browserTimeout }); + + // A second, long task is cancelled through tasks/cancel while it is working. + await fillArguments({ holdMs: 25_000, tickMs: 500 }); + await page.getByRole('button', { name: 'Run slow as task' }).click(); + const second = tasks.locator('li[data-task-id]').filter({ hasNot: page.locator(`[data-task-id="${firstTaskId}"]`) }).last(); + await expect(second).toHaveAttribute('data-task-status', 'working', { timeout: browserTimeout }); + const secondTaskId = await second.getAttribute('data-task-id'); + if (secondTaskId === null || secondTaskId === firstTaskId) throw new Error('Expected a second, distinct task.'); + await second.getByRole('button', { name: `Cancel ${secondTaskId.slice(0, 8)}` }).click(); + await expect(second).toHaveAttribute('data-task-status', 'cancelled', { timeout: browserTimeout }); + await expect(second).toContainText('The task was cancelled by request.', { timeout: browserTimeout }); + await expect(history).toContainText('cancelTask', { timeout: browserTimeout }); + + // tasks/list still retains both, in creation order. + await page.getByRole('button', { name: 'List tasks' }).click(); + await expect(history).toContainText('listTasks', { timeout: browserTimeout }); + await expect(tasks.locator('li[data-task-id]')).toHaveCount(2, { timeout: browserTimeout }); + if (process.env['AGENT_BUNDLE_EXAMPLE_SCREENSHOT_DIR'] !== undefined) { + await tasks.scrollIntoViewIfNeeded(); + await page.screenshot({ animations: 'disabled', path: `${process.env['AGENT_BUNDLE_EXAMPLE_SCREENSHOT_DIR']}/host-test-mcp-tasks.png` }); + } + + expect(ledger.pageErrors).toEqual([]); + expect(ledger.consoleErrors).toEqual([]); + } catch (error) { + if (process.env['AGENT_BUNDLE_E2E_FAILURE_SCREENSHOT'] !== undefined) { + await page.screenshot({ path: process.env['AGENT_BUNDLE_E2E_FAILURE_SCREENSHOT'] }).catch(() => undefined); + await writeFile(`${process.env['AGENT_BUNDLE_E2E_FAILURE_SCREENSHOT']}.html`, await page.content()).catch(() => undefined); + } + throw error; + } finally { + await server.close(); + await project.release(); + } +}); diff --git a/packages/workbench/tests/support/example-acceptance.ts b/packages/workbench/tests/support/example-acceptance.ts index b4a5e8c0d..d4e4df33b 100644 --- a/packages/workbench/tests/support/example-acceptance.ts +++ b/packages/workbench/tests/support/example-acceptance.ts @@ -8,7 +8,7 @@ import type { Page, Request } from 'playwright-core'; import { workspaceRoot } from './workbench-e2e.ts'; import { timeScale } from '../../../agent-bundle/tests/support/time-scale.ts'; -export type ExampleName = 'audiobook-curator' | 'hooks-and-scripts' | 'mcp-app' | 'skills-starter'; +export type ExampleName = 'audiobook-curator' | 'hooks-and-scripts' | 'host-test' | 'mcp-app' | 'skills-starter'; export interface ExampleCapture { readonly example: ExampleName; diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index f85eebc84..2ddfe4861 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -87,6 +87,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/workbench/tests/mcp-json-input.test.ts', 'packages/workbench/tests/mcp-page-app-browser.test.ts', 'packages/workbench/tests/mcp-session-timeout.e2e.test.ts', + 'packages/workbench/tests/mcp-tasks.e2e.test.ts', 'packages/workbench/tests/overview.e2e.test.ts', 'packages/workbench/tests/playground-real.e2e.test.ts', 'packages/workbench/tests/rsbuild-closure.test.ts', From 761d1c2775a80c3972d4ef3e430d2eb8e389d622 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:43:25 +0000 Subject: [PATCH 3/8] docs(mcp): document long-running tools as tasks, record per-host task-call evidence, lift the #394 deferral (#369) --- .changeset/369-mcp-tasks.md | 6 ++ docs/entry-conventions.md | 16 +++- docs/mcp-conformance.md | 82 ++++++++++++++++++- .../adapters/capabilities/claude-2.1.260.json | 11 ++- .../adapters/capabilities/codex-0.147.0.json | 10 ++- .../capabilities/cursor-2026-08-28.json | 10 ++- .../adapters/capabilities/portable-1.0.0.json | 9 +- packages/rsc-runtime/README.md | 17 ++-- website/docs/en/guide/authoring/mcp.mdx | 78 ++++++++++++++++++ website/docs/zh/guide/authoring/mcp.mdx | 68 +++++++++++++++ website/plugins/generated-reference.ts | 32 +++++++- 11 files changed, 321 insertions(+), 18 deletions(-) create mode 100644 .changeset/369-mcp-tasks.md diff --git a/.changeset/369-mcp-tasks.md b/.changeset/369-mcp-tasks.md new file mode 100644 index 000000000..6d4af3b85 --- /dev/null +++ b/.changeset/369-mcp-tasks.md @@ -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 (#538) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 736960b60..2662e2b98 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -948,10 +948,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//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: diff --git a/docs/mcp-conformance.md b/docs/mcp-conformance.md index 83d008c41..b2beae534 100644 --- a/docs/mcp-conformance.md +++ b/docs/mcp-conformance.md @@ -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 diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json index e61c15f38..e96c5d6c8 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json @@ -487,7 +487,16 @@ }, "mcp": { "stdio": true, - "streamableHttp": true + "streamableHttp": true, + "tasks": { + "state": "unavailable", + "reason": "Claude Code never task-augments a tools/call (MCP 2025-11-25 Tasks): a main-conversation call still running after two minutes moves to a client-side background task (`/tasks`, `CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS`) while the call itself stays an ordinary tools/call held open against the server. A generated server's task-capable tools serve it as ordinary requests.", + "evidence": [ + "retrieved 2026-09-04 from https://code.claude.com/docs/en/mcp: \"An MCP tool call in the main conversation that is still running after two minutes moves to a background task instead of blocking the session ... The per-call limits still apply while the call runs in the background\" (v2.1.212+); the page names no `tasks` capability, no `params.task`, and no `tasks/get`, `tasks/result`, or `tasks/cancel` request.", + "retrieved 2026-09-04 from https://code.claude.com/docs/en/mcp: the v2 runtime (SDK 2.0, v2.1.232+) asks stdio servers for protocol revision 2026-07-28 only when `MCP_PROTOCOL_NEGOTIATION=auto`; otherwise stdio servers negotiate through the earlier `initialize` handshake, where the generated server serves the 2025-11-25 core Tasks utility to a client that sends `params.task`.", + "live model 2026-09-03, Claude Code 2.1.257 (host-lineage audit): every recorded tools/call carried `_meta.claudecode/toolUseId` and a progressToken; none carried `params.task`." + ] + } }, "noticeDelivery": { "current-response": { diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 7a1c3f9a1..139d1ca4d 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -596,7 +596,15 @@ "state": "degraded" }, "stdio": true, - "streamableHttp": true + "streamableHttp": true, + "tasks": { + "state": "unavailable", + "reason": "Codex 0.147.0 does not task-augment a tools/call. Its opt-in MCP 2026-07-28 support (`mcp_2026_07_28`) documents paginated discovery, multi-round requests, and non-blocking server startup; on that revision tasks are the `io.modelcontextprotocol/tasks` extension (`resultType: \"task\"`, `tasks/update`), a shape the generated server's SDK does not serve. With the flag off it negotiates the legacy `initialize` handshake and sends no `params.task`.", + "evidence": [ + "Codex CLI 0.147.0 release (2026-08-07): MCP 2026-07-28 support is opt-in through the `mcp_2026_07_28` feature flag or `codex --enable mcp_2026_07_28`, adding paginated discovery, multi-round (`input_required`) requests, and non-blocking server startup; task-augmented tool calls are not among the documented additions.", + "2026-09-03: recorded tools/call requests carry `_meta.x-codex-turn-metadata` (thread_id, turn_id, session_id) and no `params.task`." + ] + } }, "noticeDelivery": { "current-response": { diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index a0c2751ff..aeda042fa 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -406,7 +406,15 @@ ] }, "stdio": true, - "streamableHttp": true + "streamableHttp": true, + "tasks": { + "state": "unavailable", + "reason": "Cursor does not task-augment a tools/call: its documented MCP protocol and extension support lists Tools, Prompts, Resources, Roots, Elicitation, and Apps, not Tasks, and recorded calls carry no `params.task`.", + "evidence": [ + "retrieved 2026-09-04 from https://cursor.com/docs/context/mcp, \"Protocol and extension support\": Tools, Prompts, Resources, Roots, Elicitation, Apps (extension) are the supported rows; Tasks is absent.", + "2026-09-03 (#424): tools/call `_meta` carries only progressToken from the cursor-vscode client; no `params.task`." + ] + } }, "noticeDelivery": { "current-response": { diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index bb801b763..0a5bc95d9 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -136,7 +136,14 @@ ] }, "stdio": true, - "streamableHttp": true + "streamableHttp": true, + "tasks": { + "state": "unavailable", + "reason": "The portable target pins no host client, so no host is recorded as issuing task-augmented calls. The generated server serves the 2025-11-25 Tasks utility to any client of that revision that sends `params.task` to a tool declaring `execution.taskSupport` — the MCP SDK client and the Workbench MCP page do.", + "evidence": [ + "2026-09-04: packages/agent-bundle/tests/projection/mcp-in-memory.test.ts and packages/agent-bundle/tests/packed-stdio-projection.test.ts drive a task-augmented tools/call through the generated server with the SDK client; packages/workbench/tests/mcp-tasks.e2e.test.ts drives it from the Workbench against the spawned stdio artifact." + ] + } }, "noticeDelivery": { "current-response": { diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 739b6ca5f..ec996282f 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -28,14 +28,15 @@ fallback or a typed `McpProjectionError`, never a silent drop. The existing APIs for the operations-model path. Task-augmented tool calls (`CreateTaskResult`, `tasks/get`, `tasks/result`, -`tasks/cancel`) are deferred, not partially implemented. The generated servers -never advertise a `tasks` capability, and a request that carries task -augmentation is processed as an ordinary `tools/call` — the fallback the -2025-11-25 Tasks utility requires of a receiver that declared no task support. -The deferral, its SDK pin, and the exact unblock condition are recorded in -[MCP conformance evidence](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/mcp-conformance.md#task-augmented-requests-deferred-2026-09-02) -and enforced by `tests/mcp-tasks-deferral.test.ts`, which fails the day the -installed SDK grows a task runtime. +`tasks/cancel`, `tasks/list` — the MCP 2025-11-25 Tasks utility) are served by +the generated route servers of `agent-bundle` for tool routes that declare +`config.execution.taskSupport`; the projector's `Agent.Progress` projection is +what feeds a task's `tasks/get` status as well as `notifications/progress`. +The operation-based `createRscMcpServer` in this package advertises no `tasks` +capability and processes a task-augmented request as an ordinary `tools/call` +— the fallback the utility requires of a receiver that declared no task +support. See +[MCP conformance evidence](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/mcp-conformance.md#task-augmented-requests-served-2026-09-04). ```tsx import { Mcp, lowerMcpResult } from '@agent-bundle/runtime'; diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 4a97ab62f..95e705e96 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -236,6 +236,84 @@ notifications; Codex bounds a call at `tool_timeout_sec`, 60 seconds unless the it in `config.toml`. `agent-bundle inspect --routes` shows the budget with the rest of each route's compiled `config`. +## Long-running tools: tasks + +The render budget bounds how long a render may run; it does not change that an ordinary +`tools/call` blocks the client until the render ends. The MCP `2025-11-25` Tasks utility is the +complementary mechanism: a client that asks for task-augmented execution gets a task handle at +once and collects the result later, so its own request deadline stops mattering for the render. A +tool route opts in with `config.execution.taskSupport`, the tool's `execution` block as +`tools/list` advertises it: + +```ts twoslash +import type { ToolConfig } from 'agent-bundle'; + +export const config = { + annotations: { readOnlyHint: true }, + description: 'Convert the selected audiobook.', + execution: { taskSupport: 'optional' }, +} satisfies ToolConfig; +``` + +`taskSupport` is `'forbidden'` (the wire default when the key is absent: every call is an +ordinary request), `'optional'` (a client may ask for a task; one that does not gets the ordinary +result), or `'required'` (an ordinary call is refused with JSON-RPC `-32601`); anything else, an +unknown key under `execution`, or the key on a resource or prompt route is `AB4836`. The route +module itself does not change: it renders exactly as for an ordinary call, and `signal`, +`progress.report()`, and streamed `Agent.Progress` fallbacks mean what they always did. + +When at least one tool opted in, the generated server declares +`capabilities.tasks: { list, cancel, requests: { tools: { call } } }` and serves the lifecycle on +a `2025-11-25` session: + +| Request | Answer | +| --- | --- | +| `tools/call` with `params.task` (`{ ttl?, pollInterval? }`) on an opted-in tool | `CreateTaskResult` at once: `task.taskId`, `status: "working"`, `createdAt`, `lastUpdatedAt`, the honoured `ttl` (the client's request, capped at 24 hours; five minutes when omitted) and `pollInterval` (at least 100 ms; 1 s when omitted), plus `_meta["io.modelcontextprotocol/model-immediate-response"]`, a sentence a host may hand its model while the task runs. The render starts under the task. | +| `tasks/get` | The task: `working` with `statusMessage` set to the latest progress message and `_meta["agent-bundle/progress"]` carrying `{ progress, total?, message? }` — the same `Agent.Progress` projection that feeds `notifications/progress`; `completed`; `failed` with the tool error's text as `statusMessage` (a result with `isError: true` is a failed task, as the spec requires); or `cancelled`. The last progress stays readable on a settled task. | +| `tasks/result` | Blocks until the task settles, then returns exactly what the ordinary call would have: the same `content`, `structuredContent`, and layout `_meta`, plus `_meta["io.modelcontextprotocol/related-task"]`; a render that ended in a JSON-RPC error returns that error. | +| `tasks/cancel` | Transitions the task to `cancelled` before answering and aborts the render through the same `AbortSignal` a cancelled request uses; the interrupted render settles as the SDK's tool error, which `tasks/result` then returns. Cancelling a settled task is `-32602`. | +| `tasks/list` | Every task the session still retains, oldest first, paged by an opaque `cursor`. | + +An unknown `taskId` is `-32602`; a task-augmented call to a tool that declared no support (or +none) is `-32601`. Progress notifications for a task still flow only when the request carried +`_meta.progressToken`, now stamped with the related-task key; the task observes progress either +way. A client that never sends `params.task` sees no change at all, and a server none of whose +tools opted in advertises no `tasks` capability and processes a task-augmented request as an +ordinary one, as the utility requires of a receiver without the capability. Task records belong +to the session that created them (the spawned stdio process, one client), stay for `ttl` after +the task settles, at most 256 per server, and every task still working is cancelled when the +session closes. + +The render budget applies to the task's render, not to the client's request: a task's +`tools/call` returns immediately whatever the budget, and the render behind it is still bounded +by the route's `config.render` (or the 60-second default). Raise the budget only when the render +itself needs longer, not to outlast a host's request deadline — that is what the task is for. The +`2026-07-28` protocol revision moves tasks out of the core into the `io.modelcontextprotocol/tasks` +extension with a different shape (`resultType: "task"`, `tasks/update`, no `tasks/result`); the +generated server's SDK release does not serve that extension, so a client on that revision keeps +the ordinary contract and no task capability is advertised to it. Whether a pinned host issues +task-augmented calls at all is recorded per host in the [host capability +matrix](../../reference/hosts.md); at the time of writing none does, so the lifecycle is proven with +the SDK client and the Workbench MCP page, where a tool that advertises task support offers +**Run as task** and the Tasks panel polls `tasks/get`, fetches `tasks/result`, and cancels. + +The `mcp-in-memory` level proves the whole contract with the real SDK client: + +```ts +import { specTypeSchemas } from '@modelcontextprotocol/client'; +import { openInMemoryMcpServer } from 'agent-bundle/test'; + +await using session = await openInMemoryMcpServer(); +const created = await session.client.request({ + method: 'tools/call', + params: { arguments: { holdMs: 2000 }, name: 'wait', task: { ttl: 60_000 } }, +}, specTypeSchemas.CreateTaskResult); +const result = await session.client.request({ + method: 'tasks/result', + params: { taskId: created.task.taskId }, +}, specTypeSchemas.CallToolResult); +``` + ## Shared layouts `src/layout.tsx` is the composition point around every rendered route — the `layout.tsx` idea from diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index ad8da6f2c..b911c3676 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -212,6 +212,74 @@ Claude Code 默认的单次调用挂钟约为 28 小时(`MCP_TOOL_TIMEOUT`, 在 `config.toml` 中提高,否则为 60 秒。`agent-bundle inspect --routes` 会随每条路由编译后的 `config` 一并显示该预算。 +## 长时运行的工具:任务 + +渲染预算约束的是一次渲染最多能跑多久;它并不改变普通 `tools/call` 会一直阻塞客户端直到渲染结束这一点。 +MCP `2025-11-25` 的 Tasks 工具是与之互补的机制:请求任务增强执行的客户端会立刻拿到一个任务句柄,稍后再 +取结果,于是它自身的请求期限不再影响渲染。工具路由通过 `config.execution.taskSupport` 选择加入,这正是 +`tools/list` 宣告的工具 `execution` 块: + +```ts twoslash +import type { ToolConfig } from 'agent-bundle'; + +export const config = { + annotations: { readOnlyHint: true }, + description: 'Convert the selected audiobook.', + execution: { taskSupport: 'optional' }, +} satisfies ToolConfig; +``` + +`taskSupport` 取 `'forbidden'`(省略该键时的线上默认值:每次调用都是普通请求)、`'optional'`(客户端 +可以请求任务;不请求的客户端得到普通结果)或 `'required'`(普通调用会被以 JSON-RPC `-32601` 拒绝); +其他任何值、`execution` 下的未知键,或把该键放在资源或提示路由上,都会报 `AB4836`。路由模块本身无需 +改动:它与普通调用完全一样地渲染,`signal`、`progress.report()` 与流式 `Agent.Progress` 回退的含义也 +一如从前。 + +只要至少有一个工具选择加入,生成的服务器就会声明 +`capabilities.tasks: { list, cancel, requests: { tools: { call } } }`,并在 `2025-11-25` 会话上提供整个 +生命周期: + +| 请求 | 应答 | +| --- | --- | +| 对已加入的工具发出带 `params.task`(`{ ttl?, pollInterval? }`)的 `tools/call` | 立即返回 `CreateTaskResult`:`task.taskId`、`status: "working"`、`createdAt`、`lastUpdatedAt`、实际采用的 `ttl`(客户端请求的值,上限 24 小时;省略时为五分钟)与 `pollInterval`(至少 100 毫秒;省略时为 1 秒),以及 `_meta["io.modelcontextprotocol/model-immediate-response"]`——宿主可在任务运行期间交给模型的一句话。渲染在任务之下开始。 | +| `tasks/get` | 任务本身:`working` 时 `statusMessage` 为最新的进度消息,`_meta["agent-bundle/progress"]` 携带 `{ progress, total?, message? }`——与馈送 `notifications/progress` 的同一份 `Agent.Progress` 投影;`completed`;`failed` 时以工具错误文本作为 `statusMessage`(按规范要求,`isError: true` 的结果即失败任务);或 `cancelled`。已结束的任务上仍可读到最后一次进度。 | +| `tasks/result` | 阻塞到任务结束,然后返回与普通调用完全相同的内容:同样的 `content`、`structuredContent` 与布局 `_meta`,另加 `_meta["io.modelcontextprotocol/related-task"]`;以 JSON-RPC 错误结束的渲染返回该错误。 | +| `tasks/cancel` | 在应答前把任务转为 `cancelled`,并通过与取消请求相同的 `AbortSignal` 中止渲染;被中断的渲染以 SDK 的工具错误结束,随后 `tasks/result` 返回的就是它。取消已结束的任务报 `-32602`。 | +| `tasks/list` | 会话仍保留的所有任务,按创建先后排列,以不透明的 `cursor` 分页。 | + +未知 `taskId` 报 `-32602`;对未声明支持(或声明为 forbidden)的工具发出任务增强调用报 `-32601`。任务的 +进度通知仍只在请求携带 `_meta.progressToken` 时发送,并加盖 related-task 键;无论是否携带令牌,任务都会 +记录进度。从不发送 `params.task` 的客户端看不到任何变化;没有任何工具加入的服务器不会宣告 `tasks` 能力, +并把任务增强请求当作普通请求处理——这正是该工具对不具备能力的接收方的要求。任务记录归创建它的会话(被 +拉起的 stdio 进程,一个客户端)所有,在任务结束后保留 `ttl` 时长,每台服务器最多 256 条,会话关闭时仍在 +运行的任务全部被取消。 + +渲染预算作用于任务的渲染,而不是客户端的请求:无论预算多大,任务的 `tools/call` 都会立即返回,其背后的 +渲染仍受路由 `config.render`(或 60 秒默认值)约束。只在渲染本身确实需要更久时才提高预算,不要为了熬过 +宿主的请求期限而提高——那正是任务的用途。`2026-07-28` 协议修订把任务从核心移入 +`io.modelcontextprotocol/tasks` 扩展,形态也不同(`resultType: "task"`、`tasks/update`、没有 +`tasks/result`);生成服务器所用的 SDK 版本不提供该扩展,因此该修订上的客户端保持普通契约,也不会被宣告 +任务能力。固定版本的宿主是否会发出任务增强调用,按宿主记录在[宿主能力矩阵](../../reference/hosts.md)中; +撰写本文时没有任何宿主这样做,因此这一生命周期由 SDK 客户端与 Workbench MCP 页面证明:宣告了任务支持的 +工具会提供**Run as task**,Tasks 面板会轮询 `tasks/get`、获取 `tasks/result` 并取消任务。 + +`mcp-in-memory` 层用真实的 SDK 客户端证明整套契约: + +```ts +import { specTypeSchemas } from '@modelcontextprotocol/client'; +import { openInMemoryMcpServer } from 'agent-bundle/test'; + +await using session = await openInMemoryMcpServer(); +const created = await session.client.request({ + method: 'tools/call', + params: { arguments: { holdMs: 2000 }, name: 'wait', task: { ttl: 60_000 } }, +}, specTypeSchemas.CreateTaskResult); +const result = await session.client.request({ + method: 'tasks/result', + params: { taskId: created.task.taskId }, +}, specTypeSchemas.CallToolResult); +``` + ## 共享布局 `src/layout.tsx` 是每个渲染式路由外层的组合点——页面框架中 `layout.tsx` 的思路应用到 Agent Document 上。 diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index 6fa5e8aaa..a2a88a04d 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -142,6 +142,9 @@ const messages = { installSurface: 'Install surface', pathTokens: 'Path tokens', mcpTransports: 'MCP transports and token fields', + mcpTasksIntro: + 'The `tasks` column records whether the pinned host client issues task-augmented `tools/call` requests (the MCP `2025-11-25` Tasks utility: `params.task`, `CreateTaskResult`, `tasks/get`, `tasks/result`, `tasks/cancel`, `tasks/list`). Generated servers serve that lifecycle for any tool route that declares `config.execution.taskSupport`; the column is about the host, not the server. `unavailable` means the host is documented or observed to send ordinary calls only, with the reason and evidence below.', + mcpTasksDetails: 'Task-augmented calls by host', lineage: 'Conversation lineage', lineageIntro: 'The `lineage` section of each table: what the host tells the warm runtime about the conversation tree behind `request.lineage`. `subagent-events` says whether the host emits subagent start/stop hooks at all, `root` whether every payload names the root conversation, `parent` and `depth` how a subagent is placed under its parent (`supported` only when the child\'s own payload names it, `degraded` when the runtime registry places it from spawn-call ordering and any later host confirmation), and `mcp-correlation` how a generated MCP tool call is matched to the hook window that produced it. A degraded row records what the registry does and how certain it is; a supported row records the evidence. The `resolution` field on `request.lineage` reports which path answered (`native`, `registry`, `confirmed`, `inferred`).', @@ -164,6 +167,7 @@ const messages = { token: 'Token', stdio: 'stdio', streamableHttp: 'Streamable HTTP', + tasks: 'Task-augmented calls', tokenFields: 'Fields accepting path tokens', canonicalEvent: 'Canonical event', payloadField: 'Payload field', @@ -243,6 +247,9 @@ const messages = { installSurface: '安装方式', pathTokens: '路径令牌', mcpTransports: 'MCP 传输与令牌字段', + mcpTasksIntro: + '`tasks` 列记录固定版本的宿主客户端是否会发出任务增强的 `tools/call` 请求(MCP `2025-11-25` Tasks 工具:`params.task`、`CreateTaskResult`、`tasks/get`、`tasks/result`、`tasks/cancel`、`tasks/list`)。生成的服务器会为任何声明了 `config.execution.taskSupport` 的工具路由提供这一生命周期;本列描述的是宿主,而不是服务器。`unavailable` 表示文档或观测表明该宿主只发送普通调用,原因与证据见下表。', + mcpTasksDetails: '各宿主的任务增强调用', lineage: '会话谱系', lineageIntro: '每张表的 `lineage` 部分:宿主向常驻运行时提供了哪些关于 `request.lineage` 背后会话树的信息。`subagent-events` 表示宿主是否发出子代理 start/stop 钩子,`root` 表示每个载荷是否都给出根会话,`parent` 与 `depth` 表示子代理如何被放到其父节点之下(只有当子代理自己的载荷给出父节点时才是 `supported`;由运行时注册表按 spawn 调用顺序放置、再由宿主事后确认时为 `degraded`),`mcp-correlation` 表示生成的 MCP 工具调用如何匹配到产生它的钩子窗口。degraded 行记录注册表的做法及其确定程度;supported 行记录证据。`request.lineage` 上的 `resolution` 字段报告是哪条路径给出了答案(`native`、`registry`、`confirmed`、`inferred`)。', @@ -265,6 +272,7 @@ const messages = { token: '令牌', stdio: 'stdio', streamableHttp: 'Streamable HTTP', + tasks: '任务增强调用', tokenFields: '接受路径令牌的字段', canonicalEvent: '规范事件', payloadField: '载荷字段', @@ -531,7 +539,7 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string sections.push(`## ${m.mcpTransports}\n`); sections.push( table( - [m.headers.host, m.headers.stdio, m.headers.streamableHttp, m.headers.tokenFields], + [m.headers.host, m.headers.stdio, m.headers.streamableHttp, m.headers.tasks, m.headers.tokenFields], hosts.map(host => { const mcp = asObject(host.data.mcp); const fields = Object.entries(mcpPathTokenFields(host.data)) @@ -541,11 +549,33 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string code(host.host), mcp.stdio === true ? 'supported' : m.unavailable, mcp.streamableHttp === true ? 'supported' : m.unavailable, + stateCell(capabilityRow(mcp.tasks), m), fields.length > 0 ? fields : mcpPathTokenLoweringNote(host.data) ?? m.notApplicable, ]; }), ), ); + sections.push(m.mcpTasksIntro); + sections.push(`### ${m.mcpTasksDetails}\n`); + sections.push( + table( + [m.headers.host, m.headers.state, m.headers.detail], + hosts.flatMap(host => { + const entry = capabilityRow(asObject(host.data.mcp).tasks); + if (entry === undefined) { + return []; + } + const details: string[] = []; + if (entry.reason !== undefined) { + details.push(escapeProse(entry.reason)); + } + if (Array.isArray(entry.evidence)) { + details.push(m.evidenceNotes(entry.evidence.length)); + } + return [[code(host.host), entry.state ?? m.unavailable, details.length > 0 ? details.join('
') : m.notApplicable]]; + }), + ), + ); sections.push(`## ${m.lineage}\n`); sections.push(m.lineageIntro); From 4eebd3c36c8dde0940f176b1b6507112f836c5e2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 08:09:17 +0000 Subject: [PATCH 4/8] build(agent-bundle): emit mcp-tasks as its own rslib entry so the bundled mcp-server-runtime chunk stays free of the rslib runtime import A generated artifact bundles dist/mcp-server-runtime.js; when rslib concatenated the task module into that chunk it added an `import { __webpack_require__ } from './rslib-runtime.js'` whose identifiers shadow the artifact bundler's own runtime, and the packed stdio entry failed at load (`__webpack_modules__[moduleId] is not a function`). Proven by the packed-stdio proof, which now also drives the task journey. --- packages/agent-bundle/rslib.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index d1cdedcf4..4b61a5a83 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -107,6 +107,12 @@ export default defineConfig({ 'mcp-entry': './src/mcp-entry.ts', meta: './src/meta.ts', 'mcp-server-runtime': './src/mcp-server-runtime.ts', + // Its own entry so it is emitted as a chunk beside the runtime rather + // than concatenated into it: a generated artifact bundles + // `dist/mcp-server-runtime.js`, and a chunk that also hosts a sibling + // module carries rslib's `__webpack_require__` runtime import, whose + // identifiers shadow the artifact bundler's own runtime. + 'mcp-tasks': './src/mcp-tasks.ts', // The route authoring surface: types plus the compile-time helpers a // route module may import at run time without pulling the compiler // into its generated bundle. From e869845bec6fb015a9cedbda4493ca81a10460fe Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 08:17:14 +0000 Subject: [PATCH 5/8] chore: reference #550 in the changeset --- .changeset/369-mcp-tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/369-mcp-tasks.md b/.changeset/369-mcp-tasks.md index 6d4af3b85..2268fcd5c 100644 --- a/.changeset/369-mcp-tasks.md +++ b/.changeset/369-mcp-tasks.md @@ -3,4 +3,4 @@ "@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 (#538) +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) From 597e03390cd010138ff160f456bf98ed99a36534 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 08:19:48 +0000 Subject: [PATCH 6/8] test(workbench): drop the failure-screenshot debugging hook from the task e2e --- packages/workbench/tests/mcp-tasks.e2e.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/workbench/tests/mcp-tasks.e2e.test.ts b/packages/workbench/tests/mcp-tasks.e2e.test.ts index 778e8b9d1..69cfd7466 100644 --- a/packages/workbench/tests/mcp-tasks.e2e.test.ts +++ b/packages/workbench/tests/mcp-tasks.e2e.test.ts @@ -1,5 +1,3 @@ -import { writeFile } from 'node:fs/promises'; - import { expect } from '@rstest/playwright'; import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench-assets.ts'; @@ -104,12 +102,6 @@ e2e('runs, polls, collects, and cancels a task-augmented tool call in real Chrom expect(ledger.pageErrors).toEqual([]); expect(ledger.consoleErrors).toEqual([]); - } catch (error) { - if (process.env['AGENT_BUNDLE_E2E_FAILURE_SCREENSHOT'] !== undefined) { - await page.screenshot({ path: process.env['AGENT_BUNDLE_E2E_FAILURE_SCREENSHOT'] }).catch(() => undefined); - await writeFile(`${process.env['AGENT_BUNDLE_E2E_FAILURE_SCREENSHOT']}.html`, await page.content()).catch(() => undefined); - } - throw error; } finally { await server.close(); await project.release(); From 7997f2ada37df6c8ca408d3343c08a1074be70f7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 08:54:21 +0000 Subject: [PATCH 7/8] fix(mcp): keep required tools callable on a session without the core Tasks utility; clear a task's stale error on a later successful answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of 3928a61: the required-tool rejection now applies only on a task-capable session (2025-11-25 with the capability declared) — elsewhere every call, required tools included, is the ordinary request and task metadata is ignored; the Workbench task fold drops a prior error when a later tasks/get, tasks/list, or tasks/result succeeds, so polling resumes. --- packages/agent-bundle/src/mcp-tasks.ts | 15 ++++++---- packages/agent-bundle/tests/mcp-tasks.test.ts | 28 +++++++++++++++++++ packages/workbench/src/mcp/mcp-page.tsx | 16 +++++++---- packages/workbench/tests/mcp-page.test.ts | 7 +++++ 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/packages/agent-bundle/src/mcp-tasks.ts b/packages/agent-bundle/src/mcp-tasks.ts index 48daa9855..69f1f4d40 100644 --- a/packages/agent-bundle/src/mcp-tasks.ts +++ b/packages/agent-bundle/src/mcp-tasks.ts @@ -303,11 +303,19 @@ export class TaskAugmentedServer extends Server { const wrapped = super._wrapHandler(method, handler); if (method !== 'tools/call') return wrapped; return async (request, ctx) => { + // The lifecycle exists only where it was declared: a session on the one + // revision whose core defines the utility, on a server that advertised + // the capability. Anywhere else — no tool opted in, or a revision whose + // wire strips `execution.taskSupport` and `capabilities.tasks` — every + // call is the ordinary request, `required` tools included, and any + // task metadata is ignored as the utility requires of a receiver + // without the capability. + if (!this.#installed || !this.#taskSession()) return wrapped(request, ctx); const params = request.params; const toolName = isRecord(params) && typeof params['name'] === 'string' ? params['name'] : undefined; // 2025-11-25 Tasks: a request is task-augmented when its params carry a // `task` object (the SDK's own guard accepts params without one). - const augmented = this.#taskSession() && isRecord(params) && isRecord(params['task']); + const augmented = isRecord(params) && isRecord(params['task']); const support = toolName === undefined ? 'forbidden' : this.taskSupport(toolName); if (!augmented) { if (support === 'required') { @@ -319,11 +327,6 @@ export class TaskAugmentedServer extends Server { } return wrapped(request, ctx); } - if (!this.#installed) { - // No tool opted in: the server declared no task capability, so the - // request is processed normally and its task metadata ignored. - return wrapped(request, ctx); - } if (support === 'forbidden') { // The capability is declared for tools/call, but not by this tool. throw new ProtocolError( diff --git a/packages/agent-bundle/tests/mcp-tasks.test.ts b/packages/agent-bundle/tests/mcp-tasks.test.ts index ece4e996d..a700a312e 100644 --- a/packages/agent-bundle/tests/mcp-tasks.test.ts +++ b/packages/agent-bundle/tests/mcp-tasks.test.ts @@ -325,6 +325,34 @@ describe('task-augmented tools/call (#369)', () => { } }); + it('serves every tool as an ordinary request on a revision without the core Tasks utility, required ones included', async () => { + // The SDK client negotiates 2025-11-25 by default; the server's own view + // of the session is what gates the lifecycle, so it is narrowed here to + // what a 2026-07-28 session reports (where the wire has no task vocabulary). + const { declareTool, install, server, tasks } = createTaskAugmentedMcpServer({ name: 'tasks-unit', version: '0.0.0' }); + const required = server.registerTool('background-only', { inputSchema: z.object({}) }, async () => ({ content: [{ text: 'ran', type: 'text' }] })); + declareTool(required, 'background-only', 'required'); + install(); + Object.defineProperty(tasks, 'getNegotiatedProtocolVersion', { configurable: true, value: () => '2026-07-28' }); + const client = new Client({ name: 'tasks-unit-client', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + // An ordinary call to the required tool is served, not refused. + expect(await client.callTool({ arguments: {}, name: 'background-only' })).toEqual({ content: [{ text: 'ran', type: 'text' }] }); + // Task metadata is ignored: the ordinary result comes back, no task handle. + const result = await client.request({ + method: 'tools/call', + params: { arguments: {}, name: 'background-only', task: { ttl: 1000 } }, + }, clientSchemas.CallToolResult); + expect(result).toEqual({ content: [{ text: 'ran', type: 'text' }] }); + expect(tasks.tasks()).toEqual([]); + } finally { + await client.close(); + await server.close(); + } + }); + it('keeps ordinary calls untouched: no task shape, no progress without a token, tool errors as before', async () => { const harness = await open(); try { diff --git a/packages/workbench/src/mcp/mcp-page.tsx b/packages/workbench/src/mcp/mcp-page.tsx index 0eb74bf30..da8a6e076 100644 --- a/packages/workbench/src/mcp/mcp-page.tsx +++ b/packages/workbench/src/mcp/mcp-page.tsx @@ -259,11 +259,18 @@ const taskProgress = (task: Readonly>): McpPageTask['pro */ export const mcpPageTasksFor = (history: readonly McpBrowserSessionInvocation[]): readonly McpPageTask[] => { const tasks = new Map(); + // A successful answer supersedes an earlier error (a tasks/result that + // timed out while the task was still working, say), so polling resumes. + const settled = (current: McpPageTask | undefined): Omit & { readonly task?: McpPageTask['task'] } => { + if (current === undefined) return {}; + const { error: _cleared, ...rest } = current; + return rest; + }; const refresh = (task: McpPageTask['task'], extra: Partial = {}): void => { const current = tasks.get(task.taskId); const { _meta: _dropped, ...bare } = task; tasks.set(task.taskId, Object.freeze({ - ...current, + ...settled(current), ...extra, progress: taskProgress(task) ?? current?.progress, task: bare as McpPageTask['task'], @@ -300,10 +307,9 @@ export const mcpPageTasksFor = (history: readonly McpBrowserSessionInvocation[]) if (typeof request.taskId !== 'string') break; const current = tasks.get(request.taskId); if (current === undefined) break; - tasks.set(request.taskId, Object.freeze({ - ...current, - ...(invocation.error === undefined ? { result: invocation.result } : { error: invocation.error }), - })); + tasks.set(request.taskId, Object.freeze(invocation.error === undefined + ? { ...settled(current), result: invocation.result, task: current.task } + : { ...current, error: invocation.error })); break; } case 'callTool': diff --git a/packages/workbench/tests/mcp-page.test.ts b/packages/workbench/tests/mcp-page.test.ts index 834cdbca8..722a87882 100644 --- a/packages/workbench/tests/mcp-page.test.ts +++ b/packages/workbench/tests/mcp-page.test.ts @@ -976,6 +976,13 @@ describe('MCP page', () => { const failing = mcpPageTasksFor([history[0]!, { ...history[5]!, request: { taskId } }]); expect(failing[0]).toMatchObject({ error: { code: -32_602 }, task: { status: 'working' } }); expect(mcpPageTasksFor([history[5]!])).toEqual([]); + // A later successful answer — a poll or the result — clears the error, so polling resumes. + const recovered = mcpPageTasksFor([history[0]!, { ...history[5]!, request: { taskId } }, history[1]!]); + expect(recovered[0]).not.toHaveProperty('error'); + expect(recovered[0]).toMatchObject({ task: { status: 'working', statusMessage: 'waiting' } }); + const resultAfterTimeout = mcpPageTasksFor([history[0]!, { ...history[2]!, error: { message: 'timed out' }, result: undefined }, history[2]!]); + expect(resultAfterTimeout[0]).not.toHaveProperty('error'); + expect(resultAfterTimeout[0]).toMatchObject({ result: { structuredContent: { waitedMs: 400 } } }); }); it('renders the task panel, the run-as-task toggle for opted-in tools, and the list control when the server declares tasks', () => { From 45d2e370058596e8bd9fb6dc518cc9d7fcc51542 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 18:47:31 +0000 Subject: [PATCH 8/8] docs(agent-bundle): record the task-augmented lifecycle proof in the README testing story (#369) --- packages/agent-bundle/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 86174fbba..e3918d1cb 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -836,6 +836,18 @@ validation. MCP Apps are not registered at this level: every app route reports `surface-completeness` as `not-applicable` and receives no coverage or sweep check, and app fixture entries are accepted and ignored. +**Task-augmented calls (`mcp-in-memory`, #369).** The session's `client` is +the SDK `Client`, so a tool route that declares +`config.execution.taskSupport` is proved through the same lifecycle a +task-aware host would drive: `client.request({ method: 'tools/call', params: +{ name, arguments, task: {} } }, CreateTaskResultSchema)` answers at once, +`tasks/get` reports `working` with the last render progress, `tasks/result` +blocks for the same `CallToolResult` an ordinary `callTool` produces (plus +`_meta["io.modelcontextprotocol/related-task"]`), and `tasks/cancel` +interrupts the render. The generated server declares the `tasks` capability +only when at least one tool opted in; `runContractMatrix` keeps calling every +tool as an ordinary request, which is the contract task-free clients rely on. + **`runPackedContractMatrix` (`packed-stdio` / `packed-deleted-source`)** runs against an already-open packed session (the single packed journey owns session open/close). It proves process stdio evidence for surface completeness