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
What happened?
On a
2026-07-28connection, after the client declaresio.modelcontextprotocol/tasksin its per-request capabilities and the server advertises the same extension, the SDK has no end-to-end path for theCreateTaskResultthat the extension permits in place of aCallToolResult:McpServer.registerTool()types the callback asCallToolResult | InputRequiredResult, so returningCreateTaskResultrequires a cast.resultType: "task", the server preserves it on the wire, butClient.callTool()rejects it withSdkErrorCode.UnsupportedResultType:Unsupported result type 'task' for tools/call.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
Client.callTool()resultType: "task"resultType: "task", pluscontent: []UNSUPPORTED_RESULT_TYPEresultTyperesultType: "complete", pluscontent: []{ content: [] }controlresultType: "complete",content: []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/nodeto2.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-typeThe observed output for the conforming case is:
For the missing-discriminator case:
Why this appears inconsistent
The Tasks extension says that, once the extension is negotiated, a server may return
CreateTaskResultinstead ofCallToolResult, 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 setresultTypeto"task"when returning aCreateTaskResult: 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 fortools/call:typescript-sdk/packages/core-internal/src/wire/rev2026-07-28/encodeContract.ts
Lines 45 to 85 in cc4b416
The client codec recognizes
input_required, but treats every other non-completediscriminator, includingtask, as unsupported:typescript-sdk/packages/core-internal/src/wire/rev2026-07-28/codec.ts
Lines 177 to 234 in cc4b416
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
Expected
After both peers declare
io.modelcontextprotocol/tasks, there should be a typed server path for returningCreateTaskResultand 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()andToolCallbackto 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.jsv24.18.0.Area
Server and Client