feat: add v2 codemod draft (#1950)

This commit is contained in:
Konstantin Konstantinov 2026-05-21 16:13:33 +03:00 • committed by GitHub
commit 48251fe2cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 9239 additions and 1 deletions

View file

@ -0,0 +1,3 @@
repos/
results/
tarballs/

View file

@ -0,0 +1,102 @@
# Codemod Batch Test
Tests the v1-to-v2 codemod against real-world repos to find bugs, missing transforms, and gaps.
## How it works
For each repo in `repos.json`, the batch test:
1. Clones the repo (or resets an existing clone)
2. Installs dependencies
3. Runs baseline checks (typecheck, build, test, lint) to confirm the repo is healthy
4. Runs the codemod using the programmatic API
5. Packs local SDK packages as tarballs and rewrites `package.json` deps to use them (so the test runs against the current SDK branch, not published npm versions)
6. Re-installs dependencies
7. Re-runs the same checks
8. Writes structured JSON reports
Errors that appear in step 7 but not step 3 are codemod-introduced regressions.
## Usage
```bash
# Build all SDK packages first (tarballs need built dist/)
pnpm build:all
# Run the batch test
pnpm --filter @modelcontextprotocol/codemod batch-test
# Clean cloned repos, results, and tarballs
pnpm --filter @modelcontextprotocol/codemod batch-test:clean
```
## Output
Results are written to `batch-test/results/`:
- `summary.json` — overview across all repos: which passed, which failed, error counts
- `<repo-slug>/report.json` — per-repo detail: baseline vs post-codemod check results, codemod diagnostics, change counts
## Repo manifest (`repos.json`)
An array of repo entries. Each entry specifies a GitHub repo and one or more packages within it.
```json
{
"repo": "owner/repo-name",
"ref": "main",
"packages": [
{
"dir": "packages/mcp-server",
"sourceDir": "src",
"checks": {
"typecheck": "npx tsc --noEmit",
"build": "npm run build",
"test": "npm run test",
"lint": null
}
}
]
}
```
| Field | Required | Default | Description |
| ---------------------- | -------- | -------------------------------------- | ----------------------------------------------------------------- |
| `repo` | yes | — | GitHub `owner/name` |
| `ref` | no | `main` | Branch or tag to clone |
| `packages` | no | `[{ "dir": ".", "sourceDir": "src" }]` | Package targets within the repo |
| `packages[].dir` | yes | — | Path to package root (where `package.json` lives) |
| `packages[].sourceDir` | no | `src` | Source directory relative to `dir` (passed to codemod) |
| `packages[].checks` | no | auto-detect | Override check commands; set a value to `null` to skip that check |
When `checks` is omitted, the runner auto-detects commands from the package's `package.json` scripts (probing names like `typecheck`, `build`, `test`, `lint`). The package manager is auto-detected from the lockfile at the repo root.
## Analyzing results
`analyze-prompt.md` contains instructions for Claude Code to run the batch test and produce a categorized analysis. Each error is classified as:
| Category | Meaning |
| ------------------- | -------------------------------------------------- |
| `codemod-bug` | A transform produced incorrect output |
| `missing-transform` | The codemod should handle this pattern but doesn't |
| `manual-migration` | Expected — requires human judgment |
| `repo-specific` | Unusual pattern not worth handling in the codemod |
## Adding a repo
1. Edit `repos.json` and add an entry
2. Run `pnpm --filter @modelcontextprotocol/codemod batch-test`
3. Check `results/<repo-slug>/report.json` for new findings
For monorepos, list each package that uses `@modelcontextprotocol/sdk` as a separate entry in `packages`.
## Iteration workflow
```
1. Run the batch test
2. Review results — identify codemod bugs / missing transforms
3. Fix the codemod transforms
4. Run batch-test:clean, then re-run the batch test
5. Confirm the fixes resolved the issues
6. Repeat
```

View file

@ -0,0 +1,87 @@
# Codemod Batch Test: Analysis
## Context
The MCP TypeScript SDK is migrating from a single v1 package (`@modelcontextprotocol/sdk`) to a multi-package v2 architecture (`@modelcontextprotocol/client`, `/server`, `/core`, `/node`, `/express`). This involves renamed APIs, restructured context objects, removed modules, and
new import paths.
The `@modelcontextprotocol/codemod` package automates the mechanical parts of this migration. It runs 9 ordered AST transforms via ts-morph: import path rewrites, symbol renames, McpServer API restructuring, handler registration changes, context property remapping, and more. It
also updates `package.json` to swap v1 deps for v2.
The **batch test** runs this codemod against a curated list of real-world repos that use the v1 SDK. For each repo it:
1. Clones and installs
2. Runs baseline checks (typecheck, build, test, lint) to confirm the repo is healthy before migration
3. Runs the codemod
4. Re-installs (package.json was updated with v2 deps)
5. Re-runs the same checks
The goal is to find issues in the codemod itself — incorrect transforms, missing transforms, or gaps — so we can fix them.
## Instructions
1. Build the codemod:
```
pnpm --filter @modelcontextprotocol/codemod build
```
2. Run the batch test:
```
pnpm --filter @modelcontextprotocol/codemod batch-test
```
3. Read `packages/codemod/batch-test/results/summary.json` for the overview. Note which repos have `postCodemodClean: false` and which check types have new errors.
4. For each repo with new errors, read its `packages/codemod/batch-test/results/<repo-slug>/report.json`. Compare `baseline` vs `postCodemod` for each check — only errors that appear in `postCodemod` but not in `baseline` are codemod-introduced.
5. Also review the `codemod.diagnostics` array in each report — these are warnings the codemod itself emitted about patterns it couldn't fully handle.
6. For each codemod-introduced error, look at the actual source file in the cloned repo (`packages/codemod/batch-test/repos/<repo-slug>/...`) to understand what the codemod produced and what it should have produced.
7. Categorize each finding using the categories below, then produce the output described in the Output Format section.
## Error Categories
| Category | Meaning | What to do |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `codemod-bug` | A transform produced incorrect output — the code it generated is wrong | Identify which transform is responsible and what the correct output should be. This is a bug to fix in the codemod. |
| `missing-transform` | The codemod left v1 code untouched that it should have migrated | Identify the v1 pattern and which existing transform should handle it, or whether a new transform is needed. |
| `manual-migration` | The error is expected — the migration guide documents this as requiring human judgment (e.g., removed APIs, architectural changes) | Verify the codemod emitted a diagnostic for it. If not, add one. |
| `repo-specific` | An unusual pattern unique to this repo that isn't worth handling in the codemod | Note it briefly but don't suggest codemod changes. |
## Output Format
### Summary
- Repos tested: X
- Repos clean after codemod: Y
- Repos with new errors: Z
- Total codemod-introduced errors: N
### Findings by Category
#### Codemod Bugs
| Repo | File:Line | Error | Transform | Root Cause | Correct Output |
| ---- | --------- | ----- | --------- | ---------- | -------------- |
#### Missing Transforms
| Repo | File:Line | Error | v1 Pattern | Suggested Fix |
| ---- | --------- | ----- | ---------- | ------------- |
#### Manual Migration (expected)
| Repo | File:Line | Error | Has Diagnostic? | Migration Guide Reference |
| ---- | --------- | ----- | --------------- | ------------------------- |
#### Repo-Specific
| Repo | File:Line | Error | Why Not Worth Handling |
| ---- | --------- | ----- | ---------------------- |
### Priority Fixes
List the top 3-5 codemod improvements that would fix the most repos, ordered by impact (number of repos affected). For each, state: what to change, in which transform, and how many repos it would fix.

View file

@ -0,0 +1,44 @@
[
{
"repo": "KKonstantinov/mcp-servers-fork",
"ref": "feature/upgrade-zod-v4",
"packages": [
{
"dir": "src/everything",
"sourceDir": ".",
"checks": {
"typecheck": "npx tsc --noEmit",
"build": "npm run build",
"test": "npm run test",
"lint": "npm run prettier:check"
}
}
]
},
{
"repo": "modelcontextprotocol/inspector",
"ref": "main",
"packages": [
{
"dir": "client",
"sourceDir": "src",
"checks": {
"typecheck": "npx tsc --noEmit",
"build": "npm run build",
"test": "npm run test",
"lint": "npm run lint"
}
},
{
"dir": "server",
"sourceDir": "src",
"checks": {
"typecheck": "npx tsc --noEmit",
"build": "npm run build",
"test": null,
"lint": null
}
}
]
}
]

View file

@ -0,0 +1,5 @@
// @ts-check
import baseConfig from '@modelcontextprotocol/eslint-config';
export default [...baseConfig];

View file

@ -0,0 +1,71 @@
{
"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",
"generate:spec-schemas": "tsx scripts/generateSpecSchemaMap.ts",
"prebuild": "pnpm run generate:versions && pnpm run generate:spec-schemas",
"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",
"batch-test": "tsx src/bin/batchTest.ts",
"batch-test:clean": "rm -rf batch-test/repos batch-test/results batch-test/tarballs",
"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"
}
}

View file

@ -0,0 +1,39 @@
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 specTypeSchemaPath = path.resolve(__dirname, '../../core/src/types/specTypeSchema.ts');
const source = readFileSync(specTypeSchemaPath, 'utf8');
// Extract SPEC_SCHEMA_KEYS array entries
const keysMatch = source.match(/const SPEC_SCHEMA_KEYS = \[([\s\S]*?)\] as const/);
if (!keysMatch) throw new Error('Could not find SPEC_SCHEMA_KEYS in specTypeSchema.ts');
const protocolSchemas = [...keysMatch[1]!.matchAll(/'([^']+)'/g)].map(m => m[1]!);
// Extract auth schema keys
const authMatch = source.match(/const authSchemas = \{([\s\S]*?)\} as const/);
if (!authMatch) throw new Error('Could not find authSchemas in specTypeSchema.ts');
const authSchemas = [...authMatch[1]!.matchAll(/(\w+Schema)/g)].map(m => m[1]!);
const allSchemas = [...protocolSchemas, ...authSchemas].toSorted();
const entries = allSchemas.map((s, i) => ` '${s}'${i < allSchemas.length - 1 ? ',' : ''}`).join('\n');
const output = `// AUTO-GENERATED — do not edit. Run \`pnpm run generate:spec-schemas\` to regenerate.
export const SPEC_SCHEMA_NAMES: ReadonlySet<string> = new Set([
${entries}
]);
export function specSchemaToTypeName(schemaName: string): string | undefined {
if (!SPEC_SCHEMA_NAMES.has(schemaName)) return undefined;
return schemaName.slice(0, -'Schema'.length);
}
`;
const outPath = path.resolve(__dirname, '../src/generated/specSchemaMap.ts');
writeFileSync(outPath, output);
console.log(`Wrote ${outPath} (${allSchemas.length} schemas)`);

View file

@ -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<string, string> = {
'@modelcontextprotocol/client': 'client',
'@modelcontextprotocol/server': 'server',
'@modelcontextprotocol/node': 'middleware/node',
'@modelcontextprotocol/express': 'middleware/express'
};
const versions: Record<string, string> = {};
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<string, string> = {
${lines}
};
`;
const outPath = path.resolve(__dirname, '../src/generated/versions.ts');
writeFileSync(outPath, output);
console.log(`Wrote ${outPath}`);

View file

@ -0,0 +1,463 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getMigration } from '../migrations/index.js';
import { run } from '../runner.js';
import type { Diagnostic, RunnerResult } from '../types.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PackageEntry {
dir: string;
sourceDir?: string;
checks?: Record<string, string | null>;
}
interface RepoEntry {
repo: string;
ref?: string;
packages?: PackageEntry[];
}
interface CheckResult {
exitCode: number;
stdout: string;
stderr: string;
}
interface PackageReport {
dir: string;
sourceDir: string;
codemod: {
filesChanged: number;
totalChanges: number;
diagnostics: Diagnostic[];
};
baseline: Record<string, CheckResult>;
postCodemod: Record<string, CheckResult>;
}
interface RepoReport {
repo: string;
ref: string;
timestamp: string;
packageManager: string;
packages: PackageReport[];
}
interface SummaryEntry {
repo: string;
package: string;
baselineClean: boolean;
postCodemodClean: boolean;
newErrors: Record<string, number>;
codemodDiagnostics: Record<string, number>;
}
interface Summary {
timestamp: string;
codemodVersion: string;
codemodCommit: string;
totalRepos: number;
totalPackages: number;
results: SummaryEntry[];
aggregated: {
reposClean: number;
reposWithNewErrors: number;
totalNewTypecheckErrors: number;
totalCodemodWarnings: number;
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const SDK_ROOT = path.resolve(SCRIPT_DIR, '../../../..');
const BATCH_DIR = path.resolve(SDK_ROOT, 'packages/codemod/batch-test');
const LOCAL_PACKAGE_DIRS: Record<string, string> = {
'@modelcontextprotocol/client': path.join(SDK_ROOT, 'packages/client'),
'@modelcontextprotocol/core': path.join(SDK_ROOT, 'packages/core'),
'@modelcontextprotocol/server': path.join(SDK_ROOT, 'packages/server'),
'@modelcontextprotocol/express': path.join(SDK_ROOT, 'packages/middleware/express'),
'@modelcontextprotocol/fastify': path.join(SDK_ROOT, 'packages/middleware/fastify'),
'@modelcontextprotocol/hono': path.join(SDK_ROOT, 'packages/middleware/hono'),
'@modelcontextprotocol/node': path.join(SDK_ROOT, 'packages/middleware/node')
};
const TARBALL_DIR = path.join(BATCH_DIR, 'tarballs');
const CHECK_SCRIPT_NAMES: Record<string, string[]> = {
typecheck: ['typecheck', 'type-check', 'check:types', 'tsc'],
build: ['build', 'compile'],
test: ['test', 'test:unit', 'test:all'],
lint: ['lint', 'lint:check']
};
function detectPm(repoRoot: string): string {
if (existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) return 'pnpm';
if (existsSync(path.join(repoRoot, 'yarn.lock'))) return 'yarn';
if (existsSync(path.join(repoRoot, 'bun.lockb'))) return 'bun';
return 'npm';
}
function detectCheckCmd(pkgDir: string, checkType: string): string | null {
const pkgJsonPath = path.join(pkgDir, 'package.json');
if (!existsSync(pkgJsonPath)) return null;
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as { scripts?: Record<string, string> };
const scripts = pkgJson.scripts ?? {};
const candidates = CHECK_SCRIPT_NAMES[checkType] ?? [];
for (const name of candidates) {
if (name in scripts) return name;
}
if (checkType === 'typecheck') return '__fallback_tsc';
return null;
}
function shell(cmd: string, cwd?: string): { exitCode: number; stdout: string; stderr: string } {
try {
const stdout = execSync(cmd, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
timeout: 5 * 60 * 1000
}).toString();
return { exitCode: 0, stdout, stderr: '' };
} catch (error: unknown) {
const e = error as { status?: number; stdout?: Buffer; stderr?: Buffer };
return {
exitCode: e.status ?? 1,
stdout: e.stdout?.toString() ?? '',
stderr: e.stderr?.toString() ?? ''
};
}
}
function runCheck(pm: string, pkgDir: string, checkType: string, override?: string | null): CheckResult {
if (override === null) {
return { exitCode: -1, stdout: '', stderr: 'skipped by manifest' };
}
let cmd: string;
if (override) {
cmd = override;
} else {
const detected = detectCheckCmd(pkgDir, checkType);
if (!detected) {
return { exitCode: -1, stdout: '', stderr: 'skipped: no matching script' };
}
cmd = detected === '__fallback_tsc' ? 'npx tsc --noEmit' : `${pm} run ${detected}`;
}
return shell(cmd, pkgDir);
}
function truncate(s: string, max = 50_000): string {
return s.length > max ? s.slice(0, max) + '\n... (truncated)' : s;
}
function packLocalPackages(): Record<string, string> {
mkdirSync(TARBALL_DIR, { recursive: true });
const tarballs: Record<string, string> = {};
for (const [name, pkgDir] of Object.entries(LOCAL_PACKAGE_DIRS)) {
console.log(` Packing ${name}...`);
const result = shell(`pnpm pack --pack-destination ${JSON.stringify(TARBALL_DIR)}`, pkgDir);
if (result.exitCode !== 0) {
console.error(` ERROR: failed to pack ${name}: ${result.stderr.split('\n')[0]}`);
continue;
}
const tarballFile = result.stdout.trim().split('\n').pop()!;
tarballs[name] = path.resolve(TARBALL_DIR, path.basename(tarballFile));
}
return tarballs;
}
function rewriteToLocalTarballs(pkgJsonPath: string, tarballs: Record<string, string>): number {
const raw = readFileSync(pkgJsonPath, 'utf8');
const pkgJson = JSON.parse(raw) as Record<string, unknown>;
let rewrites = 0;
for (const section of ['dependencies', 'devDependencies']) {
const deps = pkgJson[section] as Record<string, string> | undefined;
if (!deps) continue;
for (const [name, tarballPath] of Object.entries(tarballs)) {
if (name in deps) {
deps[name] = `file:${tarballPath}`;
rewrites++;
}
}
}
if (rewrites > 0) {
const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' ';
const trailingNewline = raw.endsWith('\n');
let output = JSON.stringify(pkgJson, null, indent);
if (trailingNewline) output += '\n';
writeFileSync(pkgJsonPath, output);
}
return rewrites;
}
function getCheckOverride(checks: Record<string, string | null>, type: string): string | null | undefined {
if (type in checks) return checks[type] ?? null;
return undefined;
}
function isAllClean(checks: Record<string, CheckResult>): boolean {
return Object.values(checks).every(c => c.exitCode === 0 || c.exitCode === -1);
}
function hasNewError(baseline: Record<string, CheckResult>, post: Record<string, CheckResult>, type: string): number {
return baseline[type]!.exitCode === 0 && post[type]!.exitCode !== 0 ? 1 : 0;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const CLONE_DIR = path.join(BATCH_DIR, 'repos');
const OUTPUT_DIR = path.join(BATCH_DIR, 'results');
function parseArgs(): { manifest: string } {
const args = process.argv.slice(2).filter(a => a !== '--');
const opts = {
manifest: path.join(BATCH_DIR, 'repos.json')
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--manifest': {
opts.manifest = args[++i]!;
break;
}
default: {
console.error(`Unknown flag: ${args[i]}`);
process.exit(1);
}
}
}
return opts;
}
function main(): void {
const opts = parseArgs();
if (!existsSync(opts.manifest)) {
console.error(`Error: manifest not found at ${opts.manifest}`);
process.exit(1);
}
const migration = getMigration('v1-to-v2');
if (!migration) {
console.error('Error: v1-to-v2 migration not found');
process.exit(1);
}
mkdirSync(CLONE_DIR, { recursive: true });
mkdirSync(OUTPUT_DIR, { recursive: true });
const manifest: RepoEntry[] = JSON.parse(readFileSync(opts.manifest, 'utf8')) as RepoEntry[];
const codemodPkg = JSON.parse(readFileSync(path.join(SDK_ROOT, 'packages/codemod/package.json'), 'utf8')) as { version: string };
const codemodVersion = codemodPkg.version;
const codemodCommit = execSync('git rev-parse --short HEAD', { cwd: SDK_ROOT }).toString().trim();
const timestamp = new Date().toISOString();
console.log('=== Codemod Batch Test ===');
console.log(`Manifest: ${opts.manifest} (${manifest.length} repos)`);
console.log(`Codemod: v${codemodVersion} (${codemodCommit})`);
console.log('');
console.log('--- Packing local SDK packages ---');
const tarballs = packLocalPackages();
console.log(` Packed ${Object.keys(tarballs).length} packages\n`);
const summaryResults: SummaryEntry[] = [];
let totalPackages = 0;
for (let i = 0; i < manifest.length; i++) {
const entry = manifest[i]!;
const ref = entry.ref ?? 'main';
const repoSlug = entry.repo.replace('/', '_');
const clonePath = path.join(CLONE_DIR, repoSlug);
console.log(`--- [${i + 1}/${manifest.length}] ${entry.repo} (${ref}) ---`);
// Step 1: Clone or reset
if (existsSync(path.join(clonePath, '.git'))) {
console.log(' Resetting existing clone...');
shell('git restore .', clonePath);
shell('git clean -fd', clonePath);
} else {
console.log(' Cloning...');
const cloneResult = shell(
`git clone --depth 1 --branch ${ref} https://github.com/${entry.repo}.git ${JSON.stringify(clonePath)}`
);
if (cloneResult.exitCode !== 0) {
console.log(` ERROR: clone failed, skipping\n ${cloneResult.stderr.split('\n')[0]}`);
continue;
}
}
// Step 2: Detect package manager
const pm = detectPm(clonePath);
console.log(` Package manager: ${pm}`);
// Step 3: Install
console.log(' Installing dependencies...');
const installResult = shell(`${pm} install --ignore-scripts`, clonePath);
if (installResult.exitCode !== 0) {
console.log(` ERROR: install failed, skipping\n ${installResult.stderr.split('\n')[0]}`);
continue;
}
// Process packages
const packages: PackageEntry[] = entry.packages ?? [{ dir: '.', sourceDir: 'src' }];
const repoPkgResults: PackageReport[] = [];
for (const pkg of packages) {
const sourceDir = pkg.sourceDir ?? 'src';
const fullPkgDir = path.join(clonePath, pkg.dir);
const fullSourceDir = path.join(fullPkgDir, sourceDir);
console.log(` Package: ${pkg.dir} (source: ${sourceDir})`);
const checks = pkg.checks ?? {};
// Step 4: Baseline checks
console.log(' Running baseline checks...');
const baseline: Record<string, CheckResult> = {};
for (const checkType of ['typecheck', 'build', 'test', 'lint']) {
baseline[checkType] = runCheck(pm, fullPkgDir, checkType, getCheckOverride(checks, checkType));
}
console.log(
` Baseline: tc=${baseline['typecheck']!.exitCode} build=${baseline['build']!.exitCode} test=${baseline['test']!.exitCode} lint=${baseline['lint']!.exitCode}`
);
// Step 5: Run codemod (programmatic API)
console.log(' Running codemod...');
let codemodResult: RunnerResult;
try {
codemodResult = run(migration, { targetDir: fullSourceDir, verbose: true });
} catch (error) {
console.log(` ERROR: codemod threw: ${error}`);
codemodResult = { filesChanged: 0, totalChanges: 0, diagnostics: [], fileResults: [] };
}
console.log(
` Codemod: files=${codemodResult.filesChanged} changes=${codemodResult.totalChanges} diags=${codemodResult.diagnostics.length}`
);
// Step 6: Rewrite v2 deps to local tarballs, then re-install
const rewrites = rewriteToLocalTarballs(path.join(fullPkgDir, 'package.json'), tarballs);
if (rewrites > 0) {
console.log(` Rewrote ${rewrites} deps to local tarballs`);
}
console.log(' Re-installing dependencies...');
shell(`${pm} install --ignore-scripts`, clonePath);
// Step 7: Post-codemod checks
console.log(' Running post-codemod checks...');
const postCodemod: Record<string, CheckResult> = {};
for (const checkType of ['typecheck', 'build', 'test', 'lint']) {
postCodemod[checkType] = runCheck(pm, fullPkgDir, checkType, getCheckOverride(checks, checkType));
}
console.log(
` Post: tc=${postCodemod['typecheck']!.exitCode} build=${postCodemod['build']!.exitCode} test=${postCodemod['test']!.exitCode} lint=${postCodemod['lint']!.exitCode}`
);
// Truncate large outputs for the report
for (const r of [...Object.values(baseline), ...Object.values(postCodemod)]) {
r.stdout = truncate(r.stdout);
r.stderr = truncate(r.stderr);
}
repoPkgResults.push({
dir: pkg.dir,
sourceDir,
codemod: {
filesChanged: codemodResult.filesChanged,
totalChanges: codemodResult.totalChanges,
diagnostics: codemodResult.diagnostics
},
baseline,
postCodemod
});
totalPackages++;
summaryResults.push({
repo: entry.repo,
package: pkg.dir,
baselineClean: isAllClean(baseline),
postCodemodClean: isAllClean(postCodemod),
newErrors: {
typecheck: hasNewError(baseline, postCodemod, 'typecheck'),
build: hasNewError(baseline, postCodemod, 'build'),
test: hasNewError(baseline, postCodemod, 'test'),
lint: hasNewError(baseline, postCodemod, 'lint')
},
codemodDiagnostics: {
warning: codemodResult.diagnostics.filter(d => d.level === 'warning').length,
error: codemodResult.diagnostics.filter(d => d.level === 'error').length,
info: codemodResult.diagnostics.filter(d => d.level === 'info').length
}
});
}
// Step 8: Write per-repo report
const repoOutputDir = path.join(OUTPUT_DIR, repoSlug);
mkdirSync(repoOutputDir, { recursive: true });
const report: RepoReport = {
repo: entry.repo,
ref,
timestamp,
packageManager: pm,
packages: repoPkgResults
};
writeFileSync(path.join(repoOutputDir, 'report.json'), JSON.stringify(report, null, 2));
console.log(` Report: ${repoOutputDir}/report.json\n`);
}
// Write summary
const reposClean = summaryResults.filter(r => r.postCodemodClean).length;
const reposWithErrors = summaryResults.filter(r => !r.postCodemodClean).length;
const totalNewTc = summaryResults.reduce((sum, r) => sum + r.newErrors['typecheck']!, 0);
const totalWarnings = summaryResults.reduce((sum, r) => sum + r.codemodDiagnostics['warning']!, 0);
const summary: Summary = {
timestamp,
codemodVersion,
codemodCommit,
totalRepos: manifest.length,
totalPackages,
results: summaryResults,
aggregated: {
reposClean,
reposWithNewErrors: reposWithErrors,
totalNewTypecheckErrors: totalNewTc,
totalCodemodWarnings: totalWarnings
}
};
writeFileSync(path.join(OUTPUT_DIR, 'summary.json'), JSON.stringify(summary, null, 2));
console.log('=== Summary ===');
console.log(`Repos: ${manifest.length} | Packages: ${totalPackages}`);
console.log(`Clean after codemod: ${reposClean} | With new errors: ${reposWithErrors}`);
console.log(`New typecheck errors: ${totalNewTc} | Codemod warnings: ${totalWarnings}`);
console.log('');
console.log(`Results: ${OUTPUT_DIR}/summary.json`);
}
main();

158
packages/codemod/src/cli.ts Normal file
View file

@ -0,0 +1,158 @@
#!/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 <ids>', 'Comma-separated transform IDs to run (default: all)')
.option('-v, --verbose', 'Show detailed per-change output')
.option('--ignore <patterns...>', 'Additional glob patterns to ignore')
.option('--list', 'List available transforms for this migration')
.action((targetDir: string | undefined, opts: Record<string, unknown>) => {
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 <target-dir>.\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 && !result.packageJsonChanges) {
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 && d.category !== 'v2-gap');
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('');
}
const v2Gaps = result.diagnostics.filter(d => d.category === 'v2-gap');
if (v2Gaps.length > 0) {
console.log(`SDK v2 known issues (${v2Gaps.length}):`);
for (const d of v2Gaps) {
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();

View file

@ -0,0 +1,170 @@
// AUTO-GENERATED — do not edit. Run `pnpm run generate:spec-schemas` to regenerate.
export const SPEC_SCHEMA_NAMES: ReadonlySet<string> = new Set([
'AnnotationsSchema',
'AudioContentSchema',
'BaseMetadataSchema',
'BlobResourceContentsSchema',
'BooleanSchemaSchema',
'CallToolRequestParamsSchema',
'CallToolRequestSchema',
'CallToolResultSchema',
'CancelTaskRequestSchema',
'CancelTaskResultSchema',
'CancelledNotificationParamsSchema',
'CancelledNotificationSchema',
'ClientCapabilitiesSchema',
'ClientNotificationSchema',
'ClientRequestSchema',
'ClientResultSchema',
'CompatibilityCallToolResultSchema',
'CompleteRequestParamsSchema',
'CompleteRequestSchema',
'CompleteResultSchema',
'ContentBlockSchema',
'CreateMessageRequestParamsSchema',
'CreateMessageRequestSchema',
'CreateMessageResultSchema',
'CreateMessageResultWithToolsSchema',
'CreateTaskResultSchema',
'CursorSchema',
'ElicitRequestFormParamsSchema',
'ElicitRequestParamsSchema',
'ElicitRequestSchema',
'ElicitRequestURLParamsSchema',
'ElicitResultSchema',
'ElicitationCompleteNotificationParamsSchema',
'ElicitationCompleteNotificationSchema',
'EmbeddedResourceSchema',
'EmptyResultSchema',
'EnumSchemaSchema',
'GetPromptRequestParamsSchema',
'GetPromptRequestSchema',
'GetPromptResultSchema',
'GetTaskPayloadRequestSchema',
'GetTaskPayloadResultSchema',
'GetTaskRequestSchema',
'GetTaskResultSchema',
'IconSchema',
'IconsSchema',
'ImageContentSchema',
'ImplementationSchema',
'InitializeRequestParamsSchema',
'InitializeRequestSchema',
'InitializeResultSchema',
'InitializedNotificationSchema',
'JSONArraySchema',
'JSONObjectSchema',
'JSONRPCErrorResponseSchema',
'JSONRPCMessageSchema',
'JSONRPCNotificationSchema',
'JSONRPCRequestSchema',
'JSONRPCResponseSchema',
'JSONRPCResultResponseSchema',
'JSONValueSchema',
'LegacyTitledEnumSchemaSchema',
'ListPromptsRequestSchema',
'ListPromptsResultSchema',
'ListResourceTemplatesRequestSchema',
'ListResourceTemplatesResultSchema',
'ListResourcesRequestSchema',
'ListResourcesResultSchema',
'ListRootsRequestSchema',
'ListRootsResultSchema',
'ListTasksRequestSchema',
'ListTasksResultSchema',
'ListToolsRequestSchema',
'ListToolsResultSchema',
'LoggingLevelSchema',
'LoggingMessageNotificationParamsSchema',
'LoggingMessageNotificationSchema',
'ModelHintSchema',
'ModelPreferencesSchema',
'MultiSelectEnumSchemaSchema',
'NotificationSchema',
'NumberSchemaSchema',
'OAuthClientInformationFullSchema',
'OAuthClientInformationSchema',
'OAuthClientMetadataSchema',
'OAuthClientRegistrationErrorSchema',
'OAuthErrorResponseSchema',
'OAuthMetadataSchema',
'OAuthProtectedResourceMetadataSchema',
'OAuthTokenRevocationRequestSchema',
'OAuthTokensSchema',
'OpenIdProviderDiscoveryMetadataSchema',
'OpenIdProviderMetadataSchema',
'PaginatedRequestParamsSchema',
'PaginatedRequestSchema',
'PaginatedResultSchema',
'PingRequestSchema',
'PrimitiveSchemaDefinitionSchema',
'ProgressNotificationParamsSchema',
'ProgressNotificationSchema',
'ProgressSchema',
'ProgressTokenSchema',
'PromptArgumentSchema',
'PromptListChangedNotificationSchema',
'PromptMessageSchema',
'PromptReferenceSchema',
'PromptSchema',
'ReadResourceRequestParamsSchema',
'ReadResourceRequestSchema',
'ReadResourceResultSchema',
'RelatedTaskMetadataSchema',
'RequestIdSchema',
'RequestMetaSchema',
'RequestSchema',
'ResourceContentsSchema',
'ResourceLinkSchema',
'ResourceListChangedNotificationSchema',
'ResourceRequestParamsSchema',
'ResourceSchema',
'ResourceTemplateReferenceSchema',
'ResourceTemplateSchema',
'ResourceUpdatedNotificationParamsSchema',
'ResourceUpdatedNotificationSchema',
'ResultSchema',
'RoleSchema',
'RootSchema',
'RootsListChangedNotificationSchema',
'SamplingContentSchema',
'SamplingMessageContentBlockSchema',
'SamplingMessageSchema',
'ServerCapabilitiesSchema',
'ServerNotificationSchema',
'ServerRequestSchema',
'ServerResultSchema',
'SetLevelRequestParamsSchema',
'SetLevelRequestSchema',
'SingleSelectEnumSchemaSchema',
'StringSchemaSchema',
'SubscribeRequestParamsSchema',
'SubscribeRequestSchema',
'TaskAugmentedRequestParamsSchema',
'TaskCreationParamsSchema',
'TaskMetadataSchema',
'TaskSchema',
'TaskStatusNotificationParamsSchema',
'TaskStatusNotificationSchema',
'TaskStatusSchema',
'TextContentSchema',
'TextResourceContentsSchema',
'TitledMultiSelectEnumSchemaSchema',
'TitledSingleSelectEnumSchemaSchema',
'ToolAnnotationsSchema',
'ToolChoiceSchema',
'ToolExecutionSchema',
'ToolListChangedNotificationSchema',
'ToolResultContentSchema',
'ToolSchema',
'ToolUseContentSchema',
'UnsubscribeRequestParamsSchema',
'UnsubscribeRequestSchema',
'UntitledMultiSelectEnumSchemaSchema',
'UntitledSingleSelectEnumSchemaSchema'
]);
export function specSchemaToTypeName(schemaName: string): string | undefined {
if (!SPEC_SCHEMA_NAMES.has(schemaName)) return undefined;
return schemaName.slice(0, -'Schema'.length);
}

View file

@ -0,0 +1,7 @@
// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate.
export const V2_PACKAGE_VERSIONS: Record<string, string> = {
'@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'
};

View file

@ -0,0 +1,14 @@
export { getMigration, listMigrations } from './migrations/index.js';
export { run } from './runner.js';
export type {
Diagnostic,
FileResult,
Migration,
PackageJsonChange,
RunnerOptions,
RunnerResult,
Transform,
TransformContext,
TransformResult
} from './types.js';
export { DiagnosticLevel } from './types.js';

View file

@ -0,0 +1,12 @@
import type { Migration } from '../types.js';
import { v1ToV2Migration } from './v1-to-v2/index.js';
const migrations = new Map<string, Migration>([['v1-to-v2', v1ToV2Migration]]);
export function getMigration(name: string): Migration | undefined {
return migrations.get(name);
}
export function listMigrations(): Map<string, Migration> {
return migrations;
}

View file

@ -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
};

View file

@ -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';

View file

@ -0,0 +1,168 @@
export interface ImportMapping {
target: string;
status: 'moved' | 'removed' | 'renamed';
renamedSymbols?: Record<string, string>;
/** Route specific symbols to a different target package than `target`. */
symbolTargetOverrides?: Record<string, string>;
removalMessage?: string;
/** No entries currently set this; scaffolding for when a v1 symbol has no v2 equivalent yet. */
isV2Gap?: boolean;
}
export const IMPORT_MAP: Record<string, ImportMapping> = {
'@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',
symbolTargetOverrides: {
StdioClientTransport: '@modelcontextprotocol/client/stdio',
DEFAULT_INHERITED_ENV_VARS: '@modelcontextprotocol/client/stdio',
getDefaultEnvironment: '@modelcontextprotocol/client/stdio',
StdioServerParameters: '@modelcontextprotocol/client/stdio'
}
},
'@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/stdio',
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/zod-compat.js': {
target: '',
status: 'removed',
removalMessage:
'zod-compat removed in v2. AnySchema and SchemaOutput types have no v2 equivalent — v2 uses StandardSchemaV1 from @standard-schema/spec. Rewrite generic function signatures to use StandardSchemaV1 directly.'
},
'@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 provider removed in v2. For Resource-Server auth (token verification), see @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).'
},
'@modelcontextprotocol/sdk/server/auth/router.js': {
target: '',
status: 'removed',
removalMessage:
'Server auth router removed in v2. For metadata endpoints, see mcpAuthMetadataRouter from @modelcontextprotocol/express. For full OAuth AS router, see @modelcontextprotocol/server-auth-legacy (PR #1908).'
},
'@modelcontextprotocol/sdk/server/auth/middleware.js': {
target: '',
status: 'removed',
removalMessage:
'Server auth middleware removed in v2. For bearer token validation, see requireBearerAuth from @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).'
},
'@modelcontextprotocol/sdk/server/auth/errors.js': {
target: '',
status: 'removed',
removalMessage:
'Auth error subclasses consolidated in v2. Use OAuthError + OAuthErrorCode from @modelcontextprotocol/server. See also @modelcontextprotocol/server-auth-legacy (PR #1908).'
},
'@modelcontextprotocol/sdk/types.js': {
target: 'RESOLVE_BY_CONTEXT',
status: 'moved',
renamedSymbols: {
ResourceTemplate: 'ResourceTemplateType'
}
},
'@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: 'RESOLVE_BY_CONTEXT',
status: 'moved'
}
};
export function isAuthImport(specifier: string): boolean {
return specifier.includes('/server/auth/') || specifier.includes('/server/auth.');
}

View file

@ -0,0 +1,36 @@
export const SCHEMA_TO_METHOD: Record<string, string> = {
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',
ListTasksRequestSchema: 'tasks/list',
GetTaskRequestSchema: 'tasks/get',
GetTaskPayloadRequestSchema: 'tasks/result',
CancelTaskRequestSchema: 'tasks/cancel'
};
export const NOTIFICATION_SCHEMA_TO_METHOD: Record<string, string> = {
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',
TaskStatusNotificationSchema: 'notifications/tasks/status',
ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete'
};

View file

@ -0,0 +1,11 @@
export const SIMPLE_RENAMES: Record<string, string> = {
McpError: 'ProtocolError',
JSONRPCError: 'JSONRPCErrorResponse',
JSONRPCErrorSchema: 'JSONRPCErrorResponseSchema',
isJSONRPCError: 'isJSONRPCErrorResponse',
isJSONRPCResponse: 'isJSONRPCResultResponse',
ResourceReference: 'ResourceTemplateReference',
ResourceReferenceSchema: 'ResourceTemplateReferenceSchema'
};
export const ERROR_CODE_SDK_MEMBERS = new Set(['RequestTimeout', 'ConnectionClosed']);

View file

@ -0,0 +1,257 @@
import type { SourceFile } from 'ts-morph';
import { Node, SyntaxKind } from 'ts-morph';
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js';
import { info, warning } from '../../../utils/diagnostics.js';
import { hasMcpImports } from '../../../utils/importUtils.js';
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', 'registerToolTask', 'tool', 'prompt', 'resource']);
/**
* Attempt to rename the second parameter of a callback from 'extra' to 'ctx'
* and rewrite context property accesses in its body.
* Returns the number of changes made, or -1 if skipped.
*/
function processCallback(
callbackNode: Node,
sourceFile: SourceFile,
diagnostics: Diagnostic[],
methodName: string,
callLine: number
): number {
if (!Node.isArrowFunction(callbackNode) && !Node.isFunctionExpression(callbackNode) && !Node.isMethodDeclaration(callbackNode))
return -1;
const params = callbackNode.getParameters();
if (params.length < 2) return -1;
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.'
)
);
return -1;
}
const paramName = extraParam.getName();
if (paramName !== EXTRA_PARAM_NAME) return -1;
const body = callbackNode.getBody();
const otherParams = callbackNode.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.`
)
);
return -1;
}
if (body) {
let ctxAlreadyInScope = false;
body.forEachDescendant((node, traversal) => {
if (
(Node.isArrowFunction(node) || Node.isFunctionExpression(node) || Node.isFunctionDeclaration(node)) &&
node.getParameters().some(p => p.getName() === CTX_PARAM_NAME)
) {
traversal.skip();
return;
}
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.`
)
);
return -1;
}
}
// Rename param declaration and rewrite body references using AST traversal.
// We walk Identifier nodes to avoid corrupting string literals, comments, and
// unrelated property names (e.g., meta.extra) that regex-based replacement would hit.
const paramDecl = extraParam.getNameNode();
paramDecl.replaceWithText(CTX_PARAM_NAME);
if (body) {
const sortedMappings = [...CONTEXT_PROPERTY_MAP].filter(m => m.from !== m.to).toSorted((a, b) => b.from.length - a.from.length);
// Collect identifiers that are actual references to the `extra` parameter
const identifiers: import('ts-morph').Node[] = [];
body.forEachDescendant(node => {
if (!Node.isIdentifier(node) || node.getText() !== EXTRA_PARAM_NAME) return;
const parent = node.getParent();
// Skip property-name positions (e.g., meta.extra, { extra: value }, { extra }, { extra: x } = obj)
if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return;
if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return;
if (parent && Node.isShorthandPropertyAssignment(parent)) return;
if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === node) return;
identifiers.push(node);
});
// Build replacements: apply property mappings for PropertyAccess/QualifiedName, plain rename otherwise
const replacements: { node: import('ts-morph').Node; newText: string }[] = [];
for (const id of identifiers) {
const parent = id.getParent();
// Value-position property access: extra.signal → ctx.mcpReq.signal
if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === id) {
const propName = '.' + parent.getName();
const mapping = sortedMappings.find(m => m.from === propName);
if (mapping) {
replacements.push({ node: parent, newText: CTX_PARAM_NAME + mapping.to });
continue;
}
}
// Type-position qualified name: typeof extra.signal → typeof ctx.mcpReq.signal
if (parent && parent.getKind() === SyntaxKind.QualifiedName && parent.getChildAtIndex(0) === id) {
const right = parent.getChildAtIndex(2);
if (right) {
const propName = '.' + right.getText();
const mapping = sortedMappings.find(m => m.from === propName);
if (mapping) {
replacements.push({ node: parent, newText: CTX_PARAM_NAME + mapping.to });
continue;
}
}
}
replacements.push({ node: id, newText: CTX_PARAM_NAME });
}
// Apply in reverse position order to avoid node invalidation
const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart());
for (const { node, newText } of sorted) {
node.replaceWithText(newText);
}
}
const changes = 1;
if (['tool', 'prompt', 'resource'].includes(methodName)) {
diagnostics.push(
info(
sourceFile.getFilePath(),
callLine,
`Renamed 'extra' to 'ctx' in .${methodName}() callback. If this is not an McpServer method, revert this change.`
)
);
}
// Warn on destructuring of ctx in body (after text replacement)
const freshBody = callbackNode.getBody();
if (freshBody) {
freshBody.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 changes;
}
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[] = [];
// Process one callback at a time, re-querying the AST after each.
// processCallback uses body.replaceWithText() which invalidates sibling nodes,
// so we cannot iterate a pre-collected list of calls.
let madeProgress = true;
const processed = new Set<number>();
while (madeProgress) {
madeProgress = false;
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of calls) {
const callStart = call.getStart();
if (processed.has(callStart)) continue;
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 >= 2) {
callbackArg = args.at(-1);
}
if (!callbackArg) continue;
// Handle ObjectLiteralExpression for registerToolTask-style callbacks
if (Node.isObjectLiteralExpression(callbackArg)) {
for (const prop of callbackArg.getProperties()) {
let callbackNode: Node | undefined;
if (Node.isPropertyAssignment(prop)) {
callbackNode = prop.getInitializer();
} else if (Node.isMethodDeclaration(prop)) {
callbackNode = prop;
}
if (!callbackNode) continue;
const result = processCallback(callbackNode, sourceFile, diagnostics, methodName, call.getStartLineNumber());
if (result > 0) {
changesCount += result;
madeProgress = true;
}
}
processed.add(callStart);
if (madeProgress) break;
continue;
}
// Handle direct ArrowFunction / FunctionExpression callbacks
const result = processCallback(callbackArg, sourceFile, diagnostics, methodName, call.getStartLineNumber());
processed.add(callStart);
if (result > 0) {
changesCount += result;
madeProgress = true;
break;
}
}
}
return { changesCount, diagnostics };
}
};

Some files were not shown because too many files have changed in this diff Show more