Skip to content

[v2] Tasks extension: tools/call rejects CreateTaskResult but accepts an omitted discriminator as complete #2637

Description

@AndresSaa

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 as CallToolResult | InputRequiredResult, so returning CreateTaskResult requires a cast.
  • If the handler returns an explicit resultType: "task", the server preserves it on the wire, but Client.callTool() rejects it with SdkErrorCode.UnsupportedResultType: Unsupported result type 'task' for tools/call.
  • If a dynamically typed handler returns the same task fields but accidentally omits resultType, the server stamps resultType: "complete", adds content: [], and Client.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:

https://github.com/AndresSaa/mcp-durable-tasks/blob/faf4943ddc8fda43cb7b559a6505d0cee9b857f0/examples/conformance-reproductions/result-type.mts

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-type

The 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:

/**
* 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:

// 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:

/** 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions