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' 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..7507577d3 --- /dev/null +++ b/packages/codemod/package.json @@ -0,0 +1,68 @@ +{ + "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", + "generate:versions": "tsx scripts/generateVersions.ts", + "prebuild": "pnpm run generate:versions", + "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/scripts/generateVersions.ts b/packages/codemod/scripts/generateVersions.ts new file mode 100644 index 000000000..8a59ba74f --- /dev/null +++ b/packages/codemod/scripts/generateVersions.ts @@ -0,0 +1,34 @@ +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 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 = { +${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 new file mode 100644 index 000000000..0cdf9c312 --- /dev/null +++ b/packages/codemod/src/cli.ts @@ -0,0 +1,149 @@ +#!/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} [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 | undefined, 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; + } + + if (!targetDir) { + console.error(`\nError: missing required argument .\n`); + process.exitCode = 1; + 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(''); + } + + 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 (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) { + console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } + }); +} + +program.parse(); diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts new file mode 100644 index 000000000..b9fba64c5 --- /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/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..993c461cf --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts @@ -0,0 +1,147 @@ +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; +} + +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/server', + status: 'renamed', + renamedSymbols: { + StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' + }, + symbolTargetOverrides: { + StreamableHTTPServerTransport: '@modelcontextprotocol/node' + } + }, + '@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/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' + } +}; + +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..37352a9ee --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts @@ -0,0 +1,30 @@ +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', + ListRootsRequestSchema: 'roots/list' +}; + +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', + RootsListChangedNotificationSchema: 'notifications/roots/list_changed' +}; 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..9eecdc432 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts @@ -0,0 +1,149 @@ +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 { 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']); + +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 { + if (!hasMcpImports(sourceFile)) { + return { changesCount: 0, diagnostics: [] }; + } + + 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 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(); + + 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 => { + 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++; + + if (!body) continue; + + body.forEachDescendant(node => { + if (!Node.isPropertyAccessExpression(node)) return; + + const fullText = node.getText(); + for (const mapping of CONTEXT_PROPERTY_MAP) { + if (mapping.from === mapping.to) continue; + + const oldPattern = CTX_PARAM_NAME + mapping.from; + 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(matchedPattern, 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/expressMiddleware.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts new file mode 100644 index 000000000..5821c9109 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts @@ -0,0 +1,60 @@ +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'; +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 (!isImportedFromMcp(sourceFile, 'hostHeaderValidation')) { + return { changesCount: 0, diagnostics: [] }; + } + + 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/handlerRegistration.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts new file mode 100644 index 000000000..abe7c66e2 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts @@ -0,0 +1,51 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Transform, TransformContext, TransformResult } from '../../../types.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 = { + ...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 originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName; + const methodString = ALL_SCHEMA_TO_METHOD[originalName]; + if (!methodString) continue; + + if (!isImportedFromMcp(sourceFile, schemaName)) continue; + + firstArg.replaceWithText(`'${methodString}'`); + changesCount++; + + 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 new file mode 100644 index 000000000..263003a84 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -0,0 +1,252 @@ +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'; +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', + id: 'imports', + apply(sourceFile: SourceFile, context: TransformContext): TransformResult { + const diagnostics: ReturnType[] = []; + const usedPackages = new Set(); + 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, usedPackages); + + 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(); + const typeOnly = isTypeOnlyImport(imp); + const line = imp.getStartLineNumber(); + const defaultImport = imp.getDefaultImport(); + const namespaceImport = imp.getNamespaceImport(); + + 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); + } + + if (mapping.renamedSymbols) { + 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) { + 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()]!; + } + } + usedPackages.add(effectiveTarget); + imp.setModuleSpecifier(effectiveTarget); + if (mapping.renamedSymbols) { + for (const n of namedImports) { + const newName = mapping.renamedSymbols[n.getName()]; + if (newName) { + n.setName(newName); + } + } + if (namespaceImport) { + diagnostics.push( + warning( + filePath, + line, + `Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` + + `were renamed in ${effectiveTarget}. Update qualified accesses manually.` + ) + ); + } + } + changesCount++; + continue; + } + + for (const n of namedImports) { + const name = n.getName(); + 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(); + 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, usedPackages }; + } +}; + +function rewriteExportDeclarations( + sdkExports: import('ts-morph').ExportDeclaration[], + sourceFile: import('ts-morph').SourceFile, + filePath: string, + context: TransformContext, + diagnostics: ReturnType[], + usedPackages: Set +): 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); + } + + 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()]!; + } + } + usedPackages.add(targetPackage); + exp.setModuleSpecifier(targetPackage); + 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++; + } + + 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..bdc1c5e6b --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts @@ -0,0 +1,45 @@ +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'; + +// 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) and rewrites type references (e.g., SchemaInput → +// StandardSchemaWithJSON.InferInput). +// +// 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. +// +// 5. handlerRegistration, schemaParamRemoval, and expressMiddleware are +// independent of each other but all depend on importPaths having run. +// +// 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/mcpServerApi.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts new file mode 100644 index 000000000..a2f086b14 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -0,0 +1,281 @@ +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 { isImportedFromMcp } 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 (!isImportedFromMcp(sourceFile, 'McpServer')) { + 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); + 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(); + + 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); + } + 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 + expr.getNameNode().replaceWithText('registerResource'); + } else { + return false; + } + + 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..4061a3ef7 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -0,0 +1,262 @@ +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'; +import { SIMPLE_RENAMES } from '../mappings/symbolMap.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[] = []; + const usedPackages = new Set(); + 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, usedPackages); + } + } + } + + changesCount += rewriteDynamicImports(sourceFile, context, diagnostics, usedPackages); + + return { changesCount, diagnostics, usedPackages }; + } +}; + +function resolveTarget( + specifier: string, + context: TransformContext, + sourceFile: SourceFile +): { target: string; renamedSymbols?: Record; symbolTargetOverrides?: 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, symbolTargetOverrides: mapping.symbolTargetOverrides }; +} + +function rewriteMockCall( + call: import('ts-morph').CallExpression, + sourceFile: SourceFile, + context: TransformContext, + diagnostics: ReturnType[], + usedPackages: Set +): 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; + + 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]!]!; + } + } + + usedPackages.add(effectiveTarget); + firstArg.setLiteralValue(effectiveTarget); + changes++; + + const allRenames: Record = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols }; + if (args.length >= 2) { + changes += renameSymbolsInFactory(args[1]!, allRenames); + } + + 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; + + 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[], + usedPackages: Set +): 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) { + 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; + } + + 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()]!; + } + } + } + } + } + + usedPackages.add(effectiveTarget); + firstArg.setLiteralValue(effectiveTarget); + changes++; + + const parent = node.getParent(); + if (parent && Node.isAwaitExpression(parent)) { + const grandParent = parent.getParent(); + if (grandParent && Node.isVariableDeclaration(grandParent)) { + const nameNode = grandParent.getNameNode(); + if (Node.isObjectBindingPattern(nameNode)) { + for (const element of nameNode.getElements()) { + const propertyName = element.getPropertyNameNode()?.getText(); + const bindingName = element.getName(); + const lookupKey = propertyName ?? bindingName; + const newName = allRenames[lookupKey]; + if (newName) { + if (propertyName) { + element.getPropertyNameNode()!.replaceWithText(newName); + } else { + element.replaceWithText(`${newName}: ${bindingName}`); + } + changes++; + } + } + } + } + } + }); + + return 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..2e6fd23aa --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts @@ -0,0 +1,189 @@ +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()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + 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 localName = foundImport.getAliasNode()?.getText() ?? 'IsomorphicHeaders'; + const line = foundImportDecl.getStartLineNumber(); + + renameAllReferences(sourceFile, localName, '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 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() !== localName) 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, localName, '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/schemaParamRemoval.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts new file mode 100644 index 000000000..4c8a9e4cb --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts @@ -0,0 +1,43 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Transform, TransformContext, TransformResult } from '../../../types.js'; +import { isImportedFromMcp, removeUnusedImport, resolveOriginalImportName } from '../../../utils/importUtils.js'; + +const TARGET_METHODS = new Set(['request', 'callTool', 'send', 'sendRequest']); + +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 (!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); + changesCount++; + + 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 new file mode 100644 index 000000000..b2b2cbf06 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts @@ -0,0 +1,333 @@ +import type { SourceFile } from 'ts-morph'; +import { Node } from 'ts-morph'; + +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']); +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: Diagnostic[] = []; + let changesCount = 0; + + 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]; + 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); + changesCount += handleSchemaInput(sourceFile, context, diagnostics); + + return { changesCount, diagnostics }; + } +}; + +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) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'ErrorCode') { + errorCodeImport = namedImport; + break; + } + } + if (errorCodeImport) break; + } + + if (!errorCodeImport) return 0; + + const errorCodeLocalName = errorCodeImport.getAliasNode()?.getText() ?? 'ErrorCode'; + + let needsProtocolErrorCode = false; + let needsSdkErrorCode = false; + + sourceFile.forEachDescendant(node => { + if (!Node.isPropertyAccessExpression(node)) return; + const expr = node.getExpression(); + if (!Node.isIdentifier(expr) || expr.getText() !== errorCodeLocalName) return; + + const member = node.getName(); + if (ERROR_CODE_SDK_MEMBERS.has(member)) { + needsSdkErrorCode = true; + node.getExpression().replaceWithText('SdkErrorCode'); + } else { + needsProtocolErrorCode = true; + node.getExpression().replaceWithText('ProtocolErrorCode'); + } + changesCount++; + }); + + if (changesCount > 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(); + 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) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'RequestHandlerExtra') { + extraImport = namedImport; + extraImportDecl = imp; + break; + } + } + if (extraImport) break; + } + + 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'; + }); + 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'; + } + + let needsServerContext = false; + let needsClientContext = false; + + sourceFile.forEachDescendant(node => { + if (!Node.isTypeReference(node)) return; + const typeName = node.getTypeName(); + if (!Node.isIdentifier(typeName) || typeName.getText() !== extraLocalName) 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 (target === 'ServerContext') needsServerContext = true; + if (target === 'ClientContext') needsClientContext = true; + + if (typeArgs.length > 0) { + node.replaceWithText(target); + } else { + typeName.replaceWithText(target); + } + changesCount++; + }); + + 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' }); + 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(), + extraImportLine, + `RequestHandlerExtra renamed to ${targets}. Generic type arguments removed. Verify the migration is correct.` + ) + ); + } + + 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; + + const schemaInputLocalName = schemaInputImport.getAliasNode()?.getText() ?? 'SchemaInput'; + + sourceFile.forEachDescendant(node => { + if (!Node.isTypeReference(node)) return; + const typeName = node.getTypeName(); + if (!Node.isIdentifier(typeName) || typeName.getText() !== schemaInputLocalName) 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/runner.ts b/packages/codemod/src/runner.ts new file mode 100644 index 000000000..a58577591 --- /dev/null +++ b/packages/codemod/src/runner.ts @@ -0,0 +1,124 @@ +import { Project } from 'ts-morph'; + +import type { Diagnostic, FileResult, Migration, RunnerOptions, RunnerResult } from './types.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 { + const context = analyzeProject(options.targetDir); + + 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, + 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__/**', + '**/*.d.ts', + '**/*.d.mts', + ...(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(); + 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[] = []; + const allUsedPackages = new Set(); + 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); + if (result.usedPackages) { + for (const pkg of result.usedPackages) { + allUsedPackages.add(pkg); + } + } + } + } 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 (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(); + } + + return { + filesChanged, + totalChanges, + diagnostics: allDiagnostics, + fileResults, + packageJsonChanges + }; +} diff --git a/packages/codemod/src/types.ts b/packages/codemod/src/types.ts new file mode 100644 index 000000000..2243e5d52 --- /dev/null +++ b/packages/codemod/src/types.ts @@ -0,0 +1,64 @@ +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[]; + usedPackages?: Set; +} + +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 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/astUtils.ts b/packages/codemod/src/utils/astUtils.ts new file mode 100644 index 000000000..0ab883b0f --- /dev/null +++ b/packages/codemod/src/utils/astUtils.ts @@ -0,0 +1,32 @@ +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.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; + 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.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)) { + parent.replaceWithText(`${oldName}: ${newName}`); + return; + } + node.replaceWithText(newName); + } + }); +} 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..d48a08812 --- /dev/null +++ b/packages/codemod/src/utils/importUtils.ts @@ -0,0 +1,122 @@ +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 { + const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length); + sourceFile.insertImportDeclaration(clampedIndex, { + 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 => { + 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 => { + 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()) { + if (onlyMcpImports && !isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if ((namedImport.getAliasNode()?.getText() ?? namedImport.getName()) === symbolName) { + namedImport.remove(); + if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport()) { + imp.remove(); + } + return; + } + } + } + } +} diff --git a/packages/codemod/src/utils/packageJsonUpdater.ts b/packages/codemod/src/utils/packageJsonUpdater.ts new file mode 100644 index 000000000..9ddc99708 --- /dev/null +++ b/packages/codemod/src/utils/packageJsonUpdater.ts @@ -0,0 +1,70 @@ +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.toSorted(), + removed, + packageJsonPath: pkgJsonPath + }; +} diff --git a/packages/codemod/src/utils/projectAnalyzer.ts b/packages/codemod/src/utils/projectAnalyzer.ts new file mode 100644 index 000000000..d1e2a500b --- /dev/null +++ b/packages/codemod/src/utils/projectAnalyzer.ts @@ -0,0 +1,59 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { TransformContext } from '../types.js'; + +const PROJECT_ROOT_MARKERS = ['.git', 'node_modules']; + +export 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 = findPackageJson(targetDir); + if (!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/cli.test.ts b/packages/codemod/test/cli.test.ts new file mode 100644 index 000000000..c6379ca83 --- /dev/null +++ b/packages/codemod/test/cli.test.ts @@ -0,0 +1,130 @@ +import { mkdtempSync, writeFileSync, 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-cli-')); + return tempDir; +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('CLI diagnostic behavior', () => { + it('warnings do not produce errors-level diagnostics', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';`, + `const transport = new SSEServerTransport();`, + `` + ].join('\n') + ); + + 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('emits info-level diagnostics for z.object() wrapping', () => { + 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 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('--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(); + 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/integration.test.ts b/packages/codemod/test/integration.test.ts new file mode 100644 index 000000000..3887e09ec --- /dev/null +++ b/packages/codemod/test/integration.test.ts @@ -0,0 +1,454 @@ +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')!; + +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 { + 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' });`, + `const transport = new StreamableHTTPServerTransport({});`, + ``, + `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 + body references updated + expect(output).toContain('NodeStreamableHTTPServerTransport'); + expect(output).toContain('new NodeStreamableHTTPServerTransport({})'); + expect(output).not.toMatch(/(? { + 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('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('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 = [ + `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/packageJsonUpdater.test.ts b/packages/codemod/test/packageJsonUpdater.test.ts new file mode 100644 index 000000000..c06e76d2c --- /dev/null +++ b/packages/codemod/test/packageJsonUpdater.test.ts @@ -0,0 +1,267 @@ +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(); + }); +}); diff --git a/packages/codemod/test/projectAnalyzer.test.ts b/packages/codemod/test/projectAnalyzer.test.ts new file mode 100644 index 000000000..0f69eacf7 --- /dev/null +++ b/packages/codemod/test/projectAnalyzer.test.ts @@ -0,0 +1,130 @@ +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'); + }); + + 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 new file mode 100644 index 000000000..14221205b --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts @@ -0,0 +1,248 @@ +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' }; + +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', MCP_IMPORT + 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', 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) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + 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 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();`, + `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 new file mode 100644 index 000000000..2b520932e --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts @@ -0,0 +1,108 @@ +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); + }); + + 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'] }"); + }); + + 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 new file mode 100644 index 000000000..173bf3da2 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts @@ -0,0 +1,155 @@ +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'"); + }); + + 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('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';`, + `server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [] }));`, + `validateSchema(McpSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("from './local-schemas.js'"); + expect(result).toContain('setRequestHandler(CallToolRequestSchema'); + expect(result).not.toContain("'tools/call'"); + }); + + 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'); + }); + + 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 new file mode 100644 index 000000000..16cc4e652 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -0,0 +1,299 @@ +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('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('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';`, + `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/server'); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics.some(d => d.message.includes('renamed') && d.message.includes('StreamableHTTPServerTransport'))).toBe( + true + ); + }); + + 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 }); + 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'); + }); + + 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'); + }); + + 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/mcpServerApi.test.ts b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts new file mode 100644 index 000000000..cbecc1b39 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts @@ -0,0 +1,110 @@ +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('); + }); + + 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()'); + // 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/mockPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts new file mode 100644 index 000000000..cebd0f585 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts @@ -0,0 +1,229 @@ +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.toMatch(/(? { + 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('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('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); + 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 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 }); + 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'); + }); + + 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/removedApis.test.ts b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts new file mode 100644 index 000000000..2eff7c17d --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts @@ -0,0 +1,259 @@ +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('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' + ); + 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); + }); + + 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 new file mode 100644 index 000000000..8c575e428 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts @@ -0,0 +1,100 @@ +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); + }); + + 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('); + }); + + 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 new file mode 100644 index 000000000..22d9a80ea --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -0,0 +1,394 @@ +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.toMatch(/\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'); + }); + + 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('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('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('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('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 { ProtocolError as McpError }'); + }); + + 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); + }); + + 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('); + }); + + 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('); + }); +}); 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/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 + } +} 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