From 05eadea5a4ede39558d02a6b1970faba5f339683 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 19:57:27 +0300 Subject: [PATCH 01/22] feat: add v2 codemode draft --- packages/codemod/eslint.config.mjs | 5 + packages/codemod/package.json | 66 ++++ packages/codemod/src/cli.ts | 116 +++++++ packages/codemod/src/index.ts | 13 + packages/codemod/src/migrations/index.ts | 12 + .../codemod/src/migrations/v1-to-v2/index.ts | 8 + .../v1-to-v2/mappings/contextPropertyMap.ts | 23 ++ .../migrations/v1-to-v2/mappings/importMap.ts | 128 ++++++++ .../v1-to-v2/mappings/schemaToMethodMap.ts | 28 ++ .../migrations/v1-to-v2/mappings/symbolMap.ts | 12 + .../v1-to-v2/transforms/contextTypes.ts | 93 ++++++ .../transforms/handlerRegistration.ts | 48 +++ .../v1-to-v2/transforms/importPaths.ts | 177 +++++++++++ .../migrations/v1-to-v2/transforms/index.ts | 36 +++ .../v1-to-v2/transforms/mcpServerApi.ts | 282 ++++++++++++++++++ .../v1-to-v2/transforms/mockPaths.ts | 187 ++++++++++++ .../v1-to-v2/transforms/schemaParamRemoval.ts | 47 +++ .../v1-to-v2/transforms/symbolRenames.ts | 212 +++++++++++++ packages/codemod/src/runner.ts | 90 ++++++ packages/codemod/src/types.ts | 56 ++++ packages/codemod/src/utils/diagnostics.ts | 24 ++ packages/codemod/src/utils/importUtils.ts | 106 +++++++ packages/codemod/src/utils/projectAnalyzer.ts | 45 +++ packages/codemod/test/integration.test.ts | 204 +++++++++++++ .../v1-to-v2/transforms/contextTypes.test.ts | 152 ++++++++++ .../transforms/handlerRegistration.test.ts | 93 ++++++ .../v1-to-v2/transforms/importPaths.test.ts | 134 +++++++++ .../v1-to-v2/transforms/mcpServerApi.test.ts | 98 ++++++ .../v1-to-v2/transforms/mockPaths.test.ts | 148 +++++++++ .../transforms/schemaParamRemoval.test.ts | 77 +++++ .../v1-to-v2/transforms/symbolRenames.test.ts | 175 +++++++++++ packages/codemod/tsconfig.json | 10 + packages/codemod/tsdown.config.ts | 16 + packages/codemod/vitest.config.js | 3 + pnpm-lock.yaml | 85 ++++++ 35 files changed, 3009 insertions(+) create mode 100644 packages/codemod/eslint.config.mjs create mode 100644 packages/codemod/package.json create mode 100644 packages/codemod/src/cli.ts create mode 100644 packages/codemod/src/index.ts create mode 100644 packages/codemod/src/migrations/index.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/index.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/index.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts create mode 100644 packages/codemod/src/runner.ts create mode 100644 packages/codemod/src/types.ts create mode 100644 packages/codemod/src/utils/diagnostics.ts create mode 100644 packages/codemod/src/utils/importUtils.ts create mode 100644 packages/codemod/src/utils/projectAnalyzer.ts create mode 100644 packages/codemod/test/integration.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts create mode 100644 packages/codemod/tsconfig.json create mode 100644 packages/codemod/tsdown.config.ts create mode 100644 packages/codemod/vitest.config.js diff --git a/packages/codemod/eslint.config.mjs b/packages/codemod/eslint.config.mjs new file mode 100644 index 000000000..c1267b73c --- /dev/null +++ b/packages/codemod/eslint.config.mjs @@ -0,0 +1,5 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [...baseConfig]; diff --git a/packages/codemod/package.json b/packages/codemod/package.json new file mode 100644 index 000000000..1d69701e0 --- /dev/null +++ b/packages/codemod/package.json @@ -0,0 +1,66 @@ +{ + "name": "@modelcontextprotocol/codemod", + "version": "2.0.0-alpha.0", + "description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "codemod", + "migration" + ], + "bin": { + "mcp-codemod": "./dist/cli.mjs" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "commander": "^13.0.0", + "ts-morph": "^28.0.0" + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/codemod/src/cli.ts b/packages/codemod/src/cli.ts new file mode 100644 index 000000000..961ad8067 --- /dev/null +++ b/packages/codemod/src/cli.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +import { existsSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { Command } from 'commander'; + +import { listMigrations } from './migrations/index.js'; +import { run } from './runner.js'; +import { DiagnosticLevel } from './types.js'; +import { formatDiagnostic } from './utils/diagnostics.js'; + +const require = createRequire(import.meta.url); +const { version } = require('../package.json') as { version: string }; + +const program = new Command(); + +program.name('mcp-codemod').description('Codemod to migrate MCP TypeScript SDK code between versions').version(version); + +for (const [name, migration] of listMigrations()) { + program + .command(`${name} `) + .description(migration.description) + .option('-d, --dry-run', 'Preview changes without writing files') + .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('--list', 'List available transforms for this migration') + .action((targetDir: string, opts: Record) => { + try { + if (opts['list']) { + console.log(`\nAvailable transforms for ${name}:\n`); + for (const t of migration.transforms) { + console.log(` ${t.id.padEnd(20)} ${t.name}`); + } + console.log(''); + return; + } + + const resolvedDir = path.resolve(targetDir); + + if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { + console.error(`\nError: "${resolvedDir}" is not a valid directory.\n`); + process.exitCode = 1; + return; + } + + console.log(`\n@modelcontextprotocol/codemod — ${migration.name}\n`); + console.log(`Scanning ${resolvedDir}...`); + if (opts['dryRun']) { + console.log('(dry run — no files will be modified)\n'); + } else { + console.log(''); + } + + const transforms = opts['transforms'] ? (opts['transforms'] as string).split(',').map(s => s.trim()) : undefined; + + 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 + }); + + if (result.filesChanged === 0 && result.diagnostics.length === 0) { + console.log('No changes needed — code already migrated or no SDK imports found.\n'); + return; + } + + 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))`); + } + console.log(''); + } + + const errors = result.diagnostics.filter(d => d.level === DiagnosticLevel.Error); + if (errors.length > 0) { + console.log(`Errors (${errors.length}):`); + for (const d of errors) { + console.log(formatDiagnostic(d)); + } + console.log(''); + process.exitCode = 1; + } + + const warnings = result.diagnostics.filter(d => d.level === DiagnosticLevel.Warning); + if (warnings.length > 0) { + console.log(`Warnings (${warnings.length}):`); + for (const d of warnings) { + console.log(formatDiagnostic(d)); + } + console.log(''); + process.exitCode = 1; + } + + if (opts['dryRun']) { + console.log('Run without --dry-run to apply changes.\n'); + } else { + console.log('Migration complete. Review the changes and run your build/tests.\n'); + } + } catch (error) { + console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } + }); +} + +program.parse(); diff --git a/packages/codemod/src/index.ts b/packages/codemod/src/index.ts new file mode 100644 index 000000000..ef646b785 --- /dev/null +++ b/packages/codemod/src/index.ts @@ -0,0 +1,13 @@ +export { getMigration, listMigrations } from './migrations/index.js'; +export { run } from './runner.js'; +export type { + Diagnostic, + FileResult, + Migration, + RunnerOptions, + RunnerResult, + Transform, + TransformContext, + TransformResult +} from './types.js'; +export { DiagnosticLevel } from './types.js'; diff --git a/packages/codemod/src/migrations/index.ts b/packages/codemod/src/migrations/index.ts new file mode 100644 index 000000000..1630393a8 --- /dev/null +++ b/packages/codemod/src/migrations/index.ts @@ -0,0 +1,12 @@ +import type { Migration } from '../types.js'; +import { v1ToV2Migration } from './v1-to-v2/index.js'; + +const migrations = new Map([['v1-to-v2', v1ToV2Migration]]); + +export function getMigration(name: string): Migration | undefined { + return migrations.get(name); +} + +export function listMigrations(): Map { + return migrations; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/index.ts b/packages/codemod/src/migrations/v1-to-v2/index.ts new file mode 100644 index 000000000..689331a9c --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/index.ts @@ -0,0 +1,8 @@ +import type { Migration } from '../../types.js'; +import { v1ToV2Transforms } from './transforms/index.js'; + +export const v1ToV2Migration: Migration = { + name: 'v1-to-v2', + description: 'Migrate from @modelcontextprotocol/sdk (v1) to v2 packages (@modelcontextprotocol/client, /server, etc.)', + transforms: v1ToV2Transforms +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts new file mode 100644 index 000000000..b514d5337 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts @@ -0,0 +1,23 @@ +export interface ContextMapping { + from: string; + to: string; +} + +export const CONTEXT_PROPERTY_MAP: ContextMapping[] = [ + { from: '.signal', to: '.mcpReq.signal' }, + { from: '.requestId', to: '.mcpReq.id' }, + { from: '._meta', to: '.mcpReq._meta' }, + { from: '.sendRequest', to: '.mcpReq.send' }, + { from: '.sendNotification', to: '.mcpReq.notify' }, + { from: '.authInfo', to: '.http?.authInfo' }, + { from: '.sessionId', to: '.sessionId' }, + { from: '.requestInfo', to: '.http?.req' }, + { from: '.closeSSEStream', to: '.http?.closeSSE' }, + { from: '.closeStandaloneSSEStream', to: '.http?.closeStandaloneSSE' }, + { from: '.taskStore', to: '.task?.store' }, + { from: '.taskId', to: '.task?.id' }, + { from: '.taskRequestedTtl', to: '.task?.requestedTtl' } +]; + +export const EXTRA_PARAM_NAME = 'extra'; +export const CTX_PARAM_NAME = 'ctx'; diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts new file mode 100644 index 000000000..9899bce94 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts @@ -0,0 +1,128 @@ +export interface ImportMapping { + target: string; + status: 'moved' | 'removed' | 'renamed'; + renamedSymbols?: Record; + removalMessage?: string; +} + +export const IMPORT_MAP: Record = { + '@modelcontextprotocol/sdk/client/index.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/auth.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/streamableHttp.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/sse.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/stdio.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/websocket.js': { + target: '', + status: 'removed', + removalMessage: 'WebSocketClientTransport removed in v2. Use StreamableHTTPClientTransport or StdioClientTransport.' + }, + + '@modelcontextprotocol/sdk/server/mcp.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/index.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/stdio.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/streamableHttp.js': { + target: '@modelcontextprotocol/node', + status: 'renamed', + renamedSymbols: { + StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' + } + }, + '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/sse.js': { + target: '', + status: 'removed', + removalMessage: 'SSE server transport removed in v2. Migrate to NodeStreamableHTTPServerTransport from @modelcontextprotocol/node.' + }, + '@modelcontextprotocol/sdk/server/middleware.js': { + target: '@modelcontextprotocol/express', + status: 'moved' + }, + + '@modelcontextprotocol/sdk/server/auth/types.js': { + target: '', + status: 'removed', + removalMessage: + 'Server auth removed in v2. AuthInfo is now re-exported by @modelcontextprotocol/client and @modelcontextprotocol/server.' + }, + '@modelcontextprotocol/sdk/server/auth/provider.js': { + target: '', + status: 'removed', + removalMessage: 'Server auth removed in v2. Use an external auth library (e.g., better-auth).' + }, + '@modelcontextprotocol/sdk/server/auth/router.js': { + target: '', + status: 'removed', + removalMessage: 'Server auth removed in v2. Use an external auth library (e.g., better-auth).' + }, + '@modelcontextprotocol/sdk/server/auth/middleware.js': { + target: '', + status: 'removed', + removalMessage: 'Server auth removed in v2. Use an external auth library (e.g., better-auth).' + }, + '@modelcontextprotocol/sdk/server/auth/errors.js': { + target: '', + status: 'removed', + removalMessage: 'Server auth removed in v2. Use an external auth library (e.g., better-auth).' + }, + + '@modelcontextprotocol/sdk/types.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/shared/protocol.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/shared/transport.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/shared/uriTemplate.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/shared/auth.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/shared/stdio.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + + '@modelcontextprotocol/sdk/inMemory.js': { + target: '@modelcontextprotocol/core', + status: 'moved' + } +}; + +export function isAuthImport(specifier: string): boolean { + return specifier.includes('/server/auth/') || specifier.includes('/server/auth.'); +} diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts new file mode 100644 index 000000000..970e9e38c --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts @@ -0,0 +1,28 @@ +export const SCHEMA_TO_METHOD: Record = { + InitializeRequestSchema: 'initialize', + CallToolRequestSchema: 'tools/call', + ListToolsRequestSchema: 'tools/list', + ListPromptsRequestSchema: 'prompts/list', + GetPromptRequestSchema: 'prompts/get', + ListResourcesRequestSchema: 'resources/list', + ReadResourceRequestSchema: 'resources/read', + ListResourceTemplatesRequestSchema: 'resources/templates/list', + SubscribeRequestSchema: 'resources/subscribe', + UnsubscribeRequestSchema: 'resources/unsubscribe', + CreateMessageRequestSchema: 'sampling/createMessage', + ElicitRequestSchema: 'elicitation/create', + SetLevelRequestSchema: 'logging/setLevel', + PingRequestSchema: 'ping', + CompleteRequestSchema: 'completion/complete' +}; + +export const NOTIFICATION_SCHEMA_TO_METHOD: Record = { + LoggingMessageNotificationSchema: 'notifications/message', + ToolListChangedNotificationSchema: 'notifications/tools/list_changed', + ResourceListChangedNotificationSchema: 'notifications/resources/list_changed', + PromptListChangedNotificationSchema: 'notifications/prompts/list_changed', + ResourceUpdatedNotificationSchema: 'notifications/resources/updated', + ProgressNotificationSchema: 'notifications/progress', + CancelledNotificationSchema: 'notifications/cancelled', + InitializedNotificationSchema: 'notifications/initialized' +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts new file mode 100644 index 000000000..91152ae5a --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts @@ -0,0 +1,12 @@ +export const SIMPLE_RENAMES: Record = { + McpError: 'ProtocolError', + JSONRPCError: 'JSONRPCErrorResponse', + JSONRPCErrorSchema: 'JSONRPCErrorResponseSchema', + isJSONRPCError: 'isJSONRPCErrorResponse', + isJSONRPCResponse: 'isJSONRPCResultResponse', + ResourceReference: 'ResourceTemplateReference', + ResourceReferenceSchema: 'ResourceTemplateReferenceSchema', + StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' +}; + +export const ERROR_CODE_SDK_MEMBERS = new Set(['RequestTimeout', 'ConnectionClosed']); diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts new file mode 100644 index 000000000..11db1eff4 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts @@ -0,0 +1,93 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { warning } from '../../../utils/diagnostics.js'; +import { CONTEXT_PROPERTY_MAP, CTX_PARAM_NAME, EXTRA_PARAM_NAME } from '../mappings/contextPropertyMap.js'; + +const HANDLER_METHODS = new Set(['setRequestHandler', 'setNotificationHandler']); + +const REGISTER_METHODS = new Set(['registerTool', 'registerPrompt', 'registerResource', 'tool', 'prompt', 'resource']); + +export const contextTypesTransform: Transform = { + name: 'Context type rewrites', + id: 'context', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + let changesCount = 0; + const diagnostics: Diagnostic[] = []; + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) continue; + + const methodName = expr.getName(); + const isHandler = HANDLER_METHODS.has(methodName); + const isRegister = REGISTER_METHODS.has(methodName); + if (!isHandler && !isRegister) continue; + + const args = call.getArguments(); + + let callbackArg: Node | undefined; + if (isHandler && args.length >= 2) { + callbackArg = args[1]; + } else if (isRegister && args.length >= 3) { + callbackArg = args.at(-1); + } + + if (!callbackArg) continue; + if (!Node.isArrowFunction(callbackArg) && !Node.isFunctionExpression(callbackArg)) continue; + + const params = callbackArg.getParameters(); + if (params.length < 2) continue; + + const extraParam = params[1]!; + const paramName = extraParam.getName(); + if (paramName !== EXTRA_PARAM_NAME) continue; + + extraParam.rename(CTX_PARAM_NAME); + changesCount++; + + const body = Node.isArrowFunction(callbackArg) ? callbackArg.getBody() : callbackArg.getBody(); + + if (!body) continue; + + body.forEachDescendant(node => { + if (!Node.isPropertyAccessExpression(node)) return; + + const fullText = node.getText(); + for (const mapping of CONTEXT_PROPERTY_MAP) { + const oldPattern = CTX_PARAM_NAME + mapping.from; + if (fullText.startsWith(oldPattern)) { + const nextChar = fullText[oldPattern.length]; + if (nextChar !== undefined && /[a-zA-Z0-9_$]/.test(nextChar)) continue; + + const newText = fullText.replace(oldPattern, CTX_PARAM_NAME + mapping.to); + node.replaceWithText(newText); + changesCount++; + return; + } + } + }); + + body.forEachDescendant(node => { + if (!Node.isVariableDeclaration(node)) return; + const initializer = node.getInitializer(); + if (!initializer || !Node.isIdentifier(initializer) || initializer.getText() !== CTX_PARAM_NAME) return; + const nameNode = node.getNameNode(); + if (!Node.isObjectBindingPattern(nameNode)) return; + diagnostics.push( + warning( + sourceFile.getFilePath(), + node.getStartLineNumber(), + `Destructuring of context parameter detected: "const ${nameNode.getText()} = ${CTX_PARAM_NAME}". ` + + 'Properties have been reorganized in v2 (e.g., signal is now ctx.mcpReq.signal). Manual refactoring required.' + ) + ); + }); + } + + return { changesCount, diagnostics }; + } +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts new file mode 100644 index 000000000..9074c1a36 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts @@ -0,0 +1,48 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Transform, TransformContext, TransformResult } from '../../../types.js'; +import { removeUnusedImport } from '../../../utils/importUtils.js'; +import { NOTIFICATION_SCHEMA_TO_METHOD, SCHEMA_TO_METHOD } from '../mappings/schemaToMethodMap.js'; + +const ALL_SCHEMA_TO_METHOD: Record = { + ...SCHEMA_TO_METHOD, + ...NOTIFICATION_SCHEMA_TO_METHOD +}; + +export const handlerRegistrationTransform: Transform = { + name: 'Handler registration migration', + id: 'handlers', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + let changesCount = 0; + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) continue; + + const methodName = expr.getName(); + if (methodName !== 'setRequestHandler' && methodName !== 'setNotificationHandler') { + continue; + } + + const args = call.getArguments(); + if (args.length < 2) continue; + + const firstArg = args[0]!; + if (!Node.isIdentifier(firstArg)) continue; + + const schemaName = firstArg.getText(); + const methodString = ALL_SCHEMA_TO_METHOD[schemaName]; + if (!methodString) continue; + + firstArg.replaceWithText(`'${methodString}'`); + changesCount++; + + removeUnusedImport(sourceFile, schemaName); + } + + return { changesCount, diagnostics: [] }; + } +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts new file mode 100644 index 000000000..7ccf0c983 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -0,0 +1,177 @@ +import type { SourceFile } from 'ts-morph'; + +import type { Transform, TransformContext, TransformResult } from '../../../types.js'; +import { warning } from '../../../utils/diagnostics.js'; +import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils.js'; +import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; +import { IMPORT_MAP, isAuthImport } from '../mappings/importMap.js'; + +export const importPathsTransform: Transform = { + name: 'Import path rewrites', + id: 'imports', + apply(sourceFile: SourceFile, context: TransformContext): TransformResult { + const diagnostics: ReturnType[] = []; + let changesCount = 0; + + const sdkImports = getSdkImports(sourceFile); + const sdkExports = getSdkExports(sourceFile); + if (sdkImports.length === 0 && sdkExports.length === 0) { + return { changesCount: 0, diagnostics: [] }; + } + + const filePath = sourceFile.getFilePath(); + + changesCount += rewriteExportDeclarations(sdkExports, sourceFile, filePath, context, diagnostics); + + if (sdkImports.length === 0) { + return { changesCount, diagnostics }; + } + + const hasClientImport = sdkImports.some(imp => { + const spec = imp.getModuleSpecifierValue(); + return spec.includes('/client/'); + }); + const hasServerImport = sdkImports.some(imp => { + const spec = imp.getModuleSpecifierValue(); + return spec.includes('/server/'); + }); + + const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); + + interface PendingImport { + names: string[]; + isTypeOnly: boolean; + } + const pendingImports = new Map(); + + function addPending(target: string, names: string[], isTypeOnly: boolean): void { + if (!pendingImports.has(target)) { + pendingImports.set(target, []); + } + pendingImports.get(target)!.push({ names, isTypeOnly }); + } + + for (const imp of sdkImports) { + const specifier = imp.getModuleSpecifierValue(); + const namedImports = imp.getNamedImports().map(n => n.getName()); + const typeOnly = isTypeOnlyImport(imp); + const line = imp.getStartLineNumber(); + + let mapping = IMPORT_MAP[specifier]; + + if (!mapping && isAuthImport(specifier)) { + mapping = { + target: '', + status: 'removed', + removalMessage: 'Server auth removed in v2. Use an external auth library (e.g., better-auth).' + }; + } + + if (!mapping) { + diagnostics.push(warning(filePath, line, `Unknown SDK import path: ${specifier}. Manual migration required.`)); + continue; + } + + if (mapping.status === 'removed') { + imp.remove(); + changesCount++; + diagnostics.push(warning(filePath, line, mapping.removalMessage ?? `Import removed: ${specifier}`)); + continue; + } + + let targetPackage = mapping.target; + if (targetPackage === 'RESOLVE_BY_CONTEXT') { + targetPackage = resolveTypesPackage(context, hasClientImport, hasServerImport); + } + + let resolvedNames = namedImports; + if (mapping.renamedSymbols) { + resolvedNames = namedImports.map(name => mapping.renamedSymbols?.[name] ?? name); + } + + addPending(targetPackage, resolvedNames, typeOnly); + imp.remove(); + changesCount++; + } + + for (const [target, groups] of pendingImports) { + const typeOnlyNames = new Set(); + const valueNames = new Set(); + for (const group of groups) { + for (const name of group.names) { + if (group.isTypeOnly) { + typeOnlyNames.add(name); + } else { + valueNames.add(name); + } + } + } + + if (valueNames.size > 0) { + addOrMergeImport(sourceFile, target, [...valueNames], false, insertIndex); + } + if (typeOnlyNames.size > 0) { + const typeInsertIndex = valueNames.size > 0 ? insertIndex + 1 : insertIndex; + addOrMergeImport(sourceFile, target, [...typeOnlyNames], true, typeInsertIndex); + } + } + + return { changesCount, diagnostics }; + } +}; + +function rewriteExportDeclarations( + sdkExports: import('ts-morph').ExportDeclaration[], + sourceFile: import('ts-morph').SourceFile, + filePath: string, + context: TransformContext, + diagnostics: ReturnType[] +): number { + let changesCount = 0; + + for (const exp of sdkExports) { + const specifier = exp.getModuleSpecifierValue(); + if (!specifier) continue; + + const line = exp.getStartLineNumber(); + let mapping = IMPORT_MAP[specifier]; + + if (!mapping && isAuthImport(specifier)) { + mapping = { + target: '', + status: 'removed', + removalMessage: 'Server auth removed in v2. Use an external auth library (e.g., better-auth).' + }; + } + + if (!mapping) { + diagnostics.push(warning(filePath, line, `Unknown SDK export path: ${specifier}. Manual migration required.`)); + continue; + } + + if (mapping.status === 'removed') { + exp.remove(); + changesCount++; + diagnostics.push(warning(filePath, line, mapping.removalMessage ?? `Export removed: ${specifier}`)); + continue; + } + + let targetPackage = mapping.target; + if (targetPackage === 'RESOLVE_BY_CONTEXT') { + const hasClientImport = sourceFile.getImportDeclarations().some(imp => { + const spec = imp.getModuleSpecifierValue(); + return spec.includes('/client/') || spec === '@modelcontextprotocol/client'; + }); + const hasServerImport = sourceFile.getImportDeclarations().some(imp => { + const spec = imp.getModuleSpecifierValue(); + return spec.includes('/server/') || spec === '@modelcontextprotocol/server'; + }); + targetPackage = resolveTypesPackage(context, hasClientImport, hasServerImport); + } + + exp.setModuleSpecifier(targetPackage); + changesCount++; + } + + return changesCount; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts new file mode 100644 index 000000000..f2297e50f --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts @@ -0,0 +1,36 @@ +import type { Transform } from '../../../types.js'; +import { contextTypesTransform } from './contextTypes.js'; +import { handlerRegistrationTransform } from './handlerRegistration.js'; +import { importPathsTransform } from './importPaths.js'; +import { mcpServerApiTransform } from './mcpServerApi.js'; +import { mockPathsTransform } from './mockPaths.js'; +import { schemaParamRemovalTransform } from './schemaParamRemoval.js'; +import { symbolRenamesTransform } from './symbolRenames.js'; + +// Ordering matters — do not reorder without understanding dependencies: +// +// 1. importPaths MUST run first: rewrites import specifiers from v1 paths +// (e.g., @modelcontextprotocol/sdk/types.js) to v2 packages. Later +// transforms depend on the rewritten import declarations. +// +// 2. symbolRenames runs early: renames imported symbols (e.g., McpError → +// ProtocolError) that later transforms may reference. +// +// 3. mcpServerApi SHOULD run before contextTypes: it rewrites .tool() etc. +// to .registerTool() etc. contextTypes handles both old and new names, +// but running mcpServerApi first ensures consistent argument structure. +// +// 4. handlerRegistration and schemaParamRemoval are independent of each +// other but both depend on importPaths having run. +// +// 5. mockPaths runs last: handles test mocks and dynamic imports, +// independent of the other transforms. +export const v1ToV2Transforms: Transform[] = [ + importPathsTransform, + symbolRenamesTransform, + mcpServerApiTransform, + handlerRegistrationTransform, + schemaParamRemovalTransform, + contextTypesTransform, + mockPathsTransform +]; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts new file mode 100644 index 000000000..f948440de --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -0,0 +1,282 @@ +import type { CallExpression, SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { info, warning } from '../../../utils/diagnostics.js'; +import { hasMcpImports } from '../../../utils/importUtils.js'; + +export const mcpServerApiTransform: Transform = { + name: 'McpServer API migration', + id: 'mcpserver-api', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + const diagnostics: Diagnostic[] = []; + let changesCount = 0; + + if (!hasMcpImports(sourceFile)) { + return { changesCount: 0, diagnostics: [] }; + } + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + const toolCalls: CallExpression[] = []; + const promptCalls: CallExpression[] = []; + const resourceCalls: CallExpression[] = []; + + for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) continue; + const methodName = expr.getName(); + + switch (methodName) { + case 'tool': { + toolCalls.push(call); + break; + } + case 'prompt': { + promptCalls.push(call); + break; + } + case 'resource': { + { + resourceCalls.push(call); + // No default + } + break; + } + } + } + + for (const call of toolCalls) { + const result = migrateToolCall(call, sourceFile, diagnostics); + if (result) { + changesCount++; + } else { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + 'Could not automatically migrate .tool() call. Manual migration required.' + ) + ); + } + } + + for (const call of promptCalls) { + const result = migratePromptCall(call, sourceFile, diagnostics); + if (result) { + changesCount++; + } else { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + 'Could not automatically migrate .prompt() call. Manual migration required.' + ) + ); + } + } + + for (const call of resourceCalls) { + const result = migrateResourceCall(call, sourceFile); + if (result) { + changesCount++; + } else { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + 'Could not automatically migrate .resource() call. Manual migration required.' + ) + ); + } + } + + return { changesCount, diagnostics }; + } +}; + +function isStringArg(node: Node): boolean { + return Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node); +} + +function wrapWithZObject(schemaText: string): string { + if (schemaText.startsWith('z.object(')) return schemaText; + return `z.object(${schemaText})`; +} + +function maybeWrapSchema(node: Node): string { + const text = node.getText(); + if (Node.isObjectLiteralExpression(node)) { + return wrapWithZObject(text); + } + return text; +} + +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.' + ) + ); + } +} + +function migrateToolCall(call: CallExpression, sourceFile: SourceFile, diagnostics: Diagnostic[]): boolean { + const args = call.getArguments(); + if (args.length < 2) return false; + + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) return false; + + const nameArg = args[0]!; + if (!isStringArg(nameArg)) return false; + const nameText = nameArg.getText(); + + let description: string | undefined; + let schema: string | undefined; + let callbackText: string | undefined; + + switch (args.length) { + case 2: { + // server.tool(name, callback) + callbackText = args[1]!.getText(); + + break; + } + case 3: { + const arg1 = args[1]!; + if (isStringArg(arg1)) { + // server.tool(name, description, callback) + description = arg1.getText(); + callbackText = args[2]!.getText(); + } else { + // server.tool(name, schema, callback) + emitWrapDiagnostic(arg1, sourceFile, call, diagnostics); + schema = maybeWrapSchema(arg1); + callbackText = args[2]!.getText(); + } + + break; + } + case 4: { + // server.tool(name, description, schema, callback) + description = args[1]!.getText(); + emitWrapDiagnostic(args[2]!, sourceFile, call, diagnostics); + schema = maybeWrapSchema(args[2]!); + callbackText = args[3]!.getText(); + + break; + } + default: { + return false; + } + } + + const configParts: string[] = []; + if (description) configParts.push(`description: ${description}`); + if (schema) configParts.push(`inputSchema: ${schema}`); + const configObj = configParts.length > 0 ? `{ ${configParts.join(', ')} }` : '{}'; + + expr.getNameNode().replaceWithText('registerTool'); + for (let i = args.length - 1; i >= 0; i--) { + call.removeArgument(i); + } + call.addArguments([nameText, configObj, callbackText!]); + + return true; +} + +function migratePromptCall(call: CallExpression, sourceFile: SourceFile, diagnostics: Diagnostic[]): boolean { + const args = call.getArguments(); + if (args.length < 2) return false; + + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) return false; + + const nameArg = args[0]!; + if (!isStringArg(nameArg)) return false; + const nameText = nameArg.getText(); + + let description: string | undefined; + let schema: string | undefined; + let callbackText: string | undefined; + + switch (args.length) { + case 2: { + callbackText = args[1]!.getText(); + + break; + } + case 3: { + const arg1 = args[1]!; + if (isStringArg(arg1)) { + description = arg1.getText(); + callbackText = args[2]!.getText(); + } else { + emitWrapDiagnostic(arg1, sourceFile, call, diagnostics); + schema = maybeWrapSchema(arg1); + callbackText = args[2]!.getText(); + } + + break; + } + case 4: { + description = args[1]!.getText(); + emitWrapDiagnostic(args[2]!, sourceFile, call, diagnostics); + schema = maybeWrapSchema(args[2]!); + callbackText = args[3]!.getText(); + + break; + } + default: { + return false; + } + } + + const configParts: string[] = []; + if (description) configParts.push(`description: ${description}`); + if (schema) configParts.push(`argsSchema: ${schema}`); + const configObj = configParts.length > 0 ? `{ ${configParts.join(', ')} }` : '{}'; + + expr.getNameNode().replaceWithText('registerPrompt'); + for (let i = args.length - 1; i >= 0; i--) { + call.removeArgument(i); + } + call.addArguments([nameText, configObj, callbackText!]); + + return true; +} + +function migrateResourceCall(call: CallExpression, _sourceFile: SourceFile): boolean { + const args = call.getArguments(); + if (args.length < 3) return false; + + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) return false; + + const nameArg = args[0]!; + if (!isStringArg(nameArg)) return false; + const nameText = nameArg.getText(); + + const uriArg = args[1]!; + const uriText = uriArg.getText(); + + expr.getNameNode().replaceWithText('registerResource'); + + if (args.length === 3) { + // server.resource(name, uri, callback) → server.registerResource(name, uri, {}, callback) + const callbackText = args[2]!.getText(); + for (let i = args.length - 1; i >= 0; i--) { + call.removeArgument(i); + } + call.addArguments([nameText, uriText, '{}', callbackText]); + } else if (args.length === 4) { + // server.resource(name, uri, metadata, callback) → server.registerResource(name, uri, metadata, callback) + // Already has metadata, just rename the method + } + + 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 new file mode 100644 index 000000000..e29310b8f --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -0,0 +1,187 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Transform, TransformContext, TransformResult } from '../../../types.js'; +import { warning } from '../../../utils/diagnostics.js'; +import { isSdkSpecifier } from '../../../utils/importUtils.js'; +import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; +import { IMPORT_MAP, isAuthImport } from '../mappings/importMap.js'; + +const MOCK_METHODS = new Set(['mock', 'doMock']); +const MOCK_CALLERS = new Set(['vi', 'jest']); + +export const mockPathsTransform: Transform = { + name: 'Mock and dynamic import path rewrites', + id: 'mock-paths', + apply(sourceFile: SourceFile, context: TransformContext): TransformResult { + const diagnostics: ReturnType[] = []; + let changesCount = 0; + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const expr = call.getExpression(); + + if (Node.isPropertyAccessExpression(expr)) { + const objName = expr.getExpression().getText(); + const methodName = expr.getName(); + if (MOCK_CALLERS.has(objName) && MOCK_METHODS.has(methodName)) { + changesCount += rewriteMockCall(call, sourceFile, context, diagnostics); + } + } + + if (Node.isImportExpression(expr) || call.getExpression().getText() === 'import') { + continue; + } + } + + changesCount += rewriteDynamicImports(sourceFile, context, diagnostics); + + return { changesCount, diagnostics }; + } +}; + +function resolveTarget( + specifier: string, + context: TransformContext, + sourceFile: SourceFile +): { target: string; renamedSymbols?: Record } | 'removed' | null { + const mapping = IMPORT_MAP[specifier]; + if (!mapping && isAuthImport(specifier)) return 'removed'; + if (!mapping) return null; + if (mapping.status === 'removed') return 'removed'; + + let target = mapping.target; + if (target === 'RESOLVE_BY_CONTEXT') { + const hasClient = sourceFile.getImportDeclarations().some(i => { + const s = i.getModuleSpecifierValue(); + return s.includes('/client/') || s === '@modelcontextprotocol/client'; + }); + const hasServer = sourceFile.getImportDeclarations().some(i => { + const s = i.getModuleSpecifierValue(); + return s.includes('/server/') || s === '@modelcontextprotocol/server'; + }); + target = resolveTypesPackage(context, hasClient, hasServer); + } + + return { target, renamedSymbols: mapping.renamedSymbols }; +} + +function rewriteMockCall( + call: import('ts-morph').CallExpression, + sourceFile: SourceFile, + context: TransformContext, + diagnostics: ReturnType[] +): number { + const args = call.getArguments(); + if (args.length === 0) return 0; + + const firstArg = args[0]!; + if (!Node.isStringLiteral(firstArg)) return 0; + + const specifier = firstArg.getLiteralValue(); + if (!isSdkSpecifier(specifier)) return 0; + + const resolved = resolveTarget(specifier, context, sourceFile); + if (resolved === null) { + diagnostics.push( + warning(sourceFile.getFilePath(), call.getStartLineNumber(), `Unknown SDK mock path: ${specifier}. Manual migration required.`) + ); + return 0; + } + if (resolved === 'removed') { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + `Mock references removed SDK path: ${specifier}. Manual migration required.` + ) + ); + return 0; + } + + let changes = 0; + + firstArg.setLiteralValue(resolved.target); + changes++; + + if (resolved.renamedSymbols && args.length >= 2) { + changes += renameSymbolsInFactory(args[1]!, resolved.renamedSymbols); + } + + return changes; +} + +function renameSymbolsInFactory(factoryArg: import('ts-morph').Node, renamedSymbols: Record): number { + let changes = 0; + + factoryArg.forEachDescendant(node => { + if (Node.isPropertyAssignment(node)) { + const name = node.getName(); + const newName = renamedSymbols[name]; + if (newName) { + node.getNameNode().replaceWithText(newName); + changes++; + } + } + + if (Node.isShorthandPropertyAssignment(node)) { + const name = node.getName(); + const newName = renamedSymbols[name]; + if (newName) { + node.replaceWithText(`${newName}: ${name}`); + changes++; + } + } + }); + + return changes; +} + +function rewriteDynamicImports(sourceFile: SourceFile, context: TransformContext, _diagnostics: ReturnType[]): number { + let changes = 0; + + sourceFile.forEachDescendant(node => { + if (!Node.isCallExpression(node)) return; + + const expr = node.getExpression(); + if (expr.getKind() !== SyntaxKind.ImportKeyword) return; + + const args = node.getArguments(); + if (args.length === 0) return; + + const firstArg = args[0]!; + if (!Node.isStringLiteral(firstArg)) return; + + const specifier = firstArg.getLiteralValue(); + if (!isSdkSpecifier(specifier)) return; + + const resolved = resolveTarget(specifier, context, sourceFile); + if (resolved === null || resolved === 'removed') return; + + firstArg.setLiteralValue(resolved.target); + changes++; + + if (resolved.renamedSymbols) { + const parent = node.getParent(); + if (parent && Node.isAwaitExpression(parent)) { + const grandParent = parent.getParent(); + if (grandParent && Node.isVariableDeclaration(grandParent)) { + const nameNode = grandParent.getNameNode(); + if (Node.isObjectBindingPattern(nameNode)) { + for (const element of nameNode.getElements()) { + const bindingName = element.getName(); + const newName = resolved.renamedSymbols[bindingName]; + if (newName) { + element.getNameNode().replaceWithText(newName); + changes++; + } + } + } + } + } + } + }); + + return changes; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts new file mode 100644 index 000000000..f03d6c2a6 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts @@ -0,0 +1,47 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Transform, TransformContext, TransformResult } from '../../../types.js'; +import { isImportedFromMcp, removeUnusedImport } from '../../../utils/importUtils.js'; + +const TARGET_METHODS = new Set(['request', 'callTool', 'send']); + +export const schemaParamRemovalTransform: Transform = { + name: 'Schema parameter removal', + id: 'schema-params', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + let changesCount = 0; + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) continue; + + const methodName = expr.getName(); + if (!TARGET_METHODS.has(methodName)) continue; + + const args = call.getArguments(); + if (args.length < 2) continue; + + const secondArg = args[1]!; + if (!isSchemaReference(secondArg)) continue; + + const schemaName = secondArg.getText(); + if (!isImportedFromMcp(sourceFile, schemaName)) continue; + + call.removeArgument(1); + changesCount++; + + removeUnusedImport(sourceFile, schemaName); + } + + return { changesCount, diagnostics: [] }; + } +}; + +function isSchemaReference(node: Node): boolean { + if (!Node.isIdentifier(node)) return false; + const text = node.getText(); + return text.endsWith('Schema') || text.endsWith('ResultSchema'); +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts new file mode 100644 index 000000000..beef5eafe --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts @@ -0,0 +1,212 @@ +import type { SourceFile } from 'ts-morph'; +import { Node } from 'ts-morph'; + +import type { Transform, TransformContext, TransformResult } from '../../../types.js'; +import { warning } from '../../../utils/diagnostics.js'; +import { ERROR_CODE_SDK_MEMBERS, SIMPLE_RENAMES } from '../mappings/symbolMap.js'; + +const SERVER_GENERIC_ARGS = new Set(['ServerRequest', 'ServerNotification']); +const CLIENT_GENERIC_ARGS = new Set(['ClientRequest', 'ClientNotification']); + +export const symbolRenamesTransform: Transform = { + name: 'Symbol renames', + id: 'symbols', + apply(sourceFile: SourceFile, context: TransformContext): TransformResult { + const diagnostics: ReturnType[] = []; + let changesCount = 0; + + const imports = sourceFile.getImportDeclarations(); + + for (const imp of imports) { + for (const namedImport of imp.getNamedImports()) { + const name = namedImport.getName(); + const newName = SIMPLE_RENAMES[name]; + if (newName) { + namedImport.setName(newName); + const alias = namedImport.getAliasNode(); + if (!alias) { + renameAllReferences(sourceFile, name, newName); + } + changesCount++; + } + } + } + + changesCount += handleErrorCodeSplit(sourceFile, diagnostics); + changesCount += handleRequestHandlerExtra(sourceFile, context, diagnostics); + + return { changesCount, diagnostics }; + } +}; + +function renameAllReferences(sourceFile: SourceFile, oldName: string, newName: string): void { + sourceFile.forEachDescendant(node => { + if (Node.isIdentifier(node) && node.getText() === oldName) { + const parent = node.getParent(); + if (!parent) return; + if (Node.isImportSpecifier(parent)) return; + if (Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return; + if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return; + if (Node.isPropertySignature(parent) && parent.getNameNode() === node) return; + if (Node.isMethodDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isPropertyDeclaration(parent) && parent.getNameNode() === node) return; + node.replaceWithText(newName); + } + }); +} + +function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: ReturnType[]): number { + let changesCount = 0; + + const imports = sourceFile.getImportDeclarations(); + let errorCodeImport: ReturnType<(typeof imports)[0]['getNamedImports']>[0] | undefined; + + for (const imp of imports) { + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'ErrorCode') { + errorCodeImport = namedImport; + break; + } + } + if (errorCodeImport) break; + } + + if (!errorCodeImport) return 0; + + let needsProtocolErrorCode = false; + let needsSdkErrorCode = false; + + sourceFile.forEachDescendant(node => { + if (!Node.isPropertyAccessExpression(node)) return; + const expr = node.getExpression(); + if (!Node.isIdentifier(expr) || expr.getText() !== 'ErrorCode') 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'); + } + changesCount++; + }); + + if (changesCount > 0) { + errorCodeImport.remove(); + + const imp = sourceFile.getImportDeclarations().find(i => { + const spec = i.getModuleSpecifierValue(); + return spec === '@modelcontextprotocol/client' || spec === '@modelcontextprotocol/server'; + }); + const targetModule = imp?.getModuleSpecifierValue() ?? '@modelcontextprotocol/server'; + + const newImports: string[] = []; + if (needsProtocolErrorCode) newImports.push('ProtocolErrorCode'); + if (needsSdkErrorCode) newImports.push('SdkErrorCode'); + + if (newImports.length > 0) { + const existingImp = sourceFile + .getImportDeclarations() + .find(i => i.getModuleSpecifierValue() === targetModule && !i.isTypeOnly()); + if (existingImp) { + const existingNames = new Set(existingImp.getNamedImports().map(n => n.getName())); + const toAdd = newImports.filter(n => !existingNames.has(n)); + if (toAdd.length > 0) { + existingImp.addNamedImports(toAdd); + } + } else { + sourceFile.addImportDeclaration({ + moduleSpecifier: targetModule, + namedImports: newImports + }); + } + } + + diagnostics.push( + warning( + sourceFile.getFilePath(), + 1, + 'ErrorCode split into ProtocolErrorCode and SdkErrorCode. Verify the migration is correct.' + ) + ); + } + + return changesCount; +} + +function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformContext, diagnostics: ReturnType[]): number { + let changesCount = 0; + + const imports = sourceFile.getImportDeclarations(); + let extraImport: ReturnType<(typeof imports)[0]['getNamedImports']>[0] | undefined; + let extraImportDecl: (typeof imports)[0] | undefined; + + for (const imp of imports) { + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'RequestHandlerExtra') { + extraImport = namedImport; + extraImportDecl = imp; + break; + } + } + if (extraImport) break; + } + + if (!extraImport) return 0; + + const isClientFile = sourceFile.getImportDeclarations().some(i => { + const spec = i.getModuleSpecifierValue(); + return spec.includes('/client/') || spec === '@modelcontextprotocol/client'; + }); + const isServerFile = sourceFile.getImportDeclarations().some(i => { + const spec = i.getModuleSpecifierValue(); + return spec.includes('/server/') || spec === '@modelcontextprotocol/server'; + }); + + let defaultTarget: 'ServerContext' | 'ClientContext' = 'ServerContext'; + if (isClientFile && !isServerFile) { + defaultTarget = 'ClientContext'; + } else if (context.projectType === 'client') { + defaultTarget = 'ClientContext'; + } + + sourceFile.forEachDescendant(node => { + if (!Node.isTypeReference(node)) return; + const typeName = node.getTypeName(); + if (!Node.isIdentifier(typeName) || typeName.getText() !== 'RequestHandlerExtra') return; + + let target = defaultTarget; + const typeArgs = node.getTypeArguments(); + if (typeArgs.length > 0) { + const firstArgText = typeArgs[0]!.getText(); + if (SERVER_GENERIC_ARGS.has(firstArgText)) { + target = 'ServerContext'; + } else if (CLIENT_GENERIC_ARGS.has(firstArgText)) { + target = 'ClientContext'; + } + } + + if (typeArgs.length > 0) { + node.replaceWithText(target); + } else { + typeName.replaceWithText(target); + } + changesCount++; + }); + + if (changesCount > 0) { + extraImport.setName(defaultTarget); + changesCount++; + + diagnostics.push( + warning( + sourceFile.getFilePath(), + extraImportDecl!.getStartLineNumber(), + `RequestHandlerExtra renamed to ${defaultTarget}. Generic type arguments removed. Verify the migration is correct.` + ) + ); + } + + return changesCount; +} diff --git a/packages/codemod/src/runner.ts b/packages/codemod/src/runner.ts new file mode 100644 index 000000000..6f45f1c2a --- /dev/null +++ b/packages/codemod/src/runner.ts @@ -0,0 +1,90 @@ +import { Project } from 'ts-morph'; + +import type { Diagnostic, FileResult, Migration, RunnerOptions, RunnerResult } from './types.js'; +import { error } from './utils/diagnostics.js'; +import { analyzeProject } from './utils/projectAnalyzer.js'; + +export function run(migration: Migration, options: RunnerOptions): RunnerResult { + const context = analyzeProject(options.targetDir); + + const enabledTransforms = options.transforms + ? migration.transforms.filter(t => options.transforms!.includes(t.id)) + : migration.transforms; + + const project = new Project({ + tsConfigFilePath: undefined, + skipAddingFilesFromTsConfig: true, + compilerOptions: { + allowJs: true, + noEmit: true + } + }); + + const globPattern = `${options.targetDir}/**/*.{ts,tsx,mts}`; + const ignorePatterns = [ + '**/node_modules/**', + '**/dist/**', + '**/.git/**', + '**/build/**', + '**/.next/**', + '**/.nuxt/**', + '**/coverage/**', + '**/__generated__/**', + ...(options.ignore ?? []) + ]; + + const allPatterns = [globPattern]; + for (const ignore of ignorePatterns) { + allPatterns.push(`!${ignore}`); + } + project.addSourceFilesAtPaths(allPatterns); + + const sourceFiles = project.getSourceFiles().filter(sf => { + const fp = sf.getFilePath(); + return !fp.includes('/node_modules/') && !fp.includes('/dist/'); + }); + const fileResults: FileResult[] = []; + const allDiagnostics: Diagnostic[] = []; + let totalChanges = 0; + let filesChanged = 0; + + for (const sourceFile of sourceFiles) { + let fileChanges = 0; + const fileDiagnostics: Diagnostic[] = []; + + try { + for (const transform of enabledTransforms) { + const result = transform.apply(sourceFile, context); + fileChanges += result.changesCount; + fileDiagnostics.push(...result.diagnostics); + } + } catch (error_) { + const filePath = sourceFile.getFilePath(); + fileDiagnostics.push(error(filePath, 1, `Transform failed: ${error_ instanceof Error ? error_.message : String(error_)}`)); + } + + if (fileChanges > 0 || fileDiagnostics.length > 0) { + if (fileChanges > 0) { + filesChanged++; + totalChanges += fileChanges; + } + fileResults.push({ + filePath: sourceFile.getFilePath(), + changes: fileChanges, + diagnostics: fileDiagnostics + }); + allDiagnostics.push(...fileDiagnostics); + } + } + + if (!options.dryRun) { + project.saveSync(); + } + + return { + filesChanged, + totalChanges, + diagnostics: allDiagnostics, + fileResults + }; +} diff --git a/packages/codemod/src/types.ts b/packages/codemod/src/types.ts new file mode 100644 index 000000000..771cc8f68 --- /dev/null +++ b/packages/codemod/src/types.ts @@ -0,0 +1,56 @@ +import type { SourceFile } from 'ts-morph'; + +export enum DiagnosticLevel { + Error = 'error', + Warning = 'warning', + Info = 'info' +} + +export interface Diagnostic { + level: DiagnosticLevel; + file: string; + line: number; + message: string; +} + +export interface TransformResult { + changesCount: number; + diagnostics: Diagnostic[]; +} + +export interface Transform { + name: string; + id: string; + apply(sourceFile: SourceFile, context: TransformContext): TransformResult; +} + +export interface TransformContext { + projectType: 'client' | 'server' | 'both' | 'unknown'; +} + +export interface Migration { + name: string; + description: string; + transforms: Transform[]; +} + +export interface RunnerOptions { + targetDir: string; + dryRun?: boolean; + verbose?: boolean; + transforms?: string[]; + ignore?: string[]; +} + +export interface FileResult { + filePath: string; + changes: number; + diagnostics: Diagnostic[]; +} + +export interface RunnerResult { + filesChanged: number; + totalChanges: number; + diagnostics: Diagnostic[]; + fileResults: FileResult[]; +} diff --git a/packages/codemod/src/utils/diagnostics.ts b/packages/codemod/src/utils/diagnostics.ts new file mode 100644 index 000000000..31026ed95 --- /dev/null +++ b/packages/codemod/src/utils/diagnostics.ts @@ -0,0 +1,24 @@ +import type { Diagnostic } from '../types.js'; +import { DiagnosticLevel } from '../types.js'; + +export function error(file: string, line: number, message: string): Diagnostic { + return { level: DiagnosticLevel.Error, file, line, message }; +} + +export function warning(file: string, line: number, message: string): Diagnostic { + return { level: DiagnosticLevel.Warning, file, line, message }; +} + +export function info(file: string, line: number, message: string): Diagnostic { + return { level: DiagnosticLevel.Info, file, line, message }; +} + +const LEVEL_PREFIX: Record = { + [DiagnosticLevel.Error]: 'ERROR', + [DiagnosticLevel.Warning]: 'WARNING', + [DiagnosticLevel.Info]: 'INFO' +}; + +export function formatDiagnostic(d: Diagnostic): string { + return ` ${d.file}:${d.line} - [${LEVEL_PREFIX[d.level]}] ${d.message}`; +} diff --git a/packages/codemod/src/utils/importUtils.ts b/packages/codemod/src/utils/importUtils.ts new file mode 100644 index 000000000..3e8f9d465 --- /dev/null +++ b/packages/codemod/src/utils/importUtils.ts @@ -0,0 +1,106 @@ +import type { ExportDeclaration, ImportDeclaration, SourceFile } from 'ts-morph'; +import { Node } from 'ts-morph'; + +const SDK_PREFIX = '@modelcontextprotocol/sdk'; + +const V2_PACKAGES = new Set([ + '@modelcontextprotocol/client', + '@modelcontextprotocol/server', + '@modelcontextprotocol/core', + '@modelcontextprotocol/node', + '@modelcontextprotocol/express' +]); + +export function isSdkSpecifier(specifier: string): boolean { + return specifier === SDK_PREFIX || specifier.startsWith(SDK_PREFIX + '/'); +} + +export function getSdkImports(sourceFile: SourceFile): ImportDeclaration[] { + return sourceFile.getImportDeclarations().filter(imp => { + return isSdkSpecifier(imp.getModuleSpecifierValue()); + }); +} + +export function getSdkExports(sourceFile: SourceFile): ExportDeclaration[] { + return sourceFile.getExportDeclarations().filter(exp => { + const specifier = exp.getModuleSpecifierValue(); + return specifier != null && isSdkSpecifier(specifier); + }); +} + +export function getNamedImportNames(imp: ImportDeclaration): string[] { + return imp.getNamedImports().map(n => n.getName()); +} + +export function isTypeOnlyImport(imp: ImportDeclaration): boolean { + return imp.isTypeOnly(); +} + +export function addOrMergeImport( + sourceFile: SourceFile, + moduleSpecifier: string, + namedImports: string[], + isTypeOnly: boolean, + insertIndex: number +): void { + if (namedImports.length === 0) return; + + const existing = sourceFile.getImportDeclarations().find(imp => { + return imp.getModuleSpecifierValue() === moduleSpecifier && imp.isTypeOnly() === isTypeOnly; + }); + + if (existing) { + const existingNames = new Set(existing.getNamedImports().map(n => n.getName())); + const newNames = namedImports.filter(n => !existingNames.has(n)); + if (newNames.length > 0) { + existing.addNamedImports(newNames); + } + } else { + sourceFile.insertImportDeclaration(insertIndex, { + moduleSpecifier, + namedImports: [...new Set(namedImports)], + isTypeOnly + }); + } +} + +export function isAnyMcpSpecifier(specifier: string): boolean { + return isSdkSpecifier(specifier) || V2_PACKAGES.has(specifier); +} + +export function hasMcpImports(sourceFile: SourceFile): boolean { + return sourceFile.getImportDeclarations().some(imp => isAnyMcpSpecifier(imp.getModuleSpecifierValue())); +} + +export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): boolean { + return sourceFile.getImportDeclarations().some(imp => { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false; + return imp.getNamedImports().some(n => n.getName() === symbolName); + }); +} + +export function removeUnusedImport(sourceFile: SourceFile, symbolName: string): void { + let referenceCount = 0; + sourceFile.forEachDescendant(node => { + if (Node.isIdentifier(node) && node.getText() === symbolName) { + const parent = node.getParent(); + if (parent && !Node.isImportSpecifier(parent)) { + referenceCount++; + } + } + }); + + if (referenceCount === 0) { + for (const imp of sourceFile.getImportDeclarations()) { + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === symbolName) { + namedImport.remove(); + if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport()) { + imp.remove(); + } + return; + } + } + } + } +} diff --git a/packages/codemod/src/utils/projectAnalyzer.ts b/packages/codemod/src/utils/projectAnalyzer.ts new file mode 100644 index 000000000..6fbc7d360 --- /dev/null +++ b/packages/codemod/src/utils/projectAnalyzer.ts @@ -0,0 +1,45 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { TransformContext } from '../types.js'; + +export function analyzeProject(targetDir: string): TransformContext { + const pkgJsonPath = path.join(targetDir, 'package.json'); + if (!existsSync(pkgJsonPath)) { + return { projectType: 'unknown' }; + } + + try { + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); + const allDeps = { + ...pkgJson.dependencies, + ...pkgJson.devDependencies + }; + + const hasClient = '@modelcontextprotocol/client' in allDeps; + const hasServer = '@modelcontextprotocol/server' in allDeps; + + if (hasClient && hasServer) return { projectType: 'both' }; + if (hasClient) return { projectType: 'client' }; + if (hasServer) return { projectType: 'server' }; + return { projectType: 'unknown' }; + } catch { + return { projectType: 'unknown' }; + } +} + +export function resolveTypesPackage(context: TransformContext, fileHasClientImports: boolean, fileHasServerImports: boolean): string { + if (fileHasClientImports && !fileHasServerImports) { + return '@modelcontextprotocol/client'; + } + if (fileHasServerImports && !fileHasClientImports) { + return '@modelcontextprotocol/server'; + } + if (context.projectType === 'client') { + return '@modelcontextprotocol/client'; + } + if (context.projectType === 'server') { + return '@modelcontextprotocol/server'; + } + return '@modelcontextprotocol/server'; +} diff --git a/packages/codemod/test/integration.test.ts b/packages/codemod/test/integration.test.ts new file mode 100644 index 000000000..f2f5c4a81 --- /dev/null +++ b/packages/codemod/test/integration.test.ts @@ -0,0 +1,204 @@ +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { getMigration } from '../src/migrations/index.js'; +import { run } from '../src/runner.js'; +import { DiagnosticLevel } from '../src/types.js'; + +const migration = getMigration('v1-to-v2')!; + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-test-')); + return tempDir; +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('integration', () => { + it('applies all transforms to a realistic v1 file', () => { + const dir = createTempDir(); + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { CallToolRequestSchema, ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, + `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + ``, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + ``, + `server.tool('greet', 'Say hello', { name: z.string() }, async ({ name }, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + ``, + `server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {`, + ` const id = extra.requestId;`, + ` return { content: [] };`, + `});`, + ``, + `const code = ErrorCode.InvalidParams;`, + `const timeout = ErrorCode.RequestTimeout;`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(1); + expect(result.totalChanges).toBeGreaterThan(0); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + + // Import paths rewritten + expect(output).toContain('@modelcontextprotocol/server'); + expect(output).toContain('@modelcontextprotocol/node'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + + // Symbol renames + expect(output).toContain('NodeStreamableHTTPServerTransport'); + expect(output).toContain('ProtocolErrorCode.InvalidParams'); + expect(output).toContain('SdkErrorCode.RequestTimeout'); + + // McpServer API migration + expect(output).toContain('registerTool'); + expect(output).not.toMatch(/server\.tool\(/); + + // Handler registration + expect(output).toContain("setRequestHandler('tools/call'"); + expect(output).not.toContain('CallToolRequestSchema'); + + // Context rewrites + expect(output).toContain('ctx.mcpReq.signal'); + expect(output).toContain('ctx.mcpReq.id'); + expect(output).not.toContain('extra'); + }); + + it('dry-run mode does not modify files', () => { + const dir = createTempDir(); + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `server.tool('ping', async () => ({ content: [] }));`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + const result = run(migration, { targetDir: dir, dryRun: true }); + + expect(result.totalChanges).toBeGreaterThan(0); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + expect(output).toBe(input); + }); + + it('skips files with no SDK imports', () => { + const dir = createTempDir(); + const input = `import express from 'express';\nconst app = express();\n`; + + writeFileSync(path.join(dir, 'app.ts'), input); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(0); + expect(result.totalChanges).toBe(0); + + const output = readFileSync(path.join(dir, 'app.ts'), 'utf8'); + expect(output).toBe(input); + }); + + it('processes multiple files independently', () => { + const dir = createTempDir(); + const serverFile = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `server.tool('ping', async () => ({ content: [] }));`, + `` + ].join('\n'); + const clientFile = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `const client = new Client({ name: 'test', version: '1.0' });`, + `` + ].join('\n'); + const plainFile = `const x = 1;\n`; + + mkdirSync(path.join(dir, 'src'), { recursive: true }); + writeFileSync(path.join(dir, 'src', 'server.ts'), serverFile); + writeFileSync(path.join(dir, 'src', 'client.ts'), clientFile); + writeFileSync(path.join(dir, 'src', 'utils.ts'), plainFile); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(2); + + const serverOutput = readFileSync(path.join(dir, 'src', 'server.ts'), 'utf8'); + expect(serverOutput).toContain('@modelcontextprotocol/server'); + + const clientOutput = readFileSync(path.join(dir, 'src', 'client.ts'), 'utf8'); + expect(clientOutput).toContain('@modelcontextprotocol/client'); + + const utilsOutput = readFileSync(path.join(dir, 'src', 'utils.ts'), 'utf8'); + expect(utilsOutput).toBe(plainFile); + }); + + it('recovers from transform errors and reports diagnostics', () => { + const dir = createTempDir(); + const validFile = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'valid.ts'), validFile); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBeGreaterThanOrEqual(1); + + const validOutput = readFileSync(path.join(dir, 'valid.ts'), 'utf8'); + expect(validOutput).toContain('@modelcontextprotocol/server'); + }); + + it('respects transform filter option', () => { + const dir = createTempDir(); + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `server.tool('ping', async () => ({ content: [] }));`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + const result = run(migration, { targetDir: dir, transforms: ['imports'] }); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + // Import paths should be rewritten + expect(output).toContain('@modelcontextprotocol/server'); + // But McpServer API should NOT be migrated (mcpserver-api transform was not selected) + expect(output).toContain("server.tool('ping'"); + // McpError should NOT be renamed (symbols transform was not selected) + expect(output).toContain('McpError'); + }); + + it('emits diagnostics for removed imports', () => { + const dir = createTempDir(); + const input = [ + `import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';`, + `const transport = new SSEServerTransport();`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + const result = run(migration, { targetDir: dir }); + + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics.some(d => d.level === DiagnosticLevel.Warning)).toBe(true); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts b/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts new file mode 100644 index 000000000..64b16d8a2 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { contextTypesTransform } from '../../../src/migrations/v1-to-v2/transforms/contextTypes.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + contextTypesTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('context-types transform', () => { + it('renames extra parameter to ctx in setRequestHandler', () => { + const input = [`server.setRequestHandler('tools/call', async (request, extra) => {`, ` return { content: [] };`, `});`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('(request, ctx)'); + expect(result).not.toContain('extra'); + }); + + it('rewrites extra.signal to ctx.mcpReq.signal', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.signal'); + }); + + it('rewrites extra.requestId to ctx.mcpReq.id', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const id = extra.requestId;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.id'); + }); + + it('rewrites extra.sendNotification to ctx.mcpReq.notify', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` await extra.sendNotification({ method: 'test', params: {} });`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.notify('); + }); + + it('rewrites extra.sendRequest to ctx.mcpReq.send', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` await extra.sendRequest({ method: 'test', params: {} });`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.send('); + }); + + it('rewrites extra.authInfo to ctx.http?.authInfo', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const auth = extra.authInfo;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.http?.authInfo'); + }); + + it('rewrites extra.taskStore to ctx.task?.store', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const store = extra.taskStore;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.task?.store'); + }); + + it('does not touch non-extra parameters', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, context) => {`, + ` const s = context.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('context.signal'); + expect(result).not.toContain('ctx'); + }); + + it('does not rewrite properties that are prefixes of other properties', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const s = extra.signal;`, + ` const h = extra.signalHandler;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.signal'); + expect(result).toContain('ctx.signalHandler'); + expect(result).not.toContain('ctx.mcpReq.signalHandler'); + }); + + it('emits warning when context parameter is destructured in body', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const { signal, authInfo } = extra;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Destructuring'); + }); + + it('works with registerTool callbacks', () => { + const input = [ + `server.registerTool('test', {}, async (args, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.signal'); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts new file mode 100644 index 000000000..9e9ce9e80 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { handlerRegistrationTransform } from '../../../src/migrations/v1-to-v2/transforms/handlerRegistration.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + handlerRegistrationTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('handler-registration transform', () => { + it('replaces CallToolRequestSchema with method string', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tools/call'"); + expect(result).not.toContain('CallToolRequestSchema'); + }); + + it('replaces notification schema with method string', () => { + const input = [ + `import { LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {`, + ` console.log(notification);`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setNotificationHandler('notifications/message'"); + expect(result).not.toContain('LoggingMessageNotificationSchema'); + }); + + it('removes unused schema import after replacement', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toContain('CallToolRequestSchema'); + }); + + it('keeps import if schema is referenced elsewhere', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + `console.log(CallToolRequestSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tools/call'"); + expect(result).toContain('import { CallToolRequestSchema }'); + }); + + it('is idempotent', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('handles multiple schema replacements in one file', () => { + const input = [ + `import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [] }));`, + `server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("'tools/call'"); + expect(result).toContain("'tools/list'"); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts new file mode 100644 index 000000000..790812233 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { importPathsTransform } from '../../../src/migrations/v1-to-v2/transforms/importPaths.js'; +import type { TransformContext } from '../../../src/types.js'; + +function applyTransform(code: string, context: TransformContext = { projectType: 'both' }): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + importPathsTransform.apply(sourceFile, context); + return sourceFile.getFullText(); +} + +describe('import-paths transform', () => { + it('rewrites client imports to @modelcontextprotocol/client', () => { + const input = `import { Client } from '@modelcontextprotocol/sdk/client/index.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/client"`); + expect(result).toContain('Client'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('rewrites server imports to @modelcontextprotocol/server', () => { + const input = `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/server"`); + expect(result).toContain('McpServer'); + }); + + it('consolidates multiple SDK imports to same v2 package', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('Client'); + expect(result).toContain('StreamableHTTPClientTransport'); + const importLines = result.split('\n').filter(l => l.includes('@modelcontextprotocol/client')); + expect(importLines.length).toBe(1); + }); + + it('rewrites server streamableHttp to @modelcontextprotocol/node with rename', () => { + const input = `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/node"`); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + expect(result).not.toMatch(/(? { + const input = `import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';\n`; + const ctx: TransformContext = { projectType: 'client' }; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, ctx); + expect(sourceFile.getFullText()).not.toContain('WebSocketClientTransport'); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('WebSocketClientTransport'); + }); + + it('removes SSE server import with warning', () => { + const input = `import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';\n`; + const ctx: TransformContext = { projectType: 'server' }; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('SSE server transport'); + }); + + it('resolves sdk/types.js based on sibling client imports', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'both' }); + expect(result).toContain(`from "@modelcontextprotocol/client"`); + expect(result).toContain('CallToolResultSchema'); + }); + + it('resolves sdk/types.js based on sibling server imports', () => { + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'both' }); + expect(result).toContain(`from "@modelcontextprotocol/server"`); + }); + + it('preserves type-only imports separately', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import type { Tool } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'client' }); + expect(result).toContain('import {'); + expect(result).toContain('import type {'); + }); + + it('is idempotent', () => { + const input = `import { Client } from '@modelcontextprotocol/sdk/client/index.js';\n`; + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('skips files with no SDK imports', () => { + const input = `import { something } from 'other-package';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'both' }); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toBe(input); + }); + + it('rewrites middleware import to @modelcontextprotocol/express', () => { + const input = `import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/express"`); + }); + + it('removes auth imports with warning', () => { + const input = `import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('auth removed'); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts new file mode 100644 index 000000000..1fbf7b9bc --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { mcpServerApiTransform } from '../../../src/migrations/v1-to-v2/transforms/mcpServerApi.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; +const MCP_IMPORT = `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n`; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + code); + mcpServerApiTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('mcp-server-api transform', () => { + it('converts .tool(name, callback) to .registerTool(name, {}, callback)', () => { + const input = [`server.tool('ping', async () => {`, ` return { content: [{ type: 'text', text: 'pong' }] };`, `});`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('registerTool'); + expect(result).toContain("'ping'"); + expect(result).toContain('{}'); + }); + + it('converts .tool(name, schema, callback) wrapping raw shape', () => { + const input = [ + `server.tool('greet', { name: z.string() }, async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerTool'); + expect(result).toContain('inputSchema: z.object({ name: z.string() })'); + }); + + it('converts .tool(name, description, schema, callback)', () => { + const input = [ + `server.tool('greet', 'Greet user', { name: z.string() }, async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerTool'); + expect(result).toContain("description: 'Greet user'"); + expect(result).toContain('inputSchema: z.object({ name: z.string() })'); + }); + + it('converts .prompt(name, schema, callback)', () => { + const input = [ + `server.prompt('summarize', { text: z.string() }, async ({ text }) => {`, + ` return { messages: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerPrompt'); + expect(result).toContain('argsSchema: z.object({ text: z.string() })'); + }); + + it('converts .resource(name, uri, callback) inserting empty metadata', () => { + const input = [ + `server.resource('config', 'config://app', async (uri) => {`, + ` return { contents: [{ uri: uri.href, text: '{}' }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerResource'); + expect(result).toContain('{}'); + }); + + it('does not modify .tool() calls in files without MCP imports', () => { + const input = [`import { someLib } from 'other-package';`, `someLib.tool('test', async () => {});`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toContain("someLib.tool('test'"); + expect(sourceFile.getFullText()).not.toContain('registerTool'); + }); + + it('does not wrap z.object() schemas', () => { + const input = [ + `server.tool('greet', z.object({ name: z.string() }), async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('inputSchema: z.object({ name: z.string() })'); + expect(result).not.toContain('z.object(z.object('); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts new file mode 100644 index 000000000..104c45c7a --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { mockPathsTransform } from '../../../src/migrations/v1-to-v2/transforms/mockPaths.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string, context: TransformContext = ctx): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + mockPathsTransform.apply(sourceFile, context); + return sourceFile.getFullText(); +} + +describe('mock-paths transform', () => { + describe('vi.doMock', () => { + it('rewrites SDK path in vi.doMock', () => { + const input = [ + `vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => ({`, + ` McpServer: mockMcpServerClass`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('renames symbols in vi.doMock factory for streamableHttp', () => { + const input = [ + `vi.doMock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({`, + ` StreamableHTTPServerTransport: mockTransport`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/node'`); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + expect(result).not.toContain(/(? { + const input = [ + `vi.doMock('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js', () => ({`, + ` WebStandardStreamableHTTPServerTransport: mockTransport`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + }); + + it('rewrites sdk/types.js path', () => { + const input = [ + `vi.doMock('@modelcontextprotocol/sdk/types.js', async importOriginal => {`, + ` const original = await importOriginal();`, + ` return { ...original, isInitializeRequest: mockFn };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + }); + + describe('vi.mock', () => { + it('rewrites SDK path in vi.mock', () => { + const input = [`vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({`, ` McpServer: vi.fn()`, `}));`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + }); + }); + + describe('jest.mock', () => { + it('rewrites SDK path in jest.mock', () => { + const input = [`jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({`, ` McpServer: jest.fn()`, `}));`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + }); + + it('rewrites SDK path in jest.doMock', () => { + const input = [ + `jest.doMock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({`, + ` StreamableHTTPServerTransport: jest.fn()`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/node'`); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + }); + }); + + describe('dynamic imports', () => { + it('rewrites dynamic import path', () => { + const input = [ + `const { StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('@modelcontextprotocol/node')`); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + }); + + it('rewrites dynamic import for server/mcp.js', () => { + const input = [`const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('@modelcontextprotocol/server')`); + expect(result).toContain('McpServer'); + }); + + it('does not touch non-SDK dynamic imports', () => { + const input = [`const { something } = await import('some-other-package');`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('some-other-package')`); + }); + }); + + describe('edge cases', () => { + it('skips non-SDK mock paths', () => { + const input = [`vi.doMock('some-other-package', () => ({ foo: vi.fn() }));`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('some-other-package'); + }); + + it('is idempotent', () => { + const input = [`vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => ({`, ` McpServer: mockClass`, `}));`, ''].join( + '\n' + ); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('emits warning for unknown SDK mock path', () => { + const input = [`vi.doMock('@modelcontextprotocol/sdk/unknown/path.js', () => ({}));`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Unknown SDK mock path'); + }); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts new file mode 100644 index 000000000..8ecd6cd46 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { schemaParamRemovalTransform } from '../../../src/migrations/v1-to-v2/transforms/schemaParamRemoval.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'client' }; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + schemaParamRemovalTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('schema-param-removal transform', () => { + it('removes schema from client.request()', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await client.request({ method: 'tools/call', params: {} }, CallToolResultSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("client.request({ method: 'tools/call', params: {} })"); + expect(result).not.toContain('CallToolResultSchema'); + }); + + it('removes schema from client.callTool()', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await client.callTool({ name: 'test', arguments: {} }, CallToolResultSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("client.callTool({ name: 'test', arguments: {} })"); + expect(result).not.toContain('CallToolResultSchema'); + }); + + it('removes schema from send() and shifts options arg', () => { + const input = [ + `import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await ctx.mcpReq.send({ method: 'sampling/createMessage', params: {} }, CreateMessageResultSchema, { timeout: 5000 });`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('send('); + expect(result).toContain('{ timeout: 5000 }'); + expect(result).not.toContain('CreateMessageResultSchema'); + }); + + it('does not remove non-schema arguments', () => { + const input = [`const result = await client.request({ method: 'tools/call' }, { timeout: 5000 });`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('{ timeout: 5000 }'); + }); + + it('does not remove custom schemas not imported from MCP', () => { + const input = [ + `import { MyCustomSchema } from './my-schemas';`, + `const result = await client.request(params, MyCustomSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('MyCustomSchema'); + }); + + it('is idempotent', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await client.request({ method: 'tools/call' }, CallToolResultSchema);`, + '' + ].join('\n'); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts new file mode 100644 index 000000000..939a55ff8 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { symbolRenamesTransform } from '../../../src/migrations/v1-to-v2/transforms/symbolRenames.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + symbolRenamesTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('symbol-renames transform', () => { + it('renames McpError to ProtocolError', () => { + const input = [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `throw new McpError(1, 'error');`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ProtocolError'); + expect(result).not.toContain('McpError'); + }); + + it('renames JSONRPCError to JSONRPCErrorResponse', () => { + const input = [`import { JSONRPCError } from '@modelcontextprotocol/sdk/types.js';`, `const e: JSONRPCError = error;`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('JSONRPCErrorResponse'); + expect(result).not.toContain(/\bJSONRPCError\b/); + }); + + it('renames isJSONRPCError to isJSONRPCErrorResponse', () => { + const input = [`import { isJSONRPCError } from '@modelcontextprotocol/sdk/types.js';`, `if (isJSONRPCError(x)) {}`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('isJSONRPCErrorResponse'); + }); + + it('renames isJSONRPCResponse to isJSONRPCResultResponse', () => { + const input = [`import { isJSONRPCResponse } from '@modelcontextprotocol/sdk/types.js';`, `if (isJSONRPCResponse(x)) {}`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('isJSONRPCResultResponse'); + }); + + it('renames ResourceReference to ResourceTemplateReference', () => { + const input = [ + `import { ResourceReference } from '@modelcontextprotocol/sdk/types.js';`, + `const ref: ResourceReference = { type: 'ref', uri: '' };`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ResourceTemplateReference'); + expect(result).not.toMatch(/\bResourceReference\b/); + }); + + it('splits ErrorCode into ProtocolErrorCode and SdkErrorCode', () => { + const input = [ + `import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, + `const a = ErrorCode.InvalidParams;`, + `const b = ErrorCode.RequestTimeout;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ProtocolErrorCode.InvalidParams'); + expect(result).toContain('SdkErrorCode.RequestTimeout'); + expect(result).not.toMatch(/\bErrorCode\./); + expect(result).not.toMatch(/import.*\bErrorCode\b/); + }); + + it('handles ErrorCode with only SDK members', () => { + const input = [`import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, `const a = ErrorCode.ConnectionClosed;`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('SdkErrorCode.ConnectionClosed'); + expect(result).toContain('SdkErrorCode'); + expect(result).not.toContain('ProtocolErrorCode'); + }); + + it('does not rename property keys that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `const config = { McpError: 'some value' };`, + `throw new McpError(1, 'error');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("{ McpError: 'some value' }"); + expect(result).toContain('new ProtocolError'); + }); + + it('does not rename property access names that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `const x = config.McpError;`, + `throw new McpError(1, 'error');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('config.McpError'); + expect(result).toContain('new ProtocolError'); + }); + + it('is idempotent', () => { + const input = [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `throw new McpError(1, 'error');`, ''].join('\n'); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('renames RequestHandlerExtra to ServerContext with server generic args', () => { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type MyHandler = (args: any, extra: RequestHandlerExtra) => void;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ServerContext'); + expect(result).not.toContain('RequestHandlerExtra'); + expect(result).not.toContain('ServerRequest'); + expect(result).not.toContain('ServerNotification'); + }); + + it('renames RequestHandlerExtra to ClientContext with client generic args', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type MyHandler = (args: any, extra: RequestHandlerExtra) => void;`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + symbolRenamesTransform.apply(sourceFile, { projectType: 'client' }); + const result = sourceFile.getFullText(); + expect(result).toContain('ClientContext'); + expect(result).not.toContain('RequestHandlerExtra'); + }); + + it('strips generic type arguments from RequestHandlerExtra', () => { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `const extra = {} as RequestHandlerExtra;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('as ServerContext;'); + expect(result).not.toContain(' { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type Extra = RequestHandlerExtra;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ServerContext'); + expect(result).not.toContain('RequestHandlerExtra'); + }); + + it('defaults RequestHandlerExtra to ClientContext for client projects', () => { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type Extra = RequestHandlerExtra;`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + symbolRenamesTransform.apply(sourceFile, { projectType: 'client' }); + const result = sourceFile.getFullText(); + expect(result).toContain('ClientContext'); + }); +}); diff --git a/packages/codemod/tsconfig.json b/packages/codemod/tsconfig.json new file mode 100644 index 000000000..f6db267ff --- /dev/null +++ b/packages/codemod/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist", "test/**/fixtures"], + "compilerOptions": { + "paths": { + "*": ["./*"] + } + } +} diff --git a/packages/codemod/tsdown.config.ts b/packages/codemod/tsdown.config.ts new file mode 100644 index 000000000..21ae185db --- /dev/null +++ b/packages/codemod/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/cli.ts', 'src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + shims: true, + dts: { + resolver: 'tsc' + } +}); diff --git a/packages/codemod/vitest.config.js b/packages/codemod/vitest.config.js new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/codemod/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 899586750..7293b1024 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -572,6 +572,58 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/codemod: + dependencies: + commander: + specifier: ^13.0.0 + version: 13.1.0 + ts-morph: + specifier: ^28.0.0 + version: 28.0.0 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + tsx: + specifier: catalog:devTools + version: 4.21.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/core: dependencies: ajv: @@ -2336,6 +2388,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@ts-morph/common@0.29.0': + resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2965,6 +3020,9 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2976,6 +3034,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -4197,6 +4259,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4711,6 +4776,9 @@ packages: peerDependencies: typescript: '>=4.0.0' + ts-morph@28.0.0: + resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -6092,6 +6160,12 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@ts-morph/common@0.29.0': + dependencies: + minimatch: 10.2.4 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -6721,6 +6795,8 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + code-block-writer@13.0.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -6731,6 +6807,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@13.1.0: {} + commander@14.0.3: {} component-emitter@1.3.1: {} @@ -8062,6 +8140,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -8673,6 +8753,11 @@ snapshots: picomatch: 4.0.4 typescript: 5.9.3 + ts-morph@28.0.0: + dependencies: + '@ts-morph/common': 0.29.0 + code-block-writer: 13.0.3 + tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 From 44ac6092a4812add26e42ce9ba85640d47a832da Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 20:54:17 +0300 Subject: [PATCH 02/22] claude review fixes --- .../v1-to-v2/transforms/expressMiddleware.ts | 55 +++++ .../v1-to-v2/transforms/importPaths.ts | 31 ++- .../migrations/v1-to-v2/transforms/index.ts | 19 +- .../v1-to-v2/transforms/mockPaths.ts | 6 +- .../v1-to-v2/transforms/removedApis.ts | 186 +++++++++++++++ .../v1-to-v2/transforms/symbolRenames.ts | 98 ++++++-- packages/codemod/src/utils/astUtils.ts | 18 ++ packages/codemod/test/integration.test.ts | 51 +++- .../transforms/expressMiddleware.test.ts | 85 +++++++ .../v1-to-v2/transforms/importPaths.test.ts | 50 ++++ .../v1-to-v2/transforms/mockPaths.test.ts | 14 +- .../v1-to-v2/transforms/removedApis.test.ts | 221 ++++++++++++++++++ .../v1-to-v2/transforms/symbolRenames.test.ts | 52 ++++- 13 files changed, 855 insertions(+), 31 deletions(-) create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts create mode 100644 packages/codemod/src/utils/astUtils.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts new file mode 100644 index 000000000..c1c1f77ac --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts @@ -0,0 +1,55 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { info } from '../../../utils/diagnostics.js'; + +export const expressMiddlewareTransform: Transform = { + name: 'Express middleware signature migration', + id: 'express-middleware', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + const diagnostics: Diagnostic[] = []; + let changesCount = 0; + + changesCount += rewriteHostHeaderValidation(sourceFile, diagnostics); + + return { changesCount, diagnostics }; + } +}; + +function rewriteHostHeaderValidation(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { + let changesCount = 0; + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isIdentifier(expr) || expr.getText() !== 'hostHeaderValidation') continue; + + const args = call.getArguments(); + if (args.length !== 1) continue; + + const firstArg = args[0]!; + if (!Node.isObjectLiteralExpression(firstArg)) continue; + + const allowedHostsProp = firstArg.getProperty('allowedHosts'); + if (!allowedHostsProp || !Node.isPropertyAssignment(allowedHostsProp)) continue; + + const initializer = allowedHostsProp.getInitializer(); + if (!initializer) continue; + + const arrayText = initializer.getText(); + firstArg.replaceWithText(arrayText); + changesCount++; + + diagnostics.push( + info( + sourceFile.getFilePath(), + call.getStartLineNumber(), + 'hostHeaderValidation({ allowedHosts: [...] }) simplified to hostHeaderValidation([...]). Verify the migration.' + ) + ); + } + + return changesCount; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 7ccf0c983..11451303d 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -1,6 +1,7 @@ import type { SourceFile } from 'ts-morph'; import type { Transform, TransformContext, TransformResult } from '../../../types.js'; +import { renameAllReferences } from '../../../utils/astUtils.js'; import { warning } from '../../../utils/diagnostics.js'; import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils.js'; import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; @@ -53,9 +54,11 @@ export const importPathsTransform: Transform = { for (const imp of sdkImports) { const specifier = imp.getModuleSpecifierValue(); - const namedImports = imp.getNamedImports().map(n => n.getName()); + const namedImports = imp.getNamedImports(); const typeOnly = isTypeOnlyImport(imp); const line = imp.getStartLineNumber(); + const defaultImport = imp.getDefaultImport(); + const namespaceImport = imp.getNamespaceImport(); let mapping = IMPORT_MAP[specifier]; @@ -84,9 +87,31 @@ export const importPathsTransform: Transform = { targetPackage = resolveTypesPackage(context, hasClientImport, hasServerImport); } - let resolvedNames = namedImports; if (mapping.renamedSymbols) { - resolvedNames = namedImports.map(name => mapping.renamedSymbols?.[name] ?? name); + for (const [oldName, newName] of Object.entries(mapping.renamedSymbols)) { + renameAllReferences(sourceFile, oldName, newName); + } + } + + const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined); + if (defaultImport || namespaceImport || hasAlias) { + imp.setModuleSpecifier(targetPackage); + if (mapping.renamedSymbols) { + for (const n of namedImports) { + const newName = mapping.renamedSymbols[n.getName()]; + if (newName) { + n.setName(newName); + } + } + } + changesCount++; + continue; + } + + const names = namedImports.map(n => n.getName()); + let resolvedNames = names; + if (mapping.renamedSymbols) { + resolvedNames = names.map(name => mapping.renamedSymbols?.[name] ?? name); } addPending(targetPackage, resolvedNames, typeOnly); diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts index f2297e50f..bdc1c5e6b 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts @@ -1,9 +1,11 @@ import type { Transform } from '../../../types.js'; import { contextTypesTransform } from './contextTypes.js'; +import { expressMiddlewareTransform } from './expressMiddleware.js'; import { handlerRegistrationTransform } from './handlerRegistration.js'; import { importPathsTransform } from './importPaths.js'; import { mcpServerApiTransform } from './mcpServerApi.js'; import { mockPathsTransform } from './mockPaths.js'; +import { removedApisTransform } from './removedApis.js'; import { schemaParamRemovalTransform } from './schemaParamRemoval.js'; import { symbolRenamesTransform } from './symbolRenames.js'; @@ -14,23 +16,30 @@ import { symbolRenamesTransform } from './symbolRenames.js'; // transforms depend on the rewritten import declarations. // // 2. symbolRenames runs early: renames imported symbols (e.g., McpError → -// ProtocolError) that later transforms may reference. +// ProtocolError) and rewrites type references (e.g., SchemaInput → +// StandardSchemaWithJSON.InferInput). // -// 3. mcpServerApi SHOULD run before contextTypes: it rewrites .tool() etc. +// 3. removedApis runs after symbolRenames: handles removed Zod helpers, +// IsomorphicHeaders, and StreamableHTTPError. Conceptually different +// from renames — these are removals with diagnostic guidance. +// +// 4. mcpServerApi SHOULD run before contextTypes: it rewrites .tool() etc. // to .registerTool() etc. contextTypes handles both old and new names, // but running mcpServerApi first ensures consistent argument structure. // -// 4. handlerRegistration and schemaParamRemoval are independent of each -// other but both depend on importPaths having run. +// 5. handlerRegistration, schemaParamRemoval, and expressMiddleware are +// independent of each other but all depend on importPaths having run. // -// 5. mockPaths runs last: handles test mocks and dynamic imports, +// 6. mockPaths runs last: handles test mocks and dynamic imports, // independent of the other transforms. export const v1ToV2Transforms: Transform[] = [ importPathsTransform, symbolRenamesTransform, + removedApisTransform, mcpServerApiTransform, handlerRegistrationTransform, schemaParamRemovalTransform, + expressMiddlewareTransform, contextTypesTransform, mockPathsTransform ]; 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 e29310b8f..ca8115d42 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -173,7 +173,11 @@ function rewriteDynamicImports(sourceFile: SourceFile, context: TransformContext const bindingName = element.getName(); const newName = resolved.renamedSymbols[bindingName]; if (newName) { - element.getNameNode().replaceWithText(newName); + if (element.getPropertyNameNode()) { + element.getPropertyNameNode()!.replaceWithText(newName); + } else { + element.replaceWithText(`${newName}: ${bindingName}`); + } changes++; } } diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts new file mode 100644 index 000000000..20abfa281 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts @@ -0,0 +1,186 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { renameAllReferences } from '../../../utils/astUtils.js'; +import { warning } from '../../../utils/diagnostics.js'; +import { addOrMergeImport, isAnyMcpSpecifier } from '../../../utils/importUtils.js'; + +const REMOVED_ZOD_HELPERS: Record = { + schemaToJson: + "Removed in v2. Use `fromJsonSchema()` from @modelcontextprotocol/server for JSON Schema, or your schema library's native conversion.", + parseSchemaAsync: "Removed in v2. Use your schema library's validation directly (e.g., Zod's `.safeParseAsync()`).", + getSchemaShape: "Removed in v2. These Zod-specific introspection helpers have no v2 equivalent. Use your schema library's native API.", + getSchemaDescription: + "Removed in v2. These Zod-specific introspection helpers have no v2 equivalent. Use your schema library's native API.", + isOptionalSchema: + "Removed in v2. These Zod-specific introspection helpers have no v2 equivalent. Use your schema library's native API.", + unwrapOptionalSchema: + "Removed in v2. These Zod-specific introspection helpers have no v2 equivalent. Use your schema library's native API." +}; + +export const removedApisTransform: Transform = { + name: 'Removed API handling', + id: 'removed-apis', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + const diagnostics: Diagnostic[] = []; + let changesCount = 0; + + changesCount += handleRemovedZodHelpers(sourceFile, diagnostics); + changesCount += handleIsomorphicHeaders(sourceFile, diagnostics); + changesCount += handleStreamableHTTPError(sourceFile, diagnostics); + + return { changesCount, diagnostics }; + } +}; + +function handleRemovedZodHelpers(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { + interface Removal { + importName: string; + message: string; + line: number; + } + + const removals: Removal[] = []; + + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + const line = imp.getStartLineNumber(); + for (const namedImport of imp.getNamedImports()) { + const name = namedImport.getName(); + const message = REMOVED_ZOD_HELPERS[name]; + if (message) { + removals.push({ importName: name, message, line }); + } + } + } + + for (const removal of removals) { + for (const imp of sourceFile.getImportDeclarations()) { + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === removal.importName) { + namedImport.remove(); + if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport()) { + imp.remove(); + } + break; + } + } + } + diagnostics.push(warning(sourceFile.getFilePath(), removal.line, `${removal.importName}: ${removal.message}`)); + } + + return removals.length; +} + +function handleIsomorphicHeaders(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { + let changesCount = 0; + let foundImport: ReturnType[0]['getNamedImports']>[0] | undefined; + let foundImportDecl: ReturnType[0] | undefined; + + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'IsomorphicHeaders') { + foundImport = namedImport; + foundImportDecl = imp; + break; + } + } + if (foundImport) break; + } + + if (!foundImport || !foundImportDecl) return 0; + + const line = foundImportDecl.getStartLineNumber(); + + renameAllReferences(sourceFile, 'IsomorphicHeaders', 'Headers'); + changesCount++; + + foundImport.remove(); + if (foundImportDecl.getNamedImports().length === 0 && !foundImportDecl.getDefaultImport() && !foundImportDecl.getNamespaceImport()) { + foundImportDecl.remove(); + } + changesCount++; + + diagnostics.push( + warning( + sourceFile.getFilePath(), + line, + 'IsomorphicHeaders replaced with standard Web Headers API. Note: Headers uses .get()/.set() methods, not bracket access.' + ) + ); + + return changesCount; +} + +function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { + let changesCount = 0; + let foundImport: ReturnType[0]['getNamedImports']>[0] | undefined; + let foundImportDecl: ReturnType[0] | undefined; + + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'StreamableHTTPError') { + foundImport = namedImport; + foundImportDecl = imp; + break; + } + } + if (foundImport) break; + } + + if (!foundImport || !foundImportDecl) return 0; + + const line = foundImportDecl.getStartLineNumber(); + const moduleSpec = foundImportDecl.getModuleSpecifierValue(); + + for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) { + const expr = node.getExpression(); + if (!Node.isIdentifier(expr) || expr.getText() !== 'StreamableHTTPError') continue; + diagnostics.push( + warning( + sourceFile.getFilePath(), + node.getStartLineNumber(), + 'new StreamableHTTPError(statusCode, statusText, body?) → new SdkError(code, message, data?). ' + + 'Constructor arguments differ — manual review required. Map HTTP status to SdkErrorCode enum value.' + ) + ); + } + + renameAllReferences(sourceFile, 'StreamableHTTPError', 'SdkError'); + changesCount++; + + foundImport.remove(); + if (foundImportDecl.getNamedImports().length === 0 && !foundImportDecl.getDefaultImport() && !foundImportDecl.getNamespaceImport()) { + foundImportDecl.remove(); + } + + const targetModule = resolveTargetModule(sourceFile, moduleSpec); + const insertIndex = sourceFile.getImportDeclarations().length; + addOrMergeImport(sourceFile, targetModule, ['SdkError', 'SdkErrorCode'], false, insertIndex); + changesCount++; + + diagnostics.push( + warning( + sourceFile.getFilePath(), + line, + 'StreamableHTTPError replaced with SdkError. Constructor arguments differ — manual review required. ' + + 'HTTP status is now in error.data?.status.' + ) + ); + + return changesCount; +} + +function resolveTargetModule(sourceFile: SourceFile, originalModule: string): string { + const imp = sourceFile.getImportDeclarations().find(i => { + const spec = i.getModuleSpecifierValue(); + return spec === '@modelcontextprotocol/client' || spec === '@modelcontextprotocol/server'; + }); + if (imp) return imp.getModuleSpecifierValue(); + + if (originalModule.includes('/client')) return '@modelcontextprotocol/client'; + return '@modelcontextprotocol/server'; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts index beef5eafe..dc41f75f1 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts @@ -1,8 +1,11 @@ import type { SourceFile } from 'ts-morph'; import { Node } from 'ts-morph'; -import type { Transform, TransformContext, TransformResult } from '../../../types.js'; -import { warning } from '../../../utils/diagnostics.js'; +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { renameAllReferences } from '../../../utils/astUtils.js'; +import { info, warning } from '../../../utils/diagnostics.js'; +import { addOrMergeImport, isAnyMcpSpecifier } from '../../../utils/importUtils.js'; +import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; import { ERROR_CODE_SDK_MEMBERS, SIMPLE_RENAMES } from '../mappings/symbolMap.js'; const SERVER_GENERIC_ARGS = new Set(['ServerRequest', 'ServerNotification']); @@ -12,7 +15,7 @@ export const symbolRenamesTransform: Transform = { name: 'Symbol renames', id: 'symbols', apply(sourceFile: SourceFile, context: TransformContext): TransformResult { - const diagnostics: ReturnType[] = []; + const diagnostics: Diagnostic[] = []; let changesCount = 0; const imports = sourceFile.getImportDeclarations(); @@ -34,27 +37,12 @@ export const symbolRenamesTransform: Transform = { changesCount += handleErrorCodeSplit(sourceFile, diagnostics); changesCount += handleRequestHandlerExtra(sourceFile, context, diagnostics); + changesCount += handleSchemaInput(sourceFile, context, diagnostics); return { changesCount, diagnostics }; } }; -function renameAllReferences(sourceFile: SourceFile, oldName: string, newName: string): void { - sourceFile.forEachDescendant(node => { - if (Node.isIdentifier(node) && node.getText() === oldName) { - const parent = node.getParent(); - if (!parent) return; - if (Node.isImportSpecifier(parent)) return; - if (Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return; - if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return; - if (Node.isPropertySignature(parent) && parent.getNameNode() === node) return; - if (Node.isMethodDeclaration(parent) && parent.getNameNode() === node) return; - if (Node.isPropertyDeclaration(parent) && parent.getNameNode() === node) return; - node.replaceWithText(newName); - } - }); -} - function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: ReturnType[]): number { let changesCount = 0; @@ -210,3 +198,75 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon return changesCount; } + +function handleSchemaInput(sourceFile: SourceFile, context: TransformContext, diagnostics: Diagnostic[]): number { + let changesCount = 0; + + const imports = sourceFile.getImportDeclarations(); + let schemaInputImport: ReturnType<(typeof imports)[0]['getNamedImports']>[0] | undefined; + let schemaInputImportDecl: (typeof imports)[0] | undefined; + + for (const imp of imports) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'SchemaInput') { + schemaInputImport = namedImport; + schemaInputImportDecl = imp; + break; + } + } + if (schemaInputImport) break; + } + + if (!schemaInputImport || !schemaInputImportDecl) return 0; + + sourceFile.forEachDescendant(node => { + if (!Node.isTypeReference(node)) return; + const typeName = node.getTypeName(); + if (!Node.isIdentifier(typeName) || typeName.getText() !== 'SchemaInput') return; + + const typeArgs = node.getTypeArguments(); + if (typeArgs.length > 0) { + const argText = typeArgs[0]!.getText(); + node.replaceWithText(`StandardSchemaWithJSON.InferInput<${argText}>`); + } else { + node.replaceWithText('StandardSchemaWithJSON.InferInput'); + } + changesCount++; + }); + + if (changesCount > 0) { + schemaInputImport.remove(); + if ( + schemaInputImportDecl.getNamedImports().length === 0 && + !schemaInputImportDecl.getDefaultImport() && + !schemaInputImportDecl.getNamespaceImport() + ) { + schemaInputImportDecl.remove(); + } + + const isClientFile = sourceFile.getImportDeclarations().some(i => { + const spec = i.getModuleSpecifierValue(); + return spec.includes('/client') || spec === '@modelcontextprotocol/client'; + }); + const isServerFile = sourceFile.getImportDeclarations().some(i => { + const spec = i.getModuleSpecifierValue(); + return spec.includes('/server') || spec === '@modelcontextprotocol/server'; + }); + const targetModule = resolveTypesPackage(context, isClientFile, isServerFile); + + const insertIndex = sourceFile.getImportDeclarations().length; + addOrMergeImport(sourceFile, targetModule, ['StandardSchemaWithJSON'], true, insertIndex); + changesCount++; + + diagnostics.push( + info( + sourceFile.getFilePath(), + 1, + 'SchemaInput replaced with StandardSchemaWithJSON.InferInput. Verify the migration is correct.' + ) + ); + } + + return changesCount; +} diff --git a/packages/codemod/src/utils/astUtils.ts b/packages/codemod/src/utils/astUtils.ts new file mode 100644 index 000000000..463aad196 --- /dev/null +++ b/packages/codemod/src/utils/astUtils.ts @@ -0,0 +1,18 @@ +import type { SourceFile } from 'ts-morph'; +import { Node } from 'ts-morph'; + +export function renameAllReferences(sourceFile: SourceFile, oldName: string, newName: string): void { + sourceFile.forEachDescendant(node => { + if (Node.isIdentifier(node) && node.getText() === oldName) { + const parent = node.getParent(); + if (!parent) return; + if (Node.isImportSpecifier(parent)) return; + if (Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return; + if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return; + if (Node.isPropertySignature(parent) && parent.getNameNode() === node) return; + if (Node.isMethodDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isPropertyDeclaration(parent) && parent.getNameNode() === node) return; + node.replaceWithText(newName); + } + }); +} diff --git a/packages/codemod/test/integration.test.ts b/packages/codemod/test/integration.test.ts index f2f5c4a81..e14d4c722 100644 --- a/packages/codemod/test/integration.test.ts +++ b/packages/codemod/test/integration.test.ts @@ -31,6 +31,7 @@ describe('integration', () => { `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, ``, `const server = new McpServer({ name: 'test', version: '1.0' });`, + `const transport = new StreamableHTTPServerTransport({});`, ``, `server.tool('greet', 'Say hello', { name: z.string() }, async ({ name }, extra) => {`, ` const s = extra.signal;`, @@ -61,8 +62,10 @@ describe('integration', () => { expect(output).toContain('@modelcontextprotocol/node'); expect(output).not.toContain('@modelcontextprotocol/sdk'); - // Symbol renames + // Symbol renames + body references updated expect(output).toContain('NodeStreamableHTTPServerTransport'); + expect(output).toContain('new NodeStreamableHTTPServerTransport({})'); + expect(output).not.toMatch(/(? { expect(output).toContain('McpError'); }); + it('applies new transforms (removed APIs, SchemaInput, express middleware)', () => { + const dir = createTempDir(); + const input = [ + `import { McpServer, schemaToJson, IsomorphicHeaders } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import type { SchemaInput } from '@modelcontextprotocol/sdk/types.js';`, + `import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`, + `import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js';`, + ``, + `type Input = SchemaInput;`, + `const h: IsomorphicHeaders = {};`, + `if (error instanceof StreamableHTTPError) {}`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(1); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + + // SchemaInput rewritten + expect(output).toContain('StandardSchemaWithJSON.InferInput'); + expect(output).not.toContain('SchemaInput'); + + // IsomorphicHeaders replaced with global Headers + expect(output).toContain('const h: Headers'); + expect(output).not.toContain('IsomorphicHeaders'); + + // StreamableHTTPError renamed to SdkError + expect(output).toContain('instanceof SdkError'); + expect(output).not.toContain('StreamableHTTPError'); + + // schemaToJson removed (import gone) + expect(output).not.toContain('schemaToJson'); + + // hostHeaderValidation signature migrated + expect(output).toContain("hostHeaderValidation(['localhost'])"); + expect(output).not.toContain('allowedHosts'); + + // Diagnostics emitted + expect(result.diagnostics.length).toBeGreaterThan(0); + }); + it('emits diagnostics for removed imports', () => { const dir = createTempDir(); const input = [ diff --git a/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts new file mode 100644 index 000000000..4d73b551e --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { expressMiddlewareTransform } from '../../../src/migrations/v1-to-v2/transforms/expressMiddleware.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string) { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + const result = expressMiddlewareTransform.apply(sourceFile, ctx); + return { text: sourceFile.getFullText(), result }; +} + +describe('express-middleware transform', () => { + it('rewrites hostHeaderValidation({ allowedHosts: [...] }) to hostHeaderValidation([...])', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost', '127.0.0.1'] }));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("hostHeaderValidation(['localhost', '127.0.0.1'])"); + expect(text).not.toContain('allowedHosts'); + expect(result.changesCount).toBe(1); + }); + + it('preserves calls that already use array syntax', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation(['localhost', '127.0.0.1']));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("hostHeaderValidation(['localhost', '127.0.0.1'])"); + expect(result.changesCount).toBe(0); + }); + + it('handles variable references in allowedHosts', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `const hosts = ['localhost'];`, + `app.use(hostHeaderValidation({ allowedHosts: hosts }));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('hostHeaderValidation(hosts)'); + expect(text).not.toContain('allowedHosts'); + expect(result.changesCount).toBe(1); + }); + + it('does not modify calls with non-object arguments', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation(someVariable));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('hostHeaderValidation(someVariable)'); + expect(result.changesCount).toBe(0); + }); + + it('does not modify calls with no arguments', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation());`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('hostHeaderValidation()'); + expect(result.changesCount).toBe(0); + }); + + it('is idempotent', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + '' + ].join('\n'); + const { text: first } = applyTransform(input); + const { text: second } = applyTransform(first); + expect(second).toBe(first); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index 790812233..7aa1cab5d 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -123,6 +123,56 @@ describe('import-paths transform', () => { expect(result).toContain(`from "@modelcontextprotocol/express"`); }); + it('renames body references when renamedSymbols applies', () => { + const input = [ + `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const transport = new StreamableHTTPServerTransport({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('new NodeStreamableHTTPServerTransport({})'); + expect(result).not.toMatch(/(? { + const input = [ + `import { Client as MCPClient } from '@modelcontextprotocol/sdk/client/index.js';`, + `const c = new MCPClient({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('MCPClient'); + expect(result).toContain('@modelcontextprotocol/client'); + expect(result).toContain('new MCPClient({})'); + }); + + it('preserves namespace imports by rewriting module specifier', () => { + const input = [`import * as types from '@modelcontextprotocol/sdk/types.js';`, `const x: types.Tool = {};`, ''].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain('import * as types'); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toContain('types.Tool'); + }); + + it('preserves default imports by rewriting module specifier', () => { + const input = [`import sdk from '@modelcontextprotocol/sdk/types.js';`, `const x = sdk.foo;`, ''].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain('import sdk'); + expect(result).toContain('@modelcontextprotocol/server'); + }); + + it('handles aliased renamedSymbols correctly', () => { + const input = [ + `import { StreamableHTTPServerTransport as SHST } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const t = new SHST({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('NodeStreamableHTTPServerTransport as SHST'); + expect(result).toContain('new SHST({})'); + expect(result).toContain('@modelcontextprotocol/node'); + }); + it('removes auth imports with warning', () => { const input = `import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';\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 104c45c7a..e6199fd9c 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts @@ -37,7 +37,7 @@ describe('mock-paths transform', () => { const result = applyTransform(input); expect(result).toContain(`'@modelcontextprotocol/node'`); expect(result).toContain('NodeStreamableHTTPServerTransport'); - expect(result).not.toContain(/(? { @@ -106,6 +106,18 @@ describe('mock-paths transform', () => { expect(result).toContain('NodeStreamableHTTPServerTransport'); }); + it('preserves local binding when renaming dynamic import destructuring', () => { + const input = [ + `const { StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');`, + `const transport = new StreamableHTTPServerTransport({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('@modelcontextprotocol/node')`); + expect(result).toContain('NodeStreamableHTTPServerTransport: StreamableHTTPServerTransport'); + expect(result).toContain('new StreamableHTTPServerTransport({})'); + }); + it('rewrites dynamic import for server/mcp.js', () => { const input = [`const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');`, ''].join('\n'); const result = applyTransform(input); diff --git a/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts new file mode 100644 index 000000000..40e8ea606 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { removedApisTransform } from '../../../src/migrations/v1-to-v2/transforms/removedApis.js'; +import type { TransformContext } from '../../../src/types.js'; +import { DiagnosticLevel } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string, context: TransformContext = ctx) { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + const result = removedApisTransform.apply(sourceFile, context); + return { text: sourceFile.getFullText(), result }; +} + +describe('removed-apis transform', () => { + describe('removed Zod helpers', () => { + it('removes schemaToJson import and emits warning', () => { + const input = [`import { schemaToJson } from '@modelcontextprotocol/server';`, `const json = schemaToJson(schema);`, ''].join( + '\n' + ); + const { text, result } = applyTransform(input); + expect(text).not.toContain('import { schemaToJson }'); + expect(result.changesCount).toBeGreaterThan(0); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.level).toBe(DiagnosticLevel.Warning); + expect(result.diagnostics[0]!.message).toContain('schemaToJson'); + }); + + it('removes parseSchemaAsync import and emits warning', () => { + const input = [ + `import { parseSchemaAsync } from '@modelcontextprotocol/server';`, + `const result = await parseSchemaAsync(schema, data);`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).not.toContain('import { parseSchemaAsync }'); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.message).toContain('parseSchemaAsync'); + }); + + it('removes getSchemaShape import and emits warning', () => { + const input = [ + `import { getSchemaShape } from '@modelcontextprotocol/server';`, + `const shape = getSchemaShape(schema);`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).not.toContain('import { getSchemaShape }'); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.message).toContain('getSchemaShape'); + }); + + it('removes multiple zod helpers from same import declaration', () => { + const input = [`import { schemaToJson, parseSchemaAsync, getSchemaShape } from '@modelcontextprotocol/server';`, ''].join('\n'); + const { text, result } = applyTransform(input); + expect(text).not.toContain('schemaToJson'); + expect(text).not.toContain('parseSchemaAsync'); + expect(text).not.toContain('getSchemaShape'); + expect(text).not.toContain("from '@modelcontextprotocol/server'"); + expect(result.changesCount).toBe(3); + expect(result.diagnostics).toHaveLength(3); + }); + + it('preserves non-removed symbols in same import', () => { + const input = [ + `import { McpServer, schemaToJson } from '@modelcontextprotocol/server';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("import { McpServer } from '@modelcontextprotocol/server'"); + expect(text).not.toContain('schemaToJson'); + expect(result.changesCount).toBe(1); + }); + + it('does not touch non-MCP imports with same names', () => { + const input = [`import { schemaToJson } from 'some-other-lib';`, `const json = schemaToJson(schema);`, ''].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("import { schemaToJson } from 'some-other-lib'"); + expect(result.changesCount).toBe(0); + }); + + it('is idempotent', () => { + const input = [`import { schemaToJson } from '@modelcontextprotocol/server';`, `const json = schemaToJson(schema);`, ''].join( + '\n' + ); + const { text: first } = applyTransform(input); + const { text: second } = applyTransform(first); + expect(second).toBe(first); + }); + }); + + describe('IsomorphicHeaders removal', () => { + it('replaces IsomorphicHeaders with Headers in type annotations', () => { + const input = [ + `import { IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const headers: IsomorphicHeaders = new Headers();`, + `function getHeaders(): IsomorphicHeaders { return new Headers(); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('const headers: Headers'); + expect(text).toContain('function getHeaders(): Headers'); + expect(text).not.toContain('IsomorphicHeaders'); + }); + + it('removes IsomorphicHeaders import entirely', () => { + const input = [ + `import { IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const h: IsomorphicHeaders = {};`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).not.toContain("from '@modelcontextprotocol/server'"); + }); + + it('preserves other imports when removing IsomorphicHeaders', () => { + const input = [ + `import { McpServer, IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const h: IsomorphicHeaders = {};`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain("import { McpServer } from '@modelcontextprotocol/server'"); + expect(text).not.toContain('IsomorphicHeaders'); + }); + + it('emits warning about Headers API differences', () => { + const input = [ + `import { IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const h: IsomorphicHeaders = {};`, + '' + ].join('\n'); + const { result } = applyTransform(input); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.level).toBe(DiagnosticLevel.Warning); + expect(result.diagnostics[0]!.message).toContain('Headers'); + }); + + it('is idempotent', () => { + const input = [ + `import { IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const h: IsomorphicHeaders = {};`, + '' + ].join('\n'); + const { text: first } = applyTransform(input); + const { text: second } = applyTransform(first); + expect(second).toBe(first); + }); + }); + + describe('StreamableHTTPError → SdkError', () => { + it('renames StreamableHTTPError to SdkError in references', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) { throw error; }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('instanceof SdkError'); + expect(text).not.toContain('StreamableHTTPError'); + }); + + it('adds SdkError and SdkErrorCode imports', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) {}`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('SdkError'); + expect(text).toContain('SdkErrorCode'); + }); + + it('emits warning for constructor calls', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `throw new StreamableHTTPError(404, 'Not Found');`, + '' + ].join('\n'); + const { result } = applyTransform(input); + const constructorWarning = result.diagnostics.find(d => d.message.includes('Constructor arguments differ')); + expect(constructorWarning).toBeDefined(); + }); + + it('emits general migration warning', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) {}`, + '' + ].join('\n'); + const { result } = applyTransform(input); + const migrationWarning = result.diagnostics.find(d => d.message.includes('error.data?.status')); + expect(migrationWarning).toBeDefined(); + }); + + it('removes old import and adds new one', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) {}`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).not.toContain('import { StreamableHTTPError }'); + expect(text).toMatch(/import.*SdkError/); + }); + + it('is idempotent', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) {}`, + '' + ].join('\n'); + const { text: first } = applyTransform(input); + const { text: second } = applyTransform(first); + expect(second).toBe(first); + }); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts index 939a55ff8..1cde28886 100644 --- a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -27,7 +27,7 @@ describe('symbol-renames transform', () => { ); const result = applyTransform(input); expect(result).toContain('JSONRPCErrorResponse'); - expect(result).not.toContain(/\bJSONRPCError\b/); + expect(result).not.toMatch(/\bJSONRPCError\b/); }); it('renames isJSONRPCError to isJSONRPCErrorResponse', () => { @@ -172,4 +172,54 @@ describe('symbol-renames transform', () => { const result = sourceFile.getFullText(); expect(result).toContain('ClientContext'); }); + + it('replaces SchemaInput with StandardSchemaWithJSON.InferInput', () => { + const input = [ + `import type { SchemaInput } from '@modelcontextprotocol/server';`, + `type Input = SchemaInput;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('StandardSchemaWithJSON.InferInput'); + expect(result).not.toContain('SchemaInput'); + }); + + it('replaces bare SchemaInput with StandardSchemaWithJSON.InferInput', () => { + const input = [`import type { SchemaInput } from '@modelcontextprotocol/server';`, `type Input = SchemaInput;`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('StandardSchemaWithJSON.InferInput'); + expect(result).not.toContain('SchemaInput'); + }); + + it('adds StandardSchemaWithJSON type import for SchemaInput migration', () => { + const input = [ + `import type { SchemaInput } from '@modelcontextprotocol/server';`, + `type Input = SchemaInput;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('StandardSchemaWithJSON'); + expect(result).toMatch(/import type.*StandardSchemaWithJSON/); + }); + + it('removes SchemaInput import after migration', () => { + const input = [ + `import type { SchemaInput } from '@modelcontextprotocol/server';`, + `type Input = SchemaInput;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toMatch(/import.*SchemaInput/); + }); + + it('is idempotent for SchemaInput transform', () => { + const input = [ + `import type { SchemaInput } from '@modelcontextprotocol/server';`, + `type Input = SchemaInput;`, + '' + ].join('\n'); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); }); From a202bbaddd64ac8e491000a965de47fcb907d69e Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 20:55:13 +0300 Subject: [PATCH 03/22] typedoc add to codemod --- packages/codemod/typedoc.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/codemod/typedoc.json diff --git a/packages/codemod/typedoc.json b/packages/codemod/typedoc.json new file mode 100644 index 000000000..a9fd090d0 --- /dev/null +++ b/packages/codemod/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts", "**/__*__/**"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} From 6386627df8d5bae81f753a2b9ae3d44d2e355464 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 21:47:44 +0300 Subject: [PATCH 04/22] claude review fixes --- packages/codemod/src/cli.ts | 20 ++- packages/codemod/src/utils/projectAnalyzer.ts | 18 ++- packages/codemod/test/cli.test.ts | 83 +++++++++++++ packages/codemod/test/projectAnalyzer.test.ts | 117 ++++++++++++++++++ 4 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 packages/codemod/test/cli.test.ts create mode 100644 packages/codemod/test/projectAnalyzer.test.ts diff --git a/packages/codemod/src/cli.ts b/packages/codemod/src/cli.ts index 961ad8067..52c24a486 100644 --- a/packages/codemod/src/cli.ts +++ b/packages/codemod/src/cli.ts @@ -20,14 +20,14 @@ program.name('mcp-codemod').description('Codemod to migrate MCP TypeScript SDK c for (const [name, migration] of listMigrations()) { program - .command(`${name} `) + .command(`${name} [target-dir]`) .description(migration.description) .option('-d, --dry-run', 'Preview changes without writing files') .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('--list', 'List available transforms for this migration') - .action((targetDir: string, opts: Record) => { + .action((targetDir: string | undefined, opts: Record) => { try { if (opts['list']) { console.log(`\nAvailable transforms for ${name}:\n`); @@ -38,6 +38,12 @@ for (const [name, migration] of listMigrations()) { return; } + if (!targetDir) { + console.error(`\nError: missing required argument .\n`); + process.exitCode = 1; + return; + } + const resolvedDir = path.resolve(targetDir); if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { @@ -98,7 +104,15 @@ for (const [name, migration] of listMigrations()) { console.log(formatDiagnostic(d)); } console.log(''); - process.exitCode = 1; + } + + const infos = result.diagnostics.filter(d => d.level === DiagnosticLevel.Info); + if (infos.length > 0) { + console.log(`Info (${infos.length}):`); + for (const d of infos) { + console.log(formatDiagnostic(d)); + } + console.log(''); } if (opts['dryRun']) { diff --git a/packages/codemod/src/utils/projectAnalyzer.ts b/packages/codemod/src/utils/projectAnalyzer.ts index 6fbc7d360..a62f628c5 100644 --- a/packages/codemod/src/utils/projectAnalyzer.ts +++ b/packages/codemod/src/utils/projectAnalyzer.ts @@ -3,9 +3,23 @@ import path from 'node:path'; import type { TransformContext } from '../types.js'; +const PROJECT_ROOT_MARKERS = ['.git', 'node_modules']; + +function findPackageJson(startDir: string): string | undefined { + let dir = path.resolve(startDir); + const root = path.parse(dir).root; + while (true) { + const candidate = path.join(dir, 'package.json'); + if (existsSync(candidate)) return candidate; + if (dir === root) return undefined; + if (PROJECT_ROOT_MARKERS.some(m => existsSync(path.join(dir, m)))) return undefined; + dir = path.dirname(dir); + } +} + export function analyzeProject(targetDir: string): TransformContext { - const pkgJsonPath = path.join(targetDir, 'package.json'); - if (!existsSync(pkgJsonPath)) { + const pkgJsonPath = findPackageJson(targetDir); + if (!pkgJsonPath) { return { projectType: 'unknown' }; } diff --git a/packages/codemod/test/cli.test.ts b/packages/codemod/test/cli.test.ts new file mode 100644 index 000000000..d33d6a233 --- /dev/null +++ b/packages/codemod/test/cli.test.ts @@ -0,0 +1,83 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, afterEach } from 'vitest'; + +const CLI_PATH = path.resolve(__dirname, '../dist/cli.mjs'); + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-cli-')); + return tempDir; +} + +function runCli(args: string[]): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync('node', [CLI_PATH, ...args], { + encoding: 'utf8', + env: { ...process.env, NODE_NO_WARNINGS: '1' } + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (error: unknown) { + const e = error as { stdout: string; stderr: string; status: number }; + return { stdout: e.stdout ?? '', stderr: e.stderr ?? '', exitCode: e.status ?? 1 }; + } +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('CLI', () => { + it('--list works without a target-dir argument', () => { + const { stdout, exitCode } = runCli(['v1-to-v2', '--list']); + expect(exitCode).toBe(0); + expect(stdout).toContain('Available transforms'); + expect(stdout).toContain('imports'); + }); + + it('errors when target-dir is missing and --list is not set', () => { + const { stderr, exitCode } = runCli(['v1-to-v2']); + expect(exitCode).toBe(1); + expect(stderr).toContain('missing required argument'); + }); + + it('exits 0 when only warnings are present (no errors)', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';`, + `const transport = new SSEServerTransport();`, + `` + ].join('\n') + ); + + const { stdout, exitCode } = runCli(['v1-to-v2', dir]); + expect(stdout).toContain('Warning'); + expect(exitCode).toBe(0); + }); + + it('prints info diagnostics', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `server.tool('greet', 'Say hello', { name: z.string() }, async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + `` + ].join('\n') + ); + + const { stdout, exitCode } = runCli(['v1-to-v2', dir]); + expect(stdout).toContain('Info'); + expect(exitCode).toBe(0); + }); +}); diff --git a/packages/codemod/test/projectAnalyzer.test.ts b/packages/codemod/test/projectAnalyzer.test.ts new file mode 100644 index 000000000..da5a918c7 --- /dev/null +++ b/packages/codemod/test/projectAnalyzer.test.ts @@ -0,0 +1,117 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { analyzeProject } from '../src/utils/projectAnalyzer.js'; + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-analyzer-')); + return tempDir; +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('analyzeProject', () => { + it('returns unknown when no package.json exists', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, '.git'), { recursive: true }); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + + const result = analyzeProject(path.join(dir, 'src')); + expect(result.projectType).toBe('unknown'); + }); + + it('finds package.json in parent directory', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/client': '^2.0.0' } + }) + ); + + const result = analyzeProject(path.join(dir, 'src')); + expect(result.projectType).toBe('client'); + }); + + it('finds package.json multiple levels up', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'src', 'lib', 'utils'), { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/server': '^2.0.0' } + }) + ); + + const result = analyzeProject(path.join(dir, 'src', 'lib', 'utils')); + expect(result.projectType).toBe('server'); + }); + + it('stops walking at .git boundary', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'project', 'src'), { recursive: true }); + mkdirSync(path.join(dir, 'project', '.git'), { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/client': '^2.0.0' } + }) + ); + + const result = analyzeProject(path.join(dir, 'project', 'src')); + expect(result.projectType).toBe('unknown'); + }); + + it('stops walking at node_modules boundary', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'project', 'src'), { recursive: true }); + mkdirSync(path.join(dir, 'project', 'node_modules'), { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/client': '^2.0.0' } + }) + ); + + const result = analyzeProject(path.join(dir, 'project', 'src')); + expect(result.projectType).toBe('unknown'); + }); + + it('detects both client and server dependencies', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { + '@modelcontextprotocol/client': '^2.0.0', + '@modelcontextprotocol/server': '^2.0.0' + } + }) + ); + + const result = analyzeProject(dir); + expect(result.projectType).toBe('both'); + }); + + it('finds package.json at targetDir itself', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/server': '^2.0.0' } + }) + ); + + const result = analyzeProject(dir); + expect(result.projectType).toBe('server'); + }); +}); From 834bfab9ba17f71f61203e59e59fbf6ad8d3cc3d Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 22:08:21 +0300 Subject: [PATCH 05/22] nitpicks fix --- .../v1-to-v2/transforms/contextTypes.ts | 2 +- .../v1-to-v2/transforms/mcpServerApi.ts | 5 +- .../v1-to-v2/transforms/mockPaths.ts | 3 - .../v1-to-v2/transforms/symbolRenames.ts | 44 +++++++++++- packages/codemod/test/cli.test.ts | 72 +++++++++---------- .../v1-to-v2/transforms/symbolRenames.test.ts | 16 +++++ 6 files changed, 94 insertions(+), 48 deletions(-) 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 11db1eff4..87a622b04 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts @@ -49,7 +49,7 @@ export const contextTypesTransform: Transform = { extraParam.rename(CTX_PARAM_NAME); changesCount++; - const body = Node.isArrowFunction(callbackArg) ? callbackArg.getBody() : callbackArg.getBody(); + const body = callbackArg.getBody(); if (!body) continue; 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 f948440de..ae64f02ec 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -37,10 +37,7 @@ export const mcpServerApiTransform: Transform = { break; } case 'resource': { - { - resourceCalls.push(call); - // No default - } + resourceCalls.push(call); break; } } 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 ca8115d42..d94282e23 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -30,9 +30,6 @@ export const mockPathsTransform: Transform = { } } - if (Node.isImportExpression(expr) || call.getExpression().getText() === 'import') { - continue; - } } changesCount += rewriteDynamicImports(sourceFile, context, diagnostics); diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts index dc41f75f1..9144834c2 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts @@ -159,6 +159,9 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon defaultTarget = 'ClientContext'; } + let needsServerContext = false; + let needsClientContext = false; + sourceFile.forEachDescendant(node => { if (!Node.isTypeReference(node)) return; const typeName = node.getTypeName(); @@ -175,6 +178,9 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon } } + if (target === 'ServerContext') needsServerContext = true; + if (target === 'ClientContext') needsClientContext = true; + if (typeArgs.length > 0) { node.replaceWithText(target); } else { @@ -184,14 +190,48 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon }); if (changesCount > 0) { - extraImport.setName(defaultTarget); + extraImport.remove(); + + const newImports: Array<{ name: string; target: string }> = []; + if (needsServerContext) newImports.push({ name: 'ServerContext', target: '@modelcontextprotocol/server' }); + if (needsClientContext) newImports.push({ name: 'ClientContext', target: '@modelcontextprotocol/client' }); + + for (const { name, target } of newImports) { + const existingImp = sourceFile + .getImportDeclarations() + .find(i => i.getModuleSpecifierValue() === target && i.isTypeOnly()); + if (existingImp) { + const existingNames = new Set(existingImp.getNamedImports().map(n => n.getName())); + if (!existingNames.has(name)) { + existingImp.addNamedImports([name]); + } + } else { + const valueImp = sourceFile + .getImportDeclarations() + .find(i => i.getModuleSpecifierValue() === target && !i.isTypeOnly()); + if (valueImp) { + const existingNames = new Set(valueImp.getNamedImports().map(n => n.getName())); + if (!existingNames.has(name)) { + valueImp.addNamedImports([name]); + } + } else { + sourceFile.addImportDeclaration({ + isTypeOnly: true, + moduleSpecifier: target, + namedImports: [name] + }); + } + } + } + changesCount++; + const targets = newImports.map(i => i.name).join(' and '); diagnostics.push( warning( sourceFile.getFilePath(), extraImportDecl!.getStartLineNumber(), - `RequestHandlerExtra renamed to ${defaultTarget}. Generic type arguments removed. Verify the migration is correct.` + `RequestHandlerExtra renamed to ${targets}. Generic type arguments removed. Verify the migration is correct.` ) ); } diff --git a/packages/codemod/test/cli.test.ts b/packages/codemod/test/cli.test.ts index d33d6a233..56d3deff1 100644 --- a/packages/codemod/test/cli.test.ts +++ b/packages/codemod/test/cli.test.ts @@ -1,10 +1,13 @@ -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, it, expect, afterEach } from 'vitest'; -const CLI_PATH = path.resolve(__dirname, '../dist/cli.mjs'); +import { getMigration } from '../src/migrations/index.js'; +import { run } from '../src/runner.js'; +import { DiagnosticLevel } from '../src/types.js'; + +const migration = getMigration('v1-to-v2')!; let tempDir: string; @@ -13,40 +16,14 @@ function createTempDir(): string { return tempDir; } -function runCli(args: string[]): { stdout: string; stderr: string; exitCode: number } { - try { - const stdout = execFileSync('node', [CLI_PATH, ...args], { - encoding: 'utf8', - env: { ...process.env, NODE_NO_WARNINGS: '1' } - }); - return { stdout, stderr: '', exitCode: 0 }; - } catch (error: unknown) { - const e = error as { stdout: string; stderr: string; status: number }; - return { stdout: e.stdout ?? '', stderr: e.stderr ?? '', exitCode: e.status ?? 1 }; - } -} - afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); } }); -describe('CLI', () => { - it('--list works without a target-dir argument', () => { - const { stdout, exitCode } = runCli(['v1-to-v2', '--list']); - expect(exitCode).toBe(0); - expect(stdout).toContain('Available transforms'); - expect(stdout).toContain('imports'); - }); - - it('errors when target-dir is missing and --list is not set', () => { - const { stderr, exitCode } = runCli(['v1-to-v2']); - expect(exitCode).toBe(1); - expect(stderr).toContain('missing required argument'); - }); - - it('exits 0 when only warnings are present (no errors)', () => { +describe('CLI diagnostic behavior', () => { + it('warnings do not produce errors-level diagnostics', () => { const dir = createTempDir(); writeFileSync( path.join(dir, 'server.ts'), @@ -57,12 +34,15 @@ describe('CLI', () => { ].join('\n') ); - const { stdout, exitCode } = runCli(['v1-to-v2', dir]); - expect(stdout).toContain('Warning'); - expect(exitCode).toBe(0); + const result = run(migration, { targetDir: dir }); + + const warnings = result.diagnostics.filter(d => d.level === DiagnosticLevel.Warning); + const errors = result.diagnostics.filter(d => d.level === DiagnosticLevel.Error); + expect(warnings.length).toBeGreaterThan(0); + expect(errors.length).toBe(0); }); - it('prints info diagnostics', () => { + it('emits info-level diagnostics for z.object() wrapping', () => { const dir = createTempDir(); writeFileSync( path.join(dir, 'server.ts'), @@ -76,8 +56,24 @@ describe('CLI', () => { ].join('\n') ); - const { stdout, exitCode } = runCli(['v1-to-v2', dir]); - expect(stdout).toContain('Info'); - expect(exitCode).toBe(0); + const result = run(migration, { targetDir: dir }); + + const infos = result.diagnostics.filter(d => d.level === DiagnosticLevel.Info); + expect(infos.length).toBeGreaterThan(0); + expect(infos.some(d => d.message.includes('z.object'))).toBe(true); + }); +}); + +describe('CLI command declaration', () => { + it('v1-to-v2 migration is registered and has transforms', () => { + expect(migration).toBeDefined(); + expect(migration.transforms.length).toBeGreaterThan(0); + }); + + it('all transforms have an id and name', () => { + for (const t of migration.transforms) { + expect(t.id).toBeTruthy(); + expect(t.name).toBeTruthy(); + } }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts index 1cde28886..62afe9969 100644 --- a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -212,6 +212,22 @@ describe('symbol-renames transform', () => { expect(result).not.toMatch(/import.*SchemaInput/); }); + it('imports both ServerContext and ClientContext when file has both generic arg types', () => { + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type S = RequestHandlerExtra;`, + `type C = RequestHandlerExtra;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('type S = ServerContext;'); + expect(result).toContain('type C = ClientContext;'); + expect(result).toMatch(/import.*ServerContext/); + expect(result).toMatch(/import.*ClientContext/); + expect(result).not.toContain('RequestHandlerExtra'); + }); + it('is idempotent for SchemaInput transform', () => { const input = [ `import type { SchemaInput } from '@modelcontextprotocol/server';`, From 1f2b15af12339e853d733c58798091aae30e5ba8 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 22:12:15 +0300 Subject: [PATCH 06/22] lint fix --- .../src/migrations/v1-to-v2/transforms/mockPaths.ts | 1 - .../src/migrations/v1-to-v2/transforms/symbolRenames.ts | 8 ++------ 2 files changed, 2 insertions(+), 7 deletions(-) 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 d94282e23..660647f1b 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -29,7 +29,6 @@ export const mockPathsTransform: Transform = { changesCount += rewriteMockCall(call, sourceFile, context, diagnostics); } } - } changesCount += rewriteDynamicImports(sourceFile, context, diagnostics); diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts index 9144834c2..9866ebe01 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts @@ -197,18 +197,14 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon if (needsClientContext) newImports.push({ name: 'ClientContext', target: '@modelcontextprotocol/client' }); for (const { name, target } of newImports) { - const existingImp = sourceFile - .getImportDeclarations() - .find(i => i.getModuleSpecifierValue() === target && i.isTypeOnly()); + const existingImp = sourceFile.getImportDeclarations().find(i => i.getModuleSpecifierValue() === target && i.isTypeOnly()); if (existingImp) { const existingNames = new Set(existingImp.getNamedImports().map(n => n.getName())); if (!existingNames.has(name)) { existingImp.addNamedImports([name]); } } else { - const valueImp = sourceFile - .getImportDeclarations() - .find(i => i.getModuleSpecifierValue() === target && !i.isTypeOnly()); + const valueImp = sourceFile.getImportDeclarations().find(i => i.getModuleSpecifierValue() === target && !i.isTypeOnly()); if (valueImp) { const existingNames = new Set(valueImp.getNamedImports().map(n => n.getName())); if (!existingNames.has(name)) { From b77cc9b517811b1ca8af273efd8a4a1f2b529171 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 22:24:14 +0300 Subject: [PATCH 07/22] add codemod to pr-pkg-new --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 959e29e23..f78f1463c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,4 +40,4 @@ jobs: - name: Publish preview packages run: pnpm dlx pkg-pr-new publish --packageManager=npm --pnpm './packages/server' './packages/client' - './packages/middleware/express' './packages/middleware/fastify' './packages/middleware/hono' './packages/middleware/node' + './packages/codemod' './packages/middleware/express' './packages/middleware/fastify' './packages/middleware/hono' './packages/middleware/node' From 9c292f8dd78efbf406737b85e6862d82e7000ee5 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 22:33:42 +0300 Subject: [PATCH 08/22] fixes --- .../v1-to-v2/transforms/mockPaths.ts | 23 +++++++++++++++++-- .../v1-to-v2/transforms/symbolRenames.ts | 21 ++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) 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 660647f1b..edfe1e45e 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -134,7 +134,7 @@ function renameSymbolsInFactory(factoryArg: import('ts-morph').Node, renamedSymb return changes; } -function rewriteDynamicImports(sourceFile: SourceFile, context: TransformContext, _diagnostics: ReturnType[]): number { +function rewriteDynamicImports(sourceFile: SourceFile, context: TransformContext, diagnostics: ReturnType[]): number { let changes = 0; sourceFile.forEachDescendant(node => { @@ -153,7 +153,26 @@ function rewriteDynamicImports(sourceFile: SourceFile, context: TransformContext if (!isSdkSpecifier(specifier)) return; const resolved = resolveTarget(specifier, context, sourceFile); - if (resolved === null || resolved === 'removed') return; + if (resolved === null) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + node.getStartLineNumber(), + `Unknown SDK dynamic import path: ${specifier}. Manual migration required.` + ) + ); + return; + } + if (resolved === 'removed') { + diagnostics.push( + warning( + sourceFile.getFilePath(), + node.getStartLineNumber(), + `Dynamic import references removed SDK path: ${specifier}. Manual migration required.` + ) + ); + return; + } firstArg.setLiteralValue(resolved.target); changes++; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts index 9866ebe01..5a83ce9d1 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts @@ -21,6 +21,7 @@ export const symbolRenamesTransform: Transform = { const imports = sourceFile.getImportDeclarations(); for (const imp of imports) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; for (const namedImport of imp.getNamedImports()) { const name = namedImport.getName(); const newName = SIMPLE_RENAMES[name]; @@ -50,6 +51,7 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: ReturnType[0] | undefined; for (const imp of imports) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; for (const namedImport of imp.getNamedImports()) { if (namedImport.getName() === 'ErrorCode') { errorCodeImport = namedImport; @@ -81,7 +83,15 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: ReturnType 0) { + const errorCodeImportDecl = errorCodeImport.getImportDeclaration(); errorCodeImport.remove(); + if ( + errorCodeImportDecl.getNamedImports().length === 0 && + !errorCodeImportDecl.getDefaultImport() && + !errorCodeImportDecl.getNamespaceImport() + ) { + errorCodeImportDecl.remove(); + } const imp = sourceFile.getImportDeclarations().find(i => { const spec = i.getModuleSpecifierValue(); @@ -131,6 +141,7 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon let extraImportDecl: (typeof imports)[0] | undefined; for (const imp of imports) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; for (const namedImport of imp.getNamedImports()) { if (namedImport.getName() === 'RequestHandlerExtra') { extraImport = namedImport; @@ -190,7 +201,15 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon }); if (changesCount > 0) { + const extraImportLine = extraImportDecl!.getStartLineNumber(); extraImport.remove(); + if ( + extraImportDecl!.getNamedImports().length === 0 && + !extraImportDecl!.getDefaultImport() && + !extraImportDecl!.getNamespaceImport() + ) { + extraImportDecl!.remove(); + } const newImports: Array<{ name: string; target: string }> = []; if (needsServerContext) newImports.push({ name: 'ServerContext', target: '@modelcontextprotocol/server' }); @@ -226,7 +245,7 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon diagnostics.push( warning( sourceFile.getFilePath(), - extraImportDecl!.getStartLineNumber(), + extraImportLine, `RequestHandlerExtra renamed to ${targets}. Generic type arguments removed. Verify the migration is correct.` ) ); From 50e24f1e4af9f53fb3f453544c51a4fa24c78230 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 22:35:07 +0300 Subject: [PATCH 09/22] add tests --- .../v1-to-v2/transforms/mockPaths.test.ts | 18 +++++ .../v1-to-v2/transforms/symbolRenames.test.ts | 66 +++++++++++++++++++ 2 files changed, 84 insertions(+) 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 e6199fd9c..bae680796 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts @@ -148,6 +148,24 @@ describe('mock-paths transform', () => { expect(second).toBe(first); }); + it('emits warning for unknown SDK dynamic import path', () => { + const input = [`const m = await import('@modelcontextprotocol/sdk/unknown/path.js');`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Unknown SDK dynamic import path'); + }); + + it('emits warning for removed SDK dynamic import path', () => { + const input = [`const m = await import('@modelcontextprotocol/sdk/server/sse.js');`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Dynamic import references removed SDK path'); + }); + it('emits warning for unknown SDK mock path', () => { const input = [`vi.doMock('@modelcontextprotocol/sdk/unknown/path.js', () => ({}));`, ''].join('\n'); const project = new Project({ useInMemoryFileSystem: true }); diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts index 62afe9969..e71ec437e 100644 --- a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -228,6 +228,72 @@ describe('symbol-renames transform', () => { expect(result).not.toContain('RequestHandlerExtra'); }); + it('does not rename symbols from non-MCP imports', () => { + const input = [ + `import { ErrorCode } from '@grpc/grpc-js';`, + `import { ResourceReference } from '@google-cloud/asset';`, + `if (err.code === ErrorCode.NOT_FOUND) {}`, + `const ref: ResourceReference = {};`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ErrorCode.NOT_FOUND'); + expect(result).toContain('ResourceReference'); + expect(result).not.toContain('ProtocolErrorCode'); + expect(result).not.toContain('SdkErrorCode'); + expect(result).not.toContain('ResourceTemplateReference'); + }); + + it('does not split ErrorCode from non-MCP imports', () => { + const input = [ + `import { ErrorCode } from '@grpc/grpc-js';`, + `const a = ErrorCode.NOT_FOUND;`, + `const b = ErrorCode.CANCELLED;`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = symbolRenamesTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toContain('ErrorCode.NOT_FOUND'); + expect(sourceFile.getFullText()).toContain('ErrorCode.CANCELLED'); + }); + + it('does not rename RequestHandlerExtra from non-MCP imports', () => { + const input = [ + `import type { RequestHandlerExtra } from './my-local-types.js';`, + `type MyHandler = (extra: RequestHandlerExtra) => void;`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = symbolRenamesTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toContain('RequestHandlerExtra'); + expect(sourceFile.getFullText()).not.toContain('ServerContext'); + }); + + it('cleans up empty import declaration after ErrorCode split', () => { + const input = [ + `import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, + `const a = ErrorCode.InvalidParams;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toMatch(/import\s*\{\s*\}/); + }); + + it('cleans up empty import declaration after RequestHandlerExtra removal', () => { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type Extra = RequestHandlerExtra;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toMatch(/import\s+type\s*\{\s*\}/); + expect(result).not.toContain('@modelcontextprotocol/sdk/shared/protocol.js'); + }); + it('is idempotent for SchemaInput transform', () => { const input = [ `import type { SchemaInput } from '@modelcontextprotocol/server';`, From 44b2c6716f306e3ec36c2d6d14427d501055f8e2 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 22:36:45 +0300 Subject: [PATCH 10/22] lint fix --- .../test/v1-to-v2/transforms/symbolRenames.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts index e71ec437e..a0e3114cb 100644 --- a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -274,11 +274,9 @@ describe('symbol-renames transform', () => { }); it('cleans up empty import declaration after ErrorCode split', () => { - const input = [ - `import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, - `const a = ErrorCode.InvalidParams;`, - '' - ].join('\n'); + const input = [`import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, `const a = ErrorCode.InvalidParams;`, ''].join( + '\n' + ); const result = applyTransform(input); expect(result).not.toMatch(/import\s*\{\s*\}/); }); From cfac1973adf34d587b7becac0c16dc4d7b7a73ee Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 23 Apr 2026 22:58:31 +0300 Subject: [PATCH 11/22] runner and test fix --- packages/codemod/src/runner.ts | 21 ++++++++++--- packages/codemod/test/cli.test.ts | 51 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/codemod/src/runner.ts b/packages/codemod/src/runner.ts index 6f45f1c2a..5fa5f8477 100644 --- a/packages/codemod/src/runner.ts +++ b/packages/codemod/src/runner.ts @@ -7,9 +7,18 @@ import { analyzeProject } from './utils/projectAnalyzer.js'; export function run(migration: Migration, options: RunnerOptions): RunnerResult { const context = analyzeProject(options.targetDir); - const enabledTransforms = options.transforms - ? migration.transforms.filter(t => options.transforms!.includes(t.id)) - : migration.transforms; + let enabledTransforms = migration.transforms; + if (options.transforms) { + const validIds = new Set(migration.transforms.map(t => t.id)); + const unknown = options.transforms.filter(id => !validIds.has(id)); + if (unknown.length > 0) { + throw new Error( + `Unknown transform ID(s): ${unknown.join(', ')}. ` + + `Available: ${[...validIds].join(', ')}. Use --list to see all transforms.` + ); + } + enabledTransforms = migration.transforms.filter(t => options.transforms!.includes(t.id)); + } const project = new Project({ tsConfigFilePath: undefined, @@ -30,6 +39,8 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult '**/.nuxt/**', '**/coverage/**', '**/__generated__/**', + '**/*.d.ts', + '**/*.d.mts', ...(options.ignore ?? []) ]; @@ -41,7 +52,9 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult const sourceFiles = project.getSourceFiles().filter(sf => { const fp = sf.getFilePath(); - return !fp.includes('/node_modules/') && !fp.includes('/dist/'); + if (fp.includes('/node_modules/') || fp.includes('/dist/')) return false; + if (fp.endsWith('.d.ts') || fp.endsWith('.d.mts')) return false; + return true; }); const fileResults: FileResult[] = []; const allDiagnostics: Diagnostic[] = []; diff --git a/packages/codemod/test/cli.test.ts b/packages/codemod/test/cli.test.ts index 56d3deff1..c6379ca83 100644 --- a/packages/codemod/test/cli.test.ts +++ b/packages/codemod/test/cli.test.ts @@ -64,6 +64,57 @@ describe('CLI diagnostic behavior', () => { }); }); +describe('--transforms validation', () => { + it('throws on unknown transform IDs', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'test.ts'), `const x = 1;\n`); + + expect(() => run(migration, { targetDir: dir, transforms: ['import-paths', 'symbol-renames'] })).toThrow(/Unknown transform ID/); + }); + + it('error message lists the unknown IDs and available IDs', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'test.ts'), `const x = 1;\n`); + + expect(() => run(migration, { targetDir: dir, transforms: ['bogus'] })).toThrow(/bogus.*Available:/); + }); + + it('accepts valid transform IDs', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'test.ts'), + [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `throw new McpError(1, 'e');`, ''].join('\n') + ); + + const result = run(migration, { targetDir: dir, transforms: ['symbols'] }); + expect(result.totalChanges).toBeGreaterThan(0); + }); +}); + +describe('.d.ts exclusion', () => { + it('skips .d.ts files', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'types.d.ts'), + [`import type { McpError } from '@modelcontextprotocol/sdk/types.js';`, `export type E = McpError;`, ''].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + expect(result.filesChanged).toBe(0); + }); + + it('skips .d.mts files', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'types.d.mts'), + [`import type { McpError } from '@modelcontextprotocol/sdk/types.js';`, `export type E = McpError;`, ''].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + expect(result.filesChanged).toBe(0); + }); +}); + describe('CLI command declaration', () => { it('v1-to-v2 migration is registered and has transforms', () => { expect(migration).toBeDefined(); From 321e9184c3ab8d9173ea9688903672ebbea6740b Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 24 Apr 2026 10:46:27 +0300 Subject: [PATCH 12/22] fixes --- .../v1-to-v2/transforms/importPaths.ts | 5 +++++ .../v1-to-v2/transforms/removedApis.ts | 1 + packages/codemod/src/utils/astUtils.ts | 5 +++++ .../v1-to-v2/transforms/importPaths.test.ts | 15 +++++++++++++ .../v1-to-v2/transforms/removedApis.test.ts | 12 ++++++++++ .../v1-to-v2/transforms/symbolRenames.test.ts | 22 +++++++++++++++++++ 6 files changed, 60 insertions(+) diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 11451303d..a5f8b3092 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -103,6 +103,11 @@ export const importPathsTransform: Transform = { n.setName(newName); } } + if (namespaceImport) { + diagnostics.push(warning(filePath, line, + `Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` + + `were renamed in ${targetPackage}. Update qualified accesses manually.`)); + } } changesCount++; continue; 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 20abfa281..d84899d57 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts @@ -57,6 +57,7 @@ function handleRemovedZodHelpers(sourceFile: SourceFile, diagnostics: Diagnostic for (const removal of removals) { for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; for (const namedImport of imp.getNamedImports()) { if (namedImport.getName() === removal.importName) { namedImport.remove(); diff --git a/packages/codemod/src/utils/astUtils.ts b/packages/codemod/src/utils/astUtils.ts index 463aad196..d7d306f6a 100644 --- a/packages/codemod/src/utils/astUtils.ts +++ b/packages/codemod/src/utils/astUtils.ts @@ -7,11 +7,16 @@ export function renameAllReferences(sourceFile: SourceFile, oldName: string, new const parent = node.getParent(); if (!parent) return; if (Node.isImportSpecifier(parent)) return; + if (Node.isExportSpecifier(parent)) return; if (Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return; if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return; if (Node.isPropertySignature(parent) && parent.getNameNode() === node) return; if (Node.isMethodDeclaration(parent) && parent.getNameNode() === node) return; if (Node.isPropertyDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isShorthandPropertyAssignment(parent)) { + parent.replaceWithText(`${oldName}: ${newName}`); + return; + } node.replaceWithText(newName); } }); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index 7aa1cab5d..0be2f5f97 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -173,6 +173,21 @@ describe('import-paths transform', () => { expect(result).toContain('@modelcontextprotocol/node'); }); + it('emits warning for namespace import with renamedSymbols', () => { + const input = [ + `import * as transport from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const t = new transport.StreamableHTTPServerTransport({});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(sourceFile.getFullText()).toContain('import * as transport'); + expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/node'); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics.some(d => d.message.includes('renamed') && d.message.includes('StreamableHTTPServerTransport'))).toBe(true); + }); + it('removes auth imports with warning', () => { const input = `import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';\n`; const project = new Project({ useInMemoryFileSystem: true }); 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 40e8ea606..8621a756f 100644 --- a/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts @@ -82,6 +82,18 @@ describe('removed-apis transform', () => { expect(result.changesCount).toBe(0); }); + it('does not remove same-named import from non-MCP package when MCP import is also present', () => { + const input = [ + `import { McpServer, schemaToJson } from '@modelcontextprotocol/server';`, + `import { schemaToJson as otherToJson } from 'some-json-schema-lib';`, + `const json = otherToJson(schema);`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain("import { schemaToJson as otherToJson } from 'some-json-schema-lib'"); + expect(text).toContain('otherToJson(schema)'); + }); + it('is idempotent', () => { const input = [`import { schemaToJson } from '@modelcontextprotocol/server';`, `const json = schemaToJson(schema);`, ''].join( '\n' diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts index a0e3114cb..cf9a49ddd 100644 --- a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -292,6 +292,28 @@ describe('symbol-renames transform', () => { expect(result).not.toContain('@modelcontextprotocol/sdk/shared/protocol.js'); }); + it('preserves shorthand property keys when renaming', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `const errors = { McpError };`, + `throw new McpError(1, 'error');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('McpError: ProtocolError'); + expect(result).toContain('new ProtocolError'); + }); + + it('does not rename export specifiers', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `export { McpError };`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('export { McpError }'); + }); + it('is idempotent for SchemaInput transform', () => { const input = [ `import type { SchemaInput } from '@modelcontextprotocol/server';`, From dc25694f7902ca3690157fba11fa39b570da7829 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 24 Apr 2026 10:48:06 +0300 Subject: [PATCH 13/22] lint fix --- .../src/migrations/v1-to-v2/transforms/importPaths.ts | 11 ++++++++--- .../test/v1-to-v2/transforms/importPaths.test.ts | 4 +++- .../test/v1-to-v2/transforms/symbolRenames.test.ts | 6 +----- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index a5f8b3092..f4685e790 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -104,9 +104,14 @@ export const importPathsTransform: Transform = { } } if (namespaceImport) { - diagnostics.push(warning(filePath, line, - `Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` + - `were renamed in ${targetPackage}. Update qualified accesses manually.`)); + diagnostics.push( + warning( + filePath, + line, + `Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` + + `were renamed in ${targetPackage}. Update qualified accesses manually.` + ) + ); } } changesCount++; diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index 0be2f5f97..6d0584748 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -185,7 +185,9 @@ describe('import-paths transform', () => { expect(sourceFile.getFullText()).toContain('import * as transport'); expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/node'); expect(result.diagnostics.length).toBeGreaterThan(0); - expect(result.diagnostics.some(d => d.message.includes('renamed') && d.message.includes('StreamableHTTPServerTransport'))).toBe(true); + expect(result.diagnostics.some(d => d.message.includes('renamed') && d.message.includes('StreamableHTTPServerTransport'))).toBe( + true + ); }); it('removes auth imports with warning', () => { diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts index cf9a49ddd..64c8a7aac 100644 --- a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -305,11 +305,7 @@ describe('symbol-renames transform', () => { }); it('does not rename export specifiers', () => { - const input = [ - `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, - `export { McpError };`, - '' - ].join('\n'); + const input = [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `export { McpError };`, ''].join('\n'); const result = applyTransform(input); expect(result).toContain('export { McpError }'); }); From 36a177470cb4b35b6e252e9d5791fafee394928c Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 24 Apr 2026 11:02:24 +0300 Subject: [PATCH 14/22] fix --- .../src/migrations/v1-to-v2/transforms/mcpServerApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ae64f02ec..e62ad0988 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -3,7 +3,7 @@ import { Node, SyntaxKind } from 'ts-morph'; import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; import { info, warning } from '../../../utils/diagnostics.js'; -import { hasMcpImports } from '../../../utils/importUtils.js'; +import { isImportedFromMcp } from '../../../utils/importUtils.js'; export const mcpServerApiTransform: Transform = { name: 'McpServer API migration', @@ -12,7 +12,7 @@ export const mcpServerApiTransform: Transform = { const diagnostics: Diagnostic[] = []; let changesCount = 0; - if (!hasMcpImports(sourceFile)) { + if (!isImportedFromMcp(sourceFile, 'McpServer')) { return { changesCount: 0, diagnostics: [] }; } From 174a8b6c302784f62c1cbf04a7093ba7307d259f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 24 Apr 2026 11:15:41 +0300 Subject: [PATCH 15/22] astUtils fix --- packages/codemod/src/utils/astUtils.ts | 6 +++++- .../codemod/test/v1-to-v2/transforms/symbolRenames.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/codemod/src/utils/astUtils.ts b/packages/codemod/src/utils/astUtils.ts index d7d306f6a..91b769bc9 100644 --- a/packages/codemod/src/utils/astUtils.ts +++ b/packages/codemod/src/utils/astUtils.ts @@ -7,7 +7,11 @@ export function renameAllReferences(sourceFile: SourceFile, oldName: string, new const parent = node.getParent(); if (!parent) return; if (Node.isImportSpecifier(parent)) return; - if (Node.isExportSpecifier(parent)) return; + if (Node.isExportSpecifier(parent)) { + if (!parent.getAliasNode()) parent.setAlias(oldName); + parent.getNameNode().replaceWithText(newName); + return; + } if (Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return; if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return; if (Node.isPropertySignature(parent) && parent.getNameNode() === node) return; diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts index 64c8a7aac..59e441b3d 100644 --- a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -304,10 +304,10 @@ describe('symbol-renames transform', () => { expect(result).toContain('new ProtocolError'); }); - it('does not rename export specifiers', () => { + it('preserves export specifier public name with alias', () => { const input = [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `export { McpError };`, ''].join('\n'); const result = applyTransform(input); - expect(result).toContain('export { McpError }'); + expect(result).toContain('export { ProtocolError as McpError }'); }); it('is idempotent for SchemaInput transform', () => { From cf990e30590bc319f70b3a30778c14e40d12d9b1 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 24 Apr 2026 13:35:40 +0300 Subject: [PATCH 16/22] fixes and improvements --- .../v1-to-v2/transforms/contextTypes.ts | 54 ++++++++++-- .../v1-to-v2/transforms/expressMiddleware.ts | 5 ++ .../transforms/handlerRegistration.ts | 6 +- .../v1-to-v2/transforms/importPaths.ts | 20 +++-- .../v1-to-v2/transforms/mcpServerApi.ts | 2 + .../v1-to-v2/transforms/schemaParamRemoval.ts | 2 +- .../v1-to-v2/transforms/symbolRenames.ts | 14 +++- packages/codemod/src/utils/astUtils.ts | 4 + packages/codemod/src/utils/importUtils.ts | 6 +- packages/codemod/test/projectAnalyzer.test.ts | 13 +++ .../v1-to-v2/transforms/contextTypes.test.ts | 84 ++++++++++++++++++- .../transforms/expressMiddleware.test.ts | 11 +++ .../transforms/handlerRegistration.test.ts | 25 ++++++ .../v1-to-v2/transforms/importPaths.test.ts | 35 ++++++++ .../v1-to-v2/transforms/mcpServerApi.test.ts | 9 ++ .../v1-to-v2/transforms/symbolRenames.test.ts | 59 +++++++++++++ 16 files changed, 327 insertions(+), 22 deletions(-) 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 87a622b04..26bdc89de 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts @@ -3,6 +3,7 @@ import { Node, SyntaxKind } from 'ts-morph'; import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; import { warning } from '../../../utils/diagnostics.js'; +import { hasMcpImports } from '../../../utils/importUtils.js'; import { CONTEXT_PROPERTY_MAP, CTX_PARAM_NAME, EXTRA_PARAM_NAME } from '../mappings/contextPropertyMap.js'; const HANDLER_METHODS = new Set(['setRequestHandler', 'setNotificationHandler']); @@ -13,6 +14,10 @@ export const contextTypesTransform: Transform = { name: 'Context type rewrites', id: 'context', apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + if (!hasMcpImports(sourceFile)) { + return { changesCount: 0, diagnostics: [] }; + } + let changesCount = 0; const diagnostics: Diagnostic[] = []; @@ -43,14 +48,45 @@ export const contextTypesTransform: Transform = { if (params.length < 2) continue; const extraParam = params[1]!; + const paramNameNode = extraParam.getNameNode(); + if (Node.isObjectBindingPattern(paramNameNode)) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + extraParam.getStartLineNumber(), + `Destructuring of context parameter in signature: "${paramNameNode.getText()}". ` + + 'Properties have been reorganized in v2 (e.g., signal is now ctx.mcpReq.signal). Manual refactoring required.' + ) + ); + continue; + } const paramName = extraParam.getName(); if (paramName !== EXTRA_PARAM_NAME) continue; + const body = callbackArg.getBody(); + + if (body) { + let ctxAlreadyInScope = false; + body.forEachDescendant(node => { + if (Node.isIdentifier(node) && node.getText() === CTX_PARAM_NAME) { + ctxAlreadyInScope = true; + } + }); + if (ctxAlreadyInScope) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + extraParam.getStartLineNumber(), + `Cannot rename '${EXTRA_PARAM_NAME}' to '${CTX_PARAM_NAME}': '${CTX_PARAM_NAME}' is already referenced in this scope. Manual migration required.` + ) + ); + continue; + } + } + extraParam.rename(CTX_PARAM_NAME); changesCount++; - const body = callbackArg.getBody(); - if (!body) continue; body.forEachDescendant(node => { @@ -58,12 +94,20 @@ export const contextTypesTransform: Transform = { const fullText = node.getText(); for (const mapping of CONTEXT_PROPERTY_MAP) { + if (mapping.from === mapping.to) continue; + const oldPattern = CTX_PARAM_NAME + mapping.from; - if (fullText.startsWith(oldPattern)) { - const nextChar = fullText[oldPattern.length]; + const oldPatternOptional = CTX_PARAM_NAME + '?' + mapping.from; + const matchedPattern = fullText.startsWith(oldPattern) + ? oldPattern + : fullText.startsWith(oldPatternOptional) + ? oldPatternOptional + : null; + if (matchedPattern) { + const nextChar = fullText[matchedPattern.length]; if (nextChar !== undefined && /[a-zA-Z0-9_$]/.test(nextChar)) continue; - const newText = fullText.replace(oldPattern, CTX_PARAM_NAME + mapping.to); + const newText = fullText.replace(matchedPattern, CTX_PARAM_NAME + mapping.to); node.replaceWithText(newText); changesCount++; return; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts index c1c1f77ac..72e8137be 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts @@ -3,11 +3,16 @@ import { Node, SyntaxKind } from 'ts-morph'; import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; import { info } from '../../../utils/diagnostics.js'; +import { hasMcpImports } from '../../../utils/importUtils.js'; export const expressMiddlewareTransform: Transform = { name: 'Express middleware signature migration', id: 'express-middleware', apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + if (!hasMcpImports(sourceFile)) { + return { changesCount: 0, diagnostics: [] }; + } + const diagnostics: Diagnostic[] = []; let changesCount = 0; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts index 9074c1a36..34a088fe7 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts @@ -2,7 +2,7 @@ import type { SourceFile } from 'ts-morph'; import { Node, SyntaxKind } from 'ts-morph'; import type { Transform, TransformContext, TransformResult } from '../../../types.js'; -import { removeUnusedImport } from '../../../utils/importUtils.js'; +import { isImportedFromMcp, removeUnusedImport } from '../../../utils/importUtils.js'; import { NOTIFICATION_SCHEMA_TO_METHOD, SCHEMA_TO_METHOD } from '../mappings/schemaToMethodMap.js'; const ALL_SCHEMA_TO_METHOD: Record = { @@ -37,10 +37,12 @@ export const handlerRegistrationTransform: Transform = { const methodString = ALL_SCHEMA_TO_METHOD[schemaName]; if (!methodString) continue; + if (!isImportedFromMcp(sourceFile, schemaName)) continue; + firstArg.replaceWithText(`'${methodString}'`); changesCount++; - removeUnusedImport(sourceFile, schemaName); + removeUnusedImport(sourceFile, schemaName, true); } return { changesCount, diagnostics: [] }; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index f4685e790..c4773f945 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -118,13 +118,12 @@ export const importPathsTransform: Transform = { continue; } - const names = namedImports.map(n => n.getName()); - let resolvedNames = names; - if (mapping.renamedSymbols) { - resolvedNames = names.map(name => mapping.renamedSymbols?.[name] ?? name); + for (const n of namedImports) { + const name = n.getName(); + const resolvedName = mapping.renamedSymbols?.[name] ?? name; + const specifierTypeOnly = typeOnly || n.isTypeOnly(); + addPending(targetPackage, [resolvedName], specifierTypeOnly); } - - addPending(targetPackage, resolvedNames, typeOnly); imp.remove(); changesCount++; } @@ -205,6 +204,15 @@ function rewriteExportDeclarations( } exp.setModuleSpecifier(targetPackage); + if (mapping.renamedSymbols) { + for (const spec of exp.getNamedExports()) { + const newName = mapping.renamedSymbols[spec.getName()]; + if (newName) { + if (!spec.getAliasNode()) spec.setAlias(spec.getName()); + spec.setName(newName); + } + } + } changesCount++; } 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 e62ad0988..2d13b7b28 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -273,6 +273,8 @@ function migrateResourceCall(call: CallExpression, _sourceFile: SourceFile): boo } else if (args.length === 4) { // server.resource(name, uri, metadata, callback) → server.registerResource(name, uri, metadata, callback) // Already has metadata, just rename the method + } else { + return false; } return true; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts index f03d6c2a6..4507797f3 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts @@ -43,5 +43,5 @@ export const schemaParamRemovalTransform: Transform = { function isSchemaReference(node: Node): boolean { if (!Node.isIdentifier(node)) return false; const text = node.getText(); - return text.endsWith('Schema') || text.endsWith('ResultSchema'); + return text.endsWith('Schema'); } diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts index 5a83ce9d1..9450108e6 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts @@ -63,13 +63,15 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: ReturnType { if (!Node.isPropertyAccessExpression(node)) return; const expr = node.getExpression(); - if (!Node.isIdentifier(expr) || expr.getText() !== 'ErrorCode') return; + if (!Node.isIdentifier(expr) || expr.getText() !== errorCodeLocalName) return; const member = node.getName(); if (ERROR_CODE_SDK_MEMBERS.has(member)) { @@ -154,6 +156,8 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon if (!extraImport) return 0; + const extraLocalName = extraImport.getAliasNode()?.getText() ?? 'RequestHandlerExtra'; + const isClientFile = sourceFile.getImportDeclarations().some(i => { const spec = i.getModuleSpecifierValue(); return spec.includes('/client/') || spec === '@modelcontextprotocol/client'; @@ -176,7 +180,7 @@ function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformCon sourceFile.forEachDescendant(node => { if (!Node.isTypeReference(node)) return; const typeName = node.getTypeName(); - if (!Node.isIdentifier(typeName) || typeName.getText() !== 'RequestHandlerExtra') return; + if (!Node.isIdentifier(typeName) || typeName.getText() !== extraLocalName) return; let target = defaultTarget; const typeArgs = node.getTypeArguments(); @@ -275,10 +279,12 @@ function handleSchemaInput(sourceFile: SourceFile, context: TransformContext, di if (!schemaInputImport || !schemaInputImportDecl) return 0; + const schemaInputLocalName = schemaInputImport.getAliasNode()?.getText() ?? 'SchemaInput'; + sourceFile.forEachDescendant(node => { if (!Node.isTypeReference(node)) return; const typeName = node.getTypeName(); - if (!Node.isIdentifier(typeName) || typeName.getText() !== 'SchemaInput') return; + if (!Node.isIdentifier(typeName) || typeName.getText() !== schemaInputLocalName) return; const typeArgs = node.getTypeArguments(); if (typeArgs.length > 0) { @@ -302,7 +308,7 @@ function handleSchemaInput(sourceFile: SourceFile, context: TransformContext, di const isClientFile = sourceFile.getImportDeclarations().some(i => { const spec = i.getModuleSpecifierValue(); - return spec.includes('/client') || spec === '@modelcontextprotocol/client'; + return spec.includes('/client/') || spec === '@modelcontextprotocol/client'; }); const isServerFile = sourceFile.getImportDeclarations().some(i => { const spec = i.getModuleSpecifierValue(); diff --git a/packages/codemod/src/utils/astUtils.ts b/packages/codemod/src/utils/astUtils.ts index 91b769bc9..41acad453 100644 --- a/packages/codemod/src/utils/astUtils.ts +++ b/packages/codemod/src/utils/astUtils.ts @@ -16,7 +16,11 @@ export function renameAllReferences(sourceFile: SourceFile, oldName: string, new if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return; if (Node.isPropertySignature(parent) && parent.getNameNode() === node) return; if (Node.isMethodDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isMethodSignature(parent) && parent.getNameNode() === node) return; if (Node.isPropertyDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isEnumMember(parent) && parent.getNameNode() === node) return; + if (Node.isGetAccessorDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isSetAccessorDeclaration(parent) && parent.getNameNode() === node) return; if (Node.isShorthandPropertyAssignment(parent)) { parent.replaceWithText(`${oldName}: ${newName}`); return; diff --git a/packages/codemod/src/utils/importUtils.ts b/packages/codemod/src/utils/importUtils.ts index 3e8f9d465..b7e92abe2 100644 --- a/packages/codemod/src/utils/importUtils.ts +++ b/packages/codemod/src/utils/importUtils.ts @@ -56,7 +56,8 @@ export function addOrMergeImport( existing.addNamedImports(newNames); } } else { - sourceFile.insertImportDeclaration(insertIndex, { + const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length); + sourceFile.insertImportDeclaration(clampedIndex, { moduleSpecifier, namedImports: [...new Set(namedImports)], isTypeOnly @@ -79,7 +80,7 @@ export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): b }); } -export function removeUnusedImport(sourceFile: SourceFile, symbolName: string): void { +export function removeUnusedImport(sourceFile: SourceFile, symbolName: string, onlyMcpImports?: boolean): void { let referenceCount = 0; sourceFile.forEachDescendant(node => { if (Node.isIdentifier(node) && node.getText() === symbolName) { @@ -92,6 +93,7 @@ export function removeUnusedImport(sourceFile: SourceFile, symbolName: string): if (referenceCount === 0) { for (const imp of sourceFile.getImportDeclarations()) { + if (onlyMcpImports && !isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; for (const namedImport of imp.getNamedImports()) { if (namedImport.getName() === symbolName) { namedImport.remove(); diff --git a/packages/codemod/test/projectAnalyzer.test.ts b/packages/codemod/test/projectAnalyzer.test.ts index da5a918c7..0f69eacf7 100644 --- a/packages/codemod/test/projectAnalyzer.test.ts +++ b/packages/codemod/test/projectAnalyzer.test.ts @@ -114,4 +114,17 @@ describe('analyzeProject', () => { const result = analyzeProject(dir); expect(result.projectType).toBe('server'); }); + + it('returns unknown for v1 SDK package (falls through to per-file resolution)', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }) + ); + + const result = analyzeProject(dir); + expect(result.projectType).toBe('unknown'); + }); }); 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 64b16d8a2..a27e892ba 100644 --- a/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts @@ -6,9 +6,11 @@ import type { TransformContext } from '../../../src/types.js'; const ctx: TransformContext = { projectType: 'server' }; +const MCP_IMPORT = `import { McpServer } from '@modelcontextprotocol/server';\n`; + function applyTransform(code: string): string { const project = new Project({ useInMemoryFileSystem: true }); - const sourceFile = project.createSourceFile('test.ts', code); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + code); contextTypesTransform.apply(sourceFile, ctx); return sourceFile.getFullText(); } @@ -132,12 +134,28 @@ describe('context-types transform', () => { '' ].join('\n'); const project = new Project({ useInMemoryFileSystem: true }); - const sourceFile = project.createSourceFile('test.ts', input); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); const result = contextTypesTransform.apply(sourceFile, ctx); expect(result.diagnostics.length).toBeGreaterThan(0); expect(result.diagnostics[0]!.message).toContain('Destructuring'); }); + it('emits warning when context parameter is destructured in signature', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, { signal, authInfo }) => {`, + ` if (signal.aborted) return;`, + ` return { content: [] };`, + `});`, + '' + ].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.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Destructuring'); + expect(result.diagnostics[0]!.message).toContain('signal'); + }); + it('works with registerTool callbacks', () => { const input = [ `server.registerTool('test', {}, async (args, extra) => {`, @@ -149,4 +167,66 @@ describe('context-types transform', () => { const result = applyTransform(input); expect(result).toContain('ctx.mcpReq.signal'); }); + + it('does not transform files without MCP imports', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toContain('extra.signal'); + }); + + it('emits warning when ctx variable already exists in scope', () => { + const input = [ + `const ctx = getApplicationContext();`, + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` console.log(ctx.appName);`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].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.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('ctx'); + expect(result.diagnostics[0]!.message).toContain('already referenced'); + expect(sourceFile.getFullText()).toContain('extra.signal'); + }); + + it('handles optional chaining on context properties', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const s = extra?.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.signal'); + expect(result).not.toContain('extra'); + }); + + it('does not inflate change count for identity mappings like sessionId', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const id = extra.sessionId;`, + ` return { content: [] };`, + `});`, + '' + ].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.changesCount).toBe(1); + expect(sourceFile.getFullText()).toContain('ctx.sessionId'); + }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts index 4d73b551e..e92bb67ee 100644 --- a/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts @@ -82,4 +82,15 @@ describe('express-middleware transform', () => { const { text: second } = applyTransform(first); expect(second).toBe(first); }); + + it('does not modify calls when hostHeaderValidation is not from MCP', () => { + const input = [ + `import { hostHeaderValidation } from './my-middleware.js';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(result.changesCount).toBe(0); + expect(text).toContain("{ allowedHosts: ['localhost'] }"); + }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts index 9e9ce9e80..5762a9abe 100644 --- a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts @@ -90,4 +90,29 @@ describe('handler-registration transform', () => { expect(result).toContain("'tools/call'"); expect(result).toContain("'tools/list'"); }); + + it('does not replace schema identifiers from non-MCP packages', () => { + const input = [ + `import { CallToolRequestSchema } from './local-schemas.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('CallToolRequestSchema'); + expect(result).not.toContain("'tools/call'"); + }); + + it('only removes MCP import when same symbol exists in non-MCP package', () => { + const input = [ + `import { CallToolRequestSchema } from './local-schemas.js';`, + `import { CallToolRequestSchema as McpSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(McpSchema, async () => ({ content: [] }));`, + `validateSchema(CallToolRequestSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("from './local-schemas.js'"); + }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index 6d0584748..2981ff127 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -190,6 +190,13 @@ describe('import-paths transform', () => { ); }); + it('rewrites re-export with renamedSymbols and preserves public name', () => { + const input = `export { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';\n`; + const result = applyTransform(input); + expect(result).toContain('@modelcontextprotocol/node'); + expect(result).toContain('NodeStreamableHTTPServerTransport as StreamableHTTPServerTransport'); + }); + it('removes auth imports with warning', () => { const input = `import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';\n`; const project = new Project({ useInMemoryFileSystem: true }); @@ -198,4 +205,32 @@ describe('import-paths transform', () => { expect(result.diagnostics.length).toBeGreaterThan(0); expect(result.diagnostics[0]!.message).toContain('auth removed'); }); + + it('handles per-specifier type modifiers', () => { + const input = [ + `import { McpServer, type ServerContext } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const s = new McpServer({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toMatch(/import\s*\{[^}]*McpServer[^}]*\}\s*from\s*['"]@modelcontextprotocol\/server['"]/); + expect(result).toMatch(/import\s+type\s*\{[^}]*ServerContext[^}]*\}\s*from\s*['"]@modelcontextprotocol\/server['"]/); + }); + + it('does not crash when value import merges into existing import', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/client';`, + `import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`, + `import type { Tool } from '@modelcontextprotocol/sdk/types.js';`, + `const c = new Client({});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'client' }); + expect(result.changesCount).toBeGreaterThan(0); + const output = sourceFile.getFullText(); + expect(output).toContain('@modelcontextprotocol/client'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + }); }); 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 1fbf7b9bc..fcfcfc084 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts @@ -95,4 +95,13 @@ describe('mcp-server-api transform', () => { expect(result).toContain('inputSchema: z.object({ name: z.string() })'); expect(result).not.toContain('z.object(z.object('); }); + + it('emits warning for .resource() with 5+ arguments', () => { + const input = [`server.resource('name', 'uri://x', metadata, callback, extraArg);`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Could not automatically migrate .resource()'); + }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts index 59e441b3d..6aff0010e 100644 --- a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -320,4 +320,63 @@ describe('symbol-renames transform', () => { const second = applyTransform(first); expect(second).toBe(first); }); + + it('handles aliased ErrorCode import', () => { + const input = [ + `import { ErrorCode as EC } from '@modelcontextprotocol/server';`, + `const a = EC.InvalidParams;`, + `const b = EC.RequestTimeout;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ProtocolErrorCode.InvalidParams'); + expect(result).toContain('SdkErrorCode.RequestTimeout'); + expect(result).not.toMatch(/\bEC\./); + }); + + it('handles aliased RequestHandlerExtra import', () => { + const input = [ + `import type { RequestHandlerExtra as RHE } from '@modelcontextprotocol/server';`, + `type MyHandler = (extra: RHE) => void;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ServerContext'); + expect(result).not.toMatch(/\bRHE\b/); + }); + + it('handles aliased SchemaInput import', () => { + const input = [ + `import type { SchemaInput as SI } from '@modelcontextprotocol/server';`, + `type Input = SI;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('StandardSchemaWithJSON.InferInput'); + expect(result).not.toMatch(/\bSI\b/); + }); + + it('does not rename method signature names that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/server';`, + `interface ErrorHandler { McpError(): void; }`, + `throw new McpError('test');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('interface ErrorHandler { McpError(): void; }'); + expect(result).toContain('new ProtocolError('); + }); + + it('does not rename enum member names that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/server';`, + `enum Errors { McpError = 1 }`, + `throw new McpError('test');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('enum Errors { McpError = 1 }'); + expect(result).toContain('new ProtocolError('); + }); }); From b8e397a2e98d2a59cc1f9a10ff9029f380d9c8ed Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 24 Apr 2026 16:01:21 +0300 Subject: [PATCH 17/22] fixes --- .../v1-to-v2/mappings/schemaToMethodMap.ts | 6 ++-- .../v1-to-v2/transforms/contextTypes.ts | 12 ++++++++ .../v1-to-v2/transforms/expressMiddleware.ts | 4 +-- .../v1-to-v2/transforms/mcpServerApi.ts | 4 +-- .../v1-to-v2/transforms/removedApis.ts | 8 +++-- .../v1-to-v2/transforms/schemaParamRemoval.ts | 4 +-- .../v1-to-v2/transforms/symbolRenames.ts | 2 +- .../v1-to-v2/transforms/contextTypes.test.ts | 16 ++++++++++ .../transforms/expressMiddleware.test.ts | 12 ++++++++ .../transforms/handlerRegistration.test.ts | 30 +++++++++++++++++-- .../v1-to-v2/transforms/mcpServerApi.test.ts | 3 ++ .../v1-to-v2/transforms/removedApis.test.ts | 26 ++++++++++++++++ .../transforms/schemaParamRemoval.test.ts | 11 +++++++ 13 files changed, 123 insertions(+), 15 deletions(-) diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts index 970e9e38c..37352a9ee 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts @@ -13,7 +13,8 @@ export const SCHEMA_TO_METHOD: Record = { ElicitRequestSchema: 'elicitation/create', SetLevelRequestSchema: 'logging/setLevel', PingRequestSchema: 'ping', - CompleteRequestSchema: 'completion/complete' + CompleteRequestSchema: 'completion/complete', + ListRootsRequestSchema: 'roots/list' }; export const NOTIFICATION_SCHEMA_TO_METHOD: Record = { @@ -24,5 +25,6 @@ export const NOTIFICATION_SCHEMA_TO_METHOD: Record = { ResourceUpdatedNotificationSchema: 'notifications/resources/updated', ProgressNotificationSchema: 'notifications/progress', CancelledNotificationSchema: 'notifications/cancelled', - InitializedNotificationSchema: 'notifications/initialized' + InitializedNotificationSchema: 'notifications/initialized', + RootsListChangedNotificationSchema: 'notifications/roots/list_changed' }; 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 26bdc89de..9eecdc432 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts @@ -65,6 +65,18 @@ export const contextTypesTransform: Transform = { const body = callbackArg.getBody(); + const otherParams = callbackArg.getParameters().filter(p => p !== extraParam); + if (otherParams.some(p => p.getName() === CTX_PARAM_NAME)) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + extraParam.getStartLineNumber(), + `Cannot rename '${EXTRA_PARAM_NAME}' to '${CTX_PARAM_NAME}': another parameter is already named '${CTX_PARAM_NAME}'. Manual migration required.` + ) + ); + continue; + } + if (body) { let ctxAlreadyInScope = false; body.forEachDescendant(node => { diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts index 72e8137be..5821c9109 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts @@ -3,13 +3,13 @@ import { Node, SyntaxKind } from 'ts-morph'; import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; import { info } from '../../../utils/diagnostics.js'; -import { hasMcpImports } from '../../../utils/importUtils.js'; +import { isImportedFromMcp } from '../../../utils/importUtils.js'; export const expressMiddlewareTransform: Transform = { name: 'Express middleware signature migration', id: 'express-middleware', apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { - if (!hasMcpImports(sourceFile)) { + if (!isImportedFromMcp(sourceFile, 'hostHeaderValidation')) { return { changesCount: 0, 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 2d13b7b28..a2f086b14 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -261,10 +261,9 @@ function migrateResourceCall(call: CallExpression, _sourceFile: SourceFile): boo const uriArg = args[1]!; const uriText = uriArg.getText(); - expr.getNameNode().replaceWithText('registerResource'); - if (args.length === 3) { // server.resource(name, uri, callback) → server.registerResource(name, uri, {}, callback) + expr.getNameNode().replaceWithText('registerResource'); const callbackText = args[2]!.getText(); for (let i = args.length - 1; i >= 0; i--) { call.removeArgument(i); @@ -273,6 +272,7 @@ function migrateResourceCall(call: CallExpression, _sourceFile: SourceFile): boo } else if (args.length === 4) { // server.resource(name, uri, metadata, callback) → server.registerResource(name, uri, metadata, callback) // Already has metadata, just rename the method + expr.getNameNode().replaceWithText('registerResource'); } else { return false; } 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 d84899d57..2e6fd23aa 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts @@ -93,9 +93,10 @@ function handleIsomorphicHeaders(sourceFile: SourceFile, diagnostics: Diagnostic if (!foundImport || !foundImportDecl) return 0; + const localName = foundImport.getAliasNode()?.getText() ?? 'IsomorphicHeaders'; const line = foundImportDecl.getStartLineNumber(); - renameAllReferences(sourceFile, 'IsomorphicHeaders', 'Headers'); + renameAllReferences(sourceFile, localName, 'Headers'); changesCount++; foundImport.remove(); @@ -134,12 +135,13 @@ function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnost if (!foundImport || !foundImportDecl) return 0; + const localName = foundImport.getAliasNode()?.getText() ?? 'StreamableHTTPError'; const line = foundImportDecl.getStartLineNumber(); const moduleSpec = foundImportDecl.getModuleSpecifierValue(); for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) { const expr = node.getExpression(); - if (!Node.isIdentifier(expr) || expr.getText() !== 'StreamableHTTPError') continue; + if (!Node.isIdentifier(expr) || expr.getText() !== localName) continue; diagnostics.push( warning( sourceFile.getFilePath(), @@ -150,7 +152,7 @@ function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnost ); } - renameAllReferences(sourceFile, 'StreamableHTTPError', 'SdkError'); + renameAllReferences(sourceFile, localName, 'SdkError'); changesCount++; foundImport.remove(); diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts index 4507797f3..0ef1b9673 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts @@ -4,7 +4,7 @@ import { Node, SyntaxKind } from 'ts-morph'; import type { Transform, TransformContext, TransformResult } from '../../../types.js'; import { isImportedFromMcp, removeUnusedImport } from '../../../utils/importUtils.js'; -const TARGET_METHODS = new Set(['request', 'callTool', 'send']); +const TARGET_METHODS = new Set(['request', 'callTool', 'send', 'sendRequest']); export const schemaParamRemovalTransform: Transform = { name: 'Schema parameter removal', @@ -33,7 +33,7 @@ export const schemaParamRemovalTransform: Transform = { call.removeArgument(1); changesCount++; - removeUnusedImport(sourceFile, schemaName); + removeUnusedImport(sourceFile, schemaName, true); } return { changesCount, diagnostics: [] }; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts index 9450108e6..b2b2cbf06 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts @@ -312,7 +312,7 @@ function handleSchemaInput(sourceFile: SourceFile, context: TransformContext, di }); const isServerFile = sourceFile.getImportDeclarations().some(i => { const spec = i.getModuleSpecifierValue(); - return spec.includes('/server') || spec === '@modelcontextprotocol/server'; + return spec.includes('/server/') || spec === '@modelcontextprotocol/server'; }); const targetModule = resolveTypesPackage(context, isClientFile, isServerFile); 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 a27e892ba..14221205b 100644 --- a/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts @@ -183,6 +183,22 @@ describe('context-types transform', () => { expect(sourceFile.getFullText()).toContain('extra.signal'); }); + it('emits warning when another parameter is already named ctx', () => { + const input = [ + `server.setRequestHandler('tools/call', async (ctx, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].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.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('another parameter'); + expect(sourceFile.getFullText()).toContain('extra.signal'); + }); + it('emits warning when ctx variable already exists in scope', () => { const input = [ `const ctx = getApplicationContext();`, diff --git a/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts index e92bb67ee..2b520932e 100644 --- a/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts @@ -93,4 +93,16 @@ describe('express-middleware transform', () => { expect(result.changesCount).toBe(0); expect(text).toContain("{ allowedHosts: ['localhost'] }"); }); + + it('does not modify non-MCP hostHeaderValidation even when other MCP imports exist', () => { + const input = [ + `import { McpServer } from '@modelcontextprotocol/server';`, + `import { hostHeaderValidation } from './my-middleware.js';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(result.changesCount).toBe(0); + expect(text).toContain("{ allowedHosts: ['localhost'] }"); + }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts index 5762a9abe..b74723aff 100644 --- a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts @@ -104,15 +104,39 @@ describe('handler-registration transform', () => { expect(result).not.toContain("'tools/call'"); }); - it('only removes MCP import when same symbol exists in non-MCP package', () => { + it('does not remove non-MCP import when MCP import of same name is consumed', () => { const input = [ `import { CallToolRequestSchema } from './local-schemas.js';`, `import { CallToolRequestSchema as McpSchema } from '@modelcontextprotocol/sdk/types.js';`, - `server.setRequestHandler(McpSchema, async () => ({ content: [] }));`, - `validateSchema(CallToolRequestSchema);`, + `server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [] }));`, + `validateSchema(McpSchema);`, '' ].join('\n'); const result = applyTransform(input); expect(result).toContain("from './local-schemas.js'"); + expect(result).toContain("'tools/call'"); + expect(result).not.toMatch(/setRequestHandler\(CallToolRequestSchema/); + }); + + it('replaces ListRootsRequestSchema with method string', () => { + const input = [ + `import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(ListRootsRequestSchema, async () => ({ roots: [] }));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("'roots/list'"); + expect(result).not.toContain('ListRootsRequestSchema'); + }); + + it('replaces RootsListChangedNotificationSchema with method string', () => { + const input = [ + `import { RootsListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("'notifications/roots/list_changed'"); + expect(result).not.toContain('RootsListChangedNotificationSchema'); }); }); 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 fcfcfc084..cbecc1b39 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts @@ -103,5 +103,8 @@ describe('mcp-server-api transform', () => { const result = mcpServerApiTransform.apply(sourceFile, ctx); expect(result.diagnostics.length).toBeGreaterThan(0); expect(result.diagnostics[0]!.message).toContain('Could not automatically migrate .resource()'); + // Verify the method name was NOT mutated when migration fails + expect(sourceFile.getFullText()).toContain('.resource('); + expect(sourceFile.getFullText()).not.toContain('registerResource'); }); }); 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 8621a756f..2eff7c17d 100644 --- a/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts @@ -229,5 +229,31 @@ describe('removed-apis transform', () => { const { text: second } = applyTransform(first); expect(second).toBe(first); }); + + it('handles aliased StreamableHTTPError import', () => { + const input = [ + `import { StreamableHTTPError as SHE } from '@modelcontextprotocol/client';`, + `if (error instanceof SHE) {}`, + `throw new SHE(404, 'Not Found');`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('instanceof SdkError'); + expect(text).not.toMatch(/\bSHE\b/); + expect(text).toMatch(/import.*SdkError/); + const constructorWarning = result.diagnostics.find(d => d.message.includes('Constructor arguments differ')); + expect(constructorWarning).toBeDefined(); + }); + }); + + describe('IsomorphicHeaders alias', () => { + it('handles aliased IsomorphicHeaders import', () => { + const input = [`import { IsomorphicHeaders as IH } from '@modelcontextprotocol/server';`, `const h: IH = new IH();`, ''].join( + '\n' + ); + const { text } = applyTransform(input); + expect(text).toContain('const h: Headers = new Headers()'); + expect(text).not.toMatch(/\bIH\b/); + }); }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts index 8ecd6cd46..acf390a97 100644 --- a/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts @@ -74,4 +74,15 @@ describe('schema-param-removal transform', () => { const second = applyTransform(first); expect(second).toBe(first); }); + + it('removes schema from v1 sendRequest calls', () => { + const input = [ + `import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await extra.sendRequest({ method: 'sampling/createMessage', params }, CreateMessageResultSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toContain('CreateMessageResultSchema'); + expect(result).toContain('extra.sendRequest('); + }); }); From 1a46c4cc4a33c65c201a838347016cd29c79d2f6 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 24 Apr 2026 17:12:00 +0300 Subject: [PATCH 18/22] fixes & edge case handling --- .../transforms/handlerRegistration.ts | 5 +- .../v1-to-v2/transforms/importPaths.ts | 26 +++++++--- .../v1-to-v2/transforms/mockPaths.ts | 41 ++++++++------- .../v1-to-v2/transforms/schemaParamRemoval.ts | 12 ++--- packages/codemod/src/utils/astUtils.ts | 1 + packages/codemod/src/utils/importUtils.ts | 18 ++++++- .../transforms/handlerRegistration.test.ts | 19 +++++-- .../v1-to-v2/transforms/importPaths.test.ts | 37 ++++++++++++++ .../v1-to-v2/transforms/mockPaths.test.ts | 51 +++++++++++++++++++ .../transforms/schemaParamRemoval.test.ts | 12 +++++ .../v1-to-v2/transforms/symbolRenames.test.ts | 12 +++++ 11 files changed, 193 insertions(+), 41 deletions(-) diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts index 34a088fe7..abe7c66e2 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts @@ -2,7 +2,7 @@ import type { SourceFile } from 'ts-morph'; import { Node, SyntaxKind } from 'ts-morph'; import type { Transform, TransformContext, TransformResult } from '../../../types.js'; -import { isImportedFromMcp, removeUnusedImport } from '../../../utils/importUtils.js'; +import { isImportedFromMcp, removeUnusedImport, resolveOriginalImportName } from '../../../utils/importUtils.js'; import { NOTIFICATION_SCHEMA_TO_METHOD, SCHEMA_TO_METHOD } from '../mappings/schemaToMethodMap.js'; const ALL_SCHEMA_TO_METHOD: Record = { @@ -34,7 +34,8 @@ export const handlerRegistrationTransform: Transform = { if (!Node.isIdentifier(firstArg)) continue; const schemaName = firstArg.getText(); - const methodString = ALL_SCHEMA_TO_METHOD[schemaName]; + const originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName; + const methodString = ALL_SCHEMA_TO_METHOD[originalName]; if (!methodString) continue; if (!isImportedFromMcp(sourceFile, schemaName)) continue; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index c4773f945..27d0aff71 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -6,6 +6,16 @@ import { warning } from '../../../utils/diagnostics.js'; import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils.js'; import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; import { IMPORT_MAP, isAuthImport } from '../mappings/importMap.js'; +import { SIMPLE_RENAMES } from '../mappings/symbolMap.js'; + +const REEXPORT_WARNINGS: Record = { + ErrorCode: 'Re-exported ErrorCode was split into ProtocolErrorCode and SdkErrorCode in v2. Update this re-export manually.', + RequestHandlerExtra: + 'Re-exported RequestHandlerExtra was renamed to ServerContext/ClientContext in v2. Update this re-export manually.', + IsomorphicHeaders: 'Re-exported IsomorphicHeaders was removed in v2 (replaced by standard Headers API). Remove this re-export.', + StreamableHTTPError: + 'Re-exported StreamableHTTPError was renamed to SdkError in v2 with different constructor. Update this re-export manually.' +}; export const importPathsTransform: Transform = { name: 'Import path rewrites', @@ -204,13 +214,15 @@ function rewriteExportDeclarations( } exp.setModuleSpecifier(targetPackage); - if (mapping.renamedSymbols) { - for (const spec of exp.getNamedExports()) { - const newName = mapping.renamedSymbols[spec.getName()]; - if (newName) { - if (!spec.getAliasNode()) spec.setAlias(spec.getName()); - spec.setName(newName); - } + for (const spec of exp.getNamedExports()) { + const name = spec.getName(); + const newName = mapping.renamedSymbols?.[name] ?? SIMPLE_RENAMES[name]; + if (newName) { + if (!spec.getAliasNode()) spec.setAlias(name); + spec.setName(newName); + } + if (REEXPORT_WARNINGS[name]) { + diagnostics.push(warning(filePath, line, REEXPORT_WARNINGS[name]!)); } } changesCount++; 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 edfe1e45e..3483823f4 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -6,6 +6,7 @@ import { warning } from '../../../utils/diagnostics.js'; import { isSdkSpecifier } from '../../../utils/importUtils.js'; import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; import { IMPORT_MAP, isAuthImport } from '../mappings/importMap.js'; +import { SIMPLE_RENAMES } from '../mappings/symbolMap.js'; const MOCK_METHODS = new Set(['mock', 'doMock']); const MOCK_CALLERS = new Set(['vi', 'jest']); @@ -101,8 +102,9 @@ function rewriteMockCall( firstArg.setLiteralValue(resolved.target); changes++; - if (resolved.renamedSymbols && args.length >= 2) { - changes += renameSymbolsInFactory(args[1]!, resolved.renamedSymbols); + const allRenames: Record = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols }; + if (args.length >= 2) { + changes += renameSymbolsInFactory(args[1]!, allRenames); } return changes; @@ -177,24 +179,25 @@ function rewriteDynamicImports(sourceFile: SourceFile, context: TransformContext firstArg.setLiteralValue(resolved.target); changes++; - if (resolved.renamedSymbols) { - const parent = node.getParent(); - if (parent && Node.isAwaitExpression(parent)) { - const grandParent = parent.getParent(); - if (grandParent && Node.isVariableDeclaration(grandParent)) { - const nameNode = grandParent.getNameNode(); - if (Node.isObjectBindingPattern(nameNode)) { - for (const element of nameNode.getElements()) { - const bindingName = element.getName(); - const newName = resolved.renamedSymbols[bindingName]; - if (newName) { - if (element.getPropertyNameNode()) { - element.getPropertyNameNode()!.replaceWithText(newName); - } else { - element.replaceWithText(`${newName}: ${bindingName}`); - } - changes++; + const allRenames: Record = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols }; + const parent = node.getParent(); + if (parent && Node.isAwaitExpression(parent)) { + const grandParent = parent.getParent(); + if (grandParent && Node.isVariableDeclaration(grandParent)) { + const nameNode = grandParent.getNameNode(); + if (Node.isObjectBindingPattern(nameNode)) { + for (const element of nameNode.getElements()) { + const propertyName = element.getPropertyNameNode()?.getText(); + const bindingName = element.getName(); + const lookupKey = propertyName ?? bindingName; + const newName = allRenames[lookupKey]; + if (newName) { + if (propertyName) { + element.getPropertyNameNode()!.replaceWithText(newName); + } else { + element.replaceWithText(`${newName}: ${bindingName}`); } + changes++; } } } diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts index 0ef1b9673..4c8a9e4cb 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts @@ -2,7 +2,7 @@ import type { SourceFile } from 'ts-morph'; import { Node, SyntaxKind } from 'ts-morph'; import type { Transform, TransformContext, TransformResult } from '../../../types.js'; -import { isImportedFromMcp, removeUnusedImport } from '../../../utils/importUtils.js'; +import { isImportedFromMcp, removeUnusedImport, resolveOriginalImportName } from '../../../utils/importUtils.js'; const TARGET_METHODS = new Set(['request', 'callTool', 'send', 'sendRequest']); @@ -25,9 +25,11 @@ export const schemaParamRemovalTransform: Transform = { if (args.length < 2) continue; const secondArg = args[1]!; - if (!isSchemaReference(secondArg)) continue; + if (!Node.isIdentifier(secondArg)) continue; const schemaName = secondArg.getText(); + const originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName; + if (!originalName.endsWith('Schema')) continue; if (!isImportedFromMcp(sourceFile, schemaName)) continue; call.removeArgument(1); @@ -39,9 +41,3 @@ export const schemaParamRemovalTransform: Transform = { return { changesCount, diagnostics: [] }; } }; - -function isSchemaReference(node: Node): boolean { - if (!Node.isIdentifier(node)) return false; - const text = node.getText(); - return text.endsWith('Schema'); -} diff --git a/packages/codemod/src/utils/astUtils.ts b/packages/codemod/src/utils/astUtils.ts index 41acad453..0ab883b0f 100644 --- a/packages/codemod/src/utils/astUtils.ts +++ b/packages/codemod/src/utils/astUtils.ts @@ -19,6 +19,7 @@ export function renameAllReferences(sourceFile: SourceFile, oldName: string, new if (Node.isMethodSignature(parent) && parent.getNameNode() === node) return; if (Node.isPropertyDeclaration(parent) && parent.getNameNode() === node) return; if (Node.isEnumMember(parent) && parent.getNameNode() === node) return; + if (Node.isBindingElement(parent) && parent.getPropertyNameNode() === node) return; if (Node.isGetAccessorDeclaration(parent) && parent.getNameNode() === node) return; if (Node.isSetAccessorDeclaration(parent) && parent.getNameNode() === node) return; if (Node.isShorthandPropertyAssignment(parent)) { diff --git a/packages/codemod/src/utils/importUtils.ts b/packages/codemod/src/utils/importUtils.ts index b7e92abe2..d48a08812 100644 --- a/packages/codemod/src/utils/importUtils.ts +++ b/packages/codemod/src/utils/importUtils.ts @@ -76,10 +76,24 @@ export function hasMcpImports(sourceFile: SourceFile): boolean { export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): boolean { return sourceFile.getImportDeclarations().some(imp => { if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false; - return imp.getNamedImports().some(n => n.getName() === symbolName); + return imp.getNamedImports().some(n => { + const localName = n.getAliasNode()?.getText() ?? n.getName(); + return localName === symbolName; + }); }); } +export function resolveOriginalImportName(sourceFile: SourceFile, localName: string): string | undefined { + for (const imp of sourceFile.getImportDeclarations()) { + for (const n of imp.getNamedImports()) { + const alias = n.getAliasNode()?.getText(); + if (alias === localName) return n.getName(); + if (!alias && n.getName() === localName) return localName; + } + } + return undefined; +} + export function removeUnusedImport(sourceFile: SourceFile, symbolName: string, onlyMcpImports?: boolean): void { let referenceCount = 0; sourceFile.forEachDescendant(node => { @@ -95,7 +109,7 @@ export function removeUnusedImport(sourceFile: SourceFile, symbolName: string, o for (const imp of sourceFile.getImportDeclarations()) { if (onlyMcpImports && !isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; for (const namedImport of imp.getNamedImports()) { - if (namedImport.getName() === symbolName) { + if ((namedImport.getAliasNode()?.getText() ?? namedImport.getName()) === symbolName) { namedImport.remove(); if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport()) { imp.remove(); diff --git a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts index b74723aff..173bf3da2 100644 --- a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts @@ -104,7 +104,7 @@ describe('handler-registration transform', () => { expect(result).not.toContain("'tools/call'"); }); - it('does not remove non-MCP import when MCP import of same name is consumed', () => { + it('does not rewrite local import when aliased MCP import has same export name', () => { const input = [ `import { CallToolRequestSchema } from './local-schemas.js';`, `import { CallToolRequestSchema as McpSchema } from '@modelcontextprotocol/sdk/types.js';`, @@ -114,8 +114,8 @@ describe('handler-registration transform', () => { ].join('\n'); const result = applyTransform(input); expect(result).toContain("from './local-schemas.js'"); - expect(result).toContain("'tools/call'"); - expect(result).not.toMatch(/setRequestHandler\(CallToolRequestSchema/); + expect(result).toContain('setRequestHandler(CallToolRequestSchema'); + expect(result).not.toContain("'tools/call'"); }); it('replaces ListRootsRequestSchema with method string', () => { @@ -139,4 +139,17 @@ describe('handler-registration transform', () => { expect(result).toContain("'notifications/roots/list_changed'"); expect(result).not.toContain('RootsListChangedNotificationSchema'); }); + + it('handles aliased schema imports', () => { + const input = [ + `import { CallToolRequestSchema as CTRS } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CTRS, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("'tools/call'"); + expect(result).not.toContain('CTRS'); + }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index 2981ff127..389506514 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -233,4 +233,41 @@ describe('import-paths transform', () => { expect(output).toContain('@modelcontextprotocol/client'); expect(output).not.toContain('@modelcontextprotocol/sdk'); }); + + it('applies SIMPLE_RENAMES to re-export specifiers', () => { + const input = `export { McpError, ResourceReference } from '@modelcontextprotocol/sdk/types.js';\n`; + const result = applyTransform(input); + expect(result).toContain('ProtocolError as McpError'); + expect(result).toContain('ResourceTemplateReference as ResourceReference'); + expect(result).toContain('@modelcontextprotocol/server'); + }); + + it('emits warning for re-exported ErrorCode', () => { + const input = `export { ErrorCode } from '@modelcontextprotocol/sdk/types.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('ErrorCode'); + expect(result.diagnostics[0]!.message).toContain('split'); + }); + + it('emits warning for re-exported RequestHandlerExtra', () => { + const input = `export { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('RequestHandlerExtra'); + }); + + it('emits warning for re-exported IsomorphicHeaders', () => { + const input = `export { IsomorphicHeaders } from '@modelcontextprotocol/sdk/types.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('IsomorphicHeaders'); + expect(result.diagnostics[0]!.message).toContain('removed'); + }); }); 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 bae680796..cebd0f585 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts @@ -118,6 +118,18 @@ describe('mock-paths transform', () => { expect(result).toContain('new StreamableHTTPServerTransport({})'); }); + it('handles aliased dynamic import destructuring', () => { + const input = [ + `const { StreamableHTTPServerTransport: MyTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');`, + `const t = new MyTransport({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('@modelcontextprotocol/node')`); + expect(result).toContain('NodeStreamableHTTPServerTransport: MyTransport'); + expect(result).toContain('new MyTransport({})'); + }); + it('rewrites dynamic import for server/mcp.js', () => { const input = [`const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');`, ''].join('\n'); const result = applyTransform(input); @@ -174,5 +186,44 @@ describe('mock-paths transform', () => { expect(result.diagnostics.length).toBeGreaterThan(0); expect(result.diagnostics[0]!.message).toContain('Unknown SDK mock path'); }); + + it('renames SIMPLE_RENAMES symbols in mock factory', () => { + const input = [ + `vi.mock('@modelcontextprotocol/sdk/types.js', () => ({`, + ` McpError: vi.fn(),`, + ` ResourceReference: { type: 'resource' },`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toContain('ProtocolError'); + expect(result).toContain('ResourceTemplateReference'); + expect(result).not.toMatch(/(? { + const input = [ + `const { McpError, ResourceReference } = await import('@modelcontextprotocol/sdk/types.js');`, + `const err = new McpError('fail');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toContain('ProtocolError: McpError'); + expect(result).toContain('ResourceTemplateReference: ResourceReference'); + expect(result).toContain('new McpError('); + }); + + it('renames SIMPLE_RENAMES symbols in aliased dynamic import destructuring', () => { + const input = [ + `const { McpError: MyError } = await import('@modelcontextprotocol/sdk/types.js');`, + `throw new MyError('fail');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ProtocolError: MyError'); + expect(result).toContain('new MyError('); + }); }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts index acf390a97..8c575e428 100644 --- a/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts @@ -85,4 +85,16 @@ describe('schema-param-removal transform', () => { expect(result).not.toContain('CreateMessageResultSchema'); expect(result).toContain('extra.sendRequest('); }); + + it('removes aliased schema from sendRequest calls', () => { + const input = [ + `import { CreateMessageResultSchema as CMRS } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await extra.sendRequest({ method: 'sampling/createMessage', params }, CMRS);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toContain('CMRS'); + expect(result).toContain('extra.sendRequest('); + expect(result).not.toContain('CreateMessageResultSchema'); + }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts index 6aff0010e..22d9a80ea 100644 --- a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -379,4 +379,16 @@ describe('symbol-renames transform', () => { expect(result).toContain('enum Errors { McpError = 1 }'); expect(result).toContain('new ProtocolError('); }); + + it('does not rename destructuring property names that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/server';`, + `const { McpError: localErr } = someObject;`, + `throw new McpError('test');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('{ McpError: localErr }'); + expect(result).toContain('new ProtocolError('); + }); }); From 9ce1fcd0899ebf3caab8c594d1f7f7b13a70aee3 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 24 Apr 2026 18:03:19 +0300 Subject: [PATCH 19/22] add codemod results from everything-server --- .../migrations/v1-to-v2/mappings/importMap.ts | 21 +++++++- .../v1-to-v2/transforms/importPaths.ts | 21 ++++++-- .../v1-to-v2/transforms/mockPaths.ts | 54 +++++++++++++++++-- .../v1-to-v2/transforms/importPaths.test.ts | 28 +++++++++- 4 files changed, 114 insertions(+), 10 deletions(-) diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts index 9899bce94..993c461cf 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts @@ -2,6 +2,8 @@ export interface ImportMapping { target: string; status: 'moved' | 'removed' | 'renamed'; renamedSymbols?: Record; + /** Route specific symbols to a different target package than `target`. */ + symbolTargetOverrides?: Record; removalMessage?: string; } @@ -45,10 +47,13 @@ export const IMPORT_MAP: Record = { status: 'moved' }, '@modelcontextprotocol/sdk/server/streamableHttp.js': { - target: '@modelcontextprotocol/node', + target: '@modelcontextprotocol/server', status: 'renamed', renamedSymbols: { StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' + }, + symbolTargetOverrides: { + StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }, '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js': { @@ -117,6 +122,20 @@ export const IMPORT_MAP: Record = { status: 'moved' }, + '@modelcontextprotocol/sdk/server/completable.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + + '@modelcontextprotocol/sdk/experimental/tasks': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/experimental/tasks.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/inMemory.js': { target: '@modelcontextprotocol/core', status: 'moved' diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 27d0aff71..78f5d5b48 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -105,7 +105,14 @@ export const importPathsTransform: Transform = { const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined); if (defaultImport || namespaceImport || hasAlias) { - imp.setModuleSpecifier(targetPackage); + let effectiveTarget = targetPackage; + if (mapping.symbolTargetOverrides && !namespaceImport && !defaultImport) { + const allOverridden = namedImports.length > 0 && namedImports.every(n => n.getName() in mapping.symbolTargetOverrides!); + if (allOverridden) { + effectiveTarget = mapping.symbolTargetOverrides[namedImports[0]!.getName()]!; + } + } + imp.setModuleSpecifier(effectiveTarget); if (mapping.renamedSymbols) { for (const n of namedImports) { const newName = mapping.renamedSymbols[n.getName()]; @@ -119,7 +126,7 @@ export const importPathsTransform: Transform = { filePath, line, `Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` + - `were renamed in ${targetPackage}. Update qualified accesses manually.` + `were renamed in ${effectiveTarget}. Update qualified accesses manually.` ) ); } @@ -132,7 +139,8 @@ export const importPathsTransform: Transform = { const name = n.getName(); const resolvedName = mapping.renamedSymbols?.[name] ?? name; const specifierTypeOnly = typeOnly || n.isTypeOnly(); - addPending(targetPackage, [resolvedName], specifierTypeOnly); + const symbolTarget = mapping.symbolTargetOverrides?.[name] ?? targetPackage; + addPending(symbolTarget, [resolvedName], specifierTypeOnly); } imp.remove(); changesCount++; @@ -213,6 +221,13 @@ function rewriteExportDeclarations( targetPackage = resolveTypesPackage(context, hasClientImport, hasServerImport); } + if (mapping.symbolTargetOverrides) { + const namedExports = exp.getNamedExports(); + const allOverridden = namedExports.length > 0 && namedExports.every(s => s.getName() in mapping.symbolTargetOverrides!); + if (allOverridden) { + targetPackage = mapping.symbolTargetOverrides[namedExports[0]!.getName()]!; + } + } exp.setModuleSpecifier(targetPackage); for (const spec of exp.getNamedExports()) { const name = spec.getName(); 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 3483823f4..c02598462 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -42,7 +42,7 @@ function resolveTarget( specifier: string, context: TransformContext, sourceFile: SourceFile -): { target: string; renamedSymbols?: Record } | 'removed' | null { +): { target: string; renamedSymbols?: Record; symbolTargetOverrides?: Record } | 'removed' | null { const mapping = IMPORT_MAP[specifier]; if (!mapping && isAuthImport(specifier)) return 'removed'; if (!mapping) return null; @@ -61,7 +61,7 @@ function resolveTarget( target = resolveTypesPackage(context, hasClient, hasServer); } - return { target, renamedSymbols: mapping.renamedSymbols }; + return { target, renamedSymbols: mapping.renamedSymbols, symbolTargetOverrides: mapping.symbolTargetOverrides }; } function rewriteMockCall( @@ -99,7 +99,15 @@ function rewriteMockCall( let changes = 0; - firstArg.setLiteralValue(resolved.target); + let effectiveTarget = resolved.target; + if (resolved.symbolTargetOverrides && args.length >= 2) { + const factorySymbols = collectFactorySymbols(args[1]!); + if (factorySymbols.length > 0 && factorySymbols.every(s => s in resolved.symbolTargetOverrides!)) { + effectiveTarget = resolved.symbolTargetOverrides[factorySymbols[0]!]!; + } + } + + firstArg.setLiteralValue(effectiveTarget); changes++; const allRenames: Record = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols }; @@ -110,6 +118,16 @@ function rewriteMockCall( return changes; } +function collectFactorySymbols(factoryArg: import('ts-morph').Node): string[] { + const symbols: string[] = []; + factoryArg.forEachDescendant(node => { + if (Node.isPropertyAssignment(node) || Node.isShorthandPropertyAssignment(node)) { + symbols.push(node.getName()); + } + }); + return symbols; +} + function renameSymbolsInFactory(factoryArg: import('ts-morph').Node, renamedSymbols: Record): number { let changes = 0; @@ -176,10 +194,36 @@ function rewriteDynamicImports(sourceFile: SourceFile, context: TransformContext return; } - firstArg.setLiteralValue(resolved.target); + let effectiveTarget = resolved.target; + const allRenames: Record = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols }; + + // Check if destructured symbols should route to an override target + if (resolved.symbolTargetOverrides) { + const parent = node.getParent(); + if (parent && Node.isAwaitExpression(parent)) { + const grandParent = parent.getParent(); + if (grandParent && Node.isVariableDeclaration(grandParent)) { + const nameNode = grandParent.getNameNode(); + if (Node.isObjectBindingPattern(nameNode)) { + const elements = nameNode.getElements(); + const allOverridden = + elements.length > 0 && + elements.every(el => { + const key = el.getPropertyNameNode()?.getText() ?? el.getName(); + return key in resolved.symbolTargetOverrides!; + }); + if (allOverridden) { + effectiveTarget = + resolved.symbolTargetOverrides[elements[0]!.getPropertyNameNode()?.getText() ?? elements[0]!.getName()]!; + } + } + } + } + } + + firstArg.setLiteralValue(effectiveTarget); changes++; - const allRenames: Record = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols }; const parent = node.getParent(); if (parent && Node.isAwaitExpression(parent)) { const grandParent = parent.getParent(); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index 389506514..16cc4e652 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -173,6 +173,32 @@ describe('import-paths transform', () => { expect(result).toContain('@modelcontextprotocol/node'); }); + it('splits streamableHttp import: transport to /node, types to /server', () => { + const input = [ + `import { StreamableHTTPServerTransport, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + expect(result).toContain('@modelcontextprotocol/node'); + expect(result).toContain('EventStore'); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('splits streamableHttp type import: transport to /node, types to /server', () => { + const input = [ + `import type { StreamableHTTPServerTransport, EventStore, StreamId } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + expect(result).toContain('@modelcontextprotocol/node'); + expect(result).toContain('EventStore'); + expect(result).toContain('StreamId'); + expect(result).toContain('@modelcontextprotocol/server'); + }); + it('emits warning for namespace import with renamedSymbols', () => { const input = [ `import * as transport from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, @@ -183,7 +209,7 @@ describe('import-paths transform', () => { const sourceFile = project.createSourceFile('test.ts', input); const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); expect(sourceFile.getFullText()).toContain('import * as transport'); - expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/node'); + expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/server'); expect(result.diagnostics.length).toBeGreaterThan(0); expect(result.diagnostics.some(d => d.message.includes('renamed') && d.message.includes('StreamableHTTPServerTransport'))).toBe( true From 2e349ddaf380aa32d40080cee6b1c8945d27cb15 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 25 Apr 2026 01:50:18 +0300 Subject: [PATCH 20/22] add package.json transform --- packages/codemod/package.json | 2 + packages/codemod/scripts/generate-versions.ts | 35 +++ packages/codemod/src/cli.ts | 19 ++ packages/codemod/src/generated/versions.ts | 7 + .../v1-to-v2/transforms/importPaths.ts | 11 +- .../v1-to-v2/transforms/mockPaths.ts | 19 +- packages/codemod/src/runner.ts | 25 +- packages/codemod/src/types.ts | 8 + .../codemod/src/utils/packageJsonUpdater.ts | 76 +++++ packages/codemod/src/utils/projectAnalyzer.ts | 2 +- packages/codemod/test/integration.test.ts | 204 +++++++++++++ .../codemod/test/packageJsonUpdater.test.ts | 271 ++++++++++++++++++ 12 files changed, 668 insertions(+), 11 deletions(-) create mode 100644 packages/codemod/scripts/generate-versions.ts create mode 100644 packages/codemod/src/generated/versions.ts create mode 100644 packages/codemod/src/utils/packageJsonUpdater.ts create mode 100644 packages/codemod/test/packageJsonUpdater.test.ts diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 1d69701e0..daf68067b 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -34,6 +34,8 @@ ], "scripts": { "typecheck": "tsgo -p tsconfig.json --noEmit", + "generate:versions": "tsx scripts/generate-versions.ts", + "prebuild": "pnpm run generate:versions", "build": "tsdown", "build:watch": "tsdown --watch", "prepack": "pnpm run build", diff --git a/packages/codemod/scripts/generate-versions.ts b/packages/codemod/scripts/generate-versions.ts new file mode 100644 index 000000000..d6dd49c80 --- /dev/null +++ b/packages/codemod/scripts/generate-versions.ts @@ -0,0 +1,35 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packagesDir = path.resolve(__dirname, '../..'); + +const PACKAGE_DIRS: Record = { + '@modelcontextprotocol/client': 'client', + '@modelcontextprotocol/server': 'server', + '@modelcontextprotocol/node': 'middleware/node', + '@modelcontextprotocol/express': 'middleware/express', +}; + +const versions: Record = {}; + +for (const [pkg, dir] of Object.entries(PACKAGE_DIRS)) { + const pkgJsonPath = path.join(packagesDir, dir, 'package.json'); + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); + versions[pkg] = `^${pkgJson.version}`; +} + +const lines = Object.entries(versions) + .map(([pkg, ver]) => ` '${pkg}': '${ver}',`) + .join('\n'); + +const output = `// AUTO-GENERATED — do not edit. Run \`pnpm run generate:versions\` to regenerate. +export const V2_PACKAGE_VERSIONS: Record = { +${lines} +}; +`; + +const outPath = path.resolve(__dirname, '../src/generated/versions.ts'); +writeFileSync(outPath, output); +console.log(`Wrote ${outPath}`); diff --git a/packages/codemod/src/cli.ts b/packages/codemod/src/cli.ts index 52c24a486..0cdf9c312 100644 --- a/packages/codemod/src/cli.ts +++ b/packages/codemod/src/cli.ts @@ -115,9 +115,28 @@ 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:'); + } + 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 (opts['dryRun']) { console.log('Run without --dry-run to apply changes.\n'); } else { + if (result.packageJsonChanges) { + 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'); } } catch (error) { diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts new file mode 100644 index 000000000..fb5bd057f --- /dev/null +++ b/packages/codemod/src/generated/versions.ts @@ -0,0 +1,7 @@ +// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. +export const V2_PACKAGE_VERSIONS: Record = { + '@modelcontextprotocol/client': '^2.0.0-alpha.2', + '@modelcontextprotocol/server': '^2.0.0-alpha.2', + '@modelcontextprotocol/node': '^2.0.0-alpha.2', + '@modelcontextprotocol/express': '^2.0.0-alpha.2', +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 78f5d5b48..263003a84 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -22,6 +22,7 @@ export const importPathsTransform: Transform = { id: 'imports', apply(sourceFile: SourceFile, context: TransformContext): TransformResult { const diagnostics: ReturnType[] = []; + const usedPackages = new Set(); let changesCount = 0; const sdkImports = getSdkImports(sourceFile); @@ -32,7 +33,7 @@ export const importPathsTransform: Transform = { const filePath = sourceFile.getFilePath(); - changesCount += rewriteExportDeclarations(sdkExports, sourceFile, filePath, context, diagnostics); + changesCount += rewriteExportDeclarations(sdkExports, sourceFile, filePath, context, diagnostics, usedPackages); if (sdkImports.length === 0) { return { changesCount, diagnostics }; @@ -112,6 +113,7 @@ export const importPathsTransform: Transform = { effectiveTarget = mapping.symbolTargetOverrides[namedImports[0]!.getName()]!; } } + usedPackages.add(effectiveTarget); imp.setModuleSpecifier(effectiveTarget); if (mapping.renamedSymbols) { for (const n of namedImports) { @@ -140,6 +142,7 @@ export const importPathsTransform: Transform = { const resolvedName = mapping.renamedSymbols?.[name] ?? name; const specifierTypeOnly = typeOnly || n.isTypeOnly(); const symbolTarget = mapping.symbolTargetOverrides?.[name] ?? targetPackage; + usedPackages.add(symbolTarget); addPending(symbolTarget, [resolvedName], specifierTypeOnly); } imp.remove(); @@ -168,7 +171,7 @@ export const importPathsTransform: Transform = { } } - return { changesCount, diagnostics }; + return { changesCount, diagnostics, usedPackages }; } }; @@ -177,7 +180,8 @@ function rewriteExportDeclarations( sourceFile: import('ts-morph').SourceFile, filePath: string, context: TransformContext, - diagnostics: ReturnType[] + diagnostics: ReturnType[], + usedPackages: Set ): number { let changesCount = 0; @@ -228,6 +232,7 @@ function rewriteExportDeclarations( targetPackage = mapping.symbolTargetOverrides[namedExports[0]!.getName()]!; } } + usedPackages.add(targetPackage); exp.setModuleSpecifier(targetPackage); for (const spec of exp.getNamedExports()) { const name = spec.getName(); 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 c02598462..4061a3ef7 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -16,6 +16,7 @@ export const mockPathsTransform: Transform = { id: 'mock-paths', apply(sourceFile: SourceFile, context: TransformContext): TransformResult { const diagnostics: ReturnType[] = []; + const usedPackages = new Set(); let changesCount = 0; const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); @@ -27,14 +28,14 @@ export const mockPathsTransform: Transform = { const objName = expr.getExpression().getText(); const methodName = expr.getName(); if (MOCK_CALLERS.has(objName) && MOCK_METHODS.has(methodName)) { - changesCount += rewriteMockCall(call, sourceFile, context, diagnostics); + changesCount += rewriteMockCall(call, sourceFile, context, diagnostics, usedPackages); } } } - changesCount += rewriteDynamicImports(sourceFile, context, diagnostics); + changesCount += rewriteDynamicImports(sourceFile, context, diagnostics, usedPackages); - return { changesCount, diagnostics }; + return { changesCount, diagnostics, usedPackages }; } }; @@ -68,7 +69,8 @@ function rewriteMockCall( call: import('ts-morph').CallExpression, sourceFile: SourceFile, context: TransformContext, - diagnostics: ReturnType[] + diagnostics: ReturnType[], + usedPackages: Set ): number { const args = call.getArguments(); if (args.length === 0) return 0; @@ -107,6 +109,7 @@ function rewriteMockCall( } } + usedPackages.add(effectiveTarget); firstArg.setLiteralValue(effectiveTarget); changes++; @@ -154,7 +157,12 @@ function renameSymbolsInFactory(factoryArg: import('ts-morph').Node, renamedSymb return changes; } -function rewriteDynamicImports(sourceFile: SourceFile, context: TransformContext, diagnostics: ReturnType[]): number { +function rewriteDynamicImports( + sourceFile: SourceFile, + context: TransformContext, + diagnostics: ReturnType[], + usedPackages: Set +): number { let changes = 0; sourceFile.forEachDescendant(node => { @@ -221,6 +229,7 @@ function rewriteDynamicImports(sourceFile: SourceFile, context: TransformContext } } + usedPackages.add(effectiveTarget); firstArg.setLiteralValue(effectiveTarget); changes++; diff --git a/packages/codemod/src/runner.ts b/packages/codemod/src/runner.ts index 5fa5f8477..a58577591 100644 --- a/packages/codemod/src/runner.ts +++ b/packages/codemod/src/runner.ts @@ -1,7 +1,8 @@ import { Project } from 'ts-morph'; import type { Diagnostic, FileResult, Migration, RunnerOptions, RunnerResult } from './types.js'; -import { error } from './utils/diagnostics.js'; +import { error, info } from './utils/diagnostics.js'; +import { updatePackageJson } from './utils/packageJsonUpdater.js'; import { analyzeProject } from './utils/projectAnalyzer.js'; export function run(migration: Migration, options: RunnerOptions): RunnerResult { @@ -58,6 +59,7 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult }); const fileResults: FileResult[] = []; const allDiagnostics: Diagnostic[] = []; + const allUsedPackages = new Set(); let totalChanges = 0; let filesChanged = 0; @@ -70,6 +72,11 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult const result = transform.apply(sourceFile, context); fileChanges += result.changesCount; fileDiagnostics.push(...result.diagnostics); + if (result.usedPackages) { + for (const pkg of result.usedPackages) { + allUsedPackages.add(pkg); + } + } } } catch (error_) { const filePath = sourceFile.getFilePath(); @@ -90,6 +97,19 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult } } + if (allUsedPackages.has('@modelcontextprotocol/core')) { + allDiagnostics.push( + info( + 'package.json', + 0, + '@modelcontextprotocol/core is a private package not published to npm. ' + + 'If you use InMemoryTransport, you may need to find an alternative or use a local reference.' + ) + ); + } + + const packageJsonChanges = updatePackageJson(options.targetDir, allUsedPackages, options.dryRun ?? false); + if (!options.dryRun) { project.saveSync(); } @@ -98,6 +118,7 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult filesChanged, totalChanges, diagnostics: allDiagnostics, - fileResults + fileResults, + packageJsonChanges }; } diff --git a/packages/codemod/src/types.ts b/packages/codemod/src/types.ts index 771cc8f68..2243e5d52 100644 --- a/packages/codemod/src/types.ts +++ b/packages/codemod/src/types.ts @@ -16,6 +16,7 @@ export interface Diagnostic { export interface TransformResult { changesCount: number; diagnostics: Diagnostic[]; + usedPackages?: Set; } export interface Transform { @@ -48,9 +49,16 @@ export interface FileResult { diagnostics: Diagnostic[]; } +export interface PackageJsonChange { + added: string[]; + removed: string[]; + packageJsonPath: string; +} + export interface RunnerResult { filesChanged: number; totalChanges: number; diagnostics: Diagnostic[]; fileResults: FileResult[]; + packageJsonChanges?: PackageJsonChange; } diff --git a/packages/codemod/src/utils/packageJsonUpdater.ts b/packages/codemod/src/utils/packageJsonUpdater.ts new file mode 100644 index 000000000..0dad5dde9 --- /dev/null +++ b/packages/codemod/src/utils/packageJsonUpdater.ts @@ -0,0 +1,76 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +import { V2_PACKAGE_VERSIONS } from '../generated/versions.js'; +import type { PackageJsonChange } from '../types.js'; +import { findPackageJson } from './projectAnalyzer.js'; + +const V1_PACKAGE = '@modelcontextprotocol/sdk'; +const PRIVATE_PACKAGES = new Set(['@modelcontextprotocol/core']); + +function detectIndent(text: string): string { + const match = text.match(/\n([ \t]+)/); + return match ? match[1]! : ' '; +} + +export function updatePackageJson( + targetDir: string, + usedPackages: Set, + dryRun: boolean +): PackageJsonChange | undefined { + const pkgJsonPath = findPackageJson(targetDir); + if (!pkgJsonPath) return undefined; + + let raw: string; + let pkgJson: Record; + try { + raw = readFileSync(pkgJsonPath, 'utf8'); + pkgJson = JSON.parse(raw) as Record; + } catch { + return undefined; + } + const deps = pkgJson.dependencies as Record | undefined; + const devDeps = pkgJson.devDependencies as Record | undefined; + + const inDeps = deps !== undefined && V1_PACKAGE in deps; + const inDevDeps = devDeps !== undefined && V1_PACKAGE in devDeps; + if (!inDeps && !inDevDeps) return undefined; + + const packagesToAdd = [...usedPackages].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] = {}; + } + (pkgJson[targetSection] as Record)[pkg] = V2_PACKAGE_VERSIONS[pkg]!; + added.push(pkg); + } + + 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); + } + + return { + added: added.sort(), + removed, + packageJsonPath: pkgJsonPath + }; +} diff --git a/packages/codemod/src/utils/projectAnalyzer.ts b/packages/codemod/src/utils/projectAnalyzer.ts index a62f628c5..d1e2a500b 100644 --- a/packages/codemod/src/utils/projectAnalyzer.ts +++ b/packages/codemod/src/utils/projectAnalyzer.ts @@ -5,7 +5,7 @@ import type { TransformContext } from '../types.js'; const PROJECT_ROOT_MARKERS = ['.git', 'node_modules']; -function findPackageJson(startDir: string): string | undefined { +export function findPackageJson(startDir: string): string | undefined { let dir = path.resolve(startDir); const root = path.parse(dir).root; while (true) { diff --git a/packages/codemod/test/integration.test.ts b/packages/codemod/test/integration.test.ts index e14d4c722..ded789cc8 100644 --- a/packages/codemod/test/integration.test.ts +++ b/packages/codemod/test/integration.test.ts @@ -9,6 +9,10 @@ import { DiagnosticLevel } from '../src/types.js'; const migration = getMigration('v1-to-v2')!; +function writePkgJson(dir: string, content: Record): void { + writeFileSync(path.join(dir, 'package.json'), JSON.stringify(content, null, 2) + '\n'); +} + let tempDir: string; function createTempDir(): string { @@ -235,6 +239,206 @@ describe('integration', () => { expect(result.diagnostics.length).toBeGreaterThan(0); }); + it('updates package.json: removes v1 SDK and adds detected v2 packages', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify( + { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0', + 'express': '^4.0.0' + } + }, + null, + 2 + ) + '\n' + ); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `const transport = new StreamableHTTPServerTransport({});`, + `` + ].join('\n') + ); + + 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'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/server']).toBeDefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/node']).toBeDefined(); + expect(pkgJson.dependencies['express']).toBe('^4.0.0'); + }); + + it('does not modify package.json in dry-run mode', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + writeFileSync( + path.join(dir, 'server.ts'), + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n` + ); + + const result = run(migration, { targetDir: dir, dryRun: true }); + + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBe('^1.0.0'); + }); + + it('package.json: client-only project adds only @modelcontextprotocol/client', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + writeFileSync( + path.join(dir, 'client.ts'), + [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `const client = new Client({ name: 'test', version: '1.0' });`, + `` + ].join('\n') + ); + + 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'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/client']).toBeDefined(); + }); + + it('package.json: client + server project adds both packages', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + writeFileSync( + path.join(dir, 'src', 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `` + ].join('\n') + ); + writeFileSync( + path.join(dir, 'src', 'client.ts'), + [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `const client = new Client({ name: 'test', version: '1.0' });`, + `` + ].join('\n') + ); + + 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'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/server']).toBeDefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/client']).toBeDefined(); + }); + + it('package.json: express middleware import adds @modelcontextprotocol/express', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + `` + ].join('\n') + ); + + 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'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/express']).toBeDefined(); + }); + + it('package.json: works when no package.json is present', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, '.git'), { recursive: true }); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `` + ].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(1); + expect(result.packageJsonChanges).toBeUndefined(); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + expect(output).toContain('@modelcontextprotocol/server'); + }); + + it('package.json: split import adds both /server and /node', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { StreamableHTTPServerTransport, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const transport = new StreamableHTTPServerTransport({});`, + `const store: EventStore = {} as any;`, + `` + ].join('\n') + ); + + 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'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/server']).toBeDefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/node']).toBeDefined(); + }); + it('emits diagnostics for removed imports', () => { const dir = createTempDir(); const input = [ diff --git a/packages/codemod/test/packageJsonUpdater.test.ts b/packages/codemod/test/packageJsonUpdater.test.ts new file mode 100644 index 000000000..43d836471 --- /dev/null +++ b/packages/codemod/test/packageJsonUpdater.test.ts @@ -0,0 +1,271 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { updatePackageJson } from '../src/utils/packageJsonUpdater.js'; + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-pkgjson-')); + return tempDir; +} + +function writePkgJson(dir: string, content: Record, indent: string | number = 2): void { + writeFileSync(path.join(dir, 'package.json'), JSON.stringify(content, null, indent) + '\n'); +} + +function readPkgJson(dir: string): Record { + return JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('updatePackageJson', () => { + it('removes v1 SDK from dependencies and adds v2 packages', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0', + 'express': '^4.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + expect(result).toBeDefined(); + expect(result!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result!.added).toContain('@modelcontextprotocol/server'); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(deps['@modelcontextprotocol/server']).toBeDefined(); + expect(deps['express']).toBe('^4.0.0'); + }); + + it('removes v1 SDK from devDependencies and adds v2 packages there', () => { + const dir = createTempDir(); + writePkgJson(dir, { + devDependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/client']), false); + + expect(result).toBeDefined(); + const pkg = readPkgJson(dir); + const devDeps = pkg.devDependencies as Record; + expect(devDeps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(devDeps['@modelcontextprotocol/client']).toBeDefined(); + }); + + it('removes v1 SDK from both sections, adds v2 to dependencies only', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + }, + devDependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + expect(result).toBeDefined(); + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + const devDeps = pkg.devDependencies as Record; + expect(deps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(devDeps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(deps['@modelcontextprotocol/server']).toBeDefined(); + expect(devDeps['@modelcontextprotocol/server']).toBeUndefined(); + }); + + it('skips v2 packages that are already present', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0', + '@modelcontextprotocol/server': '^2.0.0' + } + }); + + const result = updatePackageJson( + dir, + new Set(['@modelcontextprotocol/server', '@modelcontextprotocol/node']), + false + ); + + expect(result).toBeDefined(); + expect(result!.added).toContain('@modelcontextprotocol/node'); + expect(result!.added).not.toContain('@modelcontextprotocol/server'); + }); + + it('returns undefined when no package.json exists', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, '.git'), { recursive: true }); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + + const result = updatePackageJson(path.join(dir, 'src'), new Set(['@modelcontextprotocol/server']), false); + expect(result).toBeUndefined(); + }); + + it('returns undefined when v1 SDK is not in package.json', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + 'express': '^4.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + expect(result).toBeUndefined(); + }); + + it('does not write file in dry-run mode', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), true); + + expect(result).toBeDefined(); + expect(result!.added).toContain('@modelcontextprotocol/server'); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/sdk']).toBe('^1.0.0'); + expect(deps['@modelcontextprotocol/server']).toBeUndefined(); + }); + + it('filters out @modelcontextprotocol/core (private package)', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson( + dir, + new Set(['@modelcontextprotocol/core', '@modelcontextprotocol/server']), + false + ); + + expect(result).toBeDefined(); + expect(result!.added).not.toContain('@modelcontextprotocol/core'); + expect(result!.added).toContain('@modelcontextprotocol/server'); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/core']).toBeUndefined(); + }); + + it('preserves 4-space indentation', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }, 4); + + updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + const raw = readFileSync(path.join(dir, 'package.json'), 'utf8'); + expect(raw).toContain(' "dependencies"'); + }); + + it('preserves trailing newline', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + const raw = readFileSync(path.join(dir, 'package.json'), 'utf8'); + expect(raw.endsWith('\n')).toBe(true); + expect(raw.endsWith('\n\n')).toBe(false); + }); + + it('removes v1 SDK even when no v2 packages are detected', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0', + 'express': '^4.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(), false); + + expect(result).toBeDefined(); + expect(result!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result!.added).toEqual([]); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(deps['express']).toBe('^4.0.0'); + }); + + it('version strings have caret range format', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/server']).toMatch(/^\^/); + }); + + it('returns undefined for malformed package.json', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'package.json'), '{ invalid json }'); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + expect(result).toBeUndefined(); + }); + + it('adds multiple v2 packages', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson( + dir, + new Set(['@modelcontextprotocol/server', '@modelcontextprotocol/node', '@modelcontextprotocol/express']), + false + ); + + expect(result).toBeDefined(); + expect(result!.added).toEqual(['@modelcontextprotocol/express', '@modelcontextprotocol/node', '@modelcontextprotocol/server']); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/server']).toBeDefined(); + expect(deps['@modelcontextprotocol/node']).toBeDefined(); + expect(deps['@modelcontextprotocol/express']).toBeDefined(); + }); +}); From c9ef51e3ea5f021fc8480583333c1c7132f8c6c2 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 25 Apr 2026 01:53:54 +0300 Subject: [PATCH 21/22] lint fix --- packages/codemod/scripts/generate-versions.ts | 2 +- packages/codemod/src/generated/versions.ts | 2 +- .../codemod/src/utils/packageJsonUpdater.ts | 12 ++----- packages/codemod/test/integration.test.ts | 7 ++-- .../codemod/test/packageJsonUpdater.test.ts | 32 ++++++++----------- 5 files changed, 21 insertions(+), 34 deletions(-) diff --git a/packages/codemod/scripts/generate-versions.ts b/packages/codemod/scripts/generate-versions.ts index d6dd49c80..5fb96899b 100644 --- a/packages/codemod/scripts/generate-versions.ts +++ b/packages/codemod/scripts/generate-versions.ts @@ -9,7 +9,7 @@ const PACKAGE_DIRS: Record = { '@modelcontextprotocol/client': 'client', '@modelcontextprotocol/server': 'server', '@modelcontextprotocol/node': 'middleware/node', - '@modelcontextprotocol/express': 'middleware/express', + '@modelcontextprotocol/express': 'middleware/express' }; const versions: Record = {}; diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index fb5bd057f..b9fba64c5 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -3,5 +3,5 @@ export const V2_PACKAGE_VERSIONS: Record = { '@modelcontextprotocol/client': '^2.0.0-alpha.2', '@modelcontextprotocol/server': '^2.0.0-alpha.2', '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', + '@modelcontextprotocol/express': '^2.0.0-alpha.2' }; diff --git a/packages/codemod/src/utils/packageJsonUpdater.ts b/packages/codemod/src/utils/packageJsonUpdater.ts index 0dad5dde9..9ddc99708 100644 --- a/packages/codemod/src/utils/packageJsonUpdater.ts +++ b/packages/codemod/src/utils/packageJsonUpdater.ts @@ -12,11 +12,7 @@ function detectIndent(text: string): string { return match ? match[1]! : ' '; } -export function updatePackageJson( - targetDir: string, - usedPackages: Set, - dryRun: boolean -): PackageJsonChange | undefined { +export function updatePackageJson(targetDir: string, usedPackages: Set, dryRun: boolean): PackageJsonChange | undefined { const pkgJsonPath = findPackageJson(targetDir); if (!pkgJsonPath) return undefined; @@ -35,9 +31,7 @@ export function updatePackageJson( const inDevDeps = devDeps !== undefined && V1_PACKAGE in devDeps; if (!inDeps && !inDevDeps) return undefined; - const packagesToAdd = [...usedPackages].filter( - pkg => !PRIVATE_PACKAGES.has(pkg) && pkg in V2_PACKAGE_VERSIONS - ); + const packagesToAdd = [...usedPackages].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. @@ -69,7 +63,7 @@ export function updatePackageJson( } return { - added: added.sort(), + added: added.toSorted(), removed, packageJsonPath: pkgJsonPath }; diff --git a/packages/codemod/test/integration.test.ts b/packages/codemod/test/integration.test.ts index ded789cc8..3887e09ec 100644 --- a/packages/codemod/test/integration.test.ts +++ b/packages/codemod/test/integration.test.ts @@ -247,7 +247,7 @@ describe('integration', () => { { dependencies: { '@modelcontextprotocol/sdk': '^1.0.0', - 'express': '^4.0.0' + express: '^4.0.0' } }, null, @@ -286,10 +286,7 @@ describe('integration', () => { '@modelcontextprotocol/sdk': '^1.0.0' } }); - writeFileSync( - path.join(dir, 'server.ts'), - `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n` - ); + writeFileSync(path.join(dir, 'server.ts'), `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n`); const result = run(migration, { targetDir: dir, dryRun: true }); diff --git a/packages/codemod/test/packageJsonUpdater.test.ts b/packages/codemod/test/packageJsonUpdater.test.ts index 43d836471..c06e76d2c 100644 --- a/packages/codemod/test/packageJsonUpdater.test.ts +++ b/packages/codemod/test/packageJsonUpdater.test.ts @@ -32,7 +32,7 @@ describe('updatePackageJson', () => { writePkgJson(dir, { dependencies: { '@modelcontextprotocol/sdk': '^1.0.0', - 'express': '^4.0.0' + express: '^4.0.0' } }); @@ -98,11 +98,7 @@ describe('updatePackageJson', () => { } }); - const result = updatePackageJson( - dir, - new Set(['@modelcontextprotocol/server', '@modelcontextprotocol/node']), - false - ); + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server', '@modelcontextprotocol/node']), false); expect(result).toBeDefined(); expect(result!.added).toContain('@modelcontextprotocol/node'); @@ -122,7 +118,7 @@ describe('updatePackageJson', () => { const dir = createTempDir(); writePkgJson(dir, { dependencies: { - 'express': '^4.0.0' + express: '^4.0.0' } }); @@ -157,11 +153,7 @@ describe('updatePackageJson', () => { } }); - const result = updatePackageJson( - dir, - new Set(['@modelcontextprotocol/core', '@modelcontextprotocol/server']), - false - ); + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/core', '@modelcontextprotocol/server']), false); expect(result).toBeDefined(); expect(result!.added).not.toContain('@modelcontextprotocol/core'); @@ -174,11 +166,15 @@ describe('updatePackageJson', () => { it('preserves 4-space indentation', () => { const dir = createTempDir(); - writePkgJson(dir, { - dependencies: { - '@modelcontextprotocol/sdk': '^1.0.0' - } - }, 4); + writePkgJson( + dir, + { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }, + 4 + ); updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); @@ -206,7 +202,7 @@ describe('updatePackageJson', () => { writePkgJson(dir, { dependencies: { '@modelcontextprotocol/sdk': '^1.0.0', - 'express': '^4.0.0' + express: '^4.0.0' } }); From 96b14814acb5ad2c6d8aefbd76266da1d99e8a3e Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 25 Apr 2026 01:57:36 +0300 Subject: [PATCH 22/22] lint fix --- packages/codemod/package.json | 2 +- .../scripts/{generate-versions.ts => generateVersions.ts} | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) rename packages/codemod/scripts/{generate-versions.ts => generateVersions.ts} (87%) diff --git a/packages/codemod/package.json b/packages/codemod/package.json index daf68067b..7507577d3 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -34,7 +34,7 @@ ], "scripts": { "typecheck": "tsgo -p tsconfig.json --noEmit", - "generate:versions": "tsx scripts/generate-versions.ts", + "generate:versions": "tsx scripts/generateVersions.ts", "prebuild": "pnpm run generate:versions", "build": "tsdown", "build:watch": "tsdown --watch", diff --git a/packages/codemod/scripts/generate-versions.ts b/packages/codemod/scripts/generateVersions.ts similarity index 87% rename from packages/codemod/scripts/generate-versions.ts rename to packages/codemod/scripts/generateVersions.ts index 5fb96899b..8a59ba74f 100644 --- a/packages/codemod/scripts/generate-versions.ts +++ b/packages/codemod/scripts/generateVersions.ts @@ -20,9 +20,8 @@ for (const [pkg, dir] of Object.entries(PACKAGE_DIRS)) { versions[pkg] = `^${pkgJson.version}`; } -const lines = Object.entries(versions) - .map(([pkg, ver]) => ` '${pkg}': '${ver}',`) - .join('\n'); +const entries = Object.entries(versions); +const lines = entries.map(([pkg, ver], i) => ` '${pkg}': '${ver}'${i < entries.length - 1 ? ',' : ''}`).join('\n'); const output = `// AUTO-GENERATED — do not edit. Run \`pnpm run generate:versions\` to regenerate. export const V2_PACKAGE_VERSIONS: Record = {