-
Notifications
You must be signed in to change notification settings - Fork 1.9k
codemod: rewrite moved error-code semantics, fix StreamableHTTPError target, preserve file headers #2240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
codemod: rewrite moved error-code semantics, fix StreamableHTTPError target, preserve file headers #2240
Changes from all commits
646a1d6
6250a78
b926f9f
7bb7e23
0298cfc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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. |
| 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
|
||
| } 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
|
||
|
Comment on lines
+77
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Extended reasoning...What happens. |
||
| } 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
|
||
|
Comment on lines
+108
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Extended reasoning...The bug. The transform's header comment (and the ordering note in |
||
| 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
|
||
|
Comment on lines
+217
to
+244
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 findInstanceofGuard() returns any Extended reasoning...What the bug is
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();
}
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:
Why nothing prevents it
ImpactThe 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 fixOnly treat an instanceof as a guard when it conjunctively ( |
||
|
|
||
| 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'; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Rewriting an
instanceof ProtocolError/McpErrorguard toinstanceof SdkErrorignores sibling comparisons under the same guard againstProtocolErrorCode(or non-movedErrorCodemembers), so a v1 condition likee instanceof McpError && (e.code === ErrorCode.RequestTimeout || e.code === ErrorCode.InvalidParams)migrates to a guard under which theProtocolErrorCode.InvalidParamsbranch 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
instanceofguard protects comparisons against both a movedSdkErrorCodemember and aProtocolErrorCodemember, the transform decides to rewrite the guard based only on the moved-member comparison. InerrorCodeSemantics.ts(lines 70–76), oncefindInstanceofGuardreturns a guard whose right side is inV1_ERROR_CLASSES(ProtocolError/McpError), the guard is unconditionally added toguardsToRewrite— there is no check whether the same condition also contains comparisons againstProtocolErrorCodeor non-movedErrorCodemembers. Those sibling comparisons remain protocol-code comparisons, but the guard they depend on becomesinstanceof 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:RequestTimeout→SdkErrorCode.RequestTimeout(it is inERROR_CODE_SDK_MEMBERS),InvalidParams→ProtocolErrorCode.InvalidParams, andMcpError→ProtocolError. Result:e instanceof ProtocolError && (e.code === SdkErrorCode.RequestTimeout || e.code === ProtocolErrorCode.InvalidParams).\n2.errorCodeSemanticscollects only theSdkErrorCode.RequestTimeoutaccess (ProtocolErrorCodeis not inMOVED_MEMBER_ENUMS, so the sibling is invisible to it).\n3.getComparisonContextmatches (the other side ise.code), andfindInstanceofGuardwalks up through the||and&&binaries toe instanceof ProtocolError.\n4.ProtocolErroris inV1_ERROR_CLASSES, so the guard goes intoguardsToRewritewith no inspection of the rest of the condition, and is replaced withe instanceof SdkError.\n5. Output:e instanceof SdkError && (e.code === SdkErrorCode.RequestTimeout || e.code === ProtocolErrorCode.InvalidParams). In v2, an InvalidParams error is raised as aProtocolError, never anSdkError, so the InvalidParams half of the condition is now unreachable. Before this PR (and aftersymbolRenamesalone), 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 oneMcpErrorguard gets silently corrupted: the protocol-code branch (e.g. reportingInvalidParams) 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 toguardsToRewrite, scan the guarded condition (the chain between the guard and the comparison, or simply the enclosing if/ternary condition) for<subject>.codecomparisons againstProtocolErrorCodeor non-movedErrorCodemembers. 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 aninstanceof SdkErrorbranch and aninstanceof ProtocolErrorbranch. Add a test for the mixed-comparison-under-one-guard pattern.