PoC: TS exactOptionalPropertyTypes support#1821
Conversation
|
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
@claude review |
felixweinberger
left a comment
There was a problem hiding this comment.
There's already #1766 which tries to keep the scope minimal - I'm not sure we want to refactor everything to support this linter rule as it's not a standard rule.
As discussed, but leaving here, few things worth clarifying: It's a compiler option, not a linter rule - #1766 seems to fix the symptom, as it patches a few properties, but downstream users with the flag enabled will still break on other types (fixed in this PR). Without the flag, any future PR that adds an optional property silently reintroduces the issue This is a draft PR to see how it'd look like if the flag was turned on for the repo. The changes are mechanical additions to type annotations. It would be a one-time cost if we wanted to close the door to this issue in the future. Edit: This could be added post-v2 if decided, but it does change the public API surface. |
|
Tagging @mattzcarey in case you have thoughts one whether there are reasons not to enable this rule on our repo? It seems commonly requested and actually enabling it for our repo seems the best way to handle things like #1855 which are more targeted but feel a bit like 'whack-a-mole' |
|
Keen for this. It's such a weird flag but seems like users are using it so we should try be compliant. |
| if (!ctx.task?.store) { | ||
| throw new Error('No task store provided.'); | ||
| } | ||
| const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } }; | ||
| const taskCtx: CreateTaskServerContext = { | ||
| ...ctx, | ||
| task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } as CreateTaskServerContext['task'] | ||
| }; | ||
| if (inputSchema) { | ||
| return taskHandler.createTask(args, taskCtx); | ||
| } |
There was a problem hiding this comment.
🔴 Three hand-written SDK files were not updated for exactOptionalPropertyTypes, leaving unsafe as-casts in mcp.ts:1133, client.ts:714, and server.ts:425, plus verbose conditional-spread workarounds in streamableHttp.ts:707–825, directly contradicting the PR's stated approach of fixing types rather than call sites. The fixes are straightforward: add | undefined to requestedTtl in CreateTaskServerContext and TaskServerContext (interfaces.ts), add | undefined to all nested optional fields of TaskRequestsCapability (helpers.ts), and add | undefined to all optional properties of MessageExtraInfo (types.ts).
Extended reasoning...
What the bug is. The PR enables exactOptionalPropertyTypes: true globally and updates nearly all optional properties to use prop?: T | undefined syntax. However, three hand-written SDK files were missed: (1) CreateTaskServerContext and TaskServerContext in packages/core/src/experimental/tasks/interfaces.ts still have requestedTtl?: number (no | undefined); (2) TaskRequestsCapability in an internal helpers file still has all nested optional properties without | undefined; (3) MessageExtraInfo in packages/core/src/types/types.ts still has authInfo?, closeSSEStream?, closeStandaloneSSEStream?, and requestInfo? without | undefined. In each case the author worked around the type error rather than fixing the source type, which is precisely what the PR description says it avoids.\n\nSpecific code paths triggered. For (1): in mcp.ts:1133, the expression ctx.task?.requestedTtl evaluates to number | undefined because TaskContext.requestedTtl was correctly updated to number | undefined in taskManager.ts and the optional-chaining ctx.task?. can itself return undefined. Under exactOptionalPropertyTypes, this value cannot be assigned to CreateTaskServerContext["task"].requestedTtl (which accepts number or absent, but not explicit undefined), forcing the as CreateTaskServerContext["task"] cast. For (2): this._serverCapabilities?.tasks?.requests is inferred by Zod v4 as { tools?: { call?: object | undefined } | undefined }, but TaskRequestsCapability lacks the | undefined additions, so client.ts:714 and server.ts:425 cast with as Parameters<typeof assertToolsCallTaskCapability>[0]. For (3): options?.authInfo evaluates to AuthInfo | undefined but MessageExtraInfo.authInfo only accepts AuthInfo (or absent), forcing at least three ...(x === undefined ? {} : { x }) conditional spreads in streamableHttp.ts.\n\nWhy the code does not catch it. The TypeScript compiler accepted the PR because the as casts and conditional spreads suppress the type errors at call sites. The compiler has no way to flag that the root cause—the missing | undefined annotations in the source types—went unaddressed. There is no lint rule or test that verifies source type definitions are fully updated.\n\nImpact. First, the PR description explicitly states "Fix types, not call sites" and "No conditional spread patterns were introduced," both of which are violated. Second, CreateTaskServerContext and TaskServerContext are exported public interfaces; downstream users building CreateTaskServerContext-typed objects with a potentially-undefined requestedTtl will get type errors under exactOptionalPropertyTypes, defeating the PR's purpose. Third, MessageExtraInfo is used in the Transport.onmessage callback signature, which third-party transport implementors must implement; they will face the same exactOptionalPropertyTypes violations when constructing MessageExtraInfo objects. Fourth, the as casts in client.ts and server.ts are wider than necessary (casting to Parameters<...>[0] rather than the specific type), making future refactors fragile.\n\nHow to fix it. (1) In interfaces.ts, change requestedTtl?: number to requestedTtl?: number | undefined in both CreateTaskServerContext.task and TaskServerContext.task. (2) In helpers.ts, add | undefined to all optional nested properties of TaskRequestsCapability, e.g. tools?: { call?: object | undefined } | undefined. (3) In types.ts, add | undefined to authInfo?, closeSSEStream?, closeStandaloneSSEStream?, and requestInfo? in MessageExtraInfo. After each fix, the corresponding as cast or conditional spread can be removed.\n\nStep-by-step proof for bug 003. Suppose ctx.task is { store: s, requestedTtl: undefined } (a TaskContext where requestedTtl was never set, so it is undefined). The expression ctx.task?.requestedTtl evaluates to undefined. Without the as cast, TypeScript under exactOptionalPropertyTypes would refuse to assign { store: s, requestedTtl: undefined } to CreateTaskServerContext["task"] because that type declares requestedTtl?: number (meaning the property may be absent or number, but never explicitly undefined). The cast suppresses this error. The analogous proof applies to bugs 004 and 005 by substituting the Zod-inferred | undefined types vs. the hand-written types.
| private _serverInfo: Implementation, | ||
| options?: ServerOptions | ||
| ) { | ||
| const tasks = extractTaskManagerOptions(options?.capabilities?.tasks); | ||
| super({ | ||
| ...options, | ||
| tasks: extractTaskManagerOptions(options?.capabilities?.tasks) | ||
| ...(tasks === undefined ? {} : { tasks }) | ||
| }); | ||
| this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; | ||
| this._instructions = options?.instructions; |
There was a problem hiding this comment.
🔴 The Server constructor uses an unnecessary spread pattern that changes semantics: when capabilities.tasks is not set, the old code explicitly passed tasks: undefined to Protocol (overriding any options.tasks from ProtocolOptions), while the new code leaves options.tasks intact, allowing a user-supplied task store to become active even though no task capability was advertised. The fix is to use the original tasks: extractTaskManagerOptions(...) pattern, now valid under exactOptionalPropertyTypes since this PR already adds | undefined to ProtocolOptions.tasks.
Extended reasoning...
What the bug is and how it manifests
The Server constructor was refactored from:
super({ ...options, tasks: extractTaskManagerOptions(options?.capabilities?.tasks) });
to:
const tasks = extractTaskManagerOptions(options?.capabilities?.tasks);
super({ ...options, ...(tasks === undefined ? {} : { tasks }) });
This conditional spread is the root cause. When capabilities.tasks is not set, extractTaskManagerOptions returns undefined, so the conditional spreads an empty object, leaving any options.tasks value passed directly via ProtocolOptions (which ServerOptions inherits) to flow through to Protocol unchanged.
The specific code path that triggers it
ServerOptions inherits from ProtocolOptions, which now has tasks?: TaskManagerOptions | undefined. A user who sets the tasks field directly on their ServerOptions without also setting capabilities.tasks will have their task store silently activated. The old code always explicitly overrode this field, ensuring Protocol only ever received a TaskManager derived from the advertised capabilities.
Why existing code does not prevent it
While assertTaskHandlerCapability guards some specific task-operation entry points by checking capabilities.tasks.requests, it does not prevent TaskManager initialization itself. When options.tasks flows through to Protocol, a live TaskManager with a real taskStore is constructed and binds task-related request handlers (tasks/list, tasks/get, etc.). This creates a split: the capability advertisement says no task support, but internal handler registration says otherwise. The refutation argues assertTaskHandlerCapability prevents exploitation, but having an initialized TaskManager in an inconsistent state is itself a semantic regression, regardless of whether every code path is individually guarded.
Why the conditional spread was not needed
The PR description explicitly states: "No conditional spread patterns were introduced". This commit directly contradicts that claim. Since the PR also adds | undefined to ProtocolOptions.tasks in protocol.ts, writing tasks: extractTaskManagerOptions(...) (which can return undefined) is now valid under exactOptionalPropertyTypes. No workaround was required. The Client class does not use a conditional spread for its task initialization, demonstrating that the simpler pattern is sufficient.
Step-by-step proof
- User constructs: new Server(info, { tasks: { taskStore: myStore } }) — no capabilities.tasks set.
- extractTaskManagerOptions(options?.capabilities?.tasks) -> extractTaskManagerOptions(undefined) -> returns undefined.
- OLD code: super({ ...options, tasks: undefined }) — options.tasks is overridden with undefined. Protocol receives tasks: undefined -> NullTaskManager used. Correct behavior.
- NEW code: ...(undefined === undefined ? {} : { tasks }) spreads {} — options.tasks = { taskStore: myStore } passes through unchanged. Protocol receives tasks: { taskStore: myStore } -> new TaskManager({ taskStore: myStore }) is constructed and binds handlers. Incorrect behavior.
- The server now has an active task store with no advertised task capability, a semantic inconsistency introduced for no type-safety benefit.
How to fix it
Replace the conditional spread with the original pattern:
super({ ...options, tasks: extractTaskManagerOptions(options?.capabilities?.tasks) });
This compiles correctly under exactOptionalPropertyTypes because ProtocolOptions.tasks is now TaskManagerOptions | undefined.
Enable
exactOptionalPropertyTypesgloballyFixes #1397. Supersedes #1439.
Motivation and Context
Users with
exactOptionalPropertyTypes: truein their projects get type errors when using the SDK. For example:A partial fix was proposed in #1439 (manually adding
| undefinedto 6 properties). However, without compiler enforcement, there is no way to avoid regression.This PR enables
exactOptionalPropertyTypes: truein the shared tsconfig so CI catches violations automatically. All optional properties in public interfaces now include| undefined, making the SDK compatible with strict downstream configs.How Has This Been Tested?
pnpm typecheck:all-- 0 errorspnpm lint:all-- passespnpm test:all-- 1,360 tests passed, 0 failuresBreaking Changes
No breaking changes for downstream consumers. This is purely additive -- existing code that doesn't pass
undefinedcontinues to work. Code that explicitly passesundefinedfor optional properties (which previously failed underexactOptionalPropertyTypes) now works.Contributors adding new optional properties must use
prop?: Type | undefinedinstead ofprop?: Type. The compiler enforces this.Types of changes
Checklist
Additional context
Approach
Fix types, not call sites. All 195 original errors were resolved by adding
| undefinedto type definitions. No conditional spread patterns (...(x !== undefined ? { x } : {})) were introduced -- object construction likereturn { sessionId, nextCursor }continues to worknaturally.
spec.types.test.tsutility typeThe bidirectional spec/SDK type compatibility tests needed a
DeepAddUndefinedToOptionals<T>utility type. Zod v4 infersfield?: T | undefinedfor.optional()while the upstream spec type generator emitsfield?: T. UnderexactOptionalPropertyTypesthese are incompatible,but the distinction is meaningless for JSON-deserialized data. The utility bridges this gap without requiring upstream changes.
8 specific test lines needed
@ts-expect-errorfor a separate structural mismatch: the SDK'sRequestMetaSchemaadds"io.modelcontextprotocol/related-task"as a named property not present in the upstream spec types. This is pre-existing and unrelated to| undefined.Third-party type exceptions
~5 call sites needed
as RequestInitcasts where spreading the DOM/NodeRequestInittype (which we don't control) produces values incompatible under the flag.Scope
spec.types.test.tsutility type@ts-expect-error(pre-existing _meta mismatch)