Skip to content

feat: add v2 codemod draft#1950

Merged
felixweinberger merged 49 commits into
mainfrom
feature/v2-codemode-draft
May 21, 2026
Merged

feat: add v2 codemod draft#1950
felixweinberger merged 49 commits into
mainfrom
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: 2cfc8e5

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: 2cfc8e5

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 Outdated
@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
@mattzcarey
Copy link
Copy Markdown
Contributor

Codemod follow-up found while migrating Cloudflare MCP: dynamic Zod-field-map input schemas are not wrapped.\n\nRepro shape:\n\nts\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\n\nfunction buildInputSchema(): Record<string, z.ZodType<unknown>> {\n return {\n code: z.string().describe('JavaScript async arrow function to execute')\n };\n}\n\nconst server = new McpServer({ name: 'test', version: '1.0.0' });\nconst inputSchema = buildInputSchema();\n\nserver.registerTool('execute', { description: 'Execute code', inputSchema }, async (params) => {\n return { content: [{ type: 'text', text: String(params.code) }] };\n});\n\n\nAfter running the current v1-to-v2 codemod, inline literals get migrated, but the dynamic property remains effectively as:\n\nts\nserver.registerTool('execute', { description: 'Execute code', inputSchema }, async (params) => {\n // ...\n});\n\n\nThat then fails typecheck on SDK v2 because inputSchema is still a map of Zod fields, not a Standard Schema:\n\ntxt\nProperty ''~standard'' is missing in type 'Record<string, ZodType<unknown, unknown, ...>>' but required in type 'StandardSchemaWithJSON<unknown, unknown>'.\nParameter 'params' implicitly has an 'any' type.\n\n\nManual fix applied in Cloudflare MCP:\n\nts\nserver.registerTool('execute', { description: 'Execute code', inputSchema: z.object(inputSchema) }, async (params) => {\n // ...\n});\n\n\nSuggested codemod behavior: when a registerTool options object contains an inputSchema shorthand/property whose value is a variable typed or inferred as a Zod field map, wrap it as z.object(inputSchema). This should not affect cases where the value is already a Standard Schema or an existing z.object(...).

@KKonstantinov KKonstantinov marked this pull request as ready for review May 21, 2026 05:10
@KKonstantinov KKonstantinov requested a review from a team as a code owner May 21, 2026 05:10
@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/contextTypes.ts Outdated
Comment thread packages/codemod/src/bin/batchTest.ts Outdated
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts
Comment thread packages/codemod/src/runner.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts Outdated
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts
Comment on lines +100 to +108
// Collect identifiers that are actual references to the `extra` parameter
const identifiers: import('ts-morph').Node[] = [];
body.forEachDescendant(node => {
if (!Node.isIdentifier(node) || node.getText() !== EXTRA_PARAM_NAME) return;
const parent = node.getParent();
// Skip property-name positions (e.g., meta.extra, { extra: value })
if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return;
if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return;
identifiers.push(node);
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 identifier-collection skip list in processCallback covers PropertyAccessExpression name-nodes and PropertyAssignment name-nodes, but misses ShorthandPropertyAssignment and BindingElement propertyName — both of which renameAllReferences in astUtils.ts already handles. A handler body like helper({ request, extra }) is rewritten to { request, ctx } (silently changing the object key) instead of { request, extra: ctx }, and const { extra: x } = unrelatedObj becomes const { ctx: x } = unrelatedObj (renaming a property-lookup key on an unrelated object). Add the same two guards already in astUtils.ts.

Extended reasoning...

What the bug is

processCallback in contextTypes.ts collects identifiers that reference the extra parameter via an AST walk (introduced in the latest commit, replacing the earlier regex-based replaceAll per review comment 3278820013). The skip list guarding which identifiers are not references to the parameter is:

if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return;
if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return;
identifiers.push(node);

That misses two parent kinds where the identifier is a key, not a value reference:

  1. ShorthandPropertyAssignment — in { extra }, the single identifier is simultaneously the object key and the value reference. Node.isPropertyAssignment does not match a shorthand (they are distinct SyntaxKinds), so the identifier is collected.
  2. BindingElement propertyName — in const { extra: x } = obj, the extra identifier is a property-lookup key on obj, not a reference to the callback parameter.

Both fall through to the unconditional fallback replacements.push({ node: id, newText: CTX_PARAM_NAME }) and get a plain text rename.

Code path that triggers it

For a handler body like await helper({ request, extra });:

  1. body.forEachDescendant visits the extra identifier inside { request, extra }. Its parent is a ShorthandPropertyAssignment.
  2. Neither skip guard matches — Node.isPropertyAccessExpression(parent) and Node.isPropertyAssignment(parent) are both false for that kind.
  3. The identifier is pushed into identifiers.
  4. In the replacements loop, the parent is not a PropertyAccessExpression (value position) or a QualifiedName, so it falls through to the plain rename.
  5. node.replaceWithText('ctx') produces await helper({ request, ctx });.

The object key is now ctx instead of extra — any consumer reading .extra gets undefined. The correct rewrite is { request, extra: ctx }, exactly what renameAllReferences (astUtils.ts:27-29) does for the identical case.

For const { extra: x } = unrelatedObj, extra is the property-name node of a BindingElement. It is not skipped, so it's renamed to ctx, and the destructuring now reads unrelatedObj.ctx instead of unrelatedObj.extra — a silent semantic change to code that has nothing to do with the context parameter.

Why nothing prevents it

renameAllReferences in astUtils.ts was hardened across multiple review rounds (comments 3133560060, 3136237226, 3138328884) and now has explicit guards for ShorthandPropertyAssignment (line 27: expand to oldName: newName) and BindingElement propertyName (line 23: skip). The new identifier-collection loop in processCallback re-implements its own parent-kind guard list rather than reusing that one, and only carries over two of the established guards. contextTypes.test.ts does cover meta.extra (the PropertyAccessExpression name case) and the { extra: value } property-assignment case implicitly, but has no fixture exercising shorthand { extra } in a call argument or const { extra: ... } = unrelated.

Impact

Both produce silently-wrong output that may still compile (especially against a loosely-typed helper or destructure target) — the worst codemod failure mode. The trigger pattern (passing extra into a shared helper via shorthand object literal, or destructuring an extra property off an unrelated object inside a handler body) is realistic but not the dominant pattern, hence nit severity.

Step-by-step proof

Input:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
server.setRequestHandler('tools/call', async (request, extra) => {
    await logToService({ request, extra });
    return { content: [] };
});
  1. processCallback finds the second param named extra, no collisions with ctx → proceeds.
  2. paramDecl.replaceWithText('ctx') renames the parameter declaration.
  3. body.forEachDescendant visits the extra identifier inside { request, extra }. Parent is ShorthandPropertyAssignment.
  4. Node.isPropertyAccessExpression(parent) → false. Node.isPropertyAssignment(parent) → false (distinct kind). → identifiers.push(node).
  5. Replacements loop: parent is not PropertyAccessExpression value position, not QualifiedNamereplacements.push({ node: id, newText: 'ctx' }).
  6. Output: await logToService({ request, ctx }); — object key changed from extra to ctx.

Fix

Add the same two guards already present in renameAllReferences (astUtils.ts):

if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return;
if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return;
if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === node) return;
if (parent && Node.isShorthandPropertyAssignment(parent)) {
    // collect, but mark for shorthand expansion
}

The simplest implementation: skip BindingElement propertyName entirely, and handle ShorthandPropertyAssignment in the replacements loop by emitting replacements.push({ node: parent, newText: \${EXTRA_PARAM_NAME}: ${CTX_PARAM_NAME}` })— the same expansionastUtils.tsandrenameSymbolsInFactory(mockPaths.ts) already use. A test withhelper({ request, extra })asserting{ request, extra: ctx }and one withconst { extra: x } = obj` asserting it is left untouched would lock both in.

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 BindingElement propertyName guard at line 105 is correct (const { extra: x } = unrelatedObj is a property lookup on an unrelated object, so skipping is right). But the ShorthandPropertyAssignment guard at line 104 just returns, dropping the identifier — while line 91 has already renamed the parameter to ctx. For:

async (request, extra) => {
    helper({ request, extra });
}

the output is now:

async (request, ctx) => {
    helper({ request, extra });   // TS2304: Cannot find name 'extra'
}

The new test 'does not rename "extra" in shorthand property assignment' asserts toContain('{ request, extra }') — which locks in this broken output, since extra is no longer in scope.

In a shorthand the identifier is both the object key (which must stay extra) and a value reference to the parameter (which is now ctx). The original comment's recommended fix was to expand it — same as renameAllReferences (astUtils.ts:27-29) and renameSymbolsInFactory (mockPaths.ts) already do:

if (parent && Node.isShorthandPropertyAssignment(parent)) {
    replacements.push({ node: parent, newText: `${EXTRA_PARAM_NAME}: ${CTX_PARAM_NAME}` });
    return;  // collected as a replacement, skip the default identifier handling
}

(or push the parent into a separate list and emit { extra: ctx } in the replacements loop). Then update the test to assert toContain('{ request, extra: ctx }').

@KKonstantinov KKonstantinov marked this pull request as draft May 21, 2026 06:08
@KKonstantinov KKonstantinov marked this pull request as ready for review May 21, 2026 06:09
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/utils/importUtils.ts
Comment on lines +84 to +103
changesCount++;
});

if (changesCount > 0) {
const errorCodeImportDecl = errorCodeImport.getImportDeclaration();
errorCodeImport.remove();
if (
errorCodeImportDecl.getNamedImports().length === 0 &&
!errorCodeImportDecl.getDefaultImport() &&
!errorCodeImportDecl.getNamespaceImport()
) {
errorCodeImportDecl.remove();
}

const imp =
sourceFile.getImportDeclarations().find(i => {
const spec = i.getModuleSpecifierValue();
return (spec === '@modelcontextprotocol/client' || spec === '@modelcontextprotocol/server') && !i.isTypeOnly();
}) ??
sourceFile.getImportDeclarations().find(i => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Two related gaps in handleErrorCodeSplit: (1) the forEachDescendant walk only rewrites PropertyAccessExpression sites (ErrorCode.X), so type annotations (const code: ErrorCode = …), bare value references (Object.values(ErrorCode)), keyof typeof ErrorCode, and QualifiedName type members survive un-rewritten while the import is removed → guaranteed TS2304; and (2) targetModule is looked up after errorCodeImportDecl.remove(), so when ErrorCode was the only MCP named import in a client-only file the fallback hard-codes @modelcontextprotocol/server (a package the package.json updater never adds for that project). Capture errorCodeImportDecl.getModuleSpecifierValue() before .remove() (matching handleStreamableHTTPError's resolveTargetModule pattern in removedApis.ts), and either also rewrite type-position/bare references or skip removing the import when non-PropertyAccess references remain.

Extended reasoning...

What the bugs are

handleErrorCodeSplit (symbolRenames.ts:46–123) splits ErrorCode.X accesses into ProtocolErrorCode.X / SdkErrorCode.X, then removes the ErrorCode import and adds an import for whichever new symbol(s) were used. Two gaps:

(1) Only PropertyAccessExpression sites are rewritten

The forEachDescendant walk at lines 71–85 has a single guard:

sourceFile.forEachDescendant(node => {
    if (!Node.isPropertyAccessExpression(node)) return;
    const expr = node.getExpression();
    if (!Node.isIdentifier(expr) || expr.getText() !== errorCodeLocalName) return;
    // … rewrite expr to ProtocolErrorCode / SdkErrorCode
});

Any reference to the local ErrorCode binding that is not the object of a property access is never visited:

  • type annotation const code: ErrorCode = ErrorCode.InvalidParams; — the type-position identifier's parent is a TypeReference, not a PAE
  • type-position member let x: ErrorCode.RequestTimeout — parent is a QualifiedName
  • bare value position Object.values(ErrorCode) / const All = ErrorCode — the identifier is a CallExpression argument or initializer, not a PAE object
  • type Codes = keyof typeof ErrorCode — parent is a TypeQuery

Because changesCount > 0 is satisfied by any rewritten PAE in the file, line 88 then unconditionally removes the ErrorCode specifier — leaving these un-rewritten references dangling. In v1, ErrorCode is a TypeScript enum used as both a value and a type, so function handle(code: ErrorCode) { return code === ErrorCode.X; } is canonical, common v1 code.

(2) targetModule is resolved after the import is removed

const errorCodeImportDecl = errorCodeImport.getImportDeclaration();
errorCodeImport.remove();
if (errorCodeImportDecl.getNamedImports().length === 0 && ) {
    errorCodeImportDecl.remove();
}

const imp = sourceFile.getImportDeclarations().find(i => {
    const spec = i.getModuleSpecifierValue();
    return spec === '@modelcontextprotocol/client' || spec === '@modelcontextprotocol/server' 
}) ?? ;
const targetModule = imp?.getModuleSpecifierValue() ?? '@modelcontextprotocol/server';

When ErrorCode was the only named import in its declaration and no other exact-match @modelcontextprotocol/client or /server import exists, the declaration is gone before the find() runs, and the ?? '@modelcontextprotocol/server' fallback fires — even when the (already-rewritten by importPaths) original specifier was @modelcontextprotocol/client. The new import { ProtocolErrorCode } from '@modelcontextprotocol/server' is added, but symbolRenames does not report usedPackages, so packageJsonUpdater only installs @modelcontextprotocol/client (tracked by importPaths) → missing-module error.

Why existing code doesn't prevent it

  • For (1), nothing later in the pipeline rewrites bare/type-position ErrorCode references — removedApis, mcpServerApi, and contextTypes don't touch it. The only diagnostic emitted is the generic "ErrorCode split into ProtocolErrorCode and SdkErrorCode. Verify the migration is correct.", which doesn't mention surviving references.
  • For (2), handleStreamableHTTPError in removedApis.ts faces the same problem and solves it correctly: it captures const moduleSpec = foundImportDecl.getModuleSpecifierValue() before .remove() and passes it to resolveTargetModule(sourceFile, originalModule), which falls back to originalModule.includes('/client') ? '@modelcontextprotocol/client' : '@modelcontextprotocol/server'. handleErrorCodeSplit was clearly meant to mirror this and missed the capture.

Step-by-step proof

(1) — type annotation survives, import removed

Input:

import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
const code: ErrorCode = ErrorCode.InvalidParams;
  1. After importPaths, the import is from '@modelcontextprotocol/server' (or /client).
  2. handleErrorCodeSplit: forEachDescendant visits the ErrorCode identifier in ErrorCode.InvalidParams (PAE object) → rewrites to ProtocolErrorCode. The type-position ErrorCode in : ErrorCode has a TypeReference parent → never visited.
  3. changesCount === 1errorCodeImport.remove().

Output:

import { ProtocolErrorCode } from '@modelcontextprotocol/server';
const code: ErrorCode = ProtocolErrorCode.InvalidParams;   // TS2304: Cannot find name 'ErrorCode'

(2) — wrong fallback module

A client-only file whose only MCP import is import { ErrorCode } from '@modelcontextprotocol/sdk/types.js':

  1. importPaths rewrites it to from '@modelcontextprotocol/client'.
  2. handleErrorCodeSplit rewrites the body, then errorCodeImport.remove() empties the declaration → errorCodeImportDecl.remove().
  3. The find() over getImportDeclarations() now returns undefined (no exact-match /client or /server import remains).
  4. targetModule = '@modelcontextprotocol/server'. New import added.
  5. packageJsonUpdater adds only @modelcontextprotocol/client (the package importPaths reported) → Cannot find module '@modelcontextprotocol/server'.

A second realistic trigger: a file with import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' plus import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'. After importPaths, the stdio import becomes @modelcontextprotocol/client/stdio (subpath, no exact match), and ErrorCode becomes its own @modelcontextprotocol/client declaration. handleErrorCodeSplit removes the now-empty /client declaration; only the /stdio subpath remains; the exact-match find() returns undefined; fallback to /server.

Impact

Both produce non-compiling output, but the failure is loud — a TS2304 at the dangling reference for (1), and a missing-module error at install/typecheck for (2). The codemod is documented as 80–90% coverage and emits a generic Verify the migration warning. Triggers are realistic but not the dominant pattern (the most common form, ErrorCode.X-only usage in a server file, works correctly). Filing as nit.

Fix

Capture the specifier before removal and use it as the fallback, mirroring handleStreamableHTTPError/resolveTargetModule:

const errorCodeImportDecl = errorCodeImport.getImportDeclaration();
const originalSpec = errorCodeImportDecl.getModuleSpecifierValue();
errorCodeImport.remove();
// … cleanup empty decl …
const imp = sourceFile.getImportDeclarations().find(/* … */);
const targetModule =
    imp?.getModuleSpecifierValue() ??
    (originalSpec.includes('/client') ? '@modelcontextprotocol/client' : '@modelcontextprotocol/server');

For the parent-kind gap, either also rewrite TypeReference / QualifiedName / TypeQuery / bare Identifier references (mapping the type to ProtocolErrorCode | SdkErrorCode where ambiguous), or — simpler — skip removing the ErrorCode import when any non-PropertyAccessExpression reference to the local binding remains, and emit a more specific warning naming the surviving sites. Tests covering const code: ErrorCode = …, Object.values(ErrorCode), and a client-only-with-stdio fixture would lock both in (every existing fixture in symbolRenames.test.ts uses only ErrorCode.X value-position accesses).

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 — gap (2) is fixed: origModule is now captured before .remove() and used as the fallback (imp?.getModuleSpecifierValue() ?? origModule ?? '@modelcontextprotocol/server'), and the find() runs before removal so it can still match the (already-rewritten) declaration. 👍

Gap (1) is still open: the forEachDescendant walk at lines 71-85 still has only the Node.isPropertyAccessExpression(node) guard, so type annotations (const code: ErrorCode = …), bare value references (Object.values(ErrorCode)), keyof typeof ErrorCode, and QualifiedName members survive un-rewritten while line 103 unconditionally removes the ErrorCode specifier when changesCount > 0 — leaving them as TS2304 dangling references. ErrorCode is a v1 enum used as both a value and a type, so function handle(code: ErrorCode) { return code === ErrorCode.X; } is canonical v1 code that still produces broken output.

The simpler half of the original suggestion would close it: before line 103, check whether any non-PropertyAccessExpression reference to errorCodeLocalName remains, and if so skip removing the import (and tighten the diagnostic to name the surviving sites). That's safer than trying to rewrite type-position references, since the type would have to become ProtocolErrorCode | SdkErrorCode in the ambiguous case.

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

@claude review

Comment on lines +104 to +105
if (parent && Node.isShorthandPropertyAssignment(parent)) return;
if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === node) 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 skip guard added for ShorthandPropertyAssignment parents (line ~104) returns at collection time, so extra inside { request, extra } is never collected — but the parameter declaration is still unconditionally renamed to ctx a few lines earlier, leaving extra as an undeclared identifier (TS2304). The new test 'does not rename "extra" in shorthand property assignment' asserts both (request, ctx) and { request, extra } together, locking in the non-compiling output. Per the prior review comment (3278917293), the fix is to expand the shorthand in the replacements loop — replacements.push({ node: parent, newText: 'extra: ctx' }) — the same expansion already used in renameAllReferences (astUtils.ts) and renameSymbolsInFactory (mockPaths.ts).

Extended reasoning...

What the bug is

processCallback in contextTypes.ts first renames the parameter declaration unconditionally:

const paramDecl = extraParam.getNameNode();
paramDecl.replaceWithText(CTX_PARAM_NAME);   // (request, extra) → (request, ctx)

…and then collects identifiers in the body to rewrite. The collection loop now contains:

if (parent && Node.isShorthandPropertyAssignment(parent)) return;   // ← skips at collection time

That guard treats a shorthand { extra } as if it were a pure key (like { extra: value } or obj.extra), but a ShorthandPropertyAssignment is both the key and a value reference to the local binding. Skipping it means the value-reference half is never updated to follow the renamed parameter.

Code path that triggers it

Input:

server.setRequestHandler('tools/call', async (request, extra) => {
    helper({ request, extra });
    const s = extra.signal;
    return { content: [] };
});
  1. processCallback renames the parameter declaration: (request, extra)(request, ctx).
  2. body.forEachDescendant visits the extra identifier inside { request, extra }. parent is a ShorthandPropertyAssignmentreturn. Not collected.
  3. extra.signal is collected and rewritten to ctx.mcpReq.signal. ✓
  4. Replacements are applied; the shorthand is untouched.

Output:

server.setRequestHandler('tools/call', async (request, ctx) => {
    helper({ request, extra });          // ← TS2304: Cannot find name 'extra'
    const s = ctx.mcpReq.signal;
    return { content: [] };
});

Why nothing else catches it

renameAllReferences in astUtils.ts correctly handles this exact case by expanding shorthand to oldName: newName, and renameSymbolsInFactory in mockPaths.ts does the same for mock factories — but processCallback re-implements its own parent-kind guard list and uses a bare skip instead of the established expansion. The prior review comment (3278917293) explicitly recommended the expansion form, not a skip; the skip is a misapplication of that guidance.

The new test 'does not rename "extra" in shorthand property assignment' makes the gap worse: it asserts toContain('{ request, extra }') and toContain('(request, ctx)') in the same fixture — i.e., it locks in the non-compiling output as expected behavior, so a future fix would have to first delete the test that defends the bug.

Impact

The output fails typecheck loudly (TS2304 points directly at the dangling extra), so the user will discover it. But the codemod claims success (changesCount is incremented, no diagnostic emitted), and the test suite asserts the broken output is correct, which makes this self-perpetuating. Passing extra to a shared helper via shorthand object literal (helper({ request, extra }), logCall({ request, extra })) is a realistic but secondary pattern in v1 handlers.

Step-by-step proof

  1. Input body text: { helper({ request, extra }); const s = extra.signal; return { content: [] }; }.
  2. Line ~91: paramDecl.replaceWithText('ctx') — parameter declaration becomes ctx.
  3. forEachDescendant visits extra in helper({ request, extra }). parent kind is ShorthandPropertyAssignment → guard fires → not collected.
  4. forEachDescendant visits extra in extra.signal. parent is a PropertyAccessExpression with getExpression() === node (object position, not name node) → collected.
  5. Replacement loop produces ctx.mcpReq.signal for the access; nothing for the shorthand.
  6. Final source contains helper({ request, extra }) with no extra in scope.

Fix

In the collection loop, do not skip ShorthandPropertyAssignment parents — instead, in the replacements-building loop, emit the expansion that the rest of the codemod already uses:

// In the replacements loop:
if (parent && Node.isShorthandPropertyAssignment(parent)) {
    replacements.push({ node: parent, newText: `${EXTRA_PARAM_NAME}: ${CTX_PARAM_NAME}` });
    continue;
}

This produces helper({ request, extra: ctx }) — preserving the public object key extra (the helper presumably reads .extra) while pointing it at the renamed local binding. Then update the test to assert toContain('{ request, extra: ctx }') and not.toContain('{ request, extra }'). (Alternatively, factor the shorthand-expansion logic out of renameAllReferences into a shared helper and reuse it here.)

Comment on lines +116 to +128
.find(i => i.getModuleSpecifierValue() === targetModule && !i.isTypeOnly());
if (existingImp) {
const existingNames = new Set(existingImp.getNamedImports().map(n => n.getName()));
const toAdd = newImports.filter(n => !existingNames.has(n));
if (toAdd.length > 0) {
existingImp.addNamedImports(toAdd);
}
} else {
sourceFile.addImportDeclaration({
moduleSpecifier: targetModule,
namedImports: newImports
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 handleErrorCodeSplit and handleRequestHandlerExtra each locate a merge-target import via .find(i => i.getModuleSpecifierValue() === target && (!)i.isTypeOnly()) with no !i.getNamespaceImport() guard, and then call existingImp.addNamedImports(...) — ts-morph 28 throws InvalidOperationError ('Cannot add a named import to an import declaration that has a namespace import') if the matched declaration is a namespace import, and the runner per-file catch then rolls back the whole file with an opaque 'Transform failed' diagnostic. These inline .find() sites do NOT route through addOrMergeImport (importUtils.ts), so the namespace guard suggested for addOrMergeImport's predicate (comment 3279075847) won't cure them — add the same guard to all three lookups (handleErrorCodeSplit's existingImp, and handleRequestHandlerExtra's existingImp and valueImp).

Extended reasoning...

What the bug is

handleErrorCodeSplit (symbolRenames.ts ~lines 113–123) and handleRequestHandlerExtra (~lines 230–250) each re-implement "find an existing import to merge new named imports into" inline rather than going through addOrMergeImport in importUtils.ts. The lookup predicates are:

  • handleErrorCodeSplit.find(i => i.getModuleSpecifierValue() === targetModule && !i.isTypeOnly()) followed by existingImp.addNamedImports(toAdd) (ProtocolErrorCode/SdkErrorCode)
  • handleRequestHandlerExtra.find(i => i.getModuleSpecifierValue() === target && i.isTypeOnly()) then a fallback .find(... && !i.isTypeOnly()), each followed by addNamedImports([name]) (ServerContext/ClientContext)

None of the three predicates exclude namespace imports. In TypeScript's grammar ImportClause.namedBindings is a discriminated union — a declaration can have a NamespaceImport or NamedImports, never both — so ts-morph 28 throws InvalidOperationError: Cannot add a named import to an import declaration that has a namespace import. when addNamedImports() is called on a matched namespace import.

The code path that triggers it

A file that ends up with both a namespace import and a named ErrorCode/RequestHandlerExtra import to the same v2 package. One realistic shape that survives the full pipeline:

import * as types from '@modelcontextprotocol/sdk/types.js';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
type H = RequestHandlerExtra<ServerRequest, ServerNotification>;
  1. importPathsTransform: the namespace import is preserved in place via imp.setModuleSpecifier('@modelcontextprotocol/server') (the defaultImport || namespaceImport || hasAlias branch). The type-only RequestHandlerExtra import is collected and re-added via addOrMergeImport with isTypeOnly===true, so it does not match the namespace import (isTypeOnly()===false) and lands in its own type-only declaration. No throw yet.
  2. symbolRenamesTransformhandleRequestHandlerExtra: the type ref is rewritten to ServerContext, the RequestHandlerExtra specifier is removed, and the now-empty type-only declaration is removed.
  3. The existingImp lookup (isTypeOnly() true) finds nothing. The fallback valueImp lookup (!isTypeOnly()) matches the namespace importgetModuleSpecifierValue() === '@modelcontextprotocol/server' and isTypeOnly() === false both hold.
  4. valueImp.addNamedImports(['ServerContext'])throws.

The same shape applies to handleErrorCodeSplit with ErrorCode in place of RequestHandlerExtra (e.g. an already-v2 file with import * as server from '@modelcontextprotocol/server' plus import { ErrorCode } from '@modelcontextprotocol/server' re-run through --transforms symbols for idempotency).

Why nothing else prevents it, and why the addOrMergeImport fix won't cure these

runner.ts wraps the per-file transform loop in a try/catch that reverts the entire file to its original source text and emits a single error(filePath, 1, 'Transform failed: …') diagnostic. So a hit here doesn't corrupt output — it just discards every successful transform that ran before the throw and gives the user a non-actionable message.

This is the same hazard class flagged in comment 3279075847 for addOrMergeImport() in importUtils.ts, but a distinct code location: handleErrorCodeSplit and handleRequestHandlerExtra re-implement the merge inline and never call addOrMergeImport. (Of the symbolRenames helpers, only handleSchemaInput routes through addOrMergeImport.) Fixing addOrMergeImport's .find() predicate alone leaves these three inline lookups unfixed.

Step-by-step proof

Input:

import * as types from '@modelcontextprotocol/sdk/types.js';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
type H = RequestHandlerExtra<ServerRequest, ServerNotification>;
  1. After importPaths: import * as types from '@modelcontextprotocol/server'; import type { RequestHandlerExtra } from '@modelcontextprotocol/server'; …
  2. handleRequestHandlerExtra replaces the type ref with ServerContext, removes the RequestHandlerExtra specifier, removes the now-empty type-only declaration. needsServerContext = true.
  3. existingImp = .find(i => spec === '@modelcontextprotocol/server' && i.isTypeOnly())undefined (only the namespace import remains, isTypeOnly()===false).
  4. valueImp = .find(i => spec === '@modelcontextprotocol/server' && !i.isTypeOnly())matches the namespace import.
  5. valueImp.addNamedImports(['ServerContext']) → ts-morph throws InvalidOperationError: Cannot add a named import to an import declaration that has a namespace import.
  6. runner.ts catch fires: file reverted to original v1 source, error(filePath, 1, 'Transform failed: Cannot add a named import…') emitted, fileChanges = 0. The importPaths rewrite that did succeed is discarded.

Impact

The trigger is uncommon — it requires a namespace import alongside a named ErrorCode/RequestHandlerExtra import that resolve to the same v2 package — and the failure is loud and safe (whole file rolled back, error diagnostic emitted, no corrupted output written). Hence nit severity. But it's a real completeness gap: the same hazard class is already on this PR for addOrMergeImport, and these inline sites need their own fix or the bug reappears the moment that one is fixed.

Fix

Add a !i.getNamespaceImport() guard to each of the three inline .find() calls:

// handleErrorCodeSplit
const existingImp = sourceFile
    .getImportDeclarations()
    .find(i => i.getModuleSpecifierValue() === targetModule && !i.isTypeOnly() && !i.getNamespaceImport());

// handleRequestHandlerExtra
const existingImp = sourceFile.getImportDeclarations()
    .find(i => i.getModuleSpecifierValue() === target && i.isTypeOnly() && !i.getNamespaceImport());
// …
const valueImp = sourceFile.getImportDeclarations()
    .find(i => i.getModuleSpecifierValue() === target && !i.isTypeOnly() && !i.getNamespaceImport());

Or, cleaner: refactor both helpers to call addOrMergeImport (as handleSchemaInput already does) and put the namespace guard once in addOrMergeImport's predicate. A test like import * as types from '@modelcontextprotocol/server'; import { ErrorCode } from '@modelcontextprotocol/server'; const c = ErrorCode.InvalidParams; run through --transforms symbols would lock both in.

Comment on lines +162 to +184
`Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.`
)
);
return true;
}

if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === ref) {
return false;
}

if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === ref) {
return false;
}

if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === ref) {
return false;
}

// Value position: replace identifier with specTypeSchemas.X
const line = ref.getStartLineNumber();
ref.replaceWithText(`specTypeSchemas.${typeName}`);
ensureImport(sourceFile, 'specTypeSchemas');
diagnostics.push(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 handleReference() in specSchemaAccess.ts guards ExportSpecifier, ShorthandPropertyAssignment, PropertyAssignment name, BindingElement propertyName, and PropertyAccessExpression name — but not PropertySignature, MethodDeclaration, MethodSignature, PropertyDeclaration, EnumMember, or get/set accessor name nodes, all of which renameAllReferences in astUtils.ts already guards. For import { ToolSchema } from '@modelcontextprotocol/server'; interface Config { ToolSchema: string; }, the interface property identifier falls through to the value-position rewrite, producing interface Config { specTypeSchemas.Tool: string; } (a syntax error) — ts-morph throws and the runner rolls back the entire file with an opaque diagnostic. Add the same guard list astUtils.ts uses, or factor it into a shared isKeyPositionIdentifier() helper.

Extended reasoning...

What the bug is

handleReference() in specSchemaAccess.ts re-implements its own parent-kind guard list rather than reusing the one in astUtils.ts renameAllReferences(). Through several review rounds it has accumulated guards for ExportSpecifier, ShorthandPropertyAssignment, PropertyAssignment name node, BindingElement property name, and PropertyAccessExpression name node — but it is still missing the rest of the key-position parent kinds that astUtils.ts already handles: PropertySignature, MethodDeclaration, MethodSignature, PropertyDeclaration, EnumMember, GetAccessorDeclaration, and SetAccessorDeclaration.

findNonImportReferences() only filters out ImportSpecifier parents, so an identifier in any of those positions is collected and passed to handleReference(). None of the existing guards match, so it falls through to the value-position default at the end of the function: ref.replaceWithText(\specTypeSchemas.${typeName}`)`.

Why the existing code doesn't prevent it

A PropertySignature (interface member) is a distinct SyntaxKind from PropertyAssignment (object literal entry), so Node.isPropertyAssignment(parent) returns false. Same for class MethodDeclaration/PropertyDeclaration, MethodSignature, EnumMember, and accessor name nodes. The function explicitly checks each kind, so a kind not in the list is silently treated as a value reference.

Step-by-step proof

Input:

import { ToolSchema } from '@modelcontextprotocol/server';
interface Config { ToolSchema: string; }
  1. collectSpecSchemaImports() finds ToolSchema (it's in SPEC_SCHEMA_NAMES), so schemaImports = { ToolSchema → ToolSchema }, typeName = 'Tool'.
  2. findNonImportReferences() visits the ToolSchema identifier inside interface Config { ToolSchema: string; }. Its parent is a PropertySignature, which is not an ImportSpecifier, so it's pushed into refs.
  3. handleReference() walks the guard chain: isTypeofInTypePosition → false (parent isn't TypeQuery); isSafeParse*/isParse* → false (parent isn't PropertyAccessExpression); the value-position PAE check → false; ExportSpecifier → false; ShorthandPropertyAssignment → false; PropertyAssignment name → false (PropertySignature is a distinct kind); BindingElement propertyName → false; PropertyAccessExpression name → false.
  4. Falls through to the value-position rewrite: ref.replaceWithText('specTypeSchemas.Tool').
  5. ts-morph attempts to write interface Config { specTypeSchemas.Tool: string; }, which is invalid syntax. replaceWithText throws a manipulation error.
  6. The runner's per-file catch (runner.ts) rolls back the entire file to its pre-pipeline state — undoing the successful importPaths/symbolRenames rewrites too — and emits a single opaque Transform failed: Manipulation error… diagnostic for line 1.

The same failure occurs for class members (class C { ToolSchema = ''; method ToolSchema() {} get ToolSchema() {} }), enum members (enum E { ToolSchema }), and interface method signatures.

Impact

The trigger is contrived — it requires importing a spec-schema name from MCP and declaring an unrelated interface/class/enum member with that exact name in the same file — and the failure is loud and safe (file reverted, error diagnostic emitted, no corrupted output written), hence nit severity. But it's a clear internal inconsistency in this transform: this is the same parent-kind-guard family that has been hardened repeatedly across this PR's review rounds (PropertyAssignment, BindingElement, and PropertyAccessExpression name guards were each added in successive commits), and astUtils.ts already has the complete list.

How to fix

Either add the missing guards alongside the existing ones in handleReference():

if (parent && Node.isPropertySignature(parent) && parent.getNameNode() === ref) return false;
if (parent && Node.isMethodDeclaration(parent) && parent.getNameNode() === ref) return false;
if (parent && Node.isMethodSignature(parent) && parent.getNameNode() === ref) return false;
if (parent && Node.isPropertyDeclaration(parent) && parent.getNameNode() === ref) return false;
if (parent && Node.isEnumMember(parent) && parent.getNameNode() === ref) return false;
if (parent && Node.isGetAccessorDeclaration(parent) && parent.getNameNode() === ref) return false;
if (parent && Node.isSetAccessorDeclaration(parent) && parent.getNameNode() === ref) return false;

Or — cleaner, since this is now the third time this skip list has needed re-syncing — factor the key-position checks out of renameAllReferences() in astUtils.ts into a shared isKeyPositionIdentifier(node) helper and reuse it in both places (and in findNonImportReferences()).

Comment on lines +134 to +154
function wrapWithZObject(schemaText: string): string {
return `z.object(${schemaText})`;
}

function maybeWrapSchema(node: Node): string {
const text = node.getText();
if (Node.isObjectLiteralExpression(node)) {
return wrapWithZObject(text);
}
return text;
}

function emitWrapDiagnostic(node: Node, sourceFile: SourceFile, call: CallExpression, diagnostics: Diagnostic[]): void {
if (Node.isObjectLiteralExpression(node)) {
diagnostics.push(
info(
sourceFile.getFilePath(),
call.getStartLineNumber(),
'Raw object literal wrapped with z.object(). Verify that zod (z) is imported in this file.'
)
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Two leftover gaps from comment 3278854656: (1) the new fall-through warning in wrapSchemaInConfig() fires for any non-object-literal initializer, including the canonical correct v2 form inputSchema: z.object({ ... }) — already-migrated configs get a spurious 'value is not an object literal' warning on every run. (2) The parallel positional path (emitWrapDiagnostic()/maybeWrapSchema() used by migrateToolCall/migratePromptCall) still emits zero diagnostics for non-object-literal schema args, so server.tool('x', buildInputSchema(), cb) becomes registerTool('x', { inputSchema: buildInputSchema() }, cb) silently — the v1-positional analogue of the Cloudflare MCP repro mattzcarey reported. Add the recommended !initializer.getText().startsWith('z.object(') guard before the warning, and emit the same warning from emitWrapDiagnostic for non-ObjectLiteralExpression nodes.

Extended reasoning...

Gap 1 — spurious warning for already-correct z.object() configs

wrapSchemaInConfig() (mcpServerApi.ts:~209-216) handles the schema property like this:

if (Node.isObjectLiteralExpression(initializer)) {
    // wrap with z.object(), emit info, return true
}

diagnostics.push(
    warning(..., `${schemaPropertyName} value is not an object literal — verify it is a z.object() schema. ...`)
);
return false;

For registerTool('x', { inputSchema: z.object({ name: z.string() }) }, cb), the schemaProp is a regular PropertyAssignment, the initializer is a CallExpression (z.object(...)), not an ObjectLiteralExpression — so it falls straight through to the warning. The warning text — "verify it is a z.object() schema" — is asking the user to verify something that is literally already a z.object() schema. The original comment 3278854656 explicitly recommended guarding with if (!initializer.getText().startsWith('z.object(')); that guard was not added.

This affects every registerTool/registerPrompt call that already uses the v2-correct form (which is also valid v1 code), and it makes the codemod non-diagnostic-idempotent: re-running it on output it just produced ({ name: z.string() }z.object({ name: z.string() })) flags the codemod's own output as suspect.

The test 'does not double-wrap z.object() in .registerTool() config' only asserts on the rewritten text (not.toContain('z.object(z.object(')) — it never inspects result.diagnostics — so the spurious warning passes CI undetected.

Gap 2 — positional form still silent

emitWrapDiagnostic() / maybeWrapSchema() are the parallel helpers for the positional v1 overloads (server.tool('x', schemaVar, cb), server.prompt('x', schemaVar, cb)). They have the opposite gap:

function maybeWrapSchema(node: Node): string {
    const text = node.getText();
    if (Node.isObjectLiteralExpression(node)) return wrapWithZObject(text);
    return text;   // ← raw text, unwrapped, no diagnostic
}

function emitWrapDiagnostic(node: Node, ...): void {
    if (Node.isObjectLiteralExpression(node)) {
        diagnostics.push(info(...));   // only fires for object literals
    }
    // ← no else branch; complete no-op for Identifier / CallExpression
}

So server.tool('execute', buildInputSchema(), cb) (where buildInputSchema() returns a Zod field map per v1's positional overload contract) becomes server.registerTool('execute', { inputSchema: buildInputSchema() }, cb) with zero diagnostics, and then fails typecheck on v2 with TS2741: Property '~standard' is missing in type 'Record<string, ZodType<...>>' — exactly the failure mattzcarey reported on this PR (2026-05-14) for the config-object form, but on the v1 positional overload that migrateToolCall converts. Comment 3278854656 explicitly asked: "Apply the same treatment in emitWrapDiagnostic/maybeWrapSchema for the positional server.tool(name, schemaVar, cb) form." That follow-up was not done. The new tests added in that round ('emits diagnostic for variable-valued schema in config', 'emits diagnostic for shorthand schema property in config') only cover the config-object form, not the positional form.

Step-by-step proof

Gap 1 — input:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const server = new McpServer({ name: 't', version: '1.0' });
server.registerTool('echo', { inputSchema: z.object({ msg: z.string() }) }, async ({ msg }) => ({ content: [] }));
  1. registerToolCalls collects the call → wrapSchemaInConfig(call, 'inputSchema', ...).
  2. schemaProp is a regular PropertyAssignment → passes isPropertyAssignment.
  3. initializer is the z.object({ msg: z.string() }) CallExpression.
  4. Node.isObjectLiteralExpression(initializer)false (it's a CallExpression).
  5. Falls through to the warning push → user is told to verify the very thing they already wrote.

Gap 2 — input:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const server = new McpServer({ name: 't', version: '1.0' });
const inputSchema = buildInputSchema(); // Record<string, z.ZodType>
server.tool('execute', inputSchema, async (params) => ({ content: [] }));
  1. migrateToolCall enters case 3, isStringArg(args[1]) → false → (name, schema, callback) branch.
  2. emitWrapDiagnostic(arg1, ...)arg1 is an Identifier, not an ObjectLiteralExpressionno-op.
  3. maybeWrapSchema(arg1) → returns the raw text 'inputSchema' unwrapped.
  4. migrateToolCall returns truechangesCount++, the "Could not automatically migrate" fallback never fires.
  5. Output: server.registerTool('execute', { inputSchema: inputSchema }, async ...) — fails TS2741 on v2, zero diagnostics, codemod claims clean migration.

Impact

Both are nit severity. Gap 1 is purely diagnostic noise (output is correct, schema is correctly left unwrapped) but trains users to ignore the codemod's diagnostics. Gap 2 leaves a TS2741 the user discovers loudly at build time, but the codemod gives no hint to track it back to server.tool() migration — contradicting the diagnostic-first design and the explicit reviewer ask in 3278854656. Both are clear leftover gaps from a fix applied directly in response to review.

Fix

For gap 1, in wrapSchemaInConfig() skip the warning when the initializer is already a z.object(...) call:

if (!initializer.getText().startsWith('z.object(')) {
    diagnostics.push(warning(...));
}
return false;

For gap 2, mirror the config-object treatment in emitWrapDiagnostic:

function emitWrapDiagnostic(node: Node, sourceFile: SourceFile, call: CallExpression, diagnostics: Diagnostic[]): void {
    if (Node.isObjectLiteralExpression(node)) {
        diagnostics.push(info(..., 'Raw object literal wrapped with z.object(). ...'));
    } else if (!node.getText().startsWith('z.object(')) {
        diagnostics.push(warning(..., 'inputSchema/argsSchema is not an object literal — verify it is a Standard Schema; if it is a raw Zod field map, wrap it with z.object(...).'));
    }
}

Tighten the test 'does not double-wrap z.object() in .registerTool() config' to also assert result.diagnostics is empty, and add a positional-form test (server.tool('x', schemaVar, cb)) asserting a warning is emitted.

🔬 also observed by mattzcarey

Comment on lines +121 to +124
}
}

changesCount += migrateConstructorTaskOptions(sourceFile, 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 wrapSchemaInConfig(call, 'uriSchema', sourceFile, diagnostics) loop for registerResourceCalls is dead code: v2's registerResource config is ResourceMetadata (Omit<Resource, 'uri' | 'name'>), which has no uriSchema property, and v1's .resource() never had one either — configArg.getProperty('uriSchema') always returns undefined and the function early-returns false. Either delete the loop or use the actual config key if one was intended (compare registerTool→inputSchema and registerPrompt→argsSchema, which are real keys).

Extended reasoning...

What this is

mcpServerApiTransform post-processes already-v2 registerTool/registerPrompt/registerResource calls to wrap raw object-literal schemas with z.object(). The three loops pass property names to wrapSchemaInConfig:

for (const call of registerToolCalls) {
    if (wrapSchemaInConfig(call, 'inputSchema', sourceFile, diagnostics)) { changesCount++; }
}
for (const call of registerPromptCalls) {
    if (wrapSchemaInConfig(call, 'argsSchema', sourceFile, diagnostics)) { changesCount++; }
}
for (const call of registerResourceCalls) {
    if (wrapSchemaInConfig(call, 'uriSchema', sourceFile, diagnostics)) { changesCount++; }
}

inputSchema and argsSchema are real config keys on v2's ToolConfig/PromptConfig. uriSchema is not. v2's registerResource(name, uriOrTemplate, config, readCallback) takes a ResourceMetadata config, defined as Omit<Resource, 'uri' | 'name'> (packages/server/src/server/mcp.ts), which has no uriSchema field. v1's deprecated .resource() overloads also never had a uriSchema config key. Grep confirms uriSchema appears nowhere in packages/server, packages/core, or packages/client.

Why the loop never executes

wrapSchemaInConfig looks up the property and bails if it isn't there:

const schemaProp = configArg.getProperty(schemaPropertyName);
if (!schemaProp) return false;

Because uriSchema is never present on any real registerResource config, getProperty('uriSchema') always returns undefined, the function returns false on the second guard, and the rest of wrapSchemaInConfig (the wrap, the shorthand/non-literal diagnostics added in later review rounds) is unreachable for this caller. The registerResourceCalls loop body never increments changesCount.

Step-by-step proof

  1. Input: server.registerResource('config', 'config://app', { description: 'App config' }, async (uri) => ({ contents: [] }));
  2. mcpServerApiTransform collects the call into registerResourceCalls.
  3. wrapSchemaInConfig(call, 'uriSchema', ...): configArg is the third argument { description: 'App config' }.
  4. configArg.getProperty('uriSchema')undefinedreturn false.
  5. changesCount not incremented. Same for any other valid v2 ResourceMetadata shape (title, description, mimeType, annotations, etc.).

There is no input that makes the loop body do anything, because there is no v1 or v2 config shape that includes a uriSchema property.

Why nothing else catches it

The transform reports changesCount: 0 for files that only use registerResource — same as a no-op — so the test suite passes regardless of whether the loop exists. mcpServerApi.test.ts has registerTool/registerPrompt config-wrap tests but no registerResource config-wrap test, because there's nothing to wrap.

Impact

Zero runtime impact — this is pure code hygiene. No corruption, no incorrect output, no missing diagnostics. The cost is purely confusion: a future reader will see the three parallel loops, assume uriSchema is a real v2 registerResource config key, and waste time looking for it. This is the same dead-code class as the previously-cleaned-up getNamedImportNames() and isV2Gap scaffolding flagged earlier on this PR.

Fix

Delete the loop:

for (const call of registerResourceCalls) {
    if (wrapSchemaInConfig(call, 'uriSchema', sourceFile, diagnostics)) {
        changesCount++;
    }
}

(registerResourceCalls is then unused too — drop the array and the case 'registerResource' collection arm. Or, if the intent was to validate a different config key on registerResource configs, swap 'uriSchema' for the real key.)

Comment on lines +95 to +106

// Collect identifiers that are actual references to the `extra` parameter
const identifiers: import('ts-morph').Node[] = [];
body.forEachDescendant(node => {
if (!Node.isIdentifier(node) || node.getText() !== EXTRA_PARAM_NAME) return;
const parent = node.getParent();
// Skip property-name positions (e.g., meta.extra, { extra: value }, { extra }, { extra: x } = obj)
if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return;
if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return;
if (parent && Node.isShorthandPropertyAssignment(parent)) return;
if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === node) return;
identifiers.push(node);
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 identifier-collection skip list in processCallback (lines ~99-105) covers PropertyAccessExpression name, PropertyAssignment name, ShorthandPropertyAssignment, and BindingElement propertyName — but is still missing the seven additional key-position parent kinds that renameAllReferences (astUtils.ts) already guards: PropertySignature, MethodDeclaration, MethodSignature, PropertyDeclaration, EnumMember, and get/set accessor name nodes. A handler body declaring a local type Result = { extra: string } or get extra() { ... } gets that key silently renamed to ctx (valid syntax → no throw, no rollback, no diagnostic). This is now the third site re-implementing this skip list — factoring it into a shared isKeyPositionIdentifier(node) helper would stop the recurring drift.

Extended reasoning...

What the bug is

processCallback in contextTypes.ts collects identifiers in the callback body that reference the extra parameter, then rewrites them to ctx. The skip list guarding which identifiers are not references to the parameter has been hardened across review rounds and now covers four key-position parent kinds:

if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return;
if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return;
if (parent && Node.isShorthandPropertyAssignment(parent)) return;
if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === node) return;
identifiers.push(node);

But renameAllReferences in astUtils.ts — the canonical key-position guard list in this PR, hardened across three earlier review rounds — additionally guards PropertySignature, MethodDeclaration, MethodSignature, PropertyDeclaration, EnumMember, GetAccessorDeclaration, and SetAccessorDeclaration. None of those seven are in processCallback's list.

The code path that triggers it

An identifier named extra whose parent is one of those seven kinds — a local type member, a class field/method, an enum member, or an object accessor — is collected as if it were a value reference to the parameter. In the replacements loop, it doesn't match the PropertyAccessExpression value-position branch or the QualifiedName branch, so it falls through to the plain rename:

replacements.push({ node: id, newText: CTX_PARAM_NAME });

Step-by-step proof

Input:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
server.setRequestHandler('tools/call', async (request, extra) => {
    type Result = { extra: string; data: unknown };   // PropertySignature key
    const obj = {
        get extra() { return computeExtra(); },       // GetAccessorDeclaration name
    };
    return { content: [], _meta: { extraResult: extra.signal } };
});
  1. processCallback renames the parameter declaration to ctx, then collects body identifiers matching extra.
  2. The extra inside { extra: string } has a PropertySignature parent. None of the four guards match (PropertySignature is a distinct SyntaxKind from PropertyAssignment). Collected.
  3. The extra inside get extra() has a GetAccessorDeclaration parent. None of the four guards match. Collected.
  4. extra.signal is a value-position PropertyAccessExpression and is correctly rewritten to ctx.mcpReq.signal.
  5. The replacement loop falls through to plain rename for the two key-position identifiers.

Output:

type Result = { ctx: string; data: unknown };
const obj = { get ctx() { return computeExtra(); } };

Both rewrites are syntactically valid TypeScript, so there is no ts-morph manipulation error, no runner rollback, and no diagnostic. The local type's property key and the object accessor name silently change — any downstream .extra access now resolves to undefined (or fails typecheck if the receiver is typed). This is the worst codemod failure mode: silent semantic corruption that compiles.

Why nothing else prevents it

The skip list in processCallback is a re-implementation of the one in astUtils.ts (which has its own copy hardened across review comments 3133560060, 3136237226, 3138328884) and the one in specSchemaAccess.ts (hardened across 3189940697, 3193275781, and a current open comment for the same gap). Each re-implementation has drifted independently and is missing different members of the canonical list.

Impact

Trigger frequency is very low — it requires the literal identifier extra as an interface/type member, class field/method, enum member, or accessor name inside a v1 handler callback body. That's a contrived collision in real code. But when it fires, the failure mode is silent corruption that compiles, which is the failure class this PR has been most aggressively hardened against. Hence nit severity.

Fix

Add the missing seven guards alongside the existing ones in processCallback's collection loop:

if (parent && Node.isPropertySignature(parent) && parent.getNameNode() === node) return;
if (parent && Node.isMethodDeclaration(parent) && parent.getNameNode() === node) return;
if (parent && Node.isMethodSignature(parent) && parent.getNameNode() === node) return;
if (parent && Node.isPropertyDeclaration(parent) && parent.getNameNode() === node) return;
if (parent && Node.isEnumMember(parent) && parent.getNameNode() === node) return;
if (parent && Node.isGetAccessorDeclaration(parent) && parent.getNameNode() === node) return;
if (parent && Node.isSetAccessorDeclaration(parent) && parent.getNameNode() === node) return;

Or — cleaner, since this is now the third file in the PR with its own copy of this guard list — factor the key-position checks out of renameAllReferences into a shared isKeyPositionIdentifier(node) helper in astUtils.ts and reuse it in processCallback, specSchemaAccess.handleReference, and specSchemaAccess.findNonImportReferences. That eliminates the recurring drift permanently. A test like async (request, extra) => { type Local = { extra: string }; return { content: [] }; } asserting extra is preserved as the type key would lock it in.

Comment on lines +11 to +32
const MOCK_METHODS = new Set(['mock', 'doMock']);
const MOCK_CALLERS = new Set(['vi', 'jest']);

export const mockPathsTransform: Transform = {
name: 'Mock and dynamic import path rewrites',
id: 'mock-paths',
apply(sourceFile: SourceFile, context: TransformContext): TransformResult {
const diagnostics: Diagnostic[] = [];
const usedPackages = new Set<string>();
let changesCount = 0;

const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);

for (const call of calls) {
const expr = call.getExpression();

if (Node.isPropertyAccessExpression(expr)) {
const objName = expr.getExpression().getText();
const methodName = expr.getName();
if (MOCK_CALLERS.has(objName) && MOCK_METHODS.has(methodName)) {
changesCount += rewriteMockCall(call, sourceFile, context, diagnostics, usedPackages);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MOCK_METHODS at mockPaths.ts:11 only covers mock/doMock, so the partial-mock idioms jest.requireActual() / vi.importActual() and the unmock family (vi.unmock, jest.unmock, jest.dontMock, jest.deepUnmock, jest.requireMock, jest.createMockFromModule) keep pointing at the v1 SDK that updatePackageJson() just removed — the migrated test file then throws Cannot find module '@modelcontextprotocol/sdk/types.js' at runtime with no codemod diagnostic. Extend MOCK_METHODS (or a sibling set) so these path-only calls route through the same firstArg.setLiteralValue(target) rewrite that rewriteMockCall already performs.

Extended reasoning...

What the bug is

mockPaths.ts:11 defines MOCK_METHODS = new Set(['mock', 'doMock']), and the dispatch loop at lines 22-32 only calls rewriteMockCall when both MOCK_CALLERS.has(objName) and MOCK_METHODS.has(methodName) hold. The other Jest/Vitest test-doubles APIs that take an SDK module specifier as their first string argument are never intercepted:

  • vi.importActual(spec) / jest.requireActual(spec) — the canonical partial-mock helper
  • vi.unmock(spec) / vi.doUnmock(spec) / jest.unmock(spec) / jest.dontMock(spec) / jest.deepUnmock(spec)
  • jest.requireMock(spec)
  • jest.createMockFromModule(spec) / jest.genMockFromModule(spec)

Grep over the file confirms none of these names appear anywhere in mockPaths.ts.

The code path that triggers it

The canonical Jest partial-mock idiom (and the Vitest explicit-string equivalent) writes the same specifier twice:

jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
    ...jest.requireActual('@modelcontextprotocol/sdk/types.js'),
    isInitializeRequest: jest.fn()
}));

After the codemod runs:

  1. mockPathsTransform finds the outer jest.mock(...) call (MOCK_METHODS.has('mock') is true) and rewriteMockCall rewrites the first argument to '@modelcontextprotocol/server'.
  2. The inner jest.requireActual(...) is also a CallExpression with a PropertyAccessExpression callee, but MOCK_METHODS.has('requireActual') is false → it is silently skipped.
  3. renameSymbolsInFactory only touches the top-level object-literal keys of the factory return value, not nested call arguments.
  4. rewriteDynamicImports only matches CallExpressions whose expr.getKind() === SyntaxKind.ImportKeywordjest.requireActual is a PropertyAccessExpression, so it is never visited.
  5. importPathsTransform only iterates static ImportDeclaration/ExportDeclaration nodes; it never looks at call arguments.
  6. updatePackageJson() then removes @modelcontextprotocol/sdk from package.json because the static imports were rewritten.

Why nothing else prevents it

No transform visits string-literal call arguments other than rewriteMockCall (gated on MOCK_METHODS) and rewriteDynamicImports (gated on ImportKeyword). TypeScript will not flag the stale path either — requireActual/importActual return unknown/any and the module specifier is just a string. The only diagnostics in this file fire on unknown SDK paths inside recognized mock methods (lines 89-97), so a stale requireActual argument produces zero diagnostics.

The codemod's own test suite (mockPaths.test.ts'rewrites sdk/types.js path') only exercises the importOriginal callback form (vi.doMock('...', async importOriginal => { ... })), which works incidentally because the parameter inherits the rewritten outer specifier. The explicit-string form is never tested.

Step-by-step proof

Input test file:

jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
    ...jest.requireActual('@modelcontextprotocol/sdk/types.js'),
    isInitializeRequest: jest.fn(),
}));
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';

After the codemod:

jest.mock('@modelcontextprotocol/server', () => ({
    ...jest.requireActual('@modelcontextprotocol/sdk/types.js'),  // ← stale
    isInitializeRequest: jest.fn(),
}));
import { isInitializeRequest } from '@modelcontextprotocol/server';

…and package.json no longer contains @modelcontextprotocol/sdk. Running the test suite produces Cannot find module '@modelcontextprotocol/sdk/types.js' thrown from inside the mock factory. Even before the package is removed, the mock specifier and the requireActual specifier no longer match, so Jest's resolver intercepts a different module than the one being spread.

Impact

Test-file-only, and the failure is loud and easy to trace once it surfaces — hence nit. But requireActual inside a mock factory is the documented partial-mock pattern in both Jest and Vitest, the codemod actively removes the dependency that would otherwise make the stale path resolvable, and no diagnostic is emitted — contradicting the codemod's diagnostic-first design.

Fix

Add the path-only methods to a sibling set and route them through the same first-arg rewrite that rewriteMockCall already performs at line ~117 (firstArg.setLiteralValue(effectiveTarget)). These methods take only a specifier (no factory to walk), so the handler is a strict subset of rewriteMockCall:

const PATH_ONLY_MOCK_METHODS = new Set([
    'importActual', 'requireActual', 'requireMock',
    'unmock', 'doUnmock', 'dontMock', 'deepUnmock',
    'createMockFromModule', 'genMockFromModule'
]);
// in apply():
if (MOCK_CALLERS.has(objName) && PATH_ONLY_MOCK_METHODS.has(methodName)) {
    changesCount += rewriteSpecifierOnlyCall(call, sourceFile, context, diagnostics, usedPackages);
}

A test like jest.mock('@modelcontextprotocol/sdk/types.js', () => ({ ...jest.requireActual('@modelcontextprotocol/sdk/types.js'), isInitializeRequest: jest.fn() })) asserting both specifiers become '@modelcontextprotocol/server' would lock this in.

Comment on lines +28 to +38
},
'@modelcontextprotocol/sdk/client/stdio.js': {
target: '@modelcontextprotocol/client',
status: 'moved',
symbolTargetOverrides: {
StdioClientTransport: '@modelcontextprotocol/client/stdio',
DEFAULT_INHERITED_ENV_VARS: '@modelcontextprotocol/client/stdio',
getDefaultEnvironment: '@modelcontextprotocol/client/stdio',
StdioServerParameters: '@modelcontextprotocol/client/stdio'
}
},
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 base target: '@modelcontextprotocol/client' for client/stdio.js is unreachable for named imports (every symbol is overridden to /client/stdio via symbolTargetOverrides), but it IS the fallback for namespace imports (import * as stdio from ...), no-factory vi.mock(...) automocks, non-destructured dynamic imports, and export * from — and @modelcontextprotocol/client doesn't export the stdio symbols (per the comment at packages/client/src/index.ts:64), so those four paths produce TS2339/dead mocks with no diagnostic. Since every symbol routes to the same subpath, just use target: '@modelcontextprotocol/client/stdio' and drop the symbolTargetOverrides block, matching the sibling server/stdio.js entry above.

Extended reasoning...

What the bug is

The IMPORT_MAP entry for @modelcontextprotocol/sdk/client/stdio.js is:

'@modelcontextprotocol/sdk/client/stdio.js': {
    target: '@modelcontextprotocol/client',
    status: 'moved',
    symbolTargetOverrides: {
        StdioClientTransport: '@modelcontextprotocol/client/stdio',
        DEFAULT_INHERITED_ENV_VARS: '@modelcontextprotocol/client/stdio',
        getDefaultEnvironment: '@modelcontextprotocol/client/stdio',
        StdioServerParameters: '@modelcontextprotocol/client/stdio'
    }
},

Every symbol that v1's client/stdio.js exports is overridden to @modelcontextprotocol/client/stdio — so the base target is unreachable for any named import. But the base target is exactly what's used in the cases where per-symbol routing can't apply, and @modelcontextprotocol/client (the root barrel) does not export StdioClientTransport / getDefaultEnvironment / DEFAULT_INHERITED_ENV_VARS / StdioServerParameterspackages/client/src/index.ts:64 explicitly notes those live on the ./stdio subpath only.

The four code paths that fall back to the wrong base target

  1. Namespace import (importPaths.ts): the override-routing block is gated on !namespaceImport && !defaultImport, so for import * as stdio from '@modelcontextprotocol/sdk/client/stdio.js' the effective target stays @modelcontextprotocol/client. Output: import * as stdio from '@modelcontextprotocol/client'stdio.StdioClientTransport is now TS2339.

  2. 1-arg vi.mock(...) automock (mockPaths.ts rewriteMockCall): override routing is gated on args.length >= 2. vi.mock('@modelcontextprotocol/sdk/client/stdio.js') becomes vi.mock('@modelcontextprotocol/client') — but the production code's static import is rewritten to @modelcontextprotocol/client/stdio, so the mock targets a different module and silently has no effect.

  3. Non-destructured dynamic import (mockPaths.ts rewriteDynamicImports): the override check requires Node.isObjectBindingPattern. const stdio = await import('@modelcontextprotocol/sdk/client/stdio.js') is rewritten to import from @modelcontextprotocol/clientstdio.StdioClientTransport is undefined at runtime.

  4. Star re-export (importPaths.ts rewriteExportDeclarations): export * from '@modelcontextprotocol/sdk/client/stdio.js'getNamedExports().length === 0, so allOverridden is false and the base target is used.

Why this is internally inconsistent

The sibling entry directly above does it right:

'@modelcontextprotocol/sdk/server/stdio.js': {
    target: '@modelcontextprotocol/server/stdio',
    status: 'moved'
},

No symbolTargetOverrides, the subpath is the target, and all four cases above route correctly for the server side. The client side should be the same shape — symbolTargetOverrides is meant for splitting an import across different v2 packages (like streamableHttp.js/server + /node); when every symbol routes to the same place, it's just an indirect (and partial) way of saying target.

Step-by-step proof (namespace import)

Input:

import * as stdio from '@modelcontextprotocol/sdk/client/stdio.js';
const t = new stdio.StdioClientTransport({ command: 'node' });
  1. importPathsTransform finds the namespace import; mapping.symbolTargetOverrides is set.
  2. The override branch is gated on !namespaceImport && !defaultImport → skipped because namespaceImport is truthy.
  3. effectiveTarget stays '@modelcontextprotocol/client' (the base target).
  4. imp.setModuleSpecifier('@modelcontextprotocol/client'). No diagnostic.
  5. Output: import * as stdio from '@modelcontextprotocol/client'; const t = new stdio.StdioClientTransport(...)TS2339: Property 'StdioClientTransport' does not exist on type 'typeof import(\"@modelcontextprotocol/client\")'.

Impact

The four trigger patterns are uncommon — most v1 code uses named imports of StdioClientTransport, which route correctly via the per-symbol addPending path (and the existing tests cover exactly that). But when one of these patterns is hit, the codemod produces non-compiling output (or a silently dead mock) with zero diagnostics, which is the worst codemod failure mode. The automock case in particular is a silent test-behavior change: the test still passes the mock setup but the real StdioClientTransport ends up loaded.

Fix

One-line change matching the sibling server/stdio.js entry:

'@modelcontextprotocol/sdk/client/stdio.js': {
    target: '@modelcontextprotocol/client/stdio',
    status: 'moved'
},

The symbolTargetOverrides block becomes a no-op once the base target equals the override target, so it can be deleted. The existing tests for named/aliased imports ('rewrites client stdio to @modelcontextprotocol/client/stdio subpath', 'preserves alias for client stdio import and routes to subpath', 'rewrites client stdio mock to /stdio subpath') will continue to pass unchanged. Adding a test for import * as stdio from '@modelcontextprotocol/sdk/client/stdio.js' would lock the fix in.

@felixweinberger felixweinberger merged commit 48251fe into main May 21, 2026
33 of 34 checks passed
@felixweinberger felixweinberger deleted the feature/v2-codemode-draft branch May 21, 2026 13:13
mattzcarey added a commit that referenced this pull request May 22, 2026
…kage

The packages/codemod/ package (added in #1950) was written before the
bundler resolution flip and used the old .js-extension import convention.
This brings it in line with the rest of the repo. Backtick string fixtures
that simulate user code (which intentionally still uses .js) are left alone.
@claude claude Bot mentioned this pull request May 22, 2026
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.

4 participants