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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/codemod-preserve-file-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/codemod': patch
---

The `v1-to-v2` codemod now writes rewritten imports where the first v1 import stood, not at the top of the file, so a license header, `// @ts-nocheck`, `/// <reference>` or a `'use client'` / `'use server'` / `'use strict'` directive above it stays in place. Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is `ErrorCode` or `StreamableHTTPError`), the new import can still land above or inside the header, and a `/** */` header can be removed. Files already migrated with codemod 2.1.0 or earlier are not repaired; check the top of those files.
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ export const importPathsTransform: Transform = {
return spec.includes('/server/');
});

const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!);
const importIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): CLAUDE.md formatting asks for 2-space indentation: every added line in importPaths.ts, importUtils.ts, commentInsertion.test.ts and importPaths.test.ts is indented in 4-space steps (e.g. 8 spaces before const importIndex). Semicolons and single quotes are fine. Fix: the repo's committed .prettierrc sets tabWidth: 4 and pnpm lint:all enforces it, so re-indenting to 2 spaces would fail lint; the compliant resolution is to bring the CLAUDE.md line in line with the Prettier config (or vice versa) rather than reformat this diff. Same instruction across all 4 changed code files.

Why this was flagged

Nothing fails at runtime. The written rule (2-space) and the enforced tool config (.prettierrc tabWidth 4, checked by pnpm lint:all) contradict each other, and this diff follows the tool rather than the text, as does all existing code in packages/codemod. The only cost is that contributors reading CLAUDE.md are told a convention the linter rejects; a one-line edit to CLAUDE.md removes the contradiction. Filed only because the instruction as written is broken by the added lines; this is a wording/layout nit for the maintainer to weigh.

Verification: Base CLAUDE.md § Code Style Guidelines reads verbatim "- Formatting: 2-space indentation, semicolons required, single quotes preferred", and it binds the whole repo (no nested CLAUDE.md under packages/codemod). Added line packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts:55 const importIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); is…

// ts-morph inserts by position among all top-level children (own-line comments and statements count), not among imports.
const childIndex = sdkImports[0]!.getChildIndex();
const previousEnd = childIndex > 0 ? sourceFile.getStatementsWithComments()[childIndex - 1]!.getEnd() : 0;
const blankLineAbove = childIndex > 0 && /^[ \t]*\r?\n[ \t]*\r?\n/.test(sourceFile.getFullText().slice(previousEnd));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): a blank line between a preceding import that ends in a trailing comment and the re-emitted SDK import is dropped after migration. blankLineAbove at importPaths.ts:59 tests the text from getEnd(), which for import a from 'a'; // note starts at // note, so the regex never sees the blank line and the flag stays false. Fix: measure the gap from the previous sibling's trailing-trivia end (getTrailingTriviaEnd()) rather than getEnd() so a same-line trailing comment on any preceding statement or comment does not hide the blank line; previousEnd for the leadingRanges filter can use the same position since trailing comments are never leading ranges of the import.

Why this was flagged

Input: import a from 'a'; // eslint-disable-line\n\nimport { X } from '@ modelcontextprotocol/sdk/types.js';, run through importPathsTransform.apply via the runner. importPaths.ts:58 sets previousEnd to the previous import's getEnd(), which is the position right after ;, so importPaths.ts:59 slices // eslint-disable-line\n\nimport and the regex ^[ \t]*\r?\n[ \t]*\r?\n fails; blankLineAbove is false. The SDK import is removed at importPaths.ts:333 and re-inserted at importPaths.ts:361 with blankLineAbove false, so importUtils.ts:105 emits no leadingTrivia; ts-morph writes only a newline between two import declarations. Result: import a from 'a'; // eslint-disable-line\nimport { X } from "@ modelcontextprotocol/server"; with the separating blank line gone. On the base branch the import went to line 1 above everything, so this is an improvement, but the PR's stated blank-line preservation does not hold for this shape. When the previous sibling is a non-import statement ts-morph adds a blank line itself, so only the previous-import (and comment-node) cases are affected.

Verification: nit — triggered when the statement directly above the first SDK import carries a same-line trailing comment (e.g. import a from 'a'; // eslint-disable-line) and is separated from it by a blank line. Mechanism verified in /home/claude/typescript-sdk/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts: line 58 `const previousEnd = childIndex > 0 ?… | nit — triggered when the…


// A leading file-header / JSDoc comment attaches to the first SDK import as leading trivia. When
// that import is removed and re-emitted (the per-symbol split/merge path calls imp.remove()),
Expand All @@ -62,9 +66,12 @@ export const importPathsTransform: Transform = {
// (a blank line, or CRLF in CRLF files), so the later survival check would never match a header
// that actually survived (in-place setModuleSpecifier rewrite) and would re-insert it, duplicating
// it. The slice reproduces the block verbatim, so the includes() guard below is byte-exact.
const leadingRanges = sdkImports[0]!.getLeadingCommentRanges();
// Own-line comments above the import are siblings and survive its removal; only comments attached to it can be dropped.
const leadingRanges = sdkImports[0]!.getLeadingCommentRanges().filter(range => range.getPos() >= previousEnd);
const leadingCommentText =
leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges[0]!.getPos(), leadingRanges.at(-1)!.getEnd()) : '';
const leadingCommentGap =
leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges.at(-1)!.getEnd(), sdkImports[0]!.getStart()) : '';

interface PendingImport {
specs: NamedImportSpec[];
Expand Down Expand Up @@ -334,6 +341,10 @@ export const importPathsTransform: Transform = {
}
}

// New imports go where the first SDK import stood, or right below it when it was rewritten in place and still stands there.
const firstRemoved = sdkImports[0]!.wasForgotten();
const insertIndex = firstRemoved ? childIndex : childIndex + 1;

const specLocal = (spec: NamedImportSpec): string => (typeof spec === 'string' ? spec : (spec.alias ?? spec.name));
for (const [target, groups] of pendingImports) {
// Dedupe by local binding name (alias when present), keeping the spec so aliases survive.
Expand All @@ -345,21 +356,31 @@ export const importPathsTransform: Transform = {
}
}

let valueInserted = false;
if (valueSpecs.size > 0) {
addOrMergeImport(sourceFile, target, [...valueSpecs.values()], false, insertIndex);
valueInserted = addOrMergeImport(
sourceFile,
target,
[...valueSpecs.values()],
false,
insertIndex,
firstRemoved && blankLineAbove
);
}
if (typeOnlySpecs.size > 0) {
const typeInsertIndex = valueSpecs.size > 0 ? insertIndex + 1 : insertIndex;
addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex);
// The type import goes one lower only when the value import was inserted, not when it merged into an existing import.
const typeInsertIndex = valueInserted ? insertIndex + 1 : insertIndex;
const blankLine = firstRemoved && blankLineAbove && !valueInserted;
addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex, blankLine);
}
Comment on lines +359 to 375

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟣 pre-existing, not blocking: Pre-existing gap the fix leaves open: a header's blank line is still consumed when the removed first SDK import produces no new declaration. The blank line is only re-emitted through the leadingTrivia of an inserted import (importPaths.ts:367 and :373), so when every pending value import merges into an existing v2 import and there are no type-only specs, or when the removed import yields no pending imports at all, nothing carries blankLineAbove and the header ends up touching the next statement. Fix: after the pending loop, when firstRemoved && blankLineAbove and no declaration was inserted at childIndex, re-insert the blank line above the statement now at childIndex (e.g. insertText of '\n' at its start), so the header keeps its separation on every path, not only the insert path.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Input: '// Copyright (c) 2026 Example Corp.', a blank line, 'import { StreamableHTTPClientTransport } from '@ modelcontextprotocol/sdk/client/streamableHttp.js';', then 'import { Client } from '@ modelcontextprotocol/client';' (a partially migrated file, the same mixed state the PR's own test at importPaths.test.ts:905 models). importPaths.ts:57-59 computes childIndex=1 and blankLineAbove=true. importPaths.ts:333 calls imp.remove(); ts-morph joins the comment node and the Client import with a single newline, consuming the blank line. importPaths.ts:361 then calls addOrMergeImport, which finds the existing '@ modelcontextprotocol/client' import (importUtils.ts:80-91) and merges, returning false and never emitting the blankLineIfLastNot trivia; typeOnlySpecs is empty so importPaths.ts:374 never runs. Output is '// Copyright (c) 2026 Example Corp.' directly followed by 'import { Client, StreamableHTTPClientTransport } ...', the header now reads as a doc comment on the import. The same happens when the removed import's only specifiers are removedSymbols (no addPending) or the module…

Verification: pre-existing — the base already drops the blank line on this path by the same route, and the PR's fix does not reach it. Trigger: the first SDK import sits under a header + blank line and is removed (line 333 imp.remove(), or line 155 for a status: 'removed' module such as @ modelcontextprotocol/sdk/client/websocket.js, importMap.ts:75-77) but produces no inserted declaration: either…

}

// Restore the captured leading comment if the rewrite dropped it (guard against duplication when
// the first import was rewritten in place and kept its comment).
if (leadingCommentText && !sourceFile.getFullText().includes(leadingCommentText)) {
const imports = sourceFile.getImportDeclarations();
const anchor = imports[Math.min(insertIndex, imports.length - 1)];
sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}\n`);
const anchor = imports[Math.min(importIndex, imports.length - 1)];
sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}${leadingCommentGap}`);
}

return { changesCount, diagnostics, usedPackages };
Expand Down
15 changes: 10 additions & 5 deletions packages/codemod/src/utils/importUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,16 @@ function specLocalName(s: { name: string; alias?: string }): string {
return s.alias ?? s.name;
}

/** Adds the names to an existing import of the module, or inserts a new import; returns true when it inserted one. */
export function addOrMergeImport(
sourceFile: SourceFile,
moduleSpecifier: string,
namedImports: NamedImportSpec[],
isTypeOnly: boolean,
insertIndex: number
): void {
if (namedImports.length === 0) return;
insertIndex: number,
blankLineAbove = false
): boolean {
if (namedImports.length === 0) return false;

const specs = namedImports.map(n => toSpec(n));

Expand All @@ -86,6 +88,7 @@ export function addOrMergeImport(
if (newSpecs.length > 0) {
existing.addNamedImports(newSpecs.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })));
}
return false;
} else {
const seen = new Set<string>();
const deduped = specs.filter(s => {
Expand All @@ -94,12 +97,14 @@ export function addOrMergeImport(
seen.add(local);
return true;
});
const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length);
const clampedIndex = Math.min(insertIndex, sourceFile.getStatementsWithComments().length);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟣 pre-existing, not blocking: pre-existing: a user whose only SDK import is StreamableHTTPError (or a schema-input import) still gets the replacement import placed above a license header or 'use client' directive, the #2575 shape this PR fixes. importUtils.ts:100 now clamps and inserts by statement index, but removedApis.ts:201 and symbolRenames.ts:737 still pass getImportDeclarations().length, an import count. With a comment node or directive at index 0 that lands before the last import, or at index 0 above the header when no import remains. Fix: pass every caller a statement-based position (child index after the last import via getStatementsWithComments, or let addOrMergeImport translate an import-relative index), which covers the 2 sites listed. Same pattern at 2 sites (removedApis.ts:201, symbolRenames.ts:737). [also at: packages/codemod/src/utils/importUtils.ts:100 - nit: REVIEW.md Completeness asks that a replaced pattern be swept from the package and every leftover site flagged: this PR changes addOrMergeImport to treat insertIndex as a position among getStatementsWithComments() and reworks importPaths.ts to compute it from getChildIndex(), but the…]
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Input: a file starting with // Copyright ... or 'use client';, a blank line, and import { StreamableHTTPError } from '@ modelcontextprotocol/sdk/client/streamableHttp.js'; as its only import, run through the v1-to-v2 migration. importPaths keeps the header in place (the new code at importPaths.ts:345-346), but handleStreamableHTTPError in removedApis.ts:195-197 then removes the emptied declaration and at removedApis.ts:201 computes insertIndex = sourceFile.getImportDeclarations().length, which is 0. addOrMergeImport at importUtils.ts:100-101 clamps against getStatementsWithComments().length and calls insertImportDeclaration(0), so the SdkHttpError import is written before the header comment or directive at statement index 0. The header is…

Verification: pre-existing; acknowledged in diff: .changeset/codemod-preserve-file-header.md explicitly says "Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is ErrorCode or StreamableHTTPError), the new import can still land above or inside the header" — the note is accurate, and the base branch already produces the same output…

sourceFile.insertImportDeclaration(clampedIndex, {
moduleSpecifier,
namedImports: deduped.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })),
isTypeOnly
isTypeOnly,
leadingTrivia: blankLineAbove ? writer => writer.blankLineIfLastNot() : undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Users with a directive directly above their SDK import, with no blank line, get an extra blank line inserted between the directive and the rewritten import, a diff the base branch never produced in that spot. importUtils.ts:100-106 inserts at a child index whose previous sibling is an ExpressionStatement, and ts-morph's _standardWrite emits blankLineIfLastNot for any previous member that is not an import or comment. Fix: only accept ts-morph's separator when blankLineAbove is true; otherwise re-emit the original gap (as leadingCommentGap does) so 'use strict';\nimport round-trips byte-for-byte, and add a no-blank-line directive test alongside the existing 'use client' test at importPaths.test.ts.

Why this was flagged

Trigger: a file beginning 'use strict'; or 'use client'; immediately followed (no blank line) by a v1 SDK import that the transform removes and re-emits (importPaths.ts:333 then :361). Entry point: every codemod run over a Node/Next.js codebase; directive-first files are common in CommonJS-style TS and React server/client components. importPaths.ts:59 computes blankLineAbove = false for this input, and importUtils.ts:105 therefore passes no leadingTrivia. But ts-morph's insertImportDeclarations uses _standardWrite with previousNewLine only true for imports and comments; for an ExpressionStatement previous member it calls writer.blankLineIfLastNot() itself, so the output becomes 'use strict';\n\nimport .... The new test at importPaths.test.ts (keeps a 'use client' directive above the rewritten import) only covers the input that already has a blank line, so the added line is never asserted against. The base branch inserted at import index 0, above the directive (a different wrong), so the byte change is new for this population and produces an unexpected formatting diff in…

Verification: nit — triggered when a file's first SDK import sits directly under a directive prologue ('use strict'; / 'use client';) with no blank line and goes down the remove-and-re-emit path. Mechanism verified in the diff: importPaths.ts:57-59 sets childIndex = index of the SDK import among getStatementsWithComments() (1 when the directive is child 0) and blankLineAbove = false for `'use… | nit…

});
return true;
}
}

Expand Down
30 changes: 30 additions & 0 deletions packages/codemod/test/commentInsertion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,36 @@ describe('comment insertion', () => {
});

describe('markers whose import-declaration anchor is removed by the same pass', () => {
it('keeps a license header first and still writes the marker below it (#2575)', () => {
const dir = createTempDir();
writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'app', dependencies: { '@modelcontextprotocol/sdk': '^1.29.0' } })
);
const file = path.join(dir, 'auth.ts');
writeFileSync(
file,
[
`// Copyright (c) 2026 Example Corp.`,
`// SPDX-License-Identifier: Apache-2.0`,
``,
`import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';`,
``,
`export const guard = requireBearerAuth({ verifier });`,
''
].join('\n')
);

const result = run(migration, { targetDir: dir, dryRun: false });

const lines = readFileSync(file, 'utf8').split('\n');
expect(lines.slice(0, 3)).toEqual(['// Copyright (c) 2026 Example Corp.', '// SPDX-License-Identifier: Apache-2.0', '']);
expect(lines[3]).toBe(`import { requireBearerAuth } from "@modelcontextprotocol/server-legacy/auth";`);
const markerIndex = lines.findIndex(line => line.includes(CODEMOD_ERROR_PREFIX));
expect(lines[markerIndex + 1]).toContain('requireBearerAuth({ verifier })');
expect(result.diagnostics.find(d => d.insertComment)?.line).toBe(markerIndex + 1);
});

it('inserts the resource-server auth helper marker at the usage site', () => {
const dir = createTempDir();
writeFileSync(
Expand Down
152 changes: 152 additions & 0 deletions packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,135 @@ describe('import-paths transform', () => {
expect(result).toContain('@modelcontextprotocol/server');
});

it('keeps a license header above the rewritten import, with its blank line intact', () => {
// #2575: the header survived in the text but the re-emitted import was inserted above it, so
// the header stopped being the first thing in the file (breaking eslint-plugin-header /
// SPDX scanners) and the blank line separating it from the code was consumed, turning the
// header into a doc comment for the next declaration. Content-only assertions miss both.
const input = [
`// Copyright (c) 2026 Example Corp.`,
`// SPDX-License-Identifier: Apache-2.0`,
``,
`import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`,
``,
`export const ok = (): CallToolResult => ({ content: [] });`,
''
].join('\n');
const result = applyTransform(input, { projectType: 'server' });
const lines = result.split('\n');

expect(lines[0]).toBe('// Copyright (c) 2026 Example Corp.');
expect(lines[1]).toBe('// SPDX-License-Identifier: Apache-2.0');
// the blank line between header and code must survive, or the header reads as a doc comment
expect(lines[2]).toBe('');
expect(lines.findIndex(l => l.startsWith('import'))).toBeGreaterThan(1);
expect(result).toContain('@modelcontextprotocol/server');
expect(result).toBe(input.replace(`'@modelcontextprotocol/sdk/types.js'`, `"@modelcontextprotocol/server"`));
});

it('does not split a multi-line header run when the SDK import is not the first import', () => {
// #2575, second shape: the rewritten import was inserted *inside* the leading `//` run,
// stranding line 1 above an unrelated import.
const input = [
`// page_to_markdown tool: fetches a URL and returns clean Markdown.`,
`// Uses @page2ai/core under the hood - inherits SSRF protection and a 10MB cap.`,
`// Static tab discovery emits per-tab sections for docs sites.`,
``,
`import { fetchAndConvert } from '@page2ai/core';`,
`import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`,
``,
`export const x = (r: CallToolResult) => fetchAndConvert(r);`,
''
].join('\n');
const result = applyTransform(input, { projectType: 'server' });
const lines = result.split('\n');

// the three header lines stay contiguous at the top of the file
expect(lines[0]).toContain('page_to_markdown tool');
expect(lines[1]).toContain('@page2ai/core under the hood');
expect(lines[2]).toContain('Static tab discovery');
expect(lines.findIndex(l => l.startsWith('import'))).toBeGreaterThan(2);
expect(result).toContain('@modelcontextprotocol/server');
expect(result).toBe(input.replace(`'@modelcontextprotocol/sdk/types.js'`, `"@modelcontextprotocol/server"`));
});

it('leaves a comment above a mid-file import attached to that import', () => {
// A comment above a later import documents that import: it stays directly above it.
const input = [
`import { fetchAndConvert } from '@page2ai/core';`,
``,
`// Result type returned to the caller.`,
`import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`,
``,
`export const x = (r: CallToolResult) => fetchAndConvert(r);`,
''
].join('\n');
const result = applyTransform(input, { projectType: 'server' });

expect(result.split('// Result type returned to the caller.').length - 1).toBe(1);
expect(result.indexOf('// Result type returned to the caller.')).toBeGreaterThan(result.indexOf('@page2ai/core'));
expect(result).toBe(input.replace(`'@modelcontextprotocol/sdk/types.js'`, `"@modelcontextprotocol/server"`));
});

it(`keeps a 'use client' directive above the rewritten import`, () => {
const input = [
`'use client';`,
``,
`import { Client } from '@modelcontextprotocol/sdk/client/index.js';`,
`import { useState } from 'react';`,
''
].join('\n');
const expected = input.replace(`'@modelcontextprotocol/sdk/client/index.js'`, `"@modelcontextprotocol/client"`);
expect(applyTransform(input)).toBe(expected);
});

it('keeps the blank line below a JSDoc-style license header', () => {
const input = [
`/**`,
` * @license Apache-2.0`,
` */`,
``,
`import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`,
``,
`export const server = new McpServer({ name: 'x', version: '1.0.0' });`,
''
].join('\n');
const expected = input.replace(`'@modelcontextprotocol/sdk/server/mcp.js'`, `"@modelcontextprotocol/server"`);
expect(applyTransform(input)).toBe(expected);
});

it('does not duplicate a license header that is followed by a JSDoc block', () => {
const input = [
`// Copyright (c) 2026 Example Corp.`,
``,
`/**`,
` * What this file does.`,
` */`,
``,
`import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`,
``,
`export const server = new McpServer({ name: 'x', version: '1.0.0' });`,
''
].join('\n');
const expected = input.replace(`'@modelcontextprotocol/sdk/server/mcp.js'`, `"@modelcontextprotocol/server"`);
expect(applyTransform(input)).toBe(expected);
});

it('puts new imports below a namespace import that is rewritten in place, not above the header', () => {
const input = [
`/** @license Apache-2.0 */`,
``,
`import * as types from '@modelcontextprotocol/sdk/types.js';`,
`import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`,
''
].join('\n');
const lines = applyTransform(input, { projectType: 'server' }).split('\n');
expect(lines[0]).toBe(`/** @license Apache-2.0 */`);
expect(lines[1]).toBe('');
expect(lines[2]).toContain('* as types');
expect(lines[3]).toBe(`import { McpServer } from "@modelcontextprotocol/server";`);
});

it('routes OAuth *Schema from sdk/shared/auth.js to core; the TYPE resolves by context', () => {
// OAuthTokensSchema is a Zod schema re-exported by core (AUTH_SCHEMA_NAMES), so route it
// there — `OAuthTokensSchema.parse(...)` keeps working. OAuthTokens (the type) has no schema-name
Expand Down Expand Up @@ -766,6 +895,29 @@ describe('import-paths transform', () => {
const output = sourceFile.getFullText();
expect(output).toContain('@modelcontextprotocol/client');
expect(output).not.toContain('@modelcontextprotocol/sdk');
const lines = output.split('\n');
expect(lines[0]).toBe(`import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';`);
expect(lines[1]).toBe(`import type { Tool } from "@modelcontextprotocol/client";`);
expect(lines.findLastIndex(line => line.startsWith('import '))).toBeLessThan(lines.findIndex(line => line.startsWith('const c')));
});

it('keeps the type import with the imports when the value import merges into an existing v2 import', () => {
const input = [
`// Copyright (c) 2026 Example Corp.`,
`// SPDX-License-Identifier: Apache-2.0`,
``,
`import { Client } from '@modelcontextprotocol/client';`,
`import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`,
`import type { Tool } from '@modelcontextprotocol/sdk/types.js';`,
``,
`const c = new Client({});`,
''
].join('\n');
const lines = applyTransform(input, { projectType: 'client' }).split('\n');
expect(lines.slice(0, 3)).toEqual(['// Copyright (c) 2026 Example Corp.', '// SPDX-License-Identifier: Apache-2.0', '']);
expect(lines[3]).toBe(`import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';`);
expect(lines[4]).toBe(`import type { Tool } from "@modelcontextprotocol/client";`);
expect(lines.findLastIndex(line => line.startsWith('import '))).toBeLessThan(lines.findIndex(line => line.startsWith('const c')));
});

it('applies SIMPLE_RENAMES to re-export specifiers', () => {
Expand Down
Loading