Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/codemod-error-code-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/codemod': minor
---

Add an `error-code-semantics` transform that fixes `instanceof` guards around the
`ErrorCode.RequestTimeout` / `ErrorCode.ConnectionClosed` members, which moved from the
protocol error enum to `SdkErrorCode` (raised on `SdkError`) in v2. Without this, migrated
checks like `e instanceof ProtocolError && e.code === SdkErrorCode.RequestTimeout` compile
but never match at runtime. The transform also flags switches and maps that mix the two
enums, since `SdkErrorCode` is a string enum.
6 changes: 6 additions & 0 deletions .changeset/codemod-preserve-file-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/codemod': patch
---

Preserve file-leading shebangs and banner comments when import rewrites remove the first
import declaration in a file. Previously the attached leading trivia was silently deleted.
7 changes: 7 additions & 0 deletions .changeset/codemod-sdkhttperror-rewrite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/codemod': patch
---

Rewrite `StreamableHTTPError` to `SdkHttpError` (the documented v2 replacement with typed
`.status`/`.statusText`) instead of `SdkError`, and correct the migration guidance the
codemod emits for it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import type { BinaryExpression, SourceFile } from 'ts-morph';
import { Node, SyntaxKind } from 'ts-morph';

import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js';
import { error, warning } from '../../../utils/diagnostics.js';
import { addOrMergeImport, isImportedFromMcp, removeUnusedImport } from '../../../utils/importUtils.js';
import { ERROR_CODE_SDK_MEMBERS } from '../mappings/symbolMap.js';

/**
* In v1, `ErrorCode.RequestTimeout` and `ErrorCode.ConnectionClosed` were numeric
* members of the protocol error enum and were raised on `McpError`. In v2 they moved
* to `SdkErrorCode` — a STRING enum — and are raised on `SdkError`, while protocol
* errors became `ProtocolError` with `ProtocolErrorCode`.
*
* The symbol rename transform updates the enum references, but a check like
*
* e instanceof ProtocolError && e.code === SdkErrorCode.RequestTimeout
*
* still compiles under loose typing and NEVER matches at runtime, because v2 raises
* timeouts/disconnects as `SdkError`, not `ProtocolError`. This transform rewrites the
* `instanceof` side of such checks to `SdkError` where that is safe, and emits
* diagnostics where it is not:
*
* - `subject instanceof ProtocolError/McpError` guarding a comparison against a moved
* member → guard rewritten to `subject instanceof SdkError` (import added).
* - comparison against a moved member with no detectable `instanceof` guard → warning
* to verify the subject is checked as `SdkError`.
* - `instanceof` guard against an unrecognized class → error with the exact manual fix.
* - `switch` over `x.code` mixing `SdkErrorCode` and `ProtocolErrorCode` cases → error
* (the enums live on different error classes in v2; the switch must be split).
* - object literals keyed by a moved member → warning (`SdkErrorCode` is a string enum;
* previously-numeric map keys are now strings).
*
* Both the post-rename (`SdkErrorCode.X`) and pre-rename (`ErrorCode.X`) spellings are
* recognized, so the transform composes with the symbol rename transform in either
* order; it never rewrites the enum reference itself.
*/

const MOVED_MEMBER_ENUMS = new Set(['SdkErrorCode', 'ErrorCode']);
const V1_ERROR_CLASSES = new Set(['ProtocolError', 'McpError']);

export const errorCodeSemanticsTransform: Transform = {
name: 'Error code semantics',
id: 'error-code-semantics',
apply(sourceFile: SourceFile, context: TransformContext): TransformResult {
const diagnostics: Diagnostic[] = [];
const filePath = sourceFile.getFilePath();
let changesCount = 0;
let needsSdkErrorImport = false;

// Collect first, mutate after — mutating while iterating invalidates descendants.
const movedMemberAccesses: Node[] = [];
sourceFile.forEachDescendant(node => {
if (!Node.isPropertyAccessExpression(node)) return;
const expr = node.getExpression();
if (!Node.isIdentifier(expr)) return;
if (!MOVED_MEMBER_ENUMS.has(expr.getText())) return;
if (!ERROR_CODE_SDK_MEMBERS.has(node.getName())) return;
movedMemberAccesses.push(node);
});

const guardsToRewrite = new Set<BinaryExpression>();
const flaggedObjectLiterals = new Set<Node>();
const flaggedSwitches = new Set<Node>();

for (const access of movedMemberAccesses) {
const memberName = access.getChildAtIndex(2)?.getText() ?? access.getText();
const line = access.getStartLineNumber();

const comparison = getComparisonContext(access);
if (comparison) {
const guard = findInstanceofGuard(access, comparison.subjectText);
if (guard) {
const className = guard.getRight().getText();
if (V1_ERROR_CLASSES.has(className)) {
guardsToRewrite.add(guard);

Check failure on line 76 in packages/codemod/src/migrations/v1-to-v2/transforms/errorCodeSemantics.ts

View check run for this annotation

Claude / Claude Code Review

Guard rewrite silently breaks sibling ProtocolErrorCode comparisons sharing the same instanceof guard

Rewriting an `instanceof ProtocolError`/`McpError` guard to `instanceof SdkError` ignores sibling comparisons under the same guard against `ProtocolErrorCode` (or non-moved `ErrorCode` members), so a v1 condition like `e instanceof McpError && (e.code === ErrorCode.RequestTimeout || e.code === ErrorCode.InvalidParams)` migrates to a guard under which the `ProtocolErrorCode.InvalidParams` branch can never match — the exact silent never-matching failure this transform exists to eliminate, with no
Comment on lines +70 to +76
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Rewriting an instanceof ProtocolError/McpError guard to instanceof SdkError ignores sibling comparisons under the same guard against ProtocolErrorCode (or non-moved ErrorCode members), so a v1 condition like e instanceof McpError && (e.code === ErrorCode.RequestTimeout || e.code === ErrorCode.InvalidParams) migrates to a guard under which the ProtocolErrorCode.InvalidParams branch can never match — the exact silent never-matching failure this transform exists to eliminate, with no diagnostic. Mirror the existing mixed-enum handling for switches/object literals: detect ProtocolErrorCode (or non-moved ErrorCode) comparisons in the guarded condition and emit the error-level "split the condition" diagnostic instead of rewriting.

Extended reasoning...

The bug. When a single instanceof guard protects comparisons against both a moved SdkErrorCode member and a ProtocolErrorCode member, the transform decides to rewrite the guard based only on the moved-member comparison. In errorCodeSemantics.ts (lines 70–76), once findInstanceofGuard returns a guard whose right side is in V1_ERROR_CLASSES (ProtocolError/McpError), the guard is unconditionally added to guardsToRewrite — there is no check whether the same condition also contains comparisons against ProtocolErrorCode or non-moved ErrorCode members. Those sibling comparisons remain protocol-code comparisons, but the guard they depend on becomes instanceof SdkError, so they can never match.\n\nStep-by-step proof. Take very common v1 retry/classification code:\n\nts\nif (e instanceof McpError && (e.code === ErrorCode.RequestTimeout || e.code === ErrorCode.InvalidParams)) { ... }\n\n\n1. symbolRenames (handleErrorCodeSplit) rewrites each member individually: RequestTimeoutSdkErrorCode.RequestTimeout (it is in ERROR_CODE_SDK_MEMBERS), InvalidParamsProtocolErrorCode.InvalidParams, and McpErrorProtocolError. Result: e instanceof ProtocolError && (e.code === SdkErrorCode.RequestTimeout || e.code === ProtocolErrorCode.InvalidParams).\n2. errorCodeSemantics collects only the SdkErrorCode.RequestTimeout access (ProtocolErrorCode is not in MOVED_MEMBER_ENUMS, so the sibling is invisible to it).\n3. getComparisonContext matches (the other side is e.code), and findInstanceofGuard walks up through the || and && binaries to e instanceof ProtocolError.\n4. ProtocolError is in V1_ERROR_CLASSES, so the guard goes into guardsToRewrite with no inspection of the rest of the condition, and is replaced with e instanceof SdkError.\n5. Output: e instanceof SdkError && (e.code === SdkErrorCode.RequestTimeout || e.code === ProtocolErrorCode.InvalidParams). In v2, an InvalidParams error is raised as a ProtocolError, never an SdkError, so the InvalidParams half of the condition is now unreachable. Before this PR (and after symbolRenames alone), that half still worked at runtime.\n\nWhy nothing catches it. No diagnostic is emitted for this case — the guard rewrite path has no mixed-enum detection, unlike the switch handler (which emits the error "switch mixes SdkErrorCode and ProtocolErrorCode cases … Split into …") and the object-literal handler (which flags mixed keys). There is also no test covering a mixed comparison chain under one guard, so the gap is invisible to the suite. This contradicts the PR's own stated principle: where a rewrite is not safely automatable, prefer an error-level diagnostic over guessing, because the failure mode being targeted is silent.\n\nImpact. Realistic error-classification/retry code that distinguishes timeouts from protocol errors under one McpError guard gets silently corrupted: the protocol-code branch (e.g. reporting InvalidParams) stops firing after migration, with no compile error under loose typing and no codemod diagnostic. That is precisely the silent never-matching breakage the new transform was added to eliminate, just shifted from the SDK member to the protocol member.\n\nFix. Before adding a guard to guardsToRewrite, scan the guarded condition (the chain between the guard and the comparison, or simply the enclosing if/ternary condition) for <subject>.code comparisons against ProtocolErrorCode or non-moved ErrorCode members. If any are found, skip the rewrite and emit the same error-level diagnostic used for mixed switches, telling the user to split the condition into an instanceof SdkError branch and an instanceof ProtocolError branch. Add a test for the mixed-comparison-under-one-guard pattern.

} else if (className !== 'SdkError') {
diagnostics.push(
error(
filePath,
line,
`\`${comparison.subjectText}.code === SdkErrorCode.${memberName}\` is guarded by ` +
`\`instanceof ${className}\`, but v2 raises ${memberName} on SdkError. ` +
`Manual fix: \`${comparison.subjectText} instanceof SdkError && ` +
`${comparison.subjectText}.code === SdkErrorCode.${memberName}\`.`
)
);
}

Check warning on line 88 in packages/codemod/src/migrations/v1-to-v2/transforms/errorCodeSemantics.ts

View check run for this annotation

Claude / Claude Code Review

Error-level diagnostic (CLI exit 1) for valid base-class guards like 'instanceof Error'

The unrecognized-class branch in errorCodeSemantics.ts emits an error-level diagnostic (which makes the CLI exit 1) for guards like `e instanceof Error && e.code === SdkErrorCode.RequestTimeout`, even though `SdkError extends Error` so that guard works correctly in v2 — the "Manual fix" message claims a problem that doesn't exist. Consider treating `Error` (and SdkError subclasses like `SdkHttpError`) as acceptable guard classes, or downgrading this specific case to a warning so a correct migrat
Comment on lines +77 to +88
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The unrecognized-class branch in errorCodeSemantics.ts emits an error-level diagnostic (which makes the CLI exit 1) for guards like e instanceof Error && e.code === SdkErrorCode.RequestTimeout, even though SdkError extends Error so that guard works correctly in v2 — the "Manual fix" message claims a problem that doesn't exist. Consider treating Error (and SdkError subclasses like SdkHttpError) as acceptable guard classes, or downgrading this specific case to a warning so a correct migration isn't reported as a failure.

Extended reasoning...

What happens. errorCodeSemantics.ts lines 77–88: when a comparison against a moved member (SdkErrorCode.RequestTimeout / ConnectionClosed) is guarded by an instanceof check whose class is not in {ProtocolError, McpError, SdkError}, the transform emits a DiagnosticLevel.Error diagnostic: \X.code === SdkErrorCode.RequestTimeout` is guarded by `instanceof `, but v2 raises RequestTimeout on SdkError. Manual fix: .... The CLI treats any error-level diagnostic as a failed run — cli.ts:90-97filtersDiagnosticLevel.Errorand setsprocess.exitCode = 1.\n\n**Why this is a false positive for instanceof Error.** SdkError extends Error (packages/core/src/errors/sdkErrors.ts:59), and SdkHttpError extends SdkError(line 95). So a defensive v1 guard likee instanceof Error && e.code === ErrorCode.RequestTimeoutmigrates (after the symbols rename) toe instanceof Error && e.code === SdkErrorCode.RequestTimeout, which is fully correct v2 runtime behavior: every SdkErrorinstance passesinstanceof Error, and .codecarries theSdkErrorCodestring. Yet the codemod tells the user this guard is broken and needs a manual fix, and the run exits 1.\n\n**Step-by-step proof.**\n1. Input v1 file (a.jsfile or loosely-typed catch block — the codemod processes.jsper theprocesses .js filesintegration test):\n ```js\n import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';\n try { await call(); } catch (e) {\n if (e instanceof Error && e.code === ErrorCode.RequestTimeout) { retry(); }\n }\n ```\n2.symbolsrewrites the member toSdkErrorCode.RequestTimeout; the guard stays e instanceof Error.\n3. error-code-semanticsfinds the moved-member comparison,getComparisonContextreturns subjecte, findInstanceofGuardmatchese instanceof Errorin the same&&chain.\n4.Erroris not inV1_ERROR_CLASSESand!== 'SdkError', so the else ifbranch at line 80 fires: error-level diagnostic with the "Manual fix" text.\n5.cli.ts:90-97sees an error-level diagnostic →process.exitCode = 1. The migration was actually correct; nothing needed fixing.\n\n**On the intentional-design objection.** One reviewer pass argued this is the documented conservative policy ("instanceof guard against an unrecognized class → error with the exact manual fix") and therefore not a bug. That policy makes sense for genuinely unknown classes (e.g. CustomError), where the guard might silently never match — and the existing test for that case is fine. But the policy's purpose is to catch never-matching guards, and instanceof Errorcannot never-match: it is a strict superclass ofSdkError. For this case the diagnostic text is factually wrong (it asserts the check is broken when it isn't), it's emitted at error level rather than as a review note, and it fails the run. The trigger surface is admittedly narrow — in strictly-typed TS the comparison is usually spelled (e as any).code, whose subject text doesn't match the guard's left operand and falls into the warning path instead — so this mainly bites JS files and any-typed subjects, which is why this is a nit rather than a blocker.\n\n**How to fix.** Either add 'Error'(and arguably'SdkHttpError', a subclass of SdkErrorthat also passes the comparison when the error is an HTTP SdkError) to the set of acceptable guard classes that take the no-diagnostic path, or keep the message but emit it as awarning(...)when the guard class isError`/an SdkError subclass so the run isn't marked failed. No code is mutated by this branch, so the change is purely about diagnostic level/wording.

} else {
diagnostics.push(
warning(
filePath,
line,
`Comparison against SdkErrorCode.${memberName}: in v2 this code is raised on SdkError ` +
`(not McpError/ProtocolError as in v1). Verify \`${comparison.subjectText}\` is checked ` +
`with \`instanceof SdkError\` before this comparison.`
)
);
}
continue;
}

const caseClause = access.getFirstAncestorByKind(SyntaxKind.CaseClause);
if (caseClause && caseClause.getExpression() === access) {
const switchStmt = caseClause.getFirstAncestorByKind(SyntaxKind.SwitchStatement);
if (switchStmt && !flaggedSwitches.has(switchStmt)) {
flaggedSwitches.add(switchStmt);
const mixesProtocolCodes = switchStmt
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
.some(p => Node.isIdentifier(p.getExpression()) && p.getExpression().getText() === 'ProtocolErrorCode');

Check warning on line 110 in packages/codemod/src/migrations/v1-to-v2/transforms/errorCodeSemantics.ts

View check run for this annotation

Claude / Claude Code Review

Mixed-enum detection only recognizes the post-rename ProtocolErrorCode spelling, missing standalone (pre-rename) runs

The mixed-enum detection for switches (lines 108-110) and object-literal keys (lines 141-143) only looks for the literal identifier `ProtocolErrorCode`, so when the transform runs standalone (`--transforms error-code-semantics`) on un-renamed v1 code, a switch/map mixing `ErrorCode.RequestTimeout` with `ErrorCode.InvalidParams` is not flagged as mixed — it gets the milder "verify instanceof SdkError" warning (which is misleading for a mixed switch) instead of the error-level "Split into ..." dia
Comment on lines +108 to +110
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The mixed-enum detection for switches (lines 108-110) and object-literal keys (lines 141-143) only looks for the literal identifier ProtocolErrorCode, so when the transform runs standalone (--transforms error-code-semantics) on un-renamed v1 code, a switch/map mixing ErrorCode.RequestTimeout with ErrorCode.InvalidParams is not flagged as mixed — it gets the milder "verify instanceof SdkError" warning (which is misleading for a mixed switch) instead of the error-level "Split into ..." diagnostic. To honor the header's claim that the pre-rename spelling is recognized and the transform is safe standalone, the mixesProtocolCodes checks should also treat ErrorCode.<member not in ERROR_CODE_SDK_MEMBERS> accesses as protocol-code cases.

Extended reasoning...

The bug. The transform's header comment (and the ordering note in index.ts) states that both the post-rename (SdkErrorCode.X) and pre-rename (ErrorCode.X) spellings are recognized, so the transform "composes with the symbol rename transform in either order" and is safe standalone. The moved-member collection honors this — MOVED_MEMBER_ENUMS contains both 'SdkErrorCode' and 'ErrorCode' — and so does the guard rewrite (V1_ERROR_CLASSES includes McpError). But the two mixed-enum checks do not: both the switch path (lines 108-110) and the object-literal path (lines 141-143) compute mixesProtocolCodes by looking only for property accesses whose expression text is the literal identifier ProtocolErrorCode.\n\nCode path that triggers it. Run --transforms error-code-semantics standalone on un-renamed v1 code:\n\nts\nimport { ErrorCode } from '@modelcontextprotocol/sdk/types.js';\ndeclare const e: { code: unknown };\nswitch (e.code) {\n case ErrorCode.RequestTimeout: // moved member → SdkErrorCode/SdkError in v2\n retry();\n break;\n case ErrorCode.InvalidParams: // protocol member → ProtocolErrorCode/ProtocolError in v2\n report();\n break;\n}\n\n\nStep-by-step:\n1. The descendant scan collects ErrorCode.RequestTimeout (ErrorCodeMOVED_MEMBER_ENUMS, RequestTimeoutERROR_CODE_SDK_MEMBERS); ErrorCode.InvalidParams is correctly not collected as a moved member.\n2. The access is a CaseClause expression, so control reaches the switch branch and the switch is added to flaggedSwitches.\n3. mixesProtocolCodes scans the switch's property accesses for an expression whose text is exactly 'ProtocolErrorCode'. In pre-rename code the protocol member is still spelled ErrorCode.InvalidParams, so nothing matches and mixesProtocolCodes is false.\n4. The transform emits the milder warning — "switch case on SdkErrorCode.RequestTimeout: ... Verify the switch subject is checked with instanceof SdkError" — instead of the error-level "switch mixes ... Split into an instanceof SdkError branch and an instanceof ProtocolError branch" diagnostic. The same gap applies to the object-literal path: a map mixing [ErrorCode.RequestTimeout] and [ErrorCode.InvalidParams] keys gets the "Update any Record<number, ...> typing" tail instead of "split it into separate maps".\n\nWhy existing code doesn't prevent it. The moved-member detection and guard rewrite were both extended for the pre-rename spelling, but the two mixesProtocolCodes checks were not, and the test suite only covers mixed switches/maps in the post-rename spelling (ProtocolErrorCode.InvalidParams), so nothing exercises a pre-rename mixed switch/map.\n\nImpact. The milder guidance is actively wrong for a mixed switch: if the user follows "verify the switch subject is checked with instanceof SdkError" and wraps the whole switch accordingly, the InvalidParams (protocol-error) case becomes unreachable in v2 — exactly the silent never-matches failure mode this transform exists to prevent. If the user instead runs the symbols transform later, the rename produces the impossible mixed-enum switch and no transform re-emits the split-the-switch error unless error-code-semantics is re-run.\n\nWhy this is only a nit (addressing the refutation). One verifier argued this isn't worth flagging because no code is rewritten incorrectly, a diagnostic is still emitted at the right location, the default pipeline (symbols → error-code-semantics) sees the post-rename spelling and works correctly, and the transform is idempotent so a re-run after the rename catches the mix. Those mitigations are all real and they cap the severity: this is a diagnostic-precision gap in a secondary execution mode, not a code-mutation bug, and it should not block the PR. But it is still a genuine gap relative to the transform's own documented standalone-composability claim, and the substituted warning's guidance is misleading for the mixed case rather than merely weaker — so it's worth a small fix rather than dropping entirely.\n\nFix. In both mixesProtocolCodes computations, also count a property access as a protocol-code occurrence when its expression is the pre-rename enum and the member is not a moved member, e.g.:\n\nts\n.some(p => {\n const expr = p.getExpression();\n if (!Node.isIdentifier(expr)) return false;\n const name = expr.getText();\n if (name === 'ProtocolErrorCode') return true;\n return MOVED_MEMBER_ENUMS.has(name) && !ERROR_CODE_SDK_MEMBERS.has(p.getName());\n});\n\n\nplus a test for a pre-rename mixed switch and map mirroring the existing post-rename ones.

if (mixesProtocolCodes) {
diagnostics.push(
error(
filePath,
line,
`switch mixes SdkErrorCode and ProtocolErrorCode cases. In v2 these enums live on ` +
`different error classes (SdkError vs ProtocolError), so a single switch over ` +
`\`.code\` cannot handle both. Split into an \`instanceof SdkError\` branch and an ` +
`\`instanceof ProtocolError\` branch.`
)
);
} else {
diagnostics.push(
warning(
filePath,
line,
`switch case on SdkErrorCode.${memberName}: in v2 this code is raised on SdkError. ` +
`Verify the switch subject is checked with \`instanceof SdkError\`.`
)
);
}
}
continue;
}

const computedName = access.getFirstAncestorByKind(SyntaxKind.ComputedPropertyName);
if (computedName && computedName.getExpression() === access) {
const objectLiteral = computedName.getFirstAncestorByKind(SyntaxKind.ObjectLiteralExpression);
if (objectLiteral && !flaggedObjectLiterals.has(objectLiteral)) {
flaggedObjectLiterals.add(objectLiteral);
const mixesProtocolCodes = objectLiteral
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
.some(p => Node.isIdentifier(p.getExpression()) && p.getExpression().getText() === 'ProtocolErrorCode');
diagnostics.push(
warning(
filePath,
line,
`Map keyed by SdkErrorCode.${memberName}: SdkErrorCode is a string enum in v2, so this key ` +
`changed from a number to a string.` +
(mixesProtocolCodes
? ` This literal also has ProtocolErrorCode keys (numeric) — split it into separate maps.`
: ` Update any \`Record<number, ...>\` typing and numeric lookups accordingly.`)
)
);
}
}
}

const rewrittenGuardClasses = new Set<string>();
for (const guard of guardsToRewrite) {
rewrittenGuardClasses.add(guard.getRight().getText());
guard.getRight().replaceWithText('SdkError');
needsSdkErrorImport = true;
changesCount++;
}

if (needsSdkErrorImport && !isImportedFromMcp(sourceFile, 'SdkError')) {
const targetModule = resolveTargetModule(sourceFile, context);
addOrMergeImport(sourceFile, targetModule, ['SdkError'], false, sourceFile.getImportDeclarations().length);
changesCount++;
}

// Retargeting a guard may have removed the last usage of the v1 error
// class — drop the now-unused import specifier so migrated output does
// not carry unused-import lint debt.
for (const className of rewrittenGuardClasses) {
if (removeUnusedImport(sourceFile, className, true)) {
changesCount++;
}
}

return { changesCount, diagnostics };
}
};

interface ComparisonContext {
subjectText: string;
}

/**
* If `access` is one side of a `===`/`!==`/`==`/`!=` comparison whose other side is a
* `<subject>.code` property access, return the subject expression text.
*/
function getComparisonContext(access: Node): ComparisonContext | undefined {
const parent = access.getParent();
if (!parent || !Node.isBinaryExpression(parent)) return undefined;
const op = parent.getOperatorToken().getKind();
if (
op !== SyntaxKind.EqualsEqualsEqualsToken &&
op !== SyntaxKind.ExclamationEqualsEqualsToken &&
op !== SyntaxKind.EqualsEqualsToken &&
op !== SyntaxKind.ExclamationEqualsToken
) {
return undefined;
}
const other = parent.getLeft() === access ? parent.getRight() : parent.getLeft();
if (!Node.isPropertyAccessExpression(other)) return undefined;
if (other.getName() !== 'code') return undefined;
return { subjectText: other.getExpression().getText() };
}

/**
* Search the enclosing conditions for `subject instanceof <Class>`. Covers the same
* logical-AND chain (`e instanceof X && e.code === ...`) and enclosing if/ternary
* conditions (`if (e instanceof X) { if (e.code === ...) ... }`).
*/
function findInstanceofGuard(access: Node, subjectText: string): BinaryExpression | undefined {
let scope: Node | undefined = access;
while (scope) {
if (Node.isBinaryExpression(scope) || Node.isIfStatement(scope) || Node.isConditionalExpression(scope)) {
const searchRoot = Node.isIfStatement(scope)
? scope.getExpression()
: Node.isConditionalExpression(scope)
? scope.getCondition()
: scope;
for (const candidate of [searchRoot, ...searchRoot.getDescendantsOfKind(SyntaxKind.BinaryExpression)]) {
if (!Node.isBinaryExpression(candidate)) continue;
if (candidate.getOperatorToken().getKind() !== SyntaxKind.InstanceOfKeyword) continue;
if (candidate.getLeft().getText() !== subjectText) continue;
return candidate;
}
}
if (
Node.isFunctionDeclaration(scope) ||
Node.isFunctionExpression(scope) ||
Node.isArrowFunction(scope) ||
Node.isSourceFile(scope)
) {
return undefined;
}
scope = scope.getParent();
}
return undefined;
}

Check failure on line 244 in packages/codemod/src/migrations/v1-to-v2/transforms/errorCodeSemantics.ts

View check run for this annotation

Claude / Claude Code Review

instanceof guard rewrite matches guards that do not actually guard the comparison (else-if, || chains, negations)

findInstanceofGuard() returns any `subject instanceof Class` found in an enclosing condition without checking that the instanceof actually guards the comparison conjunctively (&&) on the truthy path, so realistic shapes like else-if chains (`if (e instanceof ProtocolError) {...} else if (e.code === SdkErrorCode.RequestTimeout) {...}`), `||` disjunctions, and negated guards (`!(e instanceof ProtocolError) && ...`) get their unrelated instanceof silently rewritten to `SdkError`, corrupting the use
Comment on lines +217 to +244
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 findInstanceofGuard() returns any subject instanceof Class found in an enclosing condition without checking that the instanceof actually guards the comparison conjunctively (&&) on the truthy path, so realistic shapes like else-if chains (if (e instanceof ProtocolError) {...} else if (e.code === SdkErrorCode.RequestTimeout) {...}), || disjunctions, and negated guards (!(e instanceof ProtocolError) && ...) get their unrelated instanceof silently rewritten to SdkError, corrupting the user's ProtocolError handling with no diagnostic. The rewrite should only fire when the instanceof is an && conjunct guarding the comparison (or the access sits in the then/whenTrue branch); otherwise fall back to the existing warning/error diagnostics.

Extended reasoning...

What the bug is

findInstanceofGuard() (errorCodeSemantics.ts:217-244) walks up from the moved-member comparison through every enclosing BinaryExpression / IfStatement / ConditionalExpression and returns the first subject instanceof <Class> found anywhere in those conditions. The candidate loop only checks the operator kind and the left-operand text — it never verifies that (a) the instanceof is a logical-AND conjunct on the path to the comparison, or (b) the comparison sits in the guarded (truthy) branch of the if/ternary. Any matching instanceof in an enclosing condition is then added to guardsToRewrite and unconditionally rewritten to instanceof SdkError.

Code path / step-by-step proof (else-if case)

Take this realistic v1-style error-classification code (post-rename spelling shown, but the pre-rename spelling triggers the same path):

if (e instanceof ProtocolError) {
    handleProtocolError(e);
} else if (e.code === SdkErrorCode.RequestTimeout) {
    retry();
}
  1. The transform finds the moved-member access SdkErrorCode.RequestTimeout and getComparisonContext() returns subjectText = 'e' (the other side is e.code).
  2. findInstanceofGuard() walks up: the === BinaryExpression — no instanceof among its descendants; the inner IfStatement (the else-if), whose expression is the === — still no instanceof.
  3. The walk continues to the parent of the else-if, which in the AST is the outer IfStatement (an else-if is the outer if's elseStatement). Its searchRoot is e instanceof ProtocolError; the operator is instanceof and the left text is 'e', so it is returned as the "guard".
  4. ProtocolError is in V1_ERROR_CLASSES, so the outer guard is added to guardsToRewrite and rewritten to e instanceof SdkError — retargeting the protocol-error branch, which has nothing to do with the timeout comparison. The else-if comparison itself remains unguarded, and because a "guard" was found, the no-guard warning diagnostic is also suppressed. Output:
if (e instanceof SdkError) {          // was: e instanceof ProtocolError — silently corrupted
    handleProtocolError(e);
} else if (e.code === SdkErrorCode.RequestTimeout) {
    retry();                           // still effectively unreachable, and no warning emitted
}

The same over-matching hits two more shapes:

  • || chainsif (e instanceof ProtocolError || e.code === SdkErrorCode.RequestTimeout): the || BinaryExpression is itself a scope, getDescendantsOfKind(BinaryExpression) finds the instanceof disjunct (which is not a guard at all), and it is rewritten — silently dropping the user's ProtocolError handling from the disjunction.
  • Negations!(e instanceof ProtocolError) && e.code === ...: the instanceof inside the PrefixUnaryExpression is still a descendant of the && chain, so it is rewritten to !(e instanceof SdkError) && ..., inverting the intended check.

Why nothing prevents it

guardsToRewrite is consumed unconditionally — every guard collected is rewritten with no further validation, and finding a "guard" suppresses the warning that an unguarded comparison would otherwise receive. The new tests only cover the conjunctive &&-chain and nested-if happy paths; none cover else-branches, ||, or negated forms.

Impact

The codemod silently mutates user error-handling code whose semantics it has not verified, with no diagnostic — exactly the silent-failure class the PR description says this transform must never reintroduce ("it prefers an error-level diagnostic … over guessing; the codemod should never reintroduce silence"). In the else-if case it actively corrupts the other branch (protocol-error handling stops matching protocol errors) while leaving the timeout branch broken and unflagged.

How to fix

Only treat an instanceof as a guard when it conjunctively (&&) dominates the comparison: while walking up, accept an instanceof only if it is a conjunct of an && chain on the path to the access, or the condition of an if/ternary whose then/whenTrue branch contains the access — and skip candidates wrapped in a negation. When no such guard exists, fall through to the existing no-guard warning (or the foreign-class error), which is the behavior the transform already has for genuinely unguarded comparisons.


function resolveTargetModule(sourceFile: SourceFile, context: TransformContext): string {
const existing = sourceFile.getImportDeclarations().find(i => {
const spec = i.getModuleSpecifierValue();
return spec === '@modelcontextprotocol/client' || spec === '@modelcontextprotocol/server';
});
if (existing) return existing.getModuleSpecifierValue();
if (context.projectType === 'client') return '@modelcontextprotocol/client';
return '@modelcontextprotocol/server';
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import type { SourceFile } from 'ts-morph';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js';
import { renameAllReferences } from '../../../utils/astUtils.js';
import { actionRequired, info, v2Gap, warning } from '../../../utils/diagnostics.js';
import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils.js';
import {
addOrMergeImport,
captureLeadingFileTrivia,
getSdkExports,
getSdkImports,
isTypeOnlyImport,
restoreLeadingFileTrivia
} from '../../../utils/importUtils.js';
import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js';
import { IMPORT_MAP, isAuthImport } from '../mappings/importMap.js';
import { SIMPLE_RENAMES } from '../mappings/symbolMap.js';
Expand All @@ -14,7 +21,7 @@ const REEXPORT_WARNINGS: Record<string, string> = {
'Re-exported RequestHandlerExtra was renamed to ServerContext/ClientContext in v2. Update this re-export manually.',
IsomorphicHeaders: 'Re-exported IsomorphicHeaders was removed in v2 (replaced by standard Headers API). Remove this re-export.',
StreamableHTTPError:
'Re-exported StreamableHTTPError was renamed to SdkError in v2 with different constructor. Update this re-export manually.'
'Re-exported StreamableHTTPError was replaced by SdkHttpError in v2 with a different constructor. Update this re-export manually.'
};

export const importPathsTransform: Transform = {
Expand Down Expand Up @@ -49,6 +56,9 @@ export const importPathsTransform: Transform = {
});

const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!);
// Removing the first import would silently delete a file-leading shebang or
// banner comment attached to it — capture it up front and restore at the end.
const leadingTrivia = captureLeadingFileTrivia(sdkImports[0]!);

interface PendingImport {
names: string[];
Expand Down Expand Up @@ -204,6 +214,8 @@ export const importPathsTransform: Transform = {
}
}

restoreLeadingFileTrivia(sourceFile, leadingTrivia);

return { changesCount, diagnostics, usedPackages };
}
};
Expand Down
18 changes: 13 additions & 5 deletions packages/codemod/src/migrations/v1-to-v2/transforms/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Transform } from '../../../types.js';
import { contextTypesTransform } from './contextTypes.js';
import { errorCodeSemanticsTransform } from './errorCodeSemantics.js';
import { expressMiddlewareTransform } from './expressMiddleware.js';
import { handlerRegistrationTransform } from './handlerRegistration.js';
import { importPathsTransform } from './importPaths.js';
Expand All @@ -20,26 +21,33 @@ import { symbolRenamesTransform } from './symbolRenames.js';
// ProtocolError) and rewrites type references (e.g., SchemaInput<T> →
// StandardSchemaWithJSON.InferInput<T>).
//
// 3. removedApis runs after symbolRenames: handles removed Zod helpers,
// 3. errorCodeSemantics runs after symbolRenames: the rename turns
// ErrorCode.RequestTimeout/ConnectionClosed into SdkErrorCode members; this
// transform then fixes the surrounding semantics (instanceof guards that
// still reference ProtocolError, switches/maps mixing the two enums). It
// also recognizes the pre-rename spelling, so it is safe standalone.
//
// 4. removedApis runs after symbolRenames: handles removed Zod helpers,
// IsomorphicHeaders, and StreamableHTTPError. Conceptually different
// from renames — these are removals with diagnostic guidance.
//
// 4. mcpServerApi SHOULD run before contextTypes: it rewrites .tool() etc.
// 5. mcpServerApi SHOULD run before contextTypes: it rewrites .tool() etc.
// to .registerTool() etc. contextTypes handles both old and new names,
// but running mcpServerApi first ensures consistent argument structure.
//
// 5. handlerRegistration, schemaParamRemoval, and expressMiddleware are
// 6. handlerRegistration, schemaParamRemoval, and expressMiddleware are
// independent of each other but all depend on importPaths having run.
//
// 6. specSchemaAccess runs after handlerRegistration and schemaParamRemoval:
// 7. specSchemaAccess runs after handlerRegistration and schemaParamRemoval:
// those transforms remove spec schema references they handle. specSchemaAccess
// then processes remaining standalone usages (safeParse, parse, z.infer, etc.).
//
// 7. mockPaths runs last: handles test mocks and dynamic imports,
// 8. mockPaths runs last: handles test mocks and dynamic imports,
// independent of the other transforms.
export const v1ToV2Transforms: Transform[] = [
importPathsTransform,
symbolRenamesTransform,
errorCodeSemanticsTransform,
removedApisTransform,
mcpServerApiTransform,
handlerRegistrationTransform,
Expand Down
Loading
Loading