feat(codemod): manifest handling overhaul and fixes from migrating sample dependents (#2393)

This commit is contained in:
Felix Weinberger 2026-06-30 15:01:07 +01:00 • committed by GitHub
commit e4e8b22dff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 4888 additions and 448 deletions

View file

@ -8,6 +8,7 @@ import { Command } from 'commander';
import { listMigrations } from './migrations/index';
import { run } from './runner';
import type { PackageJsonChange } from './types';
import { DiagnosticLevel } from './types';
import { detectFormatter } from './utils/detectFormatter';
import { CODEMOD_ERROR_PREFIX, formatDiagnostic } from './utils/diagnostics';
@ -15,10 +16,32 @@ import { CODEMOD_ERROR_PREFIX, formatDiagnostic } from './utils/diagnostics';
const require = createRequire(import.meta.url);
const { version } = require('../package.json') as { version: string };
const SOURCE_FILE_RE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
function quoteArg(arg: string): string {
return /\s/.test(arg) ? `"${arg}"` : arg;
}
function hasManifestEdits(pc: PackageJsonChange): boolean {
return pc.added.length > 0 || pc.removed.length > 0;
}
function printManifestChange(pc: PackageJsonChange): void {
console.log(` ${path.relative(process.cwd(), pc.packageJsonPath) || pc.packageJsonPath}`);
if (pc.removed.length > 0) {
console.log(` Removed: ${pc.removed.join(', ')}`);
}
if (pc.added.length > 0) {
console.log(` Added: ${pc.added.join(', ')}`);
}
for (const n of pc.notes ?? []) {
console.log(` Note: ${n}`);
}
for (const w of pc.warnings ?? []) {
console.log(` Warning: ${w}`);
}
}
/**
* The codemod transforms the AST but does not reformat — wrapped schemas and
* generated string literals can violate a repo's lint/formatting rules. Point
@ -71,8 +94,15 @@ for (const [name, migration] of listMigrations()) {
const resolvedDir = path.resolve(targetDir);
if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) {
console.error(`\nError: "${resolvedDir}" is not a valid directory.\n`);
const targetExists = existsSync(resolvedDir);
const targetIsFile = targetExists && statSync(resolvedDir).isFile();
if (!targetExists || (!statSync(resolvedDir).isDirectory() && !(targetIsFile && SOURCE_FILE_RE.test(resolvedDir)))) {
console.error(`\nError: "${resolvedDir}" is not a directory or a TypeScript/JavaScript source file.\n`);
process.exitCode = 1;
return;
}
if (targetIsFile && /\.d\.(?:ts|mts|cts)$/.test(resolvedDir)) {
console.error(`\nError: "${resolvedDir}" is a declaration file — declaration files are not migration targets.\n`);
process.exitCode = 1;
return;
}
@ -96,18 +126,44 @@ for (const [name, migration] of listMigrations()) {
});
if (result.filesChanged === 0 && result.diagnostics.length === 0 && !result.packageJsonChanges) {
console.log('No changes needed — code already migrated or no SDK imports found.\n');
if (result.mcpImportFiles > 0 && result.v1ImportFiles === 0) {
console.log(`No changes needed — ${result.mcpImportFiles} file(s) already import the v2 packages.\n`);
} else if (result.v1ImportFiles > 0) {
console.log(
`No changes applied — ${result.v1ImportFiles} file(s) still import the v1 SDK ` +
`(check which transforms were selected).\n`
);
} else {
console.log(`No MCP SDK imports found under ${resolvedDir} — nothing to migrate.\n`);
}
return;
}
if (targetIsFile && result.packageJsonChanges) {
console.log(
'Single-file run: manifest changes are reported for the owning package but not applied — ' +
'run the codemod at the package root to apply them.\n'
);
}
if (result.filesChanged > 0) {
console.log(`Changes: ${result.totalChanges} across ${result.filesChanged} file(s)\n`);
}
if (opts['verbose']) {
console.log('Files modified:');
for (const fr of result.fileResults) {
console.log(` ${fr.filePath} (${fr.changes} change(s))`);
const modified = result.fileResults.filter(fr => fr.changes > 0);
const diagnosticsOnly = result.fileResults.filter(fr => fr.changes === 0 && fr.diagnostics.length > 0);
if (modified.length > 0) {
console.log('Files modified:');
for (const fr of modified) {
console.log(` ${fr.filePath} (${fr.changes} change(s))`);
}
}
if (diagnosticsOnly.length > 0) {
console.log('Files with diagnostics only (not modified):');
for (const fr of diagnosticsOnly) {
console.log(` ${fr.filePath} (${fr.diagnostics.length} diagnostic(s))`);
}
}
console.log('');
}
@ -149,18 +205,36 @@ for (const [name, migration] of listMigrations()) {
console.log('');
}
if (result.packageJsonChanges) {
const pc = result.packageJsonChanges;
if (opts['dryRun']) {
console.log('package.json changes (dry run — not applied):');
} else {
console.log('package.json updated:');
const manifestChanges = result.packageJsonChanges ?? [];
// In single-file mode nothing is written, so even the nearest manifest's
// change set is presented as a report.
const appliedManifests = manifestChanges.filter(pc => pc.applied && hasManifestEdits(pc) && !targetIsFile);
const reportedManifests = manifestChanges.filter(pc => (!pc.applied || targetIsFile) && hasManifestEdits(pc));
const warningOnlyManifests = manifestChanges.filter(pc => !hasManifestEdits(pc));
if (manifestChanges.length > 0) {
if (appliedManifests.length > 0) {
if (opts['dryRun']) {
console.log('package.json changes (dry run — not applied):');
} else {
console.log('package.json updated:');
}
for (const pc of appliedManifests) {
printManifestChange(pc);
}
}
if (pc.removed.length > 0) {
console.log(` Removed: ${pc.removed.join(', ')}`);
if (reportedManifests.length > 0) {
console.log(
'Manifests that declare @modelcontextprotocol/sdk but are not modified by the codemod — apply the listed changes yourself:'
);
for (const pc of reportedManifests) {
printManifestChange(pc);
}
}
if (pc.added.length > 0) {
console.log(` Added: ${pc.added.join(', ')}`);
if (warningOnlyManifests.length > 0) {
console.log('Manifest warnings:');
for (const pc of warningOnlyManifests) {
printManifestChange(pc);
}
}
console.log('');
}
@ -177,7 +251,11 @@ for (const [name, migration] of listMigrations()) {
} else {
const changedFiles = result.fileResults.filter(fr => fr.changes > 0).map(fr => fr.filePath);
printFormatGuidance(resolvedDir, changedFiles);
if (result.packageJsonChanges) {
if (reportedManifests.length > 0) {
console.log(
'Apply the manifest changes listed above, then run your package manager to install the new packages.\n'
);
} else if (appliedManifests.length > 0) {
console.log('Run your package manager to install the new packages.\n');
}
console.log('Migration complete. Review the changes and run your build/tests.\n');

View file

@ -16,7 +16,13 @@ export interface ImportMapping {
*/
schemaSymbolTarget?: string;
removalMessage?: string;
/** No entries currently set this; scaffolding for when a v1 symbol has no v2 equivalent yet. */
/**
* Symbols from this module that have no v2 export anywhere. They are dropped from
* the rewritten import and the call site gets an action-required marker carrying
* the message, instead of an import of a member the target package does not have.
*/
removedSymbols?: Record<string, string>;
/** Marks a module-level removal as a known v2 gap (downgrades the removal diagnostic to the v2-gap category). For per-symbol removals use `removedSymbols`. */
isV2Gap?: boolean;
/** Emitted as an info diagnostic after a successful move, suggesting eventual migration to v2 equivalents. */
migrationHint?: string;
@ -27,6 +33,18 @@ export interface ImportMapping {
subpathSuffix?: string;
}
/**
* Resource-server auth helpers whose maintained v2 home is `@modelcontextprotocol/express`;
* the server-legacy/auth copy they route to by default is a frozen v1 snapshot, so import
* and re-export sites get a marker prompting a deliberate re-point.
*/
export const RS_ONLY_AUTH_SYMBOLS: ReadonlySet<string> = new Set([
'requireBearerAuth',
'mcpAuthMetadataRouter',
'getOAuthProtectedResourceMetadataUrl',
'OAuthTokenVerifier'
]);
export const IMPORT_MAP: Record<string, ImportMapping> = {
'@modelcontextprotocol/sdk/client/index.js': {
target: '@modelcontextprotocol/client',
@ -114,9 +132,11 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
},
'@modelcontextprotocol/sdk/server/auth/types.js': {
target: '@modelcontextprotocol/server-legacy/auth',
status: 'moved',
migrationHint: 'Legacy auth types. AuthInfo is also re-exported by @modelcontextprotocol/server.'
// The module's only export (AuthInfo) is re-exported by both leaf packages, so
// routing by context avoids pulling the deprecated legacy package into projects
// that use no authorization-server helpers.
target: 'RESOLVE_BY_CONTEXT',
status: 'moved'
},
'@modelcontextprotocol/sdk/server/auth/provider.js': {
target: '@modelcontextprotocol/server-legacy/auth',
@ -149,7 +169,17 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
},
'@modelcontextprotocol/sdk/shared/protocol.js': {
target: 'RESOLVE_BY_CONTEXT',
status: 'moved'
status: 'moved',
removedSymbols: {
Protocol:
'The Protocol base class is not exported by the v2 packages. To observe or handle inbound requests ' +
'that have no registered handler, use client.fallbackRequestHandler / server.fallbackRequestHandler; ' +
'build custom behavior on Client or Server instead of subclassing Protocol. ' +
'See the migration guide: Behavioral changes > Client connection & dispatch.',
mergeCapabilities:
'mergeCapabilities() is not exported by the v2 packages. Pass the complete capabilities object to the ' +
'Client/Server constructor, or merge capability objects with a plain object spread.'
}
},
'@modelcontextprotocol/sdk/shared/transport.js': {
target: 'RESOLVE_BY_CONTEXT',

View file

@ -37,3 +37,13 @@ export function symbolTargetOverride(name: string, mapping: ImportMapping): stri
}
return undefined;
}
/**
* Guidance for a symbol from this module that has no v2 export anywhere (it must be
* dropped/flagged rather than routed), or `undefined` when the symbol routes normally.
* Shared by the static-import, re-export, mock, and dynamic-import rewrites so all
* four treat such symbols the same way.
*/
export function removedSymbolGuidance(name: string, mapping: ImportMapping): string | undefined {
return mapping.removedSymbols?.[name];
}

View file

@ -0,0 +1,192 @@
import type { CallExpression, Expression, PropertyAccessExpression, SourceFile } from 'ts-morph';
import { SyntaxKind } from 'ts-morph';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
import { actionRequired, info, warning } from '../../../utils/diagnostics';
import { isAnyMcpSpecifier } from '../../../utils/importUtils';
/**
* v2 resolves completion metadata on the schema found AFTER unwrapping an outer
* optional wrapper, so the v1 idiom `completable(z.string().optional(), cb)` registers
* the metadata one level too deep: completions come back empty and, when no argument
* carries metadata in the v2 position, the server does not advertise the completions
* capability. The working v2 spelling inverts the nesting:
* `completable(z.string(), cb).optional()`.
*
* Only `optional` wrappers matter — the v2 lookup unwraps exactly one outer optional
* layer and nothing else, so `.default()` / `.nullable()` / `.catch()` arguments keep
* working as-is and are left alone. `.nullish()` is the one non-`optional` tail whose
* outer layer IS an optional wrapper, so it gets a marker with the concrete rewrite.
*/
type ArgVerdict =
| { kind: 'invert'; innerSchemaText: string }
| { kind: 'nullish' }
| { kind: 'buried-optional' }
| { kind: 'plain-call' }
| { kind: 'opaque' };
function unparenthesize(expr: Expression): Expression {
let current = expr;
while (true) {
const paren = current.asKind(SyntaxKind.ParenthesizedExpression);
if (!paren) return current;
current = paren.getExpression();
}
}
/**
* Classify the first argument of a completable() call.
*
* - `invert`: outermost wrapper is `optional` and the rewrite is mechanical.
* - `nullish`: outermost wrapper is `.nullish()` (its outer layer is an optional
* wrapper) — marker with the concrete manual rewrite.
* - `buried-optional`: an `optional`/`nullish` link sits deeper in the method chain
* (e.g. `.optional().describe(...)`) — marker; the safe spot for the wrapper
* depends on the chain.
* - `plain-call`: a call chain with no optional wrapper anywhere — leave alone.
* - `opaque`: not a call (identifier, property access, …) — the schema may be
* optional-wrapped where it is defined; one nudge per file.
*/
function classifyArg(arg: Expression): ArgVerdict {
const outer = unparenthesize(arg).asKind(SyntaxKind.CallExpression);
if (!outer) return { kind: 'opaque' };
const callee = outer.getExpression().asKind(SyntaxKind.PropertyAccessExpression);
if (callee) {
const name = callee.getName();
const args = outer.getArguments();
if (name === 'optional') {
if (args.length === 0) return { kind: 'invert', innerSchemaText: callee.getExpression().getText() };
if (args.length === 1) return { kind: 'invert', innerSchemaText: args[0]!.getText() };
return { kind: 'buried-optional' };
}
if (name === 'nullish') return { kind: 'nullish' };
}
// Walk the rest of the chain (and factory-call arguments) looking for a buried
// optional/nullish link, e.g. `z.string().optional().describe('x')` or
// `z.optional(z.string()).meta({...})`.
let current: Expression | undefined = unparenthesize(arg);
while (current) {
const call: CallExpression | undefined = current.asKind(SyntaxKind.CallExpression);
if (!call) break;
const pa: PropertyAccessExpression | undefined = call.getExpression().asKind(SyntaxKind.PropertyAccessExpression);
if (!pa) break;
const name = pa.getName();
if (name === 'optional' || name === 'nullish') return { kind: 'buried-optional' };
current = pa.getExpression();
}
return { kind: 'plain-call' };
}
export const completableNestingTransform: Transform = {
name: 'Completable optional-nesting inversion',
id: 'completable-nesting',
apply(sourceFile: SourceFile, _context: TransformContext): TransformResult {
const diagnostics: Diagnostic[] = [];
let changesCount = 0;
const filePath = sourceFile.getFilePath();
// Local bindings of `completable` from an MCP package (named import, possibly
// aliased) and MCP namespace bindings (`ns.completable(...)`). importPaths runs
// earlier, so the specifier is normally the v2 package already; the v1 specifier
// is accepted too so the transform also works in isolation.
const localNames = new Set<string>();
const namespaceNames = new Set<string>();
for (const imp of sourceFile.getImportDeclarations()) {
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue;
for (const ni of imp.getNamedImports()) {
if (ni.getName() === 'completable') localNames.add(ni.getAliasNode()?.getText() ?? 'completable');
}
const ns = imp.getNamespaceImport();
if (ns) namespaceNames.add(ns.getText());
}
if (localNames.size === 0 && namespaceNames.size === 0) {
return { changesCount: 0, diagnostics: [] };
}
const completableCalls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call: CallExpression) => {
const expr = call.getExpression();
if (expr.getKind() === SyntaxKind.Identifier) return localNames.has(expr.getText());
const pa = expr.asKind(SyntaxKind.PropertyAccessExpression);
return pa !== undefined && pa.getName() === 'completable' && namespaceNames.has(pa.getExpression().getText());
});
let opaqueArgNoted = false;
// Reverse document order so each replacement leaves the still-unprocessed
// (earlier) nodes' positions intact.
for (const call of completableCalls.toReversed()) {
const args = call.getArguments();
if (args.length === 0) continue;
const verdict = classifyArg(args[0]! as Expression);
switch (verdict.kind) {
case 'invert': {
const line = call.getStartLineNumber();
const calleeText = call.getExpression().getText();
const restArgs = args.slice(1).map(a => a.getText());
call.replaceWithText(`${calleeText}(${[verdict.innerSchemaText, ...restArgs].join(', ')}).optional()`);
changesCount++;
diagnostics.push(
info(
filePath,
line,
`completable() with an optional schema argument: moved .optional() outside the call — v2 resolves ` +
`completion metadata after unwrapping an outer optional wrapper, so completable(schema, cb).optional() ` +
`keeps both optionality and completions.`
)
);
break;
}
case 'nullish': {
diagnostics.push(
actionRequired(
filePath,
call,
`completable() first argument ends in .nullish(), whose outer layer is an optional wrapper — v2 resolves ` +
`completion metadata after unwrapping it, so completions would come back empty. Rewrite as ` +
`completable(schema.nullable(), cb).optional().`
)
);
break;
}
case 'buried-optional': {
diagnostics.push(
actionRequired(
filePath,
call,
`completable() first argument contains an optional wrapper inside its method chain — v2 resolves ` +
`completion metadata after unwrapping an outer optional wrapper, so the v1 nesting returns empty ` +
`completion lists. Move the optional wrapping to the completable(...) result ` +
`(completable(schema, cb).optional()) and rebuild the rest of the chain around it.`
)
);
break;
}
case 'opaque': {
// The schema may be optional-wrapped where it is defined. One nudge per
// file, no marker (plain schemas via identifiers are common and fine).
if (!opaqueArgNoted) {
opaqueArgNoted = true;
diagnostics.push({
...warning(
filePath,
call.getStartLineNumber(),
`completable() receives a schema by reference — verify the referenced schema is not wrapped in ` +
`.optional() inside the call. v2 resolves completion metadata after unwrapping an outer optional ` +
`wrapper, so optionality belongs on the completable(...) result: completable(schema, cb).optional().`
),
advisoryOnly: true
});
}
break;
}
case 'plain-call': {
break;
}
}
}
return { changesCount, diagnostics };
}
};

View file

@ -7,6 +7,8 @@ import { actionRequired, info } from '../../../utils/diagnostics';
import { hasMcpImports } from '../../../utils/importUtils';
import { CONTEXT_PROPERTY_MAP, CTX_PARAM_NAME, EXTRA_PARAM_NAME } from '../mappings/contextPropertyMap';
const CONTEXT_LIKE_KEYS = new Set(CONTEXT_PROPERTY_MAP.map(mapping => mapping.from.slice(1)));
const HANDLER_METHODS = new Set(['setRequestHandler', 'setNotificationHandler']);
const REGISTER_METHODS = new Set(['registerTool', 'registerPrompt', 'registerResource', 'tool', 'prompt', 'resource']);
@ -29,9 +31,25 @@ function processCallback(
const params = callbackNode.getParameters();
if (params.length < 2) return -1;
const extraParam = params[1]!;
// The context argument is always trailing — registerTool/registerPrompt callbacks
// are (args, extra) but a registerResource template read callback is
// (uri, variables, extra) — so find the parameter named `extra` among the
// non-first positions instead of assuming index 1. A destructured trailing
// parameter is selected too, so it reaches the destructuring diagnostic below.
const trailing = params.at(-1)!;
const extraParam =
params.slice(1).find(par => !Node.isObjectBindingPattern(par.getNameNode()) && par.getName() === EXTRA_PARAM_NAME) ??
(Node.isObjectBindingPattern(trailing.getNameNode()) ? trailing : undefined);
if (extraParam === undefined) return -1;
const paramNameNode = extraParam.getNameNode();
if (Node.isObjectBindingPattern(paramNameNode)) {
// A destructured trailing parameter is only the context when its keys look
// like context members — a registerResource template read callback's
// `(uri, { owner, repo })` destructures the URI variables, not the context.
// Match the PROPERTY names (a renamed binding `{ signal: abort }` still
// destructures the context's `signal`).
const propertyNames = paramNameNode.getElements().map(el => el.getPropertyNameNode()?.getText() ?? el.getName());
if (!propertyNames.some(name => CONTEXT_LIKE_KEYS.has(name))) return -1;
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
@ -112,7 +130,9 @@ function processCallback(
const propName = '.' + parent.getName();
const mapping = sortedMappings.find(m => m.from === propName);
if (mapping) {
replacements.push({ node: parent, newText: CTX_PARAM_NAME + mapping.to });
// Preserve optional chaining: `extra?.signal` stays defensive.
const joiner = parent.hasQuestionDotToken() ? '?' + mapping.to : mapping.to;
replacements.push({ node: parent, newText: CTX_PARAM_NAME + joiner });
continue;
}
}
@ -155,6 +175,33 @@ function processCallback(
);
}
// A context object forwarded wholesale to a helper carries the v1 property shape
// into code this transform never sees — note each callee once.
const forwardedBody = callbackNode.getBody();
if (forwardedBody) {
const notedCallees = new Set<string>();
forwardedBody.forEachDescendant(node => {
if (!Node.isCallExpression(node)) return;
const hasBareCtx = node
.getArguments()
.some(arg => Node.isIdentifier(arg) && arg.getText() === CTX_PARAM_NAME && !isKeyPositionIdentifier(arg));
if (!hasBareCtx) return;
const calleeText = node.getExpression().getText();
if (notedCallees.has(calleeText)) return;
notedCallees.add(calleeText);
diagnostics.push({
...actionRequired(
sourceFile.getFilePath(),
node,
`The context object is forwarded to ${calleeText}(…) — its property shape changed in v2 ` +
`(e.g. extra.signal is now ctx.mcpReq.signal, extra.sendRequest is ctx.mcpReq.send). Update the ` +
`helper's parameter type and property accesses.`
),
advisoryOnly: true
});
});
}
// Warn on destructuring of ctx in body (after text replacement)
const freshBody = callbackNode.getBody();
if (freshBody) {
@ -254,6 +301,207 @@ export const contextTypesTransform: Transform = {
}
}
changesCount += processFallbackHandlerAssignments(sourceFile, diagnostics);
changesCount += remapAnnotatedContextParams(sourceFile, diagnostics);
return { changesCount, diagnostics };
}
};
/**
* `server.fallbackRequestHandler = async (request, extra) => { … }` — the assigned
* function is a handler callback in everything but registration shape.
*/
function processFallbackHandlerAssignments(sourceFile: SourceFile, diagnostics: Diagnostic[]): number {
let changes = 0;
for (const bin of sourceFile.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
if (bin.wasForgotten()) continue;
if (bin.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) continue;
const left = bin.getLeft();
if (!Node.isPropertyAccessExpression(left)) continue;
const name = left.getName();
if (name !== 'fallbackRequestHandler' && name !== 'fallbackNotificationHandler') continue;
const rhs = bin.getRight();
if (!Node.isArrowFunction(rhs) && !Node.isFunctionExpression(rhs)) continue;
const result = processCallback(rhs, sourceFile, diagnostics, name, bin.getStartLineNumber());
if (result > 0) changes += result;
}
return changes;
}
const CONTEXT_TYPE_NAMES = new Set(['RequestHandlerExtra', 'ServerContext', 'ClientContext']);
/**
* Split a type's text on top-level `|` only (angle brackets tracked). Returns
* undefined for shapes that can never be a bare context reference: intersections,
* object literals, and unbalanced text.
*/
function splitTopLevelUnion(typeText: string): string[] | undefined {
const parts: string[] = [];
let depth = 0;
let current = '';
for (const ch of typeText) {
if (ch === '<') depth++;
else if (ch === '>') depth--;
if (depth === 0 && (ch === '&' || ch === '{')) return undefined;
if (ch === '|' && depth === 0) {
parts.push(current.trim());
current = '';
} else {
current += ch;
}
}
parts.push(current.trim());
return depth === 0 ? parts.filter(part => part !== '') : undefined;
}
const CONTEXT_TYPE_RE = /\b(?:RequestHandlerExtra|ServerContext|ClientContext)\b/;
/** True when `name` is rebound by a function parameter between `node` and `scopeRoot`. */
function isRebornWithin(node: Node, scopeRoot: Node, name: string): boolean {
let current = node.getParent();
while (current !== undefined && current !== scopeRoot) {
if (
(Node.isArrowFunction(current) ||
Node.isFunctionExpression(current) ||
Node.isFunctionDeclaration(current) ||
Node.isMethodDeclaration(current)) &&
current.getParameters().some(param => param.getName() === name)
) {
return true;
}
current = current.getParent();
}
return false;
}
/**
* Functions and methods whose parameter is ANNOTATED as a context type (directly,
* `| undefined`-widened, or via same-file aliases resolved to fixpoint) carry the v1
* property shape in their bodies even when the call-site scan never reaches them —
* private handler methods, alias-typed callbacks. The accesses remap in place; the
* parameter keeps its name. Annotations that mention a context type in a shape the
* remap cannot prove (unions with other types, containers) get an advisory instead
* of silence.
*/
function remapAnnotatedContextParams(sourceFile: SourceFile, diagnostics: Diagnostic[]): number {
// An alias only joins the set when its right-hand side IS a context type (a bare,
// possibly `| undefined`-widened reference to one, or to another accepted alias).
// A wrapper that merely MENTIONS a context type (`{ mcp: ServerContext; signal: … }`)
// has its own property shape — remapping accesses on it would corrupt them — so it
// falls through to the advisory path below.
const aliasNames = new Set<string>();
const isDirectContextReference = (typeText: string): boolean => {
const parts = splitTopLevelUnion(typeText);
if (parts === undefined) return false;
const names = parts.filter(part => part !== 'undefined' && part !== 'null');
if (names.length !== 1) return false;
const base = names[0]!.replace(/<[\s\S]*$/, '').trim();
return /^[A-Za-z_$][\w$]*$/.test(base) && (CONTEXT_TYPE_NAMES.has(base) || aliasNames.has(base));
};
let grew = true;
while (grew) {
grew = false;
for (const alias of sourceFile.getTypeAliases()) {
if (aliasNames.has(alias.getName())) continue;
if (isDirectContextReference(alias.getTypeNode()?.getText() ?? '')) {
aliasNames.add(alias.getName());
grew = true;
}
}
}
// Aliases that MENTION a context type without being one (wrappers, intersections,
// containers) cannot be remapped, but parameters typed with them still carry v1
// context members somewhere inside — they get the advisory, never the rewrite.
const aliasMentions = new Set<string>(aliasNames);
grew = true;
while (grew) {
grew = false;
for (const alias of sourceFile.getTypeAliases()) {
if (aliasMentions.has(alias.getName())) continue;
const text = alias.getTypeNode()?.getText() ?? '';
const mentions =
CONTEXT_TYPE_RE.test(text) || [...aliasMentions].some(known => new RegExp(String.raw`\b${known}\b`).test(text));
if (mentions) {
aliasMentions.add(alias.getName());
grew = true;
}
}
}
const matchesContextType = (annotation: string): boolean => {
// Strip generic arguments and nullable widening; the base must BE the type.
const base = annotation
.replace(/<[\s\S]*$/, '')
.replaceAll(/\s*\|\s*(undefined|null)\s*$/g, '')
.trim();
return CONTEXT_TYPE_NAMES.has(base) || aliasNames.has(base);
};
const mentionsContextType = (annotation: string): boolean =>
CONTEXT_TYPE_RE.test(annotation) || [...aliasMentions].some(known => new RegExp(String.raw`\b${known}\b`).test(annotation));
const sortedMappings = [...CONTEXT_PROPERTY_MAP]
.filter(mapping => mapping.from !== mapping.to)
.toSorted((a, b) => b.from.length - a.from.length);
let changes = 0;
sourceFile.forEachDescendant(node => {
if (
!Node.isArrowFunction(node) &&
!Node.isFunctionExpression(node) &&
!Node.isFunctionDeclaration(node) &&
!Node.isMethodDeclaration(node)
) {
return;
}
for (const param of node.getParameters()) {
const annotation = param.getTypeNode()?.getText();
if (annotation === undefined) continue;
const nameNode = param.getNameNode();
if (Node.isObjectBindingPattern(nameNode)) continue; // destructured: handled by the marker path
if (!matchesContextType(annotation)) {
if (mentionsContextType(annotation)) {
diagnostics.push({
...actionRequired(
sourceFile.getFilePath(),
param,
`Parameter annotation '${annotation}' mentions a context type in a shape the codemod cannot ` +
`remap — review the body's property accesses (e.g. .signal is now .mcpReq.signal).`
),
advisoryOnly: true
});
}
continue;
}
const paramName = param.getName();
const body = node.getBody();
if (body === undefined) continue;
const replacements: { target: import('ts-morph').PropertyAccessExpression; newText: string }[] = [];
body.forEachDescendant(descendant => {
if (!Node.isPropertyAccessExpression(descendant)) return;
const expr = descendant.getExpression();
if (!Node.isIdentifier(expr) || expr.getText() !== paramName) return;
if (isRebornWithin(descendant, node, paramName)) return;
const mapping = sortedMappings.find(entry => entry.from === '.' + descendant.getName());
if (mapping === undefined) return;
// Preserve optional chaining: `extra?.signal` stays defensive.
const joiner = descendant.hasQuestionDotToken() ? '?' + mapping.to : mapping.to;
replacements.push({ target: descendant, newText: paramName + joiner });
});
if (replacements.length === 0) continue;
for (const { target, newText } of replacements.toSorted((a, b) => b.target.getStart() - a.target.getStart())) {
target.replaceWithText(newText);
changes++;
}
diagnostics.push(
info(
sourceFile.getFilePath(),
param.getStartLineNumber(),
`Remapped v1 context property accesses on '${paramName}' (annotated ${annotation}) to the v2 shape.`
)
);
}
});
return changes;
}

View file

@ -1,5 +1,5 @@
import type { SourceFile } from 'ts-morph';
import { Node, SyntaxKind } from 'ts-morph';
import { Node, SyntaxKind, VariableDeclarationKind } from 'ts-morph';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
import { actionRequired } from '../../../utils/diagnostics';
@ -29,6 +29,35 @@ export const handlerRegistrationTransform: Transform = {
if (!Node.isPropertyAccessExpression(expr)) continue;
const methodName = expr.getName();
if (methodName === 'removeRequestHandler' || methodName === 'removeNotificationHandler') {
const arg0 = call.getArguments()[0];
if (
arg0 === undefined ||
Node.isStringLiteral(arg0) ||
Node.isNoSubstitutionTemplateLiteral(arg0) ||
Node.isTemplateExpression(arg0)
)
continue;
const shapeMatch = arg0.getText().match(/^([A-Za-z_$][\w$]*)\.shape\.method\.value$/);
const shapeBase = shapeMatch ? (resolveOriginalImportName(sourceFile, shapeMatch[1]!) ?? shapeMatch[1]!) : undefined;
const shapeMethod = shapeBase === undefined ? undefined : ALL_SCHEMA_TO_METHOD[shapeBase];
if (shapeMatch && shapeMethod !== undefined && isImportedFromMcp(sourceFile, shapeMatch[1]!)) {
arg0.replaceWithText(`'${shapeMethod}'`);
changesCount++;
removeUnusedImport(sourceFile, shapeMatch[1]!, true);
} else {
diagnostics.push({
...actionRequired(
sourceFile.getFilePath(),
call,
`${methodName} takes the method string in v2 — replace the schema-derived argument with the ` +
`literal method name (no change needed if this already passes a string).`
),
advisoryOnly: true
});
}
continue;
}
if (methodName !== 'setRequestHandler' && methodName !== 'setNotificationHandler') {
continue;
}
@ -37,7 +66,35 @@ export const handlerRegistrationTransform: Transform = {
if (args.length < 2) continue;
const firstArg = args[0]!;
if (!Node.isIdentifier(firstArg)) continue;
if (!Node.isIdentifier(firstArg)) {
// A string first argument is already the v2 form; any other expression
// (inline schema object, property access, `X.shape.method.value`) is a
// v1 registration the rename path cannot resolve — mark it instead of
// skipping silently.
if (Node.isStringLiteral(firstArg) || Node.isNoSubstitutionTemplateLiteral(firstArg) || Node.isTemplateExpression(firstArg))
continue;
const shapeMatch = firstArg.getText().match(/^([A-Za-z_$][\w$]*)\.shape\.method\.value$/);
const shapeBase = shapeMatch ? (resolveOriginalImportName(sourceFile, shapeMatch[1]!) ?? shapeMatch[1]!) : undefined;
const shapeMethod = shapeBase === undefined ? undefined : ALL_SCHEMA_TO_METHOD[shapeBase];
if (shapeMatch && shapeMethod !== undefined && isImportedFromMcp(sourceFile, shapeMatch[1]!)) {
firstArg.replaceWithText(`'${shapeMethod}'`);
changesCount++;
removeUnusedImport(sourceFile, shapeMatch[1]!, true);
} else {
diagnostics.push({
...actionRequired(
sourceFile.getFilePath(),
call,
`${methodName} with a non-string first argument: v2 takes a method string — use the typed ` +
`two-argument spec form, or the three-argument custom form ` +
`(${methodName}('method/name', { params, result? }, handler)). No change is needed if the ` +
`argument already holds a method string.`
),
advisoryOnly: true
});
}
continue;
}
const schemaName = firstArg.getText();
const originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName;
@ -56,7 +113,44 @@ export const handlerRegistrationTransform: Transform = {
continue;
}
const methodString = ALL_SCHEMA_TO_METHOD[originalName];
let methodString = ALL_SCHEMA_TO_METHOD[originalName];
if (methodString === undefined) {
// `const S = ListToolsRequestSchema; setRequestHandler(S, …)` — resolve
// one same-file variable hop before declaring the schema custom. Only an
// unambiguous binding qualifies: exactly one const declaration of the
// name anywhere in the file, so shadowed or reassigned locals keep the
// custom-handler marker instead of being silently rewritten.
const declsOfName = sourceFile
.getDescendantsOfKind(SyntaxKind.VariableDeclaration)
.filter(decl => decl.getName() === schemaName);
const localDecl =
declsOfName.length === 1 &&
declsOfName[0]!.getVariableStatement()?.getDeclarationKind() === VariableDeclarationKind.Const
? declsOfName[0]
: undefined;
const localInit = localDecl?.getInitializer();
if (localInit !== undefined && Node.isIdentifier(localInit)) {
const initName = localInit.getText();
const initOriginal = resolveOriginalImportName(sourceFile, initName) ?? initName;
if (REMOVED_TASK_SCHEMAS.has(initOriginal) && isImportedFromMcp(sourceFile, initName)) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
call,
`Task handler registration: ${methodName}(${schemaName}, ...). ` +
`The experimental tasks feature was removed in v2 (SEP-2663); the tasks/* method strings ` +
`are not part of the typed RequestMethod surface. Remove this registration. ` +
`See docs/migration/upgrade-to-v2.md#experimental-tasks-interception-removed.`
)
);
continue;
}
const viaLocal = ALL_SCHEMA_TO_METHOD[initOriginal];
if (viaLocal !== undefined && isImportedFromMcp(sourceFile, initName)) {
methodString = viaLocal;
}
}
}
if (!methodString) {
diagnostics.push(
actionRequired(
@ -70,7 +164,8 @@ export const handlerRegistrationTransform: Transform = {
continue;
}
if (!isImportedFromMcp(sourceFile, schemaName)) continue;
const viaLocalVariable = ALL_SCHEMA_TO_METHOD[originalName] === undefined;
if (!viaLocalVariable && !isImportedFromMcp(sourceFile, schemaName)) continue;
firstArg.replaceWithText(`'${methodString}'`);
changesCount++;

View file

@ -1,14 +1,14 @@
import type { SourceFile } from 'ts-morph';
import type { Node, SourceFile } from 'ts-morph';
import { SyntaxKind } from 'ts-morph';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
import { renameAllReferences } from '../../../utils/astUtils';
import { findFirstIdentifierOutsideImports, 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 { AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT } from '../mappings/authSchemaNames';
import { isAuthImport, lookupImportMapping } from '../mappings/importMap';
import { isAuthImport, lookupImportMapping, RS_ONLY_AUTH_SYMBOLS } from '../mappings/importMap';
import { isSharedSchemaConst, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
import { SIMPLE_RENAMES } from '../mappings/symbolMap';
@ -102,6 +102,48 @@ export const importPathsTransform: Transform = {
continue;
}
// Resource-server helpers have a maintained v2 home in @modelcontextprotocol/express;
// the server-legacy/auth copy they are routed to is a frozen v1 snapshot. The re-point
// is a judgment call (the express middleware answers verifier throws of the v1 error
// classes with a generic 500, so verifiers must move to the v2 OAuthError with it) —
// mark the import so the call sites get looked at instead of quietly staying on legacy.
if (mapping.target === '@modelcontextprotocol/server-legacy/auth') {
const matched = namedImports.filter(n => RS_ONLY_AUTH_SYMBOLS.has(n.getName()));
const valueMatchedSpecifiers = matched.filter(n => !typeOnly && !n.isTypeOnly());
const valueMatched = valueMatchedSpecifiers.map(n => n.getName());
const typeMatched = matched.filter(n => typeOnly || n.isTypeOnly()).map(n => n.getName());
if (valueMatched.length > 0) {
// The marker must outlive this pass: the import declaration it
// describes is removed when the path is rerouted, so anchor at the
// first use of one of the helpers (the import line otherwise).
const usageAnchor =
valueMatchedSpecifiers
.map(n => findFirstIdentifierOutsideImports(sourceFile, n.getAliasNode()?.getText() ?? n.getName()))
.find(node => node !== undefined) ?? imp;
diagnostics.push(
actionRequired(
filePath,
usageAnchor,
`${valueMatched.join(', ')}: resource-server auth helpers routed to the frozen ` +
`@modelcontextprotocol/server-legacy/auth copy. The maintained v2 home is ` +
`@modelcontextprotocol/express — when re-pointing, verifiers must throw the v2 OAuthError ` +
`(the express middleware does not recognize the legacy error classes). ` +
`See the migration guide's server auth split section.`
)
);
}
if (typeMatched.length > 0) {
diagnostics.push(
info(
filePath,
line,
`${typeMatched.join(', ')}: type-only import routed to @modelcontextprotocol/server-legacy/auth; ` +
`the maintained v2 type lives in @modelcontextprotocol/express — re-point when convenient.`
)
);
}
}
if (mapping.status === 'removed') {
imp.remove();
changesCount++;
@ -122,7 +164,11 @@ export const importPathsTransform: Transform = {
const needsContext =
namespaceImport != null ||
defaultImport != null ||
namedImports.some(n => symbolTargetOverride(n.getName(), mapping!) === undefined);
namedImports.some(
n =>
mapping!.removedSymbols?.[n.getName()] === undefined &&
symbolTargetOverride(n.getName(), mapping!) === undefined
);
if (needsContext) {
const base = resolveTypesPackage(context, hasClientImport, hasServerImport, { filePath, line, diagnostics });
targetPackage = mapping.subpathSuffix ? `${base}${mapping.subpathSuffix}` : base;
@ -179,6 +225,36 @@ export const importPathsTransform: Transform = {
);
}
}
// Qualified accesses to symbols with no v2 export (`ns.Protocol`) can't be fixed by
// moving the namespace binding — flag each accessed one. Expression positions are
// PropertyAccessExpressions; type positions (`let p: ns.Protocol`) are QualifiedNames.
if (mapping.removedSymbols) {
const nsName = namespaceImport.getText();
const accessedRemoved = new Map<string, Node>();
for (const pa of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
const memberName = pa.getName();
if (
pa.getExpression().getText() === nsName &&
mapping.removedSymbols[memberName] !== undefined &&
!accessedRemoved.has(memberName)
) {
accessedRemoved.set(memberName, pa);
}
}
for (const qn of sourceFile.getDescendantsOfKind(SyntaxKind.QualifiedName)) {
const memberName = qn.getRight().getText();
if (
qn.getLeft().getText() === nsName &&
mapping.removedSymbols[memberName] !== undefined &&
!accessedRemoved.has(memberName)
) {
accessedRemoved.set(memberName, qn);
}
}
for (const [name, node] of accessedRemoved) {
diagnostics.push(actionRequired(filePath, node, mapping.removedSymbols[name]!));
}
}
usedPackages.add(effectiveTarget);
imp.setModuleSpecifier(effectiveTarget);
if (mapping.renamedSymbols) {
@ -203,6 +279,18 @@ export const importPathsTransform: Transform = {
for (const n of namedImports) {
const name = n.getName();
const removalGuidance = mapping.removedSymbols?.[name];
if (removalGuidance !== undefined) {
// No v2 package exports this symbol — dropping it (with a marker) beats
// emitting an import of a member the target package does not have. Anchor
// the marker to a usage site: it survives the import rewrites, so the
// runner resolves a live line, and it is where the user must act anyway.
const usageName = n.getAliasNode()?.getText() ?? name;
diagnostics.push(
actionRequired(filePath, findFirstIdentifierOutsideImports(sourceFile, usageName) ?? imp, removalGuidance)
);
continue;
}
const alias = n.getAliasNode()?.getText();
const resolvedName = mapping.renamedSymbols?.[name] ?? name;
const specifierTypeOnly = typeOnly || n.isTypeOnly();
@ -332,6 +420,17 @@ function rewriteExportDeclarations(
}
}
// A star re-export of a module with removed symbols silently drops them from the
// barrel (the new target never exported them) — flag each, mirroring the
// schema-constant star-export diagnostic below.
if (mapping.removedSymbols && exp.getNamedExports().length === 0) {
for (const [name, guidance] of Object.entries(mapping.removedSymbols)) {
diagnostics.push(
actionRequired(filePath, exp, `Star re-export of ${specifier} will no longer provide ${name}. ${guidance}`)
);
}
}
if (mapping.symbolTargetOverrides || mapping.schemaSymbolTarget) {
const namedExports = exp.getNamedExports();
// A star re-export (`export * from …`, including `export * as ns from …`) has no named
@ -375,7 +474,21 @@ function rewriteExportDeclarations(
if (!spec.getAliasNode()) spec.setAlias(name);
spec.setName(newName);
}
if (REEXPORT_WARNINGS[name]) {
const removalGuidance = mapping.removedSymbols?.[name];
if (removalGuidance !== undefined) {
diagnostics.push(
actionRequired(filePath, exp, `Re-exported ${name} has no v2 export — remove this re-export. ${removalGuidance}`)
);
} else if (RS_ONLY_AUTH_SYMBOLS.has(name) && targetPackage === '@modelcontextprotocol/server-legacy/auth') {
diagnostics.push(
actionRequired(
filePath,
exp,
`Re-exported ${name} now points at the frozen @modelcontextprotocol/server-legacy/auth copy; the ` +
`maintained v2 home is @modelcontextprotocol/express. Re-point this barrel entry deliberately.`
)
);
} else if (REEXPORT_WARNINGS[name]) {
diagnostics.push(actionRequired(filePath, exp, REEXPORT_WARNINGS[name]!));
}
}

View file

@ -1,4 +1,5 @@
import type { Transform } from '../../../types';
import { completableNestingTransform } from './completableNesting';
import { contextTypesTransform } from './contextTypes';
import { handlerRegistrationTransform } from './handlerRegistration';
import { importPathsTransform } from './importPaths';
@ -29,7 +30,10 @@ import { symbolRenamesTransform } from './symbolRenames';
// 5. handlerRegistration and schemaParamRemoval are independent of each
// other but both depend on importPaths having run.
//
// 6. mockPaths runs last: handles test mocks and dynamic imports,
// 6. completableNesting runs after importPaths (it matches the rewritten
// completable import) and is independent of the rest.
//
// 7. mockPaths runs last: handles test mocks and dynamic imports,
// independent of the other transforms.
export const v1ToV2Transforms: Transform[] = [
importPathsTransform,
@ -39,5 +43,6 @@ export const v1ToV2Transforms: Transform[] = [
handlerRegistrationTransform,
schemaParamRemovalTransform,
contextTypesTransform,
completableNestingTransform,
mockPathsTransform
];

View file

@ -3,7 +3,7 @@ import { Node, SyntaxKind } from 'ts-morph';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
import { actionRequired, info } from '../../../utils/diagnostics';
import { isOriginalNameImportedFromMcp, resolveLocalImportName } from '../../../utils/importUtils';
import { hasMcpImports, isOriginalNameImportedFromMcp, resolveLocalImportName } from '../../../utils/importUtils';
export const mcpServerApiTransform: Transform = {
name: 'McpServer API migration',
@ -12,9 +12,15 @@ export const mcpServerApiTransform: Transform = {
const diagnostics: Diagnostic[] = [];
let changesCount = 0;
if (!isOriginalNameImportedFromMcp(sourceFile, 'McpServer')) {
const hasServerImport = isOriginalNameImportedFromMcp(sourceFile, 'McpServer');
if (!hasServerImport && !hasMcpImports(sourceFile)) {
return { changesCount: 0, diagnostics: [] };
}
// Without a direct McpServer import (harness objects exposing an `mcp` field,
// wrapped servers), legacy-name calls still migrate when their shape provably
// matches the v1 signature; non-matching calls stay silent rather than
// collecting hard markers on receivers whose type the codemod cannot see.
const provableShapesOnly = !hasServerImport;
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
@ -30,102 +36,114 @@ export const mcpServerApiTransform: Transform = {
if (!Node.isPropertyAccessExpression(expr)) continue;
const methodName = expr.getName();
const legacyPrequalified =
!provableShapesOnly ||
(call.getArguments().length >= 2 &&
isStringArg(call.getArguments()[0]!) &&
receiverLooksLikeMcpServer(expr.getExpression()));
// The same receiver evidence gates the modern register* names: a file
// with only an MCP type import can carry an unrelated
// `registry.registerTool('x', { inputSchema }, cb)` whose schema must
// not be wrapped.
const registerQualified = !provableShapesOnly || receiverLooksLikeMcpServer(expr.getExpression());
switch (methodName) {
case 'tool': {
toolCalls.push(call);
if (legacyPrequalified) toolCalls.push(call);
break;
}
case 'prompt': {
promptCalls.push(call);
if (legacyPrequalified) promptCalls.push(call);
break;
}
case 'resource': {
resourceCalls.push(call);
if (legacyPrequalified) resourceCalls.push(call);
break;
}
case 'registerTool': {
registerToolCalls.push(call);
if (registerQualified) registerToolCalls.push(call);
break;
}
case 'registerPrompt': {
registerPromptCalls.push(call);
if (registerQualified) registerPromptCalls.push(call);
break;
}
case 'registerResource': {
registerResourceCalls.push(call);
if (registerQualified) registerResourceCalls.push(call);
break;
}
}
}
for (const call of toolCalls) {
const result = migrateToolCall(call, sourceFile, diagnostics);
if (result) {
changesCount++;
} else {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
call,
'Could not automatically migrate .tool() call. Manual migration required.'
)
);
}
// ONE pass in reverse document order across every category: a registration
// nested inside another handler's body must migrate before the enclosing
// call's rewrite replaces the surrounding text and forgets the inner node —
// regardless of which category either call belongs to.
interface PendingCall {
call: CallExpression;
kind: 'tool' | 'prompt' | 'resource' | 'registerTool' | 'registerPrompt' | 'registerResource';
}
const orderedCalls: PendingCall[] = [
...toolCalls.map(call => ({ call, kind: 'tool' as const })),
...promptCalls.map(call => ({ call, kind: 'prompt' as const })),
...resourceCalls.map(call => ({ call, kind: 'resource' as const })),
...registerToolCalls.map(call => ({ call, kind: 'registerTool' as const })),
...registerPromptCalls.map(call => ({ call, kind: 'registerPrompt' as const })),
...registerResourceCalls.map(call => ({ call, kind: 'registerResource' as const }))
].toSorted((a, b) => b.call.getPos() - a.call.getPos());
for (const call of promptCalls) {
const result = migratePromptCall(call, sourceFile, diagnostics);
if (result) {
changesCount++;
} else {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
call,
'Could not automatically migrate .prompt() call. Manual migration required.'
)
);
}
}
const legacyFailure = (call: CallExpression, method: string): void => {
// In fallback mode (no direct McpServer import) the receiver's type is
// unknown — failures stay silent rather than marking non-MCP code.
if (provableShapesOnly) return;
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
call,
`Could not automatically migrate .${method}() call. Manual migration required.`
)
);
};
for (const call of resourceCalls) {
const result = migrateResourceCall(call, sourceFile);
if (result) {
changesCount++;
} else {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
call,
'Could not automatically migrate .resource() call. Manual migration required.'
)
);
}
}
for (const call of registerToolCalls) {
if (wrapSchemaInConfig(call, 'inputSchema', sourceFile, diagnostics)) {
changesCount++;
}
if (wrapSchemaInConfig(call, 'outputSchema', sourceFile, diagnostics)) {
changesCount++;
}
}
for (const call of registerPromptCalls) {
if (wrapSchemaInConfig(call, 'argsSchema', sourceFile, diagnostics)) {
changesCount++;
}
}
for (const call of registerResourceCalls) {
if (wrapSchemaInConfig(call, 'uriSchema', sourceFile, diagnostics)) {
changesCount++;
for (const { call, kind } of orderedCalls) {
if (call.wasForgotten()) continue;
switch (kind) {
case 'tool': {
if (migrateToolCall(call, sourceFile, diagnostics)) changesCount++;
else legacyFailure(call, 'tool');
break;
}
case 'prompt': {
if (migratePromptCall(call, sourceFile, diagnostics)) changesCount++;
else legacyFailure(call, 'prompt');
break;
}
case 'resource': {
if (migrateResourceCall(call, sourceFile)) changesCount++;
else legacyFailure(call, 'resource');
break;
}
case 'registerTool': {
if (wrapSchemaInConfig(call, 'inputSchema', sourceFile, diagnostics)) changesCount++;
if (!call.wasForgotten() && wrapSchemaInConfig(call, 'outputSchema', sourceFile, diagnostics)) changesCount++;
break;
}
case 'registerPrompt': {
if (wrapSchemaInConfig(call, 'argsSchema', sourceFile, diagnostics)) changesCount++;
break;
}
case 'registerResource': {
if (wrapSchemaInConfig(call, 'uriSchema', sourceFile, diagnostics)) changesCount++;
break;
}
}
}
flagRemovedTaskOptions(sourceFile, diagnostics);
ensureZodImportForWraps(sourceFile, diagnostics);
noteMockShapeAssertions(sourceFile, diagnostics);
return { changesCount, diagnostics };
}
};
@ -141,6 +159,8 @@ function isZodObjectCall(node: Node): boolean {
return expr.getName() === 'object' && expr.getExpression().getText() === 'z';
}
const WRAP_NOTE = 'wrapped with z.object().';
function wrapWithZObject(schemaText: string): string {
return `z.object(${schemaText})`;
}
@ -155,21 +175,17 @@ function maybeWrapSchema(node: Node): string {
function emitWrapDiagnostic(node: Node, sourceFile: SourceFile, call: CallExpression, diagnostics: Diagnostic[]): void {
if (Node.isObjectLiteralExpression(node)) {
diagnostics.push(
info(
sourceFile.getFilePath(),
call.getStartLineNumber(),
'Raw object literal wrapped with z.object(). Verify that zod (z) is imported in this file.'
)
);
diagnostics.push(info(sourceFile.getFilePath(), call.getStartLineNumber(), `Raw object literal ${WRAP_NOTE}`));
} else if (!isZodObjectCall(node)) {
diagnostics.push(
actionRequired(
diagnostics.push({
...actionRequired(
sourceFile.getFilePath(),
call,
'Schema argument is not an object literal — verify it is a z.object() schema. V2 requires a Zod schema, not a raw object.'
)
);
'Could not verify the schema argument is a schema object. Raw shapes are deprecated in v2 — ' +
'pass a Standard Schema object (e.g. z.object({ … })); no change is needed if it already is one.'
),
advisoryOnly: true
});
}
}
@ -196,13 +212,15 @@ function wrapSchemaInConfig(call: CallExpression, schemaPropertyName: string, so
if (!schemaProp) return false;
if (Node.isShorthandPropertyAssignment(schemaProp)) {
diagnostics.push(
actionRequired(
diagnostics.push({
...actionRequired(
sourceFile.getFilePath(),
call,
`Shorthand \`{ ${schemaPropertyName} }\` in config: verify the value is a z.object() schema, not a raw object. V2 requires a Zod schema.`
)
);
`Shorthand \`{ ${schemaPropertyName} }\` in config: could not verify the value is a schema object. Raw shapes ` +
`are deprecated in v2 — pass a Standard Schema object (e.g. z.object({ … })).`
),
advisoryOnly: true
});
return false;
}
@ -215,23 +233,21 @@ function wrapSchemaInConfig(call: CallExpression, schemaPropertyName: string, so
const wrapped = wrapWithZObject(initializer.getText());
initializer.replaceWithText(wrapped);
diagnostics.push(
info(
sourceFile.getFilePath(),
call.getStartLineNumber(),
`Raw object literal in ${schemaPropertyName} wrapped with z.object(). Verify that zod (z) is imported in this file.`
)
info(sourceFile.getFilePath(), call.getStartLineNumber(), `Raw object literal in ${schemaPropertyName} ${WRAP_NOTE}`)
);
return true;
}
if (!isZodObjectCall(initializer)) {
diagnostics.push(
actionRequired(
diagnostics.push({
...actionRequired(
sourceFile.getFilePath(),
call,
`\`${schemaPropertyName}\` value is not an object literal — verify it is a z.object() schema. V2 requires a Zod schema, not a raw object.`
)
);
`Could not verify \`${schemaPropertyName}\` is a schema object. Raw shapes are deprecated in v2 — ` +
`pass a Standard Schema object (e.g. z.object({ … })); no change is needed if it already is one.`
),
advisoryOnly: true
});
}
return false;
}
@ -452,3 +468,123 @@ function flagRemovedTaskOptions(sourceFile: SourceFile, diagnostics: Diagnostic[
}
}
}
/**
* Wrapping a raw shape introduces a `z.object(...)` reference. When the file has no
* `z` binding, add `import { z } from 'zod'` so the rewrite does not leave a dangling
* identifier; the package must also declare zod (the manifest summary warns when its
* declared range cannot satisfy v2's >=4.2 floor).
*/
function ensureZodImportForWraps(sourceFile: SourceFile, diagnostics: Diagnostic[]): void {
const wrapped = diagnostics.some(d => d.message.includes(WRAP_NOTE));
if (!wrapped) return;
// A value import named `z` (type-only imports are erased and cannot back a
// runtime z.object() call).
const zValueImport = sourceFile.getImportDeclarations().some(decl => {
if (decl.isTypeOnly()) return false;
if (decl.getNamespaceImport()?.getText() === 'z') return true;
if (decl.getDefaultImport()?.getText() === 'z') return true;
return decl.getNamedImports().some(ni => !ni.isTypeOnly() && (ni.getAliasNode()?.getText() ?? ni.getName()) === 'z');
});
if (zValueImport) return;
// `z` bound some other way (a variable, or a destructured require) — adding an
// import would redeclare it, and the existing binding may not be zod at all.
const zOtherBinding =
sourceFile.getVariableDeclaration('z') !== undefined ||
sourceFile.getDescendantsOfKind(SyntaxKind.BindingElement).some(be => be.getName() === 'z');
if (zOtherBinding) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
sourceFile.getImportDeclarations()[0] ?? sourceFile,
`Raw shapes were ${WRAP_NOTE.slice(0, -1)}, but \`z\` in this file is not a value import from zod — ` +
'wire the wrapped schemas to your zod instance manually (zod >=4.2.0 satisfies v2).'
)
);
return;
}
sourceFile.addImportDeclaration({ moduleSpecifier: 'zod', namedImports: ['z'] });
diagnostics.push({
...info(
sourceFile.getFilePath(),
1,
"Added `import { z } from 'zod'` for the wrapped raw shapes. The owning package must declare zod " +
'(>=4.2.0 satisfies v2) — the manifest summary adds or warns accordingly.'
),
tag: 'zod-injected'
});
}
const SCHEMA_CONFIG_KEYS = new Set(['inputSchema', 'outputSchema', 'argsSchema', 'uriSchema']);
/**
* `expect.objectContaining({ inputSchema: { … } })`-style call-shape assertions pin
* the v1 registration config; after migration the schemas are wrapped (z.object) and
* the literal shape no longer matches. The codemod cannot rewrite the assertion —
* note it.
*/
function noteMockShapeAssertions(sourceFile: SourceFile, diagnostics: Diagnostic[]): void {
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
if (call.wasForgotten()) continue;
const callee = call.getExpression();
if (!Node.isPropertyAccessExpression(callee) || callee.getName() !== 'objectContaining') continue;
const arg = call.getArguments()[0];
if (arg === undefined || !Node.isObjectLiteralExpression(arg)) continue;
const schemaProp = arg.getProperties().find(prop => {
const name = Node.isPropertyAssignment(prop) || Node.isShorthandPropertyAssignment(prop) ? prop.getName() : undefined;
return name !== undefined && SCHEMA_CONFIG_KEYS.has(name);
});
if (schemaProp === undefined) continue;
diagnostics.push({
...actionRequired(
sourceFile.getFilePath(),
call,
`Call-shape assertion pins a registration config schema — v2 configs carry wrapped schema objects ` +
`(z.object({ … })), so a raw-shape literal no longer matches. Assert with the wrapped schema or ` +
`expect.any(Object).`
),
advisoryOnly: true
});
}
}
/**
* Without a direct `McpServer` import the receiver's type is unknown, so the only
* safe rewrites are calls whose receiver itself is named like an MCP server
* (`server.tool(…)`, `harness.mcp.tool(…)`, `this.mockServer.prompt(…)`). The check
* is strictly on the TERMINAL name of the receiver chain: a file that merely imports
* an MCP type can contain shape-identical non-MCP calls — `cli.prompt('q', cb)`,
* `app.resource('users', '/u', handler)`, and members hanging off a server such as
* `this.server.cli.prompt(…)` — which must not be touched.
*/
function receiverLooksLikeMcpServer(receiver: Node): boolean {
const unwrapped = unwrapReceiver(receiver);
if (Node.isIdentifier(unwrapped)) return nameHasMcpServerWord(unwrapped.getText());
if (Node.isPropertyAccessExpression(unwrapped)) return nameHasMcpServerWord(unwrapped.getName());
return false;
}
/** Strip wrappers that do not change which object the method is called on. */
function unwrapReceiver(node: Node): Node {
let current = node;
for (;;) {
if (
Node.isParenthesizedExpression(current) ||
Node.isNonNullExpression(current) ||
Node.isAsExpression(current) ||
Node.isSatisfiesExpression(current)
) {
current = current.getExpression();
continue;
}
return current;
}
}
/** True when one of the identifier's camelCase / snake_case words is `mcp` or `server`. */
function nameHasMcpServerWord(name: string): boolean {
return name
.split(/[^a-zA-Z0-9]+|(?=[A-Z])/)
.filter(Boolean)
.some(word => /^(mcp|server)$/i.test(word));
}

View file

@ -7,7 +7,7 @@ import { isSdkSpecifier } from '../../../utils/importUtils';
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
import type { ImportMapping } from '../mappings/importMap';
import { isAuthImport, lookupImportMapping } from '../mappings/importMap';
import { isSharedSchemaConst, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
import { isSharedSchemaConst, removedSymbolGuidance, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
import { SIMPLE_RENAMES } from '../mappings/symbolMap';
/**
@ -28,7 +28,7 @@ function routeSymbols(symbols: string[], mapping: ImportMapping): { target?: str
return { mixed: false };
}
const MOCK_METHODS = new Set([
export const MOCK_METHODS: ReadonlySet<string> = new Set([
'mock',
'doMock',
'unmock',
@ -39,7 +39,7 @@ const MOCK_METHODS = new Set([
'requireMock',
'createMockFromModule'
]);
const MOCK_CALLERS = new Set(['vi', 'jest']);
export const MOCK_CALLERS: ReadonlySet<string> = new Set(['vi', 'jest']);
export const mockPathsTransform: Transform = {
name: 'Mock and dynamic import path rewrites',
@ -152,6 +152,20 @@ function rewriteMockCall(
return 0;
}
const removedHits = factorySymbols.filter(sym => removedSymbolGuidance(sym, resolved.mapping) !== undefined);
if (removedHits.length > 0) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
call,
`Mock factory from ${specifier} provides ${removedHits.join(', ')}, which no v2 package exports — rewriting the ` +
`specifier would mock a module that never had the member. Restructure the test instead. ` +
removedHits.map(sym => removedSymbolGuidance(sym, resolved.mapping)!).join(' ')
)
);
return 0;
}
let changes = 0;
let effectiveTarget = resolved.target;
@ -398,6 +412,20 @@ function rewriteDynamicImports(
return;
}
const removedHits = destructuredKeys.filter(sym => removedSymbolGuidance(sym, resolved.mapping) !== undefined);
if (removedHits.length > 0) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
node,
`Dynamic import of ${specifier} destructures ${removedHits.join(', ')}, which no v2 package exports — the ` +
`binding would be undefined at runtime. ` +
removedHits.map(sym => removedSymbolGuidance(sym, resolved.mapping)!).join(' ')
)
);
return;
}
let effectiveTarget = resolved.target;
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.mapping.renamedSymbols };

View file

@ -3,8 +3,39 @@ import { Node, SyntaxKind } from 'ts-morph';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
import { renameAllReferences } from '../../../utils/astUtils';
import { warning } from '../../../utils/diagnostics';
import { addOrMergeImport, isAnyMcpSpecifier } from '../../../utils/importUtils';
import { actionRequired, info, warning } from '../../../utils/diagnostics';
import { addOrMergeImport, hasMcpImports, isAnyMcpSpecifier } from '../../../utils/importUtils';
/**
* v2 `finishAuth(code, iss?)` verifies the callback's `iss` when the authorization
* server advertises `authorization_response_iss_parameter_supported` — and the v2
* server-legacy router advertises it by default. A bare `finishAuth(code)` stays
* type-correct (the parameter is optional) but the verification then has no input,
* so single-argument call sites in files this run changes get a run-log note
* pointing at the guide (not a marker: the one-argument URLSearchParams form is the
* blessed v2 spelling and is statically indistinguishable from the code-string
* form, so the note is advisory-only and stays quiet on already-migrated trees).
*/
function handleFinishAuthAdvisory(sourceFile: SourceFile, diagnostics: Diagnostic[]): void {
if (!hasMcpImports(sourceFile)) return;
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const expr = call.getExpression();
if (!Node.isPropertyAccessExpression(expr) || expr.getName() !== 'finishAuth') continue;
if (call.getArguments().length !== 1) continue;
diagnostics.push({
...warning(
sourceFile.getFilePath(),
call.getStartLineNumber(),
'finishAuth with one argument: if this passes the authorization code string, v2 also accepts ' +
'finishAuth(new URL(callbackUrl).searchParams) — preferred, since it carries the iss the callback check ' +
'reads when the authorization server advertises authorization_response_iss_parameter_supported ' +
'(the v2 server-legacy router advertises it by default). A call already passing URLSearchParams needs ' +
"no change. See the migration guide's authorization-server mix-up defense section."
),
advisoryOnly: true
});
}
}
const REMOVED_ZOD_HELPERS: Record<string, string> = {
schemaToJson:
@ -29,6 +60,7 @@ export const removedApisTransform: Transform = {
changesCount += handleRemovedZodHelpers(sourceFile, diagnostics);
changesCount += handleIsomorphicHeaders(sourceFile, diagnostics);
changesCount += handleStreamableHTTPError(sourceFile, diagnostics);
handleFinishAuthAdvisory(sourceFile, diagnostics);
return { changesCount, diagnostics };
}
@ -145,9 +177,9 @@ function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnost
if (!Node.isIdentifier(expr) || expr.getText() !== localName) continue;
hasConstructorCalls = true;
diagnostics.push(
warning(
actionRequired(
sourceFile.getFilePath(),
node.getStartLineNumber(),
node,
'new StreamableHTTPError(statusCode, statusText, body?) → new SdkHttpError(code, message, data). ' +
'Constructor arguments differ — manual review required. Map the HTTP status to a SdkErrorCode enum value ' +
'and pass the HTTP status via the data argument, e.g. { status, statusText }.'
@ -158,6 +190,8 @@ function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnost
renameAllReferences(sourceFile, localName, 'SdkHttpError');
changesCount++;
changesCount += rewriteGuardedStatusReads(sourceFile, diagnostics);
foundImport.remove();
if (foundImportDecl.getNamedImports().length === 0 && !foundImportDecl.getDefaultImport() && !foundImportDecl.getNamespaceImport()) {
foundImportDecl.remove();
@ -193,3 +227,110 @@ function resolveTargetModule(sourceFile: SourceFile, originalModule: string): st
if (originalModule.includes('/client')) return '@modelcontextprotocol/client';
return '@modelcontextprotocol/server';
}
/**
* Climb only through positive conjunctions (`&&`) and parentheses: under `||` or `!`
* the other operand evaluates exactly when the instanceof check FAILED, so it is not
* a guarded scope.
*/
function conjunctionRootOf(node: Node): Node {
let top: Node = node;
let parent = top.getParent();
while (parent !== undefined) {
if (Node.isParenthesizedExpression(parent)) {
top = parent;
} else if (Node.isBinaryExpression(parent) && parent.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken) {
top = parent;
} else {
break;
}
parent = top.getParent();
}
return top;
}
/** True when `name` is rebound by a function parameter between `node` and `scopeRoot`. */
function isShadowedWithin(node: Node, scopeRoot: Node, name: string): boolean {
let current = node.getParent();
while (current !== undefined && current !== scopeRoot) {
if (
(Node.isArrowFunction(current) ||
Node.isFunctionExpression(current) ||
Node.isFunctionDeclaration(current) ||
Node.isMethodDeclaration(current)) &&
current.getParameters().some(param => param.getName() === name)
) {
return true;
}
current = current.getParent();
}
return false;
}
/** True when the scope contains any assignment whose left side is `subject`. */
function subjectReassignedWithin(scope: Node, subject: string): boolean {
return scope.getDescendantsOfKind(SyntaxKind.BinaryExpression).some(bin => {
const op = bin.getOperatorToken().getKind();
return (
(op === SyntaxKind.EqualsToken || op === SyntaxKind.QuestionQuestionEqualsToken || op === SyntaxKind.BarBarEqualsToken) &&
bin.getLeft().getText() === subject
);
});
}
/**
* v2 carries the HTTP status on `.status`; `.code` is an `SdkErrorCode` string. A
* `.code` read on a value the surrounding check proves is an `SdkHttpError` —
* positive-conjunction siblings of the check, or reads inside the positively guarded
* if-block — rewrites mechanically. Unprovable reads keep the existing file-level
* warning.
*/
function rewriteGuardedStatusReads(sourceFile: SourceFile, diagnostics: Diagnostic[]): number {
let changes = 0;
for (const guard of sourceFile.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
if (guard.wasForgotten()) continue;
if (guard.getOperatorToken().getKind() !== SyntaxKind.InstanceOfKeyword) continue;
const right = guard.getRight();
if (!Node.isIdentifier(right) || right.getText() !== 'SdkHttpError') continue;
const subject = guard.getLeft().getText();
const conjunctionRoot = conjunctionRootOf(guard);
const scopes: Node[] = [conjunctionRoot];
// The then-block is guarded only when the check reaches the if condition
// through positive conjunctions alone (no `!`, no `||` on the path).
const ifStmt = guard.getFirstAncestorByKind(SyntaxKind.IfStatement);
if (ifStmt !== undefined && conjunctionRoot === ifStmt.getExpression()) {
const thenBlock = ifStmt.getThenStatement();
if (!subjectReassignedWithin(thenBlock, subject)) scopes.push(thenBlock);
}
let rewrote = false;
for (const scope of scopes) {
const reads = scope
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
.filter(
pa =>
!pa.wasForgotten() &&
pa.getName() === 'code' &&
pa.getExpression().getText() === subject &&
!isShadowedWithin(pa, scope, subject)
);
for (const pa of reads.toReversed()) {
pa.getNameNode().replaceWithText('status');
changes++;
rewrote = true;
}
}
if (rewrote) {
diagnostics.push(
info(
sourceFile.getFilePath(),
guard.getStartLineNumber(),
`Rewrote ${subject}.code to ${subject}.status under the instanceof SdkHttpError check — v2 carries ` +
`the HTTP status on .status (.code is an SdkErrorCode string).`
)
);
}
}
return changes;
}

View file

@ -69,10 +69,32 @@ export const schemaParamRemovalTransform: Transform = {
// 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++;
if (!fileHasMcpImports || args.length < 3) continue;
const firstArg = args[0]!;
if (methodName === 'request') {
// Drop only when the method is provably a literal spec method (the same
// proof the schema-identifier path requires). When the skip leaves an
// explicit undefined behind, the result is a LOUD compile error the user
// resolves with the call's intent in view — strictly better than silently
// changing semantics. The proof also keeps the rewrite off non-SDK
// receivers that happen to be named `request` with a bare-string first
// argument, where deleting the middle argument corrupts the call.
const literal = literalMethodOf(firstArg);
if (literal === undefined || !SPEC_REQUEST_METHODS.has(literal)) continue;
} else if (
Node.isStringLiteral(firstArg) ||
Node.isNoSubstitutionTemplateLiteral(firstArg) ||
Node.isTemplateExpression(firstArg) ||
Node.isNumericLiteral(firstArg) ||
firstArg.getKind() === SyntaxKind.TrueKeyword ||
firstArg.getKind() === SyntaxKind.FalseKeyword
) {
// v1 callTool() takes a params OBJECT first — a primitive first argument
// means a non-SDK receiver sharing the method name.
continue;
}
call.removeArgument(1);
changesCount++;
continue;
}

View file

@ -1,10 +1,10 @@
import type { SourceFile } from 'ts-morph';
import { Node } from 'ts-morph';
import { Node, SyntaxKind } from 'ts-morph';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
import { renameAllReferences } from '../../../utils/astUtils';
import { info, warning } from '../../../utils/diagnostics';
import { addOrMergeImport, isAnyMcpSpecifier, removeUnusedImport } from '../../../utils/importUtils';
import { findFirstIdentifierOutsideImports, renameAllReferences } from '../../../utils/astUtils';
import { actionRequired, info, warning } from '../../../utils/diagnostics';
import { addOrMergeImport, isAnyMcpSpecifier, isV2Specifier, removeUnusedImport } from '../../../utils/importUtils';
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
import { ERROR_CODE_SDK_MEMBERS, SIMPLE_RENAMES } from '../mappings/symbolMap';
@ -36,6 +36,7 @@ export const symbolRenamesTransform: Transform = {
}
}
changesCount += renameDynamicImportBindings(sourceFile);
changesCount += handleErrorCodeSplit(sourceFile, diagnostics);
changesCount += handleRequestHandlerExtra(sourceFile, context, diagnostics);
changesCount += handleSchemaInput(sourceFile, context, diagnostics);
@ -67,6 +68,18 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[])
let needsProtocolErrorCode = false;
let needsSdkErrorCode = false;
let needsSdkError = false;
// First pass: rewrite the enum sides and remember which boolean expressions hold
// SDK-member comparisons, protocol-member comparisons, or both. The instanceof
// pairing decision needs the WHOLE expression's membership, so it runs after.
interface BooleanExprInfo {
hasSdkMember: boolean;
hasProtocolMember: boolean;
}
const booleanExprs = new Map<Node, BooleanExprInfo>();
const ctorExprs = new Map<import('ts-morph').NewExpression, BooleanExprInfo>();
const matcherSubjects = new Map<Node, Map<string, BooleanExprInfo>>();
sourceFile.forEachDescendant(node => {
if (!Node.isPropertyAccessExpression(node)) return;
@ -74,17 +87,212 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[])
if (!Node.isIdentifier(expr) || expr.getText() !== errorCodeLocalName) return;
const member = node.getName();
if (ERROR_CODE_SDK_MEMBERS.has(member)) {
needsSdkErrorCode = true;
node.getExpression().replaceWithText('SdkErrorCode');
} else {
needsProtocolErrorCode = true;
node.getExpression().replaceWithText('ProtocolErrorCode');
}
const isSdkMember = ERROR_CODE_SDK_MEMBERS.has(member);
node.getExpression().replaceWithText(isSdkMember ? 'SdkErrorCode' : 'ProtocolErrorCode');
if (isSdkMember) needsSdkErrorCode = true;
else needsProtocolErrorCode = true;
changesCount++;
if (isInComparisonContext(node)) {
const root = booleanExpressionRoot(node);
const entry = booleanExprs.get(root) ?? { hasSdkMember: false, hasProtocolMember: false };
if (isSdkMember) entry.hasSdkMember = true;
else entry.hasProtocolMember = true;
booleanExprs.set(root, entry);
}
// Constructor pairing: `new ProtocolError(ErrorCode.RequestTimeout, …)` — the
// SDK-routed codes ride on SdkError, so the class must move with the member.
// Only the FIRST argument (the code) participates, and per-constructor
// membership is tracked so a ternary mixing both enums gets a marker rather
// than a wrong class.
{
const newExpr = node.getFirstAncestorByKind(SyntaxKind.NewExpression);
const classExpr = newExpr?.getExpression();
const firstArg = newExpr?.getArguments()[0];
if (
newExpr !== undefined &&
classExpr !== undefined &&
firstArg !== undefined &&
Node.isIdentifier(classExpr) &&
(classExpr.getText() === 'ProtocolError' || classExpr.getText() === 'McpError') &&
(node === firstArg || node.getAncestors().includes(firstArg))
) {
const entry = ctorExprs.get(newExpr) ?? { hasSdkMember: false, hasProtocolMember: false };
if (isSdkMember) entry.hasSdkMember = true;
else entry.hasProtocolMember = true;
ctorExprs.set(newExpr, entry);
}
}
// Matcher pairing: `expect(err.code).toBe(ErrorCode.X)` assertions correlate —
// by asserted subject within the enclosing block — with
// `expect(err).toBeInstanceOf(…)` class assertions handled below.
const subject = matcherSubjectOf(node);
if (subject !== undefined) {
const block: Node = node.getFirstAncestorByKind(SyntaxKind.Block) ?? sourceFile;
let bySubject = matcherSubjects.get(block);
if (!bySubject) {
bySubject = new Map();
matcherSubjects.set(block, bySubject);
}
const entry = bySubject.get(subject) ?? { hasSdkMember: false, hasProtocolMember: false };
if (isSdkMember) entry.hasSdkMember = true;
else entry.hasProtocolMember = true;
bySubject.set(subject, entry);
}
});
if (changesCount > 0) {
for (const [newExpr, entry] of ctorExprs) {
if (!entry.hasSdkMember) continue;
if (entry.hasProtocolMember) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
newExpr,
`This constructor's code argument mixes SdkErrorCode and ProtocolErrorCode members, but v2 raises ` +
`them on different error classes (SdkError and ProtocolError). Split the construction per class.`
)
);
} else {
newExpr.getExpression().replaceWithText('SdkError');
needsSdkError = true;
}
}
// `toBeInstanceOf(ProtocolError)` assertions whose subject is also asserted to
// carry an SDK-routed code in the same block follow the same rule as instanceof
// guards: all-SDK rewrites the class, mixed gets a marker.
{
let unpairedMatcherSites = 0;
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const callee = call.getExpression();
if (!Node.isPropertyAccessExpression(callee) || callee.getName() !== 'toBeInstanceOf') continue;
const classArg = call.getArguments()[0];
if (classArg === undefined || !Node.isIdentifier(classArg)) continue;
const className = classArg.getText();
if (className !== 'ProtocolError' && className !== 'McpError') continue;
const subject = instanceofSubjectOf(callee.getExpression());
const block: Node = call.getFirstAncestorByKind(SyntaxKind.Block) ?? sourceFile;
const entry = subject === undefined ? undefined : matcherSubjects.get(block)?.get(subject);
if (!entry || !entry.hasSdkMember) {
if (needsSdkErrorCode) unpairedMatcherSites++;
continue;
}
if (entry.hasProtocolMember) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
call,
`This subject is asserted with both SdkErrorCode and ProtocolErrorCode members, but v2 raises them ` +
`on different error classes — one toBeInstanceOf cannot cover both. Split the assertions per class.`
)
);
} else {
classArg.replaceWithText('SdkError');
needsSdkError = true;
if (subject !== undefined) {
const fnAncestor = call.getFirstAncestor(
a =>
Node.isArrowFunction(a) ||
Node.isFunctionExpression(a) ||
Node.isFunctionDeclaration(a) ||
Node.isMethodDeclaration(a)
);
const fnBody =
fnAncestor !== undefined &&
(Node.isArrowFunction(fnAncestor) ||
Node.isFunctionExpression(fnAncestor) ||
Node.isFunctionDeclaration(fnAncestor) ||
Node.isMethodDeclaration(fnAncestor))
? fnAncestor.getBody()
: undefined;
repointSubjectCasts(fnBody ?? block, subject);
}
}
}
// Codes the codemod routed to SdkErrorCode pair with SdkError; class matchers it
// could not correlate (cast/aliased subjects, toMatchObject shapes) may still
// name the wrong class — surface a run-log note, not a marker.
if (unpairedMatcherSites > 0 && needsSdkErrorCode) {
diagnostics.push(
warning(
sourceFile.getFilePath(),
1,
`This file routes ErrorCode members to SdkErrorCode and also asserts error classes with ` +
`toBeInstanceOf — review those assertions: SDK-routed codes are raised on SdkError, not ProtocolError.`
)
);
}
}
// Second pass: v2 raises the SDK codes on SdkError, not ProtocolError, so a
// guard of `instanceof ProtocolError` paired with an SDK-code comparison never
// matches. When the expression compares ONLY SDK codes, the guard rewrites
// mechanically; when it mixes SDK and protocol codes, one guard cannot cover
// both classes — mark it. Expressions with no instanceof guard are left alone
// (the enclosing guard, if any, may be elsewhere and already correct).
for (const [root, entry] of booleanExprs) {
if (!entry.hasSdkMember) continue;
for (const guard of instanceofGuards(root)) {
if (entry.hasProtocolMember) {
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
guard,
`This check compares both SdkErrorCode and ProtocolErrorCode members, but v2 raises them on ` +
`different error classes (SdkError and ProtocolError) — one instanceof guard cannot cover both. ` +
`Split the check per error class.`
)
);
} else if (guardPinnedToSdkCode(guard)) {
guard.getRight().replaceWithText('SdkError');
needsSdkError = true;
} else {
// `e instanceof X || e.code === SdkErrorCode.Y`, `e instanceof X &&
// e.code !== …`, a comparison on another subject: v1's class carried
// BOTH code families, so no single v2 class reproduces this check.
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
guard,
`In v1 this class carried both the protocol codes and the SDK-local codes; in v2 they split into ` +
`ProtocolError and SdkError. The codes compared here route to SdkErrorCode, but this check's shape ` +
`does not pin the guarded value to one class — match SdkError for SDK-raised codes, ProtocolError ` +
`for wire errors, or both.`
)
);
}
}
}
if (changesCount === 0) {
const decl = errorCodeImport.getImportDeclaration();
// Only a v2 specifier warrants the drop: none of the v2 packages export
// `ErrorCode`, so leaving the named import behind fails at module link time.
// A still-v1 specifier (isolated `--transforms symbols` run) is valid as-is —
// the imports transform handles it when it runs.
if (!isV2Specifier(decl.getModuleSpecifierValue())) return 0;
// Drop it and mark the first remaining use (re-export-only files are handled
// by the export rewrite's own warning).
const usage = findFirstIdentifierOutsideImports(sourceFile, errorCodeLocalName);
diagnostics.push(
actionRequired(
sourceFile.getFilePath(),
usage ?? decl,
`${errorCodeLocalName} is not exported by the v2 packages — it split into ProtocolErrorCode (protocol codes) ` +
`and SdkErrorCode (local SDK codes). No direct member access could be rewritten here; replace the remaining ` +
`uses with the appropriate enum.`
)
);
errorCodeImport.remove();
if (decl.getNamedImports().length === 0 && !decl.getDefaultImport() && !decl.getNamespaceImport()) {
decl.remove();
}
return 1;
}
{
const errorCodeImportDecl = errorCodeImport.getImportDeclaration();
// Capture target module before removing the import, so we don't lose the original
// module specifier when ErrorCode was the only named import in the declaration.
@ -112,6 +320,7 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[])
const newImports: string[] = [];
if (needsProtocolErrorCode) newImports.push('ProtocolErrorCode');
if (needsSdkErrorCode) newImports.push('SdkErrorCode');
if (needsSdkError) newImports.push('SdkError');
if (newImports.length > 0) {
const existingImp = sourceFile
@ -140,9 +349,195 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[])
);
}
if (needsSdkError) {
// Class rewrites can leave the original error-class import referenced nowhere.
removeUnusedImport(sourceFile, 'ProtocolError', true);
removeUnusedImport(sourceFile, 'McpError', true);
}
return changesCount;
}
const MATCHER_EQUALITY_NAMES = new Set(['toBe', 'toEqual', 'toStrictEqual']);
/** Strip parentheses and `as`/`satisfies` casts. */
function unwrapCasts(node: Node): Node {
let current = node;
while (Node.isParenthesizedExpression(current) || Node.isAsExpression(current) || Node.isSatisfiesExpression(current)) {
current = current.getExpression();
}
return current;
}
/**
* For `expect(x.code).toBe(<member>)`-style assertions where `memberNode` is the
* matcher argument, the asserted subject (`x`) — undefined when the member is not in
* that shape. Only a `.code` / `['code']` property read pairs (that is the premise of
* the class/code correlation); casts around the base are stripped so
* `expect((err as any).code)` pairs with `expect(err)`.
*/
function matcherSubjectOf(memberNode: Node): string | undefined {
const parent = memberNode.getParent();
if (parent === undefined || !Node.isCallExpression(parent)) return undefined;
if (!parent.getArguments().includes(memberNode)) return undefined;
const callee = parent.getExpression();
if (!Node.isPropertyAccessExpression(callee) || !MATCHER_EQUALITY_NAMES.has(callee.getName())) return undefined;
const expectArg = expectArgumentOf(callee.getExpression());
if (expectArg === undefined) return undefined;
const arg = unwrapCasts(expectArg);
if (Node.isPropertyAccessExpression(arg) && arg.getName() === 'code') {
return unwrapCasts(arg.getExpression()).getText();
}
if (Node.isElementAccessExpression(arg)) {
const key = arg.getArgumentExpression();
if (key !== undefined && Node.isStringLiteral(key) && key.getLiteralValue() === 'code') {
return unwrapCasts(arg.getExpression()).getText();
}
}
return undefined;
}
/**
* The subject a `toBeInstanceOf` assertion is about: the full (cast-stripped) text of
* the expect argument — `expect(err.cause)` asserts `err.cause`, not `err`.
*/
function instanceofSubjectOf(expr: Node): string | undefined {
const arg = expectArgumentOf(expr);
if (arg === undefined) return undefined;
return unwrapCasts(arg).getText();
}
/** Walk an expect-chain receiver (`expect(x)`, `expect(x).rejects`, …) to the expect call's first argument. */
function expectArgumentOf(expr: Node): Node | undefined {
let current: Node | undefined = expr;
while (current !== undefined) {
if (Node.isCallExpression(current)) {
const callee = current.getExpression();
if (Node.isIdentifier(callee) && callee.getText() === 'expect') {
return current.getArguments()[0];
}
current = callee;
continue;
}
if (Node.isPropertyAccessExpression(current)) {
current = current.getExpression();
continue;
}
return undefined;
}
return undefined;
}
const EQUALITY_OPERATORS = new Set([
SyntaxKind.EqualsEqualsToken,
SyntaxKind.EqualsEqualsEqualsToken,
SyntaxKind.ExclamationEqualsToken,
SyntaxKind.ExclamationEqualsEqualsToken
]);
/** True when the member access is one side of an equality comparison or a switch case expression. */
function isInComparisonContext(node: Node): boolean {
const parent = node.getParent();
if (parent !== undefined && Node.isBinaryExpression(parent) && EQUALITY_OPERATORS.has(parent.getOperatorToken().getKind())) {
return true;
}
return parent !== undefined && Node.isCaseClause(parent);
}
const LOGICAL_OPERATORS = new Set([SyntaxKind.AmpersandAmpersandToken, SyntaxKind.BarBarToken]);
/**
* The outermost expression reachable from `node` through logical operators (&&/||),
* parentheses, and `!` only — assignments, comma sequences, and arithmetic stop the
* climb so a guard stored or reused elsewhere is never claimed.
*/
function booleanExpressionRoot(node: Node): Node {
let top: Node = node;
let parent = top.getParent();
while (parent !== undefined) {
if (Node.isParenthesizedExpression(parent) || Node.isPrefixUnaryExpression(parent)) {
top = parent;
} else if (Node.isBinaryExpression(parent)) {
const op = parent.getOperatorToken().getKind();
if (!LOGICAL_OPERATORS.has(op) && !EQUALITY_OPERATORS.has(op)) break;
top = parent;
} else {
break;
}
parent = top.getParent();
}
return top;
}
/**
* Whether the class an `instanceof` guard names is pinned by a conjoined SDK-routed
* code comparison ON THE SAME SUBJECT: `e instanceof X && e.code ===
* SdkErrorCode.RequestTimeout`, including a negated guard (`!(e instanceof X) && …`
* excludes exactly the SDK-raised codes, which ride on SdkError in v2) and a
* disjunction of same-subject SDK codes (`e instanceof X && (e.code === A || e.code
* === B)`). A code reached only through `||` with the guard, a negated comparison
* (`!==`), another subject's code, or a nested function proves nothing about this
* guard's class.
*/
function guardPinnedToSdkCode(guard: import('ts-morph').BinaryExpression): boolean {
const subject = unwrapCasts(guard.getLeft()).getText();
// Conjunction scope of the guard: parentheses, negation of the guard itself, `&&`.
let top: Node = guard;
let parent = top.getParent();
while (parent !== undefined) {
const isAnd = Node.isBinaryExpression(parent) && parent.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken;
if (!Node.isParenthesizedExpression(parent) && !Node.isPrefixUnaryExpression(parent) && !isAnd) break;
top = parent;
parent = top.getParent();
}
return pinsSubjectToSdkCode(top, subject, guard);
}
/**
* Boolean-shape evaluation of "every way this expression is satisfied constrains
* `subject` to an SDK-routed code": an `&&` pins if either conjunct pins, an `||`
* pins only if every branch pins, a leaf pins only as a positive same-subject
* `SdkErrorCode` equality. The guard itself and anything else (calls, negations,
* other subjects) never pin.
*/
function pinsSubjectToSdkCode(node: Node, subject: string, guard: Node): boolean {
let expr: Node = node;
while (Node.isParenthesizedExpression(expr)) expr = expr.getExpression();
if (expr === guard) return false;
if (!Node.isBinaryExpression(expr)) return false;
const op = expr.getOperatorToken().getKind();
if (op === SyntaxKind.AmpersandAmpersandToken) {
return pinsSubjectToSdkCode(expr.getLeft(), subject, guard) || pinsSubjectToSdkCode(expr.getRight(), subject, guard);
}
if (op === SyntaxKind.BarBarToken) {
return pinsSubjectToSdkCode(expr.getLeft(), subject, guard) && pinsSubjectToSdkCode(expr.getRight(), subject, guard);
}
if (op !== SyntaxKind.EqualsEqualsEqualsToken && op !== SyntaxKind.EqualsEqualsToken) return false;
const sides = [expr.getLeft(), expr.getRight()];
const memberSide = sides.find(side => Node.isPropertyAccessExpression(side) && side.getExpression().getText() === 'SdkErrorCode');
if (memberSide === undefined) return false;
const valueSide = unwrapCasts(sides.find(side => side !== memberSide)!);
if (!Node.isPropertyAccessExpression(valueSide) && !Node.isElementAccessExpression(valueSide)) return false;
return unwrapCasts(valueSide.getExpression()).getText() === subject;
}
/** `instanceof ProtocolError`/`instanceof McpError` checks within the expression. */
function instanceofGuards(root: Node): import('ts-morph').BinaryExpression[] {
const guards: import('ts-morph').BinaryExpression[] = [];
const visit = (candidate: Node): void => {
if (Node.isBinaryExpression(candidate) && candidate.getOperatorToken().getKind() === SyntaxKind.InstanceOfKeyword) {
const right = candidate.getRight();
if (Node.isIdentifier(right) && (right.getText() === 'ProtocolError' || right.getText() === 'McpError')) {
guards.push(candidate);
}
}
};
visit(root);
root.forEachDescendant(visit);
return guards;
}
function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformContext, diagnostics: Diagnostic[]): number {
let changesCount = 0;
@ -354,3 +749,72 @@ function handleSchemaInput(sourceFile: SourceFile, context: TransformContext, di
return changesCount;
}
/**
* When the pairing moves a subject's asserted class to SdkError, a
* `const err = (await p) as ProtocolError` binding for the same subject becomes a
* type lie (.code typed as a number while the runtime value is an SdkErrorCode
* string) — re-point the cast with the assertion.
*/
function repointSubjectCasts(scope: Node, subject: string): void {
const repointIn = (expr: Node): void => {
const casts = Node.isAsExpression(expr) ? [expr] : expr.getDescendantsOfKind(SyntaxKind.AsExpression);
for (const cast of casts) {
if (cast.wasForgotten()) continue;
const typeNode = cast.getTypeNode();
const typeText = typeNode?.getText();
if (typeNode !== undefined && (typeText === 'ProtocolError' || typeText === 'McpError')) {
typeNode.replaceWithText('SdkError');
}
}
};
for (const decl of scope.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
if (decl.wasForgotten() || decl.getName() !== subject) continue;
const initializer = decl.getInitializer();
if (initializer !== undefined) repointIn(initializer);
}
// `err = e as McpError` inside a catch block is the dominant test shape — the
// cast lives in an assignment, not a declaration.
for (const bin of scope.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
if (bin.wasForgotten()) continue;
if (bin.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) continue;
if (bin.getLeft().getText() !== subject) continue;
repointIn(bin.getRight());
}
}
/**
* `const { McpError } = await import('@modelcontextprotocol/…')` — the static-import
* rename pass never sees these bindings. Shorthand elements rename binding and
* references; aliased elements (`{ McpError: ME }`) re-point only the property name.
*/
function renameDynamicImportBindings(sourceFile: SourceFile): number {
let changes = 0;
for (const decl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
if (decl.wasForgotten()) continue;
const nameNode = decl.getNameNode();
if (!Node.isObjectBindingPattern(nameNode)) continue;
let initializer = decl.getInitializer();
if (initializer !== undefined && Node.isAwaitExpression(initializer)) initializer = initializer.getExpression();
if (initializer === undefined || !Node.isCallExpression(initializer)) continue;
if (initializer.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
const spec = initializer.getArguments()[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
if (spec === undefined || !isAnyMcpSpecifier(spec)) continue;
for (const element of nameNode.getElements()) {
const propertyNode = element.getPropertyNameNode();
const importedName = propertyNode?.getText() ?? element.getName();
const newName = SIMPLE_RENAMES[importedName];
if (newName === undefined) continue;
if (propertyNode === undefined) {
// Text-based like every other rename here — the language-service rename
// resolves to the installed v1 typings and can split the shorthand into
// a dead-property alias or touch node_modules.
renameAllReferences(sourceFile, importedName, newName);
} else {
propertyNode.replaceWithText(newName);
}
changes++;
}
}
return changes;
}

View file

@ -1,9 +1,15 @@
import { statSync } from 'node:fs';
import path from 'node:path';
import fg from 'fast-glob';
import type { Node } from 'ts-morph';
import { Project, SyntaxKind } from 'ts-morph';
import type { Diagnostic, FileResult, Migration, RunnerOptions, RunnerResult } from './types';
import { MOCK_CALLERS, MOCK_METHODS } from './migrations/v1-to-v2/transforms/mockPaths';
import type { Diagnostic, FileResult, Migration, PackageJsonChange, RunnerOptions, RunnerResult } from './types';
import { CODEMOD_ERROR_PREFIX, error } from './utils/diagnostics';
import { updatePackageJson } from './utils/packageJsonUpdater';
import { isSdkSpecifier, isV2Specifier } from './utils/importUtils';
import { discoverManifests, ownerManifest, updatePackageJson } from './utils/packageJsonUpdater';
import { analyzeProject } from './utils/projectAnalyzer';
const LITERAL_NODE_KINDS = new Set([
@ -75,10 +81,6 @@ function insertDiagnosticComments(project: Project, fileResults: FileResult[]):
return insertedCount;
}
function escapeGlobPath(p: string): string {
return p.replaceAll(/[[\]{}()*?!@#]/g, String.raw`\$&`);
}
export function run(migration: Migration, options: RunnerOptions): RunnerResult {
const context = analyzeProject(options.targetDir);
@ -104,7 +106,30 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
}
});
const globPattern = `${escapeGlobPath(options.targetDir)}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`;
// A file target scopes the run to that one source file; project context and the
// nearest-manifest rule still derive from its directory.
const targetIsFile = (() => {
try {
return statSync(options.targetDir).isFile();
} catch {
return false;
}
})();
const targetBase = fg.convertPathToPattern(path.resolve(targetIsFile ? path.dirname(options.targetDir) : options.targetDir));
const globPattern = `${targetBase}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`;
// The positive pattern is absolute, so fast-glob compares ignore patterns against
// absolute paths — a bare-relative --ignore would silently match nothing. Rebase
// relative user patterns onto the target directory.
const userIgnores = (options.ignore ?? []).map(pattern => {
// User ignores are PATTERNS, not paths: convertPathToPattern would escape
// their glob metacharacters (`**` → `\*\*`) and they would match nothing.
// fast-glob needs forward slashes, so Windows separators are normalized.
if (pattern.startsWith('**') || path.isAbsolute(pattern)) {
return path.sep === '\\' ? pattern.replaceAll('\\', '/') : pattern;
}
return `${targetBase}/${pattern}`;
});
const ignorePatterns = [
'**/node_modules/**',
'**/dist/**',
@ -117,14 +142,24 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
'**/*.d.ts',
'**/*.d.mts',
'**/*.d.cts',
...(options.ignore ?? [])
...userIgnores
];
const allPatterns = [globPattern];
for (const ignore of ignorePatterns) {
allPatterns.push(`!${ignore}`);
// Collect files with fast-glob directly instead of ts-morph's glob handling:
// symbolic links are never followed (pnpm node_modules layouts contain symlink
// cycles that ELOOP a following walker) and ignore patterns — including the
// user's --ignore — apply during directory descent, not as a post-filter.
const files = targetIsFile
? [path.resolve(options.targetDir)]
: fg.sync(globPattern, {
ignore: ignorePatterns,
followSymbolicLinks: false,
suppressErrors: true,
absolute: true
});
for (const filePath of files) {
project.addSourceFileAtPathIfExists(filePath);
}
project.addSourceFilesAtPaths(allPatterns);
const sourceFiles = project.getSourceFiles().filter(sf => {
const fp = sf.getFilePath();
@ -134,7 +169,6 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
});
const fileResults: FileResult[] = [];
const allDiagnostics: Diagnostic[] = [];
const allUsedPackages = new Set<string>();
const shebangs = new Map<string, string>();
let totalChanges = 0;
let filesChanged = 0;
@ -154,33 +188,11 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
shebangs.set(sourceFile.getFilePath(), shebangMatch[0]);
}
const fileClaimedPackages = new Set<string>();
try {
for (const transform of enabledTransforms) {
const result = transform.apply(sourceFile, context);
fileChanges += result.changesCount;
fileDiagnostics.push(...result.diagnostics);
if (result.usedPackages) {
for (const pkg of result.usedPackages) {
fileClaimedPackages.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();
@ -188,7 +200,14 @@ 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;
fileClaimedPackages.clear();
}
// Heuristic advisories only flush for files a transform actually changed —
// a re-run over a migrated tree stays quiet.
if (fileChanges === 0) {
const kept = fileDiagnostics.filter(d => !d.advisoryOnly);
fileDiagnostics.length = 0;
fileDiagnostics.push(...kept);
}
for (const d of fileDiagnostics) {
@ -196,7 +215,10 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
try {
d.line = d.resolveCurrentLine();
} catch {
// Node was removed by a later transform; keep snapshot line
// The anchor node was removed by a later rewrite — the snapshot
// line is stale against the final text, so keep the console
// diagnostic but never insert a comment at the wrong place.
delete d.insertComment;
}
delete d.resolveCurrentLine;
}
@ -216,10 +238,68 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
}
}
// The v2 dependency set for each manifest is derived from the POST-transform
// import state of the files it owns (longest-prefix match against the
// discovered manifests). Reading the final state — rather than what this run
// rewrote — means an already-migrated package whose v1 dependency is being
// removed still gets the v2 packages its imports need.
const hasImportsTransform = enabledTransforms.some(t => t.id === 'imports');
const packageJsonChanges = hasImportsTransform
? updatePackageJson(options.targetDir, allUsedPackages, options.dryRun ?? false)
: undefined;
let packageJsonChanges: PackageJsonChange[] | undefined;
if (hasImportsTransform) {
const manifests = discoverManifests(options.targetDir);
const usedByManifest = new Map<string, Set<string>>();
for (const sourceFile of sourceFiles) {
const owner = ownerManifest(sourceFile.getFilePath(), manifests);
if (!owner) continue;
let used = usedByManifest.get(owner.path);
if (!used) {
used = new Set();
usedByManifest.set(owner.path, used);
}
for (const decl of sourceFile.getImportDeclarations()) {
const spec = decl.getModuleSpecifierValue();
if (spec.startsWith('@modelcontextprotocol/')) used.add(spec);
}
for (const decl of sourceFile.getExportDeclarations()) {
const spec = decl.getModuleSpecifierValue();
if (spec !== undefined && spec.startsWith('@modelcontextprotocol/')) used.add(spec);
}
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const exprText = call.getExpression().getText();
const dot = exprText.indexOf('.');
const isMockCall = dot !== -1 && MOCK_CALLERS.has(exprText.slice(0, dot)) && MOCK_METHODS.has(exprText.slice(dot + 1));
const isModuleRef = call.getExpression().getKind() === SyntaxKind.ImportKeyword || exprText === 'require' || isMockCall;
if (!isModuleRef) continue;
const spec = call.getArguments()[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
if (spec !== undefined && spec.startsWith('@modelcontextprotocol/')) used.add(spec);
}
}
// A single-file run sees one file's imports — applying a manifest edit from that
// view could remove the v1 dependency the rest of the package still needs, so
// manifests are report-only in file mode.
const zodInjectedByManifest = new Map<string, string[]>();
for (const fileResult of fileResults) {
if (!fileResult.diagnostics.some(d => d.tag === 'zod-injected')) continue;
const owner = ownerManifest(fileResult.filePath, manifests);
if (!owner) continue;
let files = zodInjectedByManifest.get(owner.path);
if (!files) {
files = [];
zodInjectedByManifest.set(owner.path, files);
}
files.push(fileResult.filePath);
}
const changes = updatePackageJson(manifests, usedByManifest, (options.dryRun ?? false) || targetIsFile, zodInjectedByManifest);
packageJsonChanges = changes.length > 0 ? changes : undefined;
}
const mcpImportFiles = sourceFiles.filter(sf =>
sf.getImportDeclarations().some(decl => isV2Specifier(decl.getModuleSpecifierValue()))
).length;
const v1ImportFiles = sourceFiles.filter(sf =>
sf.getImportDeclarations().some(decl => isSdkSpecifier(decl.getModuleSpecifierValue()))
).length;
// Per-file mutations are atomic: if any transform fails, the file is rolled back to its
// original state and an error diagnostic is emitted.
@ -253,6 +333,8 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
diagnostics: allDiagnostics,
fileResults,
packageJsonChanges,
mcpImportFiles,
v1ImportFiles,
commentCount
};
}

View file

@ -12,6 +12,10 @@ export interface Diagnostic {
line: number;
message: string;
category?: 'v2-gap';
/** Heuristic "verify this" advisories: dropped by the runner for files no transform changed, so re-runs over migrated trees stay quiet. */
advisoryOnly?: boolean;
/** Machine-readable marker for cross-stage plumbing (e.g. the runner feeding manifest edits). */
tag?: 'zod-injected';
insertComment?: boolean;
resolveCurrentLine?: () => number;
}
@ -56,6 +60,17 @@ export interface PackageJsonChange {
added: string[];
removed: string[];
packageJsonPath: string;
/**
* True for the manifest the codemod writes (the nearest one walking up from the
* target directory) — or, under dry-run, the one it would write. False entries
* describe edits to other manifests — typically workspace members — that are
* reported for the user to apply themselves; those are never written.
*/
applied: boolean;
/** Context on how the change set was computed (e.g. hoisted member usage credited to this manifest). */
notes?: string[];
/** Manifest-level findings that need the user's attention (e.g. an incompatible zod range). */
warnings?: string[];
}
export interface RunnerResult {
@ -63,6 +78,10 @@ export interface RunnerResult {
totalChanges: number;
diagnostics: Diagnostic[];
fileResults: FileResult[];
packageJsonChanges?: PackageJsonChange;
packageJsonChanges?: PackageJsonChange[];
commentCount: number;
/** Source files importing a v2 `@modelcontextprotocol/*` package after the run. */
mcpImportFiles: number;
/** Source files still importing the v1 `@modelcontextprotocol/sdk` package after the run (possible under `--transforms` subsets). */
v1ImportFiles: number;
}

View file

@ -1,5 +1,5 @@
import type { SourceFile } from 'ts-morph';
import { Node } from 'ts-morph';
import { Node, SyntaxKind } from 'ts-morph';
export function isKeyPositionIdentifier(node: import('ts-morph').Node): boolean {
const parent = node.getParent();
@ -38,3 +38,13 @@ export function renameAllReferences(sourceFile: SourceFile, oldName: string, new
}
});
}
/** First identifier named `name` that is not part of an import declaration. */
export function findFirstIdentifierOutsideImports(sourceFile: SourceFile, name: string): Node | undefined {
for (const id of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
if (id.getText() !== name) continue;
if (id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)) continue;
return id;
}
return undefined;
}

View file

@ -84,6 +84,13 @@ export function addOrMergeImport(
}
}
/** True when the specifier resolves to one of the published v2 packages (root or subpath). */
export function isV2Specifier(specifier: string): boolean {
if (V2_PACKAGES.has(specifier)) return true;
const secondSlash = specifier.indexOf('/', specifier.indexOf('/') + 1);
return secondSlash !== -1 && V2_PACKAGES.has(specifier.slice(0, secondSlash));
}
export function isAnyMcpSpecifier(specifier: string): boolean {
if (isSdkSpecifier(specifier)) return true;
if (V2_PACKAGES.has(specifier)) return true;

View file

@ -1,4 +1,7 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import fg from 'fast-glob';
import { V2_PACKAGE_VERSIONS } from '../generated/versions';
import type { PackageJsonChange } from '../types';
@ -7,72 +10,422 @@ import { findPackageJson } from './projectAnalyzer';
const V1_PACKAGE = '@modelcontextprotocol/sdk';
const PRIVATE_PACKAGES = new Set(['@modelcontextprotocol/core-internal']);
function normalizeToRoot(pkg: string): string {
type ZodSegmentVerdict = 'ok' | 'v3' | 'v4pre42';
/**
* Classify one `||` alternative of a zod range by the highest version it can resolve
* to. 'ok' = can reach >=4.2 (satisfies v2's floor); 'v3' = resolves into 3.x;
* 'v4pre42' = resolves into 4.0/4.1 (typings predate `~standard.jsonSchema`, so
* registration calls fail type-checking).
*/
function classifyZodSegment(segment: string): ZodSegmentVerdict {
const seg = segment
.replace(/^(?:workspace:|npm:)/, '')
.replace(/^[=\s]+/, '')
.trim();
if (seg === '' || seg === '*' || seg === 'x' || seg === 'latest') return 'ok';
const classifyUpper = (major: number, minor: number | undefined, inclusive: boolean): ZodSegmentVerdict => {
if (major > 4) return 'ok';
if (major < 4) return 'v3';
// major === 4: an upper bound below 4.2 caps resolution at 4.0/4.1; `<4` and
// `<4.0` cap it in 3.x. A bare inclusive major (`<=4`, `… - 4`) is an
// X-range upper bound — it admits every 4.x release and satisfies the floor.
if (minor === undefined) return inclusive ? 'ok' : 'v3';
if (minor === 0 && inclusive === false) return 'v3';
if (minor < 2) return 'v4pre42';
if (minor === 2 && inclusive === false) return 'v4pre42';
return 'ok';
};
// Hyphen range `A - B`: resolution maxes at B (inclusive).
const hyphen = seg.match(/^\S+\s+-\s+v?(\d+)(?:\.(\d+))?/);
if (hyphen) {
return classifyUpper(Number(hyphen[1]), hyphen[2] === undefined ? undefined : Number(hyphen[2]), true);
}
// Comparator sets: an upper `<`/`<=` bound caps resolution; a floor with no upper
// bound resolves to the latest release.
const upper = seg.match(/<(=?)\s*v?(\d+)(?:\.(\d+))?/);
if (upper) {
return classifyUpper(Number(upper[2]), upper[3] === undefined ? undefined : Number(upper[3]), upper[1] === '=');
}
if (seg.startsWith('>')) return 'ok';
// Caret: `^3.x` cannot reach 4; `^4.x` allows everything below 5.
const caret = seg.match(/^\^\s*v?(\d+)/);
if (caret) return Number(caret[1]) < 4 ? 'v3' : 'ok';
// Tilde and bare/exact versions: `~4.1`/`4.1.x`/`=4.0.2` stay below 4.2; a bare
// major (`4`, `~4`, `4.x`) allows the whole major line.
const plain = seg.match(/^~?\s*v?(\d+)(?:\.(x|\*|\d+))?/);
if (plain) {
const major = Number(plain[1]);
if (major < 4) return 'v3';
if (major > 4) return 'ok';
const minorRaw = plain[2];
if (minorRaw === undefined || minorRaw === 'x' || minorRaw === '*') return 'ok';
return Number(minorRaw) < 2 ? 'v4pre42' : 'ok';
}
return 'ok';
}
export interface ManifestInfo {
/** Directory containing the manifest. */
dir: string;
/** Absolute path of the package.json. */
path: string;
}
export function normalizeToRoot(pkg: string): string {
const secondSlash = pkg.indexOf('/', pkg.indexOf('/') + 1);
if (secondSlash === -1) return pkg;
return pkg.slice(0, secondSlash);
}
/** ts-morph standardizes file paths to forward slashes on every platform; manifests must compare the same way. */
function toPosix(p: string): string {
return p.replaceAll('\\', '/');
}
function detectIndent(text: string): string {
const match = text.match(/\n([ \t]+)/);
return match ? match[1]! : ' ';
}
export function updatePackageJson(targetDir: string, usedPackages: Set<string>, dryRun: boolean): PackageJsonChange | undefined {
const pkgJsonPath = findPackageJson(targetDir);
if (!pkgJsonPath) return undefined;
let raw: string;
let pkgJson: Record<string, unknown>;
function readJson(p: string): { raw: string; json: Record<string, unknown> } | undefined {
try {
raw = readFileSync(pkgJsonPath, 'utf8');
pkgJson = JSON.parse(raw) as Record<string, unknown>;
const raw = readFileSync(p, 'utf8');
return { raw, json: JSON.parse(raw) as Record<string, unknown> };
} catch {
return undefined;
}
const deps = pkgJson.dependencies as Record<string, string> | undefined;
const devDeps = pkgJson.devDependencies as Record<string, string> | undefined;
}
const inDeps = deps !== undefined && V1_PACKAGE in deps;
const inDevDeps = devDeps !== undefined && V1_PACKAGE in devDeps;
if (!inDeps && !inDevDeps) return undefined;
const packagesToAdd = [...new Set([...usedPackages].map(pkg => normalizeToRoot(pkg)))].filter(
pkg => !PRIVATE_PACKAGES.has(pkg) && pkg in V2_PACKAGE_VERSIONS
);
// Determine which section to add v2 packages to.
// If v1 SDK was in both, prefer dependencies.
const targetSection = inDeps ? 'dependencies' : 'devDependencies';
const added: string[] = [];
for (const pkg of packagesToAdd) {
const alreadyInDeps = deps !== undefined && pkg in deps;
const alreadyInDevDeps = devDeps !== undefined && pkg in devDeps;
if (alreadyInDeps || alreadyInDevDeps) continue;
if (!pkgJson[targetSection]) {
pkgJson[targetSection] = {};
/** Parse the `packages:` list out of a pnpm-workspace.yaml without a YAML dependency. */
function pnpmWorkspaceGlobs(rootDir: string): string[] {
const p = path.join(rootDir, 'pnpm-workspace.yaml');
if (!existsSync(p)) return [];
const globs: string[] = [];
let inPackages = false;
for (const line of readFileSync(p, 'utf8').split('\n')) {
if (/^packages\s*:/.test(line)) {
inPackages = true;
continue;
}
if (inPackages) {
const item = line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*(?:#.*)?$/);
if (item) {
globs.push(item[1]!);
continue;
}
if (/^\S/.test(line)) inPackages = false; // next top-level key
}
(pkgJson[targetSection] as Record<string, string>)[pkg] = V2_PACKAGE_VERSIONS[pkg]!;
added.push(pkg);
}
return globs;
}
if (inDeps) delete deps![V1_PACKAGE];
if (inDevDeps) delete devDeps![V1_PACKAGE];
const removed = [V1_PACKAGE];
if (!dryRun) {
const indent = detectIndent(raw);
const trailingNewline = raw.endsWith('\n');
let output = JSON.stringify(pkgJson, null, indent);
if (trailingNewline) output += '\n';
writeFileSync(pkgJsonPath, output);
/** Workspace member globs from the root manifest's `workspaces` field (npm/yarn/bun shape). */
function npmWorkspaceGlobs(rootJson: Record<string, unknown>): string[] {
const ws = rootJson.workspaces;
if (Array.isArray(ws)) return ws.filter((g): g is string => typeof g === 'string');
if (ws && typeof ws === 'object' && Array.isArray((ws as { packages?: unknown }).packages)) {
return (ws as { packages: unknown[] }).packages.filter((g): g is string => typeof g === 'string');
}
return [];
}
/**
* The manifests a migration run may need to update: the nearest package.json walking
* up from the target directory, plus every workspace-member manifest it declares
* (npm/yarn/bun `workspaces` and pnpm-workspace.yaml), so monorepo members do not
* keep a stale v1 dependency the root swap never sees.
*/
export function discoverManifests(targetDir: string): ManifestInfo[] {
const rootManifest = findPackageJson(targetDir);
if (!rootManifest) return [];
const rootDir = path.dirname(rootManifest);
const manifests: ManifestInfo[] = [{ dir: toPosix(rootDir), path: rootManifest }];
const rootJson = readJson(rootManifest)?.json ?? {};
const memberGlobs = [...npmWorkspaceGlobs(rootJson), ...pnpmWorkspaceGlobs(rootDir)];
if (memberGlobs.length === 0) return manifests;
const memberDirs = fg.sync(memberGlobs, {
cwd: rootDir,
onlyDirectories: true,
followSymbolicLinks: false,
suppressErrors: true,
ignore: ['**/node_modules/**'],
absolute: true
});
// Workspace members live under the root that declares them; a glob that
// resolves elsewhere (an absolute pattern, a parent reference) is outside this
// run's scope and is not reported.
const resolvedRoot = path.resolve(rootDir);
for (const dir of memberDirs) {
if (!path.resolve(dir).startsWith(resolvedRoot + path.sep)) continue;
const manifest = path.join(dir, 'package.json');
if (existsSync(manifest) && manifest !== rootManifest) {
manifests.push({ dir: toPosix(dir), path: manifest });
}
}
return manifests;
}
/** Longest-prefix owner of a file among the discovered manifest directories. */
export function ownerManifest(filePath: string, manifests: readonly ManifestInfo[]): ManifestInfo | undefined {
const posixFile = toPosix(filePath);
let best: ManifestInfo | undefined;
for (const m of manifests) {
const dir = toPosix(m.dir);
const prefix = dir.endsWith('/') ? dir : dir + '/';
if (posixFile.startsWith(prefix) && (!best || dir.length > (best ? toPosix(best.dir).length : 0))) {
best = m;
}
}
return best;
}
function zodWarning(...depSections: (Record<string, string> | undefined)[]): string | undefined {
const range = depSections.find(section => section?.zod !== undefined)?.zod;
if (range === undefined) return undefined;
// Every `||` alternative must fall short of the floor before we warn — `^3.25 || ^4.5` resolves fine.
const verdicts = range.split('||').map(seg => classifyZodSegment(seg));
if (verdicts.length === 0 || verdicts.includes('ok')) return undefined;
const floor = `zod range '${range}' cannot satisfy v2's floor: zod >=4.2.0 is required. `;
if (verdicts.every(v => v === 'v4pre42')) {
return (
floor +
`This range resolves to zod 4.0-4.1, which predates ~standard.jsonSchema: registerTool/registerPrompt ` +
`calls fail type-checking (TS2769: no overload matches), and plain-JavaScript projects run through a ` +
`bundled fallback that drops .describe() field descriptions.`
);
}
return (
floor +
`An older range installs cleanly and then, depending on the zod entry point your code imports, ` +
`fails type-checking (zod/v4 subpath) or only fails at runtime ` +
`(main-entry imports: the server starts normally and the first tools/list reports the failure).`
);
}
interface ParsedManifest {
raw: string;
json: Record<string, unknown>;
deps?: Record<string, string>;
devDeps?: Record<string, string>;
peerDeps?: Record<string, string>;
optionalDeps?: Record<string, string>;
declaresV1: boolean;
}
function parseManifest(manifestPath: string): ParsedManifest | undefined {
const parsed = readJson(manifestPath);
if (!parsed) return undefined;
const deps = parsed.json.dependencies as Record<string, string> | undefined;
const devDeps = parsed.json.devDependencies as Record<string, string> | undefined;
const peerDeps = parsed.json.peerDependencies as Record<string, string> | undefined;
const optionalDeps = parsed.json.optionalDependencies as Record<string, string> | undefined;
return {
added: added.toSorted(),
removed,
packageJsonPath: pkgJsonPath
raw: parsed.raw,
json: parsed.json,
deps,
devDeps,
peerDeps,
optionalDeps,
declaresV1: (deps !== undefined && V1_PACKAGE in deps) || (devDeps !== undefined && V1_PACKAGE in devDeps)
};
}
/**
* Swap the v1 SDK dependency for the v2 packages in the **nearest** manifest (the
* first entry of `manifests`), and report — without modifying — the same edit for
* every other manifest that declares the v1 SDK, so workspace members never receive
* writes the user did not point the codemod at.
*
* The v2 additions come from the **post-transform** import state of the files each
* manifest owns (`usedByManifest`), not from what was rewritten in this run — so a
* partially or fully pre-migrated package still gets the v2 packages its imports
* need when its v1 dependency is removed.
*/
const ZOD_RANGE = '^4.2.0';
const TEST_PATH_RE = /(^|\/)(test|tests|__tests__)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
function writeManifest(manifestPath: string, raw: string, pkgJson: Record<string, unknown>): void {
const indent = detectIndent(raw);
let output = JSON.stringify(pkgJson, null, indent);
if (raw.endsWith('\n')) output += '\n';
writeFileSync(manifestPath, output);
}
export function updatePackageJson(
manifests: readonly ManifestInfo[],
usedByManifest: ReadonlyMap<string, ReadonlySet<string>>,
dryRun: boolean,
zodInjectedByManifest: ReadonlyMap<string, readonly string[]> = new Map()
): PackageJsonChange[] {
const changes: PackageJsonChange[] = [];
const nearestPath = manifests[0]?.path;
// Each manifest is read and parsed exactly once; every later step (v1 checks,
// roll-up, edits) works from this shared view.
const parsedByPath = new Map<string, ParsedManifest>();
for (const m of manifests) {
const parsed = parseManifest(m.path);
if (parsed) parsedByPath.set(m.path, parsed);
}
// Hoisted-dependency roll-up: a workspace member without its own v1 dependency
// relies on an ancestor manifest (usually the root) for SDK resolution, so its
// usage must count toward the nearest ancestor that DOES declare the v1 SDK —
// otherwise a hoisted monorepo would get the v1 dependency removed from the
// root with none of the v2 replacements added.
const effectiveUsed = new Map<string, Set<string>>();
const hoistNotes = new Map<string, string[]>();
for (const m of manifests) {
effectiveUsed.set(m.path, new Set(usedByManifest.get(m.path)));
}
const v1Ancestors = manifests.filter(a => parsedByPath.get(a.path)?.declaresV1 === true);
// zod-injected files follow the same hoisting rule as usage: a member without its
// own v1 dependency takes part through its nearest v1-declaring ancestor, so its
// injections must surface there or the add is silently lost.
const effectiveZodInjected = new Map<string, string[]>();
for (const [manifestPath, files] of zodInjectedByManifest) {
const declaresV1Here = parsedByPath.get(manifestPath)?.declaresV1 === true;
const home = declaresV1Here ? manifestPath : (ownerManifest(manifestPath, v1Ancestors)?.path ?? manifestPath);
let bucket = effectiveZodInjected.get(home);
if (!bucket) {
bucket = [];
effectiveZodInjected.set(home, bucket);
}
bucket.push(...files);
}
for (const m of manifests) {
if (parsedByPath.get(m.path)?.declaresV1 === true) continue;
const used = effectiveUsed.get(m.path);
if (!used || used.size === 0) continue;
// A member only participates when its imports map to publishable v2 packages.
const contributes = [...used]
.map(pkg => normalizeToRoot(pkg))
.some(pkg => !PRIVATE_PACKAGES.has(pkg) && pkg in V2_PACKAGE_VERSIONS);
if (!contributes) continue;
const ancestor = ownerManifest(m.path, v1Ancestors);
if (ancestor) {
const target = effectiveUsed.get(ancestor.path);
for (const pkg of used) target?.add(pkg);
let notes = hoistNotes.get(ancestor.path);
if (!notes) {
notes = [];
hoistNotes.set(ancestor.path, notes);
}
notes.push(
`workspace member ${toPosix(path.relative(ancestor.dir, m.dir)) || '.'} has no own ${V1_PACKAGE} dependency ` +
`and resolves the SDK through this manifest; its imports were included when computing the v2 dependency set`
);
}
}
for (const manifest of manifests) {
const parsed = parsedByPath.get(manifest.path);
if (!parsed) continue;
const { raw, json: pkgJson, deps, devDeps, peerDeps, optionalDeps } = parsed;
const inDeps = deps !== undefined && V1_PACKAGE in deps;
const inDevDeps = devDeps !== undefined && V1_PACKAGE in devDeps;
const warning = zodWarning(deps, devDeps, peerDeps, optionalDeps);
const declaresAnyV2 = Object.keys({ ...deps, ...devDeps }).some(dep => dep in V2_PACKAGE_VERSIONS);
const usesV2 = (effectiveUsed.get(manifest.path)?.size ?? 0) > 0;
const isNearest = manifest.path === nearestPath;
// The file-level `import { z } from 'zod'` injection promises a manifest edit;
// it must land even when this manifest never declared the v1 SDK (a member
// already on v2, or a sub-tree target whose v1 dependency lives outside it).
const injectedFiles = effectiveZodInjected.get(manifest.path);
// A peer- or optional-declared zod is still a declaration: adding a direct
// dependency on top of it would duplicate (and possibly conflict with) the
// package's own contract.
const declaresZod = [deps, devDeps, peerDeps, optionalDeps].some(section => section !== undefined && 'zod' in section);
const needsInjectedZod = injectedFiles !== undefined && injectedFiles.length > 0 && !declaresZod;
const addInjectedZod = (targetSection: 'dependencies' | 'devDependencies'): 'dependencies' | 'devDependencies' => {
// Classify against the path RELATIVE to the package — a test/ segment in
// some ancestor directory (CI checkout dirs) must not demote the dep.
const testOnly = injectedFiles!.every(file => TEST_PATH_RE.test(toPosix(path.relative(manifest.dir, file))));
const section = testOnly ? 'devDependencies' : targetSection;
if (!pkgJson[section]) {
pkgJson[section] = {};
}
(pkgJson[section] as Record<string, string>)['zod'] = ZOD_RANGE;
return section;
};
if (!inDeps && !inDevDeps) {
if (needsInjectedZod) {
addInjectedZod('dependencies');
if (isNearest && !dryRun) {
writeManifest(manifest.path, raw, pkgJson);
}
changes.push({ added: ['zod'], removed: [], packageJsonPath: manifest.path, applied: isNearest });
continue;
}
// The zod note only matters for manifests that take part in the migration:
// they declare a v2 package, or their files import one (hoisted members).
if (warning && (declaresAnyV2 || usesV2)) {
changes.push({ added: [], removed: [], packageJsonPath: manifest.path, applied: false, warnings: [warning] });
}
continue;
}
const used = effectiveUsed.get(manifest.path) ?? new Set<string>();
const packagesToAdd = [...new Set([...used].map(pkg => normalizeToRoot(pkg)))].filter(
pkg => !PRIVATE_PACKAGES.has(pkg) && pkg in V2_PACKAGE_VERSIONS
);
// If v1 SDK was in both sections, prefer dependencies.
const targetSection = inDeps ? 'dependencies' : 'devDependencies';
const added: string[] = [];
for (const pkg of packagesToAdd) {
const alreadyInDeps = deps !== undefined && pkg in deps;
const alreadyInDevDeps = devDeps !== undefined && pkg in devDeps;
if (alreadyInDeps || alreadyInDevDeps) continue;
if (!pkgJson[targetSection]) {
pkgJson[targetSection] = {};
}
(pkgJson[targetSection] as Record<string, string>)[pkg] = V2_PACKAGE_VERSIONS[pkg]!;
added.push(pkg);
}
// Wrapped raw shapes had `import { z } from 'zod'` injected — under strict
// node_modules layouts the owning package must declare zod itself, so a
// zod-less manifest gets it added (devDependencies when only tests import it).
if (needsInjectedZod) {
addInjectedZod(targetSection);
added.push('zod');
}
if (inDeps) delete deps![V1_PACKAGE];
if (inDevDeps) delete devDeps![V1_PACKAGE];
// Only the nearest manifest is written; the others are reported so the user
// applies (or deliberately skips) each workspace-member edit themselves.
if (isNearest && !dryRun) {
writeManifest(manifest.path, raw, pkgJson);
}
const notes = hoistNotes.get(manifest.path);
changes.push({
added: added.toSorted(),
removed: [V1_PACKAGE],
packageJsonPath: manifest.path,
applied: isNearest,
...(notes !== undefined && { notes }),
...(warning !== undefined && (added.length > 0 || declaresAnyV2 || usesV2) && { warnings: [warning] })
});
}
return changes;
}