Skip to content

feat: add v2 codemod draft#1950

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

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

Conversation

@KKonstantinov
Copy link
Copy Markdown
Contributor

@KKonstantinov KKonstantinov commented Apr 23, 2026

feat: add @modelcontextprotocol/codemod package for automated v1 → v2 migration

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. Also automatically updates package.json — removes the v1 SDK dependency and adds the correct v2 packages based on what the code actually imports.

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
│   ├── generated/
│   │   └── versions.ts            # Build-time v2 package versions
│   └── utils/
│       ├── astUtils.ts            # AST rename helpers
│       ├── diagnostics.ts         # Diagnostic factories
│       ├── importUtils.ts         # Import manipulation
│       ├── packageJsonUpdater.ts  # Automatic package.json updates
│       └── projectAnalyzer.ts     # Detects client/server/both
├── scripts/
│   └── generate-versions.ts       # Reads sibling package versions at build time
└── test/                          # 14 test suites, 201 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,<br/>& used v2 packages]
    I --> G
    G -->|done| P[Update package.json:<br/>remove v1 SDK,<br/>add detected v2 packages]
    P --> J{Dry run?}
    J -->|yes| K[Report changes<br/>without saving]
    J -->|no| L[Save modified files<br/>& package.json to disk]
    K --> M[Print summary:<br/>files changed,<br/>package.json changes,<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. Splits imports when symbols from a single v1 module map to different v2 packages (e.g., StreamableHTTPServerTransport/node, EventStore/server). Tracks which v2 packages are used for automatic package.json updates.
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?

  • 201 test cases across 14 test suites covering every transform, the CLI, the runner, the project analyzer, and the package.json updater.
  • Each transform has its own dedicated test suite with isolated ts-morph in-memory filesystem tests.
  • Integration tests run the full pipeline against realistic v1 source files and verify complete output including package.json updates.
  • Package.json updater has dedicated unit tests covering: deps/devDeps/both sections, partial migration, dry-run, malformed JSON, private package filtering, indentation and trailing newline preservation.
  • Integration tests verify end-to-end package detection for client-only, server-only, client+server, express middleware, and split-import (symbolTargetOverrides) scenarios.
  • 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 per-file isolation: Transforms run in a declared order (imports first, mocks last). If a transform throws for a given file, the remaining transforms are skipped for that file (since the AST may be in a partially-mutated state), but processing continues for other files. An error diagnostic is emitted for the affected file.

  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.

  6. Automatic package.json updates: As transforms rewrite imports, they track which v2 packages are targeted. After all source transforms complete, the runner removes @modelcontextprotocol/sdk from package.json and adds exactly the v2 packages the code actually uses. Version specifiers are injected at build time from sibling package versions via scripts/generate-versions.ts. Private packages (@modelcontextprotocol/core) are filtered out. The updater preserves the original indentation and trailing newline, and respects dry-run mode.

  7. Import splitting with symbolTargetOverrides: When symbols from a single v1 module map to different v2 packages (e.g., StreamableHTTPServerTransport@modelcontextprotocol/node but EventStore@modelcontextprotocol/server), the import map supports per-symbol target overrides. The transform splits the import into separate declarations for each target package.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 23, 2026

⚠️ No Changeset found

Latest commit: 96b1481

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: 96b1481

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts Outdated
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
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts Outdated
@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 thread packages/codemod/src/utils/astUtils.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

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

@claude review

Comment on lines +32 to +34
"files": [
"dist"
],
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 new @modelcontextprotocol/codemod package is added to the publish workflow but ships with no README.md, while every other published package in the repo (client, server, middleware/*) has one. The PR description's CLI usage, programmatic API, and transform table won't be visible on the npm package page — worth adding a packages/codemod/README.md before this lands (npm auto-includes README regardless of the files array, so no package.json change is needed).

Extended reasoning...

What the gap is

This PR adds @modelcontextprotocol/codemod as a new published package — it has a bin entry (mcp-codemod), a programmatic API export, and is added to .github/workflows/publish.yml:43 so pkg-pr-new is already publishing preview builds. But packages/codemod/ contains no README.md. Every other published package in the repo has one: packages/client/README.md, packages/server/README.md, and all four packages/middleware/*/README.md files exist.

The repo's own review checklist (REVIEW.md) explicitly calls this out: "New feature: verify prose documentation is added (not just JSDoc), and assess whether examples/ needs a new or updated example." A new package with both a CLI and a programmatic API is squarely in scope for that item.

Why it matters

The PR description already contains exactly the documentation users need — the CLI Usage block (mcp-codemod v1-to-v2 ./src, --dry-run, --transforms, --list, --ignore), the Programmatic API snippet (getMigration / run), and the 9-row transform table. None of that will be visible on npm. A user who runs npx @modelcontextprotocol/codemod and gets the commander help text will have nowhere to look for the transform IDs, the design decisions, or the migration scope.

Step-by-step proof

  1. .github/workflows/publish.yml:43 adds './packages/codemod' to the pkg-pr-new publish command, so the package is published on every PR push (and presumably on release).
  2. packages/codemod/package.json:32-34 sets "files": ["dist"] and a bin entry — so the package is intended for end-user consumption via npx.
  3. ls packages/codemod/ shows: eslint.config.mjs, package.json, src/, test/, tsconfig.json, tsdown.config.ts, typedoc.json, vitest.config.js — no README.md.
  4. ls packages/*/README.md packages/middleware/*/README.md shows READMEs for client, server, express, fastify, hono, and node — codemod is the only published package without one.
  5. On npm, the package page renders the README as the primary documentation surface. With no README, the page shows only the package.json description string: "Codemod to migrate MCP TypeScript SDK code from v1 to v2" — no usage, no flags, no transform list.

Note on files array

npm always includes README* (along with package.json, LICENSE*, and the main entry) regardless of the files allowlist, so adding packages/codemod/README.md is sufficient — no need to touch "files": ["dist"].

Fix

Add packages/codemod/README.md containing (at minimum) the CLI Usage block, the Programmatic API snippet, and the transform table from this PR's description. Given the PR is explicitly titled "draft" and the author has an open TODO checklist, this is likely already planned — flagging it here per the REVIEW.md checklist item so it doesn't slip.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

TBD. Add README.md once the feasibility of the codemod has been confirmed.

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

@claude review

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts Outdated
Comment on lines +76 to +81
export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): boolean {
return sourceFile.getImportDeclarations().some(imp => {
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;
return imp.getNamedImports().some(n => n.getName() === symbolName);
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 isImportedFromMcp() compares against n.getName() (the export name), but callers at handlerRegistration.ts:40 and schemaParamRemoval.ts:31 pass firstArg.getText()/secondArg.getText() — the local binding name — so an aliased MCP import plus a same-named local import confuses the provenance check. The new test 'does not remove non-MCP import when MCP import of same name is consumed' (handlerRegistration.test.ts:108-119) actually locks in the wrong behavior: CallToolRequestSchema there is bound to ./local-schemas.js, yet the test asserts it's rewritten to 'tools/call'. Note that mcpServerApi.ts:15 and expressMiddleware.ts:12 pass hardcoded export names and rely on the current semantics, so the fix is to check the local binding (n.getAliasNode()?.getText() ?? n.getName()) only for the body-identifier callers — e.g., a separate helper or an optional flag — rather than changing the function unconditionally.

Extended reasoning...

What the bug is

isImportedFromMcp(sourceFile, symbolName) (importUtils.ts:76-81) tests imp.getNamedImports().some(n => n.getName() === symbolName). ts-morph's ImportSpecifier.getName() returns the export name (the identifier before as), not the local alias — this is the same fact already established and acted on elsewhere in this PR (e.g., resolved comment 3132456083, and removedApis.ts using foundImport.getAliasNode()?.getText() ?? 'IsomorphicHeaders' to derive the local name).

Two of the four callers pass the local identifier text as it appears in the body:

  • handlerRegistration.ts:40schemaName = firstArg.getText()
  • schemaParamRemoval.ts:31schemaName = secondArg.getText()

For these callers, comparing the body identifier against the export name is a category error whenever an alias is involved.

The concrete manifestation: a test that asserts wrong behavior

The most actionable evidence is the new test at handlerRegistration.test.ts:108-119:

import { CallToolRequestSchema } from './local-schemas.js';
import { CallToolRequestSchema as McpSchema } from '@modelcontextprotocol/sdk/types.js';
server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [] }));
validateSchema(McpSchema);

In this fixture, the CallToolRequestSchema passed to setRequestHandler is the local binding from ./local-schemas.js — the MCP one is aliased to McpSchema. The test asserts expect(result).toContain("'tools/call'") and not.toMatch(/setRequestHandler\(CallToolRequestSchema/), i.e., it expects the local schema usage to be rewritten as if it were the MCP schema. That's the false positive, encoded as expected behavior. (The test was presumably written to verify that removeUnusedImport(..., onlyMcpImports=true) doesn't strip the local import — which it does verify — but the rewrite assertion is incorrect.)

Step-by-step proof (false positive)

  1. firstArg.getText() = 'CallToolRequestSchema'.
  2. ALL_SCHEMA_TO_METHOD['CallToolRequestSchema'] = 'tools/call' → passes line 38.
  3. isImportedFromMcp(sf, 'CallToolRequestSchema'): iterates imports; the second declaration is an MCP specifier; its named import has getName() === 'CallToolRequestSchema' (the export name on the aliased specifier) → returns true.
  4. firstArg.replaceWithText("'tools/call'") — but firstArg referred to the local ./local-schemas.js binding, not the MCP one.
  5. removeUnusedImport(sf, 'CallToolRequestSchema', true) then scans for body references to CallToolRequestSchema, finds none (it was just replaced), and — correctly — only removes the MCP-aliased specifier? Actually no: removeUnusedImport at importUtils.ts:96 also matches on namedImport.getName() (export name), so it would try to remove the aliased MCP specifier, but McpSchema is still referenced in validateSchema(McpSchema), so referenceCount > 0 and nothing is removed. Hence the local import survives (which is what the test's first assertion checks), but the body call was still wrongly rewritten.

Addressing the refutation

A reviewer objected that (a) the proposed fix breaks the other two callers, (b) the false-negative paths are gated elsewhere, and (c) the trigger is contrived. All three points are fair and worth incorporating:

  • (a) is correct and important. mcpServerApi.ts:15 and expressMiddleware.ts:12 pass literal export names ('McpServer', 'hostHeaderValidation') as a file-level gate. For import { McpServer as Server }, n.getName() === 'McpServer' is exactly what they want. Changing the function to only check the alias would regress them. So the fix is not the one-liner originally proposed; it's either a second helper (e.g., isLocalIdentifierFromMcp) for the body-identifier callers, or having the function match either form and letting callers 1/2 pass the local text while callers 3/4 keep passing the export name — but "match either" doesn't fix the false positive, so a separate helper is cleaner.
  • (b) is largely correct. The false-negative in handlerRegistration is moot because ALL_SCHEMA_TO_METHOD[aliasName] is undefined and short-circuits first. In schemaParamRemoval, isSchemaReference requires the text to end in 'Schema', so an alias like RS is filtered out before the provenance check; only an alias that itself ends in Schema (e.g., as ResultSchema) reaches the bug and is then silently skipped — safe (no corruption), just an incomplete migration.
  • (c) is correct on frequency, which is why this is a nit. But the fact that the test suite itself encodes the false positive as expected output is worth a one-line correction regardless of how rare the pattern is in the wild — tests that assert wrong behavior tend to bite later.

Impact

Nit severity. The output-corrupting path requires importing the same export name from both an MCP package (aliased) and a non-MCP source in one file — uncommon. The false-negative paths are mostly gated by earlier checks and, where reachable, leave code unchanged. The main value of the fix is (1) correcting the test so it doesn't lock in a wrong rewrite, and (2) eliminating an internal inconsistency where one helper serves two semantically different caller intents.

Fix

Introduce a local-binding variant for the body-identifier callers and leave the existing function for the file-level gates:

export function isLocalIdentifierFromMcp(sourceFile: SourceFile, localName: string): boolean {
    return sourceFile.getImportDeclarations().some(imp => {
        if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;
        return imp.getNamedImports().some(n => (n.getAliasNode()?.getText() ?? n.getName()) === localName);
    });
}

Use it at handlerRegistration.ts:40 and schemaParamRemoval.ts:31. Then update the test at handlerRegistration.test.ts:108-119 to assert the correct behavior: the setRequestHandler(CallToolRequestSchema, ...) call (local binding) is not rewritten, and 'tools/call' does not appear.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks — the false-positive is fixed (handlerRegistration/schemaParamRemoval now resolve via local binding + resolveOriginalImportName), and the test at handlerRegistration.test.ts now asserts correct behavior. 👍

One leftover: isImportedFromMcp was changed unconditionally to match on the local binding, which regresses the file-level-gate caller the comment flagged. mcpServerApi.ts:15 still passes the hardcoded export name 'McpServer', so for import { McpServer as Server } from '@modelcontextprotocol/server' the gate now returns false (the local name is 'Server') and every .tool()/.prompt()/.resource() call in that file is silently skipped — previously n.getName() === 'McpServer' matched the export name on the aliased specifier and the transform ran. (expressMiddleware.ts:12 is moot in practice since its body check at line 32 also matches on the literal identifier text.)

"Match either form" won't work here — it would re-introduce the false positive (the comment's refutation point (a) covers this). Simplest fix is a second helper for the export-name semantics and switch mcpServerApi.ts:15 to it:

export function isExportImportedFromMcp(sourceFile: SourceFile, exportName: string): boolean {
    return sourceFile.getImportDeclarations().some(imp =>
        isAnyMcpSpecifier(imp.getModuleSpecifierValue()) &&
        imp.getNamedImports().some(n => n.getName() === exportName)
    );
}

(Low-frequency edge case, so still nit — but it's a behavioral regression from the previous commit.)

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +10 to +14
if (Node.isExportSpecifier(parent)) {
if (!parent.getAliasNode()) parent.setAlias(oldName);
parent.getNameNode().replaceWithText(newName);
return;
}
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 ExportSpecifier branch added for comment 3136237226 checks only Node.isExportSpecifier(parent) without verifying whether the matched identifier is the name node or the alias node — unlike every sibling guard (lines 15-24) which checks parent.getNameNode() === node. For export { Foo as McpError }, forEachDescendant visits the alias identifier McpError, parent.getAliasNode() is truthy so setAlias is skipped, then parent.getNameNode().replaceWithText('ProtocolError') overwrites Fooexport { ProtocolError as McpError }, silently dropping the user's local binding. Add if (parent.getAliasNode() === node) return; at the top of the branch.

Extended reasoning...

What the bug is

The ExportSpecifier branch at astUtils.ts:10-14 was added in response to comment 3136237226 to rewrite export { McpError }export { ProtocolError as McpError }. But it only checks Node.isExportSpecifier(parent) and then unconditionally operates on parent.getNameNode() — it never verifies whether the matched identifier node is the name node (the local binding, before as) or the alias node (the public export name, after as). Every other guard in this function (lines 15-24: PropertyAssignment, PropertyAccessExpression, PropertySignature, MethodDeclaration, BindingElement, etc.) explicitly checks parent.getNameNode() === node before acting; this branch is the inconsistent one.

Code path that triggers it

In ts-morph, for export { Foo as McpError }, ExportSpecifier.getNameNode() returns the Foo identifier and getAliasNode() returns the McpError identifier. forEachDescendant visits both identifiers. When it reaches the alias McpError:

  1. Node.isIdentifier(node) && node.getText() === 'McpError' → true (matches oldName).
  2. Node.isExportSpecifier(parent) → true.
  3. parent.getAliasNode() is truthy (it's the McpError node) → setAlias is skipped.
  4. parent.getNameNode().replaceWithText('ProtocolError') → replaces Foo with ProtocolError.

Result: export { ProtocolError as McpError }. The user's local Foo binding is silently dropped, and ProtocolError may not even exist as a local binding (if the file imported the unaliased MCP McpError, the import was renamed to ProtocolError, so it happens to resolve — but the semantics changed: the module now exports the MCP error class instead of the user's Foo).

Why existing code doesn't prevent it

The branch correctly handles two of three cases: (a) export { McpError } — no alias, setAlias(oldName) then rename → export { ProtocolError as McpError } ✓; (b) export { McpError as Something }forEachDescendant matches the name node McpError, alias is truthy so setAlias skipped, name replaced → export { ProtocolError as Something } ✓. But case (c) export { Foo as McpError } — where only the alias matches oldName — falls through the same code path and corrupts the name slot. The new test 'preserves export specifier public name with alias' (symbolRenames.test.ts) only covers case (a). No test covers an export specifier whose alias matches a SIMPLE_RENAMES key.

Step-by-step proof

Input (file imports MCP McpError to trigger renameAllReferences, and separately re-exports a local class under the same public name):

import { McpError } from '@modelcontextprotocol/sdk/types.js';
class AppError extends Error {}
export { AppError as McpError };
if (e instanceof McpError) { ... }
  1. symbolRenamesTransform finds McpError in the MCP import, calls namedImport.setName('ProtocolError'), then renameAllReferences(sf, 'McpError', 'ProtocolError').
  2. forEachDescendant visits the import specifier → Node.isImportSpecifier(parent) returns early. ✓
  3. Visits McpError in the instanceof check → falls through to node.replaceWithText('ProtocolError'). ✓
  4. Visits the alias McpError in export { AppError as McpError }parent is ExportSpecifier, getAliasNode() truthy → getNameNode().replaceWithText('ProtocolError') rewrites AppError.

Output:

import { ProtocolError } from '@modelcontextprotocol/sdk/types.js';
class AppError extends Error {}
export { ProtocolError as McpError };   // ← was AppError; now exports the MCP class instead
if (e instanceof ProtocolError) { ... }

The module's public McpError export silently changed from the user's AppError to the SDK's ProtocolError — still compiles, no diagnostic, semantically wrong. (One verifier reproduced this empirically against ts-morph.)

Impact

Nit severity: the trigger requires a file that both (a) imports a SIMPLE_RENAMES key unaliased from MCP (to fire renameAllReferences) and (b) re-exports a different local under that same public name — uncommon but valid TypeScript. When it fires, though, it's the worst codemod failure mode: silent semantic corruption that type-checks. And it's a one-line internal-consistency fix in a branch that was just added in response to review.

Fix

Add a guard at the top of the branch matching the pattern used by every sibling:

if (Node.isExportSpecifier(parent)) {
    if (parent.getAliasNode() === node) return;   // alias is the public export name — leave it
    if (!parent.getAliasNode()) parent.setAlias(oldName);
    parent.getNameNode().replaceWithText(newName);
    return;
}

(Equivalently, if (parent.getNameNode() !== node) return;.) A test like import { McpError } from '@modelcontextprotocol/server'; class Foo {} export { Foo as McpError }; → expect output to contain export { Foo as McpError } would lock this in.

Comment on lines +76 to +84
export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): boolean {
return sourceFile.getImportDeclarations().some(imp => {
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;
return imp.getNamedImports().some(n => {
const localName = n.getAliasNode()?.getText() ?? n.getName();
return localName === symbolName;
});
});
}
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 latest commit changed isImportedFromMcp() to compare only against the local binding (n.getAliasNode()?.getText() ?? n.getName()), but mcpServerApi.ts:15 and expressMiddleware.ts:12 still pass hardcoded export names — exactly what unresolved comment 3137901202 warned against. So import { McpServer as Server } from '...' now makes isImportedFromMcp(sf, 'McpServer') return false and the entire mcpserver-api transform silently no-ops, regressing previously-working aliased-import handling. Add a separate export-name helper for the two file-level gates (note: simply matching either name would re-break the handlerRegistration false-positive the latest commit just fixed).

Extended reasoning...

What the bug is

isImportedFromMcp (importUtils.ts:76-84) was changed in the latest commit from n.getName() === symbolName (matching the export name) to:

const localName = n.getAliasNode()?.getText() ?? n.getName();
return localName === symbolName;

i.e., it now matches only the local binding name. This was done to fix the false-positive flagged in comment 3137901202 for handlerRegistration.ts:40 and schemaParamRemoval.ts:31, which pass body-identifier text (a local name). But that same comment explicitly warned: "mcpServerApi.ts:15 and expressMiddleware.ts:12 pass hardcoded export names and rely on the current semantics, so the fix is to check the local binding only for the body-identifier callers — e.g., a separate helper or an optional flag — rather than changing the function unconditionally." The function was changed unconditionally anyway, and those two callers were not updated.

Code path that triggers it

mcpServerApi.ts:15 calls isImportedFromMcp(sourceFile, 'McpServer') as a file-level gate; expressMiddleware.ts:12 calls isImportedFromMcp(sourceFile, 'hostHeaderValidation'). Both pass the export name as a literal string. With an aliased import, ts-morph's getAliasNode()?.getText() returns the alias, so localName is the alias — which never equals the hardcoded export name.

Why this is a regression

Before the latest commit, for import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js', n.getName() === 'McpServer' was true, the gate passed, and the collection loop (which has no receiver check) correctly migrated server.tool(...)server.registerTool(...). After the change, localName = 'Server', 'Server' !== 'McpServer', the gate returns false, and the transform returns { changesCount: 0 } without touching any .tool()/.prompt()/.resource() calls. No diagnostic is emitted. No test in mcpServerApi.test.ts covers an aliased McpServer import, so the regression is uncaught. (For expressMiddleware, the gate has the same issue, though the body check at line 32 was already literal-name-based — so the net effect there is unchanged; the observable regression is in mcpServerApi.)

Step-by-step proof

Input:

import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js';
const s = new Server({ name: 'test', version: '1.0' });
s.tool('greet', async () => ({ content: [] }));
  1. mcpServerApiTransform.apply() runs; line 15 calls isImportedFromMcp(sf, 'McpServer').
  2. importUtils.ts:78 — the import's specifier passes isAnyMcpSpecifier.
  3. importUtils.ts:80 — the single named import has getAliasNode()?.getText() = 'Server', so localName = 'Server'.
  4. importUtils.ts:81 — 'Server' === 'McpServer'false. .some() returns false.
  5. mcpServerApi.ts:15-17 — gate fails → return { changesCount: 0, diagnostics: [] }.
  6. s.tool('greet', ...) is never migrated. After importPaths rewrites the import to @modelcontextprotocol/server, the user is left with a v2 import and an un-migrated v1 .tool() call that produces a TypeScript error — with zero codemod diagnostics pointing at it.

Impact

Aliasing McpServer (e.g., as Server to avoid the Mcp prefix, or to disambiguate from another Server class) is uncommon but realistic. The failure mode is safe — the source is left unchanged rather than corrupted, and TypeScript will flag the unmigrated .tool() calls — hence nit severity. But it's a regression from the immediately-preceding commit, it was explicitly predicted in the still-unresolved review comment, and the fix is small.

Fix

Do not change isImportedFromMcp to match either export-or-local name — that would re-introduce the handlerRegistration false positive (and break the new test 'does not rewrite local import when aliased MCP import has same export name'). Instead, give the file-level gates an export-name check:

// importUtils.ts
export function isMcpExportImported(sourceFile: SourceFile, exportName: string): boolean {
    return sourceFile.getImportDeclarations().some(imp =>
        isAnyMcpSpecifier(imp.getModuleSpecifierValue()) &&
        imp.getNamedImports().some(n => n.getName() === exportName)
    );
}

Use it at mcpServerApi.ts:15 and expressMiddleware.ts:12. (Or add a { matchExportName?: boolean } flag to the existing helper.) A test with import { McpServer as Server } + s.tool(...) → expect registerTool would lock this in.

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +106 to +135
const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined);
if (defaultImport || namespaceImport || hasAlias) {
let effectiveTarget = targetPackage;
if (mapping.symbolTargetOverrides && !namespaceImport && !defaultImport) {
const allOverridden = namedImports.length > 0 && namedImports.every(n => n.getName() in mapping.symbolTargetOverrides!);
if (allOverridden) {
effectiveTarget = mapping.symbolTargetOverrides[namedImports[0]!.getName()]!;
}
}
imp.setModuleSpecifier(effectiveTarget);
if (mapping.renamedSymbols) {
for (const n of namedImports) {
const newName = mapping.renamedSymbols[n.getName()];
if (newName) {
n.setName(newName);
}
}
if (namespaceImport) {
diagnostics.push(
warning(
filePath,
line,
`Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` +
`were renamed in ${effectiveTarget}. Update qualified accesses manually.`
)
);
}
}
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 any specifier in a named import is aliased, the in-place setModuleSpecifier branch (line 107) fires, and the allOverridden check at line 110 only redirects to the override package when every specifier is in symbolTargetOverrides — so a mixed aliased import like import { StreamableHTTPServerTransport as T, EventStore } from '.../server/streamableHttp.js' is rewritten to import { NodeStreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/server', which doesn't export NodeStreamableHTTPServerTransport (TS2305), with no diagnostic. The non-aliased path at lines 138-144 already routes per-symbol via mapping.symbolTargetOverrides?.[name] ?? targetPackage; consider falling through to it (it can carry aliases via addPending) or at minimum push a warning(...) when hasAlias && symbolTargetOverrides && !allOverridden. (rewriteExportDeclarations at line 222 has the same all-or-nothing gap for mixed re-exports.)

Extended reasoning...

What the bug is

The newly-added symbolTargetOverrides mechanism lets a single v1 module map different symbols to different v2 packages — currently used only for @modelcontextprotocol/sdk/server/streamableHttp.js, where StreamableHTTPServerTransport goes to @modelcontextprotocol/node while everything else (EventStore, StreamId, etc.) goes to @modelcontextprotocol/server. The non-aliased path at importPaths.ts:138-144 handles this correctly by routing each specifier individually:

const symbolTarget = mapping.symbolTargetOverrides?.[name] ?? targetPackage;
addPending(symbolTarget, [resolvedName], specifierTypeOnly);

But line 106 computes hasAlias = namedImports.some(n => n.getAliasNode() !== undefined), and if any specifier is aliased the whole declaration is sent into the in-place setModuleSpecifier branch (107-136). That branch can only set one module specifier, and the allOverridden guard at line 110 only redirects to the override package when every named import is a key in symbolTargetOverrides. For a mixed import, allOverridden is false, effectiveTarget stays at the base targetPackage (@modelcontextprotocol/server), and the renamed transport ends up imported from the wrong package.

Code path that triggers it

Input:

import { StreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

(Note that aliasing just one of the two specifiers is enough — .some() at line 106 fires on a single match.)

  1. mapping = { target: '@modelcontextprotocol/server', renamedSymbols: { StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' }, symbolTargetOverrides: { StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }.
  2. Lines 100-104: renameAllReferences(sourceFile, 'StreamableHTTPServerTransport', 'NodeStreamableHTTPServerTransport') runs (no-op here since the body uses the alias T).
  3. Line 106: hasAlias = true → enters the in-place branch.
  4. Line 110: namedImports.every(n => n.getName() in {StreamableHTTPServerTransport: ...})EventStore is not a key → allOverridden = false.
  5. effectiveTarget stays '@modelcontextprotocol/server'; line 115 imp.setModuleSpecifier('@modelcontextprotocol/server').
  6. Lines 117-121: the specifier StreamableHTTPServerTransport is renamed to NodeStreamableHTTPServerTransport (alias T preserved).

Output:

import { NodeStreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/server';

TS2305: Module '"@modelcontextprotocol/server"' has no exported member 'NodeStreamableHTTPServerTransport'. No diagnostic is emitted (the only warning(...) in this branch is gated on namespaceImport).

Why existing code/tests don't catch it

The two existing tests bracket this case without covering it: 'handles aliased renamedSymbols correctly' (importPaths.test.ts:165) tests a single aliased specifier (so allOverridden is true → routes to /node ✓), and 'splits streamableHttp import: transport to /node, types to /server' (importPaths.test.ts:177) tests a mixed but unaliased import (so hasAlias is false → per-symbol addPending path ✓). The aliased-and-mixed combination falls between them.

The same all-or-nothing logic appears in rewriteExportDeclarations (line 222-227) — export { StreamableHTTPServerTransport, EventStore } from '.../streamableHttp.js' becomes export { NodeStreamableHTTPServerTransport as StreamableHTTPServerTransport, EventStore } from '@modelcontextprotocol/server' with the same TS2305 — and in rewriteMockCall / rewriteDynamicImports (mockPaths.ts), though those cases are even rarer.

Impact

Nit severity: only one IMPORT_MAP entry has symbolTargetOverrides, importing the transport and EventStore/StreamId from streamableHttp.js in a single declaration with at least one alias is uncommon, and the resulting TS2305 is immediate and points directly at the offending specifier. The codemod is also documented as 80-90% coverage. But it's an internal inconsistency in newly-added logic — the per-symbol routing was clearly intended (it's implemented for the common case three lines below), and this branch silently produces broken output with no diagnostic.

Step-by-step proof

Input: import { StreamableHTTPServerTransport as Transport, EventStore as Store } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

  • importPaths.ts:106 — namedImports = [⟨StreamableHTTPServerTransport as Transport⟩, ⟨EventStore as Store⟩]; .some(n => n.getAliasNode())true.
  • :107 — defaultImport=undefined, namespaceImport=undefined, hasAlias=true → enter branch.
  • :109 — mapping.symbolTargetOverrides truthy, !namespaceImport && !defaultImport → enter.
  • :110 — namedImports.every(...): 'StreamableHTTPServerTransport' in overrides ✓, 'EventStore' in overrides ✗ → false.
  • :111-113 — skipped; effectiveTarget = '@modelcontextprotocol/server'.
  • :115 — imp.setModuleSpecifier('@modelcontextprotocol/server').
  • :117-121 — n.getName()='StreamableHTTPServerTransport'n.setName('NodeStreamableHTTPServerTransport'); n.getName()='EventStore' → no rename.
  • :123 — namespaceImport falsy → no warning.
  • :134-135 — changesCount++; continue.

Final: import { NodeStreamableHTTPServerTransport as Transport, EventStore as Store } from '@modelcontextprotocol/server';NodeStreamableHTTPServerTransport is exported from @modelcontextprotocol/node, not /server. diagnostics = [].

Fix

Either drop the hasAlias short-circuit and let aliased named-only imports fall through to the per-symbol addPending loop (extending PendingImport to carry an alias?: string so addOrMergeImport can pass { name, alias } specifiers — ts-morph's insertImportDeclaration already accepts that shape), or keep the branch but split the declaration in place when symbolTargetOverrides && !allOverridden: move the overridden specifiers to a new import for the override target and leave the rest on targetPackage. At minimum, push a warning(filePath, line, ...) for this case so it's not silent.

Comment on lines +121 to +129
function collectFactorySymbols(factoryArg: import('ts-morph').Node): string[] {
const symbols: string[] = [];
factoryArg.forEachDescendant(node => {
if (Node.isPropertyAssignment(node) || Node.isShorthandPropertyAssignment(node)) {
symbols.push(node.getName());
}
});
return symbols;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 collectFactorySymbols uses forEachDescendant to collect property names at any depth, so for the canonical class-mocking pattern vi.mock('.../server/streamableHttp.js', () => ({ StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({ handleRequest: vi.fn() })) })) the nested handleRequest key is collected, allOverridden (line 105) is false, and the mock specifier is rewritten to @modelcontextprotocol/server instead of @modelcontextprotocol/node — so the mock no longer intercepts the module the migrated code actually imports the transport from. Only inspect the top-level properties of the factory's returned object literal (find the return value and iterate .getProperties()); the same depth issue in renameSymbolsInFactory (lines 131-153) would also rename a nested { McpError: ... } instance-property key inside a .mockImplementation body.

Extended reasoning...

What the bug is

collectFactorySymbols (mockPaths.ts:121-129) does:

factoryArg.forEachDescendant(node => {
    if (Node.isPropertyAssignment(node) || Node.isShorthandPropertyAssignment(node)) {
        symbols.push(node.getName());
    }
});

forEachDescendant visits every PropertyAssignment/ShorthandPropertyAssignment at any nesting depth inside the factory argument — not just the top-level keys of the object the factory returns. The result feeds the symbolTargetOverrides heuristic at line 105: factorySymbols.every(s => s in resolved.symbolTargetOverrides!). That check is meant to ask "is every export this factory mocks one that moved to the override package?" — but because nested object-literal keys (instance methods, config objects, etc.) leak into the list, the .every() is false whenever the factory contains any nested object literal.

Code path that triggers it

The canonical vitest/jest pattern for mocking a class is:

vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
    StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({
        handleRequest: vi.fn(),
        sessionId: 'test-session'
    }))
}));

Walking through rewriteMockCall:

  1. resolveTarget(...){ target: '@modelcontextprotocol/server', renamedSymbols: { StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' }, symbolTargetOverrides: { StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }.
  2. Line 103-104: collectFactorySymbols(args[1]) walks the arrow function. forEachDescendant visits the outer StreamableHTTPServerTransport: ... assignment and the inner handleRequest: vi.fn() and sessionId: 'test-session' assignments → returns ['StreamableHTTPServerTransport', 'handleRequest', 'sessionId'].
  3. Line 105: ['StreamableHTTPServerTransport', 'handleRequest', 'sessionId'].every(s => s in { StreamableHTTPServerTransport: '@modelcontextprotocol/node' })'handleRequest' in {...} is falseallOverridden = false.
  4. effectiveTarget stays at resolved.target = '@modelcontextprotocol/server'.
  5. Line 110: firstArg.setLiteralValue('@modelcontextprotocol/server').
  6. renameSymbolsInFactory then renames the top-level key to NodeStreamableHTTPServerTransport.

Output:

vi.mock('@modelcontextprotocol/server', () => ({
    NodeStreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({ handleRequest: vi.fn(), sessionId: 'test-session' }))
}));

But the migrated production code now does import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node' (per importPathsTransform's symbolTargetOverrides handling). The mock intercepts @modelcontextprotocol/server, not @modelcontextprotocol/node, so vitest/jest loads the real transport — the test silently exercises real I/O (or throws on construction) with no codemod diagnostic explaining why.

Why existing code doesn't prevent it

The existing test 'renames symbols in vi.doMock factory for streamableHttp' (mockPaths.test.ts:31-41) uses a flat factory () => ({ StreamableHTTPServerTransport: mockTransport }) with no nested properties, so collectFactorySymbols returns exactly ['StreamableHTTPServerTransport'], allOverridden is true, and /node is correctly chosen. Nothing exercises a nested .mockImplementation(() => ({...})) body, even though that's the dominant real-world shape for mocking a class constructor.

The same forEachDescendant depth issue exists in renameSymbolsInFactory (lines 131-153): a nested { McpError: someValue } inside a .mockImplementation body — i.e., an instance property on the mock, not a module export — would be renamed to ProtocolError, silently changing the mock's instance shape. That collision is less likely than the handleRequest case but is the same root cause.

Impact

When triggered, the user's migrated test file mocks the wrong module: the mock factory provides NodeStreamableHTTPServerTransport on @modelcontextprotocol/server (which doesn't export it), while the code under test imports it from @modelcontextprotocol/node and gets the real class. The failure surfaces as a test failure the user has to debug manually, with no codemod diagnostic — exactly the silent-test-behavior-change a migration tool should avoid. Nit severity because: only one symbolTargetOverrides entry exists today, it only affects test files, and the user's own test suite will catch the broken mock (loudly, if not informatively). But vi.fn().mockImplementation(() => ({...methods})) is the most common shape for class mocks, so the heuristic is broken for the majority of real inputs it was added to handle.

Step-by-step proof

Input:

vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
    StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({ handleRequest: vi.fn() }))
}));
  • mockPaths.ts:80 — specifier = '@modelcontextprotocol/sdk/server/streamableHttp.js'; isSdkSpecifier → true.
  • mockPaths.ts:82 — resolveTarget returns { target: '@modelcontextprotocol/server', renamedSymbols: {...}, symbolTargetOverrides: { StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }.
  • mockPaths.ts:102-103 — resolved.symbolTargetOverrides truthy, args.length === 2 → call collectFactorySymbols(args[1]).
  • mockPaths.ts:123 — forEachDescendant on the arrow function visits, in order: the outer ObjectLiteralExpression's PropertyAssignment (StreamableHTTPServerTransport: ...) → push 'StreamableHTTPServerTransport'; then descends into vi.fn().mockImplementation(() => ({...})), reaches the inner ObjectLiteralExpression's PropertyAssignment (handleRequest: vi.fn()) → push 'handleRequest'. Returns ['StreamableHTTPServerTransport', 'handleRequest'].
  • mockPaths.ts:105 — .every(s => s in { StreamableHTTPServerTransport: '@modelcontextprotocol/node' }): 'StreamableHTTPServerTransport' ✓, 'handleRequest' ✗ → false.
  • mockPaths.ts:106 — skipped; effectiveTarget remains '@modelcontextprotocol/server'.
  • mockPaths.ts:110 — specifier rewritten to '@modelcontextprotocol/server'.
  • mockPaths.ts:115 — renameSymbolsInFactory renames the outer key to NodeStreamableHTTPServerTransport (and leaves handleRequest alone since it's not in allRenames).

Final: vi.mock('@modelcontextprotocol/server', () => ({ NodeStreamableHTTPServerTransport: ... })) — wrong target package.

Fix

Restrict collection to the top-level properties of the factory's returned object literal. Locate the returned expression (arrow body if a parenthesized object, or the ReturnStatement argument if a block body), guard with Node.isObjectLiteralExpression, and iterate .getProperties() filtered to PropertyAssignment | ShorthandPropertyAssignment:

function collectFactorySymbols(factoryArg: Node): string[] {
    let returned: Node | undefined;
    if (Node.isArrowFunction(factoryArg) || Node.isFunctionExpression(factoryArg)) {
        const body = factoryArg.getBody();
        returned = Node.isBlock(body)
            ? body.getStatements().find(Node.isReturnStatement)?.getExpression()
            : body;
        if (returned && Node.isParenthesizedExpression(returned)) returned = returned.getExpression();
    }
    if (!returned || !Node.isObjectLiteralExpression(returned)) return [];
    return returned
        .getProperties()
        .filter(p => Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p))
        .map(p => (p as any).getName());
}

Apply the same top-level-only restriction to renameSymbolsInFactory (or have it operate on the same returned.getProperties() list) so nested instance-property keys aren't renamed. Add a test with vi.fn().mockImplementation(() => ({ handleRequest: vi.fn() })) asserting the specifier becomes '@modelcontextprotocol/node'.

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +73 to +76
if (result.filesChanged === 0 && result.diagnostics.length === 0) {
console.log('No changes needed — code already migrated or no SDK imports found.\n');
return;
}
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 early-return condition checks filesChanged === 0 && diagnostics.length === 0 but not result.packageJsonChanges — yet updatePackageJson() runs unconditionally inside run() (runner.ts:111) and writes to disk whenever @modelcontextprotocol/sdk is in package.json, regardless of whether any source files changed. So if a user points the codemod at already-migrated source (or a subdir with no SDK imports) but findPackageJson() walks up to a package.json that still lists the v1 SDK, the dependency is silently removed on disk while the CLI prints "No changes needed" and never reaches the package.json updated: block at line 118. Add && !result.packageJsonChanges to the condition.

Extended reasoning...

What the bug is

The CLI's "nothing to do" early-return at cli.ts:73-76 checks only two conditions:

if (result.filesChanged === 0 && result.diagnostics.length === 0) {
    console.log('No changes needed — code already migrated or no SDK imports found.\n');
    return;
}

It does not check result.packageJsonChanges. But updatePackageJson() is called unconditionally inside run() (runner.ts:111) before the runner returns, and it performs writeFileSync() on disk (packageJsonUpdater.ts:62) whenever (a) findPackageJson() locates a package.json and (b) that package.json contains @modelcontextprotocol/sdk in dependencies or devDependencies — independent of whether any source transforms fired. So the CLI can write to disk and then tell the user nothing happened.

Code path that triggers it

updatePackageJson() (packageJsonUpdater.ts:15-69) early-returns undefined only when no package.json is found or the v1 SDK isn't in it. Otherwise it always:

  1. Removes @modelcontextprotocol/sdk from deps/devDeps (line 53-54).
  2. Adds whatever is in usedPackages (which is empty if no source imports were rewritten — allUsedPackages is only populated by transform results).
  3. Calls writeFileSync(pkgJsonPath, output) if !dryRun (line 62).
  4. Returns { removed: ['@modelcontextprotocol/sdk'], added: [], packageJsonPath }.

This is called at runner.ts:111 with no gating other than dryRun. The result is threaded through to result.packageJsonChanges, but the CLI early-return at line 73 doesn't look at it, so control never reaches the if (result.packageJsonChanges) reporting block at lines 118-132.

Why nothing else prevents it

The triggers are realistic:

  • Second run / idempotency check: user runs the codemod twice. On the second run, all source files are already v2 (so filesChanged = 0, diagnostics = []), but if package.json was reverted or partially staged, the v1 SDK is still listed → silently removed, CLI says "No changes needed".
  • Subdirectory target: user runs mcp-codemod v1-to-v2 ./scripts where ./scripts contains no SDK imports. findPackageJson() walks up from the target dir (projectAnalyzer.ts:8-17) and finds the project-root package.json with @modelcontextprotocol/sdk → removes it (adds nothing, since allUsedPackages is empty), CLI says "No changes needed".
  • --transforms filter: user runs with e.g. --transforms context (which doesn't populate usedPackages) on already-migrated context code → same outcome.

The dedicated unit test 'removes v1 SDK even when no v2 packages are detected' (packageJsonUpdater.test.ts:202-217) confirms that updatePackageJson(dir, new Set(), false) writes to disk and returns a non-undefined result with added: [] — exactly the state that the CLI early-return then hides.

Step-by-step proof

Project layout:

my-project/
  package.json   { "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" } }
  scripts/
    build.ts     // no SDK imports

User runs mcp-codemod v1-to-v2 ./scripts:

  1. cli.ts:65 — run(migration, { targetDir: '/my-project/scripts', ... }).
  2. runner.ts:52 — glob finds scripts/build.ts; it has no SDK imports, so every transform returns { changesCount: 0, diagnostics: [] }. filesChanged = 0, allDiagnostics = [], allUsedPackages = ∅.
  3. runner.ts:111 — updatePackageJson('/my-project/scripts', ∅, false):
    • findPackageJson('/my-project/scripts') walks up to /my-project/package.json.
    • inDeps = true (v1 SDK present), so it does not early-return.
    • packagesToAdd = [] (empty set, nothing in V2_PACKAGE_VERSIONS).
    • delete deps['@modelcontextprotocol/sdk']; removed = ['@modelcontextprotocol/sdk'].
    • !dryRunwriteFileSync('/my-project/package.json', ...) — disk mutated.
    • Returns { added: [], removed: ['@modelcontextprotocol/sdk'], packageJsonPath: '...' }.
  4. run() returns { filesChanged: 0, totalChanges: 0, diagnostics: [], packageJsonChanges: {...} }.
  5. cli.ts:73 — 0 === 0 && 0 === 0true. Prints No changes needed — code already migrated or no SDK imports found. and returns.
  6. The package.json updated: Removed: @modelcontextprotocol/sdk block at lines 118-132 is never reached.

Net effect: package.json was modified on disk; the CLI explicitly told the user nothing was modified.

Impact

Nit severity. The mutation itself is benign (removing a dependency the user is migrating away from), git-visible, and easily reverted. The trigger is narrow (no source-file changes + stale v1 dep in package.json). But it's a clear correctness bug in user-facing output — a CLI that performs a disk write while reporting "No changes needed" is exactly the kind of contradiction that erodes trust in a migration tool, especially given the PR's emphasis on dry-run safety and explicit package.json reporting.

Fix

One-clause addition to the early-return condition:

if (result.filesChanged === 0 && result.diagnostics.length === 0 && !result.packageJsonChanges) {
    console.log('No changes needed — code already migrated or no SDK imports found.\n');
    return;
}

With this, control falls through to the existing if (result.packageJsonChanges) block at line 118, which already handles the added.length > 0 / removed.length > 0 cases correctly (and the "Run your package manager" hint at line 138).

Comment on lines +3 to +12
export type {
Diagnostic,
FileResult,
Migration,
RunnerOptions,
RunnerResult,
Transform,
TransformContext,
TransformResult
} from './types.js';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 RunnerResult is exported and has a packageJsonChanges?: PackageJsonChange field, but PackageJsonChange itself is missing from this type re-export list — every other type reachable from RunnerResult (Diagnostic, FileResult) is exported. Consumers can work around it via NonNullable<RunnerResult['packageJsonChanges']>, but for consistency add PackageJsonChange here.

Extended reasoning...

What the gap is

packages/codemod/src/index.ts is the package's sole public entrypoint (per package.json exports: { '.': ... }). It re-exports RunnerResult from ./types.js, and RunnerResult (types.ts:58-64) has a packageJsonChanges?: PackageJsonChange field. But PackageJsonChange is not in the export type { ... } list at index.ts:3-12 — every other type referenced by RunnerResult's fields (Diagnostic, FileResult) is.

Why it's an oversight, not a design choice

The packageJsonChanges feature was added in a later commit on this branch (2e349dd add package.json transform), after the initial index.ts was written. The type list at index.ts:3-12 was clearly intended to expose every type a programmatic-API consumer might need to annotate — it includes Diagnostic, FileResult, TransformResult, TransformContext, etc. — and PackageJsonChange is the one constituent of RunnerResult that was missed when the new field was added.

Why nothing else exposes it

package.json only exports '.'dist/index.mjs, so consumers cannot deep-import @modelcontextprotocol/codemod/types.js to get the type directly. The .d.mts bundler will include the interface structurally (so result.packageJsonChanges.added etc. type-check fine), but there's no named export, so import type { PackageJsonChange } from '@modelcontextprotocol/codemod' fails with TS2305: Module has no exported member 'PackageJsonChange'.

Step-by-step proof

A user following the PR description's Programmatic API example wants to write a helper:

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

function reportPkgChanges(changes: PackageJsonChange): void {
  console.log(`Removed: ${changes.removed.join(', ')}`);
  console.log(`Added:   ${changes.added.join(', ')}`);
}

const result = run(getMigration('v1-to-v2')!, { targetDir: './src' });
if (result.packageJsonChanges) reportPkgChanges(result.packageJsonChanges);
  1. TypeScript resolves @modelcontextprotocol/codemoddist/index.d.mts.
  2. index.d.mts re-exports RunnerResult, Diagnostic, FileResult, etc., but not PackageJsonChange.
  3. import type { PackageJsonChange }TS2305.
  4. Workaround: type PackageJsonChange = NonNullable<RunnerResult['packageJsonChanges']> — works, but is exactly the boilerplate the explicit export list is meant to avoid.

Impact

Nothing is broken: the type is structurally reachable via indexed access, no runtime behavior is affected, and there are no existing consumers (new package). This is purely an API-surface ergonomics/consistency gap — hence nit.

Fix

One word:

export type {
    Diagnostic,
    FileResult,
    Migration,
    PackageJsonChange,
    RunnerOptions,
    RunnerResult,
    Transform,
    TransformContext,
    TransformResult
} from './types.js';

);
}

const packageJsonChanges = updatePackageJson(options.targetDir, allUsedPackages, options.dryRun ?? false);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 updatePackageJson() is called unconditionally after the transform loop, but only the imports/mock-paths transforms populate allUsedPackages — so mcp-codemod v1-to-v2 ./src --transforms symbols (a documented selective-execution path) leaves all v1 @modelcontextprotocol/sdk/... import paths in source while still deleting @modelcontextprotocol/sdk from package.json, breaking the build. Consider gating the package.json update on 'imports' being in enabledTransforms (or skipping when allUsedPackages.size === 0).

Extended reasoning...

What the bug is

runner.ts:111 calls updatePackageJson(options.targetDir, allUsedPackages, options.dryRun ?? false) unconditionally after the per-file transform loop, with no check on which transforms were actually enabled. Of the nine transforms, only importPathsTransform and mockPathsTransform return a usedPackages set in their TransformResult (types.ts: usedPackages?: Set<string>); symbolRenames, removedApis, mcpServerApi, handlerRegistration, schemaParamRemoval, expressMiddleware, and contextTypes return only { changesCount, diagnostics }. So if --transforms excludes imports and mock-paths, allUsedPackages stays empty.

updatePackageJson() (packageJsonUpdater.ts:30-55) doesn't treat an empty set as a no-op. Its only short-circuit is "if v1 SDK is not in deps/devDeps, return undefined". When @modelcontextprotocol/sdk is present, it proceeds to if (inDeps) delete deps![V1_PACKAGE] regardless of whether any v2 packages were detected, then writes the file. The unit test 'removes v1 SDK even when no v2 packages are detected' (packageJsonUpdater.test.ts) explicitly locks in this behavior with updatePackageJson(dir, new Set(), false).

Code path that triggers it

The --transforms <ids> flag is a documented selective-execution feature (PR description CLI Usage: mcp-codemod v1-to-v2 ./src --transforms imports,symbols,context), and cli.test.ts exercises run(migration, { targetDir: dir, transforms: ['symbols'] }). With that invocation:

  1. runner.ts:21enabledTransforms = [symbolRenamesTransform].
  2. Per-file loop runs only symbolRenamesTransform, which returns { changesCount, diagnostics } with no usedPackages field. The if (result.usedPackages) block at runner.ts:75-79 never executes.
  3. After the loop, allUsedPackages is an empty Set.
  4. runner.ts:111updatePackageJson(targetDir, new Set(), false).
  5. findPackageJson walks up from ./src and finds package.json containing "@modelcontextprotocol/sdk": "^1.0.0".
  6. inDeps is true → packagesToAdd = [] (empty set filtered) → added = []delete deps['@modelcontextprotocol/sdk'] → file written.

Why existing code doesn't prevent it

There is no coupling between the package.json mutation and which transforms ran. The PR description's Design Decision #6 says "As transforms rewrite imports, they track which v2 packages are targeted" — i.e., the package.json update is conceptually downstream of import rewriting — but the runner doesn't gate it on imports being enabled. There's also no --no-package-json opt-out flag. The only existing test that passes transforms: ['symbols'] (cli.test.ts: "accepts valid transform IDs") doesn't write a package.json, so findPackageJson returns undefined (the temp dir is fresh and the .git boundary stops the upward walk in the other tests, but in this test there's simply no package.json) and the bug isn't observed.

Step-by-step proof

Project layout:

my-app/
├── package.json   { "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" } }
└── src/server.ts  import { McpError } from '@modelcontextprotocol/sdk/types.js'; throw new McpError(1, 'e');

User runs: mcp-codemod v1-to-v2 ./src --transforms symbols

  1. analyzeProject('./src')findPackageJson walks up from ./src, finds my-app/package.json.
  2. enabledTransforms = [symbolRenamesTransform] (validation passes, 'symbols' is a valid ID).
  3. symbolRenamesTransform finds McpError in the SDK import, renames it to ProtocolError, returns { changesCount: 1, diagnostics: [] }no usedPackages.
  4. allUsedPackages = Set {}.
  5. updatePackageJson('./src', Set {}, false): findPackageJsonmy-app/package.json; inDeps = true; packagesToAdd = [].filter(...) = []; delete deps['@modelcontextprotocol/sdk']; write file.
  6. CLI prints package.json updated: Removed: @modelcontextprotocol/sdk (with no Added: line).

Result on disk:

// src/server.ts — import path NOT rewritten (imports transform didn't run)
import { ProtocolError } from '@modelcontextprotocol/sdk/types.js';
throw new ProtocolError(1, 'e');
// package.json — dependency removed
{ "dependencies": {} }

tsc / npm install now fails: Cannot find module '@modelcontextprotocol/sdk/types.js'.

Impact

This is a destructive side-effect orthogonal to what the user asked for: they requested only symbol renames, but the tool also deleted a dependency the source code still requires. It contradicts the PR's "conservative by default" claim. Mitigating factors: --transforms without imports is an advanced/niche path; the CLI does print Removed: @modelcontextprotocol/sdk so it's not silent; the breakage surfaces immediately on the next typecheck/install; and git checkout package.json is a trivial recovery. Hence nit rather than blocking — but it's a real correctness gap in newly-added orchestration logic.

Fix

Gate the call on the imports transform being enabled (since that's the transform that semantically "owns" the v1→v2 dependency swap):

const importsEnabled = enabledTransforms.some(t => t.id === 'imports');
const packageJsonChanges = importsEnabled
    ? updatePackageJson(options.targetDir, allUsedPackages, options.dryRun ?? false)
    : undefined;

Alternatively, have updatePackageJson() early-return when usedPackages.size === 0 — though note that would also skip removal in the (legitimate) case where every v1 import maps to a removed/private path and the user did run imports, so gating on the transform ID is cleaner.

Comment on lines +38 to +40
if (sdkImports.length === 0) {
return { changesCount, diagnostics };
}
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 early return when sdkImports.length === 0 omits usedPackages from the returned TransformResult, even though rewriteExportDeclarations() on line 36 just populated it via usedPackages.add(targetPackage). For a pure barrel file (e.g., export { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' with no SDK imports), the v2 packages it re-exports from are tracked locally but never returned to the runner, so updatePackageJson() won't add them. The final return at line 175 includes usedPackages, so this is just a missed field on the early-return path — add , usedPackages here.

Extended reasoning...

What the bug is

importPathsTransform.apply() creates usedPackages = new Set<string>() at line 25, then calls rewriteExportDeclarations(sdkExports, ..., usedPackages) at line 36, which mutates the set via usedPackages.add(targetPackage) (line ~232) for each rewritten re-export. Immediately afterward, line 38-40 does:

if (sdkImports.length === 0) {
    return { changesCount, diagnostics };
}

— omitting usedPackages. TransformResult.usedPackages is optional (Set<string> | undefined), so this typechecks. The final return at line 175, by contrast, correctly returns { changesCount, diagnostics, usedPackages }, confirming this is an oversight in the early-return path rather than intentional.

Code path that triggers it

runner.ts:75-79 reads each transform result with:

if (result.usedPackages) {
    for (const pkg of result.usedPackages) {
        allUsedPackages.add(pkg);
    }
}

When result.usedPackages is undefined, the packages are silently skipped. allUsedPackages is then passed to updatePackageJson(options.targetDir, allUsedPackages, ...), which determines which v2 packages get added to package.json.

Why nothing else prevents it

The only other place usedPackages is populated in this transform is the imports loop (lines 116, 144), which is unreachable when sdkImports.length === 0. mockPathsTransform also tracks usedPackages but only for vi.mock/jest.mock/dynamic-import() calls — not static export ... from declarations. So for a file containing only export { X } from '@modelcontextprotocol/sdk/...', no transform reports the target package back to the runner.

Step-by-step proof

Consider a project that centralizes all SDK access through a barrel file src/mcp.ts:

export { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
export { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

…and all other files in the project do import { McpServer } from './mcp.js' (no direct SDK imports).

  1. importPathsTransform.apply() on src/mcp.ts: sdkImports = [], sdkExports = [<2 decls>].
  2. Line 36: rewriteExportDeclarations rewrites both specifiers to '@modelcontextprotocol/server' and calls usedPackages.add('@modelcontextprotocol/server') twice. usedPackages = {'@modelcontextprotocol/server'}, changesCount = 2.
  3. Line 38: sdkImports.length === 0 → returns { changesCount: 2, diagnostics: [] }. usedPackages is dropped.
  4. runner.ts:75: result.usedPackages is undefined → loop skipped. allUsedPackages stays empty for this file.
  5. No other file has SDK imports (they all go through the barrel), so after the project loop allUsedPackages is still empty.
  6. updatePackageJson(dir, new Set(), false) removes @modelcontextprotocol/sdk from dependencies and adds nothing (the test 'removes v1 SDK even when no v2 packages are detected' confirms this behavior).
  7. Result: the rewritten barrel now does export { McpServer } from '@modelcontextprotocol/server', but package.json no longer has any MCP dependency. The user's next npm install / build fails with a missing-module error.

Impact

Bounded — hence nit. allUsedPackages is accumulated project-wide, so the gap only manifests when no other file in the project directly imports from the same v2 package (otherwise that file's importPathsTransform run would track it). That's uncommon but realistic for projects that deliberately funnel all SDK access through a single barrel/wrapper module. The resulting missing-dependency error is loud and easy to diagnose, and the fix is a single-word addition.

Fix

if (sdkImports.length === 0) {
    return { changesCount, diagnostics, usedPackages };
}

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