Repository navigation
[v2] Tasks extension: tools/call rejects CreateTaskResult but accepts an omitted discriminator as complete #2637
Description
What happened?
On a 2026-07-28 connection, after the client declares io.modelcontextprotocol/tasks in its per-request capabilities and the server advertises the same extension, the SDK has no end-to-end path for the CreateTaskResult that the extension permits in place of a CallToolResult:
McpServer.registerTool()types the callback asCallToolResult | InputRequiredResult, so returningCreateTaskResultrequires a cast.- If the handler returns an explicit
resultType: "task", the server preserves it on the wire, butClient.callTool()rejects it withSdkErrorCode.UnsupportedResultType:Unsupported result type 'task' for tools/call. - If a dynamically typed handler returns the same task fields but accidentally omits
resultType, the server stampsresultType: "complete", addscontent: [], andClient.callTool()resolves successfully. The task fields survive only as unknown extra keys, so application code sees an empty final tool result instead of a task handle or an error.
The last case is not a conforming handler result; it is included because the current encode path converts that server error into a valid-looking success rather than surfacing it.
Measured behavior
| Handler return | Wire result | Client.callTool() |
|---|---|---|
Task handle with resultType: "task" |
resultType: "task", plus content: [] |
Rejects with UNSUPPORTED_RESULT_TYPE |
Same task fields without resultType |
resultType: "complete", plus content: [] |
Resolves as an empty result |
Ordinary { content: [] } control |
resultType: "complete", content: [] |
Resolves normally |
Every request in the reproduction declares extensions: { "io.modelcontextprotocol/tasks": {} }; the server advertises the same capability. This matters because the extension forbids returning a task handle to a client that did not declare support.
Code to reproduce
The standalone script pins @modelcontextprotocol/server, /client, and /node to 2.0.0, starts a real HTTP server and SDK client, records the exact wire result, and asserts all three outcomes:
git clone https://github.com/AndresSaa/mcp-durable-tasks.git
cd mcp-durable-tasks
git checkout faf4943ddc8fda43cb7b559a6505d0cee9b857f0
corepack pnpm install --frozen-lockfile
corepack pnpm --dir examples/conformance-reproductions run result-typeThe observed output for the conforming case is:
wireResultType: task
client: rejected
clientCode: UNSUPPORTED_RESULT_TYPE
clientMessage: Unsupported result type 'task' for tools/call
For the missing-discriminator case:
wireResultType: complete
wireContent: []
client: resolved
clientKeys: [_meta, content, createdAt, lastUpdatedAt, status, taskId, ttlMs]
Why this appears inconsistent
The Tasks extension says that, once the extension is negotiated, a server may return CreateTaskResult instead of CallToolResult, and the client must be prepared to handle either: https://github.com/modelcontextprotocol/ext-tasks/blob/2c1425d9a288b9b1f489430fe1e00bb392b47e48/specification/draft/tasks.md#L59-L61. It also requires the task discriminator to be explicit — servers MUST set resultType to "task" when returning a CreateTaskResult: https://github.com/modelcontextprotocol/ext-tasks/blob/2c1425d9a288b9b1f489430fe1e00bb392b47e48/specification/draft/tasks.md#L95-L102.
On current SDK main, the server-side encode contract stamps every absent discriminator as "complete" while allowing an explicit non-complete value through for tools/call:
typescript-sdk/packages/core-internal/src/wire/rev2026-07-28/encodeContract.ts
Lines 45 to 85 in cc4b416
| /** | |
| * Request methods whose spec result vocabulary goes beyond `'complete'` on the | |
| * 2026-07-28 revision: their results may be `input_required` (multi | |
| * round-trip requests), so a handler-provided `resultType` passes through the | |
| * stamp untouched. `subscriptions/listen` is NOT in this set: it never emits | |
| * a JSON-RPC result — termination is stream close (HTTP) or | |
| * `notifications/cancelled` (stdio) per the spec. | |
| */ | |
| export const EXTENDED_RESULT_TYPE_METHODS: readonly string[] = ['tools/call', 'prompts/get', 'resources/read']; | |
| /** | |
| * Step 1 of the encode contract: ensure the outbound result carries the | |
| * required `resultType` discriminator. | |
| * | |
| * - No handler-provided value → stamp `'complete'`. | |
| * - Handler-provided `'complete'` → kept as-is. | |
| * - Handler-provided non-`'complete'` value on a method whose vocabulary | |
| * allows it ({@linkcode EXTENDED_RESULT_TYPE_METHODS}) → passes through. | |
| * The value is forwarded verbatim — the wire vocabulary is an open union and | |
| * the SDK does not validate the string, so emitting a `resultType` the | |
| * negotiated revision does not define is the handler author's | |
| * responsibility. | |
| * - Handler-provided non-`'complete'` value on any other method → internal | |
| * error (loud): the value would be mis-typed on the wire, and silently | |
| * rewriting it would hide a server bug. | |
| */ | |
| export function stampResultType(method: string, result: Result): Result { | |
| const provided = (result as Record<string, unknown>)['resultType']; | |
| if (provided === undefined) { | |
| return { ...result, resultType: 'complete' } as Result; | |
| } | |
| if (provided === 'complete') { | |
| return result; | |
| } | |
| if (EXTENDED_RESULT_TYPE_METHODS.includes(method)) { | |
| return result; | |
| } | |
| throw new ProtocolError( | |
| ProtocolErrorCode.InternalError, | |
| `Handler for ${method} returned resultType '${String(provided)}', but results of ${method} only support 'complete' on protocol revision 2026-07-28` | |
| ); |
The client codec recognizes input_required, but treats every other non-complete discriminator, including task, as unsupported:
typescript-sdk/packages/core-internal/src/wire/rev2026-07-28/codec.ts
Lines 177 to 234 in cc4b416
| // Step 1 — RAW discrimination, before any schema (V-1). | |
| const rawResultType = raw['resultType']; | |
| if (rawResultType === undefined) { | |
| // Q1-SD3 (i): hard error naming the violation. | |
| return { | |
| kind: 'invalid', | |
| error: new SdkError( | |
| SdkErrorCode.InvalidResult, | |
| `Invalid result for ${method}: missing required resultType — servers implementing protocol revision 2026-07-28 ` + | |
| `MUST include it (the absent-means-complete bridge applies only to earlier-revision servers)`, | |
| { method, violation: 'missing-resultType' } | |
| ) | |
| }; | |
| } | |
| if (typeof rawResultType !== 'string') { | |
| return { | |
| kind: 'invalid', | |
| error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: non-string resultType`, { | |
| method, | |
| resultType: rawResultType | |
| }) | |
| }; | |
| } | |
| if (rawResultType === 'input_required') { | |
| // The driver seam (#13 consumes this payload). | |
| const rawInputRequests = raw['inputRequests']; | |
| const inputRequests = isPlainObject(rawInputRequests) ? rawInputRequests : {}; | |
| const requestState = raw['requestState']; | |
| if (Object.keys(inputRequests).length === 0 && typeof requestState !== 'string') { | |
| // At-least-one rule, client side: with neither inputRequests | |
| // nor requestState there is nothing to fulfil and nothing to | |
| // echo — retrying would only resend the original params until | |
| // the round cap is exhausted, so fail fast instead. | |
| return { | |
| kind: 'invalid', | |
| error: new SdkError( | |
| SdkErrorCode.InvalidResult, | |
| `Invalid result for ${method}: input_required carries neither inputRequests nor requestState ` + | |
| `(every input_required result must include at least one of the two)`, | |
| { method, violation: 'input-required-missing-both' } | |
| ) | |
| }; | |
| } | |
| return { | |
| kind: 'input_required', | |
| inputRequests, | |
| ...(typeof requestState === 'string' && { requestState }) | |
| }; | |
| } | |
| if (rawResultType !== 'complete') { | |
| // Unrecognized kind ⇒ invalid, no retry (DQ5). | |
| return { | |
| kind: 'invalid', | |
| error: new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type '${rawResultType}' for ${method}`, { | |
| resultType: rawResultType, | |
| method | |
| }) | |
| }; |
The public tool callback types likewise include CallToolResult | InputRequiredResult, but not an extension task result:
typescript-sdk/packages/server/src/server/mcp.ts
Lines 1218 to 1252 in cc4b416
| /** Infers the parsed-output type of a {@linkcode ZodRawShape}. */ | |
| export type InferRawShape<S extends ZodRawShape> = z.infer<z.ZodObject<S>>; | |
| /** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ | |
| export type LegacyToolCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape | |
| ? ( | |
| args: InferRawShape<Args>, | |
| ctx: ServerContext | |
| ) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult> | |
| : (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult>; | |
| /** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ | |
| export type LegacyPromptCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape | |
| ? ( | |
| args: InferRawShape<Args>, | |
| ctx: ServerContext | |
| ) => GetPromptResult | InputRequiredResult | Promise<GetPromptResult | InputRequiredResult> | |
| : (ctx: ServerContext) => GetPromptResult | InputRequiredResult | Promise<GetPromptResult | InputRequiredResult>; | |
| export type BaseToolCallback< | |
| SendResultT extends Result, | |
| Ctx extends ServerContext, | |
| Args extends StandardSchemaWithJSON | undefined | |
| > = Args extends StandardSchemaWithJSON | |
| ? (args: StandardSchemaWithJSON.InferOutput<Args>, ctx: Ctx) => SendResultT | Promise<SendResultT> | |
| : (ctx: Ctx) => SendResultT | Promise<SendResultT>; | |
| /** | |
| * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. | |
| */ | |
| export type ToolCallback<Args extends StandardSchemaWithJSON | undefined = undefined> = BaseToolCallback< | |
| CallToolResult | InputRequiredResult, | |
| ServerContext, | |
| Args | |
| >; |
Expected
After both peers declare io.modelcontextprotocol/tasks, there should be a typed server path for returning CreateTaskResult and a client path that exposes that result instead of rejecting it. A handler that intends to return a task should not be silently projected to a successful final result when its required discriminator is missing.
This looks like a focused acceptance gap under #2189. The API-shape question is whether extension support should widen callTool() and ToolCallback to discriminated unions directly, or install an extension-specific result handler through the planned extension framework. Either shape would resolve the measured wire/client mismatch; I do not want to assume which one fits the v2 design.
SDK version
@modelcontextprotocol/server@2.0.0, @modelcontextprotocol/client@2.0.0, @modelcontextprotocol/node@2.0.0, Node.js v24.18.0.
Area
Server and Client
Activity
felixweinberger commentedon Sep 28, 2026
This is part of the Tasks extension, which is tracked in #2189 (work in progress in #2782). Closing as a duplicate.