Skip to content

feat: add v2 codemod draft#1950

Draft
KKonstantinov wants to merge 12 commits intomainfrom
feature/v2-codemode-draft
Draft

feat: add v2 codemod draft#1950
KKonstantinov wants to merge 12 commits intomainfrom
feature/v2-codemode-draft

Conversation

@KKonstantinov
Copy link
Copy Markdown
Contributor

@KKonstantinov KKonstantinov commented Apr 23, 2026

Adds a new @modelcontextprotocol/codemod package that automatically migrates MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to the v2 multi-package architecture (@modelcontextprotocol/client, /server, /core, /node, /express). Powered by ts-morph for AST-level precision and shipped as both a CLI (mcp-codemod) and a programmatic API.

Motivation and Context

The v2 SDK introduces a multi-package structure, renamed APIs, restructured context objects, and removed modules (WebSocket transport, server auth, Zod helpers). Manually migrating a codebase is tedious, error-prone, and blocks adoption. This codemod handles the mechanical 80-90% of migration — rewriting imports, renaming symbols, updating method signatures, and mapping context properties — while emitting actionable diagnostics for the remaining cases that require human judgment.

Architecture

Package Structure

packages/codemod/
├── src/
│   ├── cli.ts                  # Commander CLI entry point
│   ├── runner.ts               # Core orchestrator
│   ├── types.ts                # Transform / Migration / Diagnostic types
│   ├── index.ts                # Public API exports
│   ├── migrations/
│   │   ├── index.ts            # Migration registry
│   │   └── v1-to-v2/
│   │       ├── index.ts        # Migration definition
│   │       ├── mappings/       # Declarative lookup tables
│   │       │   ├── importMap.ts
│   │       │   ├── symbolMap.ts
│   │       │   ├── schemaToMethodMap.ts
│   │       │   └── contextPropertyMap.ts
│   │       └── transforms/     # Ordered AST transforms
│   │           ├── index.ts
│   │           ├── importPaths.ts
│   │           ├── symbolRenames.ts
│   │           ├── removedApis.ts
│   │           ├── mcpServerApi.ts
│   │           ├── handlerRegistration.ts
│   │           ├── schemaParamRemoval.ts
│   │           ├── expressMiddleware.ts
│   │           ├── contextTypes.ts
│   │           └── mockPaths.ts
│   └── utils/
│       ├── astUtils.ts         # AST rename helpers
│       ├── diagnostics.ts      # Diagnostic factories
│       ├── importUtils.ts      # Import manipulation
│       └── projectAnalyzer.ts  # Detects client/server/both
└── test/                       # 12 test suites, 138 test cases

High-Level Flow

flowchart TD
    A["mcp-codemod v1-to-v2 ./src"] --> B[CLI parses args]
    B --> C[Runner loads migration]
    C --> D[Analyze project type<br/>from package.json]
    D --> E[Create ts-morph Project<br/>glob .ts/.tsx/.mts files]
    E --> F[Filter out node_modules,<br/>dist, .d.ts, .d.mts]
    F --> G{For each<br/>source file}
    G --> H[Apply transforms<br/>in order]
    H --> I[Collect diagnostics<br/>& change counts]
    I --> G
    G -->|done| J{Dry run?}
    J -->|yes| K[Report changes<br/>without saving]
    J -->|no| L[Save modified files<br/>to disk]
    K --> M[Print summary:<br/>files changed,<br/>diagnostics]
    L --> M
Loading

Transform Pipeline

Transforms run in a strict order per file. Each transform receives the SourceFile AST, mutates it in place, and returns a change count plus diagnostics. One failing transform does not block the others.

flowchart LR
    subgraph "Phase 1: Foundation"
        T1["1. importPaths<br/>Rewrite import specifiers<br/>from v1 → v2 packages"]
    end

    subgraph "Phase 2: Symbols"
        T2["2. symbolRenames<br/>McpError → ProtocolError<br/>ErrorCode split, etc."]
        T3["3. removedApis<br/>Drop Zod helpers,<br/>IsomorphicHeaders"]
    end

    subgraph "Phase 3: API Surface"
        T4["4. mcpServerApi<br/>.tool() → .registerTool()<br/>restructure args"]
        T5["5. handlerRegistration<br/>Schema → method string"]
        T6["6. schemaParamRemoval<br/>Remove schema args"]
        T7["7. expressMiddleware<br/>hostHeaderValidation<br/>signature update"]
    end

    subgraph "Phase 4: Context & Tests"
        T8["8. contextTypes<br/>extra → ctx,<br/>property path remapping"]
        T9["9. mockPaths<br/>vi.mock / jest.mock<br/>specifier updates"]
    end

    T1 --> T2 --> T3 --> T4 --> T5 & T6 & T7 --> T8 --> T9
Loading

Import Resolution Strategy

Some v1 paths (e.g., @modelcontextprotocol/sdk/types.js) are shared code that could belong to either the client or server package. The codemod resolves these contextually:

flowchart TD
    A["v1 import path"] --> B{Path in<br/>IMPORT_MAP?}
    B -->|no| Z[Leave unchanged]
    B -->|yes| C{Status?}
    C -->|removed| D["Remove import +<br/>emit warning diagnostic"]
    C -->|moved| E{Target is<br/>RESOLVE_BY_CONTEXT?}
    C -->|renamed| F["Rewrite path +<br/>rename symbols"]
    E -->|no| G["Rewrite to<br/>fixed target package"]
    E -->|yes| H{Project type?}
    H -->|client only| I["→ @modelcontextprotocol/client"]
    H -->|server only| J["→ @modelcontextprotocol/server"]
    H -->|both or unknown| K["→ @modelcontextprotocol/server<br/>(safe default)"]
Loading

Context Property Remapping

The v2 SDK restructures the handler context from a flat object into nested groups. The contextTypes transform handles this remapping:

flowchart LR
    subgraph "v1 — flat RequestHandlerExtra"
        E1["extra.signal"]
        E2["extra.requestId"]
        E3["extra.authInfo"]
        E4["extra.sendRequest(...)"]
        E5["extra.sendNotification(...)"]
        E6["extra.taskStore"]
    end

    subgraph "v2 — nested BaseContext"
        C1["ctx.mcpReq.signal"]
        C2["ctx.mcpReq.id"]
        C3["ctx.http?.authInfo"]
        C4["ctx.mcpReq.send(...)"]
        C5["ctx.mcpReq.notify(...)"]
        C6["ctx.task?.store"]
    end

    E1 --> C1
    E2 --> C2
    E3 --> C3
    E4 --> C4
    E5 --> C5
    E6 --> C6
Loading

What Each Transform Does

# Transform ID Description
1 imports Rewrites all @modelcontextprotocol/sdk/... import paths to their v2 package destinations. Merges duplicate imports, separates type-only imports, resolves ambiguous shared paths by project type.
2 symbols Renames 9 symbols (e.g., McpErrorProtocolError). Splits ErrorCode into ProtocolErrorCode + SdkErrorCode based on member usage. Converts RequestHandlerExtraServerContext/ClientContext. Replaces SchemaInput<T> with StandardSchemaWithJSON.InferInput<T>.
3 removed-apis Removes references to dropped Zod helpers (schemaToJson, parseSchemaAsync, etc.), renames IsomorphicHeadersHeaders, converts StreamableHTTPErrorSdkError with constructor mapping warnings.
4 mcpserver-api Rewrites McpServer method calls: .tool().registerTool(), .prompt().registerPrompt(), .resource().registerResource(). Restructures 2-4 positional args into (name, config, callback) form. Wraps raw object schemas with z.object().
5 handlers Converts server.setRequestHandler(CallToolRequestSchema, ...) to server.setRequestHandler('tools/call', ...) using the schema-to-method mapping table. Covers 15 request schemas and 8 notification schemas.
6 schema-params Removes the schema parameter from .request(), .callTool(), and .send() calls where the second argument is a schema reference (ends with Schema).
7 express-middleware Updates hostHeaderValidation({ allowedHosts: [...] })hostHeaderValidation([...]).
8 context Renames the extra callback parameter to ctx. Rewrites 13 property access paths from the flat v1 context to the nested v2 context structure. Warns on destructuring patterns that need manual review.
9 mock-paths Rewrites vi.mock() / jest.mock() specifiers from v1 to v2 paths. Updates import() expressions in mock factories. Renames symbol references inside mock factory return objects.

CLI Usage

# Run all transforms
mcp-codemod v1-to-v2 ./src

# Dry run — preview without writing
mcp-codemod v1-to-v2 ./src --dry-run --verbose

# Run specific transforms only
mcp-codemod v1-to-v2 ./src --transforms imports,symbols,context

# List available transforms
mcp-codemod v1-to-v2 --list

# Ignore additional patterns
mcp-codemod v1-to-v2 ./src --ignore "legacy/**" "generated/**"

Programmatic API

import { getMigration, run } from '@modelcontextprotocol/codemod';

const migration = getMigration('v1-to-v2')!;
const result = run(migration, {
  targetDir: './src',
  dryRun: false,
  verbose: true,
  transforms: ['imports', 'symbols'],
  ignore: ['test/**']
});

console.log(`${result.filesChanged} files changed, ${result.totalChanges} total changes`);
for (const d of result.diagnostics) {
  console.log(`[${d.level}] ${d.file}:${d.line}${d.message}`);
}

How Has This Been Tested?

  • 138 test cases across 12 test suites covering every transform, the CLI, the runner, and the project analyzer.
  • Each transform has its own dedicated test suite with isolated ts-morph in-memory filesystem tests.
  • An integration test runs the full pipeline against a realistic v1 source file and verifies the complete output.
  • Edge cases tested: duplicate imports, type-only imports, removed APIs, ambiguous shared paths, ErrorCode member splitting, destructuring patterns, mock factories, .d.ts exclusion, unknown transform ID validation.

Breaking Changes

None — this is a new package with no existing users.

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Design Decisions

  1. ts-morph over jscodeshift: ts-morph provides full TypeScript type information and a more ergonomic API for the precise symbol-level transforms this codemod requires (e.g., distinguishing ErrorCode.RequestTimeout from ErrorCode.InvalidRequest for the ProtocolErrorCode/SdkErrorCode split).

  2. Ordered transforms with isolation: Transforms run in a declared order (imports first, mocks last) but each transform's failure is isolated — one failing transform emits a diagnostic and doesn't block the rest. This maximizes the amount of migration that succeeds per run.

  3. Declarative mapping tables: All rename/remap rules live in dedicated mapping files (importMap.ts, symbolMap.ts, etc.) rather than being scattered across transform logic. This makes the migration rules auditable and easy to extend.

  4. Context-aware import resolution: Shared v1 paths like @modelcontextprotocol/sdk/types.js are resolved to client or server packages based on package.json dependency analysis, not just hardcoded defaults.

  5. Diagnostic-first approach for removals: When the codemod encounters removed APIs (WebSocket transport, server auth, Zod helpers), it doesn't silently drop them — it emits clear warning diagnostics with migration guidance so users know what needs manual attention.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 23, 2026

⚠️ No Changeset found

Latest commit: cfac197

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 23, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1950

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@1950

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1950

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1950

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@1950

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1950

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1950

commit: cfac197

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts Outdated
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts Outdated
Comment thread packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts Outdated
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/cli.ts Outdated
Comment thread packages/codemod/src/cli.ts
Comment thread packages/codemod/src/utils/projectAnalyzer.ts
@KKonstantinov KKonstantinov changed the title feat: add v2 codemode draft feat: add v2 codemod draft Apr 23, 2026
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

Todo:

  • Test on sample size MCP SDK (v1) dependents
  • Test on everything-server
  • Test on v1 MCP SDK examples

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/runner.ts Outdated
Comment thread packages/codemod/src/runner.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +4 to +18
export function renameAllReferences(sourceFile: SourceFile, oldName: string, newName: string): void {
sourceFile.forEachDescendant(node => {
if (Node.isIdentifier(node) && node.getText() === oldName) {
const parent = node.getParent();
if (!parent) return;
if (Node.isImportSpecifier(parent)) return;
if (Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return;
if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return;
if (Node.isPropertySignature(parent) && parent.getNameNode() === node) return;
if (Node.isMethodDeclaration(parent) && parent.getNameNode() === node) return;
if (Node.isPropertyDeclaration(parent) && parent.getNameNode() === node) return;
node.replaceWithText(newName);
}
});
}
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 skip list here covers ImportSpecifier, PropertyAssignment name, PropertyAccessExpression name, etc., but not ShorthandPropertyAssignment or ExportSpecifier — so const errors = { McpError } becomes { ProtocolError } (silently changing the object key) and a local export { McpError } becomes export { ProtocolError } (changing the module's public export name). For shorthand, expand to { McpError: ProtocolError } — exactly what renameSymbolsInFactory in mockPaths.ts:125-132 already does for the same case; for export specifiers, emit export { ProtocolError as McpError } (or skip with a diagnostic).

Extended reasoning...

What the bug is

renameAllReferences (astUtils.ts:4-18) walks every identifier matching oldName and replaces it with newName unless the parent is one of: ImportSpecifier, PropertyAssignment name node, PropertyAccessExpression name node, PropertySignature name, MethodDeclaration name, or PropertyDeclaration name. Two parent kinds that also use the identifier as an externally-visible key are missing from this list:

  • ShorthandPropertyAssignment — in { McpError }, the single identifier is simultaneously the property key and the value reference. Node.isPropertyAssignment does not match shorthand (they are distinct SyntaxKinds in TS / ts-morph), so it falls through to node.replaceWithText(newName).
  • ExportSpecifier — in export { McpError } (a local re-export with no from clause), the identifier is both the local binding and the exported name. This parent kind is also not in the skip list.

Code path that triggers it

renameAllReferences is called from symbolRenamesTransform (for every SIMPLE_RENAMES hit), importPathsTransform (for renamedSymbols mappings), and removedApisTransform (IsomorphicHeadersHeaders, StreamableHTTPErrorSdkError). Any of these can hit a shorthand property or local re-export in the file body.

Why existing code doesn't prevent it

The guard at line 10 — Node.isPropertyAssignment(parent) && parent.getNameNode() === node — only matches the long-form { McpError: x }. For shorthand, ts-morph returns a ShorthandPropertyAssignment node and Node.isPropertyAssignment(parent) is false, so the guard is bypassed. Likewise, line 9's Node.isImportSpecifier(parent) does not cover ExportSpecifier. No later transform repairs the result. The existing tests at symbolRenames.test.ts ("does not rename property keys" / "property access names") cover PropertyAssignment and PropertyAccessExpression but not shorthand or export specifiers.

Notably, renameSymbolsInFactory in mockPaths.ts:125-132 already handles the identical ShorthandPropertyAssignment case correctly by expanding to ${newName}: ${name} — so the omission here is an internal inconsistency, and the fix pattern is already in this PR.

Step-by-step proof

Shorthand case — input:

import { McpError } from '@modelcontextprotocol/sdk/types.js';
export const errors = { McpError };
  1. symbolRenamesTransform finds McpError in an MCP import, calls namedImport.setName('ProtocolError') and then renameAllReferences(sourceFile, 'McpError', 'ProtocolError').
  2. forEachDescendant visits the identifier inside { McpError }. node.getText() === 'McpError' ✓.
  3. parent is a ShorthandPropertyAssignment. None of the six guards match (isImportSpecifier ✗, isPropertyAssignment ✗ — distinct kind, isPropertyAccessExpression ✗, isPropertySignature ✗, isMethodDeclaration ✗, isPropertyDeclaration ✗).
  4. node.replaceWithText('ProtocolError') → output: export const errors = { ProtocolError };.
  5. The object's runtime key is now 'ProtocolError'; any consumer reading errors.McpError gets undefined.

ExportSpecifier case — input:

import { McpError } from '@modelcontextprotocol/sdk/types.js';
export { McpError };
  1. Same trigger as above.
  2. forEachDescendant visits the identifier inside export { McpError }. parent is an ExportSpecifier — not in the skip list.
  3. node.replaceWithText('ProtocolError')export { ProtocolError };.
  4. The module's public export name has changed; downstream files importing { McpError } from this module break. (Re-exports with a from clause go through getSdkExports/importPathsTransform instead, so this specifically affects local re-exports.)

Impact

The output still compiles (the import was renamed too, so ProtocolError is a valid local binding), which makes this a silent semantic change — the worst codemod failure mode, since neither TypeScript nor the user's tests-of-the-codemod will flag it. The trigger patterns — packing an SDK error class into a shorthand object literal (error registries, DI containers) or re-exporting it from a local barrel — are realistic but not common, hence nit severity.

Fix

Add two cases to the guard list, mirroring the mockPaths.ts pattern:

if (Node.isShorthandPropertyAssignment(parent)) {
    // { McpError } → { McpError: ProtocolError } — preserve the key
    parent.replaceWithText(`${oldName}: ${newName}`);
    return;
}
if (Node.isExportSpecifier(parent)) {
    // export { McpError } → export { ProtocolError as McpError } — preserve the exported name
    if (!parent.getAliasNode()) {
        parent.setAlias(oldName);
    }
    parent.setName(newName);
    return;
}

(Or, more conservatively, just return for both and push a diagnostic.) Add tests covering const e = { McpError }{ McpError: ProtocolError } and export { McpError }export { ProtocolError as McpError }.

Comment on lines +90 to +109
if (mapping.renamedSymbols) {
for (const [oldName, newName] of Object.entries(mapping.renamedSymbols)) {
renameAllReferences(sourceFile, oldName, newName);
}
}

const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined);
if (defaultImport || namespaceImport || hasAlias) {
imp.setModuleSpecifier(targetPackage);
if (mapping.renamedSymbols) {
for (const n of namedImports) {
const newName = mapping.renamedSymbols[n.getName()];
if (newName) {
n.setName(newName);
}
}
}
changesCount++;
continue;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 When a path with renamedSymbols is namespace-imported (e.g., import * as transport from '@modelcontextprotocol/sdk/server/streamableHttp.js'; new transport.StreamableHTTPServerTransport({})), renameAllReferences skips the qualified property name (astUtils.ts:11) and the namespaceImport branch only rewrites the module specifier — so the output references transport.StreamableHTTPServerTransport on @modelcontextprotocol/node, which doesn't export it, with no diagnostic. This is a leftover gap from the fix for the earlier namespace-import comment; consider pushing a warning(...) when namespaceImport && mapping.renamedSymbols (or rewriting <ns>.<oldName> accesses) to match the "conservative — emit diagnostics" design.

Extended reasoning...

What the bug is

The if (defaultImport || namespaceImport || hasAlias) branch added to fix the earlier namespace/alias comment (3132456083) correctly preserves namespace imports by calling imp.setModuleSpecifier(targetPackage) instead of remove-and-re-add. But when the mapping also has renamedSymbols, the branch only loops over namedImports (lines 100-106) — which is empty for a namespace import — and the renameAllReferences call just above (lines 90-94) explicitly skips identifiers that are the name node of a PropertyAccessExpression (astUtils.ts:11). So qualified accesses like transport.StreamableHTTPServerTransport are never rewritten, and no diagnostic is emitted.

Code path that triggers it

Input:

import * as transport from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const t = new transport.StreamableHTTPServerTransport({});
  1. namedImports = [], namespaceImport is set, defaultImport undefined.
  2. Lines 90-94: renameAllReferences(sourceFile, 'StreamableHTTPServerTransport', 'NodeStreamableHTTPServerTransport') walks identifiers. It finds StreamableHTTPServerTransport inside transport.StreamableHTTPServerTransport, but parent is a PropertyAccessExpression and parent.getNameNode() === node → astUtils.ts:11 returns early. Not renamed. (This guard is correct in general — you don't want to rename arbitrary obj.StreamableHTTPServerTransport accesses — but it means namespace-qualified accesses aren't covered.)
  3. Line 97: namespaceImport truthy → enters branch. imp.setModuleSpecifier('@modelcontextprotocol/node').
  4. Lines 100-106: for (const n of namedImports) iterates an empty array → no-op.
  5. continue. No diagnostic pushed.

Why nothing else catches it

symbolRenamesTransform (next in the pipeline) only triggers renameAllReferences when it finds the old name in a named import specifier (imp.getNamedImports()), and there are none here. mockPathsTransform only handles vi.mock/jest.mock and dynamic import(). So no later transform repairs the qualified property access.

The existing test 'preserves namespace imports by rewriting module specifier' (importPaths.test.ts:152) uses sdk/types.js, which has no renamedSymbols, so it doesn't exercise this combination.

Step-by-step proof

Input:

import * as transport from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const t = new transport.StreamableHTTPServerTransport({});
  • IMPORT_MAP['@modelcontextprotocol/sdk/server/streamableHttp.js'] = { target: '@modelcontextprotocol/node', status: 'renamed', renamedSymbols: { StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' } }.
  • After importPathsTransform: import * as transport from '@modelcontextprotocol/node'; const t = new transport.StreamableHTTPServerTransport({});
  • After symbolRenamesTransform: unchanged (the import declaration's getNamedImports() is [], so the SIMPLE_RENAMES loop never matches StreamableHTTPServerTransport).
  • Final output references transport.StreamableHTTPServerTransport, but @modelcontextprotocol/node exports NodeStreamableHTTPServerTransportTS2339: Property 'StreamableHTTPServerTransport' does not exist on type .... Zero diagnostics emitted.

Impact

Namespace-importing the single-export streamableHttp module is uncommon (most v1 code uses import { StreamableHTTPServerTransport }), and the resulting TypeScript error is immediate and obvious — hence nit. But it produces silently-broken output with no diagnostic, which contradicts the codemod's stated "Conservative by default: prefers emitting diagnostics for cases that need manual attention" design, and it's the one remaining gap in the namespace/alias handling that the earlier review round otherwise fixed.

Fix

Minimal: emit a warning so the user knows manual attention is needed.

if (defaultImport || namespaceImport || hasAlias) {
    imp.setModuleSpecifier(targetPackage);
    if (mapping.renamedSymbols) {
        for (const n of namedImports) { ... }
        if (namespaceImport) {
            diagnostics.push(warning(filePath, line,
                `Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` +
                `were renamed in ${targetPackage}. Update qualified accesses (e.g., ${namespaceImport.getText()}.${Object.keys(mapping.renamedSymbols)[0]}) manually.`));
        }
    }
    ...
}

Alternatively, actively rewrite <ns>.<oldName> property accesses by walking PropertyAccessExpression nodes whose object is the namespace identifier — but the warning is the cheaper fix and matches how other un-automatable cases are handled.

Comment on lines +58 to +70
for (const removal of removals) {
for (const imp of sourceFile.getImportDeclarations()) {
for (const namedImport of imp.getNamedImports()) {
if (namedImport.getName() === removal.importName) {
namedImport.remove();
if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport()) {
imp.remove();
}
break;
}
}
}
diagnostics.push(warning(sourceFile.getFilePath(), removal.line, `${removal.importName}: ${removal.message}`));
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 removal loop iterates all import declarations without re-checking isAnyMcpSpecifier(), and the break at line 66 only exits the inner named-import loop — so if a file imports e.g. schemaToJson from both an MCP package and another library (aliased), the codemod strips it from both. This is the same missing-filter class flagged and fixed in symbolRenames.ts; add if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; at line 60 to match handleIsomorphicHeaders/handleStreamableHTTPError.

Extended reasoning...

What the bug is

handleRemovedZodHelpers (removedApis.ts:37-74) uses two passes: a find loop (lines 46-56) that correctly gates on isAnyMcpSpecifier(imp.getModuleSpecifierValue()) to populate removals, and a removal loop (lines 58-69) that re-iterates sourceFile.getImportDeclarations() to actually delete the named imports. The second loop has no specifier filter — it removes any named import whose getName() matches removal.importName, regardless of which package it came from. The break on line 66 only exits the innermost for (const namedImport of ...) loop; the middle for (const imp of ...) loop continues to the next import declaration, so a matching name in a non-MCP import is also removed.

Code path that triggers it

The literal scenario "two unaliased import { schemaToJson } lines" is invalid TypeScript (duplicate identifier). The realistic trigger is an aliased import — ts-morph's ImportSpecifier.getName() returns the exported name, not the local alias, so import { schemaToJson as toJson } from 'some-other-lib' still matches namedImport.getName() === 'schemaToJson'.

Why existing code doesn't prevent it

This is internally inconsistent with the two sibling functions in the same file: handleIsomorphicHeaders (lines 81-91) and handleStreamableHTTPError (lines 122-132) both capture the specific foundImport/foundImportDecl from the MCP-filtered scan and operate only on that. handleRemovedZodHelpers instead re-scans without the filter. The existing test 'does not touch non-MCP imports with same names' (removedApis.test.ts:79) only tests a file with only the non-MCP import — removals is empty, the second loop never runs, and the gap isn't exercised. This is also the same class of missing-isAnyMcpSpecifier filter that was already flagged and fixed in symbolRenames.ts (resolved comment 3133268143).

Step-by-step proof

Input:

import { McpServer, schemaToJson } from '@modelcontextprotocol/server';
import { schemaToJson as otherToJson } from 'some-json-schema-lib';
const json = otherToJson(schema);
  1. Find loop (line 46): first declaration passes isAnyMcpSpecifier, finds schemaToJsonremovals = [{ importName: 'schemaToJson', ... }]. Second declaration fails isAnyMcpSpecifier → skipped. ✓
  2. Removal loop (line 58): removal.importName = 'schemaToJson'.
  3. Iterates first declaration (@modelcontextprotocol/server): finds schemaToJson, removes it, declaration still has McpServer so it survives, break exits the named-import loop.
  4. Middle loop continues to the second declaration (some-json-schema-lib): namedImport.getName() returns 'schemaToJson' (the export name, not the alias otherToJson) → match → namedImport.remove(). Declaration now has zero named imports → imp.remove().
  5. Output: import { McpServer } from '@modelcontextprotocol/server'; const json = otherToJson(schema);otherToJson is now an undefined reference.

Impact

Nit severity: the trigger requires importing one of the specific REMOVED_ZOD_HELPERS names (schemaToJson, parseSchemaAsync, getSchemaShape, etc.) from both an MCP package and a non-MCP package in the same file, which is uncommon. But when it does fire, the codemod silently corrupts an unrelated import with no diagnostic, and the fix is a one-liner.

Fix

Add the same guard used by the find loop to the removal loop:

for (const removal of removals) {
    for (const imp of sourceFile.getImportDeclarations()) {
        if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue;
        for (const namedImport of imp.getNamedImports()) {
            ...

(Or refactor to capture the specific ImportSpecifier in the first pass, matching handleIsomorphicHeaders/handleStreamableHTTPError.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant