diff --git a/.changeset/codemod-real-world-migration-gaps.md b/.changeset/codemod-real-world-migration-gaps.md new file mode 100644 index 0000000000..29edc5dfe9 --- /dev/null +++ b/.changeset/codemod-real-world-migration-gaps.md @@ -0,0 +1,16 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +Fix a batch of v1→v2 codemod gaps surfaced by real-world migrations: + +- Directory walk no longer follows symlinks and prunes ignored directories during descent, so a pnpm workspace with a cyclic intra-workspace dev-dependency no longer crashes with `ELOOP`, and `--ignore` actually skips a tree. +- Every workspace-member `package.json` that depends on the v1 SDK is now updated (not just the one closest to the target directory); `RunnerResult.packageJsonChanges` is now an array. +- A `zod@^3` dependency is reported (not rewritten) with guidance to bump to `^4` or pin `^3.25+` and import from `zod/v4`. +- New `--prefer client|server` flag overrides the hard-coded `server` default when no client/server signal is found. +- Leading file-header comments (JSDoc and `//` blocks) are preserved across the full transform pipeline, including when a later transform removes the rewritten first import. +- Type-position `import('@modelcontextprotocol/sdk/...').` qualifiers are rewritten and routed per-symbol. +- The `extra → ctx` remap now reaches `as`-cast/parenthesized callbacks, `fallbackRequestHandler` assignments, and single-parameter (schema-less) register callbacks; parameters typed `ServerContext`/`ClientContext` that still access v1 properties are marked with an `@mcp-codemod-error` comment. +- `error.code` accesses on an `instanceof`-checked `StreamableHTTPError`/`SdkHttpError` are marked (the HTTP status moved to `.status`). +- `require()`/`require.resolve()` of a v1 SDK path is marked (v2 subpath exports have no `require` condition). +- Wrapping a raw shape with `z.object()` now adds `import { z } from 'zod'` when missing and warns about the `zod` dependency. diff --git a/packages/codemod/src/cli.ts b/packages/codemod/src/cli.ts index 8325b352e6..d2bfcf6299 100644 --- a/packages/codemod/src/cli.ts +++ b/packages/codemod/src/cli.ts @@ -51,6 +51,7 @@ for (const [name, migration] of listMigrations()) { .option('-t, --transforms ', 'Comma-separated transform IDs to run (default: all)') .option('-v, --verbose', 'Show detailed per-change output') .option('--ignore ', 'Additional glob patterns to ignore') + .option('--prefer ', 'Default package (client|server) for shared types when no signal is found') .option('--list', 'List available transforms for this migration') .action((targetDir: string | undefined, opts: Record) => { try { @@ -87,12 +88,20 @@ for (const [name, migration] of listMigrations()) { const transforms = opts['transforms'] ? (opts['transforms'] as string).split(',').map(s => s.trim()) : undefined; + const preferRaw = opts['prefer'] as string | undefined; + if (preferRaw !== undefined && preferRaw !== 'client' && preferRaw !== 'server') { + console.error(`\nError: --prefer must be "client" or "server" (got "${preferRaw}").\n`); + process.exitCode = 1; + return; + } + const result = run(migration, { targetDir: resolvedDir, dryRun: opts['dryRun'] as boolean | undefined, verbose: opts['verbose'] as boolean | undefined, transforms, - ignore: opts['ignore'] as string[] | undefined + ignore: opts['ignore'] as string[] | undefined, + prefer: preferRaw }); if (result.filesChanged === 0 && result.diagnostics.length === 0 && !result.packageJsonChanges) { @@ -150,19 +159,21 @@ for (const [name, migration] of listMigrations()) { } 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 verb = opts['dryRun'] ? 'changes (dry run — not applied)' : 'updated'; + for (const pc of result.packageJsonChanges) { + const rel = path.relative(process.cwd(), pc.packageJsonPath) || pc.packageJsonPath; + console.log(`${rel} ${verb}:`); + if (pc.removed.length > 0) { + console.log(` Removed: ${pc.removed.join(', ')}`); + } + if (pc.added.length > 0) { + console.log(` Added: ${pc.added.join(', ')}`); + } + for (const note of pc.notes ?? []) { + console.log(` Note: ${note}`); + } + console.log(''); } - if (pc.removed.length > 0) { - console.log(` Removed: ${pc.removed.join(', ')}`); - } - if (pc.added.length > 0) { - console.log(` Added: ${pc.added.join(', ')}`); - } - console.log(''); } if (result.commentCount > 0) { diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts index 78ebc2487d..ac24128630 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts @@ -11,6 +11,29 @@ const HANDLER_METHODS = new Set(['setRequestHandler', 'setNotificationHandler']) const REGISTER_METHODS = new Set(['registerTool', 'registerPrompt', 'registerResource', 'tool', 'prompt', 'resource']); +const FALLBACK_HANDLER_PROPS = new Set(['fallbackRequestHandler', 'fallbackNotificationHandler']); + +/** v1 context properties whose presence on a non-remapped parameter should be flagged. */ +const V1_CONTEXT_PROPS = new Set(CONTEXT_PROPERTY_MAP.filter(m => m.from !== m.to).map(m => m.from.slice(1))); + +/** + * Unwrap `as`-cast and parenthesized callback expressions so the underlying arrow/function is reached. + * v1 code commonly typed a `registerTool` callback via `(async (args, extra) => …) as Parameters<…>[2]`; + * without the unwrap the remap silently skips it and `extra.authInfo` etc. survive into v2 — which + * type-checks (the cast carries its own annotation) and only fails at runtime. + */ +function unwrapCallback(node: Node | undefined): Node | undefined { + let current = node; + while (current) { + if (Node.isAsExpression(current) || Node.isParenthesizedExpression(current) || Node.isSatisfiesExpression(current)) { + current = current.getExpression(); + continue; + } + return current; + } + return current; +} + /** * Attempt to rename the second parameter of a callback from 'extra' to 'ctx' * and rewrite context property accesses in its body. @@ -27,9 +50,15 @@ function processCallback( return -1; const params = callbackNode.getParameters(); - if (params.length < 2) return -1; - - const extraParam = params[1]!; + // For `setRequestHandler`/`setNotificationHandler` and the McpServer register/tool/prompt/resource + // methods the context is the SECOND parameter when an args/request param is present. A schema-less + // tool/prompt callback (`registerTool('name', {}, async (extra) => …)`) receives the context as its + // ONLY parameter — fall back to params[0] when the sole parameter is the v1 `extra` name. + let extraParam = params[1]; + if (!extraParam && params.length === 1 && params[0]!.getName() === EXTRA_PARAM_NAME) { + extraParam = params[0]; + } + if (!extraParam) return -1; const paramNameNode = extraParam.getNameNode(); if (Node.isObjectBindingPattern(paramNameNode)) { diagnostics.push( @@ -219,6 +248,7 @@ export const contextTypesTransform: Transform = { callbackArg = args.at(-1); } + callbackArg = unwrapCallback(callbackArg); if (!callbackArg) continue; // Handle ObjectLiteralExpression callback containers (handler maps) @@ -254,6 +284,63 @@ export const contextTypesTransform: Transform = { } } + // `protocol.fallbackRequestHandler = async (request, extra) => …` — the v1 fallback handler + // is assigned, not registered, so the call-site scan above never sees it. + for (const be of sourceFile.getDescendantsOfKind(SyntaxKind.BinaryExpression)) { + if (be.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) continue; + const lhs = be.getLeft(); + if (!Node.isPropertyAccessExpression(lhs) || !FALLBACK_HANDLER_PROPS.has(lhs.getName())) continue; + const cb = unwrapCallback(be.getRight()); + if (!cb) continue; + const result = processCallback(cb, sourceFile, diagnostics, lhs.getName(), be.getStartLineNumber()); + if (result > 0) changesCount += result; + } + + // Catch-all marker for context parameters the remap could not reach: any function parameter + // annotated `ServerContext` / `ClientContext` (the symbol-renames transform has already + // rewritten v1's `RequestHandlerExtra` to those) whose body still accesses a v1-shaped + // property. Covers handlers typed via a local alias, helpers a callback forwards its context + // to, and any callback shape not enumerated above. Marking (not rewriting) keeps the + // compile-time/runtime regression visible without risking a wrong rewrite on a non-MCP type + // that happens to share the property name. + for (const param of sourceFile.getDescendantsOfKind(SyntaxKind.Parameter)) { + const typeNode = param.getTypeNode(); + if (!typeNode || !Node.isTypeReference(typeNode)) continue; + const typeName = typeNode.getTypeName().getText(); + if (typeName !== 'ServerContext' && typeName !== 'ClientContext') continue; + const nameNode = param.getNameNode(); + if (!Node.isIdentifier(nameNode)) continue; + const paramName = nameNode.getText(); + if (paramName === CTX_PARAM_NAME) continue; // already migrated form + + const fn = param.getParent(); + const body = + Node.isArrowFunction(fn) || Node.isFunctionExpression(fn) || Node.isFunctionDeclaration(fn) || Node.isMethodDeclaration(fn) + ? fn.getBody() + : undefined; + if (!body) continue; + + const stale = new Set(); + body.forEachDescendant(node => { + if (!Node.isPropertyAccessExpression(node)) return; + const obj = node.getExpression(); + if (!Node.isIdentifier(obj) || obj.getText() !== paramName) return; + if (V1_CONTEXT_PROPS.has(node.getName())) stale.add(node.getName()); + }); + if (stale.size === 0) continue; + + diagnostics.push( + actionRequired( + sourceFile.getFilePath(), + param, + `Parameter \`${paramName}: ${typeName}\` accesses v1 context propert${stale.size === 1 ? 'y' : 'ies'} ` + + `(${[...stale].map(p => `.${p}`).join(', ')}) that moved in v2 ` + + `(e.g. .signal → .mcpReq.signal, .authInfo → .http?.authInfo). The codemod could not remap this ` + + `parameter automatically — update the property accesses manually.` + ) + ); + } + return { changesCount, diagnostics }; } }; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts index 7db21088e1..1c6e203f31 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -2,9 +2,39 @@ import type { CallExpression, SourceFile } from 'ts-morph'; import { Node, SyntaxKind } from 'ts-morph'; import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types'; -import { actionRequired, info } from '../../../utils/diagnostics'; +import { actionRequired, info, warning } from '../../../utils/diagnostics'; import { isOriginalNameImportedFromMcp, resolveLocalImportName } from '../../../utils/importUtils'; +/** True when `z` is already bound in the file (named, default, or namespace) by an import from `zod`. */ +function hasZodImport(sourceFile: SourceFile): boolean { + return sourceFile.getImportDeclarations().some(imp => { + const spec = imp.getModuleSpecifierValue(); + if (spec !== 'zod' && !spec.startsWith('zod/')) return false; + if (imp.getDefaultImport()?.getText() === 'z') return true; + if (imp.getNamespaceImport()?.getText() === 'z') return true; + return imp.getNamedImports().some(n => (n.getAliasNode()?.getText() ?? n.getName()) === 'z'); + }); +} + +/** + * Ensure the `z` binding exists when this transform has just emitted a `z.object(...)` wrapper. + * Without it the wrap turns a working v1 file into a TS2304 (`Cannot find name 'z'`). Adds + * `import { z } from 'zod'` and warns that `zod` may need adding to the package's dependencies — the + * package.json updater bumps an existing zod range but does not add zod to a package that never had it. + */ +function ensureZodImport(sourceFile: SourceFile, diagnostics: Diagnostic[]): void { + if (hasZodImport(sourceFile)) return; + sourceFile.insertImportDeclaration(0, { moduleSpecifier: 'zod', namedImports: ['z'] }); + diagnostics.push( + warning( + sourceFile.getFilePath(), + 1, + "Added `import { z } from 'zod'` for the z.object() wrapper(s). " + + 'If this package does not already depend on zod, add `zod@^4` to its dependencies.' + ) + ); +} + export const mcpServerApiTransform: Transform = { name: 'McpServer API migration', id: 'mcpserver-api', @@ -126,6 +156,11 @@ export const mcpServerApiTransform: Transform = { flagRemovedTaskOptions(sourceFile, diagnostics); + // The wrap helpers emit a fixed info note whenever they emit a `z.object(...)` wrapper; use + // that as the signal so the helpers don't need to thread an extra out-param. + const wrappedRawShape = diagnostics.some(d => d.message.startsWith(RAW_WRAP_INFO_PREFIX)); + if (wrappedRawShape) ensureZodImport(sourceFile, diagnostics); + return { changesCount, diagnostics }; } }; @@ -141,6 +176,8 @@ function isZodObjectCall(node: Node): boolean { return expr.getName() === 'object' && expr.getExpression().getText() === 'z'; } +const RAW_WRAP_INFO_PREFIX = 'Raw object literal'; + function wrapWithZObject(schemaText: string): string { return `z.object(${schemaText})`; } @@ -159,7 +196,7 @@ function emitWrapDiagnostic(node: Node, sourceFile: SourceFile, call: CallExpres info( sourceFile.getFilePath(), call.getStartLineNumber(), - 'Raw object literal wrapped with z.object(). Verify that zod (z) is imported in this file.' + `${RAW_WRAP_INFO_PREFIX} wrapped with z.object(). Verify that zod (z) is imported in this file.` ) ); } else if (!isZodObjectCall(node)) { @@ -218,7 +255,7 @@ function wrapSchemaInConfig(call: CallExpression, schemaPropertyName: string, so info( sourceFile.getFilePath(), call.getStartLineNumber(), - `Raw object literal in ${schemaPropertyName} wrapped with z.object(). Verify that zod (z) is imported in this file.` + `${RAW_WRAP_INFO_PREFIX} in ${schemaPropertyName} wrapped with z.object(). Verify that zod (z) is imported in this file.` ) ); return true; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts index 7ffdd53887..bf6b5823bc 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -64,6 +64,8 @@ export const mockPathsTransform: Transform = { } changesCount += rewriteDynamicImports(sourceFile, context, diagnostics, usedPackages); + changesCount += rewriteImportTypeQualifiers(sourceFile, context, diagnostics, usedPackages); + flagRequireSpecifiers(sourceFile, diagnostics); return { changesCount, diagnostics, usedPackages }; } @@ -351,6 +353,120 @@ function collectModuleSchemaAccesses( ]; } +/** + * Rewrite type-position dynamic-import qualifiers — `import('@modelcontextprotocol/sdk/...').` — + * which TypeScript represents as `ImportTypeNode`, not the `CallExpression` covered by + * {@link rewriteDynamicImports}. Left untouched these resolve against a package the codemod has just + * removed from `package.json`, surfacing as TS2307. The qualifier names a single member, so it routes + * per-symbol exactly like a one-name static import. + */ +function rewriteImportTypeQualifiers( + sourceFile: SourceFile, + context: TransformContext, + diagnostics: Diagnostic[], + usedPackages: Set +): number { + let changes = 0; + for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.ImportType)) { + const argument = node.getArgument(); + const literal = argument.getDescendantsOfKind(SyntaxKind.StringLiteral)[0]; + if (!literal) continue; + const specifier = literal.getLiteralValue(); + if (!isSdkSpecifier(specifier)) continue; + + const qualifier = node.getQualifier(); + const memberName = qualifier && Node.isIdentifier(qualifier) ? qualifier.getText() : undefined; + const symbols = memberName ? [memberName] : []; + + const resolved = resolveTarget(specifier, context, sourceFile, symbols, { + filePath: sourceFile.getFilePath(), + line: node.getStartLineNumber(), + diagnostics + }); + if (resolved === null) { + diagnostics.push( + actionRequired(sourceFile.getFilePath(), node, `Unknown SDK type-import path: ${specifier}. Manual migration required.`) + ); + continue; + } + if ('removed' in resolved) { + const diagFn = resolved.isV2Gap ? v2Gap : warning; + diagnostics.push( + diagFn( + sourceFile.getFilePath(), + node.getStartLineNumber(), + resolved.removalMessage ?? `Type-import references removed SDK path: ${specifier}. Manual migration required.` + ) + ); + continue; + } + + let effectiveTarget = resolved.target; + const { target: routedTarget } = routeSymbols(symbols, resolved.mapping); + if (routedTarget) effectiveTarget = routedTarget; + + usedPackages.add(effectiveTarget); + literal.setLiteralValue(effectiveTarget); + changes++; + + if (memberName && qualifier) { + const newName = { ...SIMPLE_RENAMES, ...resolved.mapping.renamedSymbols }[memberName]; + if (newName) { + qualifier.replaceWithText(newName); + changes++; + } + } + } + return changes; +} + +/** + * Mark `require()` / `require.resolve()` calls whose argument is a v1 SDK specifier. The codemod does + * not rewrite these: the v2 packages' subpath exports declare no `require` condition, so a rewritten + * `require.resolve('@modelcontextprotocol/server-legacy/sse')` would still throw `ERR_PACKAGE_PATH_NOT_EXPORTED` + * at runtime. The diagnostic steers the user to `import.meta.resolve` (or path arithmetic) instead. + * Covers both bare `require` and a `createRequire(...)` binding. + */ +function flagRequireSpecifiers(sourceFile: SourceFile, diagnostics: Diagnostic[]): void { + // Any local binding initialised from `createRequire(...)` is treated as a require function. + const requireNames = new Set(['require']); + for (const decl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { + const init = decl.getInitializer(); + if (!init || !Node.isCallExpression(init)) continue; + if (init.getExpression().getText() !== 'createRequire') continue; + const nameNode = decl.getNameNode(); + if (Node.isIdentifier(nameNode)) requireNames.add(nameNode.getText()); + } + + for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const expr = call.getExpression(); + let isRequireCall = false; + if (Node.isIdentifier(expr) && requireNames.has(expr.getText())) { + isRequireCall = true; + } else if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'resolve') { + const obj = expr.getExpression(); + if (Node.isIdentifier(obj) && requireNames.has(obj.getText())) isRequireCall = true; + } + if (!isRequireCall) continue; + + const arg = call.getArguments()[0]; + if (!arg || !Node.isStringLiteral(arg)) continue; + const specifier = arg.getLiteralValue(); + if (!isSdkSpecifier(specifier)) continue; + + diagnostics.push( + actionRequired( + sourceFile.getFilePath(), + call, + `require()/require.resolve() of a v1 SDK path: ${specifier}. The v2 packages' subpath exports have no ` + + `\`require\` condition, so a rewritten specifier would still fail at runtime. Switch to ` + + `\`import.meta.resolve(...)\` (or restructure to avoid resolving SDK dist files) and update the ` + + `specifier per docs/migration/upgrade-to-v2.md.` + ) + ); + } +} + function rewriteDynamicImports( sourceFile: SourceFile, context: TransformContext, diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts index 2fc2573fed..78f1d93cc1 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts @@ -3,7 +3,7 @@ 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 { actionRequired, warning } from '../../../utils/diagnostics'; import { addOrMergeImport, isAnyMcpSpecifier } from '../../../utils/importUtils'; const REMOVED_ZOD_HELPERS: Record = { @@ -139,6 +139,34 @@ function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnost const line = foundImportDecl.getStartLineNumber(); const moduleSpec = foundImportDecl.getModuleSpecifierValue(); + // v1's `StreamableHTTPError.code` carried the HTTP status (number); v2's `SdkHttpError.code` is the + // `SdkErrorCode` enum string and the status moved to `.status`. The class rename below keeps + // `error.code === 404` compiling (TS2367 if `error` is narrowed to `SdkHttpError`, otherwise no + // error) but always-false at runtime — the silent-misclassification class. Mark every `.code` + // access on an identifier that is `instanceof`-checked against this class so the user is steered to + // `.status` at the exact site, not just the file-level summary warning. + const instanceofSubjects = new Set(); + for (const be of sourceFile.getDescendantsOfKind(SyntaxKind.BinaryExpression)) { + if (be.getOperatorToken().getKind() !== SyntaxKind.InstanceOfKeyword) continue; + const right = be.getRight(); + if (!Node.isIdentifier(right) || right.getText() !== localName) continue; + const left = be.getLeft(); + if (Node.isIdentifier(left)) instanceofSubjects.add(left.getText()); + } + for (const pa of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { + if (pa.getName() !== 'code') continue; + const obj = pa.getExpression(); + if (!Node.isIdentifier(obj) || !instanceofSubjects.has(obj.getText())) continue; + diagnostics.push( + actionRequired( + sourceFile.getFilePath(), + pa, + `\`${obj.getText()}.code\` on an SdkHttpError is the SdkErrorCode string in v2; the HTTP status is on ` + + `\`${obj.getText()}.status\`. Update this check (e.g. \`.code === 404\` → \`.status === 404\`).` + ) + ); + } + let hasConstructorCalls = false; for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) { const expr = node.getExpression(); diff --git a/packages/codemod/src/runner.ts b/packages/codemod/src/runner.ts index 1554429fae..f07d9bc4c3 100644 --- a/packages/codemod/src/runner.ts +++ b/packages/codemod/src/runner.ts @@ -1,9 +1,10 @@ import type { Node } from 'ts-morph'; import { Project, SyntaxKind } from 'ts-morph'; -import type { Diagnostic, FileResult, Migration, RunnerOptions, RunnerResult } from './types'; +import type { Diagnostic, FileResult, Migration, PackageJsonChange, RunnerOptions, RunnerResult } from './types'; import { CODEMOD_ERROR_PREFIX, error } from './utils/diagnostics'; -import { updatePackageJson } from './utils/packageJsonUpdater'; +import { collectSourceFiles, extractFileHeader } from './utils/fileWalk'; +import { applyPackageJsonUpdate, attributeUsedPackages } from './utils/packageJsonUpdater'; import { analyzeProject } from './utils/projectAnalyzer'; const LITERAL_NODE_KINDS = new Set([ @@ -75,12 +76,34 @@ function insertDiagnosticComments(project: Project, fileResults: FileResult[]): return insertedCount; } -function escapeGlobPath(p: string): string { - return p.replaceAll(/[[\]{}()*?!@#]/g, String.raw`\$&`); +/** + * Restore the file's original leading comment block when a transform has dropped or displaced it. + * + * A JSDoc block header is leading trivia of the first import declaration; ts-morph drops it with + * the node when that import is `remove()`d (the per-symbol split path in `importPaths`, and again in + * `symbolRenames`/`removedApis` when the rewritten import is itself removed). A `//` line-comment + * header instead survives a removal by re-attaching to the next statement, and a subsequent + * `insertImportDeclaration(0)` lands at byte 0 — ahead of it. Both leave the file no longer prefixed + * by its original header. We snapshot the header before transforms and, if it is no longer the file + * prefix, strip any displaced copy and re-prepend the original verbatim. The per-transform restore in + * `importPaths` handles the common case; this is the safety net for headers a later transform touches. + */ +function restoreFileHeader(sourceFile: import('ts-morph').SourceFile, originalHeader: string): void { + if (originalHeader.trim() === '') return; + const newText = sourceFile.getFullText(); + if (newText.startsWith(originalHeader)) return; + + let body = newText; + const displacedAt = body.indexOf(originalHeader); + if (displacedAt !== -1) { + body = body.slice(0, displacedAt) + body.slice(displacedAt + originalHeader.length); + } + body = body.replace(/^[\r\n]+/, ''); + sourceFile.replaceWithText(originalHeader + body); } export function run(migration: Migration, options: RunnerOptions): RunnerResult { - const context = analyzeProject(options.targetDir); + const context = analyzeProject(options.targetDir, options.prefer); let enabledTransforms = migration.transforms; if (options.transforms) { @@ -104,37 +127,23 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult } }); - const globPattern = `${escapeGlobPath(options.targetDir)}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`; - const ignorePatterns = [ - '**/node_modules/**', - '**/dist/**', - '**/.git/**', - '**/build/**', - '**/.next/**', - '**/.nuxt/**', - '**/coverage/**', - '**/__generated__/**', - '**/*.d.ts', - '**/*.d.mts', - '**/*.d.cts', - ...(options.ignore ?? []) - ]; - - const allPatterns = [globPattern]; - for (const ignore of ignorePatterns) { - allPatterns.push(`!${ignore}`); + // Own walk instead of ts-morph's `addSourceFilesAtPaths` glob: the glob follows directory symlinks + // (so a pnpm workspace with cyclic intra-workspace dev-deps dies with ELOOP) and applies negative + // patterns to matched files only (so `--ignore` cannot prune the descent that crashes). The walk + // also discovers every workspace `package.json` so a monorepo run updates each member manifest. + const { sourceFiles: sourceFilePaths, packageJsonPaths } = collectSourceFiles(options.targetDir, options.ignore ?? []); + for (const fp of sourceFilePaths) { + try { + project.addSourceFileAtPath(fp); + } catch { + // Unreadable file — skip; matches the prior glob's silent-skip behaviour. + } } - project.addSourceFilesAtPaths(allPatterns); + const sourceFiles = project.getSourceFiles(); - const sourceFiles = project.getSourceFiles().filter(sf => { - const fp = sf.getFilePath(); - if (fp.includes('/node_modules/') || fp.includes('/dist/')) return false; - if (fp.endsWith('.d.ts') || fp.endsWith('.d.mts') || fp.endsWith('.d.cts')) return false; - return true; - }); const fileResults: FileResult[] = []; const allDiagnostics: Diagnostic[] = []; - const allUsedPackages = new Set(); + const perFileUsedPackages = new Map>(); let totalChanges = 0; let filesChanged = 0; @@ -142,6 +151,7 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult let fileChanges = 0; const fileDiagnostics: Diagnostic[] = []; const originalText = sourceFile.getFullText(); + const originalHeader = extractFileHeader(originalText); const fileClaimedPackages = new Set(); try { @@ -166,11 +176,11 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult for (const literal of sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral)) { survivingSpecifiers.add(literal.getLiteralValue()); } + const surviving = new Set(); for (const pkg of fileClaimedPackages) { - if (survivingSpecifiers.has(pkg)) { - allUsedPackages.add(pkg); - } + if (survivingSpecifiers.has(pkg)) surviving.add(pkg); } + perFileUsedPackages.set(sourceFile.getFilePath(), surviving); } catch (error_) { const filePath = sourceFile.getFilePath(); fileDiagnostics.length = 0; @@ -180,6 +190,8 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult fileClaimedPackages.clear(); } + if (fileChanges > 0) restoreFileHeader(sourceFile, originalHeader); + for (const d of fileDiagnostics) { if (d.resolveCurrentLine) { try { @@ -206,9 +218,16 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult } 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 attributed = attributeUsedPackages(options.targetDir, packageJsonPaths, perFileUsedPackages); + const changes: PackageJsonChange[] = []; + for (const [pkgJsonPath, used] of attributed) { + const change = applyPackageJsonUpdate(pkgJsonPath, used, options.dryRun ?? false); + if (change) changes.push(change); + } + packageJsonChanges = changes.length > 0 ? changes : undefined; + } // Per-file mutations are atomic: if any transform fails, the file is rolled back to its // original state and an error diagnostic is emitted. diff --git a/packages/codemod/src/types.ts b/packages/codemod/src/types.ts index 6b286f5f49..858010b93f 100644 --- a/packages/codemod/src/types.ts +++ b/packages/codemod/src/types.ts @@ -30,6 +30,12 @@ export interface Transform { export interface TransformContext { projectType: 'client' | 'server' | 'both' | 'unknown'; + /** + * User override (`--prefer client|server`) applied when the project-type heuristic returns + * `unknown` for a file, instead of the hard-coded `server` default. Threaded into the context so + * the per-file `resolveTypesPackage` fallback can honour it. + */ + prefer?: 'client' | 'server'; } export interface Migration { @@ -44,6 +50,8 @@ export interface RunnerOptions { verbose?: boolean; transforms?: string[]; ignore?: string[]; + /** Default package for context-resolved imports when the project-type heuristic finds nothing. */ + prefer?: 'client' | 'server'; } export interface FileResult { @@ -56,6 +64,8 @@ export interface PackageJsonChange { added: string[]; removed: string[]; packageJsonPath: string; + /** Advisory messages about this manifest (e.g. zod v3 detected, missing zod dependency). */ + notes?: string[]; } export interface RunnerResult { @@ -63,6 +73,10 @@ export interface RunnerResult { totalChanges: number; diagnostics: Diagnostic[]; fileResults: FileResult[]; - packageJsonChanges?: PackageJsonChange; + /** + * One entry per `package.json` updated. A monorepo run updates every workspace member that + * depends on the v1 SDK; a single-package run yields at most one entry. + */ + packageJsonChanges?: PackageJsonChange[]; commentCount: number; } diff --git a/packages/codemod/src/utils/fileWalk.ts b/packages/codemod/src/utils/fileWalk.ts new file mode 100644 index 0000000000..5504ea2328 --- /dev/null +++ b/packages/codemod/src/utils/fileWalk.ts @@ -0,0 +1,155 @@ +import { existsSync, readdirSync } from 'node:fs'; +import path from 'node:path'; + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']); + +/** Directories never descended into. Matches the runner's built-in ignore list. */ +const SKIP_DIR_NAMES = new Set(['node_modules', 'dist', '.git', 'build', '.next', '.nuxt', 'coverage', '__generated__']); + +/** + * Convert a subset of glob syntax (`**`, `*`, `?`) to a `RegExp` so user `--ignore` patterns can be + * applied to discovered file paths without depending on a glob library. Brace/extglob/character-class + * syntax is not interpreted (escaped literally) — sufficient for the documented `--ignore` use cases + * (e.g. a `generated` or `src/legacy` subtree). + */ +function globToRegExp(glob: string): RegExp { + let re = ''; + let i = 0; + while (i < glob.length) { + const c = glob[i]!; + if (c === '*') { + if (glob[i + 1] === '*') { + // `**` — any path segment(s), including none. Absorb a following `/` so `**/x` matches `x`. + re += '.*'; + i += 2; + if (glob[i] === '/') i++; + continue; + } + re += '[^/]*'; + } else if (c === '?') { + re += '[^/]'; + } else if (/[\\^$.|+()[\]{}]/.test(c)) { + re += '\\' + c; + } else { + re += c; + } + i++; + } + return new RegExp(`^${re}$`); +} + +/** + * Extract bare directory names from `--ignore` globs that name a single path segment (optionally + * wrapped in globstars on either side) so they can be pruned during directory descent rather than + * only filtered post-walk. This is what makes `--ignore` actually skip a directory tree — without + * it, a cyclic-symlink directory matched by `--ignore` would still be entered. + */ +function extractIgnoreDirNames(ignorePatterns: string[]): Set { + const names = new Set(); + for (const p of ignorePatterns) { + const m = /^(?:\*\*\/)?([^/*?[\]{}]+)(?:\/\*\*)?$/.exec(p); + if (m) names.add(m[1]!); + } + return names; +} + +export interface SourceWalkResult { + sourceFiles: string[]; + /** Every `package.json` discovered under (or at) `targetDir`, excluding skipped directories. */ + packageJsonPaths: string[]; +} + +/** + * Walk `targetDir` for source files and `package.json` manifests, applying ignore patterns during + * directory descent and never following symlinks. + * + * Replaces ts-morph's `addSourceFilesAtPaths` glob, which (a) follows directory symlinks — so a pnpm + * workspace with a cyclic intra-workspace dev-dependency (`pkg-a/node_modules/@scope/pkg-b → ../pkg-b`, + * and back) recurses indefinitely and dies with `ELOOP` — and (b) applies negative globstar patterns + * to matched files only, so `--ignore` cannot prune the descent that causes the crash. + * + * `package.json` discovery is folded into the same walk so a monorepo run can update every workspace + * member that depends on the v1 SDK, not just the closest manifest to `targetDir`. + */ +export function collectSourceFiles(targetDir: string, userIgnorePatterns: string[] = []): SourceWalkResult { + const ignoreDirNames = new Set([...SKIP_DIR_NAMES, ...extractIgnoreDirNames(userIgnorePatterns)]); + const ignoreMatchers = userIgnorePatterns.map(p => globToRegExp(p)); + const root = path.resolve(targetDir); + const sourceFiles: string[] = []; + const packageJsonPaths: string[] = []; + + if (existsSync(path.join(root, 'package.json'))) { + packageJsonPaths.push(path.join(root, 'package.json')); + } + + const matchesUserIgnore = (rel: string): boolean => ignoreMatchers.some(m => m.test(rel)); + + const visit = (dir: string): void => { + let entries: import('node:fs').Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + // Never follow symlinks — pnpm represents intra-workspace deps as symlinks under + // node_modules, and following them is the ELOOP crash. A symlinked source file inside the + // user's own tree is rare enough that skipping it is the safer default. + if (entry.isSymbolicLink()) continue; + + const full = path.join(dir, entry.name); + const rel = path.relative(root, full).replaceAll('\\', '/'); + + if (entry.isDirectory()) { + if (ignoreDirNames.has(entry.name)) continue; + if (matchesUserIgnore(rel) || matchesUserIgnore(rel + '/')) continue; + visit(full); + } else if (entry.isFile()) { + if (entry.name === 'package.json') { + packageJsonPaths.push(full); + continue; + } + const ext = path.extname(entry.name); + if (!SOURCE_EXTENSIONS.has(ext)) continue; + if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.d.mts') || entry.name.endsWith('.d.cts')) continue; + if (matchesUserIgnore(rel)) continue; + sourceFiles.push(full); + } + } + }; + + visit(root); + return { sourceFiles, packageJsonPaths }; +} + +/** + * The leading comment block of a source file: every byte from offset 0 up to (but not including) the + * first non-comment, non-whitespace character. Used by the runner to snapshot a file header before + * transforms run, so it can be restored verbatim if a transform drops it (a JSDoc block header is + * leading trivia of the first import declaration, and ts-morph removes it along with the node) or + * displaces it (ts-morph's `insertImportDeclaration(0)` inserts at byte 0, ahead of any `//` header + * lines that survived a removal by re-attaching to the next statement). + */ +export function extractFileHeader(text: string): string { + let i = 0; + while (i < text.length) { + const c = text[i]!; + if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { + i++; + continue; + } + if (text.startsWith('//', i)) { + const nl = text.indexOf('\n', i); + i = nl === -1 ? text.length : nl + 1; + continue; + } + if (text.startsWith('/*', i)) { + const end = text.indexOf('*/', i + 2); + if (end === -1) break; + i = end + 2; + continue; + } + break; + } + return text.slice(0, i); +} diff --git a/packages/codemod/src/utils/packageJsonUpdater.ts b/packages/codemod/src/utils/packageJsonUpdater.ts index 1b3eb07f8a..de800eb42d 100644 --- a/packages/codemod/src/utils/packageJsonUpdater.ts +++ b/packages/codemod/src/utils/packageJsonUpdater.ts @@ -1,4 +1,5 @@ import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; import { V2_PACKAGE_VERSIONS } from '../generated/versions'; import type { PackageJsonChange } from '../types'; @@ -18,10 +19,64 @@ function detectIndent(text: string): string { return match ? match[1]! : ' '; } -export function updatePackageJson(targetDir: string, usedPackages: Set, dryRun: boolean): PackageJsonChange | undefined { - const pkgJsonPath = findPackageJson(targetDir); - if (!pkgJsonPath) return undefined; +/** + * For each discovered `package.json`, attribute the v2 packages used by the source files it owns + * (the manifest closest to a file walking up wins). The walk-up also covers a target directory that + * sits inside a package whose manifest lives above it. + * + * Returned map keys are absolute manifest paths; values are the v2 package specifiers (possibly + * subpaths — `applyPackageJsonUpdate` normalizes to the root package name). + */ +export function attributeUsedPackages( + targetDir: string, + packageJsonPaths: string[], + perFileUsed: Map> +): Map> { + // Owners sorted longest-first so the deepest enclosing manifest wins. + const ownerDirs = packageJsonPaths.map(p => path.dirname(p)).toSorted((a, b) => b.length - a.length); + const result = new Map>(); + + // A run pointed at a sub-directory may not contain its own package.json — fall back to walk-up. + const fallback = findPackageJson(targetDir); + + const ownerOf = (filePath: string): string | undefined => { + const dir = path.dirname(filePath); + for (const ownerDir of ownerDirs) { + if (dir === ownerDir || dir.startsWith(ownerDir + path.sep)) return path.join(ownerDir, 'package.json'); + } + return fallback; + }; + + for (const [filePath, used] of perFileUsed) { + if (used.size === 0) continue; + const owner = ownerOf(filePath); + if (!owner) continue; + let bucket = result.get(owner); + if (!bucket) result.set(owner, (bucket = new Set())); + for (const p of used) bucket.add(p); + } + + // Manifests that own no changed files (or whose files only used packages later pruned by the + // surviving-specifier check) still get an entry so the v1 SDK is removed and zod is checked. + for (const p of packageJsonPaths) { + if (!result.has(p)) result.set(p, new Set()); + } + if (fallback && !result.has(fallback)) result.set(fallback, new Set()); + + return result; +} +/** + * Apply the v1→v2 dependency swap to a single manifest. Returns `undefined` when the manifest does + * not depend on the v1 SDK (so a monorepo's tooling-only packages and the workspace root are left + * alone) or cannot be read/parsed. + * + * A `zod@^3` range is reported (not rewritten): v2 imports from `zod/v4`, but bumping a user's + * Zod major can break their own non-SDK Zod code, so the choice (bump to ^4, or pin ^3.25+ and + * import from `zod/v4`) is theirs. The note text matches the §Packaging guidance in + * `docs/migration/upgrade-to-v2.md`. + */ +export function applyPackageJsonUpdate(pkgJsonPath: string, usedPackages: Set, dryRun: boolean): PackageJsonChange | undefined { let raw: string; let pkgJson: Record; try { @@ -62,6 +117,16 @@ export function updatePackageJson(targetDir: string, usedPackages: Set, if (inDevDeps) delete devDeps![V1_PACKAGE]; const removed = [V1_PACKAGE]; + const notes: string[] = []; + const zodRange = deps?.['zod'] ?? devDeps?.['zod']; + if (zodRange && /^[~^]?3\./.test(zodRange)) { + notes.push( + `\`zod\` is at ${zodRange}. SDK v2 imports from 'zod/v4'. Either bump to zod@^4 ` + + `(review zod's own migration guide for your code) or pin ^3.25+ and import from 'zod/v4'. ` + + `See docs/migration/upgrade-to-v2.md §Packaging.` + ); + } + if (!dryRun) { const indent = detectIndent(raw); const trailingNewline = raw.endsWith('\n'); @@ -73,6 +138,14 @@ export function updatePackageJson(targetDir: string, usedPackages: Set, return { added: added.toSorted(), removed, - packageJsonPath: pkgJsonPath + packageJsonPath: pkgJsonPath, + ...(notes.length > 0 ? { notes } : {}) }; } + +/** Back-compat single-manifest entry point used by the existing unit tests. */ +export function updatePackageJson(targetDir: string, usedPackages: Set, dryRun: boolean): PackageJsonChange | undefined { + const pkgJsonPath = findPackageJson(targetDir); + if (!pkgJsonPath) return undefined; + return applyPackageJsonUpdate(pkgJsonPath, usedPackages, dryRun); +} diff --git a/packages/codemod/src/utils/projectAnalyzer.ts b/packages/codemod/src/utils/projectAnalyzer.ts index 1a836021c7..1a3e09e2b0 100644 --- a/packages/codemod/src/utils/projectAnalyzer.ts +++ b/packages/codemod/src/utils/projectAnalyzer.ts @@ -31,7 +31,7 @@ export function findPackageJson(startDir: string): string | undefined { } } -export function analyzeProject(targetDir: string): TransformContext { +export function analyzeProject(targetDir: string, prefer?: 'client' | 'server'): TransformContext { const pkgJsonPath = findPackageJson(targetDir); if (pkgJsonPath) { try { @@ -44,9 +44,9 @@ export function analyzeProject(targetDir: string): TransformContext { const hasClient = '@modelcontextprotocol/client' in allDeps; const hasServer = '@modelcontextprotocol/server' in allDeps; - if (hasClient && hasServer) return { projectType: 'both' }; - if (hasClient) return { projectType: 'client' }; - if (hasServer) return { projectType: 'server' }; + if (hasClient && hasServer) return { projectType: 'both', prefer }; + if (hasClient) return { projectType: 'client', prefer }; + if (hasServer) return { projectType: 'server', prefer }; // No v2 split deps — this is almost always a v1 project mid-migration (v1 ships as the single // `@modelcontextprotocol/sdk` package). Fall through to inferring the type from source usage. } catch { @@ -54,7 +54,7 @@ export function analyzeProject(targetDir: string): TransformContext { } } - return { projectType: inferProjectTypeFromSource(targetDir) }; + return { projectType: inferProjectTypeFromSource(targetDir), prefer }; } /** @@ -149,13 +149,29 @@ export function resolveTypesPackage( } return '@modelcontextprotocol/server'; } + // No signal from this file or the project scan. Honour an explicit `--prefer` so a known + // client-only (or server-only) tool isn't blanket-routed to the wrong package — the per-file + // warning is downgraded to an info note since the user told us which way to lean. + if (context.prefer) { + const target = context.prefer === 'client' ? '@modelcontextprotocol/client' : '@modelcontextprotocol/server'; + if (diagnosticSink) { + diagnosticSink.diagnostics.push( + info( + diagnosticSink.filePath, + diagnosticSink.line, + `Shared protocol types imported from ${target} (no client/server signal in this file; using --prefer ${context.prefer}).` + ) + ); + } + return target; + } if (diagnosticSink) { diagnosticSink.diagnostics.push( warning( diagnosticSink.filePath, diagnosticSink.line, 'Could not determine project type (client vs server). Defaulting to @modelcontextprotocol/server. ' + - 'If this is a client-only project, adjust imports manually.' + 'Pass --prefer client (or --prefer server) to override, or adjust imports manually.' ) ); } diff --git a/packages/codemod/test/integration.test.ts b/packages/codemod/test/integration.test.ts index 557b533173..e98f47c415 100644 --- a/packages/codemod/test/integration.test.ts +++ b/packages/codemod/test/integration.test.ts @@ -268,7 +268,7 @@ describe('integration', () => { // Phantom package from the reverted transform should not leak into package.json expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.added).not.toContain('@modelcontextprotocol/phantom-pkg'); + expect(result.packageJsonChanges![0]!.added).not.toContain('@modelcontextprotocol/phantom-pkg'); }); it('respects transform filter option', () => { @@ -368,9 +368,9 @@ describe('integration', () => { const result = run(migration, { targetDir: dir }); expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/node'); + expect(result.packageJsonChanges![0]!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/node'); const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); @@ -406,8 +406,8 @@ describe('integration', () => { // So core must not be added; the package actually imported (server) still is. expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); - expect(result.packageJsonChanges!.added).not.toContain('@modelcontextprotocol/core'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges![0]!.added).not.toContain('@modelcontextprotocol/core'); const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); expect(pkgJson.dependencies['@modelcontextprotocol/core']).toBeUndefined(); @@ -437,7 +437,7 @@ describe('integration', () => { expect(output).toContain('CallToolResultSchema.parse'); expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/core'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/core'); const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); expect(pkgJson.dependencies['@modelcontextprotocol/core']).toBeDefined(); @@ -455,7 +455,7 @@ describe('integration', () => { const result = run(migration, { targetDir: dir, dryRun: true }); expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/server'); const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBe('^1.0.0'); @@ -478,10 +478,10 @@ describe('integration', () => { const result = run(migration, { targetDir: dir }); expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/client'); - expect(result.packageJsonChanges!.added).not.toContain('@modelcontextprotocol/server'); - expect(result.packageJsonChanges!.added).not.toContain('@modelcontextprotocol/node'); + expect(result.packageJsonChanges![0]!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/client'); + expect(result.packageJsonChanges![0]!.added).not.toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges![0]!.added).not.toContain('@modelcontextprotocol/node'); const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); @@ -514,9 +514,9 @@ describe('integration', () => { const result = run(migration, { targetDir: dir }); expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/client'); + expect(result.packageJsonChanges![0]!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/client'); const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); @@ -542,9 +542,9 @@ describe('integration', () => { const result = run(migration, { targetDir: dir }); expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/express'); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges![0]!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/express'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/server'); const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); @@ -590,9 +590,9 @@ describe('integration', () => { const result = run(migration, { targetDir: dir }); expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); - expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/node'); + expect(result.packageJsonChanges![0]!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges![0]!.added).toContain('@modelcontextprotocol/node'); const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); @@ -634,7 +634,7 @@ describe('integration', () => { const result = run(migration, { targetDir: dir }); expect(result.filesChanged).toBe(0); expect(result.packageJsonChanges).toBeDefined(); - expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges![0]!.removed).toContain('@modelcontextprotocol/sdk'); }); it('emits info diagnostics for legacy-moved imports', () => { diff --git a/packages/codemod/test/v1-to-v2/realWorldGaps.test.ts b/packages/codemod/test/v1-to-v2/realWorldGaps.test.ts new file mode 100644 index 0000000000..fd4b6b0668 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/realWorldGaps.test.ts @@ -0,0 +1,188 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { getMigration } from '../../src/migrations/index'; +import { run } from '../../src/runner'; +import { DiagnosticLevel } from '../../src/types'; + +const migration = getMigration('v1-to-v2')!; + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-rw-')); + return tempDir; +} + +function writePkgJson(dir: string, content: Record): void { + writeFileSync(path.join(dir, 'package.json'), JSON.stringify(content, null, 2) + '\n'); +} + +afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); +}); + +// ─── #165: ELOOP on pnpm symlink cycles; --ignore not honoured during descent ────────────────────── + +describe('#165 — directory walk skips symlinks and prunes ignored dirs', () => { + it('does not follow a cyclic node_modules symlink (pnpm intra-workspace dep cycle)', () => { + const dir = createTempDir(); + // packages/a/node_modules/@scope/b → ../../b ; packages/b/node_modules/@scope/a → ../../a + for (const name of ['a', 'b']) { + mkdirSync(path.join(dir, 'packages', name, 'node_modules', '@scope'), { recursive: true }); + writePkgJson(path.join(dir, 'packages', name), { + name: `@scope/${name}`, + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + writeFileSync( + path.join(dir, 'packages', name, 'index.ts'), + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n` + ); + } + symlinkSync(path.join(dir, 'packages', 'b'), path.join(dir, 'packages', 'a', 'node_modules', '@scope', 'b'), 'dir'); + symlinkSync(path.join(dir, 'packages', 'a'), path.join(dir, 'packages', 'b', 'node_modules', '@scope', 'a'), 'dir'); + + // Would previously die with ELOOP inside ts-morph's glob. + const result = run(migration, { targetDir: dir }); + expect(result.filesChanged).toBe(2); + }); + + it('honours --ignore during directory descent (a cyclic-symlink dir matched by --ignore is never entered)', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'vendor', 'loop'), { recursive: true }); + symlinkSync(path.join(dir, 'vendor'), path.join(dir, 'vendor', 'loop', 'back'), 'dir'); + writeFileSync(path.join(dir, 'index.ts'), `import { Client } from '@modelcontextprotocol/sdk/client/index.js';\n`); + writePkgJson(dir, { dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } }); + + const result = run(migration, { targetDir: dir, ignore: ['**/vendor/**'] }); + expect(result.filesChanged).toBe(1); + }); +}); + +// ─── #150: workspace-member package.json files are updated ───────────────────────────────────────── + +describe('#150 — pnpm workspace member manifests are updated', () => { + it('updates every workspace member that depends on the v1 SDK, attributing per-package usage', () => { + const dir = createTempDir(); + // Workspace root has no SDK dep — should be left alone. + writePkgJson(dir, { name: 'root', private: true, workspaces: ['packages/*'] }); + mkdirSync(path.join(dir, 'packages', 'svc'), { recursive: true }); + mkdirSync(path.join(dir, 'packages', 'cli'), { recursive: true }); + writePkgJson(path.join(dir, 'packages', 'svc'), { + name: 'svc', + dependencies: { '@modelcontextprotocol/sdk': '^1.29.0' } + }); + writePkgJson(path.join(dir, 'packages', 'cli'), { + name: 'cli', + dependencies: { '@modelcontextprotocol/sdk': '^1.29.0' } + }); + writeFileSync( + path.join(dir, 'packages', 'svc', 'index.ts'), + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n` + ); + writeFileSync( + path.join(dir, 'packages', 'cli', 'index.ts'), + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';\n` + ); + + const result = run(migration, { targetDir: dir }); + + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.length).toBe(2); + + const svc = JSON.parse(readFileSync(path.join(dir, 'packages', 'svc', 'package.json'), 'utf8')); + const cli = JSON.parse(readFileSync(path.join(dir, 'packages', 'cli', 'package.json'), 'utf8')); + expect(svc.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(svc.dependencies['@modelcontextprotocol/server']).toBeDefined(); + expect(svc.dependencies['@modelcontextprotocol/client']).toBeUndefined(); + expect(cli.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(cli.dependencies['@modelcontextprotocol/client']).toBeDefined(); + expect(cli.dependencies['@modelcontextprotocol/server']).toBeUndefined(); + + // Root manifest has no v1 SDK dep — must not be touched. + const root = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(root.dependencies).toBeUndefined(); + }); +}); + +// ─── #122: zod v3 range diagnostic + --prefer override ───────────────────────────────────────────── + +describe('#122 — zod v3 range is reported (not rewritten); --prefer overrides the unknown-project default', () => { + it('reports (does NOT rewrite) a zod ^3 range alongside the v1→v2 dependency swap', () => { + // Bumping the user's Zod major can break their own non-SDK Zod code; the codemod only + // surfaces the note and leaves the range untouched. The note steers to the two valid paths + // (bump to ^4, or pin ^3.25+ and import from 'zod/v4'). + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0', zod: '^3.25.0' } + }); + writeFileSync(path.join(dir, 'index.ts'), `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n`); + + const result = run(migration, { targetDir: dir }); + const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkg.dependencies['zod']).toBe('^3.25.0'); + const note = result.packageJsonChanges![0]!.notes?.find(n => n.includes('zod')); + expect(note).toBeDefined(); + expect(note).toContain("'zod/v4'"); + expect(note).toContain('upgrade-to-v2.md'); + }); + + it('routes shared types to client when --prefer client is set and no signal is found (info, not warning)', () => { + const dir = createTempDir(); + // No package.json, no client/server-specific imports — `unknown` project. + mkdirSync(path.join(dir, '.git')); + writeFileSync(path.join(dir, 'index.ts'), `import { Progress } from '@modelcontextprotocol/sdk/types.js';\n`); + + const result = run(migration, { targetDir: dir, prefer: 'client' }); + const out = readFileSync(path.join(dir, 'index.ts'), 'utf8'); + expect(out).toContain('@modelcontextprotocol/client'); + expect(result.diagnostics.some(d => d.level === DiagnosticLevel.Warning && d.message.includes('Could not determine'))).toBe(false); + expect(result.diagnostics.some(d => d.level === DiagnosticLevel.Info && d.message.includes('--prefer client'))).toBe(true); + }); +}); + +// ─── #164: file-header comment preservation across all transforms ────────────────────────────────── + +describe('#164 — leading file-header comments survive the full transform pipeline', () => { + it('preserves a /** */ header when a later transform removes the rewritten first import', () => { + // importPaths rewrites the first (and only) SDK import; symbolRenames then removes that import + // declaration entirely (RequestHandlerExtra is the only specifier) — taking the JSDoc header, + // which is its leading trivia, with it. The runner-level restore must put the header back. + const dir = createTempDir(); + const input = [ + `/**`, + ` * Twenty lines of design documentation about capability negotiation`, + ` * that must not be silently deleted.`, + ` */`, + `import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + ``, + `export function helper(extra: RequestHandlerExtra): void {}`, + `` + ].join('\n'); + writeFileSync(path.join(dir, 'capabilities.ts'), input); + + run(migration, { targetDir: dir }); + const out = readFileSync(path.join(dir, 'capabilities.ts'), 'utf8'); + expect(out.startsWith('/**')).toBe(true); + expect(out).toContain('design documentation about capability negotiation'); + expect(out).toContain('ServerContext'); + }); + + it('keeps a multi-line // header at the top of the file (not displaced below an inserted import)', () => { + // ts-morph re-attaches the surviving // lines to the next statement and inserts the new import + // at byte 0, leaving the header below it. The runner-level restore must move it back to the top. + const dir = createTempDir(); + const header = `// This file wires the HTTP transport. It also explains why\n// session ordering matters across reconnects.\n`; + const input = header + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';\nnew Client({});\n`; + writeFileSync(path.join(dir, 'http.ts'), input); + + run(migration, { targetDir: dir }); + const out = readFileSync(path.join(dir, 'http.ts'), 'utf8'); + expect(out.startsWith(header)).toBe(true); + // Exactly one copy of each header line. + expect(out.split('wires the HTTP transport').length - 1).toBe(1); + expect(out.split('session ordering matters').length - 1).toBe(1); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts b/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts index 039fcf559d..fd3f349289 100644 --- a/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts @@ -368,6 +368,72 @@ describe('context-types transform', () => { expect(result).not.toContain('extra'); }); + // #152 — as-cast/parenthesized callbacks were skipped (the callback arg is an AsExpression, not the + // arrow function), leaving `extra.authInfo` etc. in code that type-checks via the cast and only + // fails at runtime. + it('#152 — remaps an as-cast/parenthesized registerTool callback', () => { + const input = [ + `server.registerTool('issue', { inputSchema: z.object({}) }, (async (args, extra) => {`, + ` if (!extra.authInfo) throw new Error('no auth');`, + ` return { content: [] };`, + `}) as Parameters[2]);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('(args, ctx)'); + expect(result).toContain('ctx.http?.authInfo'); + expect(result).not.toContain('extra.authInfo'); + }); + + // #152 — `protocol.fallbackRequestHandler = async (request, extra) => …` is an assignment, not a + // call, so the call-site scan never reached it. + it('#152 — remaps a fallbackRequestHandler assignment', () => { + const input = [ + `server.fallbackRequestHandler = async (request, extra) => {`, + ` await extra.sendNotification({ method: 'test' });`, + ` return {};`, + `};`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('(request, ctx)'); + expect(result).toContain('ctx.mcpReq.notify'); + }); + + // #152 — helpers a callback forwards its context to (or any parameter explicitly typed + // ServerContext/ClientContext) are not remapped automatically; emit a site-level marker so the + // stale property access is visible in the diff instead of silently breaking at runtime. + it('#152 — marks v1 property accesses on a parameter typed ServerContext that the remap could not reach', () => { + const input = [ + `import type { ServerContext } from '@modelcontextprotocol/server';`, + `function helper(extra: ServerContext) {`, + ` return extra.sendRequest({ method: 'x' }, S);`, + `}`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.diagnostics.some(d => d.insertComment && d.message.includes('.sendRequest'))).toBe(true); + }); + + // #122 — a schema-less McpServer registerTool callback receives the context as its ONLY parameter; + // the remap previously required a second parameter and skipped these. + it('#122 — remaps a single-parameter (context-only) registerTool callback', () => { + const input = [ + `server.registerTool('ping', {}, async (extra) => {`, + ` const m = extra._meta;`, + ` await extra.sendNotification({ method: 'test' });`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('async (ctx)'); + expect(result).toContain('ctx.mcpReq._meta'); + expect(result).toContain('ctx.mcpReq.notify'); + }); + it('rewrites typeof ctx.signal in type positions', () => { const input = [ `server.setRequestHandler('tools/call', async (request, extra) => {`, diff --git a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts index c9392aa830..818f290f19 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts @@ -396,6 +396,38 @@ describe('mcp-server-api transform', () => { expect(result.diagnostics.some(d => d.message.includes("'taskStore'"))).toBe(true); }); + // #161 — wrapping a raw shape with z.object() in a file that never imported `z` produces TS2304. + describe('#161 — adds the zod import when emitting a z.object() wrapper', () => { + it("adds `import { z } from 'zod'` when no z binding exists and warns about the dependency", () => { + const input = [ + `server.registerTool('echo', { inputSchema: { msg: { type: 'string' } } }, async ({ msg }) => ({`, + ` content: [{ type: 'text', text: msg }]`, + `}));`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + const out = sourceFile.getFullText(); + expect(out).toContain('z.object('); + expect(out).toMatch(/import\s*\{\s*z\s*\}\s*from\s*["']zod["']/); + expect(result.diagnostics.some(d => d.message.includes('zod@^4'))).toBe(true); + }); + + it('does not add a second zod import when z is already bound', () => { + const input = [ + `import { z } from 'zod';`, + `server.registerTool('echo', { inputSchema: { msg: z.string() } }, async ({ msg }) => ({ content: [] }));`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + mcpServerApiTransform.apply(sourceFile, ctx); + const out = sourceFile.getFullText(); + expect(out.match(/from ['"]zod['"]/g)?.length).toBe(1); + }); + }); + it('emits no task diagnostics for McpServer options without task options', () => { const input = [`const server = new McpServer({ name: 'test', version: '1.0' }, { instructions: 'hi' });`, ''].join('\n'); const project = new Project({ useInMemoryFileSystem: true }); diff --git a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts index ed613ddc2f..e06517dd12 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts @@ -557,6 +557,41 @@ describe('mock-paths transform', () => { expect(result).toContain('AjvJsonSchemaValidator'); }); + // #122 — type-position dynamic-import qualifiers (`import('…').Name`) are ImportTypeNode, not the + // CallExpression covered by rewriteDynamicImports. Left untouched they resolve against the removed + // v1 package and surface as TS2307. + it("#122 — rewrites a type-position import('…').Name qualifier and routes per-symbol", () => { + const input = [ + `let meta: import('@modelcontextprotocol/sdk/shared/auth.js').OAuthClientMetadata;`, + `let schema: import('@modelcontextprotocol/sdk/types.js').CallToolResultSchema;`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'client' }); + expect(result).toContain(`import('@modelcontextprotocol/client').OAuthClientMetadata`); + // Schema constants route to core (per-symbol override), matching the static-import transform. + expect(result).toContain(`import('@modelcontextprotocol/core').CallToolResultSchema`); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + // #162 — require()/require.resolve() of a v1 SDK path is not rewritten (the v2 subpath exports + // have no `require` condition, so a rewrite would still fail at runtime); mark the site instead. + it('#162 — marks require.resolve() of a v1 SDK path (createRequire and bare require)', () => { + const input = [ + `import { createRequire } from 'node:module';`, + `const req = createRequire(import.meta.url);`, + `const p = req.resolve('@modelcontextprotocol/sdk/server/sse.js');`, + `const q = require('@modelcontextprotocol/sdk/client/index.js');`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + const markers = result.diagnostics.filter(d => d.insertComment && d.message.includes('require')); + expect(markers.length).toBe(2); + // The specifiers are left untouched (not rewritten). + expect(sourceFile.getFullText()).toContain(`'@modelcontextprotocol/sdk/server/sse.js'`); + }); + it('rewrites jest.mock of validator short alias to the subpath', () => { const input = [ `jest.mock('@modelcontextprotocol/sdk/validation/cfworker', () => ({`, diff --git a/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts index bf1001978a..43945c2177 100644 --- a/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts @@ -257,6 +257,37 @@ describe('removed-apis transform', () => { }); }); + // #155 — .code on a v2 SdkHttpError is the SdkErrorCode string; the HTTP status moved to .status. + // Renaming the class without flagging the property access leaves `error.code === 404` compiling + // (TS2367 at best) but always-false at runtime. + describe('#155 — marks .code accesses on instanceof-checked SdkHttpError', () => { + it('emits an action-required marker on each .code access of an instanceof-checked error', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`, + `try { await op(); } catch (error) {`, + ` if (error instanceof StreamableHTTPError && error.code === 404) {}`, + ` if (error instanceof StreamableHTTPError && typeof error.code === 'number') {}`, + `}`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('instanceof SdkHttpError'); + const markers = result.diagnostics.filter(d => d.insertComment && d.message.includes('.status')); + expect(markers.length).toBe(2); + }); + + it('does not flag .code on an unrelated identifier', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (e instanceof StreamableHTTPError) {}`, + `const x = other.code;`, + '' + ].join('\n'); + const { result } = applyTransform(input); + expect(result.diagnostics.some(d => d.insertComment && d.message.includes('.status'))).toBe(false); + }); + }); + describe('IsomorphicHeaders alias', () => { it('handles aliased IsomorphicHeaders import', () => { const input = [`import { IsomorphicHeaders as IH } from '@modelcontextprotocol/server';`, `const h: IH = new IH();`, ''].join(