') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(codemod): keep a file's leading comment block above rewritten imports by axits-lab · Pull Request #2582 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

fix(codemod): keep a file's leading comment block above rewritten imports - #2582

Open
axits-lab wants to merge 1 commit into
modelcontextprotocol:mainfrom
axits-lab:fix/codemod-preserve-file-header
Open

fix(codemod): keep a file's leading comment block above rewritten imports#2582
axits-lab wants to merge 1 commit into
modelcontextprotocol:mainfrom
axits-lab:fix/codemod-preserve-file-header

Conversation

@axits-lab

Copy link
Copy Markdown

Fixes#2575

Root cause

v1-to-v2 positions a re-emitted import against the full start of the declaration it replaces, which is ahead of that declaration's leading trivia. Both shapes in the issue follow from that one fact:

Shape A — header hoisted over, blank line consumed

// Copyright (c) 2026 Example Corp.// SPDX-License-Identifier: Apache-2.0importtype{CallToolResult}from'@modelcontextprotocol/sdk/types.js';exportconstok=(): CallToolResult=>({content: []});

became

importtype{CallToolResult}from"@modelcontextprotocol/server";// Copyright (c) 2026 Example Corp.// SPDX-License-Identifier: Apache-2.0exportconstok=(): CallToolResult=>({content: []});

The header stops being the first thing in the file, so eslint-plugin-header / eslint-plugin-notice / SPDX scanners start failing on a file that previously passed. The blank line goes with it, so the header now reads as a doc comment on export const ok.

Shape B — import inserted inside the header run

When the header is a multi-line // run and the SDK import is not the first import, line 1 is stranded above an unrelated import:

// page_to_markdown tool: fetches a URL and returns clean Markdown.importtype{CallToolResult}from"@modelcontextprotocol/server";// 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';

As the reporter notes, Prettier does not move the import back, so the guide's "run your formatter" step does not cover either shape.

Fix

Detach the comment block anchored at byte 0 for the duration of the rewrite and restore the exact bytes afterwards — the trivia those insertion positions are derived from no longer exists while imports are emitted.

Scope is deliberately narrow: only a block starting at byte 0 is treated as a file header. A comment above a mid-file import documents that import and must travel with it, which the existing leading-comment capture still handles (covered by a third test).

The detached slice includes the whitespace following the block, so the blank line separating header from code survives the round trip.

On the existing tests

There were already three leading-header tests. All three assert only that the header's content survives (toContain) — none assert its position, which is why both shapes passed them. The new tests assert position and the surviving blank line. Verified they fail on main without the src change:

× keeps a license header above the rewritten import, with its blank line intact
× does not split a multi-line header run when the SDK import is not the first import

Verification

  • 632/632 codemod tests pass (629 existing + 3 new)
  • pnpm lint and pnpm typecheck clean
  • Built the CLI and ran it against the reporter's exact repro — output now matches their "Expected" block byte-for-byte:
// Copyright (c) 2026 Example Corp.// SPDX-License-Identifier: Apache-2.0importtype{CallToolResult}from"@modelcontextprotocol/server";exportconstok=(): CallToolResult=>({content: []});

Changeset included.

…orts
`v1-to-v2` inserts a re-emitted import against the full start of the
declaration it replaces, which is ahead of that declaration's leading
trivia. Two things follow, both reported in modelcontextprotocol#2575:
- A license/SPDX header stops being the first thing in the file, so
eslint-plugin-header, eslint-plugin-notice and SPDX scanners start
failing on a file that previously passed. The blank line under the
header is consumed with it, leaving the header attached to the next
declaration as a doc comment.
- When the header is a multi-line `//` run and the SDK import is not the
first import, the new import is inserted between two header lines,
stranding line 1 above an unrelated import.
Prettier does not move the import back, so the guide's "run your
formatter" step does not cover either shape.
Detach the block anchored at byte 0 for the duration of the rewrite and
restore the exact bytes afterwards, so the positions the insertion is
derived from no longer exist. Only a block starting at byte 0 is treated
as a file header; a comment above a mid-file import documents that import
and still travels with it.
The three existing leading-header tests only assert the header's content
survives, which is why both shapes passed them — the new tests assert its
position and the surviving blank line.
Fixesmodelcontextprotocol#2575
@axits-lab
axits-lab requested a review from a team as a code ownerJuly 30, 2026 13:22
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4afd7e4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
NameType
@modelcontextprotocol/codemodPatch
@modelcontextprotocol/corePatch
@modelcontextprotocol/clientPatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/server-legacyPatch
@modelcontextprotocol/core-internalPatch

Not sure what this means? Click here to learn what changesets are.

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

@pkg-pr-new

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2582

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2582

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2582

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2582

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2582

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2582

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2582

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2582

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2582

commit: 4afd7e4

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

Labels

v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

codemod v1-to-v2 hoists rewritten imports above the file's license header

1 participant

@axits-lab