v2: codemod iterations, canonical zod schema exports from sdk-shared (#2354)

Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
Konstantin Konstantinov 2026-06-25 21:42:44 +03:00 • committed by GitHub
commit 0fb8406d83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2954 additions and 1665 deletions

View file

@ -85,9 +85,10 @@ const BATCH_DIR = path.resolve(SDK_ROOT, 'packages/codemod/batch-test');
const LOCAL_PACKAGE_DIRS: Record<string, string> = {
'@modelcontextprotocol/client': path.join(SDK_ROOT, 'packages/client'),
'@modelcontextprotocol/core': path.join(SDK_ROOT, 'packages/core'),
'@modelcontextprotocol/core-internal': path.join(SDK_ROOT, 'packages/core-internal'),
'@modelcontextprotocol/server': path.join(SDK_ROOT, 'packages/server'),
'@modelcontextprotocol/server-legacy': path.join(SDK_ROOT, 'packages/server-legacy'),
'@modelcontextprotocol/core': path.join(SDK_ROOT, 'packages/core'),
'@modelcontextprotocol/express': path.join(SDK_ROOT, 'packages/middleware/express'),
'@modelcontextprotocol/fastify': path.join(SDK_ROOT, 'packages/middleware/fastify'),
'@modelcontextprotocol/hono': path.join(SDK_ROOT, 'packages/middleware/hono'),
@ -110,6 +111,22 @@ function detectPm(repoRoot: string): string {
return 'npm';
}
function installCommand(pm: string): string {
if (pm !== 'pnpm') return `${pm} install --ignore-scripts`;
// pnpm walks up to find a workspace; clones live inside this SDK's pnpm workspace, so a plain
// `pnpm install` targets the OUTER workspace and never populates the clone's node_modules — every
// downstream check (tsc base config, tsup, vitest) then fails identically at baseline and post,
// masking real codemod signal.
// --ignore-workspace: treat the clone as a standalone project (not part of the SDK workspace).
// --no-frozen-lockfile: the codemod rewrites package.json to swap v1 → v2 deps, so the lockfile
// must be allowed to change. CI=true (set in shell()) otherwise defaults
// pnpm to a frozen lockfile and the post-codemod reinstall silently skips
// the new v2 deps, leaving the clone on v1.
// npm/yarn/bun key off a `workspaces` field in package.json (absent at this repo root), so they
// need no equivalent flags.
return 'pnpm install --ignore-scripts --ignore-workspace --no-frozen-lockfile';
}
function detectCheckCmd(pkgDir: string, checkType: string): string | null {
const pkgJsonPath = path.join(pkgDir, 'package.json');
if (!existsSync(pkgJsonPath)) return null;
@ -132,7 +149,11 @@ function shell(cmd: string, cwd?: string): { exitCode: number; stdout: string; s
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
timeout: 5 * 60 * 1000
timeout: 5 * 60 * 1000,
// Commands are spawned without a TTY (piped stdio). Set CI so package managers run fully
// non-interactively — without it, pnpm aborts rebuilding a clone's modules dir with
// ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY when --ignore-workspace changes its link mode.
env: { ...process.env, CI: 'true' }
}).toString();
return { exitCode: 0, stdout, stderr: '' };
} catch (error: unknown) {
@ -318,7 +339,7 @@ function main(): void {
// Step 3: Install
console.log(' Installing dependencies...');
const installResult = shell(`${pm} install --ignore-scripts`, clonePath);
const installResult = shell(installCommand(pm), clonePath);
if (installResult.exitCode !== 0) {
console.log(` ERROR: install failed, skipping\n ${installResult.stderr.split('\n')[0]}`);
continue;
@ -366,7 +387,7 @@ function main(): void {
console.log(` Rewrote ${rewrites} deps to local tarballs`);
}
console.log(' Re-installing dependencies...');
shell(`${pm} install --ignore-scripts`, clonePath);
shell(installCommand(pm), clonePath);
// Step 7: Post-codemod checks
console.log(' Running post-codemod checks...');

View file

@ -9,11 +9,36 @@ import { Command } from 'commander';
import { listMigrations } from './migrations/index';
import { run } from './runner';
import { DiagnosticLevel } from './types';
import { detectFormatter } from './utils/detectFormatter';
import { CODEMOD_ERROR_PREFIX, formatDiagnostic } from './utils/diagnostics';
const require = createRequire(import.meta.url);
const { version } = require('../package.json') as { version: string };
function quoteArg(arg: string): string {
return /\s/.test(arg) ? `"${arg}"` : arg;
}
/**
* The codemod transforms the AST but does not reformat — wrapped schemas and
* generated string literals can violate a repo's lint/formatting rules. Point
* the user at their own formatter (which respects their config) for the exact
* files that changed.
*/
function printFormatGuidance(targetDir: string, changedFiles: string[]): void {
if (changedFiles.length === 0) return;
const formatter = detectFormatter(targetDir);
const fileArgs = changedFiles.map(file => quoteArg(path.relative(process.cwd(), file) || file));
console.log("This codemod doesn't reformat its output. Run your formatter on the changed file(s):");
if (formatter) {
console.log(` ${formatter.bin} ${[...formatter.writeArgs, ...fileArgs].join(' ')}\n`);
} else {
console.log(` e.g. prettier --write ${fileArgs.join(' ')}\n`);
}
}
const program = new Command();
program.name('mcp-codemod').description('Codemod to migrate MCP TypeScript SDK code between versions').version(version);
@ -150,6 +175,8 @@ for (const [name, migration] of listMigrations()) {
if (opts['dryRun']) {
console.log('Run without --dry-run to apply changes.\n');
} else {
const changedFiles = result.fileResults.filter(fr => fr.changes > 0).map(fr => fr.filePath);
printFormatGuidance(resolvedDir, changedFiles);
if (result.packageJsonChanges) {
console.log('Run your package manager to install the new packages.\n');
}

View file

@ -4,5 +4,6 @@ export const V2_PACKAGE_VERSIONS: Record<string, string> = {
'@modelcontextprotocol/server': '^2.0.0-alpha.2',
'@modelcontextprotocol/node': '^2.0.0-alpha.2',
'@modelcontextprotocol/express': '^2.0.0-alpha.2',
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2'
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2',
'@modelcontextprotocol/core': '^2.0.0-alpha.0'
};

View file

@ -0,0 +1,30 @@
// The auth (OAuth / OpenID) Zod schema CONSTANTS that v1 exported from
// `@modelcontextprotocol/sdk/shared/auth.js` and that core still re-exports in v2. The import
// transform routes a `*Schema` symbol imported from that v1 path to core when its name is in this
// set (the corresponding TYPES, e.g. OAuthTokens, resolve by context to @modelcontextprotocol/client |
// /server). This is the v1 auth-schema set — a SUBSET of core's auth exports. v2-only auth schemas
// (e.g. IdJagTokenExchangeResponseSchema) are exported by core but NOT listed here: v1 never had
// them, so there is nothing to migrate. test/v1-to-v2/authSchemaNames.test.ts asserts every name here is
// exported by core (so the rewritten import resolves). Keep alphabetized.
export const AUTH_SCHEMA_NAMES: ReadonlySet<string> = new Set([
'OAuthClientInformationFullSchema',
'OAuthClientInformationSchema',
'OAuthClientMetadataSchema',
'OAuthClientRegistrationErrorSchema',
'OAuthErrorResponseSchema',
'OAuthMetadataSchema',
'OAuthProtectedResourceMetadataSchema',
'OAuthTokenRevocationRequestSchema',
'OAuthTokensSchema',
'OpenIdProviderDiscoveryMetadataSchema',
'OpenIdProviderMetadataSchema'
]);
// v1's `@modelcontextprotocol/sdk/shared/auth.js` also exported these as Zod schema CONSTANTS, but they
// are typeless internal URL field-validators (no public spec type), so v2's core deliberately does
// NOT re-export them (see packages/core/src/index.ts) and no other public v2 package exports them.
// They therefore have no v2 home: routed by context they would produce a codemod-introduced "has no
// exported member" error. importPaths emits an actionRequired diagnostic instead of silently breaking.
// test/v1-to-v2/authSchemaNames.test.ts asserts these are NOT in AUTH_SCHEMA_NAMES (and so are not
// claimed to be core exports).
export const AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT: ReadonlySet<string> = new Set(['SafeUrlSchema', 'OptionalSafeUrlSchema']);

View file

@ -4,6 +4,17 @@ export interface ImportMapping {
renamedSymbols?: Record<string, string>;
/** Route specific symbols to a different target package than `target`. */
symbolTargetOverrides?: Record<string, string>;
/**
* Route an imported symbol to this package (instead of `target`) when its rename-resolved name is
* a Zod schema constant re-exported by core — a member of `SPEC_SCHEMA_NAMES` (spec schemas,
* for `sdk/types.js`) or `AUTH_SCHEMA_NAMES` (OAuth/OpenID schemas, for `sdk/shared/auth.js`). The
* schemas now live in `@modelcontextprotocol/core` (so `<Name>Schema.parse(...)` keeps
* working), while the corresponding types/constants/guards resolve by context. Matching on
* membership (not a `*Schema` suffix) keeps TYPES whose name ends in `Schema` — e.g. the
* elicitation primitives `BooleanSchema`/`StringSchema`/`EnumSchema` — routed by context, where
* their types live. `symbolTargetOverrides` (exact-name) takes precedence.
*/
schemaSymbolTarget?: string;
removalMessage?: string;
/** No entries currently set this; scaffolding for when a v1 symbol has no v2 equivalent yet. */
isV2Gap?: boolean;
@ -119,6 +130,7 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
'@modelcontextprotocol/sdk/types.js': {
target: 'RESOLVE_BY_CONTEXT',
status: 'moved',
schemaSymbolTarget: '@modelcontextprotocol/core',
renamedSymbols: {
ResourceTemplate: 'ResourceTemplateType'
}
@ -137,7 +149,12 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
},
'@modelcontextprotocol/sdk/shared/auth.js': {
target: 'RESOLVE_BY_CONTEXT',
status: 'moved'
status: 'moved',
// OAuth/OpenID Zod schema constants (AUTH_SCHEMA_NAMES) are re-exported by core as a
// separate group, so route them there (keeping `OAuthTokensSchema.parse(...)` working). The
// OAuth/OpenID TYPES (OAuthTokens, etc.) carry no `schemaSymbolTarget` match and resolve by
// context to @modelcontextprotocol/client | /server.
schemaSymbolTarget: '@modelcontextprotocol/core'
},
'@modelcontextprotocol/sdk/shared/stdio.js': {
target: 'RESOLVE_BY_CONTEXT',
@ -190,3 +207,28 @@ for (const barrelSpecifier of ['@modelcontextprotocol/sdk/validation/index.js',
export function isAuthImport(specifier: string): boolean {
return specifier.includes('/server/auth/') || specifier.includes('/server/auth.');
}
// SDK subpath specifiers can be written with or without a JS extension
// (e.g. `@modelcontextprotocol/sdk/types` vs `.../types.js`) depending on the
// consumer's module resolution (`bundler`/`nodenext` allow the extensionless form).
// Normalize the extension so both spellings resolve to the same mapping. Built
// after every IMPORT_MAP entry above is populated; entries whose `.js` and
// extensionless forms coexist (e.g. `experimental/tasks`) share an identical
// mapping, so the collapse is lossless.
function stripJsExtension(specifier: string): string {
return specifier.replace(/\.(?:js|mjs|cjs)$/, '');
}
const NORMALIZED_IMPORT_MAP: Record<string, ImportMapping> = {};
for (const [key, mapping] of Object.entries(IMPORT_MAP)) {
NORMALIZED_IMPORT_MAP[stripJsExtension(key)] = mapping;
}
/**
* Resolves the v2 mapping for a v1 SDK import/export/mock specifier, tolerating
* JS extension variance. An exact match always wins; otherwise the specifier is
* matched ignoring a trailing `.js`/`.mjs`/`.cjs` (or its absence).
*/
export function lookupImportMapping(specifier: string): ImportMapping | undefined {
return IMPORT_MAP[specifier] ?? NORMALIZED_IMPORT_MAP[stripJsExtension(specifier)];
}

View file

@ -0,0 +1,39 @@
// Shared per-symbol routing logic for v1→v2 import/export/mock rewrites. Centralized here so the
// import-path transform (static imports/re-exports) and the mock-path transform (vi.mock/jest.mock
// factories, dynamic import() destructurings) route a given symbol to exactly the same v2 package.
import { AUTH_SCHEMA_NAMES } from './authSchemaNames';
import type { ImportMapping } from './importMap';
import { SPEC_SCHEMA_NAMES } from './specSchemaNames';
import { SIMPLE_RENAMES } from './symbolMap';
/** The v2 name a symbol resolves to after renames (per-mapping override, then global SIMPLE_RENAMES). */
export function resolveRenamedName(name: string, mapping: ImportMapping): string {
return mapping.renamedSymbols?.[name] ?? SIMPLE_RENAMES[name] ?? name;
}
/**
* True when `name` (after renames) is a Zod schema CONSTANT that core re-exports — either a spec
* schema (`SPEC_SCHEMA_NAMES`) or an OAuth/OpenID schema (`AUTH_SCHEMA_NAMES`). Membership (not a
* `*Schema` suffix) is what keeps TYPES whose name ends in `Schema` — e.g. `BooleanSchema` — out.
*/
export function isSharedSchemaConst(name: string, mapping: ImportMapping): boolean {
const resolved = resolveRenamedName(name, mapping);
return SPEC_SCHEMA_NAMES.has(resolved) || AUTH_SCHEMA_NAMES.has(resolved);
}
/**
* The per-symbol target package for a symbol imported/re-exported/mocked from `mapping`'s module, or
* `undefined` when the symbol should use the mapping's resolved `target`. Exact-name
* `symbolTargetOverrides` win over `schemaSymbolTarget`, which routes a symbol to the shared-schemas
* package only when its rename-resolved name is a schema constant re-exported by core (see
* `isSharedSchemaConst`).
*/
export function symbolTargetOverride(name: string, mapping: ImportMapping): string | undefined {
if (mapping.symbolTargetOverrides && name in mapping.symbolTargetOverrides) {
return mapping.symbolTargetOverrides[name];
}
if (mapping.schemaSymbolTarget && isSharedSchemaConst(name, mapping)) {
return mapping.schemaSymbolTarget;
}
return undefined;
}

View file

@ -14,7 +14,11 @@ export const SCHEMA_TO_METHOD: Record<string, string> = {
SetLevelRequestSchema: 'logging/setLevel',
PingRequestSchema: 'ping',
CompleteRequestSchema: 'completion/complete',
ListRootsRequestSchema: 'roots/list'
ListRootsRequestSchema: 'roots/list',
GetTaskRequestSchema: 'tasks/get',
GetTaskPayloadRequestSchema: 'tasks/result',
ListTasksRequestSchema: 'tasks/list',
CancelTaskRequestSchema: 'tasks/cancel'
};
export const NOTIFICATION_SCHEMA_TO_METHOD: Record<string, string> = {
@ -27,5 +31,6 @@ export const NOTIFICATION_SCHEMA_TO_METHOD: Record<string, string> = {
CancelledNotificationSchema: 'notifications/cancelled',
InitializedNotificationSchema: 'notifications/initialized',
RootsListChangedNotificationSchema: 'notifications/roots/list_changed',
ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete'
ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete',
TaskStatusNotificationSchema: 'notifications/tasks/status'
};

View file

@ -0,0 +1,164 @@
// AUTO-VERIFIED against @modelcontextprotocol/core's public exports by
// test/v1-to-v2/specSchemaNames.test.ts (drift guard). These are the spec Zod schema CONSTANTS that
// core re-exports as standalone values; the v1->v2 import transform routes a `*Schema` symbol
// imported from `@modelcontextprotocol/sdk/types.js` to core ONLY when its (rename-resolved)
// name is in this set. Names that merely END in `Schema` but are NOT here — e.g. the elicitation
// primitive TYPES `BooleanSchema`/`StringSchema`/`EnumSchema` (whose Zod const is `<Name>SchemaSchema`)
// — fall through to context resolution (@modelcontextprotocol/client | /server), where their TYPES
// live. Keep alphabetized.
export const SPEC_SCHEMA_NAMES: ReadonlySet<string> = new Set([
'AnnotationsSchema',
'AudioContentSchema',
'BaseMetadataSchema',
'BlobResourceContentsSchema',
'BooleanSchemaSchema',
'CallToolRequestParamsSchema',
'CallToolRequestSchema',
'CallToolResultSchema',
'CancelTaskRequestSchema',
'CancelTaskResultSchema',
'CancelledNotificationParamsSchema',
'CancelledNotificationSchema',
'ClientCapabilitiesSchema',
'ClientNotificationSchema',
'ClientRequestSchema',
'ClientResultSchema',
'CompatibilityCallToolResultSchema',
'CompleteRequestParamsSchema',
'CompleteRequestSchema',
'CompleteResultSchema',
'ContentBlockSchema',
'CreateMessageRequestParamsSchema',
'CreateMessageRequestSchema',
'CreateMessageResultSchema',
'CreateMessageResultWithToolsSchema',
'CreateTaskResultSchema',
'CursorSchema',
'DiscoverRequestSchema',
'DiscoverResultSchema',
'ElicitRequestFormParamsSchema',
'ElicitRequestParamsSchema',
'ElicitRequestSchema',
'ElicitRequestURLParamsSchema',
'ElicitResultSchema',
'ElicitationCompleteNotificationParamsSchema',
'ElicitationCompleteNotificationSchema',
'EmbeddedResourceSchema',
'EmptyResultSchema',
'EnumSchemaSchema',
'GetPromptRequestParamsSchema',
'GetPromptRequestSchema',
'GetPromptResultSchema',
'GetTaskPayloadRequestSchema',
'GetTaskPayloadResultSchema',
'GetTaskRequestSchema',
'GetTaskResultSchema',
'IconSchema',
'IconsSchema',
'ImageContentSchema',
'ImplementationSchema',
'InitializeRequestParamsSchema',
'InitializeRequestSchema',
'InitializeResultSchema',
'InitializedNotificationSchema',
'JSONArraySchema',
'JSONObjectSchema',
'JSONRPCErrorResponseSchema',
'JSONRPCMessageSchema',
'JSONRPCNotificationSchema',
'JSONRPCRequestSchema',
'JSONRPCResponseSchema',
'JSONRPCResultResponseSchema',
'JSONValueSchema',
'LegacyTitledEnumSchemaSchema',
'ListPromptsRequestSchema',
'ListPromptsResultSchema',
'ListResourceTemplatesRequestSchema',
'ListResourceTemplatesResultSchema',
'ListResourcesRequestSchema',
'ListResourcesResultSchema',
'ListRootsRequestSchema',
'ListRootsResultSchema',
'ListTasksRequestSchema',
'ListTasksResultSchema',
'ListToolsRequestSchema',
'ListToolsResultSchema',
'LoggingLevelSchema',
'LoggingMessageNotificationParamsSchema',
'LoggingMessageNotificationSchema',
'ModelHintSchema',
'ModelPreferencesSchema',
'MultiSelectEnumSchemaSchema',
'NotificationSchema',
'NumberSchemaSchema',
'PaginatedRequestParamsSchema',
'PaginatedRequestSchema',
'PaginatedResultSchema',
'PingRequestSchema',
'PrimitiveSchemaDefinitionSchema',
'ProgressNotificationParamsSchema',
'ProgressNotificationSchema',
'ProgressSchema',
'ProgressTokenSchema',
'PromptArgumentSchema',
'PromptListChangedNotificationSchema',
'PromptMessageSchema',
'PromptReferenceSchema',
'PromptSchema',
'ReadResourceRequestParamsSchema',
'ReadResourceRequestSchema',
'ReadResourceResultSchema',
'RelatedTaskMetadataSchema',
'RequestIdSchema',
'RequestMetaEnvelopeSchema',
'RequestMetaSchema',
'RequestSchema',
'ResourceContentsSchema',
'ResourceLinkSchema',
'ResourceListChangedNotificationSchema',
'ResourceRequestParamsSchema',
'ResourceSchema',
'ResourceTemplateReferenceSchema',
'ResourceTemplateSchema',
'ResourceUpdatedNotificationParamsSchema',
'ResourceUpdatedNotificationSchema',
'ResultSchema',
'RoleSchema',
'RootSchema',
'RootsListChangedNotificationSchema',
'SamplingContentSchema',
'SamplingMessageContentBlockSchema',
'SamplingMessageSchema',
'ServerCapabilitiesSchema',
'ServerNotificationSchema',
'ServerRequestSchema',
'ServerResultSchema',
'SetLevelRequestParamsSchema',
'SetLevelRequestSchema',
'SingleSelectEnumSchemaSchema',
'StringSchemaSchema',
'SubscribeRequestParamsSchema',
'SubscribeRequestSchema',
'TaskAugmentedRequestParamsSchema',
'TaskCreationParamsSchema',
'TaskMetadataSchema',
'TaskSchema',
'TaskStatusNotificationParamsSchema',
'TaskStatusNotificationSchema',
'TaskStatusSchema',
'TextContentSchema',
'TextResourceContentsSchema',
'TitledMultiSelectEnumSchemaSchema',
'TitledSingleSelectEnumSchemaSchema',
'ToolAnnotationsSchema',
'ToolChoiceSchema',
'ToolExecutionSchema',
'ToolListChangedNotificationSchema',
'ToolResultContentSchema',
'ToolSchema',
'ToolUseContentSchema',
'UnsubscribeRequestParamsSchema',
'UnsubscribeRequestSchema',
'UntitledMultiSelectEnumSchemaSchema',
'UntitledSingleSelectEnumSchemaSchema'
]);

View file

@ -4,6 +4,15 @@ export const SIMPLE_RENAMES: Record<string, string> = {
JSONRPCErrorSchema: 'JSONRPCErrorResponseSchema',
isJSONRPCError: 'isJSONRPCErrorResponse',
isJSONRPCResponse: 'isJSONRPCResultResponse',
// v1's JSONRPCResponse type / JSONRPCResponseSchema constant both validated only *result*
// responses. v2 reuses each name for the result|error form — the type becomes
// `Infer<z.union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema])>` and the schema the
// matching `z.union([...])` — so a migrated `JSONRPCResponseSchema.parse(...)` (or a typed
// `JSONRPCResponse` value) would silently widen. Rename both to the result-only equivalents to
// preserve v1 behavior — mirroring the isJSONRPCResponse guard rename above. Both the type and the
// schema are public in v2 (re-exported from core via @modelcontextprotocol/client | /server).
JSONRPCResponse: 'JSONRPCResultResponse',
JSONRPCResponseSchema: 'JSONRPCResultResponseSchema',
ResourceReference: 'ResourceTemplateReference',
ResourceReferenceSchema: 'ResourceTemplateReferenceSchema'
};

View file

@ -1,11 +1,15 @@
import type { SourceFile } from 'ts-morph';
import { SyntaxKind } from 'ts-morph';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
import { renameAllReferences } from '../../../utils/astUtils';
import { actionRequired, info, v2Gap, warning } from '../../../utils/diagnostics';
import type { NamedImportSpec } from '../../../utils/importUtils';
import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils';
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
import { IMPORT_MAP, isAuthImport } from '../mappings/importMap';
import { AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT } from '../mappings/authSchemaNames';
import { isAuthImport, lookupImportMapping } from '../mappings/importMap';
import { isSharedSchemaConst, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
import { SIMPLE_RENAMES } from '../mappings/symbolMap';
const REEXPORT_WARNINGS: Record<string, string> = {
@ -50,17 +54,29 @@ export const importPathsTransform: Transform = {
const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!);
// 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()),
// ts-morph drops the comment with it. Capture it now and restore it after emitting if it was lost.
// Capture the EXACT source bytes spanning all leading comment ranges (first range's start to last
// range's end) rather than re-joining each range with '\n' — a join drops the original separators
// (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();
const leadingCommentText =
leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges[0]!.getPos(), leadingRanges.at(-1)!.getEnd()) : '';
interface PendingImport {
names: string[];
specs: NamedImportSpec[];
isTypeOnly: boolean;
}
const pendingImports = new Map<string, PendingImport[]>();
function addPending(target: string, names: string[], isTypeOnly: boolean): void {
function addPending(target: string, specs: NamedImportSpec[], isTypeOnly: boolean): void {
if (!pendingImports.has(target)) {
pendingImports.set(target, []);
}
pendingImports.get(target)!.push({ names, isTypeOnly });
pendingImports.get(target)!.push({ specs, isTypeOnly });
}
for (const imp of sdkImports) {
@ -71,7 +87,7 @@ export const importPathsTransform: Transform = {
const defaultImport = imp.getDefaultImport();
const namespaceImport = imp.getNamespaceImport();
let mapping = IMPORT_MAP[specifier];
let mapping = lookupImportMapping(specifier);
if (!mapping && isAuthImport(specifier)) {
mapping = {
@ -94,15 +110,22 @@ export const importPathsTransform: Transform = {
continue;
}
// Resolve a RESOLVE_BY_CONTEXT mapping (sdk/types.js, sdk/shared/auth.js) only when a binding
// actually routes to the context package. resolveTypesPackage's diagnostic sink emits a "could
// not determine project type" warning (or, for a 'both' project, an info note), so resolving
// eagerly would emit that note even for an import of nothing but `*Schema` constants — which
// routes entirely to core and never uses the context package. A namespace or default
// binding always needs context; a named symbol needs it only when it has no per-symbol override
// (i.e. it is not a `*Schema` routed to core).
let targetPackage = mapping.target;
if (targetPackage === 'RESOLVE_BY_CONTEXT') {
targetPackage = resolveTypesPackage(context, hasClientImport, hasServerImport, {
filePath,
line,
diagnostics
});
if (mapping.subpathSuffix) {
targetPackage = `${targetPackage}${mapping.subpathSuffix}`;
const needsContext =
namespaceImport != null ||
defaultImport != null ||
namedImports.some(n => symbolTargetOverride(n.getName(), mapping!) === undefined);
if (needsContext) {
const base = resolveTypesPackage(context, hasClientImport, hasServerImport, { filePath, line, diagnostics });
targetPackage = mapping.subpathSuffix ? `${base}${mapping.subpathSuffix}` : base;
}
}
@ -116,20 +139,42 @@ export const importPathsTransform: Transform = {
}
}
const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined);
if (defaultImport || namespaceImport || hasAlias) {
let effectiveTarget = targetPackage;
if (mapping.symbolTargetOverrides && !namespaceImport && !defaultImport) {
const allOverridden = namedImports.length > 0 && namedImports.every(n => n.getName() in mapping.symbolTargetOverrides!);
if (allOverridden) {
effectiveTarget = mapping.symbolTargetOverrides[namedImports[0]!.getName()]!;
} else if (namedImports.some(n => n.getName() in mapping.symbolTargetOverrides!)) {
// A namespace import (`import * as ns from …`) cannot be split per-symbol — usages are
// qualified (`ns.Foo`), so the whole binding moves to one package. Named imports (aliased or
// not), including the named siblings of a default import, DO fall through to the per-symbol
// splitter below — so an all-`*Schema` import routes entirely to core, a single aliased
// specifier no longer forces unrelated symbols into the wrong package, and a mixed
// `import sdk, { CallToolResultSchema }` routes the schema to core while the default
// binding (handled at the end of the per-symbol path) moves to the context package.
if (namespaceImport) {
const effectiveTarget = targetPackage;
// Any `ns.<Name>Schema` accesses would silently resolve against the wrong package (the
// namespace can't be split), so flag them.
if (mapping.schemaSymbolTarget) {
const nsName = namespaceImport.getText();
// Map each accessed v1 name to the v2 name core actually exports — some are
// renamed (e.g. JSONRPCErrorSchema → JSONRPCErrorResponseSchema), and core only
// exports the v2 name. Dedupe by the accessed (v1) name.
const schemaAccesses = [
...new Map(
sourceFile
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
.filter(pa => pa.getExpression().getText() === nsName && isSharedSchemaConst(pa.getName(), mapping))
.map(pa => [pa.getName(), resolveRenamedName(pa.getName(), mapping)] as const)
)
];
if (schemaAccesses.length > 0) {
const accessed = schemaAccesses.map(([v1]) => v1).join(', ');
const importName = schemaAccesses[0]![1];
const renamed = schemaAccesses.filter(([v1, v2]) => v1 !== v2);
const renameNote =
renamed.length > 0 ? ` Renamed in v2: ${renamed.map(([v1, v2]) => `${v1} → ${v2}`).join(', ')}.` : '';
diagnostics.push(
actionRequired(
filePath,
imp,
`Aliased import from ${specifier} mixes symbols that belong to different v2 packages. ` +
`Split the import manually so each symbol targets the correct package.`
`Namespace import of ${specifier} is used to access Zod schema(s) (${accessed}) that moved to ${mapping.schemaSymbolTarget}.${renameNote} ` +
`Import them with a named import (e.g. \`import { ${importName} } from '${mapping.schemaSymbolTarget}'\`) and update the qualified usages.`
)
);
}
@ -137,22 +182,14 @@ export const importPathsTransform: Transform = {
usedPackages.add(effectiveTarget);
imp.setModuleSpecifier(effectiveTarget);
if (mapping.renamedSymbols) {
for (const n of namedImports) {
const newName = mapping.renamedSymbols[n.getName()];
if (newName) {
n.setName(newName);
}
}
if (namespaceImport) {
diagnostics.push(
actionRequired(
filePath,
imp,
`Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` +
`were renamed in ${effectiveTarget}. Update qualified accesses manually.`
)
);
}
diagnostics.push(
actionRequired(
filePath,
imp,
`Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` +
`were renamed in ${effectiveTarget}. Update qualified accesses manually.`
)
);
}
changesCount++;
if (mapping.migrationHint) {
@ -166,13 +203,40 @@ export const importPathsTransform: Transform = {
for (const n of namedImports) {
const name = n.getName();
const alias = n.getAliasNode()?.getText();
const resolvedName = mapping.renamedSymbols?.[name] ?? name;
const specifierTypeOnly = typeOnly || n.isTypeOnly();
const symbolTarget = mapping.symbolTargetOverrides?.[name] ?? targetPackage;
const symbolTarget = symbolTargetOverride(name, mapping) ?? targetPackage;
// A v1 auth-schema constant with no public v2 home (SafeUrlSchema/OptionalSafeUrlSchema)
// routes by context to a package that doesn't export it. Flag it so the user inlines the
// validation instead of hitting a silent "has no exported member" error.
if (mapping.schemaSymbolTarget && AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT.has(name)) {
diagnostics.push(
actionRequired(
filePath,
imp,
`${name} was an internal URL field-validator in v1's ${specifier} with no public v2 equivalent ` +
`(it is not re-exported by @modelcontextprotocol/core). Remove this import and inline the ` +
`validation (e.g. validate the URL with the WHATWG \`URL\` constructor or your own Zod schema).`
)
);
}
usedPackages.add(symbolTarget);
addPending(symbolTarget, [resolvedName], specifierTypeOnly);
addPending(symbolTarget, [alias ? { name: resolvedName, alias } : resolvedName], specifierTypeOnly);
}
if (defaultImport) {
// The default binding can't be split per-symbol, so move it (and the module specifier) to
// the resolved context/target package. The named siblings were just routed per-symbol
// above, so drop them from this now default-only import.
const effectiveTarget = targetPackage;
usedPackages.add(effectiveTarget);
if (namedImports.length > 0) {
imp.removeNamedImports();
}
imp.setModuleSpecifier(effectiveTarget);
} else {
imp.remove();
}
imp.remove();
changesCount++;
if (mapping.migrationHint) {
diagnostics.push(info(filePath, line, mapping.migrationHint));
@ -182,28 +246,34 @@ export const importPathsTransform: Transform = {
}
}
const specLocal = (spec: NamedImportSpec): string => (typeof spec === 'string' ? spec : (spec.alias ?? spec.name));
for (const [target, groups] of pendingImports) {
const typeOnlyNames = new Set<string>();
const valueNames = new Set<string>();
// Dedupe by local binding name (alias when present), keeping the spec so aliases survive.
const typeOnlySpecs = new Map<string, NamedImportSpec>();
const valueSpecs = new Map<string, NamedImportSpec>();
for (const group of groups) {
for (const name of group.names) {
if (group.isTypeOnly) {
typeOnlyNames.add(name);
} else {
valueNames.add(name);
}
for (const spec of group.specs) {
(group.isTypeOnly ? typeOnlySpecs : valueSpecs).set(specLocal(spec), spec);
}
}
if (valueNames.size > 0) {
addOrMergeImport(sourceFile, target, [...valueNames], false, insertIndex);
if (valueSpecs.size > 0) {
addOrMergeImport(sourceFile, target, [...valueSpecs.values()], false, insertIndex);
}
if (typeOnlyNames.size > 0) {
const typeInsertIndex = valueNames.size > 0 ? insertIndex + 1 : insertIndex;
addOrMergeImport(sourceFile, target, [...typeOnlyNames], true, typeInsertIndex);
if (typeOnlySpecs.size > 0) {
const typeInsertIndex = valueSpecs.size > 0 ? insertIndex + 1 : insertIndex;
addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex);
}
}
// 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`);
}
return { changesCount, diagnostics, usedPackages };
}
};
@ -223,7 +293,7 @@ function rewriteExportDeclarations(
if (!specifier) continue;
const line = exp.getStartLineNumber();
let mapping = IMPORT_MAP[specifier];
let mapping = lookupImportMapping(specifier);
if (!mapping && isAuthImport(specifier)) {
mapping = {
@ -262,12 +332,30 @@ function rewriteExportDeclarations(
}
}
if (mapping.symbolTargetOverrides) {
if (mapping.symbolTargetOverrides || mapping.schemaSymbolTarget) {
const namedExports = exp.getNamedExports();
const allOverridden = namedExports.length > 0 && namedExports.every(s => s.getName() in mapping.symbolTargetOverrides!);
if (allOverridden) {
targetPackage = mapping.symbolTargetOverrides[namedExports[0]!.getName()]!;
} else if (namedExports.some(s => s.getName() in mapping.symbolTargetOverrides!)) {
// A star re-export (`export * from …`, including `export * as ns from …`) has no named
// exports to route per-symbol, so it moves wholesale to the context package — which exports
// none of the Zod `*Schema` constants the v1 module re-exported. Downstream consumers of this
// barrel would hit "has no exported member" with no pointer to where the schemas went, so flag
// it (mirroring the namespace-import diagnostic on the import side).
if (mapping.schemaSymbolTarget && namedExports.length === 0) {
diagnostics.push(
actionRequired(
filePath,
exp,
`Star re-export of ${specifier} will not include the Zod schema constants that moved to ` +
`${mapping.schemaSymbolTarget} (they are no longer exported by ${targetPackage}). ` +
`Add an explicit \`export { … } from '${mapping.schemaSymbolTarget}'\` for any re-exported \`*Schema\` constants.`
)
);
}
const overrides = namedExports.map(s => symbolTargetOverride(s.getName(), mapping));
const uniqueOverrides = new Set(overrides.filter((t): t is string => t !== undefined));
const allOverridden = namedExports.length > 0 && overrides.every(t => t !== undefined);
if (allOverridden && uniqueOverrides.size === 1) {
targetPackage = [...uniqueOverrides][0]!;
} else if (uniqueOverrides.size > 0) {
diagnostics.push(
actionRequired(
filePath,

View file

@ -7,7 +7,6 @@ import { mcpServerApiTransform } from './mcpServerApi';
import { mockPathsTransform } from './mockPaths';
import { removedApisTransform } from './removedApis';
import { schemaParamRemovalTransform } from './schemaParamRemoval';
import { specSchemaAccessTransform } from './specSchemaAccess';
import { symbolRenamesTransform } from './symbolRenames';
// Ordering matters — do not reorder without understanding dependencies:
@ -31,11 +30,7 @@ import { symbolRenamesTransform } from './symbolRenames';
// 5. handlerRegistration, schemaParamRemoval, and expressMiddleware are
// independent of each other but all depend on importPaths having run.
//
// 6. specSchemaAccess runs after handlerRegistration and schemaParamRemoval:
// those transforms remove spec schema references they handle. specSchemaAccess
// then processes remaining standalone usages (safeParse, parse, z.infer, etc.).
//
// 7. mockPaths runs last: handles test mocks and dynamic imports,
// 6. mockPaths runs last: handles test mocks and dynamic imports,
// independent of the other transforms.
export const v1ToV2Transforms: Transform[] = [
importPathsTransform,
@ -44,7 +39,6 @@ export const v1ToV2Transforms: Transform[] = [
mcpServerApiTransform,
handlerRegistrationTransform,
schemaParamRemovalTransform,
specSchemaAccessTransform,
expressMiddlewareTransform,
contextTypesTransform,
mockPathsTransform

View file

@ -5,9 +5,29 @@ import type { Diagnostic, Transform, TransformContext, TransformResult } from '.
import { actionRequired, v2Gap, warning } from '../../../utils/diagnostics';
import { isSdkSpecifier } from '../../../utils/importUtils';
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
import { IMPORT_MAP, isAuthImport } from '../mappings/importMap';
import type { ImportMapping } from '../mappings/importMap';
import { isAuthImport, lookupImportMapping } from '../mappings/importMap';
import { isSharedSchemaConst, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
import { SIMPLE_RENAMES } from '../mappings/symbolMap';
/**
* Resolve the single per-symbol target package shared by every `symbol` (mocked factory keys or
* destructured `import()` bindings), or report that they mix v2 packages. A mock/dynamic-import
* specifier is a single string and cannot be split, so a mix can only be flagged, not rewritten.
* Returns `target: undefined` when no symbol carries a per-symbol override (the caller keeps the
* mapping's resolved context/`target` package). Mirrors `symbolTargetOverride` routing used by the
* static import/export transform so e.g. a factory of only `*Schema` constants routes to core.
*/
function routeSymbols(symbols: string[], mapping: ImportMapping): { target?: string; mixed: boolean } {
if (symbols.length === 0) return { mixed: false };
const targets = symbols.map(s => symbolTargetOverride(s, mapping));
const overridden = targets.filter((t): t is string => t !== undefined);
const unique = new Set(overridden);
if (overridden.length === symbols.length && unique.size === 1) return { target: [...unique][0]!, mixed: false };
if (unique.size > 0) return { mixed: true };
return { mixed: false };
}
const MOCK_METHODS = new Set([
'mock',
'doMock',
@ -53,13 +73,14 @@ function resolveTarget(
specifier: string,
context: TransformContext,
sourceFile: SourceFile,
symbols: string[],
diagnosticSink?: { filePath: string; line: number; diagnostics: Diagnostic[] }
):
| { target: string; renamedSymbols?: Record<string, string>; symbolTargetOverrides?: Record<string, string> }
| { removed: true; isV2Gap?: boolean; removalMessage?: string }
| null {
const mapping = IMPORT_MAP[specifier];
if (!mapping && isAuthImport(specifier)) return { target: '@modelcontextprotocol/server-legacy/auth' };
): { target: string; mapping: ImportMapping } | { removed: true; isV2Gap?: boolean; removalMessage?: string } | null {
const mapping = lookupImportMapping(specifier);
if (!mapping && isAuthImport(specifier)) {
const authMapping: ImportMapping = { target: '@modelcontextprotocol/server-legacy/auth', status: 'moved' };
return { target: authMapping.target, mapping: authMapping };
}
if (!mapping) return null;
if (mapping.status === 'removed') return { removed: true, isV2Gap: mapping.isV2Gap, removalMessage: mapping.removalMessage };
@ -73,13 +94,24 @@ function resolveTarget(
const s = i.getModuleSpecifierValue();
return s.includes('/server/') || s === '@modelcontextprotocol/server';
});
target = resolveTypesPackage(context, hasClient, hasServer, diagnosticSink);
// Resolve lazily: only pass the diagnostic sink to resolveTypesPackage when the routed target
// actually falls back to the context package. A factory/destructuring whose symbols all route
// elsewhere (e.g. only `*Schema` constants → core) never uses the context package, so emitting a
// "could not determine project type" warning (or a 'both'-project info note) for it would be
// spurious. Mirrors the lazy `needsContext` guard in the static import transform. A
// non-destructured/non-routable binding has no symbols, so `routeSymbols` returns no target and
// context is (correctly) treated as needed.
const needsContext = routeSymbols(symbols, mapping).target === undefined;
target = resolveTypesPackage(context, hasClient, hasServer, needsContext ? diagnosticSink : undefined);
if (mapping.subpathSuffix) {
target = `${target}${mapping.subpathSuffix}`;
}
}
return { target, renamedSymbols: mapping.renamedSymbols, symbolTargetOverrides: mapping.symbolTargetOverrides };
// Return the original mapping (not just `renamedSymbols`/`symbolTargetOverrides`) so per-symbol
// routing can consult `schemaSymbolTarget` via the shared `symbolTargetOverride`/`routeSymbols`,
// matching how the static import transform routes `*Schema` constants to core.
return { target, mapping };
}
function rewriteMockCall(
@ -98,7 +130,8 @@ function rewriteMockCall(
const specifier = firstArg.getLiteralValue();
if (!isSdkSpecifier(specifier)) return 0;
const resolved = resolveTarget(specifier, context, sourceFile, {
const factorySymbols = args.length >= 2 ? collectFactorySymbols(args[1]!) : [];
const resolved = resolveTarget(specifier, context, sourceFile, factorySymbols, {
filePath: sourceFile.getFilePath(),
line: call.getStartLineNumber(),
diagnostics
@ -122,13 +155,15 @@ function rewriteMockCall(
let changes = 0;
let effectiveTarget = resolved.target;
if (resolved.symbolTargetOverrides && args.length >= 2) {
const factorySymbols = collectFactorySymbols(args[1]!);
const allOverridden = factorySymbols.length > 0 && factorySymbols.every(s => s in resolved.symbolTargetOverrides!);
const someOverridden = factorySymbols.some(s => s in resolved.symbolTargetOverrides!);
if (allOverridden) {
effectiveTarget = resolved.symbolTargetOverrides[factorySymbols[0]!]!;
} else if (someOverridden) {
if (args.length >= 2) {
// Route the factory's mocked symbols the same way the static import transform would: a factory of
// only `*Schema` constants (from sdk/types.js or sdk/shared/auth.js) moves to core; a factory
// of only `StreamableHTTPServerTransport` moves to @modelcontextprotocol/node. A single mock path
// can't be split, so a mix of packages is flagged for manual migration.
const { target: routedTarget, mixed } = routeSymbols(factorySymbols, resolved.mapping);
if (routedTarget) {
effectiveTarget = routedTarget;
} else if (mixed) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
@ -144,7 +179,7 @@ function rewriteMockCall(
firstArg.setLiteralValue(effectiveTarget);
changes++;
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols };
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.mapping.renamedSymbols };
if (args.length >= 2) {
changes += renameSymbolsInFactory(args[1]!, allRenames);
}
@ -216,6 +251,106 @@ function renameSymbolsInFactory(factoryArg: import('ts-morph').Node, renamedSymb
return changes;
}
/**
* The object binding pattern through which a dynamic import's named symbols are pulled — either
* `const { … } = await import('…')` or `import('…').then(({ … }) => …)`. Returns undefined for a
* non-destructured binding (`const mod = await import()`), an identifier `.then` param (`m => …`), or
* an unassigned `await import()`. Both destructured shapes expose named symbols that can be routed and
* renamed per-symbol (the specifier itself can't be split).
*/
function getModuleBindingPattern(node: import('ts-morph').CallExpression): import('ts-morph').ObjectBindingPattern | undefined {
const parent = node.getParent();
if (parent && Node.isAwaitExpression(parent)) {
const grandParent = parent.getParent();
if (grandParent && Node.isVariableDeclaration(grandParent)) {
const nameNode = grandParent.getNameNode();
if (Node.isObjectBindingPattern(nameNode)) return nameNode;
}
return undefined;
}
if (parent && Node.isPropertyAccessExpression(parent) && parent.getName() === 'then') {
const thenCall = parent.getParent();
if (thenCall && Node.isCallExpression(thenCall)) {
const cb = thenCall.getArguments()[0];
if (cb && (Node.isArrowFunction(cb) || Node.isFunctionExpression(cb))) {
const paramName = cb.getParameters()[0]?.getNameNode();
if (paramName && Node.isObjectBindingPattern(paramName)) return paramName;
}
}
}
return undefined;
}
/**
* The destructured binding keys of a dynamic import — for both `const { … } = await import('…')` and
* `import('…').then(({ … }) => …)` — or `[]` for a non-destructured binding, an identifier `.then`
* param, or an unassigned `await import()`. The keys feed per-symbol routing; the specifier itself
* can't be split.
*/
function getDestructuredKeys(node: import('ts-morph').CallExpression): string[] {
const pattern = getModuleBindingPattern(node);
if (!pattern) return [];
return pattern.getElements().map(el => el.getPropertyNameNode()?.getText() ?? el.getName());
}
/**
* For a dynamic import whose module binding is NOT a destructurable object pattern — a non-destructured
* `const mod = await import('…')` or a `.then(m => …)` chain — collect the Zod schema constants
* accessed off that binding (e.g. `mod.OAuthTokensSchema`). The destructured form is routed/renamed
* elsewhere; these forms can't be split per-symbol, so the schema accesses are surfaced as a diagnostic
* (mirroring the namespace-import branch of the static import transform — see `importPaths.ts`). Returns
* deduped `[v1Name, v2Name]` pairs (a schema may be renamed, e.g. JSONRPCResponseSchema →
* JSONRPCResultResponseSchema, and core only exports the v2 name). Empty unless the mapping carries a
* `schemaSymbolTarget`.
*/
function collectModuleSchemaAccesses(
node: import('ts-morph').CallExpression,
mapping: ImportMapping,
sourceFile: SourceFile
): Array<readonly [string, string]> {
if (!mapping.schemaSymbolTarget) return [];
let bindingName: string | undefined;
let scope: import('ts-morph').Node | undefined;
const parent = node.getParent();
if (parent && Node.isAwaitExpression(parent)) {
// const mod = await import('…') → `mod` is in scope for the rest of the file.
const grandParent = parent.getParent();
if (grandParent && Node.isVariableDeclaration(grandParent)) {
const nameNode = grandParent.getNameNode();
if (Node.isIdentifier(nameNode)) {
bindingName = nameNode.getText();
scope = sourceFile;
}
}
} else if (parent && Node.isPropertyAccessExpression(parent) && parent.getName() === 'then') {
// import('…').then(m => m.XxxSchema…) → the module is the `.then` callback's first parameter.
const thenCall = parent.getParent();
if (thenCall && Node.isCallExpression(thenCall)) {
const cb = thenCall.getArguments()[0];
if (cb && (Node.isArrowFunction(cb) || Node.isFunctionExpression(cb))) {
const paramName = cb.getParameters()[0]?.getNameNode();
if (paramName && Node.isIdentifier(paramName)) {
bindingName = paramName.getText();
scope = cb;
}
}
}
}
if (!bindingName || !scope) return [];
return [
...new Map(
scope
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
.filter(pa => pa.getExpression().getText() === bindingName && isSharedSchemaConst(pa.getName(), mapping))
.map(pa => [pa.getName(), resolveRenamedName(pa.getName(), mapping)] as const)
)
];
}
function rewriteDynamicImports(
sourceFile: SourceFile,
context: TransformContext,
@ -239,7 +374,8 @@ function rewriteDynamicImports(
const specifier = firstArg.getLiteralValue();
if (!isSdkSpecifier(specifier)) return;
const resolved = resolveTarget(specifier, context, sourceFile, {
const destructuredKeys = getDestructuredKeys(node);
const resolved = resolveTarget(specifier, context, sourceFile, destructuredKeys, {
filePath: sourceFile.getFilePath(),
line: node.getStartLineNumber(),
diagnostics
@ -263,59 +399,81 @@ function rewriteDynamicImports(
}
let effectiveTarget = resolved.target;
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols };
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.mapping.renamedSymbols };
// Check if destructured symbols should route to an override target
if (resolved.symbolTargetOverrides) {
const parent = node.getParent();
if (parent && Node.isAwaitExpression(parent)) {
const grandParent = parent.getParent();
if (grandParent && Node.isVariableDeclaration(grandParent)) {
const nameNode = grandParent.getNameNode();
if (Node.isObjectBindingPattern(nameNode)) {
const elements = nameNode.getElements();
const allOverridden =
elements.length > 0 &&
elements.every(el => {
const key = el.getPropertyNameNode()?.getText() ?? el.getName();
return key in resolved.symbolTargetOverrides!;
});
if (allOverridden) {
effectiveTarget =
resolved.symbolTargetOverrides[elements[0]!.getPropertyNameNode()?.getText() ?? elements[0]!.getName()]!;
}
}
}
}
// Route the destructured bindings the same way the static import transform would: a destructuring
// of only `*Schema` constants (e.g. `const { CallToolResultSchema } = await import('…/types.js')`)
// moves to core, and `StreamableHTTPServerTransport` moves to @modelcontextprotocol/node. A
// single import() specifier can't be split, so a mix of packages is flagged for manual migration.
const { target: routedTarget, mixed } = routeSymbols(destructuredKeys, resolved.mapping);
if (routedTarget) {
effectiveTarget = routedTarget;
} else if (mixed) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
node,
`Dynamic import of ${specifier} destructures symbols that belong to different v2 packages. ` +
`Split the import manually so each symbol targets the correct package.`
)
);
}
// A non-destructured binding (`const mod = await import('…')`) or a `.then(m => …)` chain can't be
// routed per-symbol, so the specifier moves to the context package — which does NOT export the
// Zod `*Schema` constants (those live in `schemaSymbolTarget`/core). Any `mod.<Name>Schema` /
// `m.<Name>Schema` accesses would silently break, so flag them (mirroring the namespace-import
// branch of the static import transform). The destructured form is handled by `routeSymbols` above.
const schemaAccesses = collectModuleSchemaAccesses(node, resolved.mapping, sourceFile);
if (schemaAccesses.length > 0) {
const accessed = schemaAccesses.map(([v1]) => v1).join(', ');
const importName = schemaAccesses[0]![1];
const renamed = schemaAccesses.filter(([v1, v2]) => v1 !== v2);
const renameNote = renamed.length > 0 ? ` Renamed in v2: ${renamed.map(([v1, v2]) => `${v1} → ${v2}`).join(', ')}.` : '';
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
node,
`Dynamic import of ${specifier} is used to access Zod schema(s) (${accessed}) that moved to ${resolved.mapping.schemaSymbolTarget}.${renameNote} ` +
`Import them with a named import (e.g. \`import { ${importName} } from '${resolved.mapping.schemaSymbolTarget}'\`) and update the qualified usages.`
)
);
}
usedPackages.add(effectiveTarget);
firstArg.setLiteralValue(effectiveTarget);
changes++;
const parent = node.getParent();
if (parent && Node.isAwaitExpression(parent)) {
const grandParent = parent.getParent();
if (grandParent && Node.isVariableDeclaration(grandParent)) {
const nameNode = grandParent.getNameNode();
if (Node.isObjectBindingPattern(nameNode)) {
for (const element of nameNode.getElements()) {
const propertyName = element.getPropertyNameNode()?.getText();
const bindingName = element.getName();
const lookupKey = propertyName ?? bindingName;
const newName = allRenames[lookupKey];
if (newName) {
if (propertyName) {
element.getPropertyNameNode()!.replaceWithText(newName);
} else {
element.replaceWithText(`${newName}: ${bindingName}`);
}
changes++;
}
// Apply symbol renames to the destructured binding elements — for both `await import()`
// destructuring and a `.then(({ … }) => …)` param (both routed per-symbol above when their
// symbols share a target, e.g. schema-only → core).
const bindingPattern = getModuleBindingPattern(node);
if (bindingPattern) {
for (const element of bindingPattern.getElements()) {
const propertyName = element.getPropertyNameNode()?.getText();
const bindingName = element.getName();
const lookupKey = propertyName ?? bindingName;
const newName = allRenames[lookupKey];
if (newName) {
if (propertyName) {
element.getPropertyNameNode()!.replaceWithText(newName);
} else {
element.replaceWithText(`${newName}: ${bindingName}`);
}
changes++;
}
const moduleRenames = resolved.renamedSymbols ?? {};
if (!Node.isObjectBindingPattern(nameNode) && Object.keys(moduleRenames).length > 0) {
}
}
// A non-destructured awaited binding (`const mod = await import('…')`) can't have per-symbol
// renames applied, so flag them if the mapping carries any. (Identifier `.then` params and bare
// `mod.<Name>Schema` accesses are surfaced by `collectModuleSchemaAccesses` above.)
const awaitParent = node.getParent();
if (awaitParent && Node.isAwaitExpression(awaitParent)) {
const decl = awaitParent.getParent();
if (decl && Node.isVariableDeclaration(decl) && !Node.isObjectBindingPattern(decl.getNameNode())) {
const moduleRenames = resolved.mapping.renamedSymbols ?? {};
if (Object.keys(moduleRenames).length > 0) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),

View file

@ -2,7 +2,7 @@ import type { SourceFile } from 'ts-morph';
import { Node, SyntaxKind } from 'ts-morph';
import type { Transform, TransformContext, TransformResult } from '../../../types';
import { isImportedFromMcp, removeUnusedImport, resolveOriginalImportName } from '../../../utils/importUtils';
import { hasMcpImports, isImportedFromMcp, removeUnusedImport, resolveOriginalImportName } from '../../../utils/importUtils';
const TARGET_METHODS = new Set(['request', 'callTool']);
@ -12,6 +12,11 @@ export const schemaParamRemovalTransform: Transform = {
apply(sourceFile: SourceFile, _context: TransformContext): TransformResult {
let changesCount = 0;
// `request`/`callTool` are common method names on non-MCP receivers too. The schema-identifier
// path guards per-symbol via `isImportedFromMcp`; the `undefined` path has no symbol to check, so
// gate it on a file-level MCP signal to avoid rewriting unrelated calls.
const fileHasMcpImports = hasMcpImports(sourceFile);
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of calls) {
@ -27,6 +32,19 @@ export const schemaParamRemovalTransform: Transform = {
const secondArg = args[1]!;
if (!Node.isIdentifier(secondArg)) continue;
// `request(req, undefined, options)` / `callTool(params, undefined, options)`: v1 passed an
// explicit `undefined` result schema before the trailing options argument. v2 removed the
// schema parameter for spec methods, so the literal `undefined` leaves the call with one
// argument too many (TS2554). Drop it only when a third argument follows — a 2-arg
// `callTool(params, undefined)` already type-checks, since `undefined` is a valid options arg.
if (secondArg.getText() === 'undefined') {
if (fileHasMcpImports && args.length >= 3) {
call.removeArgument(1);
changesCount++;
}
continue;
}
const schemaName = secondArg.getText();
const originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName;
if (!originalName.endsWith('Schema')) continue;

View file

@ -1,392 +0,0 @@
import type { SourceFile } from 'ts-morph';
import { Node, SyntaxKind } from 'ts-morph';
import { SPEC_SCHEMA_NAMES, specSchemaToTypeName } from '../../../generated/specSchemaMap';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
import { isKeyPositionIdentifier } from '../../../utils/astUtils';
import { actionRequired, warning } from '../../../utils/diagnostics';
import { addOrMergeImport, isAnyMcpSpecifier, removeUnusedImport } from '../../../utils/importUtils';
export const specSchemaAccessTransform: Transform = {
name: 'Spec schema standalone usage',
id: 'spec-schemas',
apply(sourceFile: SourceFile, _context: TransformContext): TransformResult {
const diagnostics: Diagnostic[] = [];
let changesCount = 0;
const schemaImports = collectSpecSchemaImports(sourceFile);
if (schemaImports.size === 0) return { changesCount: 0, diagnostics: [] };
for (const [localName, originalName] of schemaImports) {
const typeName = specSchemaToTypeName(originalName);
if (!typeName) continue;
const refs = findNonImportReferences(sourceFile, localName);
if (refs.length === 0) continue;
for (const ref of refs) {
const result = handleReference(ref, localName, typeName, sourceFile, diagnostics);
if (result) changesCount++;
}
removeUnusedImport(sourceFile, localName, true);
}
return { changesCount, diagnostics };
}
};
function collectSpecSchemaImports(sourceFile: SourceFile): Map<string, string> {
const result = new Map<string, string>();
for (const imp of sourceFile.getImportDeclarations()) {
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue;
for (const n of imp.getNamedImports()) {
const exportName = n.getName();
if (!SPEC_SCHEMA_NAMES.has(exportName)) continue;
const localName = n.getAliasNode()?.getText() ?? exportName;
result.set(localName, exportName);
}
}
return result;
}
function findNonImportReferences(sourceFile: SourceFile, localName: string): import('ts-morph').Node[] {
const refs: import('ts-morph').Node[] = [];
sourceFile.forEachDescendant(node => {
if (!Node.isIdentifier(node)) return;
if (node.getText() !== localName) return;
const parent = node.getParent();
if (parent && Node.isImportSpecifier(parent)) return;
refs.push(node);
});
return refs;
}
function handleReference(
ref: import('ts-morph').Node,
localName: string,
typeName: string,
sourceFile: SourceFile,
diagnostics: Diagnostic[]
): boolean {
// Pattern: z.infer<typeof XSchema> — type position
if (isTypeofInTypePosition(ref)) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
ref,
`Replace \`z.infer<typeof ${localName}>\` with the \`${typeName}\` type (already exported from the same v2 package).`
)
);
return false;
}
// Pattern: XSchema.safeParse(v).success — auto-transform to isSpecType.X(v)
if (isSafeParseSuccessPattern(ref)) {
const safeParseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression;
const safeParseCall = safeParseAccess.getParent() as import('ts-morph').CallExpression;
const successAccess = safeParseCall.getParent() as import('ts-morph').PropertyAccessExpression;
const args = safeParseCall.getArguments();
const argText = args.length > 0 ? args[0]!.getText() : '';
successAccess.replaceWithText(`isSpecType.${typeName}(${argText})`);
ensureImport(sourceFile, 'isSpecType');
return true;
}
// Pattern: const x = XSchema.safeParse(v) — auto-transform when result is captured in a variable
if (isSafeParsePattern(ref)) {
const safeParseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression;
const safeParseCall = safeParseAccess.getParent() as import('ts-morph').CallExpression;
if (isCapturedSafeParsePattern(safeParseCall)) {
return rewriteCapturedSafeParse(safeParseCall, localName, typeName, sourceFile, diagnostics);
}
return rewriteUnsupportedSchemaCall(ref, safeParseCall, localName, typeName, 'safeParse', sourceFile, diagnostics);
}
// Pattern: XSchema.parse(v) — rewrite to the StandardSchema validate() primitive (or, when the
// result is used, swap the identifier) so we never leave behind an import of a non-exported schema.
if (isParsePattern(ref)) {
const parseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression;
const parseCall = parseAccess.getParent() as import('ts-morph').CallExpression;
return rewriteUnsupportedSchemaCall(ref, parseCall, localName, typeName, 'parse', sourceFile, diagnostics);
}
// Pattern: XSchema used as value (function arg, assignment, etc.)
const parent = ref.getParent();
if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) {
const line = ref.getStartLineNumber();
ref.replaceWithText(`specTypeSchemas.${typeName}`);
ensureImport(sourceFile, 'specTypeSchemas');
diagnostics.push(
warning(
sourceFile.getFilePath(),
line,
`Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse()/.parseAsync() are not available. Manual rewrite required.`
)
);
return true;
}
if (parent && Node.isExportSpecifier(parent)) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
ref,
`Re-export of ${localName} requires manual update: replace with specTypeSchemas.${typeName} or remove.`
)
);
return false;
}
if (parent && Node.isShorthandPropertyAssignment(parent)) {
const line = ref.getStartLineNumber();
parent.replaceWithText(`'${localName}': specTypeSchemas.${typeName}`);
ensureImport(sourceFile, 'specTypeSchemas');
diagnostics.push(
warning(
sourceFile.getFilePath(),
line,
`Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.`
)
);
return true;
}
if (parent && isKeyPositionIdentifier(ref)) {
return false;
}
// Value position: replace identifier with specTypeSchemas.X
const line = ref.getStartLineNumber();
ref.replaceWithText(`specTypeSchemas.${typeName}`);
ensureImport(sourceFile, 'specTypeSchemas');
diagnostics.push(
warning(
sourceFile.getFilePath(),
line,
`Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.`
)
);
return true;
}
function isSafeParseSuccessPattern(ref: import('ts-morph').Node): boolean {
const parent = ref.getParent();
if (!parent || !Node.isPropertyAccessExpression(parent)) return false;
if (parent.getName() !== 'safeParse' || parent.getExpression() !== ref) return false;
const grandParent = parent.getParent();
if (!grandParent || !Node.isCallExpression(grandParent)) return false;
const greatGrandParent = grandParent.getParent();
if (!greatGrandParent || !Node.isPropertyAccessExpression(greatGrandParent)) return false;
return greatGrandParent.getName() === 'success';
}
function isSafeParsePattern(ref: import('ts-morph').Node): boolean {
const parent = ref.getParent();
if (!parent || !Node.isPropertyAccessExpression(parent)) return false;
if (parent.getName() !== 'safeParse' || parent.getExpression() !== ref) return false;
const grandParent = parent.getParent();
return !!grandParent && Node.isCallExpression(grandParent);
}
function isParsePattern(ref: import('ts-morph').Node): boolean {
const parent = ref.getParent();
if (!parent || !Node.isPropertyAccessExpression(parent)) return false;
if (parent.getName() !== 'parse' || parent.getExpression() !== ref) return false;
const grandParent = parent.getParent();
return !!grandParent && Node.isCallExpression(grandParent);
}
function isTypeofInTypePosition(ref: import('ts-morph').Node): boolean {
const parent = ref.getParent();
if (!parent) return false;
return Node.isTypeQuery(parent);
}
/**
* Checks if a safeParse call result is captured in a `const` variable declaration.
* Pattern: `const x = Schema.safeParse(v);`
*/
function isCapturedSafeParsePattern(safeParseCall: import('ts-morph').CallExpression): boolean {
const parent = safeParseCall.getParent();
if (!parent || !Node.isVariableDeclaration(parent)) return false;
const nameNode = parent.getNameNode();
if (!Node.isIdentifier(nameNode)) return false;
const declList = parent.getParent();
if (!declList || !Node.isVariableDeclarationList(declList)) return false;
const flags = declList.getDeclarationKind();
return flags === 'const' || flags === 'let';
}
/**
* Rewrites a captured safeParse pattern:
* const x = Schema.safeParse(v) → const x = specTypeSchemas.T['~standard'].validate(v)
* x.success → x.issues === undefined
* x.data → x.value
* x.error → x.issues
*/
function rewriteCapturedSafeParse(
safeParseCall: import('ts-morph').CallExpression,
localName: string,
typeName: string,
sourceFile: SourceFile,
diagnostics: Diagnostic[]
): boolean {
const varDecl = safeParseCall.getParent() as import('ts-morph').VariableDeclaration;
const varName = varDecl.getName();
const args = safeParseCall.getArguments();
const argText = args.length > 0 ? args[0]!.getText() : '';
// Rewrite the safeParse call
safeParseCall.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`);
ensureImport(sourceFile, 'specTypeSchemas');
// Find and rewrite all property accesses on the result variable (scoped to declaring block)
const replacements: { node: import('ts-morph').Node; newText: string }[] = [];
const scope = varDecl.getFirstAncestorByKind(SyntaxKind.Block) ?? sourceFile;
scope.forEachDescendant(node => {
if (!Node.isPropertyAccessExpression(node)) return;
const expr = node.getExpression();
if (!Node.isIdentifier(expr) || expr.getText() !== varName) return;
const propName = node.getName();
switch (propName) {
case 'success': {
// Check for !x.success → x.issues !== undefined
const parentNode = node.getParent();
if (
parentNode &&
Node.isPrefixUnaryExpression(parentNode) &&
parentNode.getOperatorToken() === SyntaxKind.ExclamationToken
) {
replacements.push({ node: parentNode, newText: `${varName}.issues !== undefined` });
} else {
replacements.push({ node, newText: `(${varName}.issues === undefined)` });
}
break;
}
case 'data': {
replacements.push({ node, newText: `${varName}.value` });
break;
}
case 'error': {
const errorParent = node.getParent();
if (errorParent && Node.isPropertyAccessExpression(errorParent) && errorParent.getExpression() === node) {
const subProp = errorParent.getName();
if (subProp === 'issues') {
replacements.push({ node: errorParent, newText: `${varName}.issues` });
} else if (subProp === 'message') {
replacements.push({ node: errorParent, newText: `${varName}.issues?.map(i => i.message).join(', ')` });
} else {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
errorParent,
`${varName}.error.${subProp} has no StandardSchema equivalent. Manual migration required.`
)
);
}
} else {
replacements.push({ node, newText: `${varName}.issues` });
}
break;
}
}
});
// Apply in reverse order to avoid position shifts
const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart());
for (const { node, newText } of sorted) {
node.replaceWithText(newText);
}
diagnostics.push(
warning(
sourceFile.getFilePath(),
varDecl.getStartLineNumber(),
`Rewrote ${localName}.safeParse() to specTypeSchemas.${typeName}['~standard'].validate(). ` +
`Result properties remapped: .success → .issues === undefined, .data → .value, .error → .issues.`
)
);
return true;
}
/**
* Handles spec-schema usages that have no behavior-preserving v2 equivalent: the Zod-only
* methods `.parse()` and (uncaptured) `.safeParse()`. In v2 these schemas are StandardSchemaV1
* values that are NOT named public exports, so leaving the original import in place produces an
* unresolved-import error (e.g. `PromptSchema` is not exported by `@modelcontextprotocol/server`).
*
* - Result discarded (validation for side-effect only): rewrite `XSchema.parse(v)` →
* `specTypeSchemas.T['~standard'].validate(v)` so the code compiles. NOTE: `validate()` does not
* throw, so `.parse()`'s throw-on-invalid behavior is lost — flagged via an actionRequired comment.
* - Result used: swap only the identifier to `specTypeSchemas.T` so the import resolves; the
* `.parse()`/`.safeParse()` call and its result shape still need a manual fix (flagged).
*
* Either way the original (now non-exported) schema import is dropped by the caller's
* removeUnusedImport, so no dangling import survives.
*/
function rewriteUnsupportedSchemaCall(
ref: import('ts-morph').Node,
callNode: import('ts-morph').CallExpression,
localName: string,
typeName: string,
method: 'parse' | 'safeParse',
sourceFile: SourceFile,
diagnostics: Diagnostic[]
): boolean {
const resultDiscarded = Node.isExpressionStatement(callNode.getParent());
if (resultDiscarded) {
const argText = callNode
.getArguments()
.map(a => a.getText())
.join(', ');
const semantics =
method === 'parse'
? 'validate() does NOT throw on invalid input (parse() did) — if you relied on that, add `if (result.issues) throw …`.'
: 'the result shape changed from { success, data, error } to { value, issues }.';
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
callNode,
`Rewrote ${localName}.${method}() to specTypeSchemas.${typeName}['~standard'].validate(): ` +
`v2 spec schemas are StandardSchemaV1, not Zod. Note: ${semantics}`
)
);
callNode.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`);
ensureImport(sourceFile, 'specTypeSchemas');
return true;
}
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
ref,
`${localName}.${method}() is not available on v2 spec schemas (StandardSchemaV1, not Zod). ` +
`Replaced ${localName} with specTypeSchemas.${typeName}; rewrite the .${method}(...) call using ` +
`specTypeSchemas.${typeName}['~standard'].validate(...) (returns { value, issues }, does not throw).`
)
);
ref.replaceWithText(`specTypeSchemas.${typeName}`);
ensureImport(sourceFile, 'specTypeSchemas');
return true;
}
function ensureImport(sourceFile: SourceFile, symbol: string): void {
const existingImport = sourceFile.getImportDeclarations().find(imp => {
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;
return imp.getNamedImports().some(n => n.getName() === symbol);
});
if (existingImport) return;
const targetPkg = sourceFile.getImportDeclarations().find(imp => {
const spec = imp.getModuleSpecifierValue();
return spec === '@modelcontextprotocol/server' || spec === '@modelcontextprotocol/client';
});
const target = targetPkg?.getModuleSpecifierValue() ?? '@modelcontextprotocol/server';
addOrMergeImport(sourceFile, target, [symbol], false, sourceFile.getImportDeclarations().length);
}

View file

@ -143,7 +143,7 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
const fileDiagnostics: Diagnostic[] = [];
const originalText = sourceFile.getFullText();
const fileUsedPackages = new Set<string>();
const fileClaimedPackages = new Set<string>();
try {
for (const transform of enabledTransforms) {
const result = transform.apply(sourceFile, context);
@ -151,12 +151,25 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
fileDiagnostics.push(...result.diagnostics);
if (result.usedPackages) {
for (const pkg of result.usedPackages) {
fileUsedPackages.add(pkg);
fileClaimedPackages.add(pkg);
}
}
}
for (const pkg of fileUsedPackages) {
allUsedPackages.add(pkg);
// A transform records a package as "used" when it routes a binding there — but importPaths does
// so the moment it rewrites an import, and later transforms (handlerRegistration,
// schemaParamRemoval) routinely rewrite the schema usage away and delete that very import.
// Honouring a claim whose import did not survive would add an unused dependency to package.json,
// so a claim counts only when the FINAL file still references the specifier. Every claim
// originates from a string literal the transform wrote (an import/export module specifier, or a
// vi.mock()/dynamic import() argument), so a surviving string-literal match is the ground truth.
const survivingSpecifiers = new Set<string>();
for (const literal of sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
survivingSpecifiers.add(literal.getLiteralValue());
}
for (const pkg of fileClaimedPackages) {
if (survivingSpecifiers.has(pkg)) {
allUsedPackages.add(pkg);
}
}
} catch (error_) {
const filePath = sourceFile.getFilePath();
@ -164,7 +177,7 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
fileDiagnostics.push(error(filePath, 1, `Transform failed: ${error_ instanceof Error ? error_.message : String(error_)}`));
sourceFile.replaceWithText(originalText);
fileChanges = 0;
fileUsedPackages.clear();
fileClaimedPackages.clear();
}
for (const d of fileDiagnostics) {

View file

@ -0,0 +1,126 @@
import { existsSync, readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/** A code formatter the codemod can recommend running after a migration. */
export interface DetectedFormatter {
/** Display name, e.g. `Prettier`. */
name: string;
/** Executable name, e.g. `prettier`. */
bin: string;
/** Arguments that write formatting in place; changed file paths are appended after these. */
writeArgs: readonly string[];
}
const BIOME_CONFIG_FILES = ['biome.json', 'biome.jsonc'];
const PRETTIER_CONFIG_FILES = [
'.prettierrc',
'.prettierrc.json',
'.prettierrc.json5',
'.prettierrc.yaml',
'.prettierrc.yml',
'.prettierrc.toml',
'.prettierrc.js',
'.prettierrc.cjs',
'.prettierrc.mjs',
'.prettierrc.ts',
'prettier.config.js',
'prettier.config.cjs',
'prettier.config.mjs',
'prettier.config.ts'
];
const ESLINT_CONFIG_FILES = [
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs',
'eslint.config.ts',
'eslint.config.mts',
'eslint.config.cts',
'.eslintrc',
'.eslintrc.js',
'.eslintrc.cjs',
'.eslintrc.json',
'.eslintrc.yml',
'.eslintrc.yaml'
];
// Precedence order: a configured dedicated formatter wins over ESLint's --fix.
const FORMATTERS = {
biome: { name: 'Biome', bin: 'biome', writeArgs: ['format', '--write'] },
prettier: { name: 'Prettier', bin: 'prettier', writeArgs: ['--write'] },
eslint: { name: 'ESLint', bin: 'eslint', writeArgs: ['--fix'] }
} as const satisfies Record<string, DetectedFormatter>;
function hasAnyFile(dir: string, files: readonly string[]): boolean {
return files.some(file => existsSync(path.join(dir, file)));
}
interface PackageJsonSignals {
prettier: boolean;
eslint: boolean;
}
function readPackageJsonSignals(dir: string): PackageJsonSignals {
const pkgJsonPath = path.join(dir, 'package.json');
if (!existsSync(pkgJsonPath)) return { prettier: false, eslint: false };
try {
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as Record<string, unknown>;
const allDeps = {
...(pkgJson.dependencies as Record<string, string> | undefined),
...(pkgJson.devDependencies as Record<string, string> | undefined)
};
return {
prettier: 'prettier' in pkgJson || 'prettier' in allDeps,
eslint: 'eslint' in allDeps
};
} catch {
return { prettier: false, eslint: false };
}
}
/**
* Walks up from `startDir` looking for a configured code formatter, so the CLI can suggest the right
* "format your changed files" command after a migration.
*
* The walk is bounded so a user-level global config is never mistaken for the project's. It stops at the
* repository root (a `.git` directory) or the filesystem root, and — for a project that is not a git
* checkout (tarball, fresh scaffold, CI workspace) — never ascends into or above `$HOME`, so a
* `~/.prettierrc`, `~/biome.json`, or `~/package.json` with formatter deps is never matched. (A `.git`
* boundary alone did not hold this guarantee for non-git projects, which would otherwise walk to `$HOME`.)
*
* Detection is config-based and runs nothing. When multiple formatters are configured, precedence is
* Biome > Prettier > ESLint.
*
* @param startDir the directory to start the upward search from.
* @param homeDir the user's home directory; the walk never reads it or any ancestor. Injectable for tests;
* defaults to `os.homedir()`.
* @returns the detected formatter, or `null` if none is configured.
*/
export function detectFormatter(startDir: string, homeDir: string = os.homedir()): DetectedFormatter | null {
let dir = path.resolve(startDir);
const root = path.parse(dir).root;
const home = path.resolve(homeDir);
const found = { biome: false, prettier: false, eslint: false };
while (true) {
if (hasAnyFile(dir, BIOME_CONFIG_FILES)) found.biome = true;
if (hasAnyFile(dir, PRETTIER_CONFIG_FILES)) found.prettier = true;
if (hasAnyFile(dir, ESLINT_CONFIG_FILES)) found.eslint = true;
const signals = readPackageJsonSignals(dir);
if (signals.prettier) found.prettier = true;
if (signals.eslint) found.eslint = true;
// Stop at the repository root (a `.git` dir) or the filesystem root. For a project that is not a
// git checkout (tarball, fresh scaffold, CI workspace), also stop before ascending into `$HOME`:
// the project is a descendant of `$HOME`, so a user-level `~/.prettierrc`, `~/biome.json`, or
// `~/package.json` with formatter deps must never be read as the project's own config.
if (existsSync(path.join(dir, '.git')) || dir === root || dir === home || path.dirname(dir) === home) break;
dir = path.dirname(dir);
}
if (found.biome) return FORMATTERS.biome;
if (found.prettier) return FORMATTERS.prettier;
if (found.eslint) return FORMATTERS.eslint;
return null;
}

View file

@ -6,6 +6,7 @@ const SDK_PREFIX = '@modelcontextprotocol/sdk';
const V2_PACKAGES = new Set([
'@modelcontextprotocol/client',
'@modelcontextprotocol/server',
'@modelcontextprotocol/core-internal',
'@modelcontextprotocol/core',
'@modelcontextprotocol/node',
'@modelcontextprotocol/express'
@ -32,31 +33,52 @@ export function isTypeOnlyImport(imp: ImportDeclaration): boolean {
return imp.isTypeOnly();
}
/** A named import to emit: either a bare name, or a `{ name, alias }` pair preserving an `as` alias. */
export type NamedImportSpec = string | { name: string; alias?: string };
function toSpec(n: NamedImportSpec): { name: string; alias?: string } {
return typeof n === 'string' ? { name: n } : n;
}
/** Local binding a spec introduces — the alias when present, otherwise the imported name. */
function specLocalName(s: { name: string; alias?: string }): string {
return s.alias ?? s.name;
}
export function addOrMergeImport(
sourceFile: SourceFile,
moduleSpecifier: string,
namedImports: string[],
namedImports: NamedImportSpec[],
isTypeOnly: boolean,
insertIndex: number
): void {
if (namedImports.length === 0) return;
const specs = namedImports.map(n => toSpec(n));
const existing = sourceFile.getImportDeclarations().find(imp => {
if (imp.getNamespaceImport()) return false;
return imp.getModuleSpecifierValue() === moduleSpecifier && imp.isTypeOnly() === isTypeOnly;
});
if (existing) {
const existingNames = new Set(existing.getNamedImports().map(n => n.getName()));
const newNames = namedImports.filter(n => !existingNames.has(n));
if (newNames.length > 0) {
existing.addNamedImports(newNames);
const existingLocals = new Set(existing.getNamedImports().map(n => n.getAliasNode()?.getText() ?? n.getName()));
const newSpecs = specs.filter(s => !existingLocals.has(specLocalName(s)));
if (newSpecs.length > 0) {
existing.addNamedImports(newSpecs.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })));
}
} else {
const seen = new Set<string>();
const deduped = specs.filter(s => {
const local = specLocalName(s);
if (seen.has(local)) return false;
seen.add(local);
return true;
});
const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length);
sourceFile.insertImportDeclaration(clampedIndex, {
moduleSpecifier,
namedImports: [...new Set(namedImports)],
namedImports: deduped.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })),
isTypeOnly
});
}

View file

@ -5,7 +5,7 @@ import type { PackageJsonChange } from '../types';
import { findPackageJson } from './projectAnalyzer';
const V1_PACKAGE = '@modelcontextprotocol/sdk';
const PRIVATE_PACKAGES = new Set(['@modelcontextprotocol/core']);
const PRIVATE_PACKAGES = new Set(['@modelcontextprotocol/core-internal']);
function normalizeToRoot(pkg: string): string {
const secondSlash = pkg.indexOf('/', pkg.indexOf('/') + 1);

View file

@ -1,11 +1,24 @@
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import type { Diagnostic, TransformContext } from '../types';
import { warning } from './diagnostics';
import { info, warning } from './diagnostics';
const PROJECT_ROOT_MARKERS = ['.git', 'node_modules'];
const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'build', '.next', '.nuxt', 'coverage']);
const SCAN_FILE_BUDGET = 5000;
// Matches a quoted v1 SDK client/server subpath import specifier — e.g.
// '@modelcontextprotocol/sdk/client/index.js' "@modelcontextprotocol/sdk/server/mcp.js"
// '@modelcontextprotocol/sdk/client' (extensionless / bare subpath; see the extensionless
// import matching the codemod already supports)
// Anchored to the opening quote and a trailing `/` or closing quote so that comments or prose that
// merely mention the path do not count, and `…/client` is not confused with `…/clientfoo`.
const CLIENT_IMPORT_RE = /['"`]@modelcontextprotocol\/sdk\/client(?:\/|['"`])/;
const SERVER_IMPORT_RE = /['"`]@modelcontextprotocol\/sdk\/server(?:\/|['"`])/;
export function findPackageJson(startDir: string): string | undefined {
let dir = path.resolve(startDir);
const root = path.parse(dir).root;
@ -20,27 +33,86 @@ export function findPackageJson(startDir: string): string | undefined {
export function analyzeProject(targetDir: string): TransformContext {
const pkgJsonPath = findPackageJson(targetDir);
if (!pkgJsonPath) {
return { projectType: 'unknown' };
if (pkgJsonPath) {
try {
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
const allDeps = {
...pkgJson.dependencies,
...pkgJson.devDependencies
};
const hasClient = '@modelcontextprotocol/client' in allDeps;
const hasServer = '@modelcontextprotocol/server' in allDeps;
if (hasClient && hasServer) return { projectType: 'both' };
if (hasClient) return { projectType: 'client' };
if (hasServer) return { projectType: 'server' };
// No v2 split deps — this is almost always a v1 project mid-migration (v1 ships as the single
// `@modelcontextprotocol/sdk` package). Fall through to inferring the type from source usage.
} catch {
// Malformed package.json — fall through to source inference.
}
}
return { projectType: inferProjectTypeFromSource(targetDir) };
}
/**
* Infer client vs server vs both by scanning the source for v1 SDK subpath imports: a
* `@modelcontextprotocol/sdk/client/...` specifier means the project will need
* `@modelcontextprotocol/client`; a `.../server/...` specifier means it needs `@modelcontextprotocol/server`.
* Files that import only shared paths (`types.js`, `shared/...`) give no signal. The scan matches quoted
* specifiers (not bare substrings), so comments/prose are ignored. Bounded: skips heavy dirs, caps the
* file count, and early-exits once both signals are seen.
*/
function inferProjectTypeFromSource(targetDir: string): TransformContext['projectType'] {
let usesClient = false;
let usesServer = false;
let scanned = 0;
const visit = (dir: string): void => {
if (usesClient && usesServer) return;
let entries: import('node:fs').Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (usesClient && usesServer) return;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SCAN_SKIP_DIRS.has(entry.name)) continue;
visit(full);
} else if (entry.isFile()) {
const ext = path.extname(entry.name);
if (!SCAN_EXTENSIONS.has(ext) || entry.name.endsWith('.d.ts')) continue;
if (scanned >= SCAN_FILE_BUDGET) return;
scanned++;
let content: string;
try {
content = readFileSync(full, 'utf8');
} catch {
continue;
}
if (!usesClient && CLIENT_IMPORT_RE.test(content)) usesClient = true;
if (!usesServer && SERVER_IMPORT_RE.test(content)) usesServer = true;
}
}
};
let root = targetDir;
try {
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
const allDeps = {
...pkgJson.dependencies,
...pkgJson.devDependencies
};
const hasClient = '@modelcontextprotocol/client' in allDeps;
const hasServer = '@modelcontextprotocol/server' in allDeps;
if (hasClient && hasServer) return { projectType: 'both' };
if (hasClient) return { projectType: 'client' };
if (hasServer) return { projectType: 'server' };
return { projectType: 'unknown' };
if (!statSync(targetDir).isDirectory()) root = path.dirname(targetDir);
} catch {
return { projectType: 'unknown' };
return 'unknown';
}
visit(root);
if (usesClient && usesServer) return 'both';
if (usesClient) return 'client';
if (usesServer) return 'server';
return 'unknown';
}
export function resolveTypesPackage(
@ -61,6 +133,22 @@ export function resolveTypesPackage(
if (context.projectType === 'server') {
return '@modelcontextprotocol/server';
}
if (context.projectType === 'both') {
// Both packages are present and both re-export the shared protocol types (from core), so importing
// from either compiles. This file has no client/server-specific signal — default to server and note
// it as an optional preference, not an action-required warning.
if (diagnosticSink) {
diagnosticSink.diagnostics.push(
info(
diagnosticSink.filePath,
diagnosticSink.line,
'Shared protocol types imported from @modelcontextprotocol/server (both client and server ' +
're-export them). Switch to @modelcontextprotocol/client if this is client-only code.'
)
);
}
return '@modelcontextprotocol/server';
}
if (diagnosticSink) {
diagnosticSink.diagnostics.push(
warning(