From ada352f353a45c3e56b6b3a59f7f01202b1f46a7 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Tue, 23 Jun 2026 08:56:03 +0300 Subject: [PATCH 1/8] codemod iterations --- docs/migration-SKILL.md | 3 +- docs/migration.md | 2 + packages/codemod/batch-test/repos.json | 40 ++---- packages/codemod/src/cli.ts | 27 ++++ packages/codemod/src/utils/detectFormatter.ts | 113 +++++++++++++++++ packages/codemod/test/detectFormatter.test.ts | 119 ++++++++++++++++++ 6 files changed, 270 insertions(+), 34 deletions(-) create mode 100644 packages/codemod/src/utils/detectFormatter.ts create mode 100644 packages/codemod/test/detectFormatter.test.ts diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..5c4dc5e76b 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -549,4 +549,5 @@ Validator behavior: 8. If using server SSE transport, migrate to Streamable HTTP 9. If using server auth from the SDK: RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers → `@modelcontextprotocol/server-legacy/auth` (deprecated); migrate AS to external IdP/OAuth library 10. If relying on `listTools()`/`listPrompts()`/etc. throwing on missing capabilities, set `enforceStrictCapabilities: true` -11. Verify: build with `tsc` / run tests +11. Format the changed files with the project's formatter (`prettier --write`, `eslint --fix`, or `biome format --write`) — edits are not reformatted automatically, and the wrapped schemas (step 5) and rewritten `setRequestHandler` method strings (section 9) frequently need it to satisfy lint +12. Verify: build with `tsc` / run tests diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..cc9caeed9b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -7,6 +7,8 @@ This guide covers the breaking changes introduced in v2 of the MCP TypeScript SD Version 2 of the MCP TypeScript SDK introduces several breaking changes to improve modularity, reduce dependency bloat, and provide a cleaner API surface. The biggest change is the split from a single `@modelcontextprotocol/sdk` package into separate `@modelcontextprotocol/core`, `@modelcontextprotocol/client`, and `@modelcontextprotocol/server` packages. +> **Formatting:** The `@modelcontextprotocol/codemod` package automates most of the mechanical changes below, but it rewrites your code's AST without reformatting it — wrapped schemas and generated handler method strings may not match your project's style. After migrating (with the codemod or by hand), run your formatter on the changed files — for example `prettier --write`, `eslint --fix`, or `biome format --write` — and review the diff. + ## Breaking Changes ### Package split (monorepo) diff --git a/packages/codemod/batch-test/repos.json b/packages/codemod/batch-test/repos.json index e8d5515800..d7faa64e2c 100644 --- a/packages/codemod/batch-test/repos.json +++ b/packages/codemod/batch-test/repos.json @@ -1,42 +1,16 @@ [ { - "repo": "modelcontextprotocol/servers", - "ref": "main", + "repo": "upstash/context7", + "ref": "master", "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", + "dir": "packages/mcp", "sourceDir": "src", "checks": { - "typecheck": "npx tsc --noEmit", - "build": "npm run build", - "test": null, - "lint": null + "typecheck": "pnpm run typecheck", + "build": "pnpm run build", + "test": "pnpm run test", + "lint": "pnpm run lint" } } ] diff --git a/packages/codemod/src/cli.ts b/packages/codemod/src/cli.ts index d8599c70a0..22f878ddd5 100644 --- a/packages/codemod/src/cli.ts +++ b/packages/codemod/src/cli.ts @@ -9,11 +9,36 @@ import { Command } from 'commander'; import { listMigrations } from './migrations/index.js'; import { run } from './runner.js'; import { DiagnosticLevel } from './types.js'; +import { detectFormatter } from './utils/detectFormatter.js'; import { CODEMOD_ERROR_PREFIX, formatDiagnostic } from './utils/diagnostics.js'; const require = createRequire(import.meta.url); const { version } = require('../package.json') as { version: string }; +function quoteArg(arg: string): string { + return /\s/.test(arg) ? `"${arg}"` : arg; +} + +/** + * The codemod transforms the AST but does not reformat — wrapped schemas and + * generated string literals can violate a repo's lint/formatting rules. Point + * the user at their own formatter (which respects their config) for the exact + * files that changed. + */ +function printFormatGuidance(targetDir: string, changedFiles: string[]): void { + if (changedFiles.length === 0) return; + + const formatter = detectFormatter(targetDir); + const fileArgs = changedFiles.map(file => quoteArg(path.relative(process.cwd(), file) || file)); + + console.log("This codemod doesn't reformat its output. Run your formatter on the changed file(s):"); + if (formatter) { + console.log(` ${formatter.bin} ${[...formatter.writeArgs, ...fileArgs].join(' ')}\n`); + } else { + console.log(` e.g. prettier --write ${fileArgs.join(' ')}\n`); + } +} + const program = new Command(); program.name('mcp-codemod').description('Codemod to migrate MCP TypeScript SDK code between versions').version(version); @@ -150,6 +175,8 @@ for (const [name, migration] of listMigrations()) { if (opts['dryRun']) { console.log('Run without --dry-run to apply changes.\n'); } else { + const changedFiles = result.fileResults.filter(fr => fr.changes > 0).map(fr => fr.filePath); + printFormatGuidance(resolvedDir, changedFiles); if (result.packageJsonChanges) { console.log('Run your package manager to install the new packages.\n'); } diff --git a/packages/codemod/src/utils/detectFormatter.ts b/packages/codemod/src/utils/detectFormatter.ts new file mode 100644 index 0000000000..29d584706a --- /dev/null +++ b/packages/codemod/src/utils/detectFormatter.ts @@ -0,0 +1,113 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** A code formatter the codemod can recommend running after a migration. */ +export interface DetectedFormatter { + /** Display name, e.g. `Prettier`. */ + name: string; + /** Executable name, e.g. `prettier`. */ + bin: string; + /** Arguments that write formatting in place; changed file paths are appended after these. */ + writeArgs: readonly string[]; +} + +const BIOME_CONFIG_FILES = ['biome.json', 'biome.jsonc']; +const PRETTIER_CONFIG_FILES = [ + '.prettierrc', + '.prettierrc.json', + '.prettierrc.json5', + '.prettierrc.yaml', + '.prettierrc.yml', + '.prettierrc.toml', + '.prettierrc.js', + '.prettierrc.cjs', + '.prettierrc.mjs', + '.prettierrc.ts', + 'prettier.config.js', + 'prettier.config.cjs', + 'prettier.config.mjs', + 'prettier.config.ts' +]; +const ESLINT_CONFIG_FILES = [ + 'eslint.config.js', + 'eslint.config.mjs', + 'eslint.config.cjs', + 'eslint.config.ts', + 'eslint.config.mts', + 'eslint.config.cts', + '.eslintrc', + '.eslintrc.js', + '.eslintrc.cjs', + '.eslintrc.json', + '.eslintrc.yml', + '.eslintrc.yaml' +]; + +// Precedence order: a configured dedicated formatter wins over ESLint's --fix. +const FORMATTERS = { + biome: { name: 'Biome', bin: 'biome', writeArgs: ['format', '--write'] }, + prettier: { name: 'Prettier', bin: 'prettier', writeArgs: ['--write'] }, + eslint: { name: 'ESLint', bin: 'eslint', writeArgs: ['--fix'] } +} as const satisfies Record; + +function hasAnyFile(dir: string, files: readonly string[]): boolean { + return files.some(file => existsSync(path.join(dir, file))); +} + +interface PackageJsonSignals { + prettier: boolean; + eslint: boolean; +} + +function readPackageJsonSignals(dir: string): PackageJsonSignals { + const pkgJsonPath = path.join(dir, 'package.json'); + if (!existsSync(pkgJsonPath)) return { prettier: false, eslint: false }; + try { + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as Record; + const allDeps = { + ...(pkgJson.dependencies as Record | undefined), + ...(pkgJson.devDependencies as Record | undefined) + }; + return { + prettier: 'prettier' in pkgJson || 'prettier' in allDeps, + eslint: 'eslint' in allDeps + }; + } catch { + return { prettier: false, eslint: false }; + } +} + +/** + * Walks up from `startDir` — bounded to the repository, stopping at a `.git` + * directory so a global config in `$HOME` is never matched — looking for a + * configured code formatter, so the CLI can suggest the right "format your + * changed files" command after a migration. + * + * Detection is config-based and runs nothing. When multiple formatters are + * configured, precedence is Biome > Prettier > ESLint. + * + * @returns the detected formatter, or `null` if none is configured. + */ +export function detectFormatter(startDir: string): DetectedFormatter | null { + let dir = path.resolve(startDir); + const root = path.parse(dir).root; + const found = { biome: false, prettier: false, eslint: false }; + + while (true) { + if (hasAnyFile(dir, BIOME_CONFIG_FILES)) found.biome = true; + if (hasAnyFile(dir, PRETTIER_CONFIG_FILES)) found.prettier = true; + if (hasAnyFile(dir, ESLINT_CONFIG_FILES)) found.eslint = true; + + const signals = readPackageJsonSignals(dir); + if (signals.prettier) found.prettier = true; + if (signals.eslint) found.eslint = true; + + if (existsSync(path.join(dir, '.git')) || dir === root) break; + dir = path.dirname(dir); + } + + if (found.biome) return FORMATTERS.biome; + if (found.prettier) return FORMATTERS.prettier; + if (found.eslint) return FORMATTERS.eslint; + return null; +} diff --git a/packages/codemod/test/detectFormatter.test.ts b/packages/codemod/test/detectFormatter.test.ts new file mode 100644 index 0000000000..4958815949 --- /dev/null +++ b/packages/codemod/test/detectFormatter.test.ts @@ -0,0 +1,119 @@ +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 { detectFormatter } from '../src/utils/detectFormatter.js'; + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-formatter-')); + return tempDir; +} + +function writePkg(dir: string, pkg: Record): void { + writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)); +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('detectFormatter', () => { + it('returns null when no formatter is configured', () => { + const dir = createTempDir(); + writePkg(dir, { devDependencies: { typescript: '^5' } }); + + expect(detectFormatter(dir)).toBeNull(); + }); + + it('detects Prettier from a config file', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'prettier.config.mjs'), 'export default {};'); + + const result = detectFormatter(dir); + expect(result?.name).toBe('Prettier'); + expect(result?.bin).toBe('prettier'); + expect(result?.writeArgs).toEqual(['--write']); + }); + + it('detects Prettier from the "prettier" key in package.json', () => { + const dir = createTempDir(); + writePkg(dir, { prettier: { singleQuote: false } }); + + expect(detectFormatter(dir)?.name).toBe('Prettier'); + }); + + it('detects Prettier from devDependencies', () => { + const dir = createTempDir(); + writePkg(dir, { devDependencies: { prettier: '^3' } }); + + expect(detectFormatter(dir)?.name).toBe('Prettier'); + }); + + it('detects Biome from biome.json', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'biome.json'), '{}'); + + const result = detectFormatter(dir); + expect(result?.name).toBe('Biome'); + expect(result?.bin).toBe('biome'); + expect(result?.writeArgs).toEqual(['format', '--write']); + }); + + it('does not detect dprint — a lone dprint.json yields null', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'dprint.json'), '{}'); + + expect(detectFormatter(dir)).toBeNull(); + }); + + it('detects ESLint when only ESLint is configured', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'eslint.config.js'), 'export default [];'); + + const result = detectFormatter(dir); + expect(result?.name).toBe('ESLint'); + expect(result?.bin).toBe('eslint'); + expect(result?.writeArgs).toEqual(['--fix']); + }); + + it('prefers Prettier over ESLint when both are configured (the common prettier-plugin case)', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'eslint.config.js'), 'export default [];'); + writeFileSync(path.join(dir, 'prettier.config.mjs'), 'export default {};'); + + expect(detectFormatter(dir)?.name).toBe('Prettier'); + }); + + it('prefers Biome over Prettier when both are configured', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'biome.json'), '{}'); + writeFileSync(path.join(dir, 'prettier.config.mjs'), 'export default {};'); + + expect(detectFormatter(dir)?.name).toBe('Biome'); + }); + + it('walks up directory levels to find the formatter (monorepo layout)', () => { + const dir = createTempDir(); + const src = path.join(dir, 'packages', 'mcp', 'src'); + mkdirSync(src, { recursive: true }); + writeFileSync(path.join(dir, 'prettier.config.mjs'), 'export default {};'); + + expect(detectFormatter(src)?.name).toBe('Prettier'); + }); + + it('stops at the .git boundary and does not detect config above the repo root', () => { + const dir = createTempDir(); + const src = path.join(dir, 'project', 'src'); + mkdirSync(src, { recursive: true }); + mkdirSync(path.join(dir, 'project', '.git'), { recursive: true }); + // Config lives above the repo root — must not be picked up. + writeFileSync(path.join(dir, 'prettier.config.mjs'), 'export default {};'); + + expect(detectFormatter(src)).toBeNull(); + }); +}); From ab4b65856e10819afdeb25d7d0d2855685984c88 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Tue, 23 Jun 2026 15:25:49 +0300 Subject: [PATCH 2/8] fix(codemod): match extensionless SDK subpath import specifiers IMPORT_MAP was looked up by exact key, so extensionless specifiers like @modelcontextprotocol/sdk/types (vs .../types.js) fell through to an 'Unknown SDK import path' diagnostic and were left unmigrated. Add a shared lookupImportMapping() that tolerates .js/.mjs/.cjs extension variance, and use it for import, re-export, and mock-path resolution. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016f6h88mdVxLUdx1cNT96pW --- .../migrations/v1-to-v2/mappings/importMap.ts | 25 +++++++++++++++++++ .../v1-to-v2/transforms/importPaths.ts | 6 ++--- .../v1-to-v2/transforms/mockPaths.ts | 4 +-- .../v1-to-v2/transforms/importPaths.test.ts | 23 +++++++++++++++++ .../v1-to-v2/transforms/mockPaths.test.ts | 13 ++++++++++ 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts index 24d086f8de..bf229772b0 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts @@ -190,3 +190,28 @@ for (const barrelSpecifier of ['@modelcontextprotocol/sdk/validation/index.js', export function isAuthImport(specifier: string): boolean { return specifier.includes('/server/auth/') || specifier.includes('/server/auth.'); } + +// SDK subpath specifiers can be written with or without a JS extension +// (e.g. `@modelcontextprotocol/sdk/types` vs `.../types.js`) depending on the +// consumer's module resolution (`bundler`/`nodenext` allow the extensionless form). +// Normalize the extension so both spellings resolve to the same mapping. Built +// after every IMPORT_MAP entry above is populated; entries whose `.js` and +// extensionless forms coexist (e.g. `experimental/tasks`) share an identical +// mapping, so the collapse is lossless. +function stripJsExtension(specifier: string): string { + return specifier.replace(/\.(?:js|mjs|cjs)$/, ''); +} + +const NORMALIZED_IMPORT_MAP: Record = {}; +for (const [key, mapping] of Object.entries(IMPORT_MAP)) { + NORMALIZED_IMPORT_MAP[stripJsExtension(key)] = mapping; +} + +/** + * Resolves the v2 mapping for a v1 SDK import/export/mock specifier, tolerating + * JS extension variance. An exact match always wins; otherwise the specifier is + * matched ignoring a trailing `.js`/`.mjs`/`.cjs` (or its absence). + */ +export function lookupImportMapping(specifier: string): ImportMapping | undefined { + return IMPORT_MAP[specifier] ?? NORMALIZED_IMPORT_MAP[stripJsExtension(specifier)]; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 482ae3e57d..14c63f9233 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -5,7 +5,7 @@ import { renameAllReferences } from '../../../utils/astUtils.js'; import { actionRequired, info, v2Gap, 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 { isAuthImport, lookupImportMapping } from '../mappings/importMap.js'; import { SIMPLE_RENAMES } from '../mappings/symbolMap.js'; const REEXPORT_WARNINGS: Record = { @@ -71,7 +71,7 @@ export const importPathsTransform: Transform = { const defaultImport = imp.getDefaultImport(); const namespaceImport = imp.getNamespaceImport(); - let mapping = IMPORT_MAP[specifier]; + let mapping = lookupImportMapping(specifier); if (!mapping && isAuthImport(specifier)) { mapping = { @@ -223,7 +223,7 @@ function rewriteExportDeclarations( if (!specifier) continue; const line = exp.getStartLineNumber(); - let mapping = IMPORT_MAP[specifier]; + let mapping = lookupImportMapping(specifier); if (!mapping && isAuthImport(specifier)) { mapping = { diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts index 65ce7a4d6b..c80c695944 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -5,7 +5,7 @@ import type { Diagnostic, Transform, TransformContext, TransformResult } from '. import { actionRequired, v2Gap, 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 { isAuthImport, lookupImportMapping } from '../mappings/importMap.js'; import { SIMPLE_RENAMES } from '../mappings/symbolMap.js'; const MOCK_METHODS = new Set([ @@ -58,7 +58,7 @@ function resolveTarget( | { target: string; renamedSymbols?: Record; symbolTargetOverrides?: Record } | { removed: true; isV2Gap?: boolean; removalMessage?: string } | null { - const mapping = IMPORT_MAP[specifier]; + const mapping = lookupImportMapping(specifier); if (!mapping && isAuthImport(specifier)) return { target: '@modelcontextprotocol/server-legacy/auth' }; if (!mapping) return null; if (mapping.status === 'removed') return { removed: true, isV2Gap: mapping.isV2Gap, removalMessage: mapping.removalMessage }; diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index f0563f9f69..8a63497cac 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -95,6 +95,18 @@ describe('import-paths transform', () => { expect(result).toContain(`from "@modelcontextprotocol/server"`); }); + it('resolves extensionless sdk/types (no .js suffix) the same as sdk/types.js', () => { + const input = `import { CallToolResult } from '@modelcontextprotocol/sdk/types';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + const output = sourceFile.getFullText(); + expect(output).toContain(`from "@modelcontextprotocol/server"`); + expect(output).toContain('CallToolResult'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + expect(result.diagnostics.map(d => d.message).join('\n')).not.toContain('Unknown SDK import path'); + }); + it('preserves type-only imports separately', () => { const input = [ `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, @@ -339,6 +351,17 @@ describe('import-paths transform', () => { expect(result.diagnostics.some(d => d.message.includes('SSEServerTransport is deprecated'))).toBe(true); }); + it('resolves extensionless sdk/types re-export (no .js suffix)', () => { + const input = `export { CallToolResult } from '@modelcontextprotocol/sdk/types';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + const output = sourceFile.getFullText(); + expect(output).toContain('@modelcontextprotocol/server'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + expect(result.diagnostics.map(d => d.message).join('\n')).not.toContain('Unknown SDK export path'); + }); + it('includes server-legacy in usedPackages for SSE import', () => { const project = new Project({ useInMemoryFileSystem: true }); const sourceFile = project.createSourceFile( diff --git a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts index 2922618986..5fa76adfbf 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts @@ -63,6 +63,19 @@ describe('mock-paths transform', () => { expect(result).toContain(`'@modelcontextprotocol/server'`); expect(result).not.toContain('@modelcontextprotocol/sdk'); }); + + it('rewrites extensionless sdk/types path (no .js suffix)', () => { + const input = [ + `vi.doMock('@modelcontextprotocol/sdk/types', 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', () => { From 7097be9e2a58dfeedc2a3393fc9d5696c1098fed Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 24 Jun 2026 10:19:00 +0300 Subject: [PATCH 3/8] add canonical zod schema exports from @modelcontextprotocol/sdk-shared, add codemod --- .changeset/add-sdk-shared-package.md | 5 + .changeset/codemod-sdk-shared-routing.md | 5 + .changeset/pre.json | 1 + docs/migration-SKILL.md | 5 +- docs/migration.md | 24 +- ...5-05-21-codemod-findreferences-refactor.md | 957 ++++++++++++++++++ .../2026-05-15-codemod-batch-test-fixes.md | 549 ++++++++++ .../plans/2026-06-02-readbuffer-max-size.md | 323 ++++++ .../2026-06-02-v1-readbuffer-max-size.md | 356 +++++++ .../plans/2026-06-23-sdk-shared-package.md | 716 +++++++++++++ .../2026-05-11-codemod-batch-test-design.md | 288 ++++++ .../2026-06-02-readbuffer-max-size-design.md | 61 ++ .../specs/2026-06-08-sep-2549-ttl-design.md | 495 +++++++++ .../2026-06-23-sdk-shared-package-design.md | 135 +++ packages/codemod/batch-test/repos.json | 16 +- packages/codemod/package.json | 3 +- .../codemod/scripts/generateSpecSchemaMap.ts | 39 - packages/codemod/scripts/generateVersions.ts | 3 +- packages/codemod/src/bin/batchTest.ts | 1 + .../codemod/src/generated/specSchemaMap.ts | 173 ---- packages/codemod/src/generated/versions.ts | 3 +- .../migrations/v1-to-v2/mappings/importMap.ts | 8 + .../v1-to-v2/transforms/importPaths.ts | 66 +- .../migrations/v1-to-v2/transforms/index.ts | 8 +- .../v1-to-v2/transforms/specSchemaAccess.ts | 392 ------- packages/codemod/src/utils/importUtils.ts | 1 + .../codemod/test/commentInsertion.test.ts | 67 +- .../v1-to-v2/transforms/importPaths.test.ts | 83 +- .../transforms/specSchemaAccess.test.ts | 517 ---------- packages/sdk-shared/README.md | 34 + packages/sdk-shared/eslint.config.mjs | 12 + packages/sdk-shared/package.json | 63 ++ packages/sdk-shared/src/index.ts | 177 ++++ .../sdk-shared/test/sdkSharedSchemas.test.ts | 28 + packages/sdk-shared/tsconfig.json | 11 + packages/sdk-shared/tsdown.config.ts | 28 + packages/sdk-shared/typedoc.json | 4 + packages/sdk-shared/vitest.config.js | 3 + pnpm-lock.yaml | 62 +- typedoc.config.mjs | 14 +- 40 files changed, 4532 insertions(+), 1204 deletions(-) create mode 100644 .changeset/add-sdk-shared-package.md create mode 100644 .changeset/codemod-sdk-shared-routing.md create mode 100644 docs/superpowers/plans/2025-05-21-codemod-findreferences-refactor.md create mode 100644 docs/superpowers/plans/2026-05-15-codemod-batch-test-fixes.md create mode 100644 docs/superpowers/plans/2026-06-02-readbuffer-max-size.md create mode 100644 docs/superpowers/plans/2026-06-02-v1-readbuffer-max-size.md create mode 100644 docs/superpowers/plans/2026-06-23-sdk-shared-package.md create mode 100644 docs/superpowers/specs/2026-05-11-codemod-batch-test-design.md create mode 100644 docs/superpowers/specs/2026-06-02-readbuffer-max-size-design.md create mode 100644 docs/superpowers/specs/2026-06-08-sep-2549-ttl-design.md create mode 100644 docs/superpowers/specs/2026-06-23-sdk-shared-package-design.md delete mode 100644 packages/codemod/scripts/generateSpecSchemaMap.ts delete mode 100644 packages/codemod/src/generated/specSchemaMap.ts delete mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts delete mode 100644 packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts create mode 100644 packages/sdk-shared/README.md create mode 100644 packages/sdk-shared/eslint.config.mjs create mode 100644 packages/sdk-shared/package.json create mode 100644 packages/sdk-shared/src/index.ts create mode 100644 packages/sdk-shared/test/sdkSharedSchemas.test.ts create mode 100644 packages/sdk-shared/tsconfig.json create mode 100644 packages/sdk-shared/tsdown.config.ts create mode 100644 packages/sdk-shared/typedoc.json create mode 100644 packages/sdk-shared/vitest.config.js diff --git a/.changeset/add-sdk-shared-package.md b/.changeset/add-sdk-shared-package.md new file mode 100644 index 0000000000..48cc1291e1 --- /dev/null +++ b/.changeset/add-sdk-shared-package.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk-shared': minor +--- + +Add `@modelcontextprotocol/sdk-shared`: the public home for the MCP specification Zod schemas. It bundles the SDK's internal schema definitions and re-exports only the `*Schema` values, so consumers can validate protocol payloads (`Schema.parse(value)` / `.safeParse(value)`) without depending on a package's internal barrel. Spec types, error classes, enums, and guards continue to live on `@modelcontextprotocol/server` and `@modelcontextprotocol/client`. diff --git a/.changeset/codemod-sdk-shared-routing.md b/.changeset/codemod-sdk-shared-routing.md new file mode 100644 index 0000000000..9b9dddfbaf --- /dev/null +++ b/.changeset/codemod-sdk-shared-routing.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': minor +--- + +Route v1 `@modelcontextprotocol/sdk/types.js` schema imports to the new `@modelcontextprotocol/sdk-shared` package. The `*Schema` Zod constants now migrate as a behavior-preserving import-path swap — `Schema.parse(value)` / `.safeParse(value)` keep working — while spec types, error classes, enums, and guards continue to resolve to `@modelcontextprotocol/client` / `@modelcontextprotocol/server` by context. A single `import { CallToolResult, CallToolResultSchema } from '.../types.js'` is split accordingly. The previous `specSchemaAccess` transform (which rewrote `.parse()` into `specTypeSchemas.X['~standard'].validate(...)`) is removed. diff --git a/.changeset/pre.json b/.changeset/pre.json index c4c3cf31a8..9f29aaaee2 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -18,6 +18,7 @@ "@modelcontextprotocol/node": "2.0.0-alpha.0", "@modelcontextprotocol/server": "2.0.0-alpha.0", "@modelcontextprotocol/server-legacy": "2.0.0-alpha.0", + "@modelcontextprotocol/sdk-shared": "2.0.0-alpha.0", "@modelcontextprotocol/codemod": "2.0.0-alpha.0", "@modelcontextprotocol/test-conformance": "2.0.0-alpha.0", "@modelcontextprotocol/test-helpers": "2.0.0-alpha.0", diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 8c32258e3c..d61fb1a344 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -61,7 +61,7 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. | v1 import path | v2 package | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/types.js` | Types / error classes / enums / guards → `@modelcontextprotocol/client` or `@modelcontextprotocol/server`; Zod `*Schema` constants → `@modelcontextprotocol/sdk-shared` | | `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/uriTemplate.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | @@ -98,8 +98,7 @@ Notes: | `StreamableHTTPError` | REMOVED (use `SdkHttpError` with `SdkErrorCode.ClientHttp*`) | | `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | -All other **type** symbols from `@modelcontextprotocol/sdk/types.js` retain their original names. **Zod schemas** (e.g., `CallToolResultSchema`, `ListToolsResultSchema`) are no longer part of the public API — they are internal to the SDK. For runtime validation, use -`isSpecType.TypeName(value)` (e.g., `isSpecType.CallToolResult(v)`) or `specTypeSchemas.TypeName` for the `StandardSchemaV1Sync` validator object. The keys are typed as `SpecTypeName`, a literal union of all spec type names. +All other **type** symbols from `@modelcontextprotocol/sdk/types.js` retain their original names — import them from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`. The **Zod schemas** (e.g., `CallToolResultSchema`, `ListToolsResultSchema`) move to `@modelcontextprotocol/sdk-shared`; `Schema.parse(value)` / `.safeParse(value)` keep working unchanged (the codemod rewrites the import path). To validate **without** depending on Zod, use `isSpecType.TypeName(value)` (e.g., `isSpecType.CallToolResult(v)`) or `specTypeSchemas.TypeName` (a `StandardSchemaV1Sync` validator) from `@modelcontextprotocol/client` / `@modelcontextprotocol/server`; the keys are typed as `SpecTypeName`, a literal union of all spec type names. ### Error class changes diff --git a/docs/migration.md b/docs/migration.md index be0dbae0dd..8950d9150e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -516,29 +516,39 @@ The return type is now inferred from the method name via `ResultTypeMap`. For ex For **custom (non-spec)** methods, keep the result-schema argument — see [Sending custom-method requests](#sending-custom-method-requests). Only drop the schema when calling a spec method. -If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), use `isSpecType` or `specTypeSchemas`: +If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), import the schema from `@modelcontextprotocol/sdk-shared`. Your `.parse()` / `.safeParse()` calls keep working unchanged — only the import path changes: ```typescript -// v1: runtime validation with Zod schema +// v1 import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; if (CallToolResultSchema.safeParse(value).success) { /* ... */ } -// v2: keyed type predicate +// v2 — same code, new import path +import { CallToolResultSchema } from '@modelcontextprotocol/sdk-shared'; +if (CallToolResultSchema.safeParse(value).success) { + /* ... */ +} +``` + +`@modelcontextprotocol/sdk-shared` is the canonical home for the spec Zod schemas. `@modelcontextprotocol/server` and `@modelcontextprotocol/client` keep a Zod-free public surface, so the raw `*Schema` constants live in `sdk-shared`. (The codemod rewrites these imports for you.) + +If you'd rather **not** depend on Zod, `@modelcontextprotocol/client` and `@modelcontextprotocol/server` also expose Zod-free validators keyed by `SpecTypeName` — a literal union of every named spec type, so you get autocomplete and a compile error on typos: + +```typescript import { isSpecType } from '@modelcontextprotocol/client'; if (isSpecType.CallToolResult(value)) { /* ... */ } const blocks = mixed.filter(isSpecType.ContentBlock); -// v2: or get the StandardSchemaV1Sync validator object directly +// or the StandardSchemaV1Sync validator object directly import { specTypeSchemas } from '@modelcontextprotocol/client'; const result = specTypeSchemas.CallToolResult['~standard'].validate(value); ``` -`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync` — `validate()` returns the result synchronously, -so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works. +`specTypeSchemas.X` is a `StandardSchemaV1Sync` — `validate()` returns the result synchronously, so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works. ### Client list methods return empty results for missing capabilities @@ -584,7 +594,7 @@ The following deprecated type aliases have been removed from `@modelcontextproto | `IsomorphicHeaders` | Use Web Standard `Headers` | | `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | -All other types and schemas exported from `@modelcontextprotocol/sdk/types.js` retain their original names — import them from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`. +All other symbols exported from `@modelcontextprotocol/sdk/types.js` retain their original names. Import the **types**, error classes, enums, and guards from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`, and the **Zod schemas** (the `*Schema` constants) from `@modelcontextprotocol/sdk-shared`. > **Note on `isJSONRPCResponse`:** v1's `isJSONRPCResponse` was a deprecated alias that only checked for _result_ responses (it was equivalent to `isJSONRPCResultResponse`). v2 removes the deprecated alias and introduces a **new** `isJSONRPCResponse` with corrected semantics — it > checks for _any_ response (either result or error). If you are migrating v1 code that used `isJSONRPCResponse`, rename it to `isJSONRPCResultResponse` to preserve the original behavior. Use the new `isJSONRPCResponse` only when you want to match both result and error responses. diff --git a/docs/superpowers/plans/2025-05-21-codemod-findreferences-refactor.md b/docs/superpowers/plans/2025-05-21-codemod-findreferences-refactor.md new file mode 100644 index 0000000000..d907040b24 --- /dev/null +++ b/docs/superpowers/plans/2025-05-21-codemod-findreferences-refactor.md @@ -0,0 +1,957 @@ +# Codemod: Replace Manual AST Walking with `findReferencesAsNodes()` + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Simplify codemod transforms by replacing manual `forEachDescendant` + parent-kind-guard patterns with ts-morph's `findReferencesAsNodes()`, eliminating ~12 parent-kind guards, ~4 duplicate scope checks, and ~5 manual AST walk functions. + +**Architecture:** ts-morph's TypeScript language service already resolves symbol bindings in the current syntax-only Project mode (no tsconfig needed). `findReferencesAsNodes()` returns precisely the references to a given symbol — correctly scoped, excluding property-name positions, and handling aliases. We refactor transforms to collect references via this API *before* mutating the AST, then apply changes in reverse-position order (a pattern the codemod already uses). A second phase optionally loads the user's tsconfig for receiver-type checking. + +**Tech Stack:** ts-morph v28, vitest + +**Key invariant:** `findReferencesAsNodes()` must be called *before* the symbol binding is modified (e.g., before an import specifier is renamed or removed). After mutation, collected Node objects remain valid but the language service can no longer resolve the original binding. + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `packages/codemod/src/utils/astUtils.ts` | Modify | Replace `renameAllReferences` internals with `findReferencesAsNodes()` | +| `packages/codemod/src/utils/importUtils.ts` | Modify | Add `findImportSpecifierByName`, simplify `removeUnusedImport` | +| `packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts` | Modify | Collect refs before import mutation; use `findReferencesAsNodes()` in ErrorCode/RHE handlers | +| `packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts` | Modify | Use `findReferencesAsNodes()` on `extra` param; eliminate parent-kind guards and manual scope check | +| `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` | Modify | Use `findReferencesAsNodes()` for schema refs; eliminate `findNonImportReferences()` | +| `packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts` | Modify | Collect refs before import removal for renamed symbols | +| `packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts` | Modify | Collect refs before import removal | +| `packages/codemod/src/types.ts` | Modify | Add optional `project` to `TransformContext` (Phase 2) | +| `packages/codemod/src/runner.ts` | Modify | Optionally resolve tsconfig; pass Project via context (Phase 2) | +| `packages/codemod/src/utils/projectAnalyzer.ts` | Modify | Add `findTsConfig()` (Phase 2) | +| All test files under `packages/codemod/test/v1-to-v2/transforms/` | Verify | Existing tests must pass unchanged — this is a refactor under green | + +--- + +## Phase 1: `findReferencesAsNodes()` Refactor (no tsconfig needed) + +### Task 1: Rewrite `renameAllReferences` in astUtils.ts + +The current function (33 lines, 12 parent-kind guards) manually walks all identifiers and filters by parent kind. `findReferencesAsNodes()` eliminates 10 of those 12 guards — only `ShorthandPropertyAssignment` and `ExportSpecifier` need special handling since they require AST expansion (not just text replacement). + +**Files:** +- Modify: `packages/codemod/src/utils/astUtils.ts` +- Verify: `packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts` (primary consumer) + +- [ ] **Step 1: Read the current implementation** + +Read `packages/codemod/src/utils/astUtils.ts` — the entire file is the `renameAllReferences` function. + +Current implementation walks all identifiers with matching text and checks 12 parent kinds: +``` +ImportSpecifier, ExportSpecifier, PropertyAssignment (name), PropertyAccessExpression (name), +PropertySignature (name), MethodDeclaration (name), MethodSignature (name), +PropertyDeclaration (name), EnumMember (name), BindingElement (propertyName), +GetAccessorDeclaration (name), SetAccessorDeclaration (name), ShorthandPropertyAssignment +``` + +- [ ] **Step 2: Rewrite using `findReferencesAsNodes()`** + +Replace the body of `renameAllReferences` with: + +```typescript +import type { SourceFile } from 'ts-morph'; +import { Node } from 'ts-morph'; + +export function renameAllReferences(sourceFile: SourceFile, oldName: string, newName: string): void { + // Find the first identifier with this name to use as the findReferences anchor. + // Must be called BEFORE the symbol's import specifier is renamed/removed. + let anchor: import('ts-morph').Node | undefined; + sourceFile.forEachDescendant(node => { + if (anchor) return; + if (Node.isIdentifier(node) && node.getText() === oldName) { + anchor = node; + } + }); + if (!anchor) return; + + const refs = anchor.findReferencesAsNodes(); + + // Apply in reverse position order to avoid invalidating earlier nodes + const sorted = refs.toSorted((a, b) => b.getStart() - a.getStart()); + for (const ref of sorted) { + if (ref.wasForgotten()) continue; + const parent = ref.getParent(); + if (!parent) continue; + + // Skip import specifiers — caller manages those + if (Node.isImportSpecifier(parent)) continue; + + // ExportSpecifier: preserve public name by adding alias + if (Node.isExportSpecifier(parent)) { + if (parent.getAliasNode() === ref) continue; + if (!parent.getAliasNode()) parent.setAlias(oldName); + parent.getNameNode().replaceWithText(newName); + continue; + } + + // ShorthandPropertyAssignment: expand { McpError } → { McpError: ProtocolError } + if (Node.isShorthandPropertyAssignment(parent)) { + parent.replaceWithText(`${oldName}: ${newName}`); + continue; + } + + ref.replaceWithText(newName); + } +} +``` + +The 10 parent-kind guards (PropertyAssignment name, PropertyAccessExpression name, PropertySignature name, MethodDeclaration name, MethodSignature name, PropertyDeclaration name, EnumMember name, BindingElement propertyName, GetAccessor name, SetAccessor name) are all handled automatically by `findReferencesAsNodes()` — it never returns identifier nodes in property-name positions. + +- [ ] **Step 3: Run all transform tests to verify** + +Run: `pnpm --filter @modelcontextprotocol/codemod test` + +Expected: all tests pass. The `renameAllReferences` function is called by `symbolRenames`, `importPaths`, and `removedApis` transforms — all their tests exercise it. + +- [ ] **Step 4: Suggest commit** + +``` +feat(codemod): rewrite renameAllReferences using findReferencesAsNodes + +Replace manual 12-case parent-kind guard with ts-morph's +findReferencesAsNodes() which handles scope and position +classification automatically. Only ShorthandPropertyAssignment +and ExportSpecifier need explicit handling for AST expansion. +``` + +--- + +### Task 2: Refactor `symbolRenames.ts` — collect refs before import mutation + +The SIMPLE_RENAMES loop currently modifies the import specifier first, then calls `renameAllReferences`. But `findReferencesAsNodes()` must be called *before* the binding is modified. This task reorders the operations. + +The three `forEachDescendant` walks in `handleErrorCodeSplit` and `handleRequestHandlerExtra` are also replaced. + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts` +- Verify: `packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts` + +- [ ] **Step 1: Read current file** + +Read `packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts` (352 lines). + +The SIMPLE_RENAMES loop (lines 23-37): +```typescript +for (const namedImport of imp.getNamedImports()) { + const name = namedImport.getName(); + const newName = SIMPLE_RENAMES[name]; + if (newName) { + namedImport.setName(newName); // modifies binding FIRST + const alias = namedImport.getAliasNode(); + if (!alias) { + renameAllReferences(sourceFile, name, newName); // then renames body + } + changesCount++; + } +} +``` + +- [ ] **Step 2: Reorder SIMPLE_RENAMES to collect-before-mutate** + +```typescript +for (const namedImport of imp.getNamedImports()) { + const name = namedImport.getName(); + const newName = SIMPLE_RENAMES[name]; + if (newName) { + const alias = namedImport.getAliasNode(); + if (!alias) { + // Collect refs while binding is still intact + renameAllReferences(sourceFile, name, newName); + } + namedImport.setName(newName); // modify binding AFTER refs are renamed + changesCount++; + } +} +``` + +Note: this is just reordering the two operations. `renameAllReferences` (from Task 1) now uses `findReferencesAsNodes()` internally, which requires the binding to still exist. Moving `setName` after `renameAllReferences` satisfies this requirement. + +- [ ] **Step 3: Refactor `handleErrorCodeSplit` to use `findReferencesAsNodes()`** + +Current code (lines 71-85) does a manual `forEachDescendant` looking for `Node.isPropertyAccessExpression` where the expression is `ErrorCode`. Replace with: + +```typescript +function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[]): 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; + + // Collect ALL references while binding exists + const refs = errorCodeImport.getNameNode().findReferencesAsNodes() + .filter(n => !Node.isImportSpecifier(n.getParent())); + + let needsProtocolErrorCode = false; + let needsSdkErrorCode = false; + + // Classify each reference + const replacements: { node: import('ts-morph').Node; newText: string }[] = []; + for (const ref of refs) { + const parent = ref.getParent(); + if (!parent || !Node.isPropertyAccessExpression(parent)) continue; + if (parent.getExpression() !== ref) continue; + + const member = parent.getName(); + if (ERROR_CODE_SDK_MEMBERS.has(member)) { + needsSdkErrorCode = true; + replacements.push({ node: ref, newText: 'SdkErrorCode' }); + } else { + needsProtocolErrorCode = true; + replacements.push({ node: ref, newText: 'ProtocolErrorCode' }); + } + changesCount++; + } + + // Apply replacements in reverse order + const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart()); + for (const { node, newText } of sorted) { + node.replaceWithText(newText); + } + + // ... rest of import cleanup (unchanged from current code, lines 87-143) ... +``` + +This eliminates the `forEachDescendant` walk. The `errorCodeLocalName` variable and manual alias handling are also gone — `findReferencesAsNodes()` resolves aliases automatically. + +- [ ] **Step 4: Refactor `handleRequestHandlerExtra` similarly** + +The `forEachDescendant` walk at line 189 that finds `Node.isTypeReference` matching `extraLocalName` becomes: + +```typescript +// Collect refs while binding exists +const refs = extraImport.getNameNode().findReferencesAsNodes() + .filter(n => !Node.isImportSpecifier(n.getParent())); +``` + +The rest of the classification logic (checking `ServerRequest`/`ClientNotification` type args) stays the same — it operates on the parent `TypeReference` node. But we no longer need `extraLocalName` or manual alias handling. + +- [ ] **Step 5: Run tests** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/symbolRenames.test.ts` + +Expected: all tests pass, including alias tests (lines 366-399). + +- [ ] **Step 6: Suggest commit** + +``` +refactor(codemod): use findReferencesAsNodes in symbolRenames + +Collect symbol references via findReferencesAsNodes() before +mutating import specifiers. Eliminates three forEachDescendant +walks and manual alias tracking in handleErrorCodeSplit and +handleRequestHandlerExtra. +``` + +--- + +### Task 3: Refactor `contextTypes.ts` — eliminate parent-kind guards and scope checks + +This transform has the second-highest complexity. The `processCallback` function (lines 18-177): +- Walks callback body with `forEachDescendant` looking for `extra` identifiers (line 98) +- Checks 4 parent kinds to exclude property-name positions (lines 102-105) +- Does a separate `forEachDescendant` walk to check for `ctx` name conflicts (lines 63-74) +- Builds replacements with property mappings (lines 111-134) + +All of this collapses with `findReferencesAsNodes()` on the parameter. + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts` +- Verify: `packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts` + +- [ ] **Step 1: Read the current processCallback function** + +Read `packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts:18-177`. + +Key sections to replace: +- Lines 61-84: scope-conflict check (walk looking for `ctx` identifier) +- Lines 96-107: collect identifiers matching `extra`, filter 4 parent kinds +- Lines 110-135: build replacements + +- [ ] **Step 2: Replace identifier collection with findReferencesAsNodes** + +Replace lines 62-84 (scope conflict check) and lines 96-107 (identifier collection) with: + +```typescript + // Check for ctx name conflicts in the callback body using findReferences on + // any existing 'ctx' identifier — if found, it means ctx is in scope. + if (body) { + let ctxAlreadyInScope = false; + body.forEachDescendant(node => { + if (ctxAlreadyInScope) return; + if (Node.isIdentifier(node) && node.getText() === CTX_PARAM_NAME) { + // Check it's not inside a nested function that shadows it + const containingFn = node.getFirstAncestor(n => + Node.isArrowFunction(n) || Node.isFunctionExpression(n) || Node.isFunctionDeclaration(n) + ); + if (containingFn === callbackNode || !containingFn) { + 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; + } + } + + // Collect references to the 'extra' parameter using findReferencesAsNodes. + // This automatically: + // - scopes to this specific parameter binding (ignores shadowed 'extra' in nested fns) + // - excludes property-name positions ({ extra: value }, obj.extra, etc.) + const paramRefs = extraParam.getNameNode().findReferencesAsNodes() + .filter(n => !Node.isParameter(n.getParent())); + + // Rename param declaration + const paramDecl = extraParam.getNameNode(); + paramDecl.replaceWithText(CTX_PARAM_NAME); + + // Build replacements from collected references + const sortedMappings = [...CONTEXT_PROPERTY_MAP] + .filter(m => m.from !== m.to) + .toSorted((a, b) => b.from.length - a.from.length); + + const replacements: { node: import('ts-morph').Node; newText: string }[] = []; + for (const ref of paramRefs) { + const parent = ref.getParent(); + // Value-position property access: extra.signal → ctx.mcpReq.signal + if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) { + 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) === ref) { + 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: ref, newText: CTX_PARAM_NAME }); + } + + const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart()); + for (const { node, newText } of sorted) { + node.replaceWithText(newText); + } +``` + +**What's eliminated:** +- The 4-case parent-kind exclusion list (lines 102-106) — `findReferencesAsNodes()` handles these +- The nested-function-aware scope walk for conflict detection (lines 63-74) — simplified to a targeted check + +**What stays the same:** +- Property mapping logic (PropertyAccessExpression / QualifiedName) — this is transform-specific +- The outer call-finding loop and callback detection +- The post-rewrite destructuring warning + +- [ ] **Step 3: Run tests** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/contextTypes.test.ts` + +Expected: all tests pass. Key tests to watch: +- `should not rename 'extra' in property positions` (verifies parent-kind exclusion) +- `should not rename when ctx already exists` (verifies scope conflict) +- `should handle nested functions` (verifies scope isolation) + +- [ ] **Step 4: Suggest commit** + +``` +refactor(codemod): use findReferencesAsNodes in contextTypes + +Replace manual forEachDescendant + 4-case parent-kind guard with +findReferencesAsNodes() on the 'extra' parameter. The language +service handles scope isolation and property-name exclusion +automatically. +``` + +--- + +### Task 4: Refactor `specSchemaAccess.ts` — eliminate `findNonImportReferences` and scoped walks + +This is the most complex transform (350 lines, 6 parent-kind guards, 3-level parent walks). Two `forEachDescendant` walks are replaced. + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` +- Verify: `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` + +- [ ] **Step 1: Read the current file** + +Read `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts`. + +Key sections: +- `findNonImportReferences` (lines 51-61): manual forEachDescendant walk +- `handleReference` (lines 63-192): 6 parent-kind guards at lines 129, 143, 154, 168, 172, 176 +- `rewriteCapturedSafeParse` (lines 249-335): scoped forEachDescendant walk at line 269 + +- [ ] **Step 2: Replace `findNonImportReferences` with `findReferencesAsNodes`** + +In the main loop (lines 19-31), replace: +```typescript +const refs = findNonImportReferences(sourceFile, localName); +``` +with: +```typescript +// Find the import specifier node for this schema +const specNode = schemaImports.get(localName)!.specifier; +const refs = specNode.getNameNode().findReferencesAsNodes() + .filter(n => !Node.isImportSpecifier(n.getParent())); +``` + +This requires changing `collectSpecSchemaImports` to also return the specifier node: +```typescript +function collectSpecSchemaImports(sourceFile: SourceFile): Map { + const result = new Map(); + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const n of imp.getNamedImports()) { + const exportName = n.getName(); + if (!SPEC_SCHEMA_NAMES.has(exportName)) continue; + const localName = n.getAliasNode()?.getText() ?? exportName; + result.set(localName, { originalName: exportName, specifier: n }); + } + } + return result; +} +``` + +Delete the `findNonImportReferences` function entirely. + +- [ ] **Step 3: Simplify `handleReference` parent-kind guards** + +With `findReferencesAsNodes()`, we no longer get identifiers in property-name positions. Remove these now-unreachable guards: + +```typescript +// REMOVE — findReferencesAsNodes never returns property-name-position identifiers: +// - line 168: Node.isPropertyAssignment(parent) && parent.getNameNode() === ref +// - line 172: Node.isBindingElement(parent) && parent.getPropertyNameNode() === ref +// - line 176: Node.isPropertyAccessExpression(parent) && parent.getNameNode() === ref +``` + +Keep these — they classify the reference type, not exclude positions: +- `isTypeofInTypePosition` — distinguishes type-level `typeof X` from value usage +- `isSafeParseSuccessPattern` / `isSafeParsePattern` / `isParsePattern` — detect Zod API patterns +- `Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref` — value-position property access +- `Node.isExportSpecifier(parent)` — re-export position +- `Node.isShorthandPropertyAssignment(parent)` — shorthand property + +- [ ] **Step 4: Replace scoped walk in `rewriteCapturedSafeParse`** + +Current code (lines 268-317) does `scope.forEachDescendant` to find `${varName}.success`, `${varName}.data`, `${varName}.error` accesses. Replace with `findReferencesAsNodes()` on the variable declaration: + +```typescript +function rewriteCapturedSafeParse( + safeParseCall: import('ts-morph').CallExpression, + localName: string, + typeName: string, + sourceFile: SourceFile, + diagnostics: Diagnostic[] +): boolean { + const varDecl = safeParseCall.getParent() as import('ts-morph').VariableDeclaration; + const varName = varDecl.getName(); + const args = safeParseCall.getArguments(); + const argText = args.length > 0 ? args[0]!.getText() : ''; + + // Collect references to the result variable BEFORE rewriting the initializer + const varNameNode = varDecl.getNameNode(); + const varRefs = varNameNode.findReferencesAsNodes() + .filter(n => n !== varNameNode && !Node.isVariableDeclaration(n.getParent())); + + // Rewrite the safeParse call + safeParseCall.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`); + ensureImport(sourceFile, 'specTypeSchemas'); + + // Classify property accesses on the result variable + const replacements: { node: import('ts-morph').Node; newText: string }[] = []; + for (const ref of varRefs) { + const parent = ref.getParent(); + if (!parent || !Node.isPropertyAccessExpression(parent)) continue; + if (parent.getExpression() !== ref) continue; + + const propName = parent.getName(); + switch (propName) { + case 'success': { + const grandParent = parent.getParent(); + if (grandParent && Node.isPrefixUnaryExpression(grandParent) && + grandParent.getOperatorToken() === SyntaxKind.ExclamationToken) { + replacements.push({ node: grandParent, newText: `${varName}.issues !== undefined` }); + } else { + replacements.push({ node: parent, newText: `(${varName}.issues === undefined)` }); + } + break; + } + case 'data': + replacements.push({ node: parent, newText: `${varName}.value` }); + break; + case 'error': { + const errorParent = parent.getParent(); + if (errorParent && Node.isPropertyAccessExpression(errorParent) && errorParent.getExpression() === parent) { + const subProp = errorParent.getName(); + if (subProp === 'issues') { + replacements.push({ node: errorParent, newText: `${varName}.issues` }); + } else if (subProp === 'message') { + replacements.push({ node: errorParent, newText: `${varName}.issues?.map(i => i.message).join(', ')` }); + } else { + diagnostics.push(warning(sourceFile.getFilePath(), errorParent.getStartLineNumber(), + `${varName}.error.${subProp} has no StandardSchema equivalent. Manual migration required.`)); + } + } else { + replacements.push({ node: parent, newText: `${varName}.issues` }); + } + break; + } + } + } + + const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart()); + for (const { node, newText } of sorted) { + node.replaceWithText(newText); + } + + diagnostics.push(warning(sourceFile.getFilePath(), varDecl.getStartLineNumber(), + `Rewrote ${localName}.safeParse() to specTypeSchemas.${typeName}['~standard'].validate(). ` + + `Result properties remapped: .success → .issues === undefined, .data → .value, .error → .issues.`)); + + return true; +} +``` + +**What's eliminated:** +- `findNonImportReferences` function (11 lines) — deleted entirely +- 3 unreachable parent-kind guards in `handleReference` +- The `scope.forEachDescendant` walk in `rewriteCapturedSafeParse` (was scope-insensitive anyway, as a PR comment noted) + +- [ ] **Step 5: Run tests** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` + +Expected: all tests pass. Key tests: +- Aliased import `import { CallToolRequestSchema as CTRS }` (line 493) +- Captured safeParse rewrite (line 248+) +- Non-MCP schemas not touched (line 222+) + +- [ ] **Step 6: Suggest commit** + +``` +refactor(codemod): use findReferencesAsNodes in specSchemaAccess + +Delete findNonImportReferences() and replace both forEachDescendant +walks with findReferencesAsNodes(). The scoped safeParse-result +rewrite now uses findReferencesAsNodes on the variable declaration, +which is inherently scope-correct. +``` + +--- + +### Task 5: Refactor `importPaths.ts` — collect refs before import removal + +Currently, `importPaths.ts` removes the old import (line 170), then calls `renameAllReferences` (line 172). Since Task 1's `renameAllReferences` now uses `findReferencesAsNodes()`, the binding must exist when it's called. Reorder operations. + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts` +- Verify: `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` + +- [ ] **Step 1: Read the relevant section** + +Read `packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts:106-175`. + +The issue is at lines 162-175: +```typescript +for (const n of namedImports) { + // ... add pending imports ... +} +imp.remove(); // ← removes binding +changesCount++; +for (const [oldName, newName] of symbolsToRenameInFile) { + renameAllReferences(sourceFile, oldName, newName); // ← needs binding +} +``` + +- [ ] **Step 2: Move rename before import removal** + +```typescript +// Rename body references BEFORE removing the import (findReferencesAsNodes needs the binding) +for (const [oldName, newName] of symbolsToRenameInFile) { + renameAllReferences(sourceFile, oldName, newName); +} + +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++; +``` + +Also apply the same reorder to the in-place `setModuleSpecifier` branch (lines 106-159): move `renameAllReferences` calls (lines 156-158) before `imp.setModuleSpecifier` (line 136) — though `setModuleSpecifier` doesn't break bindings, it's cleaner to be consistent. + +- [ ] **Step 3: Run tests** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` + +Expected: all tests pass. + +- [ ] **Step 4: Suggest commit** + +``` +refactor(codemod): reorder importPaths to rename refs before import removal + +findReferencesAsNodes() (used by renameAllReferences) needs the +import binding to still exist. Move rename calls before imp.remove(). +``` + +--- + +### Task 6: Refactor `removedApis.ts` — same reorder pattern + +Same issue: `renameAllReferences` called after import removal. + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts` +- Verify: `packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts` + +- [ ] **Step 1: Read the relevant sections** + +Read `packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts`. + +Find all places where `renameAllReferences` is called and check whether the import binding has already been removed/modified. + +- [ ] **Step 2: Move renames before import removal** + +Apply the same pattern as Task 5: collect or apply renames before the import specifier or declaration is removed. + +- [ ] **Step 3: Run tests** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/removedApis.test.ts` + +Expected: all tests pass. + +- [ ] **Step 4: Suggest commit** + +``` +refactor(codemod): reorder removedApis to rename refs before import removal +``` + +--- + +### Task 7: Simplify `removeUnusedImport` in importUtils.ts + +The `removeUnusedImport` function (lines 116-141) does a manual `forEachDescendant` walk to count references. Replace with `findReferencesAsNodes()`. + +**Files:** +- Modify: `packages/codemod/src/utils/importUtils.ts` +- Verify: `pnpm --filter @modelcontextprotocol/codemod test` + +- [ ] **Step 1: Read the current function** + +Read `packages/codemod/src/utils/importUtils.ts:116-141`. + +- [ ] **Step 2: Rewrite using findReferencesAsNodes** + +```typescript +export function removeUnusedImport(sourceFile: SourceFile, symbolName: string, onlyMcpImports?: boolean): void { + 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) { + // Check if the symbol has any non-import references + const refs = namedImport.getNameNode().findReferencesAsNodes() + .filter(n => !Node.isImportSpecifier(n.getParent())); + if (refs.length === 0) { + namedImport.remove(); + if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport()) { + imp.remove(); + } + } + return; + } + } + } +} +``` + +This eliminates the manual reference-counting `forEachDescendant` walk. + +- [ ] **Step 3: Run all tests** + +Run: `pnpm --filter @modelcontextprotocol/codemod test` + +Expected: all tests pass. `removeUnusedImport` is called by `specSchemaAccess` and `symbolRenames`. + +- [ ] **Step 4: Suggest commit** + +``` +refactor(codemod): use findReferencesAsNodes in removeUnusedImport +``` + +--- + +### Task 8: Full test suite verification and cleanup + +- [ ] **Step 1: Run full test suite** + +Run: `pnpm --filter @modelcontextprotocol/codemod test` + +Expected: all 14 test files pass. + +- [ ] **Step 2: Run typecheck** + +Run: `pnpm --filter @modelcontextprotocol/codemod typecheck` + +Expected: no type errors. + +- [ ] **Step 3: Run lint** + +Run: `pnpm --filter @modelcontextprotocol/codemod lint` + +Expected: no lint errors. + +- [ ] **Step 4: Remove dead code** + +Check if these functions are still used: +- `findNonImportReferences` in specSchemaAccess.ts — should be deleted (Task 4) +- Any unused imports in modified files + +- [ ] **Step 5: Suggest commit** + +``` +chore(codemod): remove dead code after findReferencesAsNodes refactor +``` + +--- + +## Phase 2: Optional tsconfig Loading for Receiver Type Checking + +This phase is independent of Phase 1 and addresses a different class of PR comments: transforms that cannot verify the *receiver* of a method call (e.g., `.tool()` might be on any object, not just `McpServer`). + +### Task 9: Add tsconfig resolution to projectAnalyzer + +**Files:** +- Modify: `packages/codemod/src/utils/projectAnalyzer.ts` +- Modify: `packages/codemod/src/types.ts` +- Modify: `packages/codemod/src/runner.ts` +- Test: `packages/codemod/test/projectAnalyzer.test.ts` + +- [ ] **Step 1: Add `findTsConfig` to projectAnalyzer** + +```typescript +export function findTsConfig(startDir: string): string | undefined { + let dir = path.resolve(startDir); + const root = path.parse(dir).root; + while (true) { + const candidate = path.join(dir, 'tsconfig.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); + } +} +``` + +- [ ] **Step 2: Extend `TransformContext` with optional Project** + +In `packages/codemod/src/types.ts`: + +```typescript +import type { Project, SourceFile } from 'ts-morph'; + +export interface TransformContext { + projectType: 'client' | 'server' | 'both' | 'unknown'; + project?: Project; + hasTypeInfo?: boolean; +} +``` + +- [ ] **Step 3: Modify runner to optionally load tsconfig** + +In `packages/codemod/src/runner.ts`, change Project creation: + +```typescript +import { findTsConfig } from './utils/projectAnalyzer.js'; + +const tsConfigPath = findTsConfig(options.targetDir); +const project = new Project({ + tsConfigFilePath: tsConfigPath, + skipAddingFilesFromTsConfig: true, + compilerOptions: { + allowJs: true, + noEmit: true, + skipLibCheck: true, + ...(tsConfigPath ? {} : { strict: false }), + } +}); + +// ... existing file globbing ... + +const hasTypeInfo = !!tsConfigPath; +const context: TransformContext = { + ...analyzeProject(options.targetDir), + project, + hasTypeInfo, +}; +``` + +Note: `skipAddingFilesFromTsConfig: true` keeps the current behavior of globbing files ourselves. But with a tsconfig, ts-morph resolves module paths and loads declaration files from `node_modules`. + +- [ ] **Step 4: Test with and without tsconfig** + +The existing tests use `new Project({ useInMemoryFileSystem: true })` and pass `TransformContext` without a `project` field. They should continue to work because `project` and `hasTypeInfo` are optional. + +Add a targeted test in `packages/codemod/test/projectAnalyzer.test.ts`: + +```typescript +describe('findTsConfig', () => { + it('should find tsconfig.json in target directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'codemod-')); + writeFileSync(join(dir, 'tsconfig.json'), '{}'); + expect(findTsConfig(dir)).toBe(join(dir, 'tsconfig.json')); + rmSync(dir, { recursive: true }); + }); + + it('should walk up to find tsconfig.json', () => { + const dir = mkdtempSync(join(tmpdir(), 'codemod-')); + const subDir = join(dir, 'src'); + mkdirSync(subDir); + writeFileSync(join(dir, 'tsconfig.json'), '{}'); + expect(findTsConfig(subDir)).toBe(join(dir, 'tsconfig.json')); + rmSync(dir, { recursive: true }); + }); + + it('should return undefined when no tsconfig exists', () => { + const dir = mkdtempSync(join(tmpdir(), 'codemod-')); + mkdirSync(join(dir, '.git')); + expect(findTsConfig(dir)).toBeUndefined(); + rmSync(dir, { recursive: true }); + }); +}); +``` + +- [ ] **Step 5: Suggest commit** + +``` +feat(codemod): optionally resolve tsconfig for type-aware transforms + +When a tsconfig.json is found near the target directory, the ts-morph +Project loads it for module resolution and type information. Transforms +can check context.hasTypeInfo to use type-aware APIs. Falls back to +syntax-only mode when no tsconfig is found. +``` + +--- + +### Task 10: Add receiver type checking to `mcpServerApi.ts` + +When type info is available, verify that `.tool()` / `.prompt()` / `.resource()` calls are on an `McpServer` instance. This addresses the PR comment about false positives on `someOtherObj.tool()`. + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts` +- Verify: `packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts` + +- [ ] **Step 1: Add a receiver-type guard helper** + +At the top of `mcpServerApi.ts`: + +```typescript +function isMcpServerReceiver(expr: import('ts-morph').PropertyAccessExpression, context: TransformContext): boolean { + if (!context.hasTypeInfo) return true; // permissive when no types + + try { + const receiverType = expr.getExpression().getType(); + const symbol = receiverType.getSymbol(); + if (!symbol) return true; // can't determine — be permissive + const name = symbol.getName(); + return name === 'McpServer'; + } catch { + return true; // type resolution failed — be permissive + } +} +``` + +- [ ] **Step 2: Guard the call collection loop** + +In the switch statement (lines 33-59), add the guard: + +```typescript +for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) continue; + if (!isMcpServerReceiver(expr, context)) continue; // ← NEW + const methodName = expr.getName(); + // ... rest of switch ... +} +``` + +Note: `_context` parameter in `apply()` must be renamed to `context` since it's now used. + +- [ ] **Step 3: Run tests** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/mcpServerApi.test.ts` + +Expected: all tests pass. Tests use in-memory projects without type info, so `isMcpServerReceiver` returns `true` (permissive mode). + +- [ ] **Step 4: Suggest commit** + +``` +feat(codemod): add receiver type checking for McpServer API migration + +When type info is available (tsconfig resolved), verify that .tool(), +.prompt(), .resource() calls are on McpServer instances. Falls back +to permissive mode when types unavailable. +``` + +--- + +## Summary of Changes + +| Metric | Before | After Phase 1 | After Phase 2 | +|--------|--------|---------------|---------------| +| `renameAllReferences` parent guards | 12 | 2 (ShorthandProp, ExportSpecifier) | 2 | +| `contextTypes` parent guards | 4 | 0 | 0 | +| `specSchemaAccess` parent guards | 6 | 3 (pattern classification only) | 3 | +| `forEachDescendant` walks across all transforms | ~12 | ~4 | ~4 | +| Manual import-provenance functions | 6 | 6 (unchanged) | 6 (could reduce further) | +| Receiver type checking | none | none | mcpServerApi | +| Lines in astUtils.ts | 33 | ~28 | ~28 | +| Lines in specSchemaAccess.ts | 350 | ~300 | ~300 | +| Lines in contextTypes.ts | 257 | ~200 | ~200 | +| Lines in symbolRenames.ts | 352 | ~310 | ~310 | + +Phase 1 (Tasks 1-8) is the high-value work. Phase 2 (Tasks 9-10) is additive improvement. diff --git a/docs/superpowers/plans/2026-05-15-codemod-batch-test-fixes.md b/docs/superpowers/plans/2026-05-15-codemod-batch-test-fixes.md new file mode 100644 index 0000000000..48c9d8de26 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-codemod-batch-test-fixes.md @@ -0,0 +1,549 @@ +# Codemod Batch Test Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 5 codemod transform issues discovered by running the batch test against real-world repos (inspector + mcp-servers-fork). + +**Architecture:** Each fix targets a specific transform or mapping file within `packages/codemod/src/migrations/v1-to-v2/`. Fixes are ordered by dependency: Tasks 1 and 4 are independent; Tasks 2 and 3 both modify `specSchemaAccess.ts` so Task 2 must land first; Task 5 is independent. All tasks follow TDD. + +**Tech Stack:** TypeScript, ts-morph (AST manipulation), vitest + +--- + +## File Map + +| File | Action | Task(s) | +|------|--------|---------| +| `packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts` | Modify | 1 | +| `packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts` | Modify | 1 | +| `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` | Modify | 2, 3 | +| `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` | Modify | 2, 3 | +| `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts` | Modify | 4, 5 | +| `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` | Modify | 4, 5 | + +--- + +### Task 1: Complete handler registration schema-to-method mapping + +Add missing experimental/task request schemas and notification schemas to `schemaToMethodMap.ts` so the `handlerRegistration` transform auto-converts them to string method names instead of falling through to `specSchemaAccess` which incorrectly replaces them with `specTypeSchemas.X`. + +**Impact:** Fixes ~20 errors in inspector/client `useConnection.ts` (setRequestHandler + downstream param type inference). + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts` +- Test: `packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts` + +- [ ] **Step 1: Write failing tests for task request schemas** + +Add to `handlerRegistration.test.ts`: + +```typescript +it('replaces ListTasksRequestSchema with method string', () => { + const input = [ + `import { ListTasksRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(ListTasksRequestSchema, async (request) => {`, + ` return { tasks: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tasks/list'"); + expect(result).not.toContain('ListTasksRequestSchema'); +}); + +it('replaces GetTaskRequestSchema with method string', () => { + const input = [ + `import { GetTaskRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(GetTaskRequestSchema, async (request) => {`, + ` return { taskId: '1', status: 'completed' };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tasks/get'"); + expect(result).not.toContain('GetTaskRequestSchema'); +}); + +it('replaces CancelTaskRequestSchema with method string', () => { + const input = [ + `import { CancelTaskRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(CancelTaskRequestSchema, async (request) => {`, + ` return {};`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tasks/cancel'"); + expect(result).not.toContain('CancelTaskRequestSchema'); +}); + +it('replaces GetTaskPayloadRequestSchema with method string', () => { + const input = [ + `import { GetTaskPayloadRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tasks/result'"); + expect(result).not.toContain('GetTaskPayloadRequestSchema'); +}); + +it('replaces TaskStatusNotificationSchema with method string', () => { + const input = [ + `import { TaskStatusNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setNotificationHandler(TaskStatusNotificationSchema, async () => {});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setNotificationHandler('notifications/tasks/status'"); + expect(result).not.toContain('TaskStatusNotificationSchema'); +}); + +it('replaces ElicitationCompleteNotificationSchema with method string', () => { + const input = [ + `import { ElicitationCompleteNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setNotificationHandler(ElicitationCompleteNotificationSchema, async () => {});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setNotificationHandler('notifications/elicitation/complete'"); + expect(result).not.toContain('ElicitationCompleteNotificationSchema'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/handlerRegistration.test.ts` +Expected: 6 new tests FAIL (schemas not in map, get "Custom method handler" diagnostic instead) + +- [ ] **Step 3: Add missing schemas to the mapping** + +In `packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts`, add entries to `SCHEMA_TO_METHOD`: + +```typescript +ListTasksRequestSchema: 'tasks/list', +GetTaskRequestSchema: 'tasks/get', +GetTaskPayloadRequestSchema: 'tasks/result', +CancelTaskRequestSchema: 'tasks/cancel', +``` + +And add entries to `NOTIFICATION_SCHEMA_TO_METHOD`: + +```typescript +TaskStatusNotificationSchema: 'notifications/tasks/status', +ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete', +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/handlerRegistration.test.ts` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts +git commit -m "fix(codemod): add task and elicitation schemas to handler registration map" +``` + +--- + +### Task 2: Replace schema identifiers in generic property access positions + +Currently, when a spec schema like `OAuthTokensSchema` is used with a Zod-specific method (e.g., `.parseAsync()`, `.or()`, `.extend()`), the `specSchemaAccess` transform only emits a diagnostic but does NOT replace the identifier. This leaves the old schema name in imports, which breaks compilation since v2 packages don't export these schema symbols. + +**Fix:** In the generic property access case, replace the identifier with `specTypeSchemas.X` (even though the method call itself won't work). The diagnostic still tells the user what to do, but the import now resolves. + +**Impact:** Fixes ~12 "Module has no exported member 'XSchema'" errors across both repos. + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` +- Test: `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` + +- [ ] **Step 1: Write failing tests for generic property access replacement** + +Add to `specSchemaAccess.test.ts` in a new `describe` block: + +```typescript +describe('auto-transform: generic property access → specTypeSchemas.X', () => { + it('replaces schema identifier in .parseAsync() call', () => { + const input = [ + `import { OAuthTokensSchema } from '@modelcontextprotocol/server';`, + `const tokens = await OAuthTokensSchema.parseAsync(data);`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('specTypeSchemas.OAuthTokens.parseAsync(data)'); + expect(text).not.toMatch(/import\s*\{[^}]*OAuthTokensSchema[^}]*\}/); + expect(result.changesCount).toBeGreaterThan(0); + expect(result.diagnostics.length).toBeGreaterThan(0); + }); + + it('replaces schema identifier in .or() call', () => { + const input = [ + `import { ServerNotificationSchema } from '@modelcontextprotocol/server';`, + `const union = ServerNotificationSchema.or(otherSchema);`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('specTypeSchemas.ServerNotification.or(otherSchema)'); + expect(text).not.toMatch(/import\s*\{[^}]*ServerNotificationSchema[^}]*\}/); + expect(result.changesCount).toBeGreaterThan(0); + }); + + it('replaces schema identifier in .extend() call', () => { + const input = [ + `import { ToolSchema } from '@modelcontextprotocol/server';`, + `const extended = ToolSchema.extend({ extra: z.string() });`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('specTypeSchemas.Tool.extend'); + expect(result.changesCount).toBeGreaterThan(0); + }); + + it('adds specTypeSchemas import for generic property access', () => { + const input = [ + `import { OAuthTokensSchema } from '@modelcontextprotocol/server';`, + `const tokens = await OAuthTokensSchema.parseAsync(data);`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toMatch(/import.*specTypeSchemas.*from/); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` +Expected: 4 new tests FAIL (generic property access only emits diagnostic, doesn't replace) + +- [ ] **Step 3: Modify the generic property access handler to also replace the identifier** + +In `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts`, find the generic property access handler in `handleReference()` (around line 129). Change: + +```typescript +// BEFORE (diagnostic-only, no replacement): +if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + ref.getStartLineNumber(), + `${localName} is not exported in v2. Use \`specTypeSchemas.${typeName}\` (typed as StandardSchemaV1) or \`isSpecType.${typeName}\` for validation.` + ) + ); + return false; +} +``` + +to: + +```typescript +// AFTER (replace identifier AND emit diagnostic): +if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) { + const line = ref.getStartLineNumber(); + ref.replaceWithText(`specTypeSchemas.${typeName}`); + ensureImport(sourceFile, 'specTypeSchemas'); + diagnostics.push( + warning( + sourceFile.getFilePath(), + line, + `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse()/.parseAsync() are not available. Manual rewrite required.` + ) + ); + return true; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` +Expected: All tests PASS (including existing "keeps original schema import when some refs are diagnostic-only" test — verify this one still passes since the behavior changed) + +**Note:** The existing test at line 262 ("keeps original schema import when some refs are diagnostic-only") combines a `.safeParse().success` auto-transform with a `.parse()` diagnostic-only case. The `.parse()` case is separate from the generic property access case (it has its own handler returning `false`). This test should still pass because `.parse()` is handled before the generic property access check. + +- [ ] **Step 5: Commit** + +```bash +git add packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts +git commit -m "fix(codemod): replace schema identifiers in generic property access positions" +``` + +--- + +### Task 3: Fix safeParse-to-validate `.error` sub-property remapping + +When `const r = XSchema.safeParse(v)` is captured, the transform rewrites `.error` → `.issues`. But downstream accesses like `r.error.message` become `r.issues.message` (wrong — `.issues` is an array) and `r.error.issues` becomes `r.issues.issues` (double nesting). + +**Fix:** In the `case 'error':` block of `rewriteCapturedSafeParse`, check if the parent node is another PropertyAccessExpression (meaning `r.error.X`). Handle `.issues` (unwrap) and `.message` (rewrite to array map) specifically. + +**Impact:** Fixes ~10 TypeScript errors in inspector/client's `AppRenderer.tsx`, `SamplingRequest.tsx`, `ToolResults.tsx`. + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` +- Test: `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` + +- [ ] **Step 1: Write failing tests for error sub-property remapping** + +Add to `specSchemaAccess.test.ts` inside the "auto-transform: captured safeParse result" describe block: + +```typescript +it('rewrites .error.issues to .issues (unwrap double nesting)', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, + `const parsed = CallToolResultSchema.safeParse(data);`, + `if (!parsed.success) { console.log(parsed.error.issues); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('parsed.issues'); + expect(text).not.toContain('parsed.issues.issues'); + expect(text).not.toContain('parsed.error'); +}); + +it('rewrites .error.message to issues map expression', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, + `const parsed = CallToolResultSchema.safeParse(data);`, + `if (!parsed.success) { console.log(parsed.error.message); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).not.toContain('parsed.error'); + expect(text).not.toContain('parsed.issues.message'); + expect(text).toContain("parsed.issues?.map(i => i.message).join(', ')"); +}); + +it('rewrites bare .error to .issues (unchanged behavior)', () => { + const input = [ + `import { ToolSchema } from '@modelcontextprotocol/server';`, + `const result = ToolSchema.safeParse(raw);`, + `if (!result.success) { console.log(result.error); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('result.issues'); + expect(text).not.toContain('result.error'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` +Expected: First 2 new tests FAIL (`.error.issues` becomes `.issues.issues`, `.error.message` becomes `.issues.message`). Third test should already pass. + +- [ ] **Step 3: Update the error case in rewriteCapturedSafeParse** + +In `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts`, in the `rewriteCapturedSafeParse` function, replace the `case 'error'` block (around line 293): + +```typescript +// BEFORE: +case 'error': { + replacements.push({ node, newText: `${varName}.issues` }); + break; +} +``` + +with: + +```typescript +// AFTER: +case 'error': { + const errorParent = node.getParent(); + if (errorParent && Node.isPropertyAccessExpression(errorParent) && errorParent.getExpression() === node) { + const subProp = errorParent.getName(); + if (subProp === 'issues') { + replacements.push({ node: errorParent, newText: `${varName}.issues` }); + } else if (subProp === 'message') { + replacements.push({ node: errorParent, newText: `${varName}.issues?.map(i => i.message).join(', ')` }); + } else { + replacements.push({ node: errorParent, newText: `${varName}.issues` }); + } + } else { + replacements.push({ node, newText: `${varName}.issues` }); + } + break; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts +git commit -m "fix(codemod): handle .error sub-property accesses in safeParse rewrite" +``` + +--- + +### Task 4: Handle `zod-compat.js` import path + +The import path `@modelcontextprotocol/sdk/server/zod-compat.js` is not in `IMPORT_MAP`, so `importPaths` emits "Unknown SDK import path" and leaves it untouched. The file exported `AnySchema` and `SchemaOutput` types that don't exist in v2. + +**Fix:** Add the path to `IMPORT_MAP` as `removed` with a descriptive message. This removes the import and emits a clear diagnostic. + +**Impact:** Fixes "Unknown SDK import path" warnings in inspector/client (4 files). The `AnySchema`/`SchemaOutput` usages in function signatures will still need manual migration, but the import won't be stale. + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts` +- Test: `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` + +- [ ] **Step 1: Write failing test for zod-compat import removal** + +Add to `importPaths.test.ts`: + +```typescript +it('removes zod-compat.js import and emits diagnostic', () => { + const input = [ + `import { AnySchema, SchemaOutput } from '@modelcontextprotocol/sdk/server/zod-compat.js';`, + `function validate(schema: T): SchemaOutput { return {} as any; }`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, ctx); + const text = sourceFile.getFullText(); + expect(text).not.toContain('zod-compat'); + expect(text).not.toContain("from '@modelcontextprotocol/sdk"); + expect(result.changesCount).toBeGreaterThan(0); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('zod-compat'); +}); +``` + +Ensure the test file imports the necessary pieces — check the existing test imports at the top and match them. The existing test file should already import `importPathsTransform`, `Project`, and define a `ctx` constant. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` +Expected: FAIL — import is left unchanged, "Unknown SDK import path" warning emitted + +- [ ] **Step 3: Add zod-compat.js to the import map** + +In `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts`, add this entry to `IMPORT_MAP` after the `'@modelcontextprotocol/sdk/server/middleware.js'` entry: + +```typescript +'@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.' +}, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +git commit -m "fix(codemod): handle zod-compat.js import path as removed" +``` + +--- + +### Task 5: Rename `ResourceTemplate` type imports to `ResourceTemplateType` + +When `ResourceTemplate` is imported from `@modelcontextprotocol/sdk/types.js` (protocol type usage), the import is rewritten to `@modelcontextprotocol/server`. But the server exports a `ResourceTemplate` **class** (used for server-side registration), shadowing the protocol type. The protocol type already exists in v2 as `ResourceTemplateType` (defined in `core/src/types/types.ts`, publicly exported via `core/public`'s `export * from '../../types/types.js'`, and re-exported by both `@modelcontextprotocol/server` and `@modelcontextprotocol/client`). + +**Fix:** Add `ResourceTemplate` → `ResourceTemplateType` to the `renamedSymbols` mapping for the `types.js` import path. This auto-renames the import and all references. No SDK changes needed — `ResourceTemplateType` is already publicly exported. + +**Impact:** Fixes ~8 TypeScript errors in inspector/client `ResourcesTab.tsx` (`.name`, `.description`, `UriTemplate` vs `string` issues). + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts` +- Test: `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` + +- [ ] **Step 1: Write failing test for ResourceTemplate rename** + +Add to `importPaths.test.ts`: + +```typescript +it('renames ResourceTemplate to ResourceTemplateType when imported from types.js', () => { + const input = [ + `import { ResourceTemplate } from '@modelcontextprotocol/sdk/types.js';`, + `const template: ResourceTemplate = getTemplate();`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, ctx); + const text = sourceFile.getFullText(); + expect(text).toContain('ResourceTemplateType'); + expect(text).not.toMatch(/\bResourceTemplate\b(?!Type)/); + expect(result.changesCount).toBeGreaterThan(0); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` +Expected: FAIL — ResourceTemplate is not renamed + +- [ ] **Step 3: Add ResourceTemplate rename to import map** + +In `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts`, find the entry for `'@modelcontextprotocol/sdk/types.js'`: + +```typescript +'@modelcontextprotocol/sdk/types.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' +}, +``` + +Add `renamedSymbols`: + +```typescript +'@modelcontextprotocol/sdk/types.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved', + renamedSymbols: { + ResourceTemplate: 'ResourceTemplateType' + } +}, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` +Expected: All tests PASS + +- [ ] **Step 5: Run full test suite** + +Run: `pnpm --filter @modelcontextprotocol/codemod test` +Expected: All tests PASS across all test files + +- [ ] **Step 6: Commit** + +```bash +git add packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +git commit -m "fix(codemod): rename ResourceTemplate to ResourceTemplateType to avoid class collision" +``` + +--- + +## Verification + +After all 5 tasks are complete: + +- [ ] **Rebuild and re-run batch test** + +```bash +pnpm --filter @modelcontextprotocol/codemod build +pnpm --filter @modelcontextprotocol/codemod batch-test +``` + +Compare `packages/codemod/batch-test/results/summary.json` with the pre-fix results. Expected improvements: +- inspector/client: build errors should decrease significantly (StandardSchemaV1→AnySchema errors from handler registration fixed, schema import errors fixed) +- inspector/server: `SSEServerTransport` errors remain (manual migration), but `setRequestHandler` task schema errors should be fixed +- mcp-servers-fork: `SSEServerTransport` errors remain (manual migration), test context mock errors remain (manual migration) diff --git a/docs/superpowers/plans/2026-06-02-readbuffer-max-size.md b/docs/superpowers/plans/2026-06-02-readbuffer-max-size.md new file mode 100644 index 0000000000..867e8a3599 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-readbuffer-max-size.md @@ -0,0 +1,323 @@ +# ReadBuffer Max Size Guard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a configurable maximum buffer size to `ReadBuffer` to prevent unbounded memory growth from a misbehaving stdio peer (GHSA-wqgc-pwpr-pq7r). + +**Architecture:** `ReadBuffer.append()` gains a size guard that throws on overflow. Both stdio transports wrap their data handlers in try/catch to catch the throw, report via `onerror`, and close the transport. The constant and constructor option are exported as public API. + +**Tech Stack:** TypeScript, vitest + +--- + +### Task 1: Add size guard to ReadBuffer + +**Files:** +- Modify: `packages/core/src/shared/stdio.ts:1-42` + +- [ ] **Step 1: Write failing tests for buffer overflow** + +Add a new `describe` block to `packages/core/test/shared/stdio.test.ts`: + +```typescript +describe('buffer size limit', () => { + test('should throw when buffer exceeds default max size', () => { + const readBuffer = new ReadBuffer(); + const chunk = Buffer.alloc(1024 * 1024); // 1 MB + // Default is 10 MB, so 11 appends should fail + for (let i = 0; i < 10; i++) { + readBuffer.append(chunk); + } + expect(() => readBuffer.append(chunk)).toThrow( + /ReadBuffer exceeded maximum size/ + ); + }); + + test('should throw when buffer exceeds custom max size', () => { + const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); + readBuffer.append(Buffer.alloc(50)); + expect(() => readBuffer.append(Buffer.alloc(51))).toThrow( + /ReadBuffer exceeded maximum size/ + ); + }); + + test('should clear buffer before throwing on overflow', () => { + const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); + readBuffer.append(Buffer.alloc(50)); + expect(() => readBuffer.append(Buffer.alloc(51))).toThrow(); + + // Buffer should be cleared — can append again + readBuffer.append(Buffer.alloc(50)); + // And read messages normally + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should allow appending up to exactly the max size', () => { + const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); + // Should not throw — exactly at limit + expect(() => readBuffer.append(Buffer.alloc(100))).not.toThrow(); + }); + + test('should work with no options (backwards compatible)', () => { + const readBuffer = new ReadBuffer(); + // Small append should always work + readBuffer.append(Buffer.from('hello\n')); + expect(readBuffer.readMessage()).not.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm --filter @modelcontextprotocol/core test -- packages/core/test/shared/stdio.test.ts` +Expected: FAIL — `ReadBuffer` constructor doesn't accept options yet. + +- [ ] **Step 3: Implement the size guard in ReadBuffer** + +Modify `packages/core/src/shared/stdio.ts`. The full file should become: + +```typescript +import type { JSONRPCMessage } from '../types/index.js'; +import { JSONRPCMessageSchema } from '../types/index.js'; + +export const DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB + +/** + * Buffers a continuous stdio stream into discrete JSON-RPC messages. + */ +export class ReadBuffer { + private _buffer?: Buffer; + private _maxBufferSize: number; + + constructor(options?: { maxBufferSize?: number }) { + this._maxBufferSize = options?.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; + } + + append(chunk: Buffer): void { + const newSize = (this._buffer?.length ?? 0) + chunk.length; + if (newSize > this._maxBufferSize) { + this.clear(); + throw new Error( + `ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes` + ); + } + this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; + } + + readMessage(): JSONRPCMessage | null { + while (this._buffer) { + const index = this._buffer.indexOf('\n'); + if (index === -1) { + return null; + } + + const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); + this._buffer = this._buffer.subarray(index + 1); + + try { + return deserializeMessage(line); + } catch (error) { + // Skip non-JSON lines (e.g., debug output from hot-reload tools like + // tsx or nodemon that write to stdout). Schema validation errors still + // throw so malformed-but-valid-JSON messages surface via onerror. + if (error instanceof SyntaxError) { + continue; + } + throw error; + } + } + return null; + } + + clear(): void { + this._buffer = undefined; + } +} + +export function deserializeMessage(line: string): JSONRPCMessage { + return JSONRPCMessageSchema.parse(JSON.parse(line)); +} + +export function serializeMessage(message: JSONRPCMessage): string { + return JSON.stringify(message) + '\n'; +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `pnpm --filter @modelcontextprotocol/core test -- packages/core/test/shared/stdio.test.ts` +Expected: All tests PASS (including all existing tests — backwards compatible). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/shared/stdio.ts packages/core/test/shared/stdio.test.ts +git commit -m "fix(core): add max buffer size guard to ReadBuffer + +Prevents unbounded memory growth when a stdio peer sends data without +newline delimiters. Default limit is 10 MB, configurable via constructor. + +Ref: GHSA-wqgc-pwpr-pq7r" +``` + +--- + +### Task 2: Add DEFAULT_MAX_BUFFER_SIZE to public exports + +**Files:** +- Modify: `packages/core/src/exports/public/index.ts:70` + +- [ ] **Step 1: Add the constant to the public export** + +Change line 70 in `packages/core/src/exports/public/index.ts` from: + +```typescript +export { deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js'; +``` + +to: + +```typescript +export { DEFAULT_MAX_BUFFER_SIZE, deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js'; +``` + +- [ ] **Step 2: Run typecheck to confirm it compiles** + +Run: `pnpm --filter @modelcontextprotocol/core typecheck` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/exports/public/index.ts +git commit -m "feat(core): export DEFAULT_MAX_BUFFER_SIZE from public API" +``` + +--- + +### Task 3: Add try/catch to StdioClientTransport data handler + +**Files:** +- Modify: `packages/client/src/client/stdio.ts:151-154` + +- [ ] **Step 1: Wrap the data handler in try/catch** + +Change lines 151-154 of `packages/client/src/client/stdio.ts` from: + +```typescript + this._process.stdout?.on('data', chunk => { + this._readBuffer.append(chunk); + this.processReadBuffer(); + }); +``` + +to: + +```typescript + this._process.stdout?.on('data', chunk => { + try { + this._readBuffer.append(chunk); + this.processReadBuffer(); + } catch (error) { + this.onerror?.(error as Error); + this.close().catch(() => {}); + } + }); +``` + +- [ ] **Step 2: Run typecheck** + +Run: `pnpm --filter @modelcontextprotocol/client typecheck` +Expected: No errors. + +- [ ] **Step 3: Run existing stdio client tests to verify no regression** + +Run: `pnpm --filter @modelcontextprotocol/client test -- packages/client/test/client/stdio.test.ts` +Expected: All existing tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/client/src/client/stdio.ts +git commit -m "fix(client): catch ReadBuffer overflow in StdioClientTransport data handler + +Prevents an uncaught exception when ReadBuffer.append() throws due to +exceeding the max buffer size. Routes the error to onerror and closes +the transport. + +Ref: GHSA-wqgc-pwpr-pq7r" +``` + +--- + +### Task 4: Add try/catch to StdioServerTransport data handler + +**Files:** +- Modify: `packages/server/src/server/stdio.ts:34-37` + +- [ ] **Step 1: Wrap the _ondata handler in try/catch** + +Change lines 34-37 of `packages/server/src/server/stdio.ts` from: + +```typescript + _ondata = (chunk: Buffer) => { + this._readBuffer.append(chunk); + this.processReadBuffer(); + }; +``` + +to: + +```typescript + _ondata = (chunk: Buffer) => { + try { + this._readBuffer.append(chunk); + this.processReadBuffer(); + } catch (error) { + this.onerror?.(error as Error); + this.close().catch(() => {}); + } + }; +``` + +- [ ] **Step 2: Run typecheck** + +Run: `pnpm --filter @modelcontextprotocol/server typecheck` +Expected: No errors. + +- [ ] **Step 3: Run existing stdio server tests to verify no regression** + +Run: `pnpm --filter @modelcontextprotocol/server test -- packages/server/test/server/stdio.test.ts` +Expected: All existing tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/server/src/server/stdio.ts +git commit -m "fix(server): catch ReadBuffer overflow in StdioServerTransport data handler + +Prevents an uncaught exception when ReadBuffer.append() throws due to +exceeding the max buffer size. Routes the error to onerror and closes +the transport. + +Ref: GHSA-wqgc-pwpr-pq7r" +``` + +--- + +### Task 5: Full test suite verification + +- [ ] **Step 1: Run full typecheck across all packages** + +Run: `pnpm typecheck:all` +Expected: No errors. + +- [ ] **Step 2: Run full test suite** + +Run: `pnpm test:all` +Expected: All tests PASS. + +- [ ] **Step 3: Run lint** + +Run: `pnpm lint:all` +Expected: No errors (or fix any formatting issues with `pnpm lint:fix:all`). diff --git a/docs/superpowers/plans/2026-06-02-v1-readbuffer-max-size.md b/docs/superpowers/plans/2026-06-02-v1-readbuffer-max-size.md new file mode 100644 index 0000000000..cfbbfb23b9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-v1-readbuffer-max-size.md @@ -0,0 +1,356 @@ +# V1 ReadBuffer Max Size Guard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port the ReadBuffer max size guard from the v2 branch (`fix/stdio-buffer-limit`, commit `08780873`) to v1. This prevents unbounded memory growth when a misbehaving stdio peer sends data without newline delimiters (GHSA-wqgc-pwpr-pq7r). + +**Architecture:** `ReadBuffer.append()` gains a size guard that throws on overflow. Both stdio transports wrap their data handlers in try/catch to catch the throw, report via `onerror`, and close the transport. The constant `STDIO_DEFAULT_MAX_BUFFER_SIZE` is exported from `src/shared/stdio.ts`. + +**Tech Stack:** TypeScript, vitest + +**Key difference from v2:** V1 is a flat `src/` layout (not a monorepo under `packages/`). There is no public re-export index file, so the constant is only exported from `src/shared/stdio.ts` directly. + +--- + +### Task 1: Add size guard to ReadBuffer + +**Files:** +- Modify: `src/shared/stdio.ts` +- Modify: `test/shared/stdio.test.ts` + +- [ ] **Step 1: Add buffer size limit tests** + +Append the following to `test/shared/stdio.test.ts`: + +```typescript +import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '../../src/shared/stdio.js'; + +describe('buffer size limit', () => { + test('should throw when buffer exceeds default max size', () => { + const readBuffer = new ReadBuffer(); + const chunkSize = 1024 * 1024; // 1 MB + const chunk = Buffer.alloc(chunkSize); + const chunksToFill = Math.floor(STDIO_DEFAULT_MAX_BUFFER_SIZE / chunkSize); + for (let i = 0; i < chunksToFill; i++) { + readBuffer.append(chunk); + } + expect(() => readBuffer.append(chunk)).toThrow( + /ReadBuffer exceeded maximum size/ + ); + }); + + test('should throw when buffer exceeds custom max size', () => { + const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); + readBuffer.append(Buffer.alloc(50)); + expect(() => readBuffer.append(Buffer.alloc(51))).toThrow( + /ReadBuffer exceeded maximum size/ + ); + }); + + test('should clear buffer before throwing on overflow', () => { + const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); + readBuffer.append(Buffer.alloc(50)); + expect(() => readBuffer.append(Buffer.alloc(51))).toThrow(); + + // Buffer should be cleared — can append again + readBuffer.append(Buffer.alloc(50)); + // And read messages normally + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should allow appending up to exactly the max size', () => { + const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); + // Should not throw — exactly at limit + expect(() => readBuffer.append(Buffer.alloc(100))).not.toThrow(); + }); + + test('should work with no options (backwards compatible)', () => { + const readBuffer = new ReadBuffer(); + // Small append should always work + readBuffer.append(Buffer.from(JSON.stringify({ jsonrpc: '2.0', method: 'ping' }) + '\n')); + expect(readBuffer.readMessage()).not.toBeNull(); + }); +}); +``` + +Also update the existing import at the top of the file — change: + +```typescript +import { ReadBuffer } from '../../src/shared/stdio.js'; +``` + +to: + +```typescript +import { STDIO_DEFAULT_MAX_BUFFER_SIZE, ReadBuffer } from '../../src/shared/stdio.js'; +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +Run: `npx vitest test/shared/stdio.test.ts --run` +Expected: FAIL — `ReadBuffer` constructor doesn't accept options yet, `STDIO_DEFAULT_MAX_BUFFER_SIZE` doesn't exist. + +- [ ] **Step 3: Implement the size guard in ReadBuffer** + +Modify `src/shared/stdio.ts`. Add the constant and constructor, and add the size guard to `append()`. The full file should become: + +```typescript +import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; + +export const STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024; + +/** + * Buffers a continuous stdio stream into discrete JSON-RPC messages. + */ +export class ReadBuffer { + private _buffer?: Buffer; + private _maxBufferSize: number; + + constructor(options?: { maxBufferSize?: number }) { + this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE; + } + + append(chunk: Buffer): void { + const newSize = (this._buffer?.length ?? 0) + chunk.length; + if (newSize > this._maxBufferSize) { + this.clear(); + throw new Error( + `ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes` + ); + } + this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; + } + + readMessage(): JSONRPCMessage | null { + if (!this._buffer) { + return null; + } + + const index = this._buffer.indexOf('\n'); + if (index === -1) { + return null; + } + + const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); + this._buffer = this._buffer.subarray(index + 1); + return deserializeMessage(line); + } + + clear(): void { + this._buffer = undefined; + } +} + +export function deserializeMessage(line: string): JSONRPCMessage { + return JSONRPCMessageSchema.parse(JSON.parse(line)); +} + +export function serializeMessage(message: JSONRPCMessage): string { + return JSON.stringify(message) + '\n'; +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +Run: `npx vitest test/shared/stdio.test.ts --run` +Expected: All tests PASS. + +- [ ] **Step 5: Suggest commit** + +```bash +git add src/shared/stdio.ts test/shared/stdio.test.ts +git commit -m "fix: add max buffer size guard to ReadBuffer + +Prevents unbounded memory growth when a stdio peer sends data without +newline delimiters. Default limit is 10 MB, configurable via constructor. + +Ref: GHSA-wqgc-pwpr-pq7r" +``` + +--- + +### Task 2: Add try/catch to StdioServerTransport data handler + +**Files:** +- Modify: `src/server/stdio.ts:26-29` +- Modify: `test/server/stdio.test.ts` + +- [ ] **Step 1: Add overflow test for StdioServerTransport** + +Append the following test to `test/server/stdio.test.ts`: + +```typescript +test('should fire onerror and close when ReadBuffer overflows', async () => { + const server = new StdioServerTransport(input, output); + + let receivedError: Error | undefined; + server.onerror = err => { + receivedError = err; + }; + let closeCount = 0; + server.onclose = () => { + closeCount++; + }; + + await server.start(); + + // Push data exceeding the default 10 MB limit without a newline + const chunk = Buffer.alloc(11 * 1024 * 1024, 0x41); + input.push(chunk); + + // Allow the close() promise to settle + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(receivedError?.message).toMatch(/ReadBuffer exceeded maximum size/); + expect(closeCount).toBe(1); +}); +``` + +- [ ] **Step 2: Run to confirm the test fails** + +Run: `npx vitest test/server/stdio.test.ts --run` +Expected: FAIL — the uncaught throw from `append()` crashes instead of being caught. + +- [ ] **Step 3: Wrap the _ondata handler in try/catch** + +Change lines 26-29 of `src/server/stdio.ts` from: + +```typescript + _ondata = (chunk: Buffer) => { + this._readBuffer.append(chunk); + this.processReadBuffer(); + }; +``` + +to: + +```typescript + _ondata = (chunk: Buffer) => { + try { + this._readBuffer.append(chunk); + this.processReadBuffer(); + } catch (error) { + this.onerror?.(error as Error); + this.close().catch(() => {}); + } + }; +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +Run: `npx vitest test/server/stdio.test.ts --run` +Expected: All tests PASS. + +- [ ] **Step 5: Suggest commit** + +```bash +git add src/server/stdio.ts test/server/stdio.test.ts +git commit -m "fix(server): catch ReadBuffer overflow in StdioServerTransport + +Prevents an uncaught exception when ReadBuffer.append() throws due to +exceeding the max buffer size. Routes the error to onerror and closes +the transport. + +Ref: GHSA-wqgc-pwpr-pq7r" +``` + +--- + +### Task 3: Add try/catch to StdioClientTransport data handler + +**Files:** +- Modify: `src/client/stdio.ts:150-153` +- Modify: `test/client/stdio.test.ts` + +- [ ] **Step 1: Add overflow test for StdioClientTransport** + +Append the following test to `test/client/stdio.test.ts`: + +```typescript +test('should fire onerror and close when ReadBuffer overflows', async () => { + const client = new StdioClientTransport({ + command: 'node', + args: ['-e', 'process.stdout.write(Buffer.alloc(11 * 1024 * 1024, 0x41))'] + }); + + const errorReceived = new Promise(resolve => { + client.onerror = resolve; + }); + const closed = new Promise(resolve => { + client.onclose = () => resolve(); + }); + + await client.start(); + + const error = await errorReceived; + expect(error.message).toMatch(/ReadBuffer exceeded maximum size/); + await closed; +}); +``` + +- [ ] **Step 2: Run to confirm the test fails** + +Run: `npx vitest test/client/stdio.test.ts --run` +Expected: FAIL — the uncaught throw from `append()` crashes. + +- [ ] **Step 3: Wrap the stdout data handler in try/catch** + +Change lines 150-153 of `src/client/stdio.ts` from: + +```typescript + this._process.stdout?.on('data', chunk => { + this._readBuffer.append(chunk); + this.processReadBuffer(); + }); +``` + +to: + +```typescript + this._process.stdout?.on('data', chunk => { + try { + this._readBuffer.append(chunk); + this.processReadBuffer(); + } catch (error) { + this.onerror?.(error as Error); + this.close().catch(() => {}); + } + }); +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +Run: `npx vitest test/client/stdio.test.ts --run` +Expected: All tests PASS. + +- [ ] **Step 5: Suggest commit** + +```bash +git add src/client/stdio.ts test/client/stdio.test.ts +git commit -m "fix(client): catch ReadBuffer overflow in StdioClientTransport + +Prevents an uncaught exception when ReadBuffer.append() throws due to +exceeding the max buffer size. Routes the error to onerror and closes +the transport. + +Ref: GHSA-wqgc-pwpr-pq7r" +``` + +--- + +### Task 4: Full verification + +- [ ] **Step 1: Run typecheck** + +Run: `npm run typecheck` +Expected: No errors. + +- [ ] **Step 2: Run full test suite** + +Run: `npm test` +Expected: All tests PASS. + +- [ ] **Step 3: Run lint** + +Run: `npm run lint` +Expected: No errors (or fix with `npm run lint:fix`). diff --git a/docs/superpowers/plans/2026-06-23-sdk-shared-package.md b/docs/superpowers/plans/2026-06-23-sdk-shared-package.md new file mode 100644 index 0000000000..c148328705 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-sdk-shared-package.md @@ -0,0 +1,716 @@ +# `@modelcontextprotocol/sdk-shared` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract the canonical MCP spec data model (Zod schemas + derived TS types + protocol constants) into a new publishable package `@modelcontextprotocol/sdk-shared`, so v1→v2 schema-validation migration becomes a mechanical import-path swap with `.parse`/`.safeParse`/all Zod methods preserved. + +**Architecture:** A new zod-only, runtime-neutral package owns `constants.ts` + `schemas.ts` + `types.ts` (moved from `core`). `core` keeps thin re-export shims at the old paths (churn control); `core/public`, `server`, and `client` re-export the **types** (Zod-free) and continue to expose `specTypeSchemas` unchanged; the raw Zod `*Schema` constants are reachable only from `sdk-shared`. The codemod routes `@modelcontextprotocol/sdk/types.js` → `@modelcontextprotocol/sdk-shared` as a fixed path swap and drops the `specSchemaAccess` rewriting entirely. + +**Tech Stack:** TypeScript (NodeNext, `tsgo` typecheck), Zod v4, tsdown (build, ESM `.mjs`/`.d.mts`), vitest, ts-morph (codemod), changesets (prerelease `alpha` mode), pnpm workspaces. + +## Global Constraints + +- Node engine floor: `>=20`. Package version line: `2.0.0-alpha.2` (match other runtime packages). +- Formatting (Prettier, `.prettierrc.json`): 4-space indent, single quotes, semicolons, **no trailing commas**, print width 140. All new/edited files must satisfy `prettier --check`. +- Source imports use explicit `.js` extensions (NodeNext); sibling `.ts` files import each other as `./x.js`. +- Public API uses **explicit named exports** except `types.ts`, which is the one intentional `export *` (it contains only spec-derived TS types). +- `sdk-shared` must be **runtime-neutral** (no Node builtins) — guarded by a `barrelClean` test. +- `sdk-shared`'s only runtime dependency is `zod` (`catalog:runtimeShared` → `^4.2.0`). No `publishConfig` (root `.npmrc` + changesets `access: public` handle it). +- Never run `git add`/`git commit` (a hook blocks it). At each "Commit" step, **print the suggested commands** for the user to run manually. +- Typecheck per package: `tsgo -p tsconfig.json --noEmit`. Tests: `vitest run` (tests live in `test/**/*.test.ts`, not colocated). + +--- + +## File Structure + +**New package `packages/sdk-shared/`:** +- `package.json`, `tsconfig.json`, `tsdown.config.ts`, `vitest.config.js`, `eslint.config.mjs`, `README.md` +- `src/constants.ts`, `src/schemas.ts`, `src/types.ts` — relocated from `packages/core/src/types/` +- `src/index.ts` — main barrel: types + constants + schemas (everything; first-class Zod) +- `test/barrelClean.test.ts` — runtime-neutrality guard +- The `./types` subpath is served directly by the built `src/types.ts` (types-only; Zod-free) for `core/public` to re-export. + +**Modified in `packages/core/`:** +- `src/types/constants.ts`, `src/types/schemas.ts`, `src/types/types.ts` → become 1-line re-export shims pointing at `sdk-shared` (churn control) +- `src/exports/public/index.ts` → re-point the types `export *` and the constants named-export at `sdk-shared` +- `package.json` → add `@modelcontextprotocol/sdk-shared` dependency +- `src/types/specTypeSchema.ts` → its `import * as schemas from './schemas.js'` keeps working via the shim (no edit needed if shim is in place) + +**Modified in `packages/server/`, `packages/client/`:** +- `package.json` → add `@modelcontextprotocol/sdk-shared` dependency +- `tsdown.config.ts` → add `@modelcontextprotocol/sdk-shared` to `external`; add its `src` path to the dts `paths` so `.d.mts` resolves + +**Modified in `packages/codemod/`:** +- `scripts/generateVersions.ts` → add `sdk-shared` to `PACKAGE_DIRS`; regenerate `src/generated/versions.ts` +- `src/migrations/v1-to-v2/mappings/importMap.ts` → `sdk/types.js` target becomes `@modelcontextprotocol/sdk-shared` +- `src/migrations/v1-to-v2/transforms/index.ts` → remove `specSchemaAccess` from the pipeline +- delete `src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` + `test/v1-to-v2/transforms/specSchemaAccess.test.ts` +- update `test/v1-to-v2/transforms/importPaths.test.ts` and any integration test expecting `specTypeSchemas` output +- `src/bin/batchTest.ts` → add `sdk-shared` to `LOCAL_PACKAGE_DIRS`; add `overrides` so transitive `server→sdk-shared` resolves to the local tarball + +**Modified docs / release:** +- `docs/migration.md`, `docs/migration-SKILL.md` → rewrite spec-schema validation section +- `.changeset/pre.json` → add `sdk-shared` to `initialVersions`; new `.changeset/add-sdk-shared-package.md` + +--- + +## Phase 1 — Create `sdk-shared`, move the spec data model, rewire consumers + +### Task 1.1: Scaffold the empty `sdk-shared` package + +**Files:** +- Create: `packages/sdk-shared/package.json`, `tsconfig.json`, `tsdown.config.ts`, `vitest.config.js`, `eslint.config.mjs`, `README.md`, `src/index.ts` +- Modify: `.changeset/pre.json` +- Create: `.changeset/add-sdk-shared-package.md` + +**Interfaces:** +- Produces: a buildable workspace package `@modelcontextprotocol/sdk-shared` whose `dist/index.mjs` + `dist/index.d.mts` exist. No real exports yet (placeholder). + +- [ ] **Step 1: Create `packages/sdk-shared/package.json`** + +```json +{ + "name": "@modelcontextprotocol/sdk-shared", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Shared types and Zod schemas for the Model Context Protocol TypeScript SDK", + "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", "schemas", "types"], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./types": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs" + } + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "types": ["dist/types.d.mts"] + } + }, + "files": ["dist"], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-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", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} +``` + +- [ ] **Step 2: Create `packages/sdk-shared/tsconfig.json`** + +```json +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { "*": ["./*"] } + } +} +``` + +- [ ] **Step 3: Create `packages/sdk-shared/tsdown.config.ts`** (two entries: main + the types-only subpath) + +```ts +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/index.ts', 'src/types.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + shims: true, + dts: { resolver: 'tsc' } +}); +``` + +- [ ] **Step 4: Create `packages/sdk-shared/vitest.config.js`** + +```js +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; +``` + +- [ ] **Step 5: Create `packages/sdk-shared/eslint.config.mjs`** + +```js +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/sdk-shared' + } + } +]; +``` + +- [ ] **Step 6: Create `packages/sdk-shared/README.md`** + +```md +# @modelcontextprotocol/sdk-shared + +Shared types and Zod schemas for the Model Context Protocol TypeScript SDK. Exposes the canonical MCP spec data model: the Zod `*Schema` constants, their derived TypeScript types, and protocol constants. + +- Import types and Zod schemas from `@modelcontextprotocol/sdk-shared`. +- For library-agnostic (Standard Schema) validation, prefer `specTypeSchemas` from `@modelcontextprotocol/server` / `@modelcontextprotocol/client`. +``` + +- [ ] **Step 7: Create placeholder `packages/sdk-shared/src/index.ts`** + +```ts +// Placeholder — real exports added in Task 1.2. +export const SDK_SHARED_PLACEHOLDER = true; +``` + +- [ ] **Step 8: Register the package in changesets prerelease state** — edit `.changeset/pre.json`, adding this entry to the `initialVersions` object (alphabetical position is fine): + +```json +"@modelcontextprotocol/sdk-shared": "2.0.0-alpha.0" +``` + +- [ ] **Step 9: Create `.changeset/add-sdk-shared-package.md`** + +```md +--- +'@modelcontextprotocol/sdk-shared': minor +--- + +Add @modelcontextprotocol/sdk-shared package: the canonical home for MCP spec Zod schemas, their derived TypeScript types, and protocol constants. +``` + +- [ ] **Step 10: Install + build to verify the scaffold** + +Run: `pnpm install && pnpm --filter @modelcontextprotocol/sdk-shared build` +Expected: install succeeds; build writes `packages/sdk-shared/dist/index.mjs` and `dist/index.d.mts` (and `dist/types.*`). Verify: `ls packages/sdk-shared/dist` shows `index.mjs index.d.mts types.mjs types.d.mts`. + +- [ ] **Step 11: Commit** (print for the user) + +```bash +git add packages/sdk-shared .changeset/pre.json .changeset/add-sdk-shared-package.md +git commit -m "feat(sdk-shared): scaffold empty @modelcontextprotocol/sdk-shared package" +``` + +--- + +### Task 1.2: Relocate the spec data model into `sdk-shared` + +**Files:** +- Move: `packages/core/src/types/constants.ts` → `packages/sdk-shared/src/constants.ts` +- Move: `packages/core/src/types/schemas.ts` → `packages/sdk-shared/src/schemas.ts` +- Move: `packages/core/src/types/types.ts` → `packages/sdk-shared/src/types.ts` +- Modify: `packages/sdk-shared/src/index.ts` + +**Interfaces:** +- Produces: `@modelcontextprotocol/sdk-shared` exports all spec types + all `*Schema` Zod constants + all protocol constants from `.`; `@modelcontextprotocol/sdk-shared/types` exports the spec **types only**. +- Consumes: nothing new (the three files are self-contained — only external import is `zod/v4`). + +- [ ] **Step 1: Move the three files** (preserves content + history) + +```bash +git mv packages/core/src/types/constants.ts packages/sdk-shared/src/constants.ts +git mv packages/core/src/types/schemas.ts packages/sdk-shared/src/schemas.ts +git mv packages/core/src/types/types.ts packages/sdk-shared/src/types.ts +``` + +The internal relative imports between these three files (`./constants.js`, `./types.js`, `./schemas.js`) and `zod/v4` remain valid in the new location — no edits needed inside them. Remove the `⚠️ PUBLIC API` comment header in `types.ts` that references `exports/public/index.ts` only if it is now inaccurate; otherwise leave it. + +- [ ] **Step 2: Write the real `packages/sdk-shared/src/index.ts`** (replace the placeholder) + +```ts +// Canonical MCP spec data model: protocol constants, spec-derived TS types, and the +// Zod *Schema constants. The `.` entry is the first-class public surface (Zod included). +// The types-only `./types` subpath is served by ./types.ts directly (see package.json exports). +export * from './constants.js'; +export * from './types.js'; +export * from './schemas.js'; +``` + +- [ ] **Step 3: Typecheck `sdk-shared` in isolation** + +Run: `pnpm --filter @modelcontextprotocol/sdk-shared typecheck` +Expected: PASS (no errors). If `tsgo` reports a missing import, it means a fourth file was part of the closure — re-check `schemas.ts`/`types.ts`/`constants.ts` imports and move any additional self-contained spec file. + +- [ ] **Step 4: Build `sdk-shared`** + +Run: `pnpm --filter @modelcontextprotocol/sdk-shared build` +Expected: PASS; `dist/index.mjs` now contains the schema runtime values; `dist/types.d.mts` exposes the 178 types. + +- [ ] **Step 5: Commit** (print for the user) + +```bash +git add packages/sdk-shared packages/core/src/types +git commit -m "feat(sdk-shared): move spec constants, schemas, and types into sdk-shared" +``` + +--- + +### Task 1.3: Rewire `core` to consume `sdk-shared` via re-export shims + +**Files:** +- Create (at the old paths): `packages/core/src/types/constants.ts`, `packages/core/src/types/schemas.ts`, `packages/core/src/types/types.ts` — now 1-line re-export shims +- Modify: `packages/core/package.json` (add dependency) +- Modify: `packages/core/src/exports/public/index.ts` (re-point types `export *`) +- Modify: `packages/core/tsconfig.json` (path mapping for `tsgo`, if needed) + +**Interfaces:** +- Consumes: `@modelcontextprotocol/sdk-shared` (`.` and `./types`). +- Produces: `core`'s internal relative imports of `./types.js`/`./schemas.js`/`./constants.js` keep resolving (via shims); `core/public` exports the same public symbols as before (types via `sdk-shared/types`, constants via `sdk-shared`, no schema values), so `server`/`client` surfaces are unchanged and Zod-free. + +- [ ] **Step 1: Add the dependency to `packages/core/package.json`** — add to `dependencies`: + +```json +"@modelcontextprotocol/sdk-shared": "workspace:^" +``` + +- [ ] **Step 2: Create the re-export shims at the old core paths.** `packages/core/src/types/constants.ts`: + +```ts +// Moved to @modelcontextprotocol/sdk-shared. Re-exported here so core's internal +// relative imports (./constants.js) keep resolving without a wide rename. +export * from '@modelcontextprotocol/sdk-shared'; +``` + +`packages/core/src/types/schemas.ts`: + +```ts +// Moved to @modelcontextprotocol/sdk-shared. +export * from '@modelcontextprotocol/sdk-shared'; +``` + +`packages/core/src/types/types.ts`: + +```ts +// Moved to @modelcontextprotocol/sdk-shared (types-only subpath keeps this Zod-free). +export * from '@modelcontextprotocol/sdk-shared/types'; +``` + +(The `schemas.ts` shim re-exports the full surface so `import * as schemas from './schemas.js'` in `specTypeSchema.ts` still finds every `*Schema` value. The `types.ts` shim uses the types-only subpath so anything `export *`-ing it stays Zod-free.) + +- [ ] **Step 3: Re-point the types `export *` in `packages/core/src/exports/public/index.ts`.** The line currently reads `export * from '../../types/types.js';`. It can stay as-is (the shim now forwards to `sdk-shared/types`). **Verify** the constants named-export block (`export { BAGGAGE_META_KEY, … } from '../../types/constants.js';`) still resolves through the `constants.ts` shim. No code change required if shims are in place — confirm in Step 5. + +- [ ] **Step 4: Update `core`'s tsgo path mapping if needed.** If Step 5 typecheck fails to resolve `@modelcontextprotocol/sdk-shared`, add to `packages/core/tsconfig.json` `compilerOptions.paths`: + +```json +"@modelcontextprotocol/sdk-shared": ["./node_modules/@modelcontextprotocol/sdk-shared/src/index.ts"], +"@modelcontextprotocol/sdk-shared/types": ["./node_modules/@modelcontextprotocol/sdk-shared/src/types.ts"] +``` + +- [ ] **Step 5: Reinstall, typecheck, and test core** + +Run: `pnpm install && pnpm --filter @modelcontextprotocol/core typecheck && pnpm --filter @modelcontextprotocol/core test` +Expected: typecheck PASS; all core tests PASS. The key assertion: `specTypeSchemas` still builds (it reads schema values through the `schemas.ts` shim). + +- [ ] **Step 6: Commit** (print for the user) + +```bash +git add packages/core +git commit -m "refactor(core): consume sdk-shared via re-export shims; keep public surface unchanged" +``` + +--- + +### Task 1.4: Wire `server` and `client` to depend on `sdk-shared` (external, not bundled) + +**Files:** +- Modify: `packages/server/package.json`, `packages/client/package.json` (add dependency) +- Modify: `packages/server/tsdown.config.ts`, `packages/client/tsdown.config.ts` (external + dts paths) +- Modify: `packages/server/tsconfig.json`, `packages/client/tsconfig.json` (tsgo path mapping) + +**Interfaces:** +- Consumes: `@modelcontextprotocol/sdk-shared` at runtime (external dependency). +- Produces: `server`/`client` `dist` no longer inlines the schema/type source; their root barrels still re-export the spec **types** and `specTypeSchemas` (Zod-free); `barrelClean` still passes. + +- [ ] **Step 1: Add the dependency** to both `packages/server/package.json` and `packages/client/package.json` `dependencies`: + +```json +"@modelcontextprotocol/sdk-shared": "workspace:^" +``` + +- [ ] **Step 2: Mark it external in `packages/server/tsdown.config.ts`.** Add `'@modelcontextprotocol/sdk-shared'` to the `external` array (create the array if absent — server already has `external: ['@modelcontextprotocol/server/_shims']`): + +```ts + external: ['@modelcontextprotocol/server/_shims', '@modelcontextprotocol/sdk-shared'], +``` + +Add its source path to the dts `compilerOptions.paths` block so `.d.mts` generation resolves the external types: + +```ts + '@modelcontextprotocol/sdk-shared': ['../sdk-shared/src/index.ts'], + '@modelcontextprotocol/sdk-shared/types': ['../sdk-shared/src/types.ts'], +``` + +- [ ] **Step 3: Do the same in `packages/client/tsdown.config.ts`** (add `'@modelcontextprotocol/sdk-shared'` to `external`, and the two `paths` entries to the dts block). + +- [ ] **Step 4: Add tsgo path mapping** to `packages/server/tsconfig.json` and `packages/client/tsconfig.json` `compilerOptions.paths` (mirroring how they map `@modelcontextprotocol/core`): + +```json +"@modelcontextprotocol/sdk-shared": ["./node_modules/@modelcontextprotocol/sdk-shared/src/index.ts"], +"@modelcontextprotocol/sdk-shared/types": ["./node_modules/@modelcontextprotocol/sdk-shared/src/types.ts"] +``` + +- [ ] **Step 5: Reinstall, build, typecheck, test both packages** + +Run: `pnpm install && pnpm --filter @modelcontextprotocol/server --filter @modelcontextprotocol/client build && pnpm --filter @modelcontextprotocol/server --filter @modelcontextprotocol/client typecheck && pnpm --filter @modelcontextprotocol/server --filter @modelcontextprotocol/client test` +Expected: all PASS, including `barrelClean.test.ts`. + +- [ ] **Step 6: Verify `sdk-shared` is external in the build output** (not inlined) + +Run: `grep -c "@modelcontextprotocol/sdk-shared" packages/server/dist/index.mjs` +Expected: ≥ 1 (an `import ... from "@modelcontextprotocol/sdk-shared..."` line — proving it's referenced as an external dependency, not bundled). And the spec schema source is NOT inlined: `grep -c "z.object" packages/server/dist/index.mjs` should be markedly lower than before the change (spot-check; not a hard gate). + +- [ ] **Step 7: Full repo gate** + +Run: `pnpm typecheck:all && pnpm test:all` +Expected: all PASS. This confirms the move didn't break any sibling package. + +- [ ] **Step 8: Commit** (print for the user) + +```bash +git add packages/server packages/client +git commit -m "refactor(server,client): depend on sdk-shared as an external dependency" +``` + +--- + +## Phase 2 — Codemod: route `types.js` → `sdk-shared`, drop `specSchemaAccess` + +### Task 2.1: Register `sdk-shared` in the codemod version map + +**Files:** +- Modify: `packages/codemod/scripts/generateVersions.ts` +- Regenerate: `packages/codemod/src/generated/versions.ts` + +**Interfaces:** +- Produces: `V2_PACKAGE_VERSIONS` includes `@modelcontextprotocol/sdk-shared`, so `updatePackageJson` is allowed to add it to a consumer's deps. + +- [ ] **Step 1: Add `sdk-shared` to `PACKAGE_DIRS`** in `packages/codemod/scripts/generateVersions.ts`: + +```ts +const PACKAGE_DIRS: Record = { + '@modelcontextprotocol/client': 'client', + '@modelcontextprotocol/server': 'server', + '@modelcontextprotocol/node': 'middleware/node', + '@modelcontextprotocol/express': 'middleware/express', + '@modelcontextprotocol/server-legacy': 'server-legacy', + '@modelcontextprotocol/sdk-shared': 'sdk-shared' +}; +``` + +- [ ] **Step 2: Regenerate** + +Run: `pnpm --filter @modelcontextprotocol/codemod generate:versions` +Expected: `src/generated/versions.ts` now contains `'@modelcontextprotocol/sdk-shared': '^2.0.0-alpha.2'`. Verify: `grep sdk-shared packages/codemod/src/generated/versions.ts`. + +- [ ] **Step 3: Commit** (print for the user) + +```bash +git add packages/codemod/scripts/generateVersions.ts packages/codemod/src/generated/versions.ts +git commit -m "feat(codemod): register sdk-shared in V2_PACKAGE_VERSIONS" +``` + +--- + +### Task 2.2: Route `sdk/types.js` to `sdk-shared` (TDD) + +**Files:** +- Test: `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` +- Modify: `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts` + +**Interfaces:** +- Consumes: `lookupImportMapping` (already extension-tolerant from prior work). +- Produces: any import from `@modelcontextprotocol/sdk/types.js` or `@modelcontextprotocol/sdk/types` is rewritten to `@modelcontextprotocol/sdk-shared` (fixed target, no context resolution), names preserved, and `@modelcontextprotocol/sdk-shared` is added to `usedPackages`. + +- [ ] **Step 1: Write the failing test** — add to `importPaths.test.ts` inside the `describe('import-paths transform', …)` block. Also covers that schema-value imports keep their names (no `specTypeSchemas` rewrite): + +```ts +it('routes sdk/types.js to @modelcontextprotocol/sdk-shared (types + schemas, fixed target)', () => { + const input = [ + `import { CallToolResult, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + const output = sourceFile.getFullText(); + expect(output).toContain(`from "@modelcontextprotocol/sdk-shared"`); + expect(output).toContain('CallToolResult'); + expect(output).toContain('CallToolResultSchema'); + expect(output).not.toContain('@modelcontextprotocol/sdk/types'); + expect(output).not.toContain('specTypeSchemas'); + expect(result.usedPackages?.has('@modelcontextprotocol/sdk-shared')).toBe(true); +}); +``` + +- [ ] **Step 2: Run it; verify it fails** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- importPaths -t "sdk-shared (types + schemas"` +Expected: FAIL — current output routes to `@modelcontextprotocol/server` (RESOLVE_BY_CONTEXT), so the `sdk-shared` assertion fails. + +- [ ] **Step 3: Change the `types.js` mapping** in `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts`. Replace the existing entry: + +```ts + '@modelcontextprotocol/sdk/types.js': { + target: '@modelcontextprotocol/sdk-shared', + status: 'moved', + renamedSymbols: { + ResourceTemplate: 'ResourceTemplateType' + } + }, +``` + +(Only this entry changes from `RESOLVE_BY_CONTEXT` to the fixed `@modelcontextprotocol/sdk-shared` target. Leave `shared/protocol.js`, `shared/transport.js`, `inMemory.js`, etc. as `RESOLVE_BY_CONTEXT`.) + +- [ ] **Step 4: Run the test; verify it passes** + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- importPaths -t "sdk-shared (types + schemas"` +Expected: PASS. + +- [ ] **Step 5: Update the now-obsolete `types.js` context tests.** The existing tests `resolves sdk/types.js based on sibling client imports`, `resolves sdk/types.js based on sibling server imports`, and the extensionless `resolves extensionless sdk/types …` tests now expect `@modelcontextprotocol/sdk-shared` instead of `@modelcontextprotocol/client`/`/server`. Update each assertion to `expect(result).toContain('@modelcontextprotocol/sdk-shared')` (drop the client/server expectations for the `types`-only cases). Re-run the full file: + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- importPaths` +Expected: all PASS. + +- [ ] **Step 6: Commit** (print for the user) + +```bash +git add packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +git commit -m "feat(codemod): route sdk/types.js to @modelcontextprotocol/sdk-shared" +``` + +--- + +### Task 2.3: Remove the `specSchemaAccess` transform + +**Files:** +- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/index.ts` +- Delete: `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` +- Delete: `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` +- Modify: codemod integration tests that assert `specTypeSchemas` output (e.g. `test/integration.test.ts`) + +**Interfaces:** +- Produces: `*Schema` value usages (`.parse`, `.safeParse`, `.extend`, …) pass through untouched — they ride the `types.js → sdk-shared` path swap with names intact. + +- [ ] **Step 1: Write a failing pass-through test** in `importPaths.test.ts` (or a new `test/v1-to-v2/passthrough.test.ts` running the full migration) asserting `.parse()` survives. Minimal transform-level version: + +```ts +it('leaves *Schema runtime usage (.parse) untouched after routing to sdk-shared', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const x = CallToolResultSchema.parse(value);`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + importPathsTransform.apply(sourceFile, { projectType: 'server' }); + const output = sourceFile.getFullText(); + expect(output).toContain('CallToolResultSchema.parse(value)'); + expect(output).not.toContain('specTypeSchemas'); + expect(output).not.toContain("['~standard']"); +}); +``` + +- [ ] **Step 2: Run it; verify current behavior** — with `specSchemaAccess` still in the pipeline this transform-only test on `importPathsTransform` already passes (specSchemaAccess is a separate transform). To see the regression the removal prevents, run the FULL migration in this test instead by importing and applying every transform in order. Confirm that BEFORE removal the full-migration output contains `specTypeSchemas` (FAIL of the `not.toContain` assertion), proving `specSchemaAccess` is what rewrites it. + +Run: `pnpm --filter @modelcontextprotocol/codemod test -- passthrough` +Expected: FAIL on `not.toContain('specTypeSchemas')` (full-migration variant). + +- [ ] **Step 3: Remove `specSchemaAccess` from the pipeline** in `packages/codemod/src/migrations/v1-to-v2/transforms/index.ts` — delete its import and its entry in the exported transforms array. + +- [ ] **Step 4: Delete the transform + its unit test** + +```bash +git rm packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts +git rm packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts +``` + +- [ ] **Step 5: Update integration tests.** Search for residual expectations and fix them: + +Run: `grep -rn "specTypeSchemas\|~standard\|specSchemaAccess" packages/codemod/test packages/codemod/src/migrations` +Expected after fixes: only legitimate references remain (none asserting the codemod *produces* `specTypeSchemas`). Update `test/integration.test.ts` cases that expected `.parse`→`validate` rewrites to instead expect the schema usage unchanged. + +- [ ] **Step 6: Run the full codemod suite** + +Run: `pnpm --filter @modelcontextprotocol/codemod test` +Expected: all PASS. + +- [ ] **Step 7: Typecheck + lint the codemod** (catches the dangling `specSchemaAccess` import and any unused `specSchemaMap` reference) + +Run: `pnpm --filter @modelcontextprotocol/codemod check` +Expected: PASS. If `src/generated/specSchemaMap.ts` / `scripts/generateSpecSchemaMap.ts` are now unused, remove them and the `generate:spec-schemas` prebuild step; otherwise leave them. + +- [ ] **Step 8: Commit** (print for the user) + +```bash +git add packages/codemod +git commit -m "refactor(codemod): drop specSchemaAccess; schema usage migrates by path swap" +``` + +--- + +## Phase 3 — Batch-test validation + docs + +### Task 3.1: Teach the batch test about `sdk-shared` and re-validate firebase-tools + +**Files:** +- Modify: `packages/codemod/src/bin/batchTest.ts` + +**Interfaces:** +- Consumes: the packed local tarballs. +- Produces: the batch test packs `sdk-shared` and forces the transitive `server`/`client` → `sdk-shared` edge to resolve to the local tarball. + +- [ ] **Step 1: Add `sdk-shared` to `LOCAL_PACKAGE_DIRS`** in `packages/codemod/src/bin/batchTest.ts`: + +```ts + '@modelcontextprotocol/sdk-shared': path.join(SDK_ROOT, 'packages/sdk-shared'), +``` + +- [ ] **Step 2: Force transitive resolution via `overrides`.** In `rewriteToLocalTarballs` (or right after it), ensure the consumer `package.json` gets an `overrides` map pinning `@modelcontextprotocol/sdk-shared` (and the other v2 packages) to their local tarball paths, so `server`'s own `^2.0.0-alpha.2` dependency on `sdk-shared` resolves locally. Add, after the dependency rewrite loop: + +```ts + // npm/pnpm: pin transitive @modelcontextprotocol/* (e.g. server -> sdk-shared) to local tarballs. + const overrides = (pkgJson.overrides as Record | undefined) ?? {}; + for (const [name, tarballPath] of Object.entries(tarballs)) { + overrides[name] = `file:${tarballPath}`; + } + pkgJson.overrides = overrides; + rewrites++; // ensure the file is written +``` + +(If the manifest's package manager is pnpm, the equivalent key is `pnpm.overrides`; firebase-tools uses npm, so top-level `overrides` is correct. Generalize only if a pnpm repo is added.) + +- [ ] **Step 3: Rebuild SDK packages and re-run the batch test** + +Run: `pnpm build:all && pnpm --filter @modelcontextprotocol/codemod batch-test` +Expected: completes; `packages/codemod/batch-test/results/summary.json` shows `firebase/firebase-tools` with `newErrors.typecheck: 0`. + +- [ ] **Step 4: Confirm the win in the report** + +Run: `node -e "const r=require('./packages/codemod/batch-test/results/firebase_firebase-tools/report.json');const p=r.packages[0];console.log('post typecheck exit:',p.postCodemod.typecheck.exitCode);console.log('Unknown SDK import path diags:',p.codemod.diagnostics.filter(d=>d.message.includes('Unknown SDK import path')).length);console.log('project-type diags:',p.codemod.diagnostics.filter(d=>d.message.includes('Could not determine project type')).length);"` +Expected: `post typecheck exit: 0`; `Unknown SDK import path diags: 0`; `project-type diags: 0`. Spot-check `repos/firebase_firebase-tools/src/mcp/onemcp/onemcp_server.ts` — the `.parse()` calls are intact and import `*Schema` from `@modelcontextprotocol/sdk-shared`. + +- [ ] **Step 5: Commit** (print for the user) + +```bash +git add packages/codemod/src/bin/batchTest.ts +git commit -m "test(codemod): pack sdk-shared and pin transitive deps in batch test" +``` + +--- + +### Task 3.2: Migration docs + finalize + +**Files:** +- Modify: `docs/migration.md`, `docs/migration-SKILL.md` + +**Interfaces:** none (docs only). + +- [ ] **Step 1: Rewrite the spec-schema validation section in `docs/migration.md`.** Replace the `CallToolResultSchema` → `specTypeSchemas.X['~standard'].validate()` guidance (around the section found by `grep -n "specTypeSchemas\|CallToolResultSchema" docs/migration.md`) with: + +```md +### Schema validation (`*Schema.parse` / `.safeParse`) + +The Zod schema constants moved to `@modelcontextprotocol/sdk-shared`. Update the import path; the schemas are unchanged Zod schemas, so `.parse()`, `.safeParse()`, `.extend()`, etc. keep working. + +```ts +// v1 +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +// v2 +import { CallToolResultSchema } from '@modelcontextprotocol/sdk-shared'; + +const result = CallToolResultSchema.parse(value); // unchanged +``` + +For library-agnostic (Standard Schema) validation that does not couple your code to Zod, use `specTypeSchemas` from `@modelcontextprotocol/server` or `@modelcontextprotocol/client` instead: + +```ts +import { specTypeSchemas } from '@modelcontextprotocol/server'; +const r = specTypeSchemas.CallToolResult['~standard'].validate(value); // { value, issues } +``` +``` + +- [ ] **Step 2: Update `docs/migration-SKILL.md`** — replace the mapping-table rows that map `Schema.parse(value)` → `specTypeSchemas.['~standard'].validate(value)` with a row mapping the **import path**: `import … from '@modelcontextprotocol/sdk/types.js'` → `import … from '@modelcontextprotocol/sdk-shared'` (schemas and types), and note that `.parse`/`.safeParse` are unchanged. Keep the `specTypeSchemas` row as the optional library-agnostic alternative. + +- [ ] **Step 3: Sync snippets + docs check** + +Run: `pnpm sync:snippets && pnpm run docs:check` +Expected: PASS (or no changes). Fix any snippet drift. + +- [ ] **Step 4: Final full-repo gate** + +Run: `pnpm check:all && pnpm test:all` +Expected: all PASS. + +- [ ] **Step 5: Commit** (print for the user) + +```bash +git add docs/migration.md docs/migration-SKILL.md +git commit -m "docs(migration): schemas import from @modelcontextprotocol/sdk-shared" +``` + +--- + +## Self-Review + +**Spec coverage:** package creation (1.1), spec types + Zod schema move (1.2), first-class Zod positioning / no nudge (codemod has no nudge; docs present both — 2.2/2.3/3.2), regular `dependency` model (1.3/1.4), external-not-bundled (1.4), types-only re-export keeping the surface Zod-free (1.2 `./types` + 1.3 shim), `specTypeSchemas` unchanged (1.3), churn-limiting shims (1.3), codemod path swap (2.2), drop `specSchemaAccess` (2.3), batch-test wiring + 0-error validation (3.1), docs + changeset (1.1/3.2). PR #2277 supersession is covered by the `specSchemaAccess` removal (no `specTypeSchemas` rewrite produced). All spec sections map to a task. + +**Placeholder scan:** no `TBD`/`TODO`; the one conditional (`tsconfig paths` in 1.3 Step 4 / 1.4 Step 4) is gated on a concrete typecheck failure with the exact lines to add. Move tasks specify exact `git mv` targets rather than reproducing the 2346-line `schemas.ts` (relocation, not authoring). + +**Type/name consistency:** `@modelcontextprotocol/sdk-shared` used verbatim throughout; `./types` subpath defined in 1.1 (package.json exports + typesVersions), produced in 1.2 (built from `src/types.ts`), consumed in 1.3 (core `types.ts` shim) and 1.4 (server/client dts paths); `lookupImportMapping` (2.2) matches the existing helper; `LOCAL_PACKAGE_DIRS`/`rewriteToLocalTarballs`/`tarballs` (3.1) match `batchTest.ts`. + +## Execution Handoff + +Two execution options: + +1. **Subagent-Driven (recommended)** — a fresh subagent per task, with review between tasks. +2. **Inline Execution** — execute tasks in this session with checkpoints. + +Note: every "Commit" step prints commands for **you** to run (the `git add`/`git commit` hook blocks the agent from committing). diff --git a/docs/superpowers/specs/2026-05-11-codemod-batch-test-design.md b/docs/superpowers/specs/2026-05-11-codemod-batch-test-design.md new file mode 100644 index 0000000000..03709e94e2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-codemod-batch-test-design.md @@ -0,0 +1,288 @@ +# Codemod Batch Test: Design Spec + +Repeatable process for running the MCP v1-to-v2 codemod against real-world repos, identifying issues, and iterating on the codemod. + +## Goal + +Improve the codemod by testing it against 10-15 curated external repos. Each iteration: run the codemod, compare baseline vs. post-codemod check results, have Claude categorize failures, fix the codemod, repeat. + +## System Overview + +Three components, all living in `packages/codemod/batch-test/`: + +1. **Repo manifest** (`repos.json`) -- JSON file listing target repos, their structure, and optional overrides. +2. **Batch runner** (`run-codemod-batch.sh`) -- Shell script that iterates the manifest: clones, installs, baselines, codemods, re-checks, writes structured output. +3. **Analysis prompt** (`analyze-prompt.md`) -- Instructions for Claude Code to run the script and analyze results in a single session. + +### Data Flow + +``` +repos.json --> run-codemod-batch.sh --> results//report.json (per-repo) + --> results/summary.json (consolidated) + +Claude Code: runs script, reads results, produces categorized analysis +``` + +## Repo Manifest (`repos.json`) + +An array of repo entries. Each entry represents a GitHub repo and one or more packages within it that use `@modelcontextprotocol/sdk` v1. + +```json +[ + { + "repo": "owner/repo-name", + "ref": "main", + "packages": [ + { + "dir": "packages/mcp-server", + "sourceDir": "src", + "checks": { + "typecheck": "npm run check:ts", + "test": null + } + } + ] + } +] +``` + +### Fields + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `repo` | yes | -- | GitHub `owner/name` | +| `ref` | no | `main` | Branch or tag to check out | +| `packages` | no | `[{ "dir": ".", "sourceDir": "src" }]` | Package targets within the repo | +| `packages[].dir` | yes | -- | Path to the package root (where its `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 key to `null` to skip that check | + +### Auto-Detection Rules + +**Package manager** (first lockfile found at repo root): +- `pnpm-lock.yaml` -> `pnpm` +- `yarn.lock` -> `yarn` +- `package-lock.json` -> `npm` +- `bun.lockb` -> `bun` + +**Check commands** (read `scripts` from the package's `package.json`, first match wins): + +| Check | Script names probed (in order) | Fallback | +|-------|-------------------------------|----------| +| typecheck | `typecheck`, `type-check`, `check:types`, `tsc` | `npx tsc --noEmit` | +| build | `build`, `compile` | skip | +| test | `test`, `test:unit`, `test:all` | skip | +| lint | `lint`, `lint:check` | skip | + +The detected command runs as ` run `. + +## Batch Runner (`run-codemod-batch.sh`) + +### CLI + +```bash +./run-codemod-batch.sh [--manifest repos.json] [--output-dir ./results] [--clone-dir ./repos] [--fresh-clones] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--manifest` | `./repos.json` | Path to repo manifest | +| `--output-dir` | `./results` | Where to write reports | +| `--clone-dir` | `./repos` | Where to clone repos | +| `--fresh-clones` | off | Force re-clone even if clone exists | + +Clones are kept between runs by default for fast iteration. + +### Per-Repo Flow + +``` +1. CLONE OR RESET + - If clone exists: git restore . && git clean -fd + - If no clone: git clone --depth 1 --branch + +2. DETECT PACKAGE MANAGER + - Check for lockfile at repo root + +3. INSTALL + - cd && install + - If install fails: record error, skip to next repo + +4. BASELINE CHECKS (for each package) + - Auto-detect or use override check commands + - Run: typecheck, build, test, lint + - Capture: exit code, stdout, stderr for each + +5. RUN CODEMOD (for each package) + - node /packages/codemod/dist/cli.mjs v1-to-v2 \ + // --verbose + - Capture: full output, diagnostics, change count + +6. RE-INSTALL + - cd && install + - Picks up new v2 deps from updated package.json files + +7. POST-CODEMOD CHECKS (for each package) + - Same checks as step 4, captured separately + +8. WRITE REPORT + - Write per-repo JSON to results//report.json + - Append entry to summary +``` + +### Error Handling + +If any step fails for a repo, the script logs the failure, writes what it has to the report, and moves to the next repo. One broken repo does not stop the batch. + +### Path Resolution + +The script resolves `SDK_ROOT` from its own location (`SDK_ROOT=$(cd "$(dirname "$0")/../../.." && pwd)`). All default paths (`--clone-dir`, `--output-dir`) are relative to the script's directory (`packages/codemod/batch-test/`). + +### Codemod Binary + +The script always uses the locally-built codemod from the current branch: +``` +node "$SDK_ROOT/packages/codemod/dist/cli.mjs" +``` +This ensures each run tests the current state of the codemod. + +## Output Format + +### Per-Repo Report (`results//report.json`) + +```json +{ + "repo": "user/mcp-server-example", + "ref": "main", + "timestamp": "2026-05-11T14:30:00Z", + "packageManager": "pnpm", + "packages": [ + { + "dir": ".", + "sourceDir": "src", + "codemod": { + "filesChanged": 12, + "totalChanges": 47, + "diagnostics": [ + { + "level": "warning", + "file": "src/server.ts", + "line": 42, + "message": "Destructuring pattern for 'extra' -- review manually", + "transformId": "context" + } + ] + }, + "baseline": { + "typecheck": { "exitCode": 0, "stdout": "", "stderr": "" }, + "build": { "exitCode": 0, "stdout": "", "stderr": "" }, + "test": { "exitCode": 0, "stdout": "", "stderr": "" }, + "lint": { "exitCode": 0, "stdout": "", "stderr": "" } + }, + "postCodemod": { + "typecheck": { "exitCode": 2, "stdout": "", "stderr": "src/handler.ts(15,3): error TS2345: ..." }, + "build": { "exitCode": 2, "stdout": "", "stderr": "..." }, + "test": { "exitCode": 0, "stdout": "", "stderr": "" }, + "lint": { "exitCode": 0, "stdout": "", "stderr": "" } + } + } + ] +} +``` + +### Consolidated Summary (`results/summary.json`) + +```json +{ + "timestamp": "2026-05-11T14:30:00Z", + "codemodVersion": "2.0.0-alpha.0", + "codemodCommit": "abc1234", + "totalRepos": 12, + "totalPackages": 15, + "results": [ + { + "repo": "user/mcp-server-example", + "package": ".", + "baselineClean": true, + "postCodemodClean": false, + "newErrors": { "typecheck": 3, "build": 1, "test": 0, "lint": 0 }, + "codemodDiagnostics": { "warning": 2, "error": 0, "info": 1 } + } + ], + "aggregated": { + "reposClean": 7, + "reposWithNewErrors": 5, + "totalNewTypecheckErrors": 18, + "totalCodemodWarnings": 12, + "topErrorPatterns": ["TS2345", "TS2339", "TS2554"] + } +} +``` + +## Claude Analysis Workflow + +### Prompt (`analyze-prompt.md`) + +Saved in `packages/codemod/batch-test/analyze-prompt.md`. You tell Claude Code to follow these instructions: + +``` +Run the batch codemod test and analyze results: + +1. Build the codemod: + pnpm --filter @modelcontextprotocol/codemod build + +2. Run the batch test: + ./packages/codemod/batch-test/run-codemod-batch.sh + +3. Read results/summary.json for the overview. + +4. For each repo with new errors, read its results//report.json. + +5. Categorize each new error (present in postCodemod but not in baseline): + - codemod-bug: The transform produced incorrect output + - missing-transform: The codemod should handle this pattern but doesn't + - manual-migration: Expected -- documented in migration guide, needs human judgment + - repo-specific: Unusual pattern unique to this repo, not worth handling + +6. Produce findings grouped by category with: + - Repo, file, line, error message + - Root cause (one sentence) + - For codemod-bug/missing-transform: which transform to fix and what correct output looks like + +7. Produce a "Priority Fixes" list: top 3-5 codemod improvements sorted by impact + (number of repos affected). +``` + +### Iteration Loop + +``` +1. Fix a codemod transform +2. Tell Claude: "Re-run the batch test and analyze" + --> Claude rebuilds codemod, resets clones, re-runs, reads results, analyzes +3. Review Claude's findings +4. Go to 1 +``` + +## Error Categorization Reference + +| Category | Meaning | Action | +|----------|---------|--------| +| `codemod-bug` | Transform produced wrong output | Fix the transform | +| `missing-transform` | Pattern not handled | Add handling to existing transform or create new one | +| `manual-migration` | Requires human judgment (removed API, architectural change) | Ensure migration guide covers it; improve codemod diagnostic | +| `repo-specific` | Unusual pattern unique to one repo | Document but don't add to codemod | + +## File Structure + +``` +packages/codemod/batch-test/ + repos.json # Repo manifest (curated list) + run-codemod-batch.sh # Batch runner script + analyze-prompt.md # Claude analysis instructions + repos/ # Cloned repos (gitignored) + results/ # Output reports (gitignored) + summary.json + / + report.json +``` + +`repos/` and `results/` are added to `.gitignore`. Only the manifest, script, and prompt are committed. diff --git a/docs/superpowers/specs/2026-06-02-readbuffer-max-size-design.md b/docs/superpowers/specs/2026-06-02-readbuffer-max-size-design.md new file mode 100644 index 0000000000..a8e89c3647 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-readbuffer-max-size-design.md @@ -0,0 +1,61 @@ +# ReadBuffer Maximum Size Guard + +**Date:** 2026-06-02 +**Advisory:** GHSA-wqgc-pwpr-pq7r +**Severity:** Low (DoS via stdio transport, local attack surface) + +## Problem + +`ReadBuffer.append()` in `packages/core/src/shared/stdio.ts` concatenates incoming data with no size limit. A malicious MCP server subprocess can write continuous data to stdout without newline delimiters, causing the host process (Claude Desktop, Cursor, VS Code, etc.) to grow memory without bound until OOM-killed. + +The `data` event handlers in both `StdioClientTransport` and `StdioServerTransport` call `append()` outside any try/catch, so a thrown error from `append()` would become an uncaught exception — this must also be addressed. + +## Design + +### 1. ReadBuffer (`packages/core/src/shared/stdio.ts`) + +- Add exported constant `DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024` (10 MB). +- Constructor accepts optional `{ maxBufferSize?: number }` options object. +- `append()` checks `(currentSize + chunk.length) > maxBufferSize` before concatenating. +- On overflow: call `this.clear()` first (leave object in clean state), then throw `Error`. +- Fully backwards compatible — `new ReadBuffer()` with no args uses the default. + +### 2. StdioClientTransport (`packages/client/src/client/stdio.ts`) + +- Wrap the `stdout.on('data')` handler body in try/catch. +- On catch: route error to `this.onerror?.(error)`, then call `this.close()`. + +### 3. StdioServerTransport (`packages/server/src/server/stdio.ts`) + +- Wrap the `_ondata` handler body in try/catch. +- On catch: route error to `this.onerror?.(error)`, then call `this.close()`. + +### 4. Tests (`packages/core/test/shared/stdio.test.ts`) + +- `append()` throws when buffer exceeds default limit. +- `append()` throws with custom `maxBufferSize`. +- Buffer is cleared after overflow (object reusable). +- Default limit can be overridden via constructor. + +### 5. No changes to + +- Public API exports (`ReadBuffer` is already exported; constructor change is additive). +- `processReadBuffer()` in either transport (existing try/catch handles `readMessage()` errors; new try/catch handles `append()` errors at a higher level). + +## Files Modified + +| File | Change | +|------|--------| +| `packages/core/src/shared/stdio.ts` | Add `DEFAULT_MAX_BUFFER_SIZE`, constructor options, size guard in `append()` | +| `packages/client/src/client/stdio.ts` | try/catch in `data` handler, close on overflow | +| `packages/server/src/server/stdio.ts` | try/catch in `_ondata` handler, close on overflow | +| `packages/core/test/shared/stdio.test.ts` | New tests for buffer overflow behavior | + +## Decision Log + +- **10 MB default** chosen because a single JSON-RPC message shouldn't realistically exceed a few MB (even a 7 MB binary base64-encoded is ~9.3 MB). Users with legitimate large messages can raise the cap explicitly. +- **Throw from append()** rather than silent truncation or callback — uses existing error propagation paths and makes the failure visible. +- **Clear before throw** so the ReadBuffer isn't left in a corrupt state. +- **Close transport on overflow** because a buffer overflow means the peer is misbehaving and any partial data is unrecoverable. +- **No chunk-list optimization** — the 10 MB cap bounds the `Buffer.concat()` amplification to ~50 MB worst case, which is acceptable. Chunk-list can be a separate follow-up. +- **Options object** (not bare number) for the constructor parameter, for future extensibility. diff --git a/docs/superpowers/specs/2026-06-08-sep-2549-ttl-design.md b/docs/superpowers/specs/2026-06-08-sep-2549-ttl-design.md new file mode 100644 index 0000000000..f59499ed9c --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-sep-2549-ttl-design.md @@ -0,0 +1,495 @@ +# SEP-2549: TTL for List Results — Design + +**Status:** Draft for review (rev 2 — incorporates backend + software architecture review) +**Date:** 2026-06-08 +**SEP:** [2549 — TTL for List Results](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2549-TTL-for-list-results.mdx) +**Branch:** `feature/v2-SEP-2549-ttl-for-list-results` + +## Context + +MCP clients currently discover changes to a server's tools/prompts/resources only via `list_changed` notifications, which require a long-lived SSE stream. Many HTTP clients and servers cannot reliably hold such streams, and there is no signal for how often a list actually changes. SEP-2549 adds two freshness fields — `ttlMs` and `cacheScope` — to the five cacheable result types so a server can tell clients how long a response stays fresh and who may cache it. This works alongside notifications (not as a replacement) and is fully backward-compatible with pre-2549 servers. + +The upstream draft spec (`schema/draft/schema.ts`) already defines these types. This work brings the TypeScript SDK to parity and — critically — makes the SDK actually *use* the hints: a client SDK that only emits the wire fields satisfies nothing of value, because `client.listTools()` would still refetch every time and discard the TTL. The deliverable therefore spans the wire types, server emission, a client-side cache, polling helpers, and a shared (multi-tenant) cache. + +### Scope decision + +Full implementation, all acceptance criteria. Confirmed during brainstorming: +- **Layer 5 (shared multi-tenant cache, R-2549-7): in scope** — ship it now. +- **Client cache enablement: `ClientOptions` flag** — cache logic lives in `Client`, off by default for backward compatibility. + +### Delivery: two PRs + +This design is implemented and reviewed as **two independent PRs**, because the API surface and risk profiles are very different: + +- **PR 1 — wire + server emission (Layers 1–2).** Low-risk, independently valuable, satisfies R-2549-1/3/8/12/13. Adds the spec-parity types and McpServer emission config. Ships first. +- **PR 2 — client cache, polling, shared store (Layers 3–5).** Carries all the contested surface (read-through caching, invalidation, multi-tenant isolation, polling). Built on top of PR 1, reviewed on its own. + +Both PRs are described here as one design for coherence; the File Summary marks which PR each file belongs to. + +--- + +## The two safety invariants + +Two invariants are load-bearing for the entire feature and every layer below depends on them. They are stated once here and tested explicitly. + +> **Invariant A — `ttlMs: 0` is never cached.** +> An entry with `ttlMs === 0` is never stored, never returned as fresh, and never shared across principals. This is what makes the feature backward-compatible (pre-2549 servers normalize to `ttlMs: 0` ⇒ behave exactly like today) **and** what makes a `cacheScope: 'public'` *default* safe at the wire layer (a defaulted-public entry with `ttlMs: 0` can never be served from any cache, shared or not). + +> **Invariant B — only *explicitly-declared* `cacheScope: 'public'` may be shared.** +> A shared cache (Layer 5) shares an entry across principals **only if the server explicitly sent `cacheScope: 'public'` on the wire.** An absent `cacheScope` — even though it normalizes to `'public'` for wire-type parity — is treated by the cache as *unknown ⇒ private ⇒ never shared.* This resolves the SEP's internal contradiction (see below) in the fail-safe direction: a misclassification costs at most a cache miss, never a cross-tenant data leak. + +### The SEP contradiction these invariants resolve + +The SEP is internally contradictory about the default for an absent `cacheScope`: + +- The `CacheableResult` JSDoc says: *"Defaults to `"public"` if absent."* +- The Backward Compatibility section says the opposite, and explains why: *"`cacheScope` is required because there is no safe default for older servers. The server must explicitly declare the intended cache scope to prevent unintended caching of user-specific data."* It calls out `resources/read` as user-specific (private). + +We honor **both** by separating two concerns the first draft conflated: + +1. **Wire-type normalization (Layer 1):** absent `cacheScope` → `'public'` *as a type-level default only*, so the SDK output type has the required field the spec demands (parity). This default value is never, by itself, an authorization to share — Invariant A guarantees a defaulted entry (which also has `ttlMs: 0` unless the server set a TTL) cannot be cached. +2. **Caching authorization (Layers 3 & 5):** the cache tracks whether `cacheScope` was *explicitly present on the wire* (`scopeExplicit`) and shares cross-principal only when it was explicitly `'public'` (Invariant B). + +--- + +## Acceptance Criteria → Layer Map + +| ID | Requirement | Layer | PR | +|----|-------------|-------|----| +| R-2549-1 (wire) | Server MUST include `ttlMs` (≥0) and `cacheScope` on the 5 result types | 1 + 2 | 1 | +| R-2549-3 (guidance) | Per-page `ttlMs`; freshness lives on the *result*, not on `Tool`/`Resource` | 1 | 1 | +| R-2549-12 | Absent `ttlMs` → treat as 0 (BC with pre-2549 servers) | 1 | 1 | +| R-2549-13 | Negative `ttlMs` → treat as 0 | 1 | 1 | +| R-2549-8 | Same `cacheScope` on every page of a paginated response | 2 | 1 | +| R-2549-2 | Client SHOULD refetch on next access after `ttlMs` expires; MAY serve stale on refetch error | 3 | 2 | +| R-2549-11 | `list_changed` / `resources/updated` invalidates the cache regardless of remaining TTL | 3 | 2 | +| R-2549-14 | Cursor invalid (`-32602` on next-page fetch) → discard cached pages, refetch from page 1 | 3 | 2 | +| R-2549-10 (sdk) | Polling helpers MUST apply jitter + backoff | 4 | 2 | +| R-2549-7 (security) | Shared caches MUST NOT serve `private` entries to a different user (key on auth principal) | 5 | 2 | +| R-2549-4 | `list_changed` notifications still delivered if subscribed — TTL is a hint, not a replacement | (asserted by test) | 2 | + +## Architecture Overview + +``` +┌─ Layer 1: Wire types (core/types) ──────────────────────────────────┐ +│ CacheableResult { ttlMs: number; cacheScope: 'public'|'private' } │ +│ → spread into 5 result schemas; .default() normalization only │ +│ → normalizeCacheable() helper does clamp/floor + records scopeExplicit│ +└───────────────────────────────────────────────────────────────────────┘ + │ emitted by │ consumed by + ▼ ▼ +┌─ Layer 2: Server (server/mcp) ─┐ ┌─ Layer 3: Client cache (client) ──┐ +│ McpServerOptions.cache (hints) │ │ ListCacheStore (pluggable) │ +│ injects ttlMs/cacheScope into │ │ + InMemoryListCacheStore (default)│ +│ list & read results │ │ freshness, invalidation, cursor │ +└─────────────────────────────────┘ └────────────────────────────────────┘ + │ used by │ impl + ▼ ▼ + ┌─ Layer 4: pollList ─┐ ┌─ Layer 5: SharedListCacheStore ┐ + │ jitter + backoff │ │ shares only explicit-public; │ + │ (opt-in) │ │ private namespaced by principal│ + └──────────────────────┘ └────────────────────────────────┘ +``` + +--- + +## Layer 1 — Wire Types + +**Files:** `packages/core/src/types/schemas.ts`, `packages/core/src/types/types.ts`, `packages/core/src/types/spec.types.ts` (regenerated), `packages/core/test/spec.types.test.ts`. Public export of the two new **types** rides the one sanctioned `export *` from `types.ts` (see *Type exports* below — this is the only wildcard; all package-barrel symbols are explicit). + +### Spec parity is the hard constraint + +`packages/core/test/spec.types.test.ts` enforces, for every type, **bidirectional assignability** (`sdk = spec; spec = sdk`) and **exact key parity** (`AssertExactKeys`), operating on `z.output` (`Infer`). The upstream spec defines: + +```typescript +export interface CacheableResult extends Result { + ttlMs: number; // REQUIRED + cacheScope: "public" | "private"; // REQUIRED +} +export interface ListToolsResult extends PaginatedResult, CacheableResult { tools: Tool[]; } +// ...ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult likewise +export interface ReadResourceResult extends CacheableResult { contents: (...)[]; } +``` + +Because the spec fields are **required**, an `.optional()` Zod field would fail mutual assignability (an `sdk` value with `ttlMs?: number` is not assignable to a spec value requiring `ttlMs: number`). Therefore the SDK output type must have these fields **required**, while still tolerating their absence on the wire (R-2549-12). + +> **Parity covers `z.output` only.** The parity test never exercises `z.input`, so the "tolerates absence on the wire" property (R-2549-12/13) is **not** guarded by parity — it is guarded solely by the schema unit tests below. The doc previously implied parity validated normalization; it does not. + +### Mechanism: `.default()` only (no `.transform()`) + +The first draft used `.default().transform(...)`. We drop the transform for three reasons surfaced in review: (a) a `ZodEffects`/transform field is brittle when spread into objects that may later be `.extend()`/`.pick()`/`.partial()`ed; (b) it makes the normative clamp (negative→0, R-2549-13) invisible inside a field spread; (c) it widens the `z.input`/`z.output` skew more than necessary and runs again on any (future) outbound validation. Clamping moves into one named helper. + +```typescript +export const CacheScopeSchema = z.enum(['public', 'private']); + +// Field spread for the 5 result schemas. Plain .default() — output type is required, +// input is optional. No transform. +const cacheableResultFields = { + ttlMs: z.number().default(0), + cacheScope: CacheScopeSchema.default('public'), +}; +``` + +- `Infer` (z.output) = `{ ttlMs: number; cacheScope: 'public' | 'private' }` → **matches spec exactly** (parity passes). +- z.input allows both omitted → defaults applied at the parse boundary. The SDK validates results on the **receiving (client) side**, so absent `ttlMs` becomes `0` and absent `cacheScope` becomes `'public'` automatically. + +> **Spike the exact Zod v4 form before building Layer 3.** Confirm that `z.number().default(0)` spread into a `looseObject` result schema yields `z.output` with a *required* `number` and passes `AssertExactKeys` against the regenerated spec type. This is a 30-minute compile-only spike; do it first (it was the original Open Risk #1). + +### Normalization + the `scopeExplicit` signal + +Clamp/floor and the explicitness signal live in one helper consumed by the client cache (Layer 3). It runs on the **already-parsed** result *and* inspects the **raw** (pre-parse) payload to learn whether `cacheScope` was actually present on the wire: + +```typescript +// packages/core/src/types/... (exported for client use) +export interface NormalizedCacheMeta { + ttlMs: number; // clamped: negative/NaN/Infinity → 0, floored to int + cacheScope: CacheScope; // parsed value (defaulted to 'public' if absent) + scopeExplicit: boolean; // TRUE only if the raw wire payload contained `cacheScope` +} + +export function normalizeCacheMeta(parsed: CacheableResult, raw: unknown): NormalizedCacheMeta { + const ttlMs = Number.isFinite(parsed.ttlMs) && parsed.ttlMs > 0 ? Math.floor(parsed.ttlMs) : 0; // R-2549-12/13 + const scopeExplicit = typeof raw === 'object' && raw !== null && 'cacheScope' in raw; + return { ttlMs, cacheScope: parsed.cacheScope, scopeExplicit }; +} +``` + +This is the **single source of truth** for normalization. `scopeExplicit` is the signal Invariant B needs and that a bare `.default('public')` would otherwise erase. The client cache stores it on `CachedEntry`; `SharedListCacheStore` reads it to decide shareability. + +Spread `...cacheableResultFields` into the 5 result schemas only: `ListToolsResultSchema`, `ListPromptsResultSchema`, `ListResourcesResultSchema`, `ListResourceTemplatesResultSchema`, `ReadResourceResultSchema`. **Never** added to item schemas (`Tool`, `Resource`, etc.) — freshness lives on the result (R-2549-3). `ListRootsResult`, `PaginatedResult`, and `ResultSchema` are untouched. + +### Type exports & spec test + +- Add `CacheScope` and `CacheableResult` **type** exports in `types.ts`. These become public via the *one intentional* `export * from './types/types.js'` in `core/public` (documented there as the sanctioned wildcard). **All other new symbols** (`ListCacheStore`, `InMemoryListCacheStore`, `SharedListCacheStore`, `CacheHints`, `McpServerOptions`, `pollList`, etc.) live in the client/server packages and MUST be added as **explicit named exports** in `packages/client/src/index.ts` / `packages/server/src/index.ts` — never via `export *`. (The earlier draft's "transitively via `export *`" framing applied *only* to the two core types; do not let it leak into the package barrels.) +- The spec defines `CacheableResult` as a base interface with no standalone schema; the SDK mirrors it as an exported **type**, while the runtime schema is the `cacheableResultFields` spread. The exported type and the spread fields therefore have **no shared schema** and must be hand-kept in sync — call this out as a known manual-sync seam (it mirrors how `PaginatedResult` is handled today). +- `CacheableResult` is marked `@internal` in the upstream spec. We deliberately export it as public SDK API anyway, because it is the natural return-type contract for low-level handler authors (see ADR-001). Note the divergence in the export comment. +- Run `pnpm fetch:spec-types` first — the committed `spec.types.ts` is stale (commit `5c25208…`) and predates these fields. (`spec.types.ts` is `.gitignore`d and regenerated by `pnpm test`.) +- Add a `CacheableResult` entry to `sdkTypeChecks` and a `_K_CacheableResult` key-parity assertion in `spec.types.test.ts`. Update the expected spec-type count (`toHaveLength(176)` → new count). **Verify the `_meta`/index-signature interplay:** `ResultSchema` is a `looseObject` (carries an index signature), so confirm `AssertExactKeys` for `CacheableResult` resolves to exactly `{ ttlMs, cacheScope, _meta? }` and that the loose index signature neither makes the assertion trivially pass nor spuriously fail. + +### Breaking change — see ADR-001 + +Low-level `Server.setRequestHandler('tools/list', …)` handlers (and the other four) now have a return type requiring `ttlMs`/`cacheScope`. This is a **deliberate, recorded** breaking change, not an accident of typing. Rationale, the rejected alternative, and the blast radius are in **ADR-001** below. McpServer injects the fields so high-level authors are unaffected (Layer 2). Documented in `docs/migration.md` + `docs/migration-SKILL.md`. + +--- + +## ADR-001 — Breaking change to low-level `Server` handler return types + +**Status:** Accepted · **Context:** Layer 1/2 · **Decision owners:** SEP-2549 implementers + +**Context.** With `.default()`, the result schemas' `z.output` (= `Infer` = the public `ListToolsResult` type, and the type `ResultTypeMap['tools/list']` against which `setRequestHandler` types a handler's return value) has `ttlMs`/`cacheScope` as **required**. So every low-level handler for the 5 methods must now return both fields or fail to type-check. McpServer's own internal handlers are also consumers of this type and are updated in Layer 2. + +**The alternative considered.** Type handler returns against `z.input` (where `.default()` makes the fields optional) while keeping the public wire type as `z.output`. That would make the change *non-breaking* for low-level authors. + +**Why we reject it.** The protocol does **not** re-validate or re-parse outbound results through the result schema — outbound results are serialized as-is; only the *receiving* side parses (verified in `protocol.ts` request/response path). So `z.input`-typed handlers would put **no** `ttlMs`/`cacheScope` on the wire, silently violating R-2549-1 ("server MUST include"). Making the change non-breaking would require introducing (a) input/output result-type *duality* across `ResultTypeMap`/`InferHandlerResult` and (b) a new outbound-normalization pass that does not exist today — a larger, more invasive change than the break, and one that trades a compile-time error for a *silent* spec violation. + +**Decision.** Keep the breaking change. Surfacing the "server MUST include" obligation as a compile-time error on low-level handlers is the spec-faithful, fail-loud choice. + +**Consequences.** (1) Low-level `Server` users for the 5 methods must add the two fields — migration guide ships the exact two-field snippet and points to `normalizeCacheMeta`/`CacheableResult`. (2) McpServer is itself a consumer and is updated in the same PR. (3) If a future SEP needs non-breaking result-type evolution, the input/output duality is the path — recorded here so it isn't re-litigated. + +--- + +## Layer 2 — Server Emission + +**Files:** `packages/server/src/server/mcp.ts`, `packages/server/src/index.ts`. **(PR 1)** + +```typescript +// Renamed from the draft's `ListCacheConfig`: this is emission config (freshness hints), +// not a cache. Avoids prefix-collision with the client's ListCache* types. +export interface CacheHints { ttlMs?: number; cacheScope?: 'public' | 'private'; } + +export interface McpServerOptions extends ServerOptions { + cache?: { + tools?: CacheHints; + resources?: CacheHints; + resourceTemplates?: CacheHints; + prompts?: CacheHints; + resourceRead?: CacheHints; // hints for resources/read + }; +} +``` + +- `McpServer` constructor widens `options?: ServerOptions` → `McpServerOptions` (backward-compatible) and stores `_cacheOptions`. +- The 4 list handlers spread `{ ttlMs: cfg?.ttlMs ?? 0, cacheScope: cfg?.cacheScope ?? 'public' }` into their results. Because config is static per endpoint, **every page gets the same `cacheScope`** (R-2549-8) for free. +- `resources/read`: callback result is normalized — `ttlMs`/`cacheScope` from the callback win; otherwise fall back to `cache.resourceRead` config, then defaults. The `ReadResourceCallback` return type is loosened so callbacks may omit the fields; McpServer fills them. +- **`resources/read` privacy footgun (documented).** The SEP calls out `resources/read` as the user-specific endpoint. The emission default is `cacheScope: 'public'` only because the paired default `ttlMs: 0` makes it uncacheable (Invariant A). **The migration guide MUST warn:** if you configure a non-zero `ttlMs` on `resourceRead` (or return one from the callback) for user-specific content, you MUST also set `cacheScope: 'private'`, or a downstream shared gateway may cache it. As defense-in-depth, when `cache.resourceRead.ttlMs > 0` is configured but `cacheScope` is omitted, McpServer emits `'private'` (not `'public'`) for `resources/read` specifically. +- **R-2549-8 for low-level servers.** McpServer guarantees same-scope-per-page structurally (static config). A low-level `Server` author paginating by hand could still vary `cacheScope` across pages; this is the author's responsibility per the spec MUST. We document it; we do not add a runtime guard (the SDK does not own the low-level pagination loop). +- Export `CacheHints`, `McpServerOptions` from `@modelcontextprotocol/server` as **explicit named exports**. + +--- + +## Layer 3 — Client-Side List Cache + +**Files:** new `packages/client/src/client/listCache.ts`; modify `packages/client/src/client/client.ts`, `packages/client/src/index.ts`. **(PR 2)** + +### Pluggable store + +```typescript +export interface ListCacheKey { + method: 'tools/list' | 'prompts/list' | 'resources/list' | 'resources/templates/list' | 'resources/read'; + cursor?: string; // pagination position + uri?: string; // for resources/read + principal?: string; // supplied by caller for shared caches (Layer 5); undefined for per-client +} + +export interface CachedEntry { + result: unknown; + receivedAt: number; + ttlMs: number; + cacheScope: CacheScope; + scopeExplicit: boolean; // from normalizeCacheMeta — gates cross-principal sharing (Invariant B) +} + +export interface ListCacheStore { + get(key: ListCacheKey): CachedEntry | undefined; + set(key: ListCacheKey, entry: CachedEntry): void; + /** Invalidate entries for a method. See the invalidation matrix below for principal/uri semantics. */ + invalidate(method: ListCacheKey['method'], opts?: { uri?: string; principal?: string }): void; + clear(): void; +} + +export class InMemoryListCacheStore implements ListCacheStore { /* Map-backed, single-tenant */ } +``` + +### Client-local request options (no core pollution) + +`principal`/`bypassCache` are **client-cache concerns and do not belong in core `RequestOptions`** (which is the transport-agnostic, server-and-client per-call bag). They are added in a **client-local** extension instead, keeping the core protocol type pure: + +```typescript +// packages/client/src/client/client.ts +export type ListRequestOptions = RequestOptions & { + principal?: string; // routing key for a shared cache (Layer 5); MUST be the validated auth principal + bypassCache?: boolean; // force refetch, then refresh the cache entry +}; +``` + +The list/read methods widen their `options?: RequestOptions` parameter to `ListRequestOptions` locally. Core `shared/protocol.ts` is **not** modified. + +### ClientOptions + +```typescript +export type ClientOptions = ProtocolOptions & { + // ...existing... + cache?: { + store?: ListCacheStore; // default: new InMemoryListCacheStore() when `cache` present + serveStaleOnError?: boolean; // R-2549-2 "MAY"; default true (resilience) + now?: () => number; // injectable clock for tests; default Date.now (runtime-neutral) + }; +}; +``` + +Absent `cache` ⇒ no caching, current behavior preserved (BC). + +### Read-through in list/read methods + +`listTools`, `listResources`, `listResourceTemplates`, `listPrompts`, `readResource` gain a cache path when caching is enabled: + +1. Build `ListCacheKey` (method + cursor/uri + optional `principal` from `ListRequestOptions`). If `bypassCache` → skip step 2. +2. `entry = store.get(key)`. **Fresh** if `now() < entry.receivedAt + entry.ttlMs` → return cached result. +3. On miss/stale: perform the request. Run `normalizeCacheMeta(parsed, raw)`. **Invariant A:** if `ttlMs === 0`, return the result but **do not** `store.set` (never cache a zero-TTL entry). Otherwise `store.set` with `receivedAt = now()` and the normalized `ttlMs`/`cacheScope`/`scopeExplicit`. +4. On **refetch error** with a prior entry present and `serveStaleOnError` (default true): return the stale result (R-2549-2). Otherwise propagate. + +### Notification invalidation (R-2549-11) + +> **Fix from review:** the existing `_setupListChangedHandlers` only registers when the user passed a `listChanged` config **and** the server advertised the capability — the common cache-user case (no `listChanged` config) would get **no** invalidation, serving stale until TTL expiry and violating R-2549-11. + +When **caching is enabled**, the client registers cache-invalidation notification handlers **unconditionally** — independent of the `listChanged` config and independent of the server capability gate. Because `setNotificationHandler` replaces by method (and would clobber a user's `listChanged` handler), invalidation is wired through an **internal dispatcher**: a single registered handler per notification method that first runs cache invalidation, then delegates to any user-supplied handler. The existing `_setupListChangedHandlers` is refactored to register *through* this dispatcher rather than directly, so cache invalidation and user `onListChanged` callbacks **compose** instead of overwriting each other. + +- `notifications/tools/list_changed` → `store.invalidate('tools/list')` +- `notifications/prompts/list_changed` → `store.invalidate('prompts/list')` +- `notifications/resources/list_changed` → `store.invalidate('resources/list')` **and** `store.invalidate('resources/templates/list')` (conservative; over-invalidates templates on a resource-list change — acceptable, documented) +- `notifications/resources/updated` → `store.invalidate('resources/read', { uri })` for the updated URI + +Invalidation is immediate, regardless of remaining TTL. + +> **`resources/read` invalidation is subscription-dependent (documented).** `notifications/resources/updated` is only sent by the server if the client previously sent `resources/subscribe` for that URI. So notification-driven read-cache invalidation is satisfied **for subscribed URIs only**; for unsubscribed reads, the TTL is the sole staleness bound. Enabling the read cache does **not** auto-subscribe (left to the caller, by design). R-2549-11 status: *satisfied for subscribed resources; TTL-bounded otherwise.* + +### Cursor invalidation (R-2549-14) + +> **Fix from review:** the draft treated *any* `-32602` on *any* cursored fetch as "drop all pages." `-32602` (InvalidParams) is generic and can mean a malformed `params` unrelated to the cursor, masking real bugs in a surprising full-refetch loop. + +Narrowed trigger: the special handling fires **only** when (a) the failing request carried a `cursor` that this cache itself issued from a prior page of the **same list traversal** (a *cache-originated* cursor), **and** (b) the server replied with `ProtocolError` code `-32602`. On both: + +1. `store.invalidate(method)` — drop all cached pages for that list. +2. Re-fetch from page 1 (no cursor). This retry runs with cursor-invalidation **structurally disabled** (it cannot recurse into another page-drop — enforced by a flag scoped to the retry, not by convention), so a second failure propagates. +3. `log`/emit a debug signal when pages are collapsed, so a genuine `-32602` bug is not silently swallowed. + +> **No snapshot isolation across a traversal (documented).** The cache provides **per-page freshness**, not a consistent snapshot of a full paginated list. Each page has its own freshness clock (R-2549-3); a mid-traversal refetch (TTL expiry or cursor invalidation) can yield a page 1 different from the one the caller already consumed. Callers needing a consistent full-list snapshot SHOULD iterate with `bypassCache` or refetch from the beginning, per the SEP. + +--- + +## Layer 4 — Polling Helpers + +**Files:** new `packages/client/src/client/pollList.ts`; export (explicit named) from `packages/client/src/index.ts`. **(PR 2)** + +```typescript +export interface PollListOptions { + onUpdate: (result: unknown) => void; + onError?: (error: unknown) => void; + signal?: AbortSignal; + minIntervalMs?: number; // floor on the poll interval; default 30_000 (see below) + jitter?: number; // fraction, default 0.2 (±20%) + backoff?: { initialMs?: number; maxMs?: number; factor?: number }; // on error + backoffOnUnchanged?: boolean; // grow interval when results are unchanged; default true +} + +// ttlMs is read internally off the response — callers don't extract it. +export function pollList( + client: Client, + method: 'tools/list' | 'prompts/list' | 'resources/list' | 'resources/templates/list', + options: PollListOptions, +): { stop: () => void }; +``` + +> **Fix from review:** the draft's `fetch: () => Promise<{ result, ttlMs }>` leaked `ttlMs` extraction to the caller. `pollList` now takes the `client` + `method` and reads `ttlMs` off the normalized response itself. + +- Base interval seeded from the last response's `ttlMs`, **clamped up** to `minIntervalMs`. +- **`minIntervalMs` default is `30_000`, not `1_000`.** A `ttlMs: 0` server (very common — every pre-2549 server) would otherwise drive a 1-req/sec hammer per list. Polling a `ttlMs: 0` server is **degenerate**; the docs warn against it and the high floor blunts the damage. +- **Jitter** (MUST per R-2549-10): each delay multiplied by `1 ± jitter*random` to avoid thundering herd. +- **Backoff** (MUST per R-2549-10): on error, exponential backoff (`initialMs * factor^n`, capped at `maxMs`) until next success. +- **Backoff on unchanged** (default on): when a poll returns a result equal to the previous one, grow the interval (same capped exponential) so a chatty poller relaxes against a static list; reset on change. Without this, a poller never backs off a never-changing list. +- Opt-in only. Reconciles the SEP's "clients SHOULD NOT use TTL as a polling interval" guidance with R-2549-10: the *default* path is freshness-on-access (Layer 3); `pollList` is a separate utility for callers who explicitly want background refresh, and when used it MUST jitter+backoff. **Docs steer hard toward `pollList`** and explicitly warn against naive `setInterval(ttlMs)` polling — the SDK cannot enforce that a caller won't hand-roll a poll loop, so guidance carries the MUST's intent. + +> **MUST vs SHOULD note (in-code comment).** The SEP MD says polling *SHOULD* jitter+backoff; the canonical `caching.mdx` spec page (and acceptance criterion R-2549-10) escalate this to *MUST*. We implement the stricter MUST. A code comment records the source of the stricter rule so it isn't "relaxed" later by someone reading only the SEP MD. + +--- + +## Layer 5 — Shared (Multi-Tenant) Cache + +**Files:** `packages/client/src/client/listCache.ts` (add `SharedListCacheStore`); export (explicit named) from `packages/client/src/index.ts`. **(PR 2)** + +A `ListCacheStore` implementation for gateways/proxies that front multiple end users through one upstream `Client`. + +### Sharing rule (Invariant B) + +- An entry is shared across principals **only if** `entry.cacheScope === 'public'` **and** `entry.scopeExplicit === true`. A defaulted-public entry (`scopeExplicit === false`) is treated as **private** and is never served to a different principal — fail-safe against pre-2549 / misconfigured servers. +- `cacheScope: 'private'` (or non-explicit) entries are namespaced by `key.principal`. A `get` for such an entry with a non-matching (or absent) principal returns `undefined`. +- Combined with Invariant A (`ttlMs: 0` never stored), this closes the leak path the first draft had: an absent-`cacheScope` user-specific response can never be served cross-user. + +### Principal contract (security-critical) + +- The **principal is supplied by the caller per request** via `ListRequestOptions.principal`. The SDK does not infer identity. +- The principal **MUST be derived from the validated auth principal** (e.g. `ctx.http.authInfo`), an **opaque, stable identifier** — never from request-supplied, attacker-influenceable headers. This is stated as an enforced contract in the API docs, not a passing comment. +- Principal strings are compared **as-is** (no normalization): the store treats distinct representations as distinct namespaces. This is the safe direction (over-isolation, never under-isolation); callers MUST pass a canonical ID. Documented. +- A `private` (or non-explicit) `set` **without** a principal is a **no-op** (the entry cannot be safely isolated, so it is not stored) — documented and tested. + +### Invalidation matrix + +`invalidate(method, opts)` semantics, made explicit (the draft's interface couldn't express these): + +| Event | `opts` | Effect | +|-------|--------|--------| +| `list_changed` for a shared/public list | `{}` | Invalidate the shared public entry for **all** principals **and** every principal's private copy of that method | +| `resources/updated` for a private resource | `{ uri, principal }` | Invalidate only that **principal's** entry for that **uri** | +| `resources/updated`, principal unknown | `{ uri }` | Invalidate that `uri` across **all** principals (conservative) | +| explicit per-principal clear | `{ principal }` | Invalidate that principal's entries for the method | + +Isolation is the security-critical property and gets dedicated tests (below), including the **absent-`cacheScope`** case — not just the explicitly-`private` case — because that is the actual leak path. + +--- + +## R-2549-4 — Notifications Coexist + +No new machinery: `list_changed` notifications already flow through the notification path independent of TTL, and Layer 3's dispatcher composes cache invalidation **with** user `onListChanged` handlers. A test asserts that with caching enabled, a server advertising `listChanged` still delivers notifications **and** the client honors TTL — the two mechanisms layer (notification invalidates immediately; TTL bounds staleness otherwise). + +--- + +## Runtime neutrality + +`packages/client/src/index.ts` is the package root entry and MUST stay runtime-neutral (browser / Cloudflare Workers — no transitive `node:*`). The new modules `listCache.ts` and `pollList.ts` are re-exported from it, so: + +- Both modules MUST be pure JS — no `node:*`, no `crypto` import for principal hashing (principals are opaque caller-supplied strings; the store does not hash). `now` defaults to `Date.now` (neutral) and is injectable. +- Add `listCache.ts` and `pollList.ts` to the package's **`barrelClean` test** so a future accidental Node import is caught. + +--- + +## Testing Strategy + +Tests are TDD-first, one behavior at a time. Layered by package. + +**Core (`packages/core`) — PR 1:** +- `spec.types.test.ts` parity for `CacheableResult` + the 5 result types (compile-time), incl. the `_meta`/looseObject key-parity check. +- Schema unit tests (the **sole** guard for the input contract): absent `ttlMs` → 0; negative → 0; NaN/Infinity → 0; non-integer floored; absent `cacheScope` → `'public'`; explicit values preserved. +- `normalizeCacheMeta`: `scopeExplicit` true when raw payload has `cacheScope`, false when absent; clamp behavior. + +**Server (integration, `test/integration/test/server/mcp.test.ts`) — PR 1:** +- Each of the 4 list endpoints + `resources/read` emits configured `ttlMs`/`cacheScope`. +- `resources/read` callback values override config. +- `resources/read` with `ttlMs > 0` configured + `cacheScope` omitted → emits `'private'` (defense-in-depth). +- Same `cacheScope` across paginated pages (R-2549-8). +- Backward compat: no config → fields present with defaults (0/'public'). +- Low-level `Server` handler can (and must) return the fields directly (ADR-001). + +**Client (`test/integration/test/client/` + unit) — PR 2:** +- Fresh hit served from cache without a wire request; stale triggers refetch (R-2549-2), using injectable `now()`. +- **Invariant A:** `ttlMs: 0` result is never stored and always refetches (R-2549-12). +- Refetch error serves stale when `serveStaleOnError` (default), propagates when off. +- Notification invalidation for each type (R-2549-11), regardless of remaining TTL — **including the no-`listChanged`-config case** (handlers register unconditionally) and the **dispatcher composition** test (cache invalidation + user `onListChanged` both fire, neither clobbers the other). +- `resources/updated` invalidation works for a subscribed URI; documented gap asserted for unsubscribed. +- Cursor `-32602` on a **cache-originated** cursor → drop pages, refetch from page 1; retry is non-recursive (second failure propagates); a `-32602` on a non-cache-originated/non-cursored request does **not** trigger page-drop. +- `pollList`: jitter bounds the interval; backoff grows on consecutive errors then resets; backoff-on-unchanged grows then resets on change; `minIntervalMs` floor honored with `ttlMs: 0` (R-2549-10) — deterministic via injected clock + seeded randomness. +- **`SharedListCacheStore` (R-2549-7):** explicitly-private entry not served cross-principal; **absent-`cacheScope` entry not served cross-principal** (the real leak path); explicit-public entry shared; private `set` without principal is a no-op; the full invalidation matrix. +- Caching disabled by default → behavior identical to today (BC). +- `barrelClean` covers `listCache.ts` / `pollList.ts` (runtime neutrality). + +**E2E requirements manifest (`test/e2e/requirements.ts`):** +Register `caching:*` requirement entries linked to scenario tests so the conformance suite tracks coverage, mirroring existing entries: `caching:ttl:emitted`, `caching:client:freshness`, `caching:invalidate:list-changed`, `caching:invalidate:list-changed:no-config`, `caching:invalidate:resource-updated`, `caching:cursor:invalid`, `caching:scope:isolation`, `caching:scope:isolation:absent-scope`, `caching:poll:jitter-backoff`. Add scenario tests under `test/e2e/scenarios/`. + +## Documentation + +- `docs/migration.md` — human-readable: new fields, McpServer cache (`CacheHints`) config, the **`resources/read` privacy warning**, low-level `Server` breaking change (ADR-001) with the exact two-field snippet, client cache opt-in, the shared-cache **principal contract**, `pollList` (and the anti-pattern warning against `setInterval(ttlMs)`). +- `docs/migration-SKILL.md` — symbol mapping table (`CacheableResult`, `CacheScope`, `CacheHints`, `McpServerOptions`, `ListCacheStore`, `InMemoryListCacheStore`, `SharedListCacheStore`, `ListRequestOptions`, `pollList`, `normalizeCacheMeta`, schema extensions). +- A changeset under `.changeset/` per PR. + +## File Summary + +| Action | File | Layer | PR | +|--------|------|-------|----| +| Modify | `packages/core/src/types/schemas.ts` | 1 | 1 | +| Modify | `packages/core/src/types/types.ts` (+ `normalizeCacheMeta`, `CacheScope`, `CacheableResult`) | 1 | 1 | +| Regenerate | `packages/core/src/types/spec.types.ts` | 1 | 1 | +| Modify | `packages/core/test/spec.types.test.ts` | 1 | 1 | +| Modify | `packages/server/src/server/mcp.ts` | 2 | 1 | +| Modify | `packages/server/src/index.ts` (explicit exports: `CacheHints`, `McpServerOptions`) | 2 | 1 | +| Create | `packages/client/src/client/listCache.ts` | 3, 5 | 2 | +| Modify | `packages/client/src/client/client.ts` (`ListRequestOptions`, read-through, dispatcher) | 3 | 2 | +| Create | `packages/client/src/client/pollList.ts` | 4 | 2 | +| Modify | `packages/client/src/index.ts` (explicit named exports) | 3, 4, 5 | 2 | +| Modify | `test/integration/test/server/mcp.test.ts` | 2 | 1 | +| Create/Modify | `test/integration/test/client/*` | 3, 4, 5 | 2 | +| Modify | `test/e2e/requirements.ts` + `test/e2e/scenarios/*` | all | 1 & 2 | +| Modify | `docs/migration.md`, `docs/migration-SKILL.md`, `.changeset/*` | all | 1 & 2 | + +> **Note:** `packages/core/src/shared/protocol.ts` is **no longer modified.** `principal`/`bypassCache` moved to the client-local `ListRequestOptions` (Layer 3) to keep core protocol pure. + +## Review Findings → Resolution + +Traceability for the backend + software architecture review (rev 1 → rev 2): + +| Finding | Resolution | +|---------|------------| +| `cacheScope` default `'public'` leaks cross-tenant (CRITICAL ×2) | Invariants A & B; `scopeExplicit` on `CachedEntry`; `SharedListCacheStore` shares only explicit-public; `resources/read` defense-in-depth `'private'` | +| Notification invalidation gated by `listChanged` config/capability (CRITICAL) | Unconditional registration when caching on; internal dispatcher composes with user handlers; `no-config` test | +| `resources/updated` only fires when subscribed | Documented as subscription-dependent; R-2549-11 status qualified; asserted by test | +| Low-level `Server` breaking change rationale | ADR-001 (keep break; reject `z.input` softening; protocol does not back-fill outbound; McpServer is internal consumer) | +| `principal`/`bypassCache` pollute core `RequestOptions` | Moved to client-local `ListRequestOptions`; core untouched | +| `.transform()` brittle / lossy | Dropped; `.default()` + `normalizeCacheMeta` helper; `scopeExplicit` preserved | +| `-32602` cursor narrowing too broad | Scoped to cache-originated cursors; non-recursive single retry; logs on collapse; no-snapshot-isolation documented | +| Shared-cache bypass paths (F2) | Principal-derivation contract (auth principal only, opaque, no normalization); private-set-without-principal no-op; invalidation matrix; absent-scope test | +| Export plan `export *` framing | Split: two core types via sanctioned `export *`; all package-barrel symbols explicit named | +| `CacheableResult` `@internal` in spec | Deliberately public; divergence noted in export comment | +| Parity covers `z.output` only | Stated; input contract guarded by unit tests | +| `_meta`/looseObject key-parity interplay | Explicit verification step in spec-test bookkeeping | +| Type vs spread manual-sync seam | Called out (mirrors `PaginatedResult`) | +| `ListCacheConfig` prefix collision | Renamed server-side to `CacheHints` | +| `pollList` leaks `ttlMs` extraction | Signature takes `client` + `method`; reads `ttlMs` internally | +| `pollList` `minIntervalMs` 1s hammer / no unchanged-backoff | Default `30_000`; degenerate `ttlMs:0` warned; `backoffOnUnchanged` default on | +| Runtime neutrality of new modules | Pure-JS requirement + `barrelClean` coverage | +| API surface too large | Split into PR 1 (wire+server) and PR 2 (client cache) | +| `ttlMs:0` invariant under-stated | Promoted to Invariant A with dedicated test | +| R-2549-8 for low-level servers | Documented as author responsibility (no runtime guard) | + +## Open Risks + +1. **Exact Zod v4 `.default()` output-type form.** `z.number().default(0)` spread into a `looseObject` result schema must yield a *required* `number` in `z.output` and pass `AssertExactKeys`. De-risked by a compile-only spike done before Layer 3 (see Layer 1). Fallback: clamp/normalize entirely outside the schema (already the plan via `normalizeCacheMeta`). +2. **Dispatcher refactor of `_setupListChangedHandlers`.** Routing cache invalidation and user `onListChanged` through one composing dispatcher touches existing notification wiring. Covered by the composition test; verify no regression for users who configured `listChanged` without caching. +3. **Manual sync between exported `CacheableResult` type and the `cacheableResultFields` spread** (no shared schema). Low risk, mirrors `PaginatedResult`; parity test catches divergence at the result-type level. diff --git a/docs/superpowers/specs/2026-06-23-sdk-shared-package-design.md b/docs/superpowers/specs/2026-06-23-sdk-shared-package-design.md new file mode 100644 index 0000000000..4f9f4157c0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-sdk-shared-package-design.md @@ -0,0 +1,135 @@ +# Design: `@modelcontextprotocol/sdk-shared` — canonical Zod schemas package + +- **Date:** 2026-06-23 +- **Status:** Approved (design); implementation plan pending +- **Owner:** Konstantin Konstantinov + +## Problem + +The v1→v2 migration of runtime schema validation is non-mechanical and lossy. + +In v1, consumers validated values with the exported Zod schema constants: + +```ts +import { CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; + +const parsed = ListToolsResultSchema.parse(res.body.result); // throws on invalid, returns value +const r = CallToolResultSchema.safeParse(value); // { success, data, error } +``` + +In v2 these schemas are reached via `specTypeSchemas.X` and **typed** as `StandardSchemaV1` (to keep Zod out of the public API), even though **at runtime they are still the underlying Zod schemas**. Because the public type is Standard Schema, `.parse()`/`.safeParse()` are not visible to the type checker, so the current codemod: + +- rewrites `CallToolResultSchema` → `specTypeSchemas.CallToolResult`, +- converts `.safeParse(x)` → `specTypeSchemas.X['~standard'].validate(x)` and remaps `.success`/`.data`/`.error` (which also changes the thrown error type), and +- has **no** one-line equivalent for `.parse()` (it throws; `validate()` does not), so those sites get a manual-migration diagnostic and don't compile until hand-edited. + +Validated against `firebase/firebase-tools`, this produced 4 post-codemod typecheck errors (all `.parse()`), plus project-type-resolution warnings on type-only files. + +A prior attempt (PR #2277) surfaces `parse()`/`safeParse()` on each `specTypeSchemas.X` entry as **type-only** methods and migrates by reference rename. That works but (a) pollutes the deliberately library-agnostic Standard Schema type with Zod-specific methods, and (b) only covers `parse`/`safeParse`, not other Zod methods (`.extend()`, `.merge()`, `.shape`, …). + +## Goals + +- Make schema-validation migration a **mechanical, behavior-preserving import-path swap**: `.parse()`/`.safeParse()` and every other Zod method keep working unchanged. +- Keep the `server`/`client` main API surface **Zod-free**; Zod coupling is opt-in and explicit. +- Establish a **canonical home for shared spec primitives** (schemas + types now, room for more later). +- Keep `specTypeSchemas`/`isSpecType` (the Standard Schema, library-agnostic view) intact and recommended for library-agnostic validation. + +## Non-goals + +- Changing the Standard Schema typing of `specTypeSchemas` (we are **not** adding `parse`/`safeParse` to it — this supersedes PR #2277's approach). +- Moving `Protocol`, transports, or validators. They stay in `core` and follow existing migration rules. +- Moving `specTypeSchemas`/`isSpecType` out of `core/public` (possible later; out of scope now). + +> **Update during implementation (Option C, user-approved):** the protocol **enums** (`enums.ts` → +> `ProtocolErrorCode`), **error classes** (`errors.ts` → `ProtocolError`, …), and **type guards** +> (`guards.ts`) were *also* moved into `sdk-shared`, reversing the original "they stay in core" +> non-goal. Rationale: v1's `sdk/types.js` was a kitchen-sink exporting all of these alongside the +> spec types/schemas, so the codemod's `types.js → sdk-shared` routing is only correct if sdk-shared +> carries that whole surface. Their dependency closure (schemas/types/enums) is already in sdk-shared, +> so the move is clean and introduces no cycle. **Exception:** `SdkError`/`SdkErrorCode`/`SdkHttpError` +> (the SDK-side error split, in `core/errors/sdkErrors.ts`) deliberately stay in `core` → `server`/`client`. + +## Decisions (locked) + +| Decision | Choice | +| --- | --- | +| Package name | `@modelcontextprotocol/sdk-shared` | +| Scope of move | Spec **types + Zod `*Schema` constants** | +| Positioning | Zod schemas are **first-class** (no codemod nudge toward `specTypeSchemas`) | +| server/client bundling | Depend on `sdk-shared` as a **regular dependency**, marked **external** (not bundled) | +| server/client re-exports | Re-export **types** from `sdk-shared`; do **not** re-export the raw Zod `*Schema` constants | +| Consumer dependency | Regular `dependency` (not peer); codemod adds it | +| core churn control | `core`'s internal barrel **re-exports** schemas/types from `sdk-shared` | + +## Architecture + +### Package + +New public package `packages/sdk-shared/` (`@modelcontextprotocol/sdk-shared`): + +- Owns the canonical MCP spec data model: the Zod `*Schema` constants and their derived TS types (`Tool`, `CallToolResult`, …), extracted from `packages/core/src/types/types.ts`. +- Depends only on `zod` (catalog: `runtimeShared`). **Runtime-neutral** — no Node builtins — so browser/Cloudflare Workers bundlers can consume it (covered by a `barrelClean` test, per CLAUDE.md). +- Uses explicit named exports. + +### Dependency graph (new edges in **bold**) + +``` +zod + └── @modelcontextprotocol/sdk-shared (NEW — types + Zod *Schema constants; zod-only) + ├── @modelcontextprotocol/core (private; imports schemas/types from sdk-shared, re-exports them from its barrel) + ├── **@modelcontextprotocol/server** ─┐ regular dependency, + └── **@modelcontextprotocol/client** ─┘ marked EXTERNAL in tsdown (not bundled) +``` + +`server`/`client` today inline `core` (and thus the schemas). After this change they treat `@modelcontextprotocol/sdk-shared` as an external dependency, so there is a single runtime instance and their bundles shrink. (Instance identity is not a correctness concern — validation is structural — so "single instance" is about source-of-truth and bundle size, not behavior.) + +### What moves vs. stays + +- **Moves to `sdk-shared`:** the spec Zod `*Schema` constants and their inferred TS types. `types.ts` is **split** along this line; the exact boundary (pure spec schemas + inferred types move; protocol constants such as `LATEST_PROTOCOL_VERSION` and method-name constants stay in `core` for now) is finalized during implementation. `core`'s barrel keeps re-exporting the moved symbols so the ~hundreds of internal `core` imports don't all change. +- **Stays in `core`:** `Protocol`, transports, validators, error classes/enums, protocol constants, and `specTypeSchemas`/`isSpecType` (rebuilt from `sdk-shared`'s schemas, exported via `core/public` as today). + +### Public API surface after the change + +| Symbol kind | Canonical home | Also re-exported by | Typed as | +| --- | --- | --- | --- | +| Spec **types** (`Tool`, `CallToolResult`, …) | `sdk-shared` | `core/public`, `server`, `client` | TS types (Zod-free) | +| Zod **`*Schema` constants** | `sdk-shared` **only** | — (intentionally not on server/client) | real Zod schemas | +| `specTypeSchemas` / `isSpecType` | `core/public` | `server`, `client` | `StandardSchemaV1` (Zod-free) | + +Guidance: use `specTypeSchemas` for library-agnostic Standard Schema validation; import the Zod `*Schema` from `@modelcontextprotocol/sdk-shared` when you want Zod ergonomics (`.parse`, `.safeParse`, `.extend`, …) or are migrating v1 code. + +## Codemod changes + +Today: the `imports` transform sends `sdk/types.js` → `RESOLVE_BY_CONTEXT`; the `specSchemaAccess` transform rewrites the schema reference, converts `.safeParse()`, and emits a manual-migration diagnostic for `.parse()`. + +After: + +1. **`@modelcontextprotocol/sdk/types.js`** (and the extensionless `/types`, already handled) **→ `@modelcontextprotocol/sdk-shared`**: a fixed, context-free path swap covering both types and `*Schema` constants. Symbol names unchanged; existing `renamedSymbols` (e.g. `ResourceTemplate`→`ResourceTemplateType`) still apply. +2. **Retire `specSchemaAccess`'s schema rewriting.** Because `sdk-shared` exports real Zod schemas, `.parse()`/`.safeParse()`/`.extend()`/`.shape`/… all keep working untouched — no reference rename, no `.safeParse` result remap, no `.parse()` manual-migration diagnostic. The independent `schemaParamRemoval` transform (strips schema args from `request()`/`callTool()`) is unaffected and stays. +3. **`updatePackageJson` adds `@modelcontextprotocol/sdk-shared`** to the consumer whenever a `types.js` import is routed there. + +Expected effect on `firebase/firebase-tools`: the 4 `.parse()` errors disappear (schemas validate via Zod as before) and the project-type warnings on type-only files disappear (fixed target, no context resolution) → **zero codemod-introduced typecheck errors**, far fewer diagnostics. + +## Testing strategy + +- **`sdk-shared` package:** unit tests asserting expected exports exist; `barrelClean` test (no Node builtins); runtime-neutral. +- **`codemod`:** update `importPaths` tests (`types.js`/`/types` → `sdk-shared`); remove/trim `specSchemaAccess` tests; add coverage for the dependency addition and "schema usage passes through untouched." +- **`core`/`server`/`client`:** existing suites + typecheck stay green after the `types.ts` split (the main risk). +- **Batch test:** add `sdk-shared` to `LOCAL_PACKAGE_DIRS`; add an `overrides` entry so the transitive `server`→`sdk-shared` edge resolves to the local tarball; re-run `firebase/firebase-tools` and confirm 0 introduced typecheck errors. + +## Docs & rollout + +- Rewrite the spec-schema validation section in `docs/migration.md` and `docs/migration-SKILL.md`: schemas now import from `@modelcontextprotocol/sdk-shared`, `.parse`/`.safeParse` keep working; `specTypeSchemas` remains for library-agnostic validation. Document the new package. +- Add a changeset covering the new package and the codemod change. +- **PR #2277 coordination:** this **supersedes** #2277's `specTypeSchemas` type-only `parse`/`safeParse` approach. Its other improvements are independent and worth keeping: client/server inference (#2) and the `tasks/*` handler-map fix (#3). The extensionless-import fix (#4) is already implemented on this branch. + +## Risks & mitigations + +- **Splitting `types.ts`** is wide-reaching. Mitigation: keep `core`'s barrel re-exporting the moved symbols; land the move as its own step with full `core` typecheck/tests green before touching the codemod. +- **Transitive local-tarball resolution** in the batch test (`server`→`sdk-shared`). Mitigation: `overrides` entry pointing `sdk-shared` at the local tarball (or publish an alpha). +- **New publish/version target.** Mitigation: version `sdk-shared` in lockstep with the other v2 packages via changesets. + +## Open questions (non-blocking) + +- Final split boundary inside `types.ts` (which non-schema symbols, if any, also belong in `sdk-shared`). +- Whether `specTypeSchemas`/`isSpecType` should eventually move to `sdk-shared` too (deferred). diff --git a/packages/codemod/batch-test/repos.json b/packages/codemod/batch-test/repos.json index d7faa64e2c..28dbc4348d 100644 --- a/packages/codemod/batch-test/repos.json +++ b/packages/codemod/batch-test/repos.json @@ -1,16 +1,16 @@ [ { - "repo": "upstash/context7", - "ref": "master", + "repo": "firebase/firebase-tools", + "ref": "main", "packages": [ { - "dir": "packages/mcp", - "sourceDir": "src", + "dir": ".", + "sourceDir": "src/mcp", "checks": { - "typecheck": "pnpm run typecheck", - "build": "pnpm run build", - "test": "pnpm run test", - "lint": "pnpm run lint" + "typecheck": "npx tsc -p tsconfig.compile.json", + "build": null, + "test": null, + "lint": null } } ] diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 264f973ac6..6c2c290457 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -35,8 +35,7 @@ "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", + "prebuild": "pnpm run generate:versions", "build": "tsdown", "build:watch": "tsdown --watch", "prepack": "pnpm run build", diff --git a/packages/codemod/scripts/generateSpecSchemaMap.ts b/packages/codemod/scripts/generateSpecSchemaMap.ts deleted file mode 100644 index 29796f62ab..0000000000 --- a/packages/codemod/scripts/generateSpecSchemaMap.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 = 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)`); diff --git a/packages/codemod/scripts/generateVersions.ts b/packages/codemod/scripts/generateVersions.ts index 3a77b592bf..fa37e2c273 100644 --- a/packages/codemod/scripts/generateVersions.ts +++ b/packages/codemod/scripts/generateVersions.ts @@ -10,7 +10,8 @@ const PACKAGE_DIRS: Record = { '@modelcontextprotocol/server': 'server', '@modelcontextprotocol/node': 'middleware/node', '@modelcontextprotocol/express': 'middleware/express', - '@modelcontextprotocol/server-legacy': 'server-legacy' + '@modelcontextprotocol/server-legacy': 'server-legacy', + '@modelcontextprotocol/sdk-shared': 'sdk-shared' }; const versions: Record = {}; diff --git a/packages/codemod/src/bin/batchTest.ts b/packages/codemod/src/bin/batchTest.ts index 0e45e1715b..eefe0f2df5 100644 --- a/packages/codemod/src/bin/batchTest.ts +++ b/packages/codemod/src/bin/batchTest.ts @@ -88,6 +88,7 @@ const LOCAL_PACKAGE_DIRS: Record = { '@modelcontextprotocol/core': path.join(SDK_ROOT, 'packages/core'), '@modelcontextprotocol/server': path.join(SDK_ROOT, 'packages/server'), '@modelcontextprotocol/server-legacy': path.join(SDK_ROOT, 'packages/server-legacy'), + '@modelcontextprotocol/sdk-shared': path.join(SDK_ROOT, 'packages/sdk-shared'), '@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'), diff --git a/packages/codemod/src/generated/specSchemaMap.ts b/packages/codemod/src/generated/specSchemaMap.ts deleted file mode 100644 index 77f3d3dfc8..0000000000 --- a/packages/codemod/src/generated/specSchemaMap.ts +++ /dev/null @@ -1,173 +0,0 @@ -// AUTO-GENERATED — do not edit. Run `pnpm run generate:spec-schemas` to regenerate. -export const SPEC_SCHEMA_NAMES: ReadonlySet = 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', - 'DiscoverRequestSchema', - 'DiscoverResultSchema', - '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', - 'RequestMetaEnvelopeSchema', - '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); -} diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 7166154021..afcef6e1ed 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -4,5 +4,6 @@ export const V2_PACKAGE_VERSIONS: Record = { '@modelcontextprotocol/server': '^2.0.0-alpha.2', '@modelcontextprotocol/node': '^2.0.0-alpha.2', '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2' + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', + '@modelcontextprotocol/sdk-shared': '^2.0.0-alpha.0' }; diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts index bf229772b0..04366c7dd4 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts @@ -4,6 +4,13 @@ export interface ImportMapping { renamedSymbols?: Record; /** Route specific symbols to a different target package than `target`. */ symbolTargetOverrides?: Record; + /** + * Route every imported symbol whose name ends in `Schema` to this package, instead of `target`. + * Used for `sdk/types.js`: the spec Zod schemas now live in `@modelcontextprotocol/sdk-shared` + * (so `Schema.parse(...)` keeps working), while the spec types/constants resolve by context. + * `symbolTargetOverrides` (exact-name) takes precedence over this suffix rule. + */ + schemaSymbolTarget?: string; removalMessage?: string; /** No entries currently set this; scaffolding for when a v1 symbol has no v2 equivalent yet. */ isV2Gap?: boolean; @@ -119,6 +126,7 @@ export const IMPORT_MAP: Record = { '@modelcontextprotocol/sdk/types.js': { target: 'RESOLVE_BY_CONTEXT', status: 'moved', + schemaSymbolTarget: '@modelcontextprotocol/sdk-shared', renamedSymbols: { ResourceTemplate: 'ResourceTemplateType' } diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 14c63f9233..1215634460 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -1,10 +1,12 @@ import type { SourceFile } from 'ts-morph'; +import { SyntaxKind } from 'ts-morph'; import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; import { renameAllReferences } from '../../../utils/astUtils.js'; import { actionRequired, info, v2Gap, warning } from '../../../utils/diagnostics.js'; import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils.js'; import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; +import type { ImportMapping } from '../mappings/importMap.js'; import { isAuthImport, lookupImportMapping } from '../mappings/importMap.js'; import { SIMPLE_RENAMES } from '../mappings/symbolMap.js'; @@ -17,6 +19,21 @@ const REEXPORT_WARNINGS: Record = { 'Re-exported StreamableHTTPError was renamed to SdkHttpError in v2 with a different constructor. Update this re-export manually.' }; +/** + * The per-symbol target package for a symbol imported/re-exported from `mapping`'s module, or + * `undefined` when the symbol should use the mapping's resolved `target`. Exact-name + * `symbolTargetOverrides` win over the `schemaSymbolTarget` (`*Schema`) suffix rule. + */ +function symbolTargetOverride(name: string, mapping: ImportMapping): string | undefined { + if (mapping.symbolTargetOverrides && name in mapping.symbolTargetOverrides) { + return mapping.symbolTargetOverrides[name]; + } + if (mapping.schemaSymbolTarget && name.endsWith('Schema')) { + return mapping.schemaSymbolTarget; + } + return undefined; +} + export const importPathsTransform: Transform = { name: 'Import path rewrites', id: 'imports', @@ -119,11 +136,13 @@ export const importPathsTransform: Transform = { 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()]!; - } else if (namedImports.some(n => n.getName() in mapping.symbolTargetOverrides!)) { + if ((mapping.symbolTargetOverrides || mapping.schemaSymbolTarget) && !namespaceImport && !defaultImport) { + const overrides = namedImports.map(n => symbolTargetOverride(n.getName(), mapping)); + const uniqueOverrides = new Set(overrides.filter((t): t is string => t !== undefined)); + const allOverridden = namedImports.length > 0 && overrides.every(t => t !== undefined); + if (allOverridden && uniqueOverrides.size === 1) { + effectiveTarget = [...uniqueOverrides][0]!; + } else if (uniqueOverrides.size > 0) { diagnostics.push( actionRequired( filePath, @@ -134,6 +153,29 @@ export const importPathsTransform: Transform = { ); } } + // A namespace import (`import * as ns from '…/types.js'`) cannot be split per-symbol, so + // any `ns.Schema` accesses would silently resolve against the wrong package. Flag them. + if (namespaceImport && mapping.schemaSymbolTarget) { + const nsName = namespaceImport.getText(); + const schemaNames = [ + ...new Set( + sourceFile + .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression) + .filter(pa => pa.getExpression().getText() === nsName && pa.getName().endsWith('Schema')) + .map(pa => pa.getName()) + ) + ]; + if (schemaNames.length > 0) { + diagnostics.push( + actionRequired( + filePath, + imp, + `Namespace import of ${specifier} is used to access Zod schema(s) (${schemaNames.join(', ')}) that moved to ${mapping.schemaSymbolTarget}. ` + + `Import them with a named import (e.g. \`import { ${schemaNames[0]} } from '${mapping.schemaSymbolTarget}'\`) and update the qualified usages.` + ) + ); + } + } usedPackages.add(effectiveTarget); imp.setModuleSpecifier(effectiveTarget); if (mapping.renamedSymbols) { @@ -168,7 +210,7 @@ export const importPathsTransform: Transform = { const name = n.getName(); const resolvedName = mapping.renamedSymbols?.[name] ?? name; const specifierTypeOnly = typeOnly || n.isTypeOnly(); - const symbolTarget = mapping.symbolTargetOverrides?.[name] ?? targetPackage; + const symbolTarget = symbolTargetOverride(name, mapping) ?? targetPackage; usedPackages.add(symbolTarget); addPending(symbolTarget, [resolvedName], specifierTypeOnly); } @@ -262,12 +304,14 @@ function rewriteExportDeclarations( } } - if (mapping.symbolTargetOverrides) { + if (mapping.symbolTargetOverrides || mapping.schemaSymbolTarget) { 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()]!; - } else if (namedExports.some(s => s.getName() in mapping.symbolTargetOverrides!)) { + const overrides = namedExports.map(s => symbolTargetOverride(s.getName(), mapping)); + const uniqueOverrides = new Set(overrides.filter((t): t is string => t !== undefined)); + const allOverridden = namedExports.length > 0 && overrides.every(t => t !== undefined); + if (allOverridden && uniqueOverrides.size === 1) { + targetPackage = [...uniqueOverrides][0]!; + } else if (uniqueOverrides.size > 0) { diagnostics.push( actionRequired( filePath, diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts index 7b6b54b28b..bdc1c5e6ba 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts @@ -7,7 +7,6 @@ import { mcpServerApiTransform } from './mcpServerApi.js'; import { mockPathsTransform } from './mockPaths.js'; import { removedApisTransform } from './removedApis.js'; import { schemaParamRemovalTransform } from './schemaParamRemoval.js'; -import { specSchemaAccessTransform } from './specSchemaAccess.js'; import { symbolRenamesTransform } from './symbolRenames.js'; // Ordering matters — do not reorder without understanding dependencies: @@ -31,11 +30,7 @@ import { symbolRenamesTransform } from './symbolRenames.js'; // 5. handlerRegistration, schemaParamRemoval, and expressMiddleware are // independent of each other but all depend on importPaths having run. // -// 6. specSchemaAccess runs after handlerRegistration and schemaParamRemoval: -// those transforms remove spec schema references they handle. specSchemaAccess -// then processes remaining standalone usages (safeParse, parse, z.infer, etc.). -// -// 7. mockPaths runs last: handles test mocks and dynamic imports, +// 6. mockPaths runs last: handles test mocks and dynamic imports, // independent of the other transforms. export const v1ToV2Transforms: Transform[] = [ importPathsTransform, @@ -44,7 +39,6 @@ export const v1ToV2Transforms: Transform[] = [ mcpServerApiTransform, handlerRegistrationTransform, schemaParamRemovalTransform, - specSchemaAccessTransform, expressMiddlewareTransform, contextTypesTransform, mockPathsTransform diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts deleted file mode 100644 index 79f4a0a707..0000000000 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts +++ /dev/null @@ -1,392 +0,0 @@ -import type { SourceFile } from 'ts-morph'; -import { Node, SyntaxKind } from 'ts-morph'; - -import { SPEC_SCHEMA_NAMES, specSchemaToTypeName } from '../../../generated/specSchemaMap.js'; -import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; -import { isKeyPositionIdentifier } from '../../../utils/astUtils.js'; -import { actionRequired, warning } from '../../../utils/diagnostics.js'; -import { addOrMergeImport, isAnyMcpSpecifier, removeUnusedImport } from '../../../utils/importUtils.js'; - -export const specSchemaAccessTransform: Transform = { - name: 'Spec schema standalone usage', - id: 'spec-schemas', - apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { - const diagnostics: Diagnostic[] = []; - let changesCount = 0; - - const schemaImports = collectSpecSchemaImports(sourceFile); - if (schemaImports.size === 0) return { changesCount: 0, diagnostics: [] }; - - for (const [localName, originalName] of schemaImports) { - const typeName = specSchemaToTypeName(originalName); - if (!typeName) continue; - - const refs = findNonImportReferences(sourceFile, localName); - if (refs.length === 0) continue; - - for (const ref of refs) { - const result = handleReference(ref, localName, typeName, sourceFile, diagnostics); - if (result) changesCount++; - } - removeUnusedImport(sourceFile, localName, true); - } - - return { changesCount, diagnostics }; - } -}; - -function collectSpecSchemaImports(sourceFile: SourceFile): Map { - const result = new Map(); - for (const imp of sourceFile.getImportDeclarations()) { - if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; - for (const n of imp.getNamedImports()) { - const exportName = n.getName(); - if (!SPEC_SCHEMA_NAMES.has(exportName)) continue; - const localName = n.getAliasNode()?.getText() ?? exportName; - result.set(localName, exportName); - } - } - return result; -} - -function findNonImportReferences(sourceFile: SourceFile, localName: string): import('ts-morph').Node[] { - const refs: import('ts-morph').Node[] = []; - sourceFile.forEachDescendant(node => { - if (!Node.isIdentifier(node)) return; - if (node.getText() !== localName) return; - const parent = node.getParent(); - if (parent && Node.isImportSpecifier(parent)) return; - refs.push(node); - }); - return refs; -} - -function handleReference( - ref: import('ts-morph').Node, - localName: string, - typeName: string, - sourceFile: SourceFile, - diagnostics: Diagnostic[] -): boolean { - // Pattern: z.infer — type position - if (isTypeofInTypePosition(ref)) { - diagnostics.push( - actionRequired( - sourceFile.getFilePath(), - ref, - `Replace \`z.infer\` with the \`${typeName}\` type (already exported from the same v2 package).` - ) - ); - return false; - } - - // Pattern: XSchema.safeParse(v).success — auto-transform to isSpecType.X(v) - if (isSafeParseSuccessPattern(ref)) { - const safeParseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression; - const safeParseCall = safeParseAccess.getParent() as import('ts-morph').CallExpression; - const successAccess = safeParseCall.getParent() as import('ts-morph').PropertyAccessExpression; - const args = safeParseCall.getArguments(); - const argText = args.length > 0 ? args[0]!.getText() : ''; - successAccess.replaceWithText(`isSpecType.${typeName}(${argText})`); - ensureImport(sourceFile, 'isSpecType'); - return true; - } - - // Pattern: const x = XSchema.safeParse(v) — auto-transform when result is captured in a variable - if (isSafeParsePattern(ref)) { - const safeParseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression; - const safeParseCall = safeParseAccess.getParent() as import('ts-morph').CallExpression; - - if (isCapturedSafeParsePattern(safeParseCall)) { - return rewriteCapturedSafeParse(safeParseCall, localName, typeName, sourceFile, diagnostics); - } - - return rewriteUnsupportedSchemaCall(ref, safeParseCall, localName, typeName, 'safeParse', sourceFile, diagnostics); - } - - // Pattern: XSchema.parse(v) — rewrite to the StandardSchema validate() primitive (or, when the - // result is used, swap the identifier) so we never leave behind an import of a non-exported schema. - if (isParsePattern(ref)) { - const parseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression; - const parseCall = parseAccess.getParent() as import('ts-morph').CallExpression; - return rewriteUnsupportedSchemaCall(ref, parseCall, localName, typeName, 'parse', sourceFile, diagnostics); - } - - // Pattern: XSchema used as value (function arg, assignment, etc.) - const parent = ref.getParent(); - if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) { - const line = ref.getStartLineNumber(); - ref.replaceWithText(`specTypeSchemas.${typeName}`); - ensureImport(sourceFile, 'specTypeSchemas'); - diagnostics.push( - warning( - sourceFile.getFilePath(), - line, - `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse()/.parseAsync() are not available. Manual rewrite required.` - ) - ); - return true; - } - - if (parent && Node.isExportSpecifier(parent)) { - diagnostics.push( - actionRequired( - sourceFile.getFilePath(), - ref, - `Re-export of ${localName} requires manual update: replace with specTypeSchemas.${typeName} or remove.` - ) - ); - return false; - } - - if (parent && Node.isShorthandPropertyAssignment(parent)) { - const line = ref.getStartLineNumber(); - parent.replaceWithText(`'${localName}': specTypeSchemas.${typeName}`); - ensureImport(sourceFile, 'specTypeSchemas'); - diagnostics.push( - warning( - sourceFile.getFilePath(), - line, - `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.` - ) - ); - return true; - } - - if (parent && isKeyPositionIdentifier(ref)) { - return false; - } - - // Value position: replace identifier with specTypeSchemas.X - const line = ref.getStartLineNumber(); - ref.replaceWithText(`specTypeSchemas.${typeName}`); - ensureImport(sourceFile, 'specTypeSchemas'); - diagnostics.push( - warning( - sourceFile.getFilePath(), - line, - `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.` - ) - ); - return true; -} - -function isSafeParseSuccessPattern(ref: import('ts-morph').Node): boolean { - const parent = ref.getParent(); - if (!parent || !Node.isPropertyAccessExpression(parent)) return false; - if (parent.getName() !== 'safeParse' || parent.getExpression() !== ref) return false; - const grandParent = parent.getParent(); - if (!grandParent || !Node.isCallExpression(grandParent)) return false; - const greatGrandParent = grandParent.getParent(); - if (!greatGrandParent || !Node.isPropertyAccessExpression(greatGrandParent)) return false; - return greatGrandParent.getName() === 'success'; -} - -function isSafeParsePattern(ref: import('ts-morph').Node): boolean { - const parent = ref.getParent(); - if (!parent || !Node.isPropertyAccessExpression(parent)) return false; - if (parent.getName() !== 'safeParse' || parent.getExpression() !== ref) return false; - const grandParent = parent.getParent(); - return !!grandParent && Node.isCallExpression(grandParent); -} - -function isParsePattern(ref: import('ts-morph').Node): boolean { - const parent = ref.getParent(); - if (!parent || !Node.isPropertyAccessExpression(parent)) return false; - if (parent.getName() !== 'parse' || parent.getExpression() !== ref) return false; - const grandParent = parent.getParent(); - return !!grandParent && Node.isCallExpression(grandParent); -} - -function isTypeofInTypePosition(ref: import('ts-morph').Node): boolean { - const parent = ref.getParent(); - if (!parent) return false; - return Node.isTypeQuery(parent); -} - -/** - * Checks if a safeParse call result is captured in a `const` variable declaration. - * Pattern: `const x = Schema.safeParse(v);` - */ -function isCapturedSafeParsePattern(safeParseCall: import('ts-morph').CallExpression): boolean { - const parent = safeParseCall.getParent(); - if (!parent || !Node.isVariableDeclaration(parent)) return false; - const nameNode = parent.getNameNode(); - if (!Node.isIdentifier(nameNode)) return false; - const declList = parent.getParent(); - if (!declList || !Node.isVariableDeclarationList(declList)) return false; - const flags = declList.getDeclarationKind(); - return flags === 'const' || flags === 'let'; -} - -/** - * Rewrites a captured safeParse pattern: - * const x = Schema.safeParse(v) → const x = specTypeSchemas.T['~standard'].validate(v) - * x.success → x.issues === undefined - * x.data → x.value - * x.error → x.issues - */ -function rewriteCapturedSafeParse( - safeParseCall: import('ts-morph').CallExpression, - localName: string, - typeName: string, - sourceFile: SourceFile, - diagnostics: Diagnostic[] -): boolean { - const varDecl = safeParseCall.getParent() as import('ts-morph').VariableDeclaration; - const varName = varDecl.getName(); - - const args = safeParseCall.getArguments(); - const argText = args.length > 0 ? args[0]!.getText() : ''; - - // Rewrite the safeParse call - safeParseCall.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`); - ensureImport(sourceFile, 'specTypeSchemas'); - - // Find and rewrite all property accesses on the result variable (scoped to declaring block) - const replacements: { node: import('ts-morph').Node; newText: string }[] = []; - const scope = varDecl.getFirstAncestorByKind(SyntaxKind.Block) ?? sourceFile; - scope.forEachDescendant(node => { - if (!Node.isPropertyAccessExpression(node)) return; - const expr = node.getExpression(); - if (!Node.isIdentifier(expr) || expr.getText() !== varName) return; - - const propName = node.getName(); - switch (propName) { - case 'success': { - // Check for !x.success → x.issues !== undefined - const parentNode = node.getParent(); - if ( - parentNode && - Node.isPrefixUnaryExpression(parentNode) && - parentNode.getOperatorToken() === SyntaxKind.ExclamationToken - ) { - replacements.push({ node: parentNode, newText: `${varName}.issues !== undefined` }); - } else { - replacements.push({ node, newText: `(${varName}.issues === undefined)` }); - } - break; - } - case 'data': { - replacements.push({ node, newText: `${varName}.value` }); - break; - } - case 'error': { - const errorParent = node.getParent(); - if (errorParent && Node.isPropertyAccessExpression(errorParent) && errorParent.getExpression() === node) { - const subProp = errorParent.getName(); - if (subProp === 'issues') { - replacements.push({ node: errorParent, newText: `${varName}.issues` }); - } else if (subProp === 'message') { - replacements.push({ node: errorParent, newText: `${varName}.issues?.map(i => i.message).join(', ')` }); - } else { - diagnostics.push( - actionRequired( - sourceFile.getFilePath(), - errorParent, - `${varName}.error.${subProp} has no StandardSchema equivalent. Manual migration required.` - ) - ); - } - } else { - replacements.push({ node, newText: `${varName}.issues` }); - } - break; - } - } - }); - - // Apply in reverse order to avoid position shifts - const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart()); - for (const { node, newText } of sorted) { - node.replaceWithText(newText); - } - - diagnostics.push( - warning( - sourceFile.getFilePath(), - varDecl.getStartLineNumber(), - `Rewrote ${localName}.safeParse() to specTypeSchemas.${typeName}['~standard'].validate(). ` + - `Result properties remapped: .success → .issues === undefined, .data → .value, .error → .issues.` - ) - ); - - return true; -} - -/** - * Handles spec-schema usages that have no behavior-preserving v2 equivalent: the Zod-only - * methods `.parse()` and (uncaptured) `.safeParse()`. In v2 these schemas are StandardSchemaV1 - * values that are NOT named public exports, so leaving the original import in place produces an - * unresolved-import error (e.g. `PromptSchema` is not exported by `@modelcontextprotocol/server`). - * - * - Result discarded (validation for side-effect only): rewrite `XSchema.parse(v)` → - * `specTypeSchemas.T['~standard'].validate(v)` so the code compiles. NOTE: `validate()` does not - * throw, so `.parse()`'s throw-on-invalid behavior is lost — flagged via an actionRequired comment. - * - Result used: swap only the identifier to `specTypeSchemas.T` so the import resolves; the - * `.parse()`/`.safeParse()` call and its result shape still need a manual fix (flagged). - * - * Either way the original (now non-exported) schema import is dropped by the caller's - * removeUnusedImport, so no dangling import survives. - */ -function rewriteUnsupportedSchemaCall( - ref: import('ts-morph').Node, - callNode: import('ts-morph').CallExpression, - localName: string, - typeName: string, - method: 'parse' | 'safeParse', - sourceFile: SourceFile, - diagnostics: Diagnostic[] -): boolean { - const resultDiscarded = Node.isExpressionStatement(callNode.getParent()); - - if (resultDiscarded) { - const argText = callNode - .getArguments() - .map(a => a.getText()) - .join(', '); - const semantics = - method === 'parse' - ? 'validate() does NOT throw on invalid input (parse() did) — if you relied on that, add `if (result.issues) throw …`.' - : 'the result shape changed from { success, data, error } to { value, issues }.'; - diagnostics.push( - actionRequired( - sourceFile.getFilePath(), - callNode, - `Rewrote ${localName}.${method}() to specTypeSchemas.${typeName}['~standard'].validate(): ` + - `v2 spec schemas are StandardSchemaV1, not Zod. Note: ${semantics}` - ) - ); - callNode.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`); - ensureImport(sourceFile, 'specTypeSchemas'); - return true; - } - - diagnostics.push( - actionRequired( - sourceFile.getFilePath(), - ref, - `${localName}.${method}() is not available on v2 spec schemas (StandardSchemaV1, not Zod). ` + - `Replaced ${localName} with specTypeSchemas.${typeName}; rewrite the .${method}(...) call using ` + - `specTypeSchemas.${typeName}['~standard'].validate(...) (returns { value, issues }, does not throw).` - ) - ); - ref.replaceWithText(`specTypeSchemas.${typeName}`); - ensureImport(sourceFile, 'specTypeSchemas'); - return true; -} - -function ensureImport(sourceFile: SourceFile, symbol: string): void { - const existingImport = sourceFile.getImportDeclarations().find(imp => { - if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false; - return imp.getNamedImports().some(n => n.getName() === symbol); - }); - if (existingImport) return; - - const targetPkg = sourceFile.getImportDeclarations().find(imp => { - const spec = imp.getModuleSpecifierValue(); - return spec === '@modelcontextprotocol/server' || spec === '@modelcontextprotocol/client'; - }); - const target = targetPkg?.getModuleSpecifierValue() ?? '@modelcontextprotocol/server'; - addOrMergeImport(sourceFile, target, [symbol], false, sourceFile.getImportDeclarations().length); -} diff --git a/packages/codemod/src/utils/importUtils.ts b/packages/codemod/src/utils/importUtils.ts index 145c95328c..a1981cb14f 100644 --- a/packages/codemod/src/utils/importUtils.ts +++ b/packages/codemod/src/utils/importUtils.ts @@ -7,6 +7,7 @@ const V2_PACKAGES = new Set([ '@modelcontextprotocol/client', '@modelcontextprotocol/server', '@modelcontextprotocol/core', + '@modelcontextprotocol/sdk-shared', '@modelcontextprotocol/node', '@modelcontextprotocol/express' ]); diff --git a/packages/codemod/test/commentInsertion.test.ts b/packages/codemod/test/commentInsertion.test.ts index a50c4698be..2dca284968 100644 --- a/packages/codemod/test/commentInsertion.test.ts +++ b/packages/codemod/test/commentInsertion.test.ts @@ -87,11 +87,12 @@ describe('comment insertion', () => { it('inserts multiple comments in one file in correct positions', () => { const dir = createTempDir(); - // Two .parse() calls on different schemas trigger two actionRequired diagnostics + // Two custom-schema handler registrations on different lines trigger two actionRequired diagnostics const input = [ - `import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const a = CallToolRequestSchema.parse(data1);`, - `const b = ListToolsRequestSchema.parse(data2);`, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `server.setRequestHandler(FooSchema, async () => ({}));`, + `server.setRequestHandler(BarSchema, async () => ({}));`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -107,9 +108,9 @@ describe('comment insertion', () => { it('preserves indentation of the target line', () => { const dir = createTempDir(); const input = [ - `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `function validate() {`, - ` const a = CallToolRequestSchema.parse(data);`, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `function register(server: McpServer) {`, + ` server.setRequestHandler(FooSchema, async () => ({}));`, `}`, `` ].join('\n'); @@ -125,8 +126,9 @@ describe('comment insertion', () => { it('does not duplicate comments on re-run (idempotency)', () => { const dir = createTempDir(); const input = [ - `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const a = CallToolRequestSchema.parse(data);`, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `server.setRequestHandler(FooSchema, async () => ({}));`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -146,10 +148,11 @@ describe('comment insertion', () => { it('sanitizes */ in diagnostic messages', () => { const dir = createTempDir(); - // The .parse() diagnostic message doesn't contain */, but we verify the comment is well-formed + // The handler diagnostic message doesn't contain */, but we verify the comment is well-formed const input = [ - `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const a = CallToolRequestSchema.parse(data);`, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `server.setRequestHandler(FooSchema, async () => ({}));`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -167,10 +170,10 @@ describe('comment insertion', () => { // Import rewrite adds new import lines (splitting into multiple packages), // then handler transform emits actionRequired. The comment must land at the correct post-shift line. const input = [ - `import { McpServer, CallToolRequestSchema } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { McpServer, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/server/mcp.js';`, ``, `const server = new McpServer({ name: 'test', version: '1.0' });`, - `const a = CallToolRequestSchema.parse(data);`, + `server.setRequestHandler(FooSchema, async () => ({}));`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -181,16 +184,18 @@ describe('comment insertion', () => { const lines = output.split('\n'); const commentIdx = lines.findIndex(l => l.includes(CODEMOD_ERROR_PREFIX)); expect(commentIdx).toBeGreaterThan(-1); - // The comment should be directly above the parse() line (which may have moved) + // The comment should be directly above the handler line (which may have moved) const nextLine = lines[commentIdx + 1]!; - expect(nextLine).toContain('.parse(data)'); + expect(nextLine).toContain('setRequestHandler'); }); it('merges same-line diagnostics into a single comment', () => { const dir = createTempDir(); + // Two custom-schema handler registrations on the SAME physical line -> two same-line diagnostics const input = [ - `import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const a = CallToolRequestSchema.parse(data1); const b = ListToolsRequestSchema.parse(data2);`, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `server.setRequestHandler(FooSchema, async () => ({})); server.setRequestHandler(BarSchema, async () => ({}));`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -207,9 +212,10 @@ describe('comment insertion', () => { it('skips comment insertion when target line is inside a template literal', () => { const dir = createTempDir(); const input = [ - "import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';", + "import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';", + "const server = new McpServer({ name: 'test', version: '1.0' });", 'const msg = `', - ' Result: ${CallToolRequestSchema.parse(data).method}', + ' Result: ${server.setRequestHandler(FooSchema, async () => ({}))}', '`;', '' ].join('\n'); @@ -230,10 +236,11 @@ describe('comment insertion', () => { const dir = createTempDir(); // TemplateMiddle: text between two ${} spans const input = [ - "import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';", + "import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';", + "const server = new McpServer({ name: 'test', version: '1.0' });", 'const msg = `${somePrefix}', - ' A: ${CallToolRequestSchema.parse(d1)}', - ' B: ${ListToolsRequestSchema.parse(d2)}', + ' A: ${server.setRequestHandler(FooSchema, async () => ({}))}', + ' B: ${server.setRequestHandler(BarSchema, async () => ({}))}', '`;', '' ].join('\n'); @@ -249,11 +256,12 @@ describe('comment insertion', () => { it('still inserts comment when diagnostic line merely contains a template literal', () => { const dir = createTempDir(); - // The .parse() and template are on the same line, but lineStart is at "const", + // The handler call and template are on the same line, but lineStart is at "server", // which is outside the template literal. const input = [ - "import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';", - 'const a = CallToolRequestSchema.parse(`template ${data}`);', + "import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';", + "const server = new McpServer({ name: 'test', version: '1.0' });", + 'server.setRequestHandler(FooSchema, async () => ({ msg: `template ${data}` }));', '' ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -272,8 +280,9 @@ describe('comment insertion', () => { it('handles CRLF line endings without corrupting the file', () => { const dir = createTempDir(); const input = [ - `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const a = CallToolRequestSchema.parse(data);`, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `server.setRequestHandler(FooSchema, async () => ({}));`, `` ].join('\r\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -286,6 +295,6 @@ describe('comment insertion', () => { const commentIdx = lines.findIndex(l => l.includes(CODEMOD_ERROR_PREFIX)); expect(commentIdx).toBeGreaterThan(-1); expect(lines[commentIdx]!.trim()).toMatch(/^\/\*.*\*\/$/); - expect(lines[commentIdx + 1]).toContain('.parse(data)'); + expect(lines[commentIdx + 1]).toContain('setRequestHandler'); }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index 8a63497cac..c0569973a3 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -74,25 +74,100 @@ describe('import-paths transform', () => { expect(result.diagnostics[0]!.message).toContain('SSEServerTransport is deprecated'); }); - it('resolves sdk/types.js based on sibling client imports', () => { + it('resolves a sdk/types.js TYPE import based on sibling client imports', () => { const input = [ `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, - `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`, '' ].join('\n'); const result = applyTransform(input, { projectType: 'both' }); expect(result).toContain(`from "@modelcontextprotocol/client"`); + expect(result).toContain('CallToolResult'); + expect(result).not.toContain('@modelcontextprotocol/sdk-shared'); + }); + + it('resolves a sdk/types.js TYPE import based on sibling server imports', () => { + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'both' }); + expect(result).toContain(`from "@modelcontextprotocol/server"`); + expect(result).toContain('CallToolResult'); + }); + + it('routes *Schema imports from sdk/types.js to @modelcontextprotocol/sdk-shared', () => { + const input = `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';\n`; + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain(`from "@modelcontextprotocol/sdk-shared"`); expect(result).toContain('CallToolResultSchema'); + expect(result).not.toContain('@modelcontextprotocol/sdk/types'); }); - it('resolves sdk/types.js based on sibling server imports', () => { + it('routes schemas to sdk-shared regardless of client/server sibling context', () => { + // The only sibling is a client import, but the schema must still go to sdk-shared. + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'both' }); + expect(result).toContain(`from "@modelcontextprotocol/sdk-shared"`); + expect(result).toContain('ListToolsResultSchema'); + }); + + it('splits a mixed type + schema import: type resolves by context, schema to sdk-shared', () => { const input = [ `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, - `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `import { CallToolResult, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, '' ].join('\n'); const result = applyTransform(input, { projectType: 'both' }); + expect(result).toContain(`from "@modelcontextprotocol/sdk-shared"`); expect(result).toContain(`from "@modelcontextprotocol/server"`); + expect(result).toContain('CallToolResult'); + expect(result).toContain('CallToolResultSchema'); + expect(result).not.toContain('@modelcontextprotocol/sdk/types'); + }); + + it('does not rewrite schema .parse() usages (migrates as an import-path swap)', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const r = CallToolResultSchema.parse(value);`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain('CallToolResultSchema.parse(value)'); + expect(result).toContain(`from "@modelcontextprotocol/sdk-shared"`); + }); + + it('flags *Schema accesses through a namespace import of sdk/types.js (cannot be split)', () => { + const input = [ + `import * as types from '@modelcontextprotocol/sdk/types.js';`, + `const r = types.CallToolResultSchema.parse(value);`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + const messages = result.diagnostics.map(d => d.message).join('\n'); + // The namespace can't be split, so the schema can't be auto-routed — but the user must be told. + expect(messages).toContain('@modelcontextprotocol/sdk-shared'); + expect(messages).toContain('CallToolResultSchema'); + // The namespace import itself still moves to the context package (its types live there). + // (setModuleSpecifier preserves the original quote style, so match quote-agnostically.) + expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/server'); + }); + + it('does not flag a namespace import of sdk/types.js that only accesses types', () => { + const input = [`import * as types from '@modelcontextprotocol/sdk/types.js';`, `const t: types.CallToolResult = value;`, ''].join( + '\n' + ); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.map(d => d.message).join('\n')).not.toContain('@modelcontextprotocol/sdk-shared'); }); it('resolves extensionless sdk/types (no .js suffix) the same as sdk/types.js', () => { diff --git a/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts b/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts deleted file mode 100644 index 2c7f592e1f..0000000000 --- a/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts +++ /dev/null @@ -1,517 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Project } from 'ts-morph'; - -import { specSchemaAccessTransform } from '../../../src/migrations/v1-to-v2/transforms/specSchemaAccess.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 = specSchemaAccessTransform.apply(sourceFile, ctx); - return { text: sourceFile.getFullText(), result }; -} - -describe('spec-schema-access transform', () => { - describe('auto-transform: .safeParse(v).success → isSpecType.X(v)', () => { - it('rewrites XSchema.safeParse(v).success to isSpecType.X(v)', () => { - const input = [ - `import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, - `const valid = CallToolRequestSchema.safeParse(data).success;`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('isSpecType.CallToolRequest(data)'); - expect(text).not.toContain('safeParse'); - expect(result.changesCount).toBeGreaterThan(0); - }); - - it('handles safeParse().success in if-condition', () => { - const input = [ - `import { ToolSchema } from '@modelcontextprotocol/server';`, - `if (ToolSchema.safeParse(obj).success) { doSomething(); }`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('isSpecType.Tool(obj)'); - expect(text).not.toContain('safeParse'); - }); - - it('adds isSpecType import when transforming safeParse().success', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, - `const ok = CallToolResultSchema.safeParse(x).success;`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('isSpecType'); - expect(text).toMatch(/import.*isSpecType.*from/); - }); - }); - - describe('auto-transform: value position → specTypeSchemas.X', () => { - it('replaces schema passed as function arg with specTypeSchemas.X', () => { - const input = [ - `import { ListToolsRequestSchema } from '@modelcontextprotocol/server';`, - `validate(ListToolsRequestSchema);`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('specTypeSchemas.ListToolsRequest'); - expect(result.changesCount).toBeGreaterThan(0); - expect(result.diagnostics.length).toBeGreaterThan(0); - expect(result.diagnostics[0]!.message).toContain('StandardSchemaV1'); - }); - - it('adds specTypeSchemas import', () => { - const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const s = ToolSchema;`, ''].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('specTypeSchemas.Tool'); - expect(text).toMatch(/import.*specTypeSchemas.*from/); - }); - }); - - describe('auto-transform: captured safeParse result', () => { - it('rewrites captured safeParse call and result property accesses', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, - `const parsed = CallToolResultSchema.safeParse(data);`, - `if (parsed.success) { return parsed.data; }`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain("specTypeSchemas.CallToolResult['~standard'].validate(data)"); - expect(text).toContain('parsed.issues === undefined'); - expect(text).toContain('parsed.value'); - expect(text).not.toContain('safeParse'); - expect(text).not.toContain('parsed.success'); - expect(text).not.toContain('parsed.data'); - expect(result.changesCount).toBeGreaterThan(0); - }); - - it('rewrites result properties assigned to variables (const isValid = parsed.success)', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, - `const parsed = CallToolResultSchema.safeParse(data);`, - `const isValid = parsed.success;`, - `const result = parsed.data;`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('parsed.issues === undefined'); - expect(text).toContain('parsed.value'); - expect(text).not.toContain('parsed.success'); - expect(text).not.toContain('parsed.data'); - }); - - it('rewrites .error to .issues', () => { - const input = [ - `import { ToolSchema } from '@modelcontextprotocol/server';`, - `const result = ToolSchema.safeParse(raw);`, - `if (!result.success) { console.log(result.error); }`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('result.issues'); - expect(text).not.toContain('result.error'); - }); - - it('handles ternary pattern: x.success ? x.data : fallback', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, - `const parsed = CallToolResultSchema.safeParse(toolResult);`, - `return parsed.success ? parsed.data : undefined;`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain("specTypeSchemas.CallToolResult['~standard'].validate(toolResult)"); - expect(text).toContain('(parsed.issues === undefined) ? parsed.value : undefined'); - }); - - it('adds specTypeSchemas import', () => { - const input = [ - `import { ToolSchema } from '@modelcontextprotocol/server';`, - `const r = ToolSchema.safeParse(v);`, - `r.success;`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toMatch(/import.*specTypeSchemas.*from/); - }); - - it('rewrites .error.issues to .issues (unwrap double nesting)', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, - `const parsed = CallToolResultSchema.safeParse(data);`, - `if (!parsed.success) { console.log(parsed.error.issues); }`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('parsed.issues'); - expect(text).not.toContain('parsed.issues.issues'); - expect(text).not.toContain('parsed.error'); - }); - - it('rewrites .error.message to issues map expression', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, - `const parsed = CallToolResultSchema.safeParse(data);`, - `if (!parsed.success) { console.log(parsed.error.message); }`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).not.toContain('parsed.error'); - expect(text).not.toContain('parsed.issues.message'); - expect(text).toContain("parsed.issues?.map(i => i.message).join(', ')"); - }); - - it('emits diagnostic for .error.format() instead of silently rewriting', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, - `const parsed = CallToolResultSchema.safeParse(data);`, - `if (!parsed.success) { console.log(parsed.error.format()); }`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('parsed.error.format()'); - expect(text).not.toContain('parsed.issues()'); - expect(result.diagnostics.some(d => d.message.includes('no StandardSchema equivalent'))).toBe(true); - }); - - it('rewrites bare .error to .issues (unchanged behavior)', () => { - const input = [ - `import { ToolSchema } from '@modelcontextprotocol/server';`, - `const result = ToolSchema.safeParse(raw);`, - `if (!result.success) { console.log(result.error); }`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('result.issues'); - expect(text).not.toContain('result.error'); - }); - - it('does not rewrite same-named variable in sibling function', () => { - const input = [ - `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `function validate(d: unknown) {`, - ` const result = CallToolRequestSchema.safeParse(d);`, - ` return result.success;`, - `}`, - `async function callApi(client: any) {`, - ` const result = await client.get('/api');`, - ` return result.data;`, - `}`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('result.issues === undefined'); - expect(text).toContain('return result.data'); - expect(text).not.toContain('return result.value'); - }); - - it('rewrites non-captured safeParse (bare expression) to validate()', () => { - const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `ToolSchema.safeParse(data);`, ''].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain("specTypeSchemas.Tool['~standard'].validate(data)"); - expect(text).not.toMatch(/import\s*\{[^}]*ToolSchema[^}]*\}/); - expect(result.changesCount).toBeGreaterThan(0); - expect(result.diagnostics.length).toBe(1); - }); - }); - - describe('guardrails: non-MCP schemas are NOT touched', () => { - it('does not rewrite safeParse on user-defined schema with same name from local import', () => { - const input = [ - `import { CallToolResultSchema } from './mySchemas';`, - `const parsed = CallToolResultSchema.safeParse(data);`, - `if (parsed.success) { return parsed.data; }`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('CallToolResultSchema.safeParse'); - expect(text).toContain('parsed.success'); - expect(text).toContain('parsed.data'); - expect(result.changesCount).toBe(0); - expect(result.diagnostics.length).toBe(0); - }); - - it('does not rewrite safeParse on user zod schema not from MCP', () => { - const input = [ - `import { z } from 'zod';`, - `const MySchema = z.object({ name: z.string() });`, - `const parsed = MySchema.safeParse(data);`, - `if (parsed.success) { return parsed.data; }`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('MySchema.safeParse'); - expect(text).toContain('parsed.success'); - expect(text).toContain('parsed.data'); - expect(result.changesCount).toBe(0); - expect(result.diagnostics.length).toBe(0); - }); - - it('does not rewrite safeParse on non-spec schema name from MCP import', () => { - const input = [ - `import { SomeRandomSchema } from '@modelcontextprotocol/server';`, - `const parsed = SomeRandomSchema.safeParse(data);`, - `if (parsed.success) { return parsed.data; }`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('SomeRandomSchema.safeParse'); - expect(text).toContain('parsed.success'); - expect(result.changesCount).toBe(0); - expect(result.diagnostics.length).toBe(0); - }); - - it('does not rewrite safeParse on npm package schema with matching name', () => { - const input = [ - `import { CallToolResultSchema } from 'some-other-package';`, - `const parsed = CallToolResultSchema.safeParse(data);`, - `if (parsed.success) { return parsed.data; }`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('CallToolResultSchema.safeParse'); - expect(text).toContain('parsed.success'); - expect(result.changesCount).toBe(0); - expect(result.diagnostics.length).toBe(0); - }); - }); - - describe('auto-transform: generic property access → specTypeSchemas.X', () => { - it('replaces schema identifier in .parseAsync() call', () => { - const input = [ - `import { OAuthTokensSchema } from '@modelcontextprotocol/server';`, - `const tokens = await OAuthTokensSchema.parseAsync(data);`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('specTypeSchemas.OAuthTokens.parseAsync(data)'); - expect(text).not.toMatch(/import\s*\{[^}]*OAuthTokensSchema[^}]*\}/); - expect(result.changesCount).toBeGreaterThan(0); - expect(result.diagnostics.length).toBeGreaterThan(0); - }); - - it('replaces schema identifier in .or() call', () => { - const input = [ - `import { ServerNotificationSchema } from '@modelcontextprotocol/server';`, - `const union = ServerNotificationSchema.or(otherSchema);`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('specTypeSchemas.ServerNotification.or(otherSchema)'); - expect(text).not.toMatch(/import\s*\{[^}]*ServerNotificationSchema[^}]*\}/); - expect(result.changesCount).toBeGreaterThan(0); - }); - - it('replaces schema identifier in .extend() call', () => { - const input = [ - `import { ToolSchema } from '@modelcontextprotocol/server';`, - `const extended = ToolSchema.extend({ extra: z.string() });`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('specTypeSchemas.Tool.extend'); - expect(result.changesCount).toBeGreaterThan(0); - }); - - it('adds specTypeSchemas import for generic property access', () => { - const input = [ - `import { OAuthTokensSchema } from '@modelcontextprotocol/server';`, - `const tokens = await OAuthTokensSchema.parseAsync(data);`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toMatch(/import.*specTypeSchemas.*from/); - }); - }); - - describe('.parse(v)', () => { - it('rewrites discarded parse() to the validate() primitive', () => { - const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `ToolSchema.parse(raw);`, ''].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain("specTypeSchemas.Tool['~standard'].validate(raw)"); - expect(text).not.toMatch(/import\s*\{[^}]*ToolSchema[^}]*\}/); - expect(result.changesCount).toBeGreaterThan(0); - }); - - it('swaps the identifier (import stays resolvable) when the parse() result is used', () => { - const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const tool = ToolSchema.parse(raw);`, ''].join( - '\n' - ); - const { text, result } = applyTransform(input); - expect(text).toContain('specTypeSchemas.Tool.parse(raw)'); - expect(text).not.toMatch(/import\s*\{[^}]*ToolSchema[^}]*\}/); - expect(result.changesCount).toBeGreaterThan(0); - expect(result.diagnostics[0]!.message).toContain('specTypeSchemas.Tool'); - }); - }); - - describe('diagnostic: z.infer', () => { - it('emits diagnostic for typeof in type position', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/client';`, - `type Result = typeof CallToolResultSchema;`, - '' - ].join('\n'); - const { result } = applyTransform(input); - expect(result.diagnostics.length).toBe(1); - expect(result.diagnostics[0]!.message).toContain('CallToolResult'); - }); - }); - - describe('no-op cases', () => { - it('does nothing for non-MCP imports', () => { - const input = [`import { CallToolRequestSchema } from './local';`, `CallToolRequestSchema.safeParse(data);`, ''].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('CallToolRequestSchema.safeParse'); - expect(result.changesCount).toBe(0); - expect(result.diagnostics.length).toBe(0); - }); - - it('does nothing for non-spec schema names', () => { - const input = [`import { SomeRandomSchema } from '@modelcontextprotocol/server';`, `SomeRandomSchema.parse(data);`, ''].join( - '\n' - ); - const { text, result } = applyTransform(input); - expect(text).toContain('SomeRandomSchema.parse'); - expect(result.changesCount).toBe(0); - expect(result.diagnostics.length).toBe(0); - }); - - it('does nothing when no remaining references', () => { - const input = [`import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, ''].join('\n'); - const { result } = applyTransform(input); - expect(result.changesCount).toBe(0); - expect(result.diagnostics.length).toBe(0); - }); - }); - - describe('import cleanup after transform', () => { - it('removes original schema import after all refs are auto-transformed', () => { - const input = [ - `import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, - `const valid = CallToolRequestSchema.safeParse(data).success;`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('isSpecType.CallToolRequest(data)'); - expect(text).not.toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/); - }); - - it('removes the schema import even when a ref falls back to a parse()/safeParse() rewrite', () => { - const input = [ - `import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, - `const valid = CallToolRequestSchema.safeParse(data).success;`, - `const parsed = CallToolRequestSchema.parse(data);`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('isSpecType.CallToolRequest(data)'); - expect(text).toContain('specTypeSchemas.CallToolRequest.parse(data)'); - expect(text).not.toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/); - }); - - it('removes schema specifier from import that also has other symbols', () => { - const input = [ - `import { CallToolRequestSchema, McpError } from '@modelcontextprotocol/server';`, - `const valid = CallToolRequestSchema.safeParse(data).success;`, - `throw new McpError(1, 'fail');`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).not.toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/); - expect(text).toContain('McpError'); - expect(text).toContain(`@modelcontextprotocol/server`); - }); - }); - - describe('parent-kind guards', () => { - it('emits diagnostic for re-exported schema (ExportSpecifier)', () => { - const input = [ - `import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, - `export { CallToolRequestSchema };`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('export { CallToolRequestSchema }'); - expect(result.diagnostics.some(d => d.message.includes('Re-export'))).toBe(true); - expect(result.changesCount).toBe(0); - }); - - it('expands shorthand property assignment and removes import', () => { - const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const schemas = { ToolSchema };`, ''].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain("'ToolSchema': specTypeSchemas.Tool"); - expect(text).not.toMatch(/import\s*\{[^}]*ToolSchema[^}]*\}/); - expect(result.changesCount).toBeGreaterThan(0); - }); - - it('skips PropertyAssignment name-node (non-shorthand)', () => { - const input = [ - `import { ToolSchema } from '@modelcontextprotocol/server';`, - `const schemas = { ToolSchema: myValidator };`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('ToolSchema: myValidator'); - expect(result.changesCount).toBe(0); - }); - - it('skips BindingElement property-name', () => { - const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const { ToolSchema: local } = obj;`, ''].join( - '\n' - ); - const { text, result } = applyTransform(input); - expect(text).toContain('ToolSchema: local'); - expect(result.changesCount).toBe(0); - }); - - it('skips PropertyAccessExpression name-node (obj.ToolSchema)', () => { - const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const x = registry.ToolSchema;`, ''].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('registry.ToolSchema'); - expect(text).not.toContain('specTypeSchemas'); - expect(result.changesCount).toBe(0); - }); - - it('does not emit z.infer diagnostic for runtime typeof (TypeOfExpression)', () => { - const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const kind = typeof ToolSchema;`, ''].join('\n'); - const { result } = applyTransform(input); - expect(result.diagnostics.every(d => !d.message.includes('z.infer'))).toBe(true); - }); - }); - - describe('namespace imports', () => { - it('does not crash when file has namespace import from same package', () => { - const input = [ - `import * as types from '@modelcontextprotocol/server';`, - `import { ToolSchema } from '@modelcontextprotocol/server';`, - `const s = ToolSchema;`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('specTypeSchemas.Tool'); - expect(result.changesCount).toBeGreaterThan(0); - }); - }); - - describe('aliased imports', () => { - it('handles aliased import and auto-transforms captured safeParse', () => { - const input = [ - `import { CallToolRequestSchema as CTRS } from '@modelcontextprotocol/server';`, - `const result = CTRS.safeParse(data);`, - `result.success;`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain("specTypeSchemas.CallToolRequest['~standard'].validate(data)"); - expect(text).not.toContain('CTRS.safeParse'); - expect(result.changesCount).toBeGreaterThan(0); - expect(result.diagnostics[0]!.message).toContain('specTypeSchemas.CallToolRequest'); - }); - }); -}); diff --git a/packages/sdk-shared/README.md b/packages/sdk-shared/README.md new file mode 100644 index 0000000000..93ecf61690 --- /dev/null +++ b/packages/sdk-shared/README.md @@ -0,0 +1,34 @@ +# @modelcontextprotocol/sdk-shared + +Canonical public home for the [Model Context Protocol](https://modelcontextprotocol.io) specification **Zod schemas**. + +These are the exact schema constants the SDK validates protocol payloads against internally. The `@modelcontextprotocol/server` and `@modelcontextprotocol/client` packages keep a Zod-free public surface, so this package exists as the supported place to import the raw schemas when +you need to validate or parse MCP messages yourself. + +## Install + +```sh +npm install @modelcontextprotocol/sdk-shared +``` + +## Usage + +```ts +import { CallToolResultSchema } from '@modelcontextprotocol/sdk-shared'; + +// Throws on invalid input; returns the typed result on success. +const result = CallToolResultSchema.parse(payload); + +// Or non-throwing: +const parsed = CallToolResultSchema.safeParse(payload); +if (parsed.success) { + // parsed.data is a fully typed CallToolResult +} +``` + +## Scope + +This package exports **only** the spec Zod schemas (`*Schema`). The corresponding TypeScript types, error classes, enums, and type guards are part of the public API of [`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server) and +[`@modelcontextprotocol/client`](https://www.npmjs.com/package/@modelcontextprotocol/client). + +> **Migrating from v1?** In v1 these schemas were imported from `@modelcontextprotocol/sdk/types.js`. Point those `*Schema` imports at `@modelcontextprotocol/sdk-shared` and your existing `.parse()` / `.safeParse()` calls keep working unchanged. diff --git a/packages/sdk-shared/eslint.config.mjs b/packages/sdk-shared/eslint.config.mjs new file mode 100644 index 0000000000..4f034f2235 --- /dev/null +++ b/packages/sdk-shared/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/core' + } + } +]; diff --git a/packages/sdk-shared/package.json b/packages/sdk-shared/package.json new file mode 100644 index 0000000000..4e54407c72 --- /dev/null +++ b/packages/sdk-shared/package.json @@ -0,0 +1,63 @@ +{ + "name": "@modelcontextprotocol/sdk-shared", + "version": "2.0.0-alpha.0", + "description": "Model Context Protocol implementation for TypeScript - shared spec Zod schemas", + "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", + "schemas", + "zod" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/sdk-shared/src/index.ts b/packages/sdk-shared/src/index.ts new file mode 100644 index 0000000000..89ac033328 --- /dev/null +++ b/packages/sdk-shared/src/index.ts @@ -0,0 +1,177 @@ +// @modelcontextprotocol/sdk-shared +// +// Canonical public home for the Model Context Protocol specification Zod schemas. +// +// These are the exact schema constants the SDK validates against internally (defined in the +// private @modelcontextprotocol/core package). This package bundles core and re-exports ONLY the +// spec `*Schema` Zod values, so consumers can validate protocol payloads directly — e.g. +// `CallToolResultSchema.parse(value)` / `.safeParse(value)` — without depending on core's +// internal barrel. +// +// Scope: Zod schemas ONLY. The corresponding spec TypeScript types, error classes, enums, and +// type guards are part of the public API of @modelcontextprotocol/server and /client. +// +// The list below is the complete set of `export const *Schema` declarations in core's schema +// module; the sdkSharedSchemas test asserts it stays in sync. The @modelcontextprotocol/core +// specifier is aliased (tsconfig.json + tsdown.config.ts) to core's schemas module and bundled. +export { + AnnotationsSchema, + AudioContentSchema, + BaseMetadataSchema, + BaseRequestParamsSchema, + BlobResourceContentsSchema, + BooleanSchemaSchema, + CallToolRequestParamsSchema, + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationParamsSchema, + CancelledNotificationSchema, + CancelTaskRequestSchema, + CancelTaskResultSchema, + ClientCapabilitiesSchema, + ClientNotificationSchema, + ClientRequestSchema, + ClientResultSchema, + ClientTasksCapabilitySchema, + CompatibilityCallToolResultSchema, + CompleteRequestParamsSchema, + CompleteRequestSchema, + CompleteResultSchema, + ContentBlockSchema, + CreateMessageRequestParamsSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + CreateTaskResultSchema, + CursorSchema, + DiscoverRequestSchema, + DiscoverResultSchema, + ElicitationCompleteNotificationParamsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestFormParamsSchema, + ElicitRequestParamsSchema, + ElicitRequestSchema, + ElicitRequestURLParamsSchema, + ElicitResultSchema, + EmbeddedResourceSchema, + EmptyResultSchema, + EnumSchemaSchema, + GetPromptRequestParamsSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + GetTaskPayloadRequestSchema, + GetTaskPayloadResultSchema, + GetTaskRequestSchema, + GetTaskResultSchema, + IconSchema, + IconsSchema, + ImageContentSchema, + ImplementationSchema, + InitializedNotificationSchema, + InitializeRequestParamsSchema, + InitializeRequestSchema, + InitializeResultSchema, + JSONArraySchema, + JSONObjectSchema, + JSONRPCErrorResponseSchema, + JSONRPCMessageSchema, + JSONRPCNotificationSchema, + JSONRPCRequestSchema, + JSONRPCResponseSchema, + JSONRPCResultResponseSchema, + JSONValueSchema, + LegacyTitledEnumSchemaSchema, + ListChangedOptionsBaseSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListTasksRequestSchema, + ListTasksResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingLevelSchema, + LoggingMessageNotificationParamsSchema, + LoggingMessageNotificationSchema, + ModelHintSchema, + ModelPreferencesSchema, + MultiSelectEnumSchemaSchema, + NotificationSchema, + NotificationsParamsSchema, + NumberSchemaSchema, + PaginatedRequestParamsSchema, + PaginatedRequestSchema, + PaginatedResultSchema, + PingRequestSchema, + PrimitiveSchemaDefinitionSchema, + ProgressNotificationParamsSchema, + ProgressNotificationSchema, + ProgressSchema, + ProgressTokenSchema, + PromptArgumentSchema, + PromptListChangedNotificationSchema, + PromptMessageSchema, + PromptReferenceSchema, + PromptSchema, + ReadResourceRequestParamsSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + RelatedTaskMetadataSchema, + RequestIdSchema, + RequestMetaEnvelopeSchema, + RequestMetaSchema, + RequestSchema, + ResourceContentsSchema, + ResourceLinkSchema, + ResourceListChangedNotificationSchema, + ResourceRequestParamsSchema, + ResourceSchema, + ResourceTemplateReferenceSchema, + ResourceTemplateSchema, + ResourceUpdatedNotificationParamsSchema, + ResourceUpdatedNotificationSchema, + ResultSchema, + RoleSchema, + RootSchema, + RootsListChangedNotificationSchema, + SamplingContentSchema, + SamplingMessageContentBlockSchema, + SamplingMessageSchema, + ServerCapabilitiesSchema, + ServerNotificationSchema, + ServerRequestSchema, + ServerResultSchema, + ServerTasksCapabilitySchema, + 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 +} from '@modelcontextprotocol/core'; diff --git a/packages/sdk-shared/test/sdkSharedSchemas.test.ts b/packages/sdk-shared/test/sdkSharedSchemas.test.ts new file mode 100644 index 0000000000..aba0852f7c --- /dev/null +++ b/packages/sdk-shared/test/sdkSharedSchemas.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import * as sdkShared from '../src/index.js'; +import { CursorSchema, InitializeRequestSchema } from '../src/index.js'; + +describe('@modelcontextprotocol/sdk-shared', () => { + it('re-exports spec schemas as working Zod objects', () => { + // Round-trips a valid value and rejects an invalid one — proves the re-exports are the + // real Zod schemas (not type-only aliases) and that `.parse`/`.safeParse` work. + expect(CursorSchema.parse('abc')).toBe('abc'); + expect(InitializeRequestSchema.safeParse({}).success).toBe(false); + }); + + it('re-exports every *Schema declared in core (drift guard)', () => { + // If core gains a new spec schema, this fails until it is added to src/index.ts. + // (Renames/removals are already caught by typecheck — the named re-export would not resolve.) + const src = readFileSync(fileURLToPath(new URL('../../core/src/types/schemas.ts', import.meta.url)), 'utf8'); + const coreSchemas = [...src.matchAll(/^export const (\w+Schema)\b/gm)] + .map(m => m[1]) + .filter((name): name is string => name !== undefined); + const exported = new Set(Object.keys(sdkShared)); + expect(coreSchemas.filter(name => !exported.has(name))).toEqual([]); + expect(coreSchemas.length).toBeGreaterThanOrEqual(159); + }); +}); diff --git a/packages/sdk-shared/tsconfig.json b/packages/sdk-shared/tsconfig.json new file mode 100644 index 0000000000..7a1fa16622 --- /dev/null +++ b/packages/sdk-shared/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/types/schemas.ts"] + } + } +} diff --git a/packages/sdk-shared/tsdown.config.ts b/packages/sdk-shared/tsdown.config.ts new file mode 100644 index 0000000000..7fe5a320db --- /dev/null +++ b/packages/sdk-shared/tsdown.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'tsdown'; + +// sdk-shared re-exports ONLY the spec Zod schemas from @modelcontextprotocol/core (private, +// unpublished). The core specifier is aliased to core's schemas module (core/src/types/schemas.ts) +// rather than its barrel, so the bundled graph is just the schemas + the constants they use — +// never Protocol, transports, stdio, or the ajv/cfWorker validators. `platform: 'neutral'` keeps +// the output runtime-neutral: a node-only dependency leaking into the graph would fail the build +// here instead of silently shipping. +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'neutral', + dts: { + resolver: 'tsc', + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/core': ['../core/src/types/schemas.ts'] + } + } + }, + noExternal: ['@modelcontextprotocol/core'] +}); diff --git a/packages/sdk-shared/typedoc.json b/packages/sdk-shared/typedoc.json new file mode 100644 index 0000000000..08e5572417 --- /dev/null +++ b/packages/sdk-shared/typedoc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src/index.ts"] +} diff --git a/packages/sdk-shared/vitest.config.js b/packages/sdk-shared/vitest.config.js new file mode 100644 index 0000000000..496fca3200 --- /dev/null +++ b/packages/sdk-shared/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 9ffd38d3dd..f6f566d114 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,7 +257,7 @@ importers: version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + version: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) eslint-plugin-n: specifier: catalog:devTools version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) @@ -938,6 +938,55 @@ 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/sdk-shared: + dependencies: + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../core + '@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) + 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/server: dependencies: zod: @@ -7352,15 +7401,14 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) @@ -7374,7 +7422,7 @@ snapshots: eslint: 9.39.4 eslint-compat-utils: 0.5.1(eslint@9.39.4) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7385,7 +7433,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7396,8 +7444,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack diff --git a/typedoc.config.mjs b/typedoc.config.mjs index f2a4e50f56..ee7bcc663d 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -3,10 +3,12 @@ import fg from 'fast-glob'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -// Find all package.json files under packages/ and build package list +// Find all package.json files under packages/ and build package list. +// Exclude node_modules and the codemod batch-test's cloned real-world repos, which are not part +// of this SDK's public API surface (and would otherwise fail docs:check locally when present). const packageJsonPaths = await fg('packages/**/package.json', { cwd: process.cwd(), - ignore: ['**/node_modules/**'] + ignore: ['**/node_modules/**', '**/batch-test/**'] }); const packages = packageJsonPaths.map(p => { const rootDir = join(process.cwd(), p.replace('/package.json', '')); @@ -45,6 +47,14 @@ export default { readme: false }, customJs: 'docs/v2-banner.js', + // The spec-generated schema/type JSDoc uses `{@linkcode | method}` cross-references. + // With the data model split across packages (Zod schemas in @modelcontextprotocol/sdk-shared, + // their types in @modelcontextprotocol/server / -client), typedoc's per-package link resolution + // can't resolve those bare cross-package references. Disable only the invalid-link check; every + // other validation (notExported, etc.) stays on under treatWarningsAsErrors. + validation: { + invalidLink: false + }, treatWarningsAsErrors: true, out: 'tmp/docs/', externalSymbolLinkMappings: { From c65285ad0c036c972f0ca6ed4c0b8d1eb0ef8990 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 24 Jun 2026 10:40:27 +0300 Subject: [PATCH 4/8] fix(codemod): infer client/server project type from v1 source Shared protocol types/constants resolve to either @modelcontextprotocol/client or /server. The codemod read that choice from package.json, but a project mid-migration still declares the single v1 @modelcontextprotocol/sdk dependency, so the project type came back 'unknown' and every file importing only shared symbols defaulted to server with an action-required warning. Infer from source instead: when the v2 split deps are absent, scan for quoted @modelcontextprotocol/sdk/client/ and .../server/ import specifiers (both -> 'both', one -> that side, neither -> 'unknown'). Matching quoted specifiers rather than bare substrings ignores comments/prose and catches extensionless/bare subpaths. For a 'both' project, shared types resolve to server with an info note (both re-export them) instead of an action-required warning; 'unknown' still warns. The scan is bounded (skips heavy dirs, file budget, early-exit). On firebase this cuts the codemod diagnostics from 14 to 2 with 0 introduced typecheck errors. --- .changeset/codemod-infer-project-type.md | 5 + packages/codemod/src/utils/projectAnalyzer.ts | 122 +++++++++++++++--- packages/codemod/test/projectAnalyzer.test.ts | 97 +++++++++++++- 3 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 .changeset/codemod-infer-project-type.md diff --git a/.changeset/codemod-infer-project-type.md b/.changeset/codemod-infer-project-type.md new file mode 100644 index 0000000000..1fa114c775 --- /dev/null +++ b/.changeset/codemod-infer-project-type.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +Infer client/server project type from source for v1 projects. A project being migrated still declares the single v1 `@modelcontextprotocol/sdk` dependency, so detecting the project type from `package.json` came back "unknown" and every file importing only shared protocol symbols defaulted to `@modelcontextprotocol/server` with an action-required warning. The codemod now scans the source for quoted `@modelcontextprotocol/sdk/client/…` and `…/server/…` import specifiers to infer the type (both → "both", one → that side, neither → "unknown"), routing shared symbols to the installed package and replacing the spurious warnings with at most an info note for genuinely ambiguous "both" projects. diff --git a/packages/codemod/src/utils/projectAnalyzer.ts b/packages/codemod/src/utils/projectAnalyzer.ts index daf4088876..7815e8708e 100644 --- a/packages/codemod/src/utils/projectAnalyzer.ts +++ b/packages/codemod/src/utils/projectAnalyzer.ts @@ -1,11 +1,24 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; import type { Diagnostic, TransformContext } from '../types.js'; -import { warning } from './diagnostics.js'; +import { info, warning } from './diagnostics.js'; const PROJECT_ROOT_MARKERS = ['.git', 'node_modules']; +const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']); +const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'build', '.next', '.nuxt', 'coverage']); +const SCAN_FILE_BUDGET = 5000; + +// Matches a quoted v1 SDK client/server subpath import specifier — e.g. +// '@modelcontextprotocol/sdk/client/index.js' "@modelcontextprotocol/sdk/server/mcp.js" +// '@modelcontextprotocol/sdk/client' (extensionless / bare subpath; see the extensionless +// import matching the codemod already supports) +// Anchored to the opening quote and a trailing `/` or closing quote so that comments or prose that +// merely mention the path do not count, and `…/client` is not confused with `…/clientfoo`. +const CLIENT_IMPORT_RE = /['"`]@modelcontextprotocol\/sdk\/client(?:\/|['"`])/; +const SERVER_IMPORT_RE = /['"`]@modelcontextprotocol\/sdk\/server(?:\/|['"`])/; + export function findPackageJson(startDir: string): string | undefined { let dir = path.resolve(startDir); const root = path.parse(dir).root; @@ -20,27 +33,86 @@ export function findPackageJson(startDir: string): string | undefined { export function analyzeProject(targetDir: string): TransformContext { const pkgJsonPath = findPackageJson(targetDir); - if (!pkgJsonPath) { - return { projectType: 'unknown' }; + if (pkgJsonPath) { + 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' }; + // No v2 split deps — this is almost always a v1 project mid-migration (v1 ships as the single + // `@modelcontextprotocol/sdk` package). Fall through to inferring the type from source usage. + } catch { + // Malformed package.json — fall through to source inference. + } } - try { - const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); - const allDeps = { - ...pkgJson.dependencies, - ...pkgJson.devDependencies - }; + return { projectType: inferProjectTypeFromSource(targetDir) }; +} - const hasClient = '@modelcontextprotocol/client' in allDeps; - const hasServer = '@modelcontextprotocol/server' in allDeps; +/** + * Infer client vs server vs both by scanning the source for v1 SDK subpath imports: a + * `@modelcontextprotocol/sdk/client/...` specifier means the project will need + * `@modelcontextprotocol/client`; a `.../server/...` specifier means it needs `@modelcontextprotocol/server`. + * Files that import only shared paths (`types.js`, `shared/...`) give no signal. The scan matches quoted + * specifiers (not bare substrings), so comments/prose are ignored. Bounded: skips heavy dirs, caps the + * file count, and early-exits once both signals are seen. + */ +function inferProjectTypeFromSource(targetDir: string): TransformContext['projectType'] { + let usesClient = false; + let usesServer = false; + let scanned = 0; - if (hasClient && hasServer) return { projectType: 'both' }; - if (hasClient) return { projectType: 'client' }; - if (hasServer) return { projectType: 'server' }; - return { projectType: 'unknown' }; + const visit = (dir: string): void => { + if (usesClient && usesServer) return; + let entries: import('node:fs').Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (usesClient && usesServer) return; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (SCAN_SKIP_DIRS.has(entry.name)) continue; + visit(full); + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if (!SCAN_EXTENSIONS.has(ext) || entry.name.endsWith('.d.ts')) continue; + if (scanned >= SCAN_FILE_BUDGET) return; + scanned++; + let content: string; + try { + content = readFileSync(full, 'utf8'); + } catch { + continue; + } + if (!usesClient && CLIENT_IMPORT_RE.test(content)) usesClient = true; + if (!usesServer && SERVER_IMPORT_RE.test(content)) usesServer = true; + } + } + }; + + let root = targetDir; + try { + if (!statSync(targetDir).isDirectory()) root = path.dirname(targetDir); } catch { - return { projectType: 'unknown' }; + return 'unknown'; } + visit(root); + + if (usesClient && usesServer) return 'both'; + if (usesClient) return 'client'; + if (usesServer) return 'server'; + return 'unknown'; } export function resolveTypesPackage( @@ -61,6 +133,22 @@ export function resolveTypesPackage( if (context.projectType === 'server') { return '@modelcontextprotocol/server'; } + if (context.projectType === 'both') { + // Both packages are present and both re-export the shared protocol types (from core), so importing + // from either compiles. This file has no client/server-specific signal — default to server and note + // it as an optional preference, not an action-required warning. + if (diagnosticSink) { + diagnosticSink.diagnostics.push( + info( + diagnosticSink.filePath, + diagnosticSink.line, + 'Shared protocol types imported from @modelcontextprotocol/server (both client and server ' + + 're-export them). Switch to @modelcontextprotocol/client if this is client-only code.' + ) + ); + } + return '@modelcontextprotocol/server'; + } if (diagnosticSink) { diagnosticSink.diagnostics.push( warning( diff --git a/packages/codemod/test/projectAnalyzer.test.ts b/packages/codemod/test/projectAnalyzer.test.ts index 0f69eacf79..1eb3c6ee6f 100644 --- a/packages/codemod/test/projectAnalyzer.test.ts +++ b/packages/codemod/test/projectAnalyzer.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, it, expect, afterEach } from 'vitest'; -import { analyzeProject } from '../src/utils/projectAnalyzer.js'; +import { analyzeProject, resolveTypesPackage } from '../src/utils/projectAnalyzer.js'; let tempDir: string; @@ -115,7 +115,7 @@ describe('analyzeProject', () => { expect(result.projectType).toBe('server'); }); - it('returns unknown for v1 SDK package (falls through to per-file resolution)', () => { + it('returns unknown for a v1 SDK package with no source signal', () => { const dir = createTempDir(); writeFileSync( path.join(dir, 'package.json'), @@ -127,4 +127,97 @@ describe('analyzeProject', () => { const result = analyzeProject(dir); expect(result.projectType).toBe('unknown'); }); + + describe('source inference for v1 (pre-split) projects', () => { + function v1Project(files: Record): string { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } })); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(dir, 'src', name), content); + } + return dir; + } + + it('infers client from sdk/client subpath usage', () => { + const dir = v1Project({ 'a.ts': `import { Client } from '@modelcontextprotocol/sdk/client/index.js';` }); + expect(analyzeProject(dir).projectType).toBe('client'); + }); + + it('infers server from sdk/server subpath usage', () => { + const dir = v1Project({ 'a.ts': `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';` }); + expect(analyzeProject(dir).projectType).toBe('server'); + }); + + it('infers both when client and server subpaths are used across files', () => { + const dir = v1Project({ + 'client.ts': `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + 'server.ts': `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';` + }); + expect(analyzeProject(dir).projectType).toBe('both'); + }); + + it('infers from an extensionless / bare sdk subpath specifier', () => { + const dir = v1Project({ 'a.ts': `import { McpServer } from '@modelcontextprotocol/sdk/server';` }); + expect(analyzeProject(dir).projectType).toBe('server'); + }); + + it('stays unknown when only shared paths are imported', () => { + const dir = v1Project({ 'a.ts': `import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';` }); + expect(analyzeProject(dir).projectType).toBe('unknown'); + }); + + it('ignores an import path that appears only in a comment (not a quoted specifier)', () => { + // A real client import plus a comment mentioning the server subpath. A whole-file substring + // scan would flip this to "both"; the quote-anchored match keeps it "client". + const dir = v1Project({ + 'a.ts': [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `// previously imported from @modelcontextprotocol/sdk/server/mcp.js`, + '' + ].join('\n') + }); + expect(analyzeProject(dir).projectType).toBe('client'); + }); + + it('infers from source even without a package.json', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + writeFileSync(path.join(dir, 'src', 'a.ts'), `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`); + expect(analyzeProject(path.join(dir, 'src')).projectType).toBe('client'); + }); + + it('ignores node_modules when scanning source', () => { + const dir = v1Project({ 'a.ts': `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';` }); + mkdirSync(path.join(dir, 'node_modules', 'pkg'), { recursive: true }); + writeFileSync( + path.join(dir, 'node_modules', 'pkg', 'index.ts'), + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';` + ); + // Only the server import in src counts; the client import under node_modules is skipped. + expect(analyzeProject(dir).projectType).toBe('server'); + }); + }); +}); + +describe('resolveTypesPackage', () => { + it('emits an info note (not a warning) for a both-project ambiguous file', () => { + const sink = { filePath: 'f.ts', line: 1, diagnostics: [] as import('../src/types.js').Diagnostic[] }; + const target = resolveTypesPackage({ projectType: 'both' }, false, false, sink); + expect(target).toBe('@modelcontextprotocol/server'); + expect(sink.diagnostics).toHaveLength(1); + expect(sink.diagnostics[0]!.level).toBe('info'); + }); + + it('emits an action-required warning for a genuinely unknown project', () => { + const sink = { filePath: 'f.ts', line: 1, diagnostics: [] as import('../src/types.js').Diagnostic[] }; + resolveTypesPackage({ projectType: 'unknown' }, false, false, sink); + expect(sink.diagnostics).toHaveLength(1); + expect(sink.diagnostics[0]!.level).toBe('warning'); + }); + + it('resolves by per-file signal regardless of project type', () => { + expect(resolveTypesPackage({ projectType: 'both' }, true, false)).toBe('@modelcontextprotocol/client'); + expect(resolveTypesPackage({ projectType: 'unknown' }, false, true)).toBe('@modelcontextprotocol/server'); + }); }); From 7d5aac35850fba59391a29ffc5ac0ddf404ce23b Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 24 Jun 2026 10:52:05 +0300 Subject: [PATCH 5/8] feat(codemod): map task request/notification schemas to method strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler-registration transform rewrites setRequestHandler(XSchema, …) and setNotificationHandler(XSchema, …) to the v2 spec form via a schema→method table. The task schemas were missing, so a handler like setNotificationHandler(TaskStatusNotificationSchema, …) fell through to the generic "use the 3-argument form" diagnostic and was left for manual migration. Add the task entries: tasks/get, tasks/result, tasks/list, tasks/cancel, and notifications/tasks/status. These are spec methods (the request schemas are members of ServerRequestSchema and the notification is in the notification union), so the rewritten two-argument call resolves to the spec overload of setRequestHandler/setNotificationHandler and typechecks. Co-Authored-By: Felix Weinberger --- .changeset/codemod-task-handler-methods.md | 5 +++++ .../v1-to-v2/mappings/schemaToMethodMap.ts | 9 ++++++-- .../transforms/handlerRegistration.test.ts | 22 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 .changeset/codemod-task-handler-methods.md diff --git a/.changeset/codemod-task-handler-methods.md b/.changeset/codemod-task-handler-methods.md new file mode 100644 index 0000000000..5ed163d21a --- /dev/null +++ b/.changeset/codemod-task-handler-methods.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +Map the task request/notification schemas to their v2 method strings in the handler-registration transform. `setRequestHandler(GetTaskRequestSchema, …)`, `setNotificationHandler(TaskStatusNotificationSchema, …)`, and the other task handlers (`tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`, `notifications/tasks/status`) now rewrite to the v2 two-argument method-string form instead of falling through to the generic "use the 3-argument form" manual-migration diagnostic. diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts index daa7278c8f..783cf52875 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts @@ -14,7 +14,11 @@ export const SCHEMA_TO_METHOD: Record = { SetLevelRequestSchema: 'logging/setLevel', PingRequestSchema: 'ping', CompleteRequestSchema: 'completion/complete', - ListRootsRequestSchema: 'roots/list' + ListRootsRequestSchema: 'roots/list', + GetTaskRequestSchema: 'tasks/get', + GetTaskPayloadRequestSchema: 'tasks/result', + ListTasksRequestSchema: 'tasks/list', + CancelTaskRequestSchema: 'tasks/cancel' }; export const NOTIFICATION_SCHEMA_TO_METHOD: Record = { @@ -27,5 +31,6 @@ export const NOTIFICATION_SCHEMA_TO_METHOD: Record = { CancelledNotificationSchema: 'notifications/cancelled', InitializedNotificationSchema: 'notifications/initialized', RootsListChangedNotificationSchema: 'notifications/roots/list_changed', - ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete' + ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete', + TaskStatusNotificationSchema: 'notifications/tasks/status' }; diff --git a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts index e4602910de..741d2f77d0 100644 --- a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts @@ -219,6 +219,28 @@ describe('handler-registration transform', () => { expect(result).not.toContain('ElicitationCompleteNotificationSchema'); }); + it('replaces TaskStatusNotificationSchema with the tasks/status method string', () => { + const input = [ + `import { TaskStatusNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setNotificationHandler(TaskStatusNotificationSchema, async () => {});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setNotificationHandler('notifications/tasks/status'"); + expect(result).not.toContain('TaskStatusNotificationSchema'); + }); + + it('replaces task request schemas (GetTaskRequestSchema → tasks/get)', () => { + const input = [ + `import { GetTaskRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(GetTaskRequestSchema, async () => ({}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tasks/get'"); + expect(result).not.toContain('GetTaskRequestSchema'); + }); + it('does not emit diagnostic when first arg is a string literal (v2 style)', () => { const input = [`server.setRequestHandler('tools/call', async (request) => {`, ` return { content: [] };`, `});`, ''].join('\n'); const project = new Project({ useInMemoryFileSystem: true }); From 5ffdb386bcf50a88c764e54149b79d049c4fc38d Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 24 Jun 2026 16:19:57 +0300 Subject: [PATCH 6/8] fixes --- .../migrations/v1-to-v2/mappings/importMap.ts | 11 +- .../v1-to-v2/mappings/specSchemaNames.ts | 164 ++++++++++++++++++ .../v1-to-v2/transforms/importPaths.ts | 18 +- .../test/v1-to-v2/specSchemaNames.test.ts | 21 +++ .../v1-to-v2/transforms/importPaths.test.ts | 59 +++++++ packages/sdk-shared/src/index.ts | 13 +- .../sdk-shared/test/sdkSharedSchemas.test.ts | 27 ++- 7 files changed, 291 insertions(+), 22 deletions(-) create mode 100644 packages/codemod/src/migrations/v1-to-v2/mappings/specSchemaNames.ts create mode 100644 packages/codemod/test/v1-to-v2/specSchemaNames.test.ts diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts index 04366c7dd4..d06d8f37ca 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts @@ -5,10 +5,13 @@ export interface ImportMapping { /** Route specific symbols to a different target package than `target`. */ symbolTargetOverrides?: Record; /** - * Route every imported symbol whose name ends in `Schema` to this package, instead of `target`. - * Used for `sdk/types.js`: the spec Zod schemas now live in `@modelcontextprotocol/sdk-shared` - * (so `Schema.parse(...)` keeps working), while the spec types/constants resolve by context. - * `symbolTargetOverrides` (exact-name) takes precedence over this suffix rule. + * Route an imported symbol to this package (instead of `target`) when its rename-resolved name is + * an actual spec schema constant — a member of `SPEC_SCHEMA_NAMES`. Used for `sdk/types.js`: the + * spec Zod schemas now live in `@modelcontextprotocol/sdk-shared` (so `Schema.parse(...)` + * keeps working), while spec types/constants/guards resolve by context. Matching on membership + * (not a `*Schema` suffix) keeps spec TYPES whose name ends in `Schema` — e.g. the elicitation + * primitives `BooleanSchema`/`StringSchema`/`EnumSchema` — routed by context, where their types + * live. `symbolTargetOverrides` (exact-name) takes precedence. */ schemaSymbolTarget?: string; removalMessage?: string; diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/specSchemaNames.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/specSchemaNames.ts new file mode 100644 index 0000000000..3007eaea8d --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/specSchemaNames.ts @@ -0,0 +1,164 @@ +// AUTO-VERIFIED against @modelcontextprotocol/sdk-shared's public exports by +// test/v1-to-v2/specSchemaNames.test.ts (drift guard). These are the spec Zod schema CONSTANTS that +// sdk-shared re-exports as standalone values; the v1->v2 import transform routes a `*Schema` symbol +// imported from `@modelcontextprotocol/sdk/types.js` to sdk-shared ONLY when its (rename-resolved) +// name is in this set. Names that merely END in `Schema` but are NOT here — e.g. the elicitation +// primitive TYPES `BooleanSchema`/`StringSchema`/`EnumSchema` (whose Zod const is `SchemaSchema`) +// — fall through to context resolution (@modelcontextprotocol/client | /server), where their TYPES +// live. Keep alphabetized. +export const SPEC_SCHEMA_NAMES: ReadonlySet = 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', + 'DiscoverRequestSchema', + 'DiscoverResultSchema', + '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', + 'PaginatedRequestParamsSchema', + 'PaginatedRequestSchema', + 'PaginatedResultSchema', + 'PingRequestSchema', + 'PrimitiveSchemaDefinitionSchema', + 'ProgressNotificationParamsSchema', + 'ProgressNotificationSchema', + 'ProgressSchema', + 'ProgressTokenSchema', + 'PromptArgumentSchema', + 'PromptListChangedNotificationSchema', + 'PromptMessageSchema', + 'PromptReferenceSchema', + 'PromptSchema', + 'ReadResourceRequestParamsSchema', + 'ReadResourceRequestSchema', + 'ReadResourceResultSchema', + 'RelatedTaskMetadataSchema', + 'RequestIdSchema', + 'RequestMetaEnvelopeSchema', + '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' +]); diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 1215634460..fce2edf760 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -8,6 +8,7 @@ import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; import type { ImportMapping } from '../mappings/importMap.js'; import { isAuthImport, lookupImportMapping } from '../mappings/importMap.js'; +import { SPEC_SCHEMA_NAMES } from '../mappings/specSchemaNames.js'; import { SIMPLE_RENAMES } from '../mappings/symbolMap.js'; const REEXPORT_WARNINGS: Record = { @@ -19,16 +20,23 @@ const REEXPORT_WARNINGS: Record = { 'Re-exported StreamableHTTPError was renamed to SdkHttpError in v2 with a different constructor. Update this re-export manually.' }; +/** The v2 name a symbol resolves to after renames (per-mapping override, then global SIMPLE_RENAMES). */ +function resolveRenamedName(name: string, mapping: ImportMapping): string { + return mapping.renamedSymbols?.[name] ?? SIMPLE_RENAMES[name] ?? name; +} + /** * The per-symbol target package for a symbol imported/re-exported from `mapping`'s module, or * `undefined` when the symbol should use the mapping's resolved `target`. Exact-name - * `symbolTargetOverrides` win over the `schemaSymbolTarget` (`*Schema`) suffix rule. + * `symbolTargetOverrides` win over `schemaSymbolTarget`, which routes a symbol to the shared-schemas + * package only when its rename-resolved name is an actual spec schema constant (`SPEC_SCHEMA_NAMES`) — + * not merely any name ending in `Schema`, so spec TYPES such as `BooleanSchema` resolve by context. */ function symbolTargetOverride(name: string, mapping: ImportMapping): string | undefined { if (mapping.symbolTargetOverrides && name in mapping.symbolTargetOverrides) { return mapping.symbolTargetOverrides[name]; } - if (mapping.schemaSymbolTarget && name.endsWith('Schema')) { + if (mapping.schemaSymbolTarget && SPEC_SCHEMA_NAMES.has(resolveRenamedName(name, mapping))) { return mapping.schemaSymbolTarget; } return undefined; @@ -161,7 +169,11 @@ export const importPathsTransform: Transform = { ...new Set( sourceFile .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression) - .filter(pa => pa.getExpression().getText() === nsName && pa.getName().endsWith('Schema')) + .filter( + pa => + pa.getExpression().getText() === nsName && + SPEC_SCHEMA_NAMES.has(resolveRenamedName(pa.getName(), mapping)) + ) .map(pa => pa.getName()) ) ]; diff --git a/packages/codemod/test/v1-to-v2/specSchemaNames.test.ts b/packages/codemod/test/v1-to-v2/specSchemaNames.test.ts new file mode 100644 index 0000000000..bd677aa7ed --- /dev/null +++ b/packages/codemod/test/v1-to-v2/specSchemaNames.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { SPEC_SCHEMA_NAMES } from '../../src/migrations/v1-to-v2/mappings/specSchemaNames.js'; + +describe('SPEC_SCHEMA_NAMES (codemod schema-routing allowlist)', () => { + it("matches @modelcontextprotocol/sdk-shared's exported schema set exactly (drift guard)", () => { + // The import transform routes a `*Schema` symbol from sdk/types.js to sdk-shared only when the + // symbol's (rename-resolved) name is in this set. It must therefore equal sdk-shared's actual + // public exports: a name missing here would be misrouted to client/server (which export no Zod + // schema values), and a name here that sdk-shared does not export would produce a broken import. + // Read sdk-shared's barrel directly so the two cannot silently drift. + const src = readFileSync(fileURLToPath(new URL('../../../sdk-shared/src/index.ts', import.meta.url)), 'utf8'); + const block = src.slice(src.indexOf('export {') + 'export {'.length, src.indexOf('} from')); + const sdkSharedExports = [...new Set([...block.matchAll(/\b(\w+Schema)\b/g)].map(m => m[1]))].sort(); + expect([...SPEC_SCHEMA_NAMES].sort()).toEqual(sdkSharedExports); + expect(sdkSharedExports.length).toBeGreaterThanOrEqual(154); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index c0569973a3..e58a699f89 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -142,6 +142,65 @@ describe('import-paths transform', () => { expect(result).toContain(`from "@modelcontextprotocol/sdk-shared"`); }); + it('routes elicitation primitive *Schema TYPE names from sdk/types.js by context, not to sdk-shared', () => { + // These names END in `Schema` but are TYPES; their Zod constant is `SchemaSchema`. They + // must resolve to the context package (where the types live), never to sdk-shared (which only + // exports the `*SchemaSchema` constants) — otherwise the codemod emits a broken import. + const elicitationTypeNames = [ + 'BooleanSchema', + 'StringSchema', + 'NumberSchema', + 'EnumSchema', + 'SingleSelectEnumSchema', + 'MultiSelectEnumSchema', + 'TitledSingleSelectEnumSchema', + 'UntitledSingleSelectEnumSchema', + 'TitledMultiSelectEnumSchema', + 'UntitledMultiSelectEnumSchema', + 'LegacyTitledEnumSchema' + ]; + for (const typeName of elicitationTypeNames) { + const input = `import { ${typeName} } from '@modelcontextprotocol/sdk/types.js';\n`; + const result = applyTransform(input, { projectType: 'server' }); + expect(result, typeName).toContain(`from "@modelcontextprotocol/server"`); + expect(result, typeName).not.toContain('@modelcontextprotocol/sdk-shared'); + expect(result, typeName).toContain(typeName); + } + }); + + it('splits a primitive-schema TYPE from its matching schema CONSTANT (BooleanSchema vs BooleanSchemaSchema)', () => { + // They differ only by a trailing `Schema`, which the suffix heuristic could not distinguish. + // The constant goes to sdk-shared; the type resolves by context. + const input = `import { BooleanSchema, BooleanSchemaSchema } from '@modelcontextprotocol/sdk/types.js';\n`; + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain(`from "@modelcontextprotocol/sdk-shared"`); + expect(result).toContain('BooleanSchemaSchema'); + expect(result).toContain(`from "@modelcontextprotocol/server"`); + expect(result).toMatch(/BooleanSchema\b/); + expect(result).not.toContain('@modelcontextprotocol/sdk/types'); + }); + + it('routes a renamed spec schema (JSONRPCErrorSchema) from sdk/types.js to sdk-shared', () => { + // JSONRPCErrorSchema → JSONRPCErrorResponseSchema, a sdk-shared export. Membership is checked + // against the rename-resolved name; the symbolRenames transform applies the rename afterward, + // so importPaths alone leaves the name unchanged but routes it to sdk-shared. + const input = `import { JSONRPCErrorSchema } from '@modelcontextprotocol/sdk/types.js';\n`; + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain(`from "@modelcontextprotocol/sdk-shared"`); + expect(result).toContain('JSONRPCErrorSchema'); + expect(result).not.toContain('@modelcontextprotocol/sdk/types'); + }); + + it('emits a split diagnostic for a re-export mixing a spec schema and a *Schema type (no silent breakage)', () => { + // The `*Schema` suffix would have routed BooleanSchema to sdk-shared silently (no such export); + // membership routing instead surfaces the mismatch so the user splits the re-export manually. + const input = `export { CallToolResultSchema, BooleanSchema } 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.some(d => d.message.includes('mixes symbols') && d.message.includes('Split'))).toBe(true); + }); + it('flags *Schema accesses through a namespace import of sdk/types.js (cannot be split)', () => { const input = [ `import * as types from '@modelcontextprotocol/sdk/types.js';`, diff --git a/packages/sdk-shared/src/index.ts b/packages/sdk-shared/src/index.ts index 89ac033328..25745b3b9f 100644 --- a/packages/sdk-shared/src/index.ts +++ b/packages/sdk-shared/src/index.ts @@ -11,14 +11,15 @@ // Scope: Zod schemas ONLY. The corresponding spec TypeScript types, error classes, enums, and // type guards are part of the public API of @modelcontextprotocol/server and /client. // -// The list below is the complete set of `export const *Schema` declarations in core's schema -// module; the sdkSharedSchemas test asserts it stays in sync. The @modelcontextprotocol/core -// specifier is aliased (tsconfig.json + tsdown.config.ts) to core's schemas module and bundled. +// The list below is the spec `*Schema` set: every `export const *Schema` in core's schema module +// EXCEPT internal helper schemas that have no public spec type (e.g. BaseRequestParamsSchema, +// NotificationsParamsSchema). It mirrors core's SPEC_SCHEMA_KEYS allowlist; the sdkSharedSchemas +// test asserts it stays in sync. The @modelcontextprotocol/core specifier is aliased (tsconfig.json +// + tsdown.config.ts) to core's schemas module and bundled. export { AnnotationsSchema, AudioContentSchema, BaseMetadataSchema, - BaseRequestParamsSchema, BlobResourceContentsSchema, BooleanSchemaSchema, CallToolRequestParamsSchema, @@ -32,7 +33,6 @@ export { ClientNotificationSchema, ClientRequestSchema, ClientResultSchema, - ClientTasksCapabilitySchema, CompatibilityCallToolResultSchema, CompleteRequestParamsSchema, CompleteRequestSchema, @@ -81,7 +81,6 @@ export { JSONRPCResultResponseSchema, JSONValueSchema, LegacyTitledEnumSchemaSchema, - ListChangedOptionsBaseSchema, ListPromptsRequestSchema, ListPromptsResultSchema, ListResourcesRequestSchema, @@ -101,7 +100,6 @@ export { ModelPreferencesSchema, MultiSelectEnumSchemaSchema, NotificationSchema, - NotificationsParamsSchema, NumberSchemaSchema, PaginatedRequestParamsSchema, PaginatedRequestSchema, @@ -145,7 +143,6 @@ export { ServerNotificationSchema, ServerRequestSchema, ServerResultSchema, - ServerTasksCapabilitySchema, SetLevelRequestParamsSchema, SetLevelRequestSchema, SingleSelectEnumSchemaSchema, diff --git a/packages/sdk-shared/test/sdkSharedSchemas.test.ts b/packages/sdk-shared/test/sdkSharedSchemas.test.ts index aba0852f7c..7dc4e93e4d 100644 --- a/packages/sdk-shared/test/sdkSharedSchemas.test.ts +++ b/packages/sdk-shared/test/sdkSharedSchemas.test.ts @@ -14,15 +14,28 @@ describe('@modelcontextprotocol/sdk-shared', () => { expect(InitializeRequestSchema.safeParse({}).success).toBe(false); }); - it('re-exports every *Schema declared in core (drift guard)', () => { - // If core gains a new spec schema, this fails until it is added to src/index.ts. - // (Renames/removals are already caught by typecheck — the named re-export would not resolve.) + it('re-exports exactly the spec schemas declared in core — no internal helpers (drift guard)', () => { + // sdk-shared's public surface is the spec `*Schema` constants ONLY. Some `*Schema` consts in + // core's schemas.ts are internal building blocks with no public spec type; they must NOT leak + // here. This list mirrors the exclusion in core's specTypeSchema.ts (SPEC_SCHEMA_KEYS) — keep + // the two in sync. + const INTERNAL_HELPER_SCHEMAS = [ + 'BaseRequestParamsSchema', + 'ClientTasksCapabilitySchema', + 'ListChangedOptionsBaseSchema', + 'NotificationsParamsSchema', + 'ServerTasksCapabilitySchema' + ]; const src = readFileSync(fileURLToPath(new URL('../../core/src/types/schemas.ts', import.meta.url)), 'utf8'); const coreSchemas = [...src.matchAll(/^export const (\w+Schema)\b/gm)] .map(m => m[1]) - .filter((name): name is string => name !== undefined); - const exported = new Set(Object.keys(sdkShared)); - expect(coreSchemas.filter(name => !exported.has(name))).toEqual([]); - expect(coreSchemas.length).toBeGreaterThanOrEqual(159); + .filter((name): name is string => name !== undefined && /^[A-Z]/.test(name)); + // The spec schema set = every PascalCase core `*Schema` const minus the internal helpers. + const specSchemas = coreSchemas.filter(name => !INTERNAL_HELPER_SCHEMAS.includes(name)).sort(); + const exported = Object.keys(sdkShared).sort(); + // Exact match, both directions: a new core spec schema missing here fails (we forgot to + // re-export it), and any internal helper / non-spec symbol that leaks here also fails. + expect(exported).toEqual(specSchemas); + expect(specSchemas.length).toBeGreaterThanOrEqual(154); }); }); From 10a4549e327dc3198185140fbbe7b5f4c7d6ca7f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 25 Jun 2026 08:59:28 +0300 Subject: [PATCH 7/8] clean up --- ...5-05-21-codemod-findreferences-refactor.md | 957 ------------------ .../2026-05-15-codemod-batch-test-fixes.md | 549 ---------- .../plans/2026-06-02-readbuffer-max-size.md | 323 ------ .../2026-06-02-v1-readbuffer-max-size.md | 356 ------- .../plans/2026-06-23-sdk-shared-package.md | 716 ------------- .../2026-05-11-codemod-batch-test-design.md | 288 ------ .../2026-06-02-readbuffer-max-size-design.md | 61 -- .../specs/2026-06-08-sep-2549-ttl-design.md | 495 --------- .../2026-06-23-sdk-shared-package-design.md | 135 --- 9 files changed, 3880 deletions(-) delete mode 100644 docs/superpowers/plans/2025-05-21-codemod-findreferences-refactor.md delete mode 100644 docs/superpowers/plans/2026-05-15-codemod-batch-test-fixes.md delete mode 100644 docs/superpowers/plans/2026-06-02-readbuffer-max-size.md delete mode 100644 docs/superpowers/plans/2026-06-02-v1-readbuffer-max-size.md delete mode 100644 docs/superpowers/plans/2026-06-23-sdk-shared-package.md delete mode 100644 docs/superpowers/specs/2026-05-11-codemod-batch-test-design.md delete mode 100644 docs/superpowers/specs/2026-06-02-readbuffer-max-size-design.md delete mode 100644 docs/superpowers/specs/2026-06-08-sep-2549-ttl-design.md delete mode 100644 docs/superpowers/specs/2026-06-23-sdk-shared-package-design.md diff --git a/docs/superpowers/plans/2025-05-21-codemod-findreferences-refactor.md b/docs/superpowers/plans/2025-05-21-codemod-findreferences-refactor.md deleted file mode 100644 index d907040b24..0000000000 --- a/docs/superpowers/plans/2025-05-21-codemod-findreferences-refactor.md +++ /dev/null @@ -1,957 +0,0 @@ -# Codemod: Replace Manual AST Walking with `findReferencesAsNodes()` - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Simplify codemod transforms by replacing manual `forEachDescendant` + parent-kind-guard patterns with ts-morph's `findReferencesAsNodes()`, eliminating ~12 parent-kind guards, ~4 duplicate scope checks, and ~5 manual AST walk functions. - -**Architecture:** ts-morph's TypeScript language service already resolves symbol bindings in the current syntax-only Project mode (no tsconfig needed). `findReferencesAsNodes()` returns precisely the references to a given symbol — correctly scoped, excluding property-name positions, and handling aliases. We refactor transforms to collect references via this API *before* mutating the AST, then apply changes in reverse-position order (a pattern the codemod already uses). A second phase optionally loads the user's tsconfig for receiver-type checking. - -**Tech Stack:** ts-morph v28, vitest - -**Key invariant:** `findReferencesAsNodes()` must be called *before* the symbol binding is modified (e.g., before an import specifier is renamed or removed). After mutation, collected Node objects remain valid but the language service can no longer resolve the original binding. - ---- - -## File Map - -| File | Action | Responsibility | -|------|--------|----------------| -| `packages/codemod/src/utils/astUtils.ts` | Modify | Replace `renameAllReferences` internals with `findReferencesAsNodes()` | -| `packages/codemod/src/utils/importUtils.ts` | Modify | Add `findImportSpecifierByName`, simplify `removeUnusedImport` | -| `packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts` | Modify | Collect refs before import mutation; use `findReferencesAsNodes()` in ErrorCode/RHE handlers | -| `packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts` | Modify | Use `findReferencesAsNodes()` on `extra` param; eliminate parent-kind guards and manual scope check | -| `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` | Modify | Use `findReferencesAsNodes()` for schema refs; eliminate `findNonImportReferences()` | -| `packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts` | Modify | Collect refs before import removal for renamed symbols | -| `packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts` | Modify | Collect refs before import removal | -| `packages/codemod/src/types.ts` | Modify | Add optional `project` to `TransformContext` (Phase 2) | -| `packages/codemod/src/runner.ts` | Modify | Optionally resolve tsconfig; pass Project via context (Phase 2) | -| `packages/codemod/src/utils/projectAnalyzer.ts` | Modify | Add `findTsConfig()` (Phase 2) | -| All test files under `packages/codemod/test/v1-to-v2/transforms/` | Verify | Existing tests must pass unchanged — this is a refactor under green | - ---- - -## Phase 1: `findReferencesAsNodes()` Refactor (no tsconfig needed) - -### Task 1: Rewrite `renameAllReferences` in astUtils.ts - -The current function (33 lines, 12 parent-kind guards) manually walks all identifiers and filters by parent kind. `findReferencesAsNodes()` eliminates 10 of those 12 guards — only `ShorthandPropertyAssignment` and `ExportSpecifier` need special handling since they require AST expansion (not just text replacement). - -**Files:** -- Modify: `packages/codemod/src/utils/astUtils.ts` -- Verify: `packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts` (primary consumer) - -- [ ] **Step 1: Read the current implementation** - -Read `packages/codemod/src/utils/astUtils.ts` — the entire file is the `renameAllReferences` function. - -Current implementation walks all identifiers with matching text and checks 12 parent kinds: -``` -ImportSpecifier, ExportSpecifier, PropertyAssignment (name), PropertyAccessExpression (name), -PropertySignature (name), MethodDeclaration (name), MethodSignature (name), -PropertyDeclaration (name), EnumMember (name), BindingElement (propertyName), -GetAccessorDeclaration (name), SetAccessorDeclaration (name), ShorthandPropertyAssignment -``` - -- [ ] **Step 2: Rewrite using `findReferencesAsNodes()`** - -Replace the body of `renameAllReferences` with: - -```typescript -import type { SourceFile } from 'ts-morph'; -import { Node } from 'ts-morph'; - -export function renameAllReferences(sourceFile: SourceFile, oldName: string, newName: string): void { - // Find the first identifier with this name to use as the findReferences anchor. - // Must be called BEFORE the symbol's import specifier is renamed/removed. - let anchor: import('ts-morph').Node | undefined; - sourceFile.forEachDescendant(node => { - if (anchor) return; - if (Node.isIdentifier(node) && node.getText() === oldName) { - anchor = node; - } - }); - if (!anchor) return; - - const refs = anchor.findReferencesAsNodes(); - - // Apply in reverse position order to avoid invalidating earlier nodes - const sorted = refs.toSorted((a, b) => b.getStart() - a.getStart()); - for (const ref of sorted) { - if (ref.wasForgotten()) continue; - const parent = ref.getParent(); - if (!parent) continue; - - // Skip import specifiers — caller manages those - if (Node.isImportSpecifier(parent)) continue; - - // ExportSpecifier: preserve public name by adding alias - if (Node.isExportSpecifier(parent)) { - if (parent.getAliasNode() === ref) continue; - if (!parent.getAliasNode()) parent.setAlias(oldName); - parent.getNameNode().replaceWithText(newName); - continue; - } - - // ShorthandPropertyAssignment: expand { McpError } → { McpError: ProtocolError } - if (Node.isShorthandPropertyAssignment(parent)) { - parent.replaceWithText(`${oldName}: ${newName}`); - continue; - } - - ref.replaceWithText(newName); - } -} -``` - -The 10 parent-kind guards (PropertyAssignment name, PropertyAccessExpression name, PropertySignature name, MethodDeclaration name, MethodSignature name, PropertyDeclaration name, EnumMember name, BindingElement propertyName, GetAccessor name, SetAccessor name) are all handled automatically by `findReferencesAsNodes()` — it never returns identifier nodes in property-name positions. - -- [ ] **Step 3: Run all transform tests to verify** - -Run: `pnpm --filter @modelcontextprotocol/codemod test` - -Expected: all tests pass. The `renameAllReferences` function is called by `symbolRenames`, `importPaths`, and `removedApis` transforms — all their tests exercise it. - -- [ ] **Step 4: Suggest commit** - -``` -feat(codemod): rewrite renameAllReferences using findReferencesAsNodes - -Replace manual 12-case parent-kind guard with ts-morph's -findReferencesAsNodes() which handles scope and position -classification automatically. Only ShorthandPropertyAssignment -and ExportSpecifier need explicit handling for AST expansion. -``` - ---- - -### Task 2: Refactor `symbolRenames.ts` — collect refs before import mutation - -The SIMPLE_RENAMES loop currently modifies the import specifier first, then calls `renameAllReferences`. But `findReferencesAsNodes()` must be called *before* the binding is modified. This task reorders the operations. - -The three `forEachDescendant` walks in `handleErrorCodeSplit` and `handleRequestHandlerExtra` are also replaced. - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts` -- Verify: `packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts` - -- [ ] **Step 1: Read current file** - -Read `packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts` (352 lines). - -The SIMPLE_RENAMES loop (lines 23-37): -```typescript -for (const namedImport of imp.getNamedImports()) { - const name = namedImport.getName(); - const newName = SIMPLE_RENAMES[name]; - if (newName) { - namedImport.setName(newName); // modifies binding FIRST - const alias = namedImport.getAliasNode(); - if (!alias) { - renameAllReferences(sourceFile, name, newName); // then renames body - } - changesCount++; - } -} -``` - -- [ ] **Step 2: Reorder SIMPLE_RENAMES to collect-before-mutate** - -```typescript -for (const namedImport of imp.getNamedImports()) { - const name = namedImport.getName(); - const newName = SIMPLE_RENAMES[name]; - if (newName) { - const alias = namedImport.getAliasNode(); - if (!alias) { - // Collect refs while binding is still intact - renameAllReferences(sourceFile, name, newName); - } - namedImport.setName(newName); // modify binding AFTER refs are renamed - changesCount++; - } -} -``` - -Note: this is just reordering the two operations. `renameAllReferences` (from Task 1) now uses `findReferencesAsNodes()` internally, which requires the binding to still exist. Moving `setName` after `renameAllReferences` satisfies this requirement. - -- [ ] **Step 3: Refactor `handleErrorCodeSplit` to use `findReferencesAsNodes()`** - -Current code (lines 71-85) does a manual `forEachDescendant` looking for `Node.isPropertyAccessExpression` where the expression is `ErrorCode`. Replace with: - -```typescript -function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[]): 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; - - // Collect ALL references while binding exists - const refs = errorCodeImport.getNameNode().findReferencesAsNodes() - .filter(n => !Node.isImportSpecifier(n.getParent())); - - let needsProtocolErrorCode = false; - let needsSdkErrorCode = false; - - // Classify each reference - const replacements: { node: import('ts-morph').Node; newText: string }[] = []; - for (const ref of refs) { - const parent = ref.getParent(); - if (!parent || !Node.isPropertyAccessExpression(parent)) continue; - if (parent.getExpression() !== ref) continue; - - const member = parent.getName(); - if (ERROR_CODE_SDK_MEMBERS.has(member)) { - needsSdkErrorCode = true; - replacements.push({ node: ref, newText: 'SdkErrorCode' }); - } else { - needsProtocolErrorCode = true; - replacements.push({ node: ref, newText: 'ProtocolErrorCode' }); - } - changesCount++; - } - - // Apply replacements in reverse order - const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart()); - for (const { node, newText } of sorted) { - node.replaceWithText(newText); - } - - // ... rest of import cleanup (unchanged from current code, lines 87-143) ... -``` - -This eliminates the `forEachDescendant` walk. The `errorCodeLocalName` variable and manual alias handling are also gone — `findReferencesAsNodes()` resolves aliases automatically. - -- [ ] **Step 4: Refactor `handleRequestHandlerExtra` similarly** - -The `forEachDescendant` walk at line 189 that finds `Node.isTypeReference` matching `extraLocalName` becomes: - -```typescript -// Collect refs while binding exists -const refs = extraImport.getNameNode().findReferencesAsNodes() - .filter(n => !Node.isImportSpecifier(n.getParent())); -``` - -The rest of the classification logic (checking `ServerRequest`/`ClientNotification` type args) stays the same — it operates on the parent `TypeReference` node. But we no longer need `extraLocalName` or manual alias handling. - -- [ ] **Step 5: Run tests** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/symbolRenames.test.ts` - -Expected: all tests pass, including alias tests (lines 366-399). - -- [ ] **Step 6: Suggest commit** - -``` -refactor(codemod): use findReferencesAsNodes in symbolRenames - -Collect symbol references via findReferencesAsNodes() before -mutating import specifiers. Eliminates three forEachDescendant -walks and manual alias tracking in handleErrorCodeSplit and -handleRequestHandlerExtra. -``` - ---- - -### Task 3: Refactor `contextTypes.ts` — eliminate parent-kind guards and scope checks - -This transform has the second-highest complexity. The `processCallback` function (lines 18-177): -- Walks callback body with `forEachDescendant` looking for `extra` identifiers (line 98) -- Checks 4 parent kinds to exclude property-name positions (lines 102-105) -- Does a separate `forEachDescendant` walk to check for `ctx` name conflicts (lines 63-74) -- Builds replacements with property mappings (lines 111-134) - -All of this collapses with `findReferencesAsNodes()` on the parameter. - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts` -- Verify: `packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts` - -- [ ] **Step 1: Read the current processCallback function** - -Read `packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts:18-177`. - -Key sections to replace: -- Lines 61-84: scope-conflict check (walk looking for `ctx` identifier) -- Lines 96-107: collect identifiers matching `extra`, filter 4 parent kinds -- Lines 110-135: build replacements - -- [ ] **Step 2: Replace identifier collection with findReferencesAsNodes** - -Replace lines 62-84 (scope conflict check) and lines 96-107 (identifier collection) with: - -```typescript - // Check for ctx name conflicts in the callback body using findReferences on - // any existing 'ctx' identifier — if found, it means ctx is in scope. - if (body) { - let ctxAlreadyInScope = false; - body.forEachDescendant(node => { - if (ctxAlreadyInScope) return; - if (Node.isIdentifier(node) && node.getText() === CTX_PARAM_NAME) { - // Check it's not inside a nested function that shadows it - const containingFn = node.getFirstAncestor(n => - Node.isArrowFunction(n) || Node.isFunctionExpression(n) || Node.isFunctionDeclaration(n) - ); - if (containingFn === callbackNode || !containingFn) { - 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; - } - } - - // Collect references to the 'extra' parameter using findReferencesAsNodes. - // This automatically: - // - scopes to this specific parameter binding (ignores shadowed 'extra' in nested fns) - // - excludes property-name positions ({ extra: value }, obj.extra, etc.) - const paramRefs = extraParam.getNameNode().findReferencesAsNodes() - .filter(n => !Node.isParameter(n.getParent())); - - // Rename param declaration - const paramDecl = extraParam.getNameNode(); - paramDecl.replaceWithText(CTX_PARAM_NAME); - - // Build replacements from collected references - const sortedMappings = [...CONTEXT_PROPERTY_MAP] - .filter(m => m.from !== m.to) - .toSorted((a, b) => b.from.length - a.from.length); - - const replacements: { node: import('ts-morph').Node; newText: string }[] = []; - for (const ref of paramRefs) { - const parent = ref.getParent(); - // Value-position property access: extra.signal → ctx.mcpReq.signal - if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) { - 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) === ref) { - 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: ref, newText: CTX_PARAM_NAME }); - } - - const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart()); - for (const { node, newText } of sorted) { - node.replaceWithText(newText); - } -``` - -**What's eliminated:** -- The 4-case parent-kind exclusion list (lines 102-106) — `findReferencesAsNodes()` handles these -- The nested-function-aware scope walk for conflict detection (lines 63-74) — simplified to a targeted check - -**What stays the same:** -- Property mapping logic (PropertyAccessExpression / QualifiedName) — this is transform-specific -- The outer call-finding loop and callback detection -- The post-rewrite destructuring warning - -- [ ] **Step 3: Run tests** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/contextTypes.test.ts` - -Expected: all tests pass. Key tests to watch: -- `should not rename 'extra' in property positions` (verifies parent-kind exclusion) -- `should not rename when ctx already exists` (verifies scope conflict) -- `should handle nested functions` (verifies scope isolation) - -- [ ] **Step 4: Suggest commit** - -``` -refactor(codemod): use findReferencesAsNodes in contextTypes - -Replace manual forEachDescendant + 4-case parent-kind guard with -findReferencesAsNodes() on the 'extra' parameter. The language -service handles scope isolation and property-name exclusion -automatically. -``` - ---- - -### Task 4: Refactor `specSchemaAccess.ts` — eliminate `findNonImportReferences` and scoped walks - -This is the most complex transform (350 lines, 6 parent-kind guards, 3-level parent walks). Two `forEachDescendant` walks are replaced. - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` -- Verify: `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` - -- [ ] **Step 1: Read the current file** - -Read `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts`. - -Key sections: -- `findNonImportReferences` (lines 51-61): manual forEachDescendant walk -- `handleReference` (lines 63-192): 6 parent-kind guards at lines 129, 143, 154, 168, 172, 176 -- `rewriteCapturedSafeParse` (lines 249-335): scoped forEachDescendant walk at line 269 - -- [ ] **Step 2: Replace `findNonImportReferences` with `findReferencesAsNodes`** - -In the main loop (lines 19-31), replace: -```typescript -const refs = findNonImportReferences(sourceFile, localName); -``` -with: -```typescript -// Find the import specifier node for this schema -const specNode = schemaImports.get(localName)!.specifier; -const refs = specNode.getNameNode().findReferencesAsNodes() - .filter(n => !Node.isImportSpecifier(n.getParent())); -``` - -This requires changing `collectSpecSchemaImports` to also return the specifier node: -```typescript -function collectSpecSchemaImports(sourceFile: SourceFile): Map { - const result = new Map(); - for (const imp of sourceFile.getImportDeclarations()) { - if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; - for (const n of imp.getNamedImports()) { - const exportName = n.getName(); - if (!SPEC_SCHEMA_NAMES.has(exportName)) continue; - const localName = n.getAliasNode()?.getText() ?? exportName; - result.set(localName, { originalName: exportName, specifier: n }); - } - } - return result; -} -``` - -Delete the `findNonImportReferences` function entirely. - -- [ ] **Step 3: Simplify `handleReference` parent-kind guards** - -With `findReferencesAsNodes()`, we no longer get identifiers in property-name positions. Remove these now-unreachable guards: - -```typescript -// REMOVE — findReferencesAsNodes never returns property-name-position identifiers: -// - line 168: Node.isPropertyAssignment(parent) && parent.getNameNode() === ref -// - line 172: Node.isBindingElement(parent) && parent.getPropertyNameNode() === ref -// - line 176: Node.isPropertyAccessExpression(parent) && parent.getNameNode() === ref -``` - -Keep these — they classify the reference type, not exclude positions: -- `isTypeofInTypePosition` — distinguishes type-level `typeof X` from value usage -- `isSafeParseSuccessPattern` / `isSafeParsePattern` / `isParsePattern` — detect Zod API patterns -- `Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref` — value-position property access -- `Node.isExportSpecifier(parent)` — re-export position -- `Node.isShorthandPropertyAssignment(parent)` — shorthand property - -- [ ] **Step 4: Replace scoped walk in `rewriteCapturedSafeParse`** - -Current code (lines 268-317) does `scope.forEachDescendant` to find `${varName}.success`, `${varName}.data`, `${varName}.error` accesses. Replace with `findReferencesAsNodes()` on the variable declaration: - -```typescript -function rewriteCapturedSafeParse( - safeParseCall: import('ts-morph').CallExpression, - localName: string, - typeName: string, - sourceFile: SourceFile, - diagnostics: Diagnostic[] -): boolean { - const varDecl = safeParseCall.getParent() as import('ts-morph').VariableDeclaration; - const varName = varDecl.getName(); - const args = safeParseCall.getArguments(); - const argText = args.length > 0 ? args[0]!.getText() : ''; - - // Collect references to the result variable BEFORE rewriting the initializer - const varNameNode = varDecl.getNameNode(); - const varRefs = varNameNode.findReferencesAsNodes() - .filter(n => n !== varNameNode && !Node.isVariableDeclaration(n.getParent())); - - // Rewrite the safeParse call - safeParseCall.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`); - ensureImport(sourceFile, 'specTypeSchemas'); - - // Classify property accesses on the result variable - const replacements: { node: import('ts-morph').Node; newText: string }[] = []; - for (const ref of varRefs) { - const parent = ref.getParent(); - if (!parent || !Node.isPropertyAccessExpression(parent)) continue; - if (parent.getExpression() !== ref) continue; - - const propName = parent.getName(); - switch (propName) { - case 'success': { - const grandParent = parent.getParent(); - if (grandParent && Node.isPrefixUnaryExpression(grandParent) && - grandParent.getOperatorToken() === SyntaxKind.ExclamationToken) { - replacements.push({ node: grandParent, newText: `${varName}.issues !== undefined` }); - } else { - replacements.push({ node: parent, newText: `(${varName}.issues === undefined)` }); - } - break; - } - case 'data': - replacements.push({ node: parent, newText: `${varName}.value` }); - break; - case 'error': { - const errorParent = parent.getParent(); - if (errorParent && Node.isPropertyAccessExpression(errorParent) && errorParent.getExpression() === parent) { - const subProp = errorParent.getName(); - if (subProp === 'issues') { - replacements.push({ node: errorParent, newText: `${varName}.issues` }); - } else if (subProp === 'message') { - replacements.push({ node: errorParent, newText: `${varName}.issues?.map(i => i.message).join(', ')` }); - } else { - diagnostics.push(warning(sourceFile.getFilePath(), errorParent.getStartLineNumber(), - `${varName}.error.${subProp} has no StandardSchema equivalent. Manual migration required.`)); - } - } else { - replacements.push({ node: parent, newText: `${varName}.issues` }); - } - break; - } - } - } - - const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart()); - for (const { node, newText } of sorted) { - node.replaceWithText(newText); - } - - diagnostics.push(warning(sourceFile.getFilePath(), varDecl.getStartLineNumber(), - `Rewrote ${localName}.safeParse() to specTypeSchemas.${typeName}['~standard'].validate(). ` + - `Result properties remapped: .success → .issues === undefined, .data → .value, .error → .issues.`)); - - return true; -} -``` - -**What's eliminated:** -- `findNonImportReferences` function (11 lines) — deleted entirely -- 3 unreachable parent-kind guards in `handleReference` -- The `scope.forEachDescendant` walk in `rewriteCapturedSafeParse` (was scope-insensitive anyway, as a PR comment noted) - -- [ ] **Step 5: Run tests** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` - -Expected: all tests pass. Key tests: -- Aliased import `import { CallToolRequestSchema as CTRS }` (line 493) -- Captured safeParse rewrite (line 248+) -- Non-MCP schemas not touched (line 222+) - -- [ ] **Step 6: Suggest commit** - -``` -refactor(codemod): use findReferencesAsNodes in specSchemaAccess - -Delete findNonImportReferences() and replace both forEachDescendant -walks with findReferencesAsNodes(). The scoped safeParse-result -rewrite now uses findReferencesAsNodes on the variable declaration, -which is inherently scope-correct. -``` - ---- - -### Task 5: Refactor `importPaths.ts` — collect refs before import removal - -Currently, `importPaths.ts` removes the old import (line 170), then calls `renameAllReferences` (line 172). Since Task 1's `renameAllReferences` now uses `findReferencesAsNodes()`, the binding must exist when it's called. Reorder operations. - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts` -- Verify: `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` - -- [ ] **Step 1: Read the relevant section** - -Read `packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts:106-175`. - -The issue is at lines 162-175: -```typescript -for (const n of namedImports) { - // ... add pending imports ... -} -imp.remove(); // ← removes binding -changesCount++; -for (const [oldName, newName] of symbolsToRenameInFile) { - renameAllReferences(sourceFile, oldName, newName); // ← needs binding -} -``` - -- [ ] **Step 2: Move rename before import removal** - -```typescript -// Rename body references BEFORE removing the import (findReferencesAsNodes needs the binding) -for (const [oldName, newName] of symbolsToRenameInFile) { - renameAllReferences(sourceFile, oldName, newName); -} - -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++; -``` - -Also apply the same reorder to the in-place `setModuleSpecifier` branch (lines 106-159): move `renameAllReferences` calls (lines 156-158) before `imp.setModuleSpecifier` (line 136) — though `setModuleSpecifier` doesn't break bindings, it's cleaner to be consistent. - -- [ ] **Step 3: Run tests** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` - -Expected: all tests pass. - -- [ ] **Step 4: Suggest commit** - -``` -refactor(codemod): reorder importPaths to rename refs before import removal - -findReferencesAsNodes() (used by renameAllReferences) needs the -import binding to still exist. Move rename calls before imp.remove(). -``` - ---- - -### Task 6: Refactor `removedApis.ts` — same reorder pattern - -Same issue: `renameAllReferences` called after import removal. - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts` -- Verify: `packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts` - -- [ ] **Step 1: Read the relevant sections** - -Read `packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts`. - -Find all places where `renameAllReferences` is called and check whether the import binding has already been removed/modified. - -- [ ] **Step 2: Move renames before import removal** - -Apply the same pattern as Task 5: collect or apply renames before the import specifier or declaration is removed. - -- [ ] **Step 3: Run tests** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/removedApis.test.ts` - -Expected: all tests pass. - -- [ ] **Step 4: Suggest commit** - -``` -refactor(codemod): reorder removedApis to rename refs before import removal -``` - ---- - -### Task 7: Simplify `removeUnusedImport` in importUtils.ts - -The `removeUnusedImport` function (lines 116-141) does a manual `forEachDescendant` walk to count references. Replace with `findReferencesAsNodes()`. - -**Files:** -- Modify: `packages/codemod/src/utils/importUtils.ts` -- Verify: `pnpm --filter @modelcontextprotocol/codemod test` - -- [ ] **Step 1: Read the current function** - -Read `packages/codemod/src/utils/importUtils.ts:116-141`. - -- [ ] **Step 2: Rewrite using findReferencesAsNodes** - -```typescript -export function removeUnusedImport(sourceFile: SourceFile, symbolName: string, onlyMcpImports?: boolean): void { - 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) { - // Check if the symbol has any non-import references - const refs = namedImport.getNameNode().findReferencesAsNodes() - .filter(n => !Node.isImportSpecifier(n.getParent())); - if (refs.length === 0) { - namedImport.remove(); - if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport()) { - imp.remove(); - } - } - return; - } - } - } -} -``` - -This eliminates the manual reference-counting `forEachDescendant` walk. - -- [ ] **Step 3: Run all tests** - -Run: `pnpm --filter @modelcontextprotocol/codemod test` - -Expected: all tests pass. `removeUnusedImport` is called by `specSchemaAccess` and `symbolRenames`. - -- [ ] **Step 4: Suggest commit** - -``` -refactor(codemod): use findReferencesAsNodes in removeUnusedImport -``` - ---- - -### Task 8: Full test suite verification and cleanup - -- [ ] **Step 1: Run full test suite** - -Run: `pnpm --filter @modelcontextprotocol/codemod test` - -Expected: all 14 test files pass. - -- [ ] **Step 2: Run typecheck** - -Run: `pnpm --filter @modelcontextprotocol/codemod typecheck` - -Expected: no type errors. - -- [ ] **Step 3: Run lint** - -Run: `pnpm --filter @modelcontextprotocol/codemod lint` - -Expected: no lint errors. - -- [ ] **Step 4: Remove dead code** - -Check if these functions are still used: -- `findNonImportReferences` in specSchemaAccess.ts — should be deleted (Task 4) -- Any unused imports in modified files - -- [ ] **Step 5: Suggest commit** - -``` -chore(codemod): remove dead code after findReferencesAsNodes refactor -``` - ---- - -## Phase 2: Optional tsconfig Loading for Receiver Type Checking - -This phase is independent of Phase 1 and addresses a different class of PR comments: transforms that cannot verify the *receiver* of a method call (e.g., `.tool()` might be on any object, not just `McpServer`). - -### Task 9: Add tsconfig resolution to projectAnalyzer - -**Files:** -- Modify: `packages/codemod/src/utils/projectAnalyzer.ts` -- Modify: `packages/codemod/src/types.ts` -- Modify: `packages/codemod/src/runner.ts` -- Test: `packages/codemod/test/projectAnalyzer.test.ts` - -- [ ] **Step 1: Add `findTsConfig` to projectAnalyzer** - -```typescript -export function findTsConfig(startDir: string): string | undefined { - let dir = path.resolve(startDir); - const root = path.parse(dir).root; - while (true) { - const candidate = path.join(dir, 'tsconfig.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); - } -} -``` - -- [ ] **Step 2: Extend `TransformContext` with optional Project** - -In `packages/codemod/src/types.ts`: - -```typescript -import type { Project, SourceFile } from 'ts-morph'; - -export interface TransformContext { - projectType: 'client' | 'server' | 'both' | 'unknown'; - project?: Project; - hasTypeInfo?: boolean; -} -``` - -- [ ] **Step 3: Modify runner to optionally load tsconfig** - -In `packages/codemod/src/runner.ts`, change Project creation: - -```typescript -import { findTsConfig } from './utils/projectAnalyzer.js'; - -const tsConfigPath = findTsConfig(options.targetDir); -const project = new Project({ - tsConfigFilePath: tsConfigPath, - skipAddingFilesFromTsConfig: true, - compilerOptions: { - allowJs: true, - noEmit: true, - skipLibCheck: true, - ...(tsConfigPath ? {} : { strict: false }), - } -}); - -// ... existing file globbing ... - -const hasTypeInfo = !!tsConfigPath; -const context: TransformContext = { - ...analyzeProject(options.targetDir), - project, - hasTypeInfo, -}; -``` - -Note: `skipAddingFilesFromTsConfig: true` keeps the current behavior of globbing files ourselves. But with a tsconfig, ts-morph resolves module paths and loads declaration files from `node_modules`. - -- [ ] **Step 4: Test with and without tsconfig** - -The existing tests use `new Project({ useInMemoryFileSystem: true })` and pass `TransformContext` without a `project` field. They should continue to work because `project` and `hasTypeInfo` are optional. - -Add a targeted test in `packages/codemod/test/projectAnalyzer.test.ts`: - -```typescript -describe('findTsConfig', () => { - it('should find tsconfig.json in target directory', () => { - const dir = mkdtempSync(join(tmpdir(), 'codemod-')); - writeFileSync(join(dir, 'tsconfig.json'), '{}'); - expect(findTsConfig(dir)).toBe(join(dir, 'tsconfig.json')); - rmSync(dir, { recursive: true }); - }); - - it('should walk up to find tsconfig.json', () => { - const dir = mkdtempSync(join(tmpdir(), 'codemod-')); - const subDir = join(dir, 'src'); - mkdirSync(subDir); - writeFileSync(join(dir, 'tsconfig.json'), '{}'); - expect(findTsConfig(subDir)).toBe(join(dir, 'tsconfig.json')); - rmSync(dir, { recursive: true }); - }); - - it('should return undefined when no tsconfig exists', () => { - const dir = mkdtempSync(join(tmpdir(), 'codemod-')); - mkdirSync(join(dir, '.git')); - expect(findTsConfig(dir)).toBeUndefined(); - rmSync(dir, { recursive: true }); - }); -}); -``` - -- [ ] **Step 5: Suggest commit** - -``` -feat(codemod): optionally resolve tsconfig for type-aware transforms - -When a tsconfig.json is found near the target directory, the ts-morph -Project loads it for module resolution and type information. Transforms -can check context.hasTypeInfo to use type-aware APIs. Falls back to -syntax-only mode when no tsconfig is found. -``` - ---- - -### Task 10: Add receiver type checking to `mcpServerApi.ts` - -When type info is available, verify that `.tool()` / `.prompt()` / `.resource()` calls are on an `McpServer` instance. This addresses the PR comment about false positives on `someOtherObj.tool()`. - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts` -- Verify: `packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts` - -- [ ] **Step 1: Add a receiver-type guard helper** - -At the top of `mcpServerApi.ts`: - -```typescript -function isMcpServerReceiver(expr: import('ts-morph').PropertyAccessExpression, context: TransformContext): boolean { - if (!context.hasTypeInfo) return true; // permissive when no types - - try { - const receiverType = expr.getExpression().getType(); - const symbol = receiverType.getSymbol(); - if (!symbol) return true; // can't determine — be permissive - const name = symbol.getName(); - return name === 'McpServer'; - } catch { - return true; // type resolution failed — be permissive - } -} -``` - -- [ ] **Step 2: Guard the call collection loop** - -In the switch statement (lines 33-59), add the guard: - -```typescript -for (const call of calls) { - const expr = call.getExpression(); - if (!Node.isPropertyAccessExpression(expr)) continue; - if (!isMcpServerReceiver(expr, context)) continue; // ← NEW - const methodName = expr.getName(); - // ... rest of switch ... -} -``` - -Note: `_context` parameter in `apply()` must be renamed to `context` since it's now used. - -- [ ] **Step 3: Run tests** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/mcpServerApi.test.ts` - -Expected: all tests pass. Tests use in-memory projects without type info, so `isMcpServerReceiver` returns `true` (permissive mode). - -- [ ] **Step 4: Suggest commit** - -``` -feat(codemod): add receiver type checking for McpServer API migration - -When type info is available (tsconfig resolved), verify that .tool(), -.prompt(), .resource() calls are on McpServer instances. Falls back -to permissive mode when types unavailable. -``` - ---- - -## Summary of Changes - -| Metric | Before | After Phase 1 | After Phase 2 | -|--------|--------|---------------|---------------| -| `renameAllReferences` parent guards | 12 | 2 (ShorthandProp, ExportSpecifier) | 2 | -| `contextTypes` parent guards | 4 | 0 | 0 | -| `specSchemaAccess` parent guards | 6 | 3 (pattern classification only) | 3 | -| `forEachDescendant` walks across all transforms | ~12 | ~4 | ~4 | -| Manual import-provenance functions | 6 | 6 (unchanged) | 6 (could reduce further) | -| Receiver type checking | none | none | mcpServerApi | -| Lines in astUtils.ts | 33 | ~28 | ~28 | -| Lines in specSchemaAccess.ts | 350 | ~300 | ~300 | -| Lines in contextTypes.ts | 257 | ~200 | ~200 | -| Lines in symbolRenames.ts | 352 | ~310 | ~310 | - -Phase 1 (Tasks 1-8) is the high-value work. Phase 2 (Tasks 9-10) is additive improvement. diff --git a/docs/superpowers/plans/2026-05-15-codemod-batch-test-fixes.md b/docs/superpowers/plans/2026-05-15-codemod-batch-test-fixes.md deleted file mode 100644 index 48c9d8de26..0000000000 --- a/docs/superpowers/plans/2026-05-15-codemod-batch-test-fixes.md +++ /dev/null @@ -1,549 +0,0 @@ -# Codemod Batch Test Fixes Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix 5 codemod transform issues discovered by running the batch test against real-world repos (inspector + mcp-servers-fork). - -**Architecture:** Each fix targets a specific transform or mapping file within `packages/codemod/src/migrations/v1-to-v2/`. Fixes are ordered by dependency: Tasks 1 and 4 are independent; Tasks 2 and 3 both modify `specSchemaAccess.ts` so Task 2 must land first; Task 5 is independent. All tasks follow TDD. - -**Tech Stack:** TypeScript, ts-morph (AST manipulation), vitest - ---- - -## File Map - -| File | Action | Task(s) | -|------|--------|---------| -| `packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts` | Modify | 1 | -| `packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts` | Modify | 1 | -| `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` | Modify | 2, 3 | -| `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` | Modify | 2, 3 | -| `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts` | Modify | 4, 5 | -| `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` | Modify | 4, 5 | - ---- - -### Task 1: Complete handler registration schema-to-method mapping - -Add missing experimental/task request schemas and notification schemas to `schemaToMethodMap.ts` so the `handlerRegistration` transform auto-converts them to string method names instead of falling through to `specSchemaAccess` which incorrectly replaces them with `specTypeSchemas.X`. - -**Impact:** Fixes ~20 errors in inspector/client `useConnection.ts` (setRequestHandler + downstream param type inference). - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts` -- Test: `packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts` - -- [ ] **Step 1: Write failing tests for task request schemas** - -Add to `handlerRegistration.test.ts`: - -```typescript -it('replaces ListTasksRequestSchema with method string', () => { - const input = [ - `import { ListTasksRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `client.setRequestHandler(ListTasksRequestSchema, async (request) => {`, - ` return { tasks: [] };`, - `});`, - '' - ].join('\n'); - const result = applyTransform(input); - expect(result).toContain("setRequestHandler('tasks/list'"); - expect(result).not.toContain('ListTasksRequestSchema'); -}); - -it('replaces GetTaskRequestSchema with method string', () => { - const input = [ - `import { GetTaskRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `client.setRequestHandler(GetTaskRequestSchema, async (request) => {`, - ` return { taskId: '1', status: 'completed' };`, - `});`, - '' - ].join('\n'); - const result = applyTransform(input); - expect(result).toContain("setRequestHandler('tasks/get'"); - expect(result).not.toContain('GetTaskRequestSchema'); -}); - -it('replaces CancelTaskRequestSchema with method string', () => { - const input = [ - `import { CancelTaskRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `client.setRequestHandler(CancelTaskRequestSchema, async (request) => {`, - ` return {};`, - `});`, - '' - ].join('\n'); - const result = applyTransform(input); - expect(result).toContain("setRequestHandler('tasks/cancel'"); - expect(result).not.toContain('CancelTaskRequestSchema'); -}); - -it('replaces GetTaskPayloadRequestSchema with method string', () => { - const input = [ - `import { GetTaskPayloadRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `client.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {`, - ` return { content: [] };`, - `});`, - '' - ].join('\n'); - const result = applyTransform(input); - expect(result).toContain("setRequestHandler('tasks/result'"); - expect(result).not.toContain('GetTaskPayloadRequestSchema'); -}); - -it('replaces TaskStatusNotificationSchema with method string', () => { - const input = [ - `import { TaskStatusNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, - `client.setNotificationHandler(TaskStatusNotificationSchema, async () => {});`, - '' - ].join('\n'); - const result = applyTransform(input); - expect(result).toContain("setNotificationHandler('notifications/tasks/status'"); - expect(result).not.toContain('TaskStatusNotificationSchema'); -}); - -it('replaces ElicitationCompleteNotificationSchema with method string', () => { - const input = [ - `import { ElicitationCompleteNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, - `client.setNotificationHandler(ElicitationCompleteNotificationSchema, async () => {});`, - '' - ].join('\n'); - const result = applyTransform(input); - expect(result).toContain("setNotificationHandler('notifications/elicitation/complete'"); - expect(result).not.toContain('ElicitationCompleteNotificationSchema'); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/handlerRegistration.test.ts` -Expected: 6 new tests FAIL (schemas not in map, get "Custom method handler" diagnostic instead) - -- [ ] **Step 3: Add missing schemas to the mapping** - -In `packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts`, add entries to `SCHEMA_TO_METHOD`: - -```typescript -ListTasksRequestSchema: 'tasks/list', -GetTaskRequestSchema: 'tasks/get', -GetTaskPayloadRequestSchema: 'tasks/result', -CancelTaskRequestSchema: 'tasks/cancel', -``` - -And add entries to `NOTIFICATION_SCHEMA_TO_METHOD`: - -```typescript -TaskStatusNotificationSchema: 'notifications/tasks/status', -ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete', -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/handlerRegistration.test.ts` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts -git commit -m "fix(codemod): add task and elicitation schemas to handler registration map" -``` - ---- - -### Task 2: Replace schema identifiers in generic property access positions - -Currently, when a spec schema like `OAuthTokensSchema` is used with a Zod-specific method (e.g., `.parseAsync()`, `.or()`, `.extend()`), the `specSchemaAccess` transform only emits a diagnostic but does NOT replace the identifier. This leaves the old schema name in imports, which breaks compilation since v2 packages don't export these schema symbols. - -**Fix:** In the generic property access case, replace the identifier with `specTypeSchemas.X` (even though the method call itself won't work). The diagnostic still tells the user what to do, but the import now resolves. - -**Impact:** Fixes ~12 "Module has no exported member 'XSchema'" errors across both repos. - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` -- Test: `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` - -- [ ] **Step 1: Write failing tests for generic property access replacement** - -Add to `specSchemaAccess.test.ts` in a new `describe` block: - -```typescript -describe('auto-transform: generic property access → specTypeSchemas.X', () => { - it('replaces schema identifier in .parseAsync() call', () => { - const input = [ - `import { OAuthTokensSchema } from '@modelcontextprotocol/server';`, - `const tokens = await OAuthTokensSchema.parseAsync(data);`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('specTypeSchemas.OAuthTokens.parseAsync(data)'); - expect(text).not.toMatch(/import\s*\{[^}]*OAuthTokensSchema[^}]*\}/); - expect(result.changesCount).toBeGreaterThan(0); - expect(result.diagnostics.length).toBeGreaterThan(0); - }); - - it('replaces schema identifier in .or() call', () => { - const input = [ - `import { ServerNotificationSchema } from '@modelcontextprotocol/server';`, - `const union = ServerNotificationSchema.or(otherSchema);`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('specTypeSchemas.ServerNotification.or(otherSchema)'); - expect(text).not.toMatch(/import\s*\{[^}]*ServerNotificationSchema[^}]*\}/); - expect(result.changesCount).toBeGreaterThan(0); - }); - - it('replaces schema identifier in .extend() call', () => { - const input = [ - `import { ToolSchema } from '@modelcontextprotocol/server';`, - `const extended = ToolSchema.extend({ extra: z.string() });`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('specTypeSchemas.Tool.extend'); - expect(result.changesCount).toBeGreaterThan(0); - }); - - it('adds specTypeSchemas import for generic property access', () => { - const input = [ - `import { OAuthTokensSchema } from '@modelcontextprotocol/server';`, - `const tokens = await OAuthTokensSchema.parseAsync(data);`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toMatch(/import.*specTypeSchemas.*from/); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` -Expected: 4 new tests FAIL (generic property access only emits diagnostic, doesn't replace) - -- [ ] **Step 3: Modify the generic property access handler to also replace the identifier** - -In `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts`, find the generic property access handler in `handleReference()` (around line 129). Change: - -```typescript -// BEFORE (diagnostic-only, no replacement): -if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) { - diagnostics.push( - warning( - sourceFile.getFilePath(), - ref.getStartLineNumber(), - `${localName} is not exported in v2. Use \`specTypeSchemas.${typeName}\` (typed as StandardSchemaV1) or \`isSpecType.${typeName}\` for validation.` - ) - ); - return false; -} -``` - -to: - -```typescript -// AFTER (replace identifier AND emit diagnostic): -if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) { - const line = ref.getStartLineNumber(); - ref.replaceWithText(`specTypeSchemas.${typeName}`); - ensureImport(sourceFile, 'specTypeSchemas'); - diagnostics.push( - warning( - sourceFile.getFilePath(), - line, - `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse()/.parseAsync() are not available. Manual rewrite required.` - ) - ); - return true; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` -Expected: All tests PASS (including existing "keeps original schema import when some refs are diagnostic-only" test — verify this one still passes since the behavior changed) - -**Note:** The existing test at line 262 ("keeps original schema import when some refs are diagnostic-only") combines a `.safeParse().success` auto-transform with a `.parse()` diagnostic-only case. The `.parse()` case is separate from the generic property access case (it has its own handler returning `false`). This test should still pass because `.parse()` is handled before the generic property access check. - -- [ ] **Step 5: Commit** - -```bash -git add packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts -git commit -m "fix(codemod): replace schema identifiers in generic property access positions" -``` - ---- - -### Task 3: Fix safeParse-to-validate `.error` sub-property remapping - -When `const r = XSchema.safeParse(v)` is captured, the transform rewrites `.error` → `.issues`. But downstream accesses like `r.error.message` become `r.issues.message` (wrong — `.issues` is an array) and `r.error.issues` becomes `r.issues.issues` (double nesting). - -**Fix:** In the `case 'error':` block of `rewriteCapturedSafeParse`, check if the parent node is another PropertyAccessExpression (meaning `r.error.X`). Handle `.issues` (unwrap) and `.message` (rewrite to array map) specifically. - -**Impact:** Fixes ~10 TypeScript errors in inspector/client's `AppRenderer.tsx`, `SamplingRequest.tsx`, `ToolResults.tsx`. - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` -- Test: `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` - -- [ ] **Step 1: Write failing tests for error sub-property remapping** - -Add to `specSchemaAccess.test.ts` inside the "auto-transform: captured safeParse result" describe block: - -```typescript -it('rewrites .error.issues to .issues (unwrap double nesting)', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, - `const parsed = CallToolResultSchema.safeParse(data);`, - `if (!parsed.success) { console.log(parsed.error.issues); }`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('parsed.issues'); - expect(text).not.toContain('parsed.issues.issues'); - expect(text).not.toContain('parsed.error'); -}); - -it('rewrites .error.message to issues map expression', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, - `const parsed = CallToolResultSchema.safeParse(data);`, - `if (!parsed.success) { console.log(parsed.error.message); }`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).not.toContain('parsed.error'); - expect(text).not.toContain('parsed.issues.message'); - expect(text).toContain("parsed.issues?.map(i => i.message).join(', ')"); -}); - -it('rewrites bare .error to .issues (unchanged behavior)', () => { - const input = [ - `import { ToolSchema } from '@modelcontextprotocol/server';`, - `const result = ToolSchema.safeParse(raw);`, - `if (!result.success) { console.log(result.error); }`, - '' - ].join('\n'); - const { text } = applyTransform(input); - expect(text).toContain('result.issues'); - expect(text).not.toContain('result.error'); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` -Expected: First 2 new tests FAIL (`.error.issues` becomes `.issues.issues`, `.error.message` becomes `.issues.message`). Third test should already pass. - -- [ ] **Step 3: Update the error case in rewriteCapturedSafeParse** - -In `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts`, in the `rewriteCapturedSafeParse` function, replace the `case 'error'` block (around line 293): - -```typescript -// BEFORE: -case 'error': { - replacements.push({ node, newText: `${varName}.issues` }); - break; -} -``` - -with: - -```typescript -// AFTER: -case 'error': { - const errorParent = node.getParent(); - if (errorParent && Node.isPropertyAccessExpression(errorParent) && errorParent.getExpression() === node) { - const subProp = errorParent.getName(); - if (subProp === 'issues') { - replacements.push({ node: errorParent, newText: `${varName}.issues` }); - } else if (subProp === 'message') { - replacements.push({ node: errorParent, newText: `${varName}.issues?.map(i => i.message).join(', ')` }); - } else { - replacements.push({ node: errorParent, newText: `${varName}.issues` }); - } - } else { - replacements.push({ node, newText: `${varName}.issues` }); - } - break; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/specSchemaAccess.test.ts` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts -git commit -m "fix(codemod): handle .error sub-property accesses in safeParse rewrite" -``` - ---- - -### Task 4: Handle `zod-compat.js` import path - -The import path `@modelcontextprotocol/sdk/server/zod-compat.js` is not in `IMPORT_MAP`, so `importPaths` emits "Unknown SDK import path" and leaves it untouched. The file exported `AnySchema` and `SchemaOutput` types that don't exist in v2. - -**Fix:** Add the path to `IMPORT_MAP` as `removed` with a descriptive message. This removes the import and emits a clear diagnostic. - -**Impact:** Fixes "Unknown SDK import path" warnings in inspector/client (4 files). The `AnySchema`/`SchemaOutput` usages in function signatures will still need manual migration, but the import won't be stale. - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts` -- Test: `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` - -- [ ] **Step 1: Write failing test for zod-compat import removal** - -Add to `importPaths.test.ts`: - -```typescript -it('removes zod-compat.js import and emits diagnostic', () => { - const input = [ - `import { AnySchema, SchemaOutput } from '@modelcontextprotocol/sdk/server/zod-compat.js';`, - `function validate(schema: T): SchemaOutput { return {} as any; }`, - '' - ].join('\n'); - const project = new Project({ useInMemoryFileSystem: true }); - const sourceFile = project.createSourceFile('test.ts', input); - const result = importPathsTransform.apply(sourceFile, ctx); - const text = sourceFile.getFullText(); - expect(text).not.toContain('zod-compat'); - expect(text).not.toContain("from '@modelcontextprotocol/sdk"); - expect(result.changesCount).toBeGreaterThan(0); - expect(result.diagnostics.length).toBeGreaterThan(0); - expect(result.diagnostics[0]!.message).toContain('zod-compat'); -}); -``` - -Ensure the test file imports the necessary pieces — check the existing test imports at the top and match them. The existing test file should already import `importPathsTransform`, `Project`, and define a `ctx` constant. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` -Expected: FAIL — import is left unchanged, "Unknown SDK import path" warning emitted - -- [ ] **Step 3: Add zod-compat.js to the import map** - -In `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts`, add this entry to `IMPORT_MAP` after the `'@modelcontextprotocol/sdk/server/middleware.js'` entry: - -```typescript -'@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.' -}, -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts -git commit -m "fix(codemod): handle zod-compat.js import path as removed" -``` - ---- - -### Task 5: Rename `ResourceTemplate` type imports to `ResourceTemplateType` - -When `ResourceTemplate` is imported from `@modelcontextprotocol/sdk/types.js` (protocol type usage), the import is rewritten to `@modelcontextprotocol/server`. But the server exports a `ResourceTemplate` **class** (used for server-side registration), shadowing the protocol type. The protocol type already exists in v2 as `ResourceTemplateType` (defined in `core/src/types/types.ts`, publicly exported via `core/public`'s `export * from '../../types/types.js'`, and re-exported by both `@modelcontextprotocol/server` and `@modelcontextprotocol/client`). - -**Fix:** Add `ResourceTemplate` → `ResourceTemplateType` to the `renamedSymbols` mapping for the `types.js` import path. This auto-renames the import and all references. No SDK changes needed — `ResourceTemplateType` is already publicly exported. - -**Impact:** Fixes ~8 TypeScript errors in inspector/client `ResourcesTab.tsx` (`.name`, `.description`, `UriTemplate` vs `string` issues). - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts` -- Test: `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` - -- [ ] **Step 1: Write failing test for ResourceTemplate rename** - -Add to `importPaths.test.ts`: - -```typescript -it('renames ResourceTemplate to ResourceTemplateType when imported from types.js', () => { - const input = [ - `import { ResourceTemplate } from '@modelcontextprotocol/sdk/types.js';`, - `const template: ResourceTemplate = getTemplate();`, - '' - ].join('\n'); - const project = new Project({ useInMemoryFileSystem: true }); - const sourceFile = project.createSourceFile('test.ts', input); - const result = importPathsTransform.apply(sourceFile, ctx); - const text = sourceFile.getFullText(); - expect(text).toContain('ResourceTemplateType'); - expect(text).not.toMatch(/\bResourceTemplate\b(?!Type)/); - expect(result.changesCount).toBeGreaterThan(0); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` -Expected: FAIL — ResourceTemplate is not renamed - -- [ ] **Step 3: Add ResourceTemplate rename to import map** - -In `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts`, find the entry for `'@modelcontextprotocol/sdk/types.js'`: - -```typescript -'@modelcontextprotocol/sdk/types.js': { - target: 'RESOLVE_BY_CONTEXT', - status: 'moved' -}, -``` - -Add `renamedSymbols`: - -```typescript -'@modelcontextprotocol/sdk/types.js': { - target: 'RESOLVE_BY_CONTEXT', - status: 'moved', - renamedSymbols: { - ResourceTemplate: 'ResourceTemplateType' - } -}, -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- test/v1-to-v2/transforms/importPaths.test.ts` -Expected: All tests PASS - -- [ ] **Step 5: Run full test suite** - -Run: `pnpm --filter @modelcontextprotocol/codemod test` -Expected: All tests PASS across all test files - -- [ ] **Step 6: Commit** - -```bash -git add packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts -git commit -m "fix(codemod): rename ResourceTemplate to ResourceTemplateType to avoid class collision" -``` - ---- - -## Verification - -After all 5 tasks are complete: - -- [ ] **Rebuild and re-run batch test** - -```bash -pnpm --filter @modelcontextprotocol/codemod build -pnpm --filter @modelcontextprotocol/codemod batch-test -``` - -Compare `packages/codemod/batch-test/results/summary.json` with the pre-fix results. Expected improvements: -- inspector/client: build errors should decrease significantly (StandardSchemaV1→AnySchema errors from handler registration fixed, schema import errors fixed) -- inspector/server: `SSEServerTransport` errors remain (manual migration), but `setRequestHandler` task schema errors should be fixed -- mcp-servers-fork: `SSEServerTransport` errors remain (manual migration), test context mock errors remain (manual migration) diff --git a/docs/superpowers/plans/2026-06-02-readbuffer-max-size.md b/docs/superpowers/plans/2026-06-02-readbuffer-max-size.md deleted file mode 100644 index 867e8a3599..0000000000 --- a/docs/superpowers/plans/2026-06-02-readbuffer-max-size.md +++ /dev/null @@ -1,323 +0,0 @@ -# ReadBuffer Max Size Guard Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a configurable maximum buffer size to `ReadBuffer` to prevent unbounded memory growth from a misbehaving stdio peer (GHSA-wqgc-pwpr-pq7r). - -**Architecture:** `ReadBuffer.append()` gains a size guard that throws on overflow. Both stdio transports wrap their data handlers in try/catch to catch the throw, report via `onerror`, and close the transport. The constant and constructor option are exported as public API. - -**Tech Stack:** TypeScript, vitest - ---- - -### Task 1: Add size guard to ReadBuffer - -**Files:** -- Modify: `packages/core/src/shared/stdio.ts:1-42` - -- [ ] **Step 1: Write failing tests for buffer overflow** - -Add a new `describe` block to `packages/core/test/shared/stdio.test.ts`: - -```typescript -describe('buffer size limit', () => { - test('should throw when buffer exceeds default max size', () => { - const readBuffer = new ReadBuffer(); - const chunk = Buffer.alloc(1024 * 1024); // 1 MB - // Default is 10 MB, so 11 appends should fail - for (let i = 0; i < 10; i++) { - readBuffer.append(chunk); - } - expect(() => readBuffer.append(chunk)).toThrow( - /ReadBuffer exceeded maximum size/ - ); - }); - - test('should throw when buffer exceeds custom max size', () => { - const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); - readBuffer.append(Buffer.alloc(50)); - expect(() => readBuffer.append(Buffer.alloc(51))).toThrow( - /ReadBuffer exceeded maximum size/ - ); - }); - - test('should clear buffer before throwing on overflow', () => { - const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); - readBuffer.append(Buffer.alloc(50)); - expect(() => readBuffer.append(Buffer.alloc(51))).toThrow(); - - // Buffer should be cleared — can append again - readBuffer.append(Buffer.alloc(50)); - // And read messages normally - expect(readBuffer.readMessage()).toBeNull(); - }); - - test('should allow appending up to exactly the max size', () => { - const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); - // Should not throw — exactly at limit - expect(() => readBuffer.append(Buffer.alloc(100))).not.toThrow(); - }); - - test('should work with no options (backwards compatible)', () => { - const readBuffer = new ReadBuffer(); - // Small append should always work - readBuffer.append(Buffer.from('hello\n')); - expect(readBuffer.readMessage()).not.toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run the tests to confirm they fail** - -Run: `pnpm --filter @modelcontextprotocol/core test -- packages/core/test/shared/stdio.test.ts` -Expected: FAIL — `ReadBuffer` constructor doesn't accept options yet. - -- [ ] **Step 3: Implement the size guard in ReadBuffer** - -Modify `packages/core/src/shared/stdio.ts`. The full file should become: - -```typescript -import type { JSONRPCMessage } from '../types/index.js'; -import { JSONRPCMessageSchema } from '../types/index.js'; - -export const DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB - -/** - * Buffers a continuous stdio stream into discrete JSON-RPC messages. - */ -export class ReadBuffer { - private _buffer?: Buffer; - private _maxBufferSize: number; - - constructor(options?: { maxBufferSize?: number }) { - this._maxBufferSize = options?.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; - } - - append(chunk: Buffer): void { - const newSize = (this._buffer?.length ?? 0) + chunk.length; - if (newSize > this._maxBufferSize) { - this.clear(); - throw new Error( - `ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes` - ); - } - this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; - } - - readMessage(): JSONRPCMessage | null { - while (this._buffer) { - const index = this._buffer.indexOf('\n'); - if (index === -1) { - return null; - } - - const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); - this._buffer = this._buffer.subarray(index + 1); - - try { - return deserializeMessage(line); - } catch (error) { - // Skip non-JSON lines (e.g., debug output from hot-reload tools like - // tsx or nodemon that write to stdout). Schema validation errors still - // throw so malformed-but-valid-JSON messages surface via onerror. - if (error instanceof SyntaxError) { - continue; - } - throw error; - } - } - return null; - } - - clear(): void { - this._buffer = undefined; - } -} - -export function deserializeMessage(line: string): JSONRPCMessage { - return JSONRPCMessageSchema.parse(JSON.parse(line)); -} - -export function serializeMessage(message: JSONRPCMessage): string { - return JSON.stringify(message) + '\n'; -} -``` - -- [ ] **Step 4: Run the tests to confirm they pass** - -Run: `pnpm --filter @modelcontextprotocol/core test -- packages/core/test/shared/stdio.test.ts` -Expected: All tests PASS (including all existing tests — backwards compatible). - -- [ ] **Step 5: Commit** - -```bash -git add packages/core/src/shared/stdio.ts packages/core/test/shared/stdio.test.ts -git commit -m "fix(core): add max buffer size guard to ReadBuffer - -Prevents unbounded memory growth when a stdio peer sends data without -newline delimiters. Default limit is 10 MB, configurable via constructor. - -Ref: GHSA-wqgc-pwpr-pq7r" -``` - ---- - -### Task 2: Add DEFAULT_MAX_BUFFER_SIZE to public exports - -**Files:** -- Modify: `packages/core/src/exports/public/index.ts:70` - -- [ ] **Step 1: Add the constant to the public export** - -Change line 70 in `packages/core/src/exports/public/index.ts` from: - -```typescript -export { deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js'; -``` - -to: - -```typescript -export { DEFAULT_MAX_BUFFER_SIZE, deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js'; -``` - -- [ ] **Step 2: Run typecheck to confirm it compiles** - -Run: `pnpm --filter @modelcontextprotocol/core typecheck` -Expected: No errors. - -- [ ] **Step 3: Commit** - -```bash -git add packages/core/src/exports/public/index.ts -git commit -m "feat(core): export DEFAULT_MAX_BUFFER_SIZE from public API" -``` - ---- - -### Task 3: Add try/catch to StdioClientTransport data handler - -**Files:** -- Modify: `packages/client/src/client/stdio.ts:151-154` - -- [ ] **Step 1: Wrap the data handler in try/catch** - -Change lines 151-154 of `packages/client/src/client/stdio.ts` from: - -```typescript - this._process.stdout?.on('data', chunk => { - this._readBuffer.append(chunk); - this.processReadBuffer(); - }); -``` - -to: - -```typescript - this._process.stdout?.on('data', chunk => { - try { - this._readBuffer.append(chunk); - this.processReadBuffer(); - } catch (error) { - this.onerror?.(error as Error); - this.close().catch(() => {}); - } - }); -``` - -- [ ] **Step 2: Run typecheck** - -Run: `pnpm --filter @modelcontextprotocol/client typecheck` -Expected: No errors. - -- [ ] **Step 3: Run existing stdio client tests to verify no regression** - -Run: `pnpm --filter @modelcontextprotocol/client test -- packages/client/test/client/stdio.test.ts` -Expected: All existing tests PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/client/src/client/stdio.ts -git commit -m "fix(client): catch ReadBuffer overflow in StdioClientTransport data handler - -Prevents an uncaught exception when ReadBuffer.append() throws due to -exceeding the max buffer size. Routes the error to onerror and closes -the transport. - -Ref: GHSA-wqgc-pwpr-pq7r" -``` - ---- - -### Task 4: Add try/catch to StdioServerTransport data handler - -**Files:** -- Modify: `packages/server/src/server/stdio.ts:34-37` - -- [ ] **Step 1: Wrap the _ondata handler in try/catch** - -Change lines 34-37 of `packages/server/src/server/stdio.ts` from: - -```typescript - _ondata = (chunk: Buffer) => { - this._readBuffer.append(chunk); - this.processReadBuffer(); - }; -``` - -to: - -```typescript - _ondata = (chunk: Buffer) => { - try { - this._readBuffer.append(chunk); - this.processReadBuffer(); - } catch (error) { - this.onerror?.(error as Error); - this.close().catch(() => {}); - } - }; -``` - -- [ ] **Step 2: Run typecheck** - -Run: `pnpm --filter @modelcontextprotocol/server typecheck` -Expected: No errors. - -- [ ] **Step 3: Run existing stdio server tests to verify no regression** - -Run: `pnpm --filter @modelcontextprotocol/server test -- packages/server/test/server/stdio.test.ts` -Expected: All existing tests PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/server/src/server/stdio.ts -git commit -m "fix(server): catch ReadBuffer overflow in StdioServerTransport data handler - -Prevents an uncaught exception when ReadBuffer.append() throws due to -exceeding the max buffer size. Routes the error to onerror and closes -the transport. - -Ref: GHSA-wqgc-pwpr-pq7r" -``` - ---- - -### Task 5: Full test suite verification - -- [ ] **Step 1: Run full typecheck across all packages** - -Run: `pnpm typecheck:all` -Expected: No errors. - -- [ ] **Step 2: Run full test suite** - -Run: `pnpm test:all` -Expected: All tests PASS. - -- [ ] **Step 3: Run lint** - -Run: `pnpm lint:all` -Expected: No errors (or fix any formatting issues with `pnpm lint:fix:all`). diff --git a/docs/superpowers/plans/2026-06-02-v1-readbuffer-max-size.md b/docs/superpowers/plans/2026-06-02-v1-readbuffer-max-size.md deleted file mode 100644 index cfbbfb23b9..0000000000 --- a/docs/superpowers/plans/2026-06-02-v1-readbuffer-max-size.md +++ /dev/null @@ -1,356 +0,0 @@ -# V1 ReadBuffer Max Size Guard Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Port the ReadBuffer max size guard from the v2 branch (`fix/stdio-buffer-limit`, commit `08780873`) to v1. This prevents unbounded memory growth when a misbehaving stdio peer sends data without newline delimiters (GHSA-wqgc-pwpr-pq7r). - -**Architecture:** `ReadBuffer.append()` gains a size guard that throws on overflow. Both stdio transports wrap their data handlers in try/catch to catch the throw, report via `onerror`, and close the transport. The constant `STDIO_DEFAULT_MAX_BUFFER_SIZE` is exported from `src/shared/stdio.ts`. - -**Tech Stack:** TypeScript, vitest - -**Key difference from v2:** V1 is a flat `src/` layout (not a monorepo under `packages/`). There is no public re-export index file, so the constant is only exported from `src/shared/stdio.ts` directly. - ---- - -### Task 1: Add size guard to ReadBuffer - -**Files:** -- Modify: `src/shared/stdio.ts` -- Modify: `test/shared/stdio.test.ts` - -- [ ] **Step 1: Add buffer size limit tests** - -Append the following to `test/shared/stdio.test.ts`: - -```typescript -import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '../../src/shared/stdio.js'; - -describe('buffer size limit', () => { - test('should throw when buffer exceeds default max size', () => { - const readBuffer = new ReadBuffer(); - const chunkSize = 1024 * 1024; // 1 MB - const chunk = Buffer.alloc(chunkSize); - const chunksToFill = Math.floor(STDIO_DEFAULT_MAX_BUFFER_SIZE / chunkSize); - for (let i = 0; i < chunksToFill; i++) { - readBuffer.append(chunk); - } - expect(() => readBuffer.append(chunk)).toThrow( - /ReadBuffer exceeded maximum size/ - ); - }); - - test('should throw when buffer exceeds custom max size', () => { - const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); - readBuffer.append(Buffer.alloc(50)); - expect(() => readBuffer.append(Buffer.alloc(51))).toThrow( - /ReadBuffer exceeded maximum size/ - ); - }); - - test('should clear buffer before throwing on overflow', () => { - const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); - readBuffer.append(Buffer.alloc(50)); - expect(() => readBuffer.append(Buffer.alloc(51))).toThrow(); - - // Buffer should be cleared — can append again - readBuffer.append(Buffer.alloc(50)); - // And read messages normally - expect(readBuffer.readMessage()).toBeNull(); - }); - - test('should allow appending up to exactly the max size', () => { - const readBuffer = new ReadBuffer({ maxBufferSize: 100 }); - // Should not throw — exactly at limit - expect(() => readBuffer.append(Buffer.alloc(100))).not.toThrow(); - }); - - test('should work with no options (backwards compatible)', () => { - const readBuffer = new ReadBuffer(); - // Small append should always work - readBuffer.append(Buffer.from(JSON.stringify({ jsonrpc: '2.0', method: 'ping' }) + '\n')); - expect(readBuffer.readMessage()).not.toBeNull(); - }); -}); -``` - -Also update the existing import at the top of the file — change: - -```typescript -import { ReadBuffer } from '../../src/shared/stdio.js'; -``` - -to: - -```typescript -import { STDIO_DEFAULT_MAX_BUFFER_SIZE, ReadBuffer } from '../../src/shared/stdio.js'; -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -Run: `npx vitest test/shared/stdio.test.ts --run` -Expected: FAIL — `ReadBuffer` constructor doesn't accept options yet, `STDIO_DEFAULT_MAX_BUFFER_SIZE` doesn't exist. - -- [ ] **Step 3: Implement the size guard in ReadBuffer** - -Modify `src/shared/stdio.ts`. Add the constant and constructor, and add the size guard to `append()`. The full file should become: - -```typescript -import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; - -export const STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024; - -/** - * Buffers a continuous stdio stream into discrete JSON-RPC messages. - */ -export class ReadBuffer { - private _buffer?: Buffer; - private _maxBufferSize: number; - - constructor(options?: { maxBufferSize?: number }) { - this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE; - } - - append(chunk: Buffer): void { - const newSize = (this._buffer?.length ?? 0) + chunk.length; - if (newSize > this._maxBufferSize) { - this.clear(); - throw new Error( - `ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes` - ); - } - this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; - } - - readMessage(): JSONRPCMessage | null { - if (!this._buffer) { - return null; - } - - const index = this._buffer.indexOf('\n'); - if (index === -1) { - return null; - } - - const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); - this._buffer = this._buffer.subarray(index + 1); - return deserializeMessage(line); - } - - clear(): void { - this._buffer = undefined; - } -} - -export function deserializeMessage(line: string): JSONRPCMessage { - return JSONRPCMessageSchema.parse(JSON.parse(line)); -} - -export function serializeMessage(message: JSONRPCMessage): string { - return JSON.stringify(message) + '\n'; -} -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -Run: `npx vitest test/shared/stdio.test.ts --run` -Expected: All tests PASS. - -- [ ] **Step 5: Suggest commit** - -```bash -git add src/shared/stdio.ts test/shared/stdio.test.ts -git commit -m "fix: add max buffer size guard to ReadBuffer - -Prevents unbounded memory growth when a stdio peer sends data without -newline delimiters. Default limit is 10 MB, configurable via constructor. - -Ref: GHSA-wqgc-pwpr-pq7r" -``` - ---- - -### Task 2: Add try/catch to StdioServerTransport data handler - -**Files:** -- Modify: `src/server/stdio.ts:26-29` -- Modify: `test/server/stdio.test.ts` - -- [ ] **Step 1: Add overflow test for StdioServerTransport** - -Append the following test to `test/server/stdio.test.ts`: - -```typescript -test('should fire onerror and close when ReadBuffer overflows', async () => { - const server = new StdioServerTransport(input, output); - - let receivedError: Error | undefined; - server.onerror = err => { - receivedError = err; - }; - let closeCount = 0; - server.onclose = () => { - closeCount++; - }; - - await server.start(); - - // Push data exceeding the default 10 MB limit without a newline - const chunk = Buffer.alloc(11 * 1024 * 1024, 0x41); - input.push(chunk); - - // Allow the close() promise to settle - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(receivedError?.message).toMatch(/ReadBuffer exceeded maximum size/); - expect(closeCount).toBe(1); -}); -``` - -- [ ] **Step 2: Run to confirm the test fails** - -Run: `npx vitest test/server/stdio.test.ts --run` -Expected: FAIL — the uncaught throw from `append()` crashes instead of being caught. - -- [ ] **Step 3: Wrap the _ondata handler in try/catch** - -Change lines 26-29 of `src/server/stdio.ts` from: - -```typescript - _ondata = (chunk: Buffer) => { - this._readBuffer.append(chunk); - this.processReadBuffer(); - }; -``` - -to: - -```typescript - _ondata = (chunk: Buffer) => { - try { - this._readBuffer.append(chunk); - this.processReadBuffer(); - } catch (error) { - this.onerror?.(error as Error); - this.close().catch(() => {}); - } - }; -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -Run: `npx vitest test/server/stdio.test.ts --run` -Expected: All tests PASS. - -- [ ] **Step 5: Suggest commit** - -```bash -git add src/server/stdio.ts test/server/stdio.test.ts -git commit -m "fix(server): catch ReadBuffer overflow in StdioServerTransport - -Prevents an uncaught exception when ReadBuffer.append() throws due to -exceeding the max buffer size. Routes the error to onerror and closes -the transport. - -Ref: GHSA-wqgc-pwpr-pq7r" -``` - ---- - -### Task 3: Add try/catch to StdioClientTransport data handler - -**Files:** -- Modify: `src/client/stdio.ts:150-153` -- Modify: `test/client/stdio.test.ts` - -- [ ] **Step 1: Add overflow test for StdioClientTransport** - -Append the following test to `test/client/stdio.test.ts`: - -```typescript -test('should fire onerror and close when ReadBuffer overflows', async () => { - const client = new StdioClientTransport({ - command: 'node', - args: ['-e', 'process.stdout.write(Buffer.alloc(11 * 1024 * 1024, 0x41))'] - }); - - const errorReceived = new Promise(resolve => { - client.onerror = resolve; - }); - const closed = new Promise(resolve => { - client.onclose = () => resolve(); - }); - - await client.start(); - - const error = await errorReceived; - expect(error.message).toMatch(/ReadBuffer exceeded maximum size/); - await closed; -}); -``` - -- [ ] **Step 2: Run to confirm the test fails** - -Run: `npx vitest test/client/stdio.test.ts --run` -Expected: FAIL — the uncaught throw from `append()` crashes. - -- [ ] **Step 3: Wrap the stdout data handler in try/catch** - -Change lines 150-153 of `src/client/stdio.ts` from: - -```typescript - this._process.stdout?.on('data', chunk => { - this._readBuffer.append(chunk); - this.processReadBuffer(); - }); -``` - -to: - -```typescript - this._process.stdout?.on('data', chunk => { - try { - this._readBuffer.append(chunk); - this.processReadBuffer(); - } catch (error) { - this.onerror?.(error as Error); - this.close().catch(() => {}); - } - }); -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -Run: `npx vitest test/client/stdio.test.ts --run` -Expected: All tests PASS. - -- [ ] **Step 5: Suggest commit** - -```bash -git add src/client/stdio.ts test/client/stdio.test.ts -git commit -m "fix(client): catch ReadBuffer overflow in StdioClientTransport - -Prevents an uncaught exception when ReadBuffer.append() throws due to -exceeding the max buffer size. Routes the error to onerror and closes -the transport. - -Ref: GHSA-wqgc-pwpr-pq7r" -``` - ---- - -### Task 4: Full verification - -- [ ] **Step 1: Run typecheck** - -Run: `npm run typecheck` -Expected: No errors. - -- [ ] **Step 2: Run full test suite** - -Run: `npm test` -Expected: All tests PASS. - -- [ ] **Step 3: Run lint** - -Run: `npm run lint` -Expected: No errors (or fix with `npm run lint:fix`). diff --git a/docs/superpowers/plans/2026-06-23-sdk-shared-package.md b/docs/superpowers/plans/2026-06-23-sdk-shared-package.md deleted file mode 100644 index c148328705..0000000000 --- a/docs/superpowers/plans/2026-06-23-sdk-shared-package.md +++ /dev/null @@ -1,716 +0,0 @@ -# `@modelcontextprotocol/sdk-shared` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extract the canonical MCP spec data model (Zod schemas + derived TS types + protocol constants) into a new publishable package `@modelcontextprotocol/sdk-shared`, so v1→v2 schema-validation migration becomes a mechanical import-path swap with `.parse`/`.safeParse`/all Zod methods preserved. - -**Architecture:** A new zod-only, runtime-neutral package owns `constants.ts` + `schemas.ts` + `types.ts` (moved from `core`). `core` keeps thin re-export shims at the old paths (churn control); `core/public`, `server`, and `client` re-export the **types** (Zod-free) and continue to expose `specTypeSchemas` unchanged; the raw Zod `*Schema` constants are reachable only from `sdk-shared`. The codemod routes `@modelcontextprotocol/sdk/types.js` → `@modelcontextprotocol/sdk-shared` as a fixed path swap and drops the `specSchemaAccess` rewriting entirely. - -**Tech Stack:** TypeScript (NodeNext, `tsgo` typecheck), Zod v4, tsdown (build, ESM `.mjs`/`.d.mts`), vitest, ts-morph (codemod), changesets (prerelease `alpha` mode), pnpm workspaces. - -## Global Constraints - -- Node engine floor: `>=20`. Package version line: `2.0.0-alpha.2` (match other runtime packages). -- Formatting (Prettier, `.prettierrc.json`): 4-space indent, single quotes, semicolons, **no trailing commas**, print width 140. All new/edited files must satisfy `prettier --check`. -- Source imports use explicit `.js` extensions (NodeNext); sibling `.ts` files import each other as `./x.js`. -- Public API uses **explicit named exports** except `types.ts`, which is the one intentional `export *` (it contains only spec-derived TS types). -- `sdk-shared` must be **runtime-neutral** (no Node builtins) — guarded by a `barrelClean` test. -- `sdk-shared`'s only runtime dependency is `zod` (`catalog:runtimeShared` → `^4.2.0`). No `publishConfig` (root `.npmrc` + changesets `access: public` handle it). -- Never run `git add`/`git commit` (a hook blocks it). At each "Commit" step, **print the suggested commands** for the user to run manually. -- Typecheck per package: `tsgo -p tsconfig.json --noEmit`. Tests: `vitest run` (tests live in `test/**/*.test.ts`, not colocated). - ---- - -## File Structure - -**New package `packages/sdk-shared/`:** -- `package.json`, `tsconfig.json`, `tsdown.config.ts`, `vitest.config.js`, `eslint.config.mjs`, `README.md` -- `src/constants.ts`, `src/schemas.ts`, `src/types.ts` — relocated from `packages/core/src/types/` -- `src/index.ts` — main barrel: types + constants + schemas (everything; first-class Zod) -- `test/barrelClean.test.ts` — runtime-neutrality guard -- The `./types` subpath is served directly by the built `src/types.ts` (types-only; Zod-free) for `core/public` to re-export. - -**Modified in `packages/core/`:** -- `src/types/constants.ts`, `src/types/schemas.ts`, `src/types/types.ts` → become 1-line re-export shims pointing at `sdk-shared` (churn control) -- `src/exports/public/index.ts` → re-point the types `export *` and the constants named-export at `sdk-shared` -- `package.json` → add `@modelcontextprotocol/sdk-shared` dependency -- `src/types/specTypeSchema.ts` → its `import * as schemas from './schemas.js'` keeps working via the shim (no edit needed if shim is in place) - -**Modified in `packages/server/`, `packages/client/`:** -- `package.json` → add `@modelcontextprotocol/sdk-shared` dependency -- `tsdown.config.ts` → add `@modelcontextprotocol/sdk-shared` to `external`; add its `src` path to the dts `paths` so `.d.mts` resolves - -**Modified in `packages/codemod/`:** -- `scripts/generateVersions.ts` → add `sdk-shared` to `PACKAGE_DIRS`; regenerate `src/generated/versions.ts` -- `src/migrations/v1-to-v2/mappings/importMap.ts` → `sdk/types.js` target becomes `@modelcontextprotocol/sdk-shared` -- `src/migrations/v1-to-v2/transforms/index.ts` → remove `specSchemaAccess` from the pipeline -- delete `src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` + `test/v1-to-v2/transforms/specSchemaAccess.test.ts` -- update `test/v1-to-v2/transforms/importPaths.test.ts` and any integration test expecting `specTypeSchemas` output -- `src/bin/batchTest.ts` → add `sdk-shared` to `LOCAL_PACKAGE_DIRS`; add `overrides` so transitive `server→sdk-shared` resolves to the local tarball - -**Modified docs / release:** -- `docs/migration.md`, `docs/migration-SKILL.md` → rewrite spec-schema validation section -- `.changeset/pre.json` → add `sdk-shared` to `initialVersions`; new `.changeset/add-sdk-shared-package.md` - ---- - -## Phase 1 — Create `sdk-shared`, move the spec data model, rewire consumers - -### Task 1.1: Scaffold the empty `sdk-shared` package - -**Files:** -- Create: `packages/sdk-shared/package.json`, `tsconfig.json`, `tsdown.config.ts`, `vitest.config.js`, `eslint.config.mjs`, `README.md`, `src/index.ts` -- Modify: `.changeset/pre.json` -- Create: `.changeset/add-sdk-shared-package.md` - -**Interfaces:** -- Produces: a buildable workspace package `@modelcontextprotocol/sdk-shared` whose `dist/index.mjs` + `dist/index.d.mts` exist. No real exports yet (placeholder). - -- [ ] **Step 1: Create `packages/sdk-shared/package.json`** - -```json -{ - "name": "@modelcontextprotocol/sdk-shared", - "private": false, - "version": "2.0.0-alpha.2", - "description": "Shared types and Zod schemas for the Model Context Protocol TypeScript SDK", - "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", "schemas", "types"], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - }, - "./types": { - "types": "./dist/types.d.mts", - "import": "./dist/types.mjs" - } - }, - "types": "./dist/index.d.mts", - "typesVersions": { - "*": { - "types": ["dist/types.d.mts"] - } - }, - "files": ["dist"], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "zod": "catalog:runtimeShared" - }, - "devDependencies": { - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-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", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" - } -} -``` - -- [ ] **Step 2: Create `packages/sdk-shared/tsconfig.json`** - -```json -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "paths": { "*": ["./*"] } - } -} -``` - -- [ ] **Step 3: Create `packages/sdk-shared/tsdown.config.ts`** (two entries: main + the types-only subpath) - -```ts -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - failOnWarn: 'ci-only', - entry: ['src/index.ts', 'src/types.ts'], - format: ['esm'], - outDir: 'dist', - clean: true, - sourcemap: true, - target: 'esnext', - platform: 'node', - shims: true, - dts: { resolver: 'tsc' } -}); -``` - -- [ ] **Step 4: Create `packages/sdk-shared/vitest.config.js`** - -```js -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; -``` - -- [ ] **Step 5: Create `packages/sdk-shared/eslint.config.mjs`** - -```js -// @ts-check - -import baseConfig from '@modelcontextprotocol/eslint-config'; - -export default [ - ...baseConfig, - { - settings: { - 'import/internal-regex': '^@modelcontextprotocol/sdk-shared' - } - } -]; -``` - -- [ ] **Step 6: Create `packages/sdk-shared/README.md`** - -```md -# @modelcontextprotocol/sdk-shared - -Shared types and Zod schemas for the Model Context Protocol TypeScript SDK. Exposes the canonical MCP spec data model: the Zod `*Schema` constants, their derived TypeScript types, and protocol constants. - -- Import types and Zod schemas from `@modelcontextprotocol/sdk-shared`. -- For library-agnostic (Standard Schema) validation, prefer `specTypeSchemas` from `@modelcontextprotocol/server` / `@modelcontextprotocol/client`. -``` - -- [ ] **Step 7: Create placeholder `packages/sdk-shared/src/index.ts`** - -```ts -// Placeholder — real exports added in Task 1.2. -export const SDK_SHARED_PLACEHOLDER = true; -``` - -- [ ] **Step 8: Register the package in changesets prerelease state** — edit `.changeset/pre.json`, adding this entry to the `initialVersions` object (alphabetical position is fine): - -```json -"@modelcontextprotocol/sdk-shared": "2.0.0-alpha.0" -``` - -- [ ] **Step 9: Create `.changeset/add-sdk-shared-package.md`** - -```md ---- -'@modelcontextprotocol/sdk-shared': minor ---- - -Add @modelcontextprotocol/sdk-shared package: the canonical home for MCP spec Zod schemas, their derived TypeScript types, and protocol constants. -``` - -- [ ] **Step 10: Install + build to verify the scaffold** - -Run: `pnpm install && pnpm --filter @modelcontextprotocol/sdk-shared build` -Expected: install succeeds; build writes `packages/sdk-shared/dist/index.mjs` and `dist/index.d.mts` (and `dist/types.*`). Verify: `ls packages/sdk-shared/dist` shows `index.mjs index.d.mts types.mjs types.d.mts`. - -- [ ] **Step 11: Commit** (print for the user) - -```bash -git add packages/sdk-shared .changeset/pre.json .changeset/add-sdk-shared-package.md -git commit -m "feat(sdk-shared): scaffold empty @modelcontextprotocol/sdk-shared package" -``` - ---- - -### Task 1.2: Relocate the spec data model into `sdk-shared` - -**Files:** -- Move: `packages/core/src/types/constants.ts` → `packages/sdk-shared/src/constants.ts` -- Move: `packages/core/src/types/schemas.ts` → `packages/sdk-shared/src/schemas.ts` -- Move: `packages/core/src/types/types.ts` → `packages/sdk-shared/src/types.ts` -- Modify: `packages/sdk-shared/src/index.ts` - -**Interfaces:** -- Produces: `@modelcontextprotocol/sdk-shared` exports all spec types + all `*Schema` Zod constants + all protocol constants from `.`; `@modelcontextprotocol/sdk-shared/types` exports the spec **types only**. -- Consumes: nothing new (the three files are self-contained — only external import is `zod/v4`). - -- [ ] **Step 1: Move the three files** (preserves content + history) - -```bash -git mv packages/core/src/types/constants.ts packages/sdk-shared/src/constants.ts -git mv packages/core/src/types/schemas.ts packages/sdk-shared/src/schemas.ts -git mv packages/core/src/types/types.ts packages/sdk-shared/src/types.ts -``` - -The internal relative imports between these three files (`./constants.js`, `./types.js`, `./schemas.js`) and `zod/v4` remain valid in the new location — no edits needed inside them. Remove the `⚠️ PUBLIC API` comment header in `types.ts` that references `exports/public/index.ts` only if it is now inaccurate; otherwise leave it. - -- [ ] **Step 2: Write the real `packages/sdk-shared/src/index.ts`** (replace the placeholder) - -```ts -// Canonical MCP spec data model: protocol constants, spec-derived TS types, and the -// Zod *Schema constants. The `.` entry is the first-class public surface (Zod included). -// The types-only `./types` subpath is served by ./types.ts directly (see package.json exports). -export * from './constants.js'; -export * from './types.js'; -export * from './schemas.js'; -``` - -- [ ] **Step 3: Typecheck `sdk-shared` in isolation** - -Run: `pnpm --filter @modelcontextprotocol/sdk-shared typecheck` -Expected: PASS (no errors). If `tsgo` reports a missing import, it means a fourth file was part of the closure — re-check `schemas.ts`/`types.ts`/`constants.ts` imports and move any additional self-contained spec file. - -- [ ] **Step 4: Build `sdk-shared`** - -Run: `pnpm --filter @modelcontextprotocol/sdk-shared build` -Expected: PASS; `dist/index.mjs` now contains the schema runtime values; `dist/types.d.mts` exposes the 178 types. - -- [ ] **Step 5: Commit** (print for the user) - -```bash -git add packages/sdk-shared packages/core/src/types -git commit -m "feat(sdk-shared): move spec constants, schemas, and types into sdk-shared" -``` - ---- - -### Task 1.3: Rewire `core` to consume `sdk-shared` via re-export shims - -**Files:** -- Create (at the old paths): `packages/core/src/types/constants.ts`, `packages/core/src/types/schemas.ts`, `packages/core/src/types/types.ts` — now 1-line re-export shims -- Modify: `packages/core/package.json` (add dependency) -- Modify: `packages/core/src/exports/public/index.ts` (re-point types `export *`) -- Modify: `packages/core/tsconfig.json` (path mapping for `tsgo`, if needed) - -**Interfaces:** -- Consumes: `@modelcontextprotocol/sdk-shared` (`.` and `./types`). -- Produces: `core`'s internal relative imports of `./types.js`/`./schemas.js`/`./constants.js` keep resolving (via shims); `core/public` exports the same public symbols as before (types via `sdk-shared/types`, constants via `sdk-shared`, no schema values), so `server`/`client` surfaces are unchanged and Zod-free. - -- [ ] **Step 1: Add the dependency to `packages/core/package.json`** — add to `dependencies`: - -```json -"@modelcontextprotocol/sdk-shared": "workspace:^" -``` - -- [ ] **Step 2: Create the re-export shims at the old core paths.** `packages/core/src/types/constants.ts`: - -```ts -// Moved to @modelcontextprotocol/sdk-shared. Re-exported here so core's internal -// relative imports (./constants.js) keep resolving without a wide rename. -export * from '@modelcontextprotocol/sdk-shared'; -``` - -`packages/core/src/types/schemas.ts`: - -```ts -// Moved to @modelcontextprotocol/sdk-shared. -export * from '@modelcontextprotocol/sdk-shared'; -``` - -`packages/core/src/types/types.ts`: - -```ts -// Moved to @modelcontextprotocol/sdk-shared (types-only subpath keeps this Zod-free). -export * from '@modelcontextprotocol/sdk-shared/types'; -``` - -(The `schemas.ts` shim re-exports the full surface so `import * as schemas from './schemas.js'` in `specTypeSchema.ts` still finds every `*Schema` value. The `types.ts` shim uses the types-only subpath so anything `export *`-ing it stays Zod-free.) - -- [ ] **Step 3: Re-point the types `export *` in `packages/core/src/exports/public/index.ts`.** The line currently reads `export * from '../../types/types.js';`. It can stay as-is (the shim now forwards to `sdk-shared/types`). **Verify** the constants named-export block (`export { BAGGAGE_META_KEY, … } from '../../types/constants.js';`) still resolves through the `constants.ts` shim. No code change required if shims are in place — confirm in Step 5. - -- [ ] **Step 4: Update `core`'s tsgo path mapping if needed.** If Step 5 typecheck fails to resolve `@modelcontextprotocol/sdk-shared`, add to `packages/core/tsconfig.json` `compilerOptions.paths`: - -```json -"@modelcontextprotocol/sdk-shared": ["./node_modules/@modelcontextprotocol/sdk-shared/src/index.ts"], -"@modelcontextprotocol/sdk-shared/types": ["./node_modules/@modelcontextprotocol/sdk-shared/src/types.ts"] -``` - -- [ ] **Step 5: Reinstall, typecheck, and test core** - -Run: `pnpm install && pnpm --filter @modelcontextprotocol/core typecheck && pnpm --filter @modelcontextprotocol/core test` -Expected: typecheck PASS; all core tests PASS. The key assertion: `specTypeSchemas` still builds (it reads schema values through the `schemas.ts` shim). - -- [ ] **Step 6: Commit** (print for the user) - -```bash -git add packages/core -git commit -m "refactor(core): consume sdk-shared via re-export shims; keep public surface unchanged" -``` - ---- - -### Task 1.4: Wire `server` and `client` to depend on `sdk-shared` (external, not bundled) - -**Files:** -- Modify: `packages/server/package.json`, `packages/client/package.json` (add dependency) -- Modify: `packages/server/tsdown.config.ts`, `packages/client/tsdown.config.ts` (external + dts paths) -- Modify: `packages/server/tsconfig.json`, `packages/client/tsconfig.json` (tsgo path mapping) - -**Interfaces:** -- Consumes: `@modelcontextprotocol/sdk-shared` at runtime (external dependency). -- Produces: `server`/`client` `dist` no longer inlines the schema/type source; their root barrels still re-export the spec **types** and `specTypeSchemas` (Zod-free); `barrelClean` still passes. - -- [ ] **Step 1: Add the dependency** to both `packages/server/package.json` and `packages/client/package.json` `dependencies`: - -```json -"@modelcontextprotocol/sdk-shared": "workspace:^" -``` - -- [ ] **Step 2: Mark it external in `packages/server/tsdown.config.ts`.** Add `'@modelcontextprotocol/sdk-shared'` to the `external` array (create the array if absent — server already has `external: ['@modelcontextprotocol/server/_shims']`): - -```ts - external: ['@modelcontextprotocol/server/_shims', '@modelcontextprotocol/sdk-shared'], -``` - -Add its source path to the dts `compilerOptions.paths` block so `.d.mts` generation resolves the external types: - -```ts - '@modelcontextprotocol/sdk-shared': ['../sdk-shared/src/index.ts'], - '@modelcontextprotocol/sdk-shared/types': ['../sdk-shared/src/types.ts'], -``` - -- [ ] **Step 3: Do the same in `packages/client/tsdown.config.ts`** (add `'@modelcontextprotocol/sdk-shared'` to `external`, and the two `paths` entries to the dts block). - -- [ ] **Step 4: Add tsgo path mapping** to `packages/server/tsconfig.json` and `packages/client/tsconfig.json` `compilerOptions.paths` (mirroring how they map `@modelcontextprotocol/core`): - -```json -"@modelcontextprotocol/sdk-shared": ["./node_modules/@modelcontextprotocol/sdk-shared/src/index.ts"], -"@modelcontextprotocol/sdk-shared/types": ["./node_modules/@modelcontextprotocol/sdk-shared/src/types.ts"] -``` - -- [ ] **Step 5: Reinstall, build, typecheck, test both packages** - -Run: `pnpm install && pnpm --filter @modelcontextprotocol/server --filter @modelcontextprotocol/client build && pnpm --filter @modelcontextprotocol/server --filter @modelcontextprotocol/client typecheck && pnpm --filter @modelcontextprotocol/server --filter @modelcontextprotocol/client test` -Expected: all PASS, including `barrelClean.test.ts`. - -- [ ] **Step 6: Verify `sdk-shared` is external in the build output** (not inlined) - -Run: `grep -c "@modelcontextprotocol/sdk-shared" packages/server/dist/index.mjs` -Expected: ≥ 1 (an `import ... from "@modelcontextprotocol/sdk-shared..."` line — proving it's referenced as an external dependency, not bundled). And the spec schema source is NOT inlined: `grep -c "z.object" packages/server/dist/index.mjs` should be markedly lower than before the change (spot-check; not a hard gate). - -- [ ] **Step 7: Full repo gate** - -Run: `pnpm typecheck:all && pnpm test:all` -Expected: all PASS. This confirms the move didn't break any sibling package. - -- [ ] **Step 8: Commit** (print for the user) - -```bash -git add packages/server packages/client -git commit -m "refactor(server,client): depend on sdk-shared as an external dependency" -``` - ---- - -## Phase 2 — Codemod: route `types.js` → `sdk-shared`, drop `specSchemaAccess` - -### Task 2.1: Register `sdk-shared` in the codemod version map - -**Files:** -- Modify: `packages/codemod/scripts/generateVersions.ts` -- Regenerate: `packages/codemod/src/generated/versions.ts` - -**Interfaces:** -- Produces: `V2_PACKAGE_VERSIONS` includes `@modelcontextprotocol/sdk-shared`, so `updatePackageJson` is allowed to add it to a consumer's deps. - -- [ ] **Step 1: Add `sdk-shared` to `PACKAGE_DIRS`** in `packages/codemod/scripts/generateVersions.ts`: - -```ts -const PACKAGE_DIRS: Record = { - '@modelcontextprotocol/client': 'client', - '@modelcontextprotocol/server': 'server', - '@modelcontextprotocol/node': 'middleware/node', - '@modelcontextprotocol/express': 'middleware/express', - '@modelcontextprotocol/server-legacy': 'server-legacy', - '@modelcontextprotocol/sdk-shared': 'sdk-shared' -}; -``` - -- [ ] **Step 2: Regenerate** - -Run: `pnpm --filter @modelcontextprotocol/codemod generate:versions` -Expected: `src/generated/versions.ts` now contains `'@modelcontextprotocol/sdk-shared': '^2.0.0-alpha.2'`. Verify: `grep sdk-shared packages/codemod/src/generated/versions.ts`. - -- [ ] **Step 3: Commit** (print for the user) - -```bash -git add packages/codemod/scripts/generateVersions.ts packages/codemod/src/generated/versions.ts -git commit -m "feat(codemod): register sdk-shared in V2_PACKAGE_VERSIONS" -``` - ---- - -### Task 2.2: Route `sdk/types.js` to `sdk-shared` (TDD) - -**Files:** -- Test: `packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts` -- Modify: `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts` - -**Interfaces:** -- Consumes: `lookupImportMapping` (already extension-tolerant from prior work). -- Produces: any import from `@modelcontextprotocol/sdk/types.js` or `@modelcontextprotocol/sdk/types` is rewritten to `@modelcontextprotocol/sdk-shared` (fixed target, no context resolution), names preserved, and `@modelcontextprotocol/sdk-shared` is added to `usedPackages`. - -- [ ] **Step 1: Write the failing test** — add to `importPaths.test.ts` inside the `describe('import-paths transform', …)` block. Also covers that schema-value imports keep their names (no `specTypeSchemas` rewrite): - -```ts -it('routes sdk/types.js to @modelcontextprotocol/sdk-shared (types + schemas, fixed target)', () => { - const input = [ - `import { CallToolResult, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, - '' - ].join('\n'); - const project = new Project({ useInMemoryFileSystem: true }); - const sourceFile = project.createSourceFile('test.ts', input); - const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); - const output = sourceFile.getFullText(); - expect(output).toContain(`from "@modelcontextprotocol/sdk-shared"`); - expect(output).toContain('CallToolResult'); - expect(output).toContain('CallToolResultSchema'); - expect(output).not.toContain('@modelcontextprotocol/sdk/types'); - expect(output).not.toContain('specTypeSchemas'); - expect(result.usedPackages?.has('@modelcontextprotocol/sdk-shared')).toBe(true); -}); -``` - -- [ ] **Step 2: Run it; verify it fails** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- importPaths -t "sdk-shared (types + schemas"` -Expected: FAIL — current output routes to `@modelcontextprotocol/server` (RESOLVE_BY_CONTEXT), so the `sdk-shared` assertion fails. - -- [ ] **Step 3: Change the `types.js` mapping** in `packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts`. Replace the existing entry: - -```ts - '@modelcontextprotocol/sdk/types.js': { - target: '@modelcontextprotocol/sdk-shared', - status: 'moved', - renamedSymbols: { - ResourceTemplate: 'ResourceTemplateType' - } - }, -``` - -(Only this entry changes from `RESOLVE_BY_CONTEXT` to the fixed `@modelcontextprotocol/sdk-shared` target. Leave `shared/protocol.js`, `shared/transport.js`, `inMemory.js`, etc. as `RESOLVE_BY_CONTEXT`.) - -- [ ] **Step 4: Run the test; verify it passes** - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- importPaths -t "sdk-shared (types + schemas"` -Expected: PASS. - -- [ ] **Step 5: Update the now-obsolete `types.js` context tests.** The existing tests `resolves sdk/types.js based on sibling client imports`, `resolves sdk/types.js based on sibling server imports`, and the extensionless `resolves extensionless sdk/types …` tests now expect `@modelcontextprotocol/sdk-shared` instead of `@modelcontextprotocol/client`/`/server`. Update each assertion to `expect(result).toContain('@modelcontextprotocol/sdk-shared')` (drop the client/server expectations for the `types`-only cases). Re-run the full file: - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- importPaths` -Expected: all PASS. - -- [ ] **Step 6: Commit** (print for the user) - -```bash -git add packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts -git commit -m "feat(codemod): route sdk/types.js to @modelcontextprotocol/sdk-shared" -``` - ---- - -### Task 2.3: Remove the `specSchemaAccess` transform - -**Files:** -- Modify: `packages/codemod/src/migrations/v1-to-v2/transforms/index.ts` -- Delete: `packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts` -- Delete: `packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts` -- Modify: codemod integration tests that assert `specTypeSchemas` output (e.g. `test/integration.test.ts`) - -**Interfaces:** -- Produces: `*Schema` value usages (`.parse`, `.safeParse`, `.extend`, …) pass through untouched — they ride the `types.js → sdk-shared` path swap with names intact. - -- [ ] **Step 1: Write a failing pass-through test** in `importPaths.test.ts` (or a new `test/v1-to-v2/passthrough.test.ts` running the full migration) asserting `.parse()` survives. Minimal transform-level version: - -```ts -it('leaves *Schema runtime usage (.parse) untouched after routing to sdk-shared', () => { - const input = [ - `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const x = CallToolResultSchema.parse(value);`, - '' - ].join('\n'); - const project = new Project({ useInMemoryFileSystem: true }); - const sourceFile = project.createSourceFile('test.ts', input); - importPathsTransform.apply(sourceFile, { projectType: 'server' }); - const output = sourceFile.getFullText(); - expect(output).toContain('CallToolResultSchema.parse(value)'); - expect(output).not.toContain('specTypeSchemas'); - expect(output).not.toContain("['~standard']"); -}); -``` - -- [ ] **Step 2: Run it; verify current behavior** — with `specSchemaAccess` still in the pipeline this transform-only test on `importPathsTransform` already passes (specSchemaAccess is a separate transform). To see the regression the removal prevents, run the FULL migration in this test instead by importing and applying every transform in order. Confirm that BEFORE removal the full-migration output contains `specTypeSchemas` (FAIL of the `not.toContain` assertion), proving `specSchemaAccess` is what rewrites it. - -Run: `pnpm --filter @modelcontextprotocol/codemod test -- passthrough` -Expected: FAIL on `not.toContain('specTypeSchemas')` (full-migration variant). - -- [ ] **Step 3: Remove `specSchemaAccess` from the pipeline** in `packages/codemod/src/migrations/v1-to-v2/transforms/index.ts` — delete its import and its entry in the exported transforms array. - -- [ ] **Step 4: Delete the transform + its unit test** - -```bash -git rm packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts -git rm packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts -``` - -- [ ] **Step 5: Update integration tests.** Search for residual expectations and fix them: - -Run: `grep -rn "specTypeSchemas\|~standard\|specSchemaAccess" packages/codemod/test packages/codemod/src/migrations` -Expected after fixes: only legitimate references remain (none asserting the codemod *produces* `specTypeSchemas`). Update `test/integration.test.ts` cases that expected `.parse`→`validate` rewrites to instead expect the schema usage unchanged. - -- [ ] **Step 6: Run the full codemod suite** - -Run: `pnpm --filter @modelcontextprotocol/codemod test` -Expected: all PASS. - -- [ ] **Step 7: Typecheck + lint the codemod** (catches the dangling `specSchemaAccess` import and any unused `specSchemaMap` reference) - -Run: `pnpm --filter @modelcontextprotocol/codemod check` -Expected: PASS. If `src/generated/specSchemaMap.ts` / `scripts/generateSpecSchemaMap.ts` are now unused, remove them and the `generate:spec-schemas` prebuild step; otherwise leave them. - -- [ ] **Step 8: Commit** (print for the user) - -```bash -git add packages/codemod -git commit -m "refactor(codemod): drop specSchemaAccess; schema usage migrates by path swap" -``` - ---- - -## Phase 3 — Batch-test validation + docs - -### Task 3.1: Teach the batch test about `sdk-shared` and re-validate firebase-tools - -**Files:** -- Modify: `packages/codemod/src/bin/batchTest.ts` - -**Interfaces:** -- Consumes: the packed local tarballs. -- Produces: the batch test packs `sdk-shared` and forces the transitive `server`/`client` → `sdk-shared` edge to resolve to the local tarball. - -- [ ] **Step 1: Add `sdk-shared` to `LOCAL_PACKAGE_DIRS`** in `packages/codemod/src/bin/batchTest.ts`: - -```ts - '@modelcontextprotocol/sdk-shared': path.join(SDK_ROOT, 'packages/sdk-shared'), -``` - -- [ ] **Step 2: Force transitive resolution via `overrides`.** In `rewriteToLocalTarballs` (or right after it), ensure the consumer `package.json` gets an `overrides` map pinning `@modelcontextprotocol/sdk-shared` (and the other v2 packages) to their local tarball paths, so `server`'s own `^2.0.0-alpha.2` dependency on `sdk-shared` resolves locally. Add, after the dependency rewrite loop: - -```ts - // npm/pnpm: pin transitive @modelcontextprotocol/* (e.g. server -> sdk-shared) to local tarballs. - const overrides = (pkgJson.overrides as Record | undefined) ?? {}; - for (const [name, tarballPath] of Object.entries(tarballs)) { - overrides[name] = `file:${tarballPath}`; - } - pkgJson.overrides = overrides; - rewrites++; // ensure the file is written -``` - -(If the manifest's package manager is pnpm, the equivalent key is `pnpm.overrides`; firebase-tools uses npm, so top-level `overrides` is correct. Generalize only if a pnpm repo is added.) - -- [ ] **Step 3: Rebuild SDK packages and re-run the batch test** - -Run: `pnpm build:all && pnpm --filter @modelcontextprotocol/codemod batch-test` -Expected: completes; `packages/codemod/batch-test/results/summary.json` shows `firebase/firebase-tools` with `newErrors.typecheck: 0`. - -- [ ] **Step 4: Confirm the win in the report** - -Run: `node -e "const r=require('./packages/codemod/batch-test/results/firebase_firebase-tools/report.json');const p=r.packages[0];console.log('post typecheck exit:',p.postCodemod.typecheck.exitCode);console.log('Unknown SDK import path diags:',p.codemod.diagnostics.filter(d=>d.message.includes('Unknown SDK import path')).length);console.log('project-type diags:',p.codemod.diagnostics.filter(d=>d.message.includes('Could not determine project type')).length);"` -Expected: `post typecheck exit: 0`; `Unknown SDK import path diags: 0`; `project-type diags: 0`. Spot-check `repos/firebase_firebase-tools/src/mcp/onemcp/onemcp_server.ts` — the `.parse()` calls are intact and import `*Schema` from `@modelcontextprotocol/sdk-shared`. - -- [ ] **Step 5: Commit** (print for the user) - -```bash -git add packages/codemod/src/bin/batchTest.ts -git commit -m "test(codemod): pack sdk-shared and pin transitive deps in batch test" -``` - ---- - -### Task 3.2: Migration docs + finalize - -**Files:** -- Modify: `docs/migration.md`, `docs/migration-SKILL.md` - -**Interfaces:** none (docs only). - -- [ ] **Step 1: Rewrite the spec-schema validation section in `docs/migration.md`.** Replace the `CallToolResultSchema` → `specTypeSchemas.X['~standard'].validate()` guidance (around the section found by `grep -n "specTypeSchemas\|CallToolResultSchema" docs/migration.md`) with: - -```md -### Schema validation (`*Schema.parse` / `.safeParse`) - -The Zod schema constants moved to `@modelcontextprotocol/sdk-shared`. Update the import path; the schemas are unchanged Zod schemas, so `.parse()`, `.safeParse()`, `.extend()`, etc. keep working. - -```ts -// v1 -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -// v2 -import { CallToolResultSchema } from '@modelcontextprotocol/sdk-shared'; - -const result = CallToolResultSchema.parse(value); // unchanged -``` - -For library-agnostic (Standard Schema) validation that does not couple your code to Zod, use `specTypeSchemas` from `@modelcontextprotocol/server` or `@modelcontextprotocol/client` instead: - -```ts -import { specTypeSchemas } from '@modelcontextprotocol/server'; -const r = specTypeSchemas.CallToolResult['~standard'].validate(value); // { value, issues } -``` -``` - -- [ ] **Step 2: Update `docs/migration-SKILL.md`** — replace the mapping-table rows that map `Schema.parse(value)` → `specTypeSchemas.['~standard'].validate(value)` with a row mapping the **import path**: `import … from '@modelcontextprotocol/sdk/types.js'` → `import … from '@modelcontextprotocol/sdk-shared'` (schemas and types), and note that `.parse`/`.safeParse` are unchanged. Keep the `specTypeSchemas` row as the optional library-agnostic alternative. - -- [ ] **Step 3: Sync snippets + docs check** - -Run: `pnpm sync:snippets && pnpm run docs:check` -Expected: PASS (or no changes). Fix any snippet drift. - -- [ ] **Step 4: Final full-repo gate** - -Run: `pnpm check:all && pnpm test:all` -Expected: all PASS. - -- [ ] **Step 5: Commit** (print for the user) - -```bash -git add docs/migration.md docs/migration-SKILL.md -git commit -m "docs(migration): schemas import from @modelcontextprotocol/sdk-shared" -``` - ---- - -## Self-Review - -**Spec coverage:** package creation (1.1), spec types + Zod schema move (1.2), first-class Zod positioning / no nudge (codemod has no nudge; docs present both — 2.2/2.3/3.2), regular `dependency` model (1.3/1.4), external-not-bundled (1.4), types-only re-export keeping the surface Zod-free (1.2 `./types` + 1.3 shim), `specTypeSchemas` unchanged (1.3), churn-limiting shims (1.3), codemod path swap (2.2), drop `specSchemaAccess` (2.3), batch-test wiring + 0-error validation (3.1), docs + changeset (1.1/3.2). PR #2277 supersession is covered by the `specSchemaAccess` removal (no `specTypeSchemas` rewrite produced). All spec sections map to a task. - -**Placeholder scan:** no `TBD`/`TODO`; the one conditional (`tsconfig paths` in 1.3 Step 4 / 1.4 Step 4) is gated on a concrete typecheck failure with the exact lines to add. Move tasks specify exact `git mv` targets rather than reproducing the 2346-line `schemas.ts` (relocation, not authoring). - -**Type/name consistency:** `@modelcontextprotocol/sdk-shared` used verbatim throughout; `./types` subpath defined in 1.1 (package.json exports + typesVersions), produced in 1.2 (built from `src/types.ts`), consumed in 1.3 (core `types.ts` shim) and 1.4 (server/client dts paths); `lookupImportMapping` (2.2) matches the existing helper; `LOCAL_PACKAGE_DIRS`/`rewriteToLocalTarballs`/`tarballs` (3.1) match `batchTest.ts`. - -## Execution Handoff - -Two execution options: - -1. **Subagent-Driven (recommended)** — a fresh subagent per task, with review between tasks. -2. **Inline Execution** — execute tasks in this session with checkpoints. - -Note: every "Commit" step prints commands for **you** to run (the `git add`/`git commit` hook blocks the agent from committing). diff --git a/docs/superpowers/specs/2026-05-11-codemod-batch-test-design.md b/docs/superpowers/specs/2026-05-11-codemod-batch-test-design.md deleted file mode 100644 index 03709e94e2..0000000000 --- a/docs/superpowers/specs/2026-05-11-codemod-batch-test-design.md +++ /dev/null @@ -1,288 +0,0 @@ -# Codemod Batch Test: Design Spec - -Repeatable process for running the MCP v1-to-v2 codemod against real-world repos, identifying issues, and iterating on the codemod. - -## Goal - -Improve the codemod by testing it against 10-15 curated external repos. Each iteration: run the codemod, compare baseline vs. post-codemod check results, have Claude categorize failures, fix the codemod, repeat. - -## System Overview - -Three components, all living in `packages/codemod/batch-test/`: - -1. **Repo manifest** (`repos.json`) -- JSON file listing target repos, their structure, and optional overrides. -2. **Batch runner** (`run-codemod-batch.sh`) -- Shell script that iterates the manifest: clones, installs, baselines, codemods, re-checks, writes structured output. -3. **Analysis prompt** (`analyze-prompt.md`) -- Instructions for Claude Code to run the script and analyze results in a single session. - -### Data Flow - -``` -repos.json --> run-codemod-batch.sh --> results//report.json (per-repo) - --> results/summary.json (consolidated) - -Claude Code: runs script, reads results, produces categorized analysis -``` - -## Repo Manifest (`repos.json`) - -An array of repo entries. Each entry represents a GitHub repo and one or more packages within it that use `@modelcontextprotocol/sdk` v1. - -```json -[ - { - "repo": "owner/repo-name", - "ref": "main", - "packages": [ - { - "dir": "packages/mcp-server", - "sourceDir": "src", - "checks": { - "typecheck": "npm run check:ts", - "test": null - } - } - ] - } -] -``` - -### Fields - -| Field | Required | Default | Description | -|-------|----------|---------|-------------| -| `repo` | yes | -- | GitHub `owner/name` | -| `ref` | no | `main` | Branch or tag to check out | -| `packages` | no | `[{ "dir": ".", "sourceDir": "src" }]` | Package targets within the repo | -| `packages[].dir` | yes | -- | Path to the package root (where its `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 key to `null` to skip that check | - -### Auto-Detection Rules - -**Package manager** (first lockfile found at repo root): -- `pnpm-lock.yaml` -> `pnpm` -- `yarn.lock` -> `yarn` -- `package-lock.json` -> `npm` -- `bun.lockb` -> `bun` - -**Check commands** (read `scripts` from the package's `package.json`, first match wins): - -| Check | Script names probed (in order) | Fallback | -|-------|-------------------------------|----------| -| typecheck | `typecheck`, `type-check`, `check:types`, `tsc` | `npx tsc --noEmit` | -| build | `build`, `compile` | skip | -| test | `test`, `test:unit`, `test:all` | skip | -| lint | `lint`, `lint:check` | skip | - -The detected command runs as ` run `. - -## Batch Runner (`run-codemod-batch.sh`) - -### CLI - -```bash -./run-codemod-batch.sh [--manifest repos.json] [--output-dir ./results] [--clone-dir ./repos] [--fresh-clones] -``` - -| Flag | Default | Description | -|------|---------|-------------| -| `--manifest` | `./repos.json` | Path to repo manifest | -| `--output-dir` | `./results` | Where to write reports | -| `--clone-dir` | `./repos` | Where to clone repos | -| `--fresh-clones` | off | Force re-clone even if clone exists | - -Clones are kept between runs by default for fast iteration. - -### Per-Repo Flow - -``` -1. CLONE OR RESET - - If clone exists: git restore . && git clean -fd - - If no clone: git clone --depth 1 --branch - -2. DETECT PACKAGE MANAGER - - Check for lockfile at repo root - -3. INSTALL - - cd && install - - If install fails: record error, skip to next repo - -4. BASELINE CHECKS (for each package) - - Auto-detect or use override check commands - - Run: typecheck, build, test, lint - - Capture: exit code, stdout, stderr for each - -5. RUN CODEMOD (for each package) - - node /packages/codemod/dist/cli.mjs v1-to-v2 \ - // --verbose - - Capture: full output, diagnostics, change count - -6. RE-INSTALL - - cd && install - - Picks up new v2 deps from updated package.json files - -7. POST-CODEMOD CHECKS (for each package) - - Same checks as step 4, captured separately - -8. WRITE REPORT - - Write per-repo JSON to results//report.json - - Append entry to summary -``` - -### Error Handling - -If any step fails for a repo, the script logs the failure, writes what it has to the report, and moves to the next repo. One broken repo does not stop the batch. - -### Path Resolution - -The script resolves `SDK_ROOT` from its own location (`SDK_ROOT=$(cd "$(dirname "$0")/../../.." && pwd)`). All default paths (`--clone-dir`, `--output-dir`) are relative to the script's directory (`packages/codemod/batch-test/`). - -### Codemod Binary - -The script always uses the locally-built codemod from the current branch: -``` -node "$SDK_ROOT/packages/codemod/dist/cli.mjs" -``` -This ensures each run tests the current state of the codemod. - -## Output Format - -### Per-Repo Report (`results//report.json`) - -```json -{ - "repo": "user/mcp-server-example", - "ref": "main", - "timestamp": "2026-05-11T14:30:00Z", - "packageManager": "pnpm", - "packages": [ - { - "dir": ".", - "sourceDir": "src", - "codemod": { - "filesChanged": 12, - "totalChanges": 47, - "diagnostics": [ - { - "level": "warning", - "file": "src/server.ts", - "line": 42, - "message": "Destructuring pattern for 'extra' -- review manually", - "transformId": "context" - } - ] - }, - "baseline": { - "typecheck": { "exitCode": 0, "stdout": "", "stderr": "" }, - "build": { "exitCode": 0, "stdout": "", "stderr": "" }, - "test": { "exitCode": 0, "stdout": "", "stderr": "" }, - "lint": { "exitCode": 0, "stdout": "", "stderr": "" } - }, - "postCodemod": { - "typecheck": { "exitCode": 2, "stdout": "", "stderr": "src/handler.ts(15,3): error TS2345: ..." }, - "build": { "exitCode": 2, "stdout": "", "stderr": "..." }, - "test": { "exitCode": 0, "stdout": "", "stderr": "" }, - "lint": { "exitCode": 0, "stdout": "", "stderr": "" } - } - } - ] -} -``` - -### Consolidated Summary (`results/summary.json`) - -```json -{ - "timestamp": "2026-05-11T14:30:00Z", - "codemodVersion": "2.0.0-alpha.0", - "codemodCommit": "abc1234", - "totalRepos": 12, - "totalPackages": 15, - "results": [ - { - "repo": "user/mcp-server-example", - "package": ".", - "baselineClean": true, - "postCodemodClean": false, - "newErrors": { "typecheck": 3, "build": 1, "test": 0, "lint": 0 }, - "codemodDiagnostics": { "warning": 2, "error": 0, "info": 1 } - } - ], - "aggregated": { - "reposClean": 7, - "reposWithNewErrors": 5, - "totalNewTypecheckErrors": 18, - "totalCodemodWarnings": 12, - "topErrorPatterns": ["TS2345", "TS2339", "TS2554"] - } -} -``` - -## Claude Analysis Workflow - -### Prompt (`analyze-prompt.md`) - -Saved in `packages/codemod/batch-test/analyze-prompt.md`. You tell Claude Code to follow these instructions: - -``` -Run the batch codemod test and analyze results: - -1. Build the codemod: - pnpm --filter @modelcontextprotocol/codemod build - -2. Run the batch test: - ./packages/codemod/batch-test/run-codemod-batch.sh - -3. Read results/summary.json for the overview. - -4. For each repo with new errors, read its results//report.json. - -5. Categorize each new error (present in postCodemod but not in baseline): - - codemod-bug: The transform produced incorrect output - - missing-transform: The codemod should handle this pattern but doesn't - - manual-migration: Expected -- documented in migration guide, needs human judgment - - repo-specific: Unusual pattern unique to this repo, not worth handling - -6. Produce findings grouped by category with: - - Repo, file, line, error message - - Root cause (one sentence) - - For codemod-bug/missing-transform: which transform to fix and what correct output looks like - -7. Produce a "Priority Fixes" list: top 3-5 codemod improvements sorted by impact - (number of repos affected). -``` - -### Iteration Loop - -``` -1. Fix a codemod transform -2. Tell Claude: "Re-run the batch test and analyze" - --> Claude rebuilds codemod, resets clones, re-runs, reads results, analyzes -3. Review Claude's findings -4. Go to 1 -``` - -## Error Categorization Reference - -| Category | Meaning | Action | -|----------|---------|--------| -| `codemod-bug` | Transform produced wrong output | Fix the transform | -| `missing-transform` | Pattern not handled | Add handling to existing transform or create new one | -| `manual-migration` | Requires human judgment (removed API, architectural change) | Ensure migration guide covers it; improve codemod diagnostic | -| `repo-specific` | Unusual pattern unique to one repo | Document but don't add to codemod | - -## File Structure - -``` -packages/codemod/batch-test/ - repos.json # Repo manifest (curated list) - run-codemod-batch.sh # Batch runner script - analyze-prompt.md # Claude analysis instructions - repos/ # Cloned repos (gitignored) - results/ # Output reports (gitignored) - summary.json - / - report.json -``` - -`repos/` and `results/` are added to `.gitignore`. Only the manifest, script, and prompt are committed. diff --git a/docs/superpowers/specs/2026-06-02-readbuffer-max-size-design.md b/docs/superpowers/specs/2026-06-02-readbuffer-max-size-design.md deleted file mode 100644 index a8e89c3647..0000000000 --- a/docs/superpowers/specs/2026-06-02-readbuffer-max-size-design.md +++ /dev/null @@ -1,61 +0,0 @@ -# ReadBuffer Maximum Size Guard - -**Date:** 2026-06-02 -**Advisory:** GHSA-wqgc-pwpr-pq7r -**Severity:** Low (DoS via stdio transport, local attack surface) - -## Problem - -`ReadBuffer.append()` in `packages/core/src/shared/stdio.ts` concatenates incoming data with no size limit. A malicious MCP server subprocess can write continuous data to stdout without newline delimiters, causing the host process (Claude Desktop, Cursor, VS Code, etc.) to grow memory without bound until OOM-killed. - -The `data` event handlers in both `StdioClientTransport` and `StdioServerTransport` call `append()` outside any try/catch, so a thrown error from `append()` would become an uncaught exception — this must also be addressed. - -## Design - -### 1. ReadBuffer (`packages/core/src/shared/stdio.ts`) - -- Add exported constant `DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024` (10 MB). -- Constructor accepts optional `{ maxBufferSize?: number }` options object. -- `append()` checks `(currentSize + chunk.length) > maxBufferSize` before concatenating. -- On overflow: call `this.clear()` first (leave object in clean state), then throw `Error`. -- Fully backwards compatible — `new ReadBuffer()` with no args uses the default. - -### 2. StdioClientTransport (`packages/client/src/client/stdio.ts`) - -- Wrap the `stdout.on('data')` handler body in try/catch. -- On catch: route error to `this.onerror?.(error)`, then call `this.close()`. - -### 3. StdioServerTransport (`packages/server/src/server/stdio.ts`) - -- Wrap the `_ondata` handler body in try/catch. -- On catch: route error to `this.onerror?.(error)`, then call `this.close()`. - -### 4. Tests (`packages/core/test/shared/stdio.test.ts`) - -- `append()` throws when buffer exceeds default limit. -- `append()` throws with custom `maxBufferSize`. -- Buffer is cleared after overflow (object reusable). -- Default limit can be overridden via constructor. - -### 5. No changes to - -- Public API exports (`ReadBuffer` is already exported; constructor change is additive). -- `processReadBuffer()` in either transport (existing try/catch handles `readMessage()` errors; new try/catch handles `append()` errors at a higher level). - -## Files Modified - -| File | Change | -|------|--------| -| `packages/core/src/shared/stdio.ts` | Add `DEFAULT_MAX_BUFFER_SIZE`, constructor options, size guard in `append()` | -| `packages/client/src/client/stdio.ts` | try/catch in `data` handler, close on overflow | -| `packages/server/src/server/stdio.ts` | try/catch in `_ondata` handler, close on overflow | -| `packages/core/test/shared/stdio.test.ts` | New tests for buffer overflow behavior | - -## Decision Log - -- **10 MB default** chosen because a single JSON-RPC message shouldn't realistically exceed a few MB (even a 7 MB binary base64-encoded is ~9.3 MB). Users with legitimate large messages can raise the cap explicitly. -- **Throw from append()** rather than silent truncation or callback — uses existing error propagation paths and makes the failure visible. -- **Clear before throw** so the ReadBuffer isn't left in a corrupt state. -- **Close transport on overflow** because a buffer overflow means the peer is misbehaving and any partial data is unrecoverable. -- **No chunk-list optimization** — the 10 MB cap bounds the `Buffer.concat()` amplification to ~50 MB worst case, which is acceptable. Chunk-list can be a separate follow-up. -- **Options object** (not bare number) for the constructor parameter, for future extensibility. diff --git a/docs/superpowers/specs/2026-06-08-sep-2549-ttl-design.md b/docs/superpowers/specs/2026-06-08-sep-2549-ttl-design.md deleted file mode 100644 index f59499ed9c..0000000000 --- a/docs/superpowers/specs/2026-06-08-sep-2549-ttl-design.md +++ /dev/null @@ -1,495 +0,0 @@ -# SEP-2549: TTL for List Results — Design - -**Status:** Draft for review (rev 2 — incorporates backend + software architecture review) -**Date:** 2026-06-08 -**SEP:** [2549 — TTL for List Results](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2549-TTL-for-list-results.mdx) -**Branch:** `feature/v2-SEP-2549-ttl-for-list-results` - -## Context - -MCP clients currently discover changes to a server's tools/prompts/resources only via `list_changed` notifications, which require a long-lived SSE stream. Many HTTP clients and servers cannot reliably hold such streams, and there is no signal for how often a list actually changes. SEP-2549 adds two freshness fields — `ttlMs` and `cacheScope` — to the five cacheable result types so a server can tell clients how long a response stays fresh and who may cache it. This works alongside notifications (not as a replacement) and is fully backward-compatible with pre-2549 servers. - -The upstream draft spec (`schema/draft/schema.ts`) already defines these types. This work brings the TypeScript SDK to parity and — critically — makes the SDK actually *use* the hints: a client SDK that only emits the wire fields satisfies nothing of value, because `client.listTools()` would still refetch every time and discard the TTL. The deliverable therefore spans the wire types, server emission, a client-side cache, polling helpers, and a shared (multi-tenant) cache. - -### Scope decision - -Full implementation, all acceptance criteria. Confirmed during brainstorming: -- **Layer 5 (shared multi-tenant cache, R-2549-7): in scope** — ship it now. -- **Client cache enablement: `ClientOptions` flag** — cache logic lives in `Client`, off by default for backward compatibility. - -### Delivery: two PRs - -This design is implemented and reviewed as **two independent PRs**, because the API surface and risk profiles are very different: - -- **PR 1 — wire + server emission (Layers 1–2).** Low-risk, independently valuable, satisfies R-2549-1/3/8/12/13. Adds the spec-parity types and McpServer emission config. Ships first. -- **PR 2 — client cache, polling, shared store (Layers 3–5).** Carries all the contested surface (read-through caching, invalidation, multi-tenant isolation, polling). Built on top of PR 1, reviewed on its own. - -Both PRs are described here as one design for coherence; the File Summary marks which PR each file belongs to. - ---- - -## The two safety invariants - -Two invariants are load-bearing for the entire feature and every layer below depends on them. They are stated once here and tested explicitly. - -> **Invariant A — `ttlMs: 0` is never cached.** -> An entry with `ttlMs === 0` is never stored, never returned as fresh, and never shared across principals. This is what makes the feature backward-compatible (pre-2549 servers normalize to `ttlMs: 0` ⇒ behave exactly like today) **and** what makes a `cacheScope: 'public'` *default* safe at the wire layer (a defaulted-public entry with `ttlMs: 0` can never be served from any cache, shared or not). - -> **Invariant B — only *explicitly-declared* `cacheScope: 'public'` may be shared.** -> A shared cache (Layer 5) shares an entry across principals **only if the server explicitly sent `cacheScope: 'public'` on the wire.** An absent `cacheScope` — even though it normalizes to `'public'` for wire-type parity — is treated by the cache as *unknown ⇒ private ⇒ never shared.* This resolves the SEP's internal contradiction (see below) in the fail-safe direction: a misclassification costs at most a cache miss, never a cross-tenant data leak. - -### The SEP contradiction these invariants resolve - -The SEP is internally contradictory about the default for an absent `cacheScope`: - -- The `CacheableResult` JSDoc says: *"Defaults to `"public"` if absent."* -- The Backward Compatibility section says the opposite, and explains why: *"`cacheScope` is required because there is no safe default for older servers. The server must explicitly declare the intended cache scope to prevent unintended caching of user-specific data."* It calls out `resources/read` as user-specific (private). - -We honor **both** by separating two concerns the first draft conflated: - -1. **Wire-type normalization (Layer 1):** absent `cacheScope` → `'public'` *as a type-level default only*, so the SDK output type has the required field the spec demands (parity). This default value is never, by itself, an authorization to share — Invariant A guarantees a defaulted entry (which also has `ttlMs: 0` unless the server set a TTL) cannot be cached. -2. **Caching authorization (Layers 3 & 5):** the cache tracks whether `cacheScope` was *explicitly present on the wire* (`scopeExplicit`) and shares cross-principal only when it was explicitly `'public'` (Invariant B). - ---- - -## Acceptance Criteria → Layer Map - -| ID | Requirement | Layer | PR | -|----|-------------|-------|----| -| R-2549-1 (wire) | Server MUST include `ttlMs` (≥0) and `cacheScope` on the 5 result types | 1 + 2 | 1 | -| R-2549-3 (guidance) | Per-page `ttlMs`; freshness lives on the *result*, not on `Tool`/`Resource` | 1 | 1 | -| R-2549-12 | Absent `ttlMs` → treat as 0 (BC with pre-2549 servers) | 1 | 1 | -| R-2549-13 | Negative `ttlMs` → treat as 0 | 1 | 1 | -| R-2549-8 | Same `cacheScope` on every page of a paginated response | 2 | 1 | -| R-2549-2 | Client SHOULD refetch on next access after `ttlMs` expires; MAY serve stale on refetch error | 3 | 2 | -| R-2549-11 | `list_changed` / `resources/updated` invalidates the cache regardless of remaining TTL | 3 | 2 | -| R-2549-14 | Cursor invalid (`-32602` on next-page fetch) → discard cached pages, refetch from page 1 | 3 | 2 | -| R-2549-10 (sdk) | Polling helpers MUST apply jitter + backoff | 4 | 2 | -| R-2549-7 (security) | Shared caches MUST NOT serve `private` entries to a different user (key on auth principal) | 5 | 2 | -| R-2549-4 | `list_changed` notifications still delivered if subscribed — TTL is a hint, not a replacement | (asserted by test) | 2 | - -## Architecture Overview - -``` -┌─ Layer 1: Wire types (core/types) ──────────────────────────────────┐ -│ CacheableResult { ttlMs: number; cacheScope: 'public'|'private' } │ -│ → spread into 5 result schemas; .default() normalization only │ -│ → normalizeCacheable() helper does clamp/floor + records scopeExplicit│ -└───────────────────────────────────────────────────────────────────────┘ - │ emitted by │ consumed by - ▼ ▼ -┌─ Layer 2: Server (server/mcp) ─┐ ┌─ Layer 3: Client cache (client) ──┐ -│ McpServerOptions.cache (hints) │ │ ListCacheStore (pluggable) │ -│ injects ttlMs/cacheScope into │ │ + InMemoryListCacheStore (default)│ -│ list & read results │ │ freshness, invalidation, cursor │ -└─────────────────────────────────┘ └────────────────────────────────────┘ - │ used by │ impl - ▼ ▼ - ┌─ Layer 4: pollList ─┐ ┌─ Layer 5: SharedListCacheStore ┐ - │ jitter + backoff │ │ shares only explicit-public; │ - │ (opt-in) │ │ private namespaced by principal│ - └──────────────────────┘ └────────────────────────────────┘ -``` - ---- - -## Layer 1 — Wire Types - -**Files:** `packages/core/src/types/schemas.ts`, `packages/core/src/types/types.ts`, `packages/core/src/types/spec.types.ts` (regenerated), `packages/core/test/spec.types.test.ts`. Public export of the two new **types** rides the one sanctioned `export *` from `types.ts` (see *Type exports* below — this is the only wildcard; all package-barrel symbols are explicit). - -### Spec parity is the hard constraint - -`packages/core/test/spec.types.test.ts` enforces, for every type, **bidirectional assignability** (`sdk = spec; spec = sdk`) and **exact key parity** (`AssertExactKeys`), operating on `z.output` (`Infer`). The upstream spec defines: - -```typescript -export interface CacheableResult extends Result { - ttlMs: number; // REQUIRED - cacheScope: "public" | "private"; // REQUIRED -} -export interface ListToolsResult extends PaginatedResult, CacheableResult { tools: Tool[]; } -// ...ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult likewise -export interface ReadResourceResult extends CacheableResult { contents: (...)[]; } -``` - -Because the spec fields are **required**, an `.optional()` Zod field would fail mutual assignability (an `sdk` value with `ttlMs?: number` is not assignable to a spec value requiring `ttlMs: number`). Therefore the SDK output type must have these fields **required**, while still tolerating their absence on the wire (R-2549-12). - -> **Parity covers `z.output` only.** The parity test never exercises `z.input`, so the "tolerates absence on the wire" property (R-2549-12/13) is **not** guarded by parity — it is guarded solely by the schema unit tests below. The doc previously implied parity validated normalization; it does not. - -### Mechanism: `.default()` only (no `.transform()`) - -The first draft used `.default().transform(...)`. We drop the transform for three reasons surfaced in review: (a) a `ZodEffects`/transform field is brittle when spread into objects that may later be `.extend()`/`.pick()`/`.partial()`ed; (b) it makes the normative clamp (negative→0, R-2549-13) invisible inside a field spread; (c) it widens the `z.input`/`z.output` skew more than necessary and runs again on any (future) outbound validation. Clamping moves into one named helper. - -```typescript -export const CacheScopeSchema = z.enum(['public', 'private']); - -// Field spread for the 5 result schemas. Plain .default() — output type is required, -// input is optional. No transform. -const cacheableResultFields = { - ttlMs: z.number().default(0), - cacheScope: CacheScopeSchema.default('public'), -}; -``` - -- `Infer` (z.output) = `{ ttlMs: number; cacheScope: 'public' | 'private' }` → **matches spec exactly** (parity passes). -- z.input allows both omitted → defaults applied at the parse boundary. The SDK validates results on the **receiving (client) side**, so absent `ttlMs` becomes `0` and absent `cacheScope` becomes `'public'` automatically. - -> **Spike the exact Zod v4 form before building Layer 3.** Confirm that `z.number().default(0)` spread into a `looseObject` result schema yields `z.output` with a *required* `number` and passes `AssertExactKeys` against the regenerated spec type. This is a 30-minute compile-only spike; do it first (it was the original Open Risk #1). - -### Normalization + the `scopeExplicit` signal - -Clamp/floor and the explicitness signal live in one helper consumed by the client cache (Layer 3). It runs on the **already-parsed** result *and* inspects the **raw** (pre-parse) payload to learn whether `cacheScope` was actually present on the wire: - -```typescript -// packages/core/src/types/... (exported for client use) -export interface NormalizedCacheMeta { - ttlMs: number; // clamped: negative/NaN/Infinity → 0, floored to int - cacheScope: CacheScope; // parsed value (defaulted to 'public' if absent) - scopeExplicit: boolean; // TRUE only if the raw wire payload contained `cacheScope` -} - -export function normalizeCacheMeta(parsed: CacheableResult, raw: unknown): NormalizedCacheMeta { - const ttlMs = Number.isFinite(parsed.ttlMs) && parsed.ttlMs > 0 ? Math.floor(parsed.ttlMs) : 0; // R-2549-12/13 - const scopeExplicit = typeof raw === 'object' && raw !== null && 'cacheScope' in raw; - return { ttlMs, cacheScope: parsed.cacheScope, scopeExplicit }; -} -``` - -This is the **single source of truth** for normalization. `scopeExplicit` is the signal Invariant B needs and that a bare `.default('public')` would otherwise erase. The client cache stores it on `CachedEntry`; `SharedListCacheStore` reads it to decide shareability. - -Spread `...cacheableResultFields` into the 5 result schemas only: `ListToolsResultSchema`, `ListPromptsResultSchema`, `ListResourcesResultSchema`, `ListResourceTemplatesResultSchema`, `ReadResourceResultSchema`. **Never** added to item schemas (`Tool`, `Resource`, etc.) — freshness lives on the result (R-2549-3). `ListRootsResult`, `PaginatedResult`, and `ResultSchema` are untouched. - -### Type exports & spec test - -- Add `CacheScope` and `CacheableResult` **type** exports in `types.ts`. These become public via the *one intentional* `export * from './types/types.js'` in `core/public` (documented there as the sanctioned wildcard). **All other new symbols** (`ListCacheStore`, `InMemoryListCacheStore`, `SharedListCacheStore`, `CacheHints`, `McpServerOptions`, `pollList`, etc.) live in the client/server packages and MUST be added as **explicit named exports** in `packages/client/src/index.ts` / `packages/server/src/index.ts` — never via `export *`. (The earlier draft's "transitively via `export *`" framing applied *only* to the two core types; do not let it leak into the package barrels.) -- The spec defines `CacheableResult` as a base interface with no standalone schema; the SDK mirrors it as an exported **type**, while the runtime schema is the `cacheableResultFields` spread. The exported type and the spread fields therefore have **no shared schema** and must be hand-kept in sync — call this out as a known manual-sync seam (it mirrors how `PaginatedResult` is handled today). -- `CacheableResult` is marked `@internal` in the upstream spec. We deliberately export it as public SDK API anyway, because it is the natural return-type contract for low-level handler authors (see ADR-001). Note the divergence in the export comment. -- Run `pnpm fetch:spec-types` first — the committed `spec.types.ts` is stale (commit `5c25208…`) and predates these fields. (`spec.types.ts` is `.gitignore`d and regenerated by `pnpm test`.) -- Add a `CacheableResult` entry to `sdkTypeChecks` and a `_K_CacheableResult` key-parity assertion in `spec.types.test.ts`. Update the expected spec-type count (`toHaveLength(176)` → new count). **Verify the `_meta`/index-signature interplay:** `ResultSchema` is a `looseObject` (carries an index signature), so confirm `AssertExactKeys` for `CacheableResult` resolves to exactly `{ ttlMs, cacheScope, _meta? }` and that the loose index signature neither makes the assertion trivially pass nor spuriously fail. - -### Breaking change — see ADR-001 - -Low-level `Server.setRequestHandler('tools/list', …)` handlers (and the other four) now have a return type requiring `ttlMs`/`cacheScope`. This is a **deliberate, recorded** breaking change, not an accident of typing. Rationale, the rejected alternative, and the blast radius are in **ADR-001** below. McpServer injects the fields so high-level authors are unaffected (Layer 2). Documented in `docs/migration.md` + `docs/migration-SKILL.md`. - ---- - -## ADR-001 — Breaking change to low-level `Server` handler return types - -**Status:** Accepted · **Context:** Layer 1/2 · **Decision owners:** SEP-2549 implementers - -**Context.** With `.default()`, the result schemas' `z.output` (= `Infer` = the public `ListToolsResult` type, and the type `ResultTypeMap['tools/list']` against which `setRequestHandler` types a handler's return value) has `ttlMs`/`cacheScope` as **required**. So every low-level handler for the 5 methods must now return both fields or fail to type-check. McpServer's own internal handlers are also consumers of this type and are updated in Layer 2. - -**The alternative considered.** Type handler returns against `z.input` (where `.default()` makes the fields optional) while keeping the public wire type as `z.output`. That would make the change *non-breaking* for low-level authors. - -**Why we reject it.** The protocol does **not** re-validate or re-parse outbound results through the result schema — outbound results are serialized as-is; only the *receiving* side parses (verified in `protocol.ts` request/response path). So `z.input`-typed handlers would put **no** `ttlMs`/`cacheScope` on the wire, silently violating R-2549-1 ("server MUST include"). Making the change non-breaking would require introducing (a) input/output result-type *duality* across `ResultTypeMap`/`InferHandlerResult` and (b) a new outbound-normalization pass that does not exist today — a larger, more invasive change than the break, and one that trades a compile-time error for a *silent* spec violation. - -**Decision.** Keep the breaking change. Surfacing the "server MUST include" obligation as a compile-time error on low-level handlers is the spec-faithful, fail-loud choice. - -**Consequences.** (1) Low-level `Server` users for the 5 methods must add the two fields — migration guide ships the exact two-field snippet and points to `normalizeCacheMeta`/`CacheableResult`. (2) McpServer is itself a consumer and is updated in the same PR. (3) If a future SEP needs non-breaking result-type evolution, the input/output duality is the path — recorded here so it isn't re-litigated. - ---- - -## Layer 2 — Server Emission - -**Files:** `packages/server/src/server/mcp.ts`, `packages/server/src/index.ts`. **(PR 1)** - -```typescript -// Renamed from the draft's `ListCacheConfig`: this is emission config (freshness hints), -// not a cache. Avoids prefix-collision with the client's ListCache* types. -export interface CacheHints { ttlMs?: number; cacheScope?: 'public' | 'private'; } - -export interface McpServerOptions extends ServerOptions { - cache?: { - tools?: CacheHints; - resources?: CacheHints; - resourceTemplates?: CacheHints; - prompts?: CacheHints; - resourceRead?: CacheHints; // hints for resources/read - }; -} -``` - -- `McpServer` constructor widens `options?: ServerOptions` → `McpServerOptions` (backward-compatible) and stores `_cacheOptions`. -- The 4 list handlers spread `{ ttlMs: cfg?.ttlMs ?? 0, cacheScope: cfg?.cacheScope ?? 'public' }` into their results. Because config is static per endpoint, **every page gets the same `cacheScope`** (R-2549-8) for free. -- `resources/read`: callback result is normalized — `ttlMs`/`cacheScope` from the callback win; otherwise fall back to `cache.resourceRead` config, then defaults. The `ReadResourceCallback` return type is loosened so callbacks may omit the fields; McpServer fills them. -- **`resources/read` privacy footgun (documented).** The SEP calls out `resources/read` as the user-specific endpoint. The emission default is `cacheScope: 'public'` only because the paired default `ttlMs: 0` makes it uncacheable (Invariant A). **The migration guide MUST warn:** if you configure a non-zero `ttlMs` on `resourceRead` (or return one from the callback) for user-specific content, you MUST also set `cacheScope: 'private'`, or a downstream shared gateway may cache it. As defense-in-depth, when `cache.resourceRead.ttlMs > 0` is configured but `cacheScope` is omitted, McpServer emits `'private'` (not `'public'`) for `resources/read` specifically. -- **R-2549-8 for low-level servers.** McpServer guarantees same-scope-per-page structurally (static config). A low-level `Server` author paginating by hand could still vary `cacheScope` across pages; this is the author's responsibility per the spec MUST. We document it; we do not add a runtime guard (the SDK does not own the low-level pagination loop). -- Export `CacheHints`, `McpServerOptions` from `@modelcontextprotocol/server` as **explicit named exports**. - ---- - -## Layer 3 — Client-Side List Cache - -**Files:** new `packages/client/src/client/listCache.ts`; modify `packages/client/src/client/client.ts`, `packages/client/src/index.ts`. **(PR 2)** - -### Pluggable store - -```typescript -export interface ListCacheKey { - method: 'tools/list' | 'prompts/list' | 'resources/list' | 'resources/templates/list' | 'resources/read'; - cursor?: string; // pagination position - uri?: string; // for resources/read - principal?: string; // supplied by caller for shared caches (Layer 5); undefined for per-client -} - -export interface CachedEntry { - result: unknown; - receivedAt: number; - ttlMs: number; - cacheScope: CacheScope; - scopeExplicit: boolean; // from normalizeCacheMeta — gates cross-principal sharing (Invariant B) -} - -export interface ListCacheStore { - get(key: ListCacheKey): CachedEntry | undefined; - set(key: ListCacheKey, entry: CachedEntry): void; - /** Invalidate entries for a method. See the invalidation matrix below for principal/uri semantics. */ - invalidate(method: ListCacheKey['method'], opts?: { uri?: string; principal?: string }): void; - clear(): void; -} - -export class InMemoryListCacheStore implements ListCacheStore { /* Map-backed, single-tenant */ } -``` - -### Client-local request options (no core pollution) - -`principal`/`bypassCache` are **client-cache concerns and do not belong in core `RequestOptions`** (which is the transport-agnostic, server-and-client per-call bag). They are added in a **client-local** extension instead, keeping the core protocol type pure: - -```typescript -// packages/client/src/client/client.ts -export type ListRequestOptions = RequestOptions & { - principal?: string; // routing key for a shared cache (Layer 5); MUST be the validated auth principal - bypassCache?: boolean; // force refetch, then refresh the cache entry -}; -``` - -The list/read methods widen their `options?: RequestOptions` parameter to `ListRequestOptions` locally. Core `shared/protocol.ts` is **not** modified. - -### ClientOptions - -```typescript -export type ClientOptions = ProtocolOptions & { - // ...existing... - cache?: { - store?: ListCacheStore; // default: new InMemoryListCacheStore() when `cache` present - serveStaleOnError?: boolean; // R-2549-2 "MAY"; default true (resilience) - now?: () => number; // injectable clock for tests; default Date.now (runtime-neutral) - }; -}; -``` - -Absent `cache` ⇒ no caching, current behavior preserved (BC). - -### Read-through in list/read methods - -`listTools`, `listResources`, `listResourceTemplates`, `listPrompts`, `readResource` gain a cache path when caching is enabled: - -1. Build `ListCacheKey` (method + cursor/uri + optional `principal` from `ListRequestOptions`). If `bypassCache` → skip step 2. -2. `entry = store.get(key)`. **Fresh** if `now() < entry.receivedAt + entry.ttlMs` → return cached result. -3. On miss/stale: perform the request. Run `normalizeCacheMeta(parsed, raw)`. **Invariant A:** if `ttlMs === 0`, return the result but **do not** `store.set` (never cache a zero-TTL entry). Otherwise `store.set` with `receivedAt = now()` and the normalized `ttlMs`/`cacheScope`/`scopeExplicit`. -4. On **refetch error** with a prior entry present and `serveStaleOnError` (default true): return the stale result (R-2549-2). Otherwise propagate. - -### Notification invalidation (R-2549-11) - -> **Fix from review:** the existing `_setupListChangedHandlers` only registers when the user passed a `listChanged` config **and** the server advertised the capability — the common cache-user case (no `listChanged` config) would get **no** invalidation, serving stale until TTL expiry and violating R-2549-11. - -When **caching is enabled**, the client registers cache-invalidation notification handlers **unconditionally** — independent of the `listChanged` config and independent of the server capability gate. Because `setNotificationHandler` replaces by method (and would clobber a user's `listChanged` handler), invalidation is wired through an **internal dispatcher**: a single registered handler per notification method that first runs cache invalidation, then delegates to any user-supplied handler. The existing `_setupListChangedHandlers` is refactored to register *through* this dispatcher rather than directly, so cache invalidation and user `onListChanged` callbacks **compose** instead of overwriting each other. - -- `notifications/tools/list_changed` → `store.invalidate('tools/list')` -- `notifications/prompts/list_changed` → `store.invalidate('prompts/list')` -- `notifications/resources/list_changed` → `store.invalidate('resources/list')` **and** `store.invalidate('resources/templates/list')` (conservative; over-invalidates templates on a resource-list change — acceptable, documented) -- `notifications/resources/updated` → `store.invalidate('resources/read', { uri })` for the updated URI - -Invalidation is immediate, regardless of remaining TTL. - -> **`resources/read` invalidation is subscription-dependent (documented).** `notifications/resources/updated` is only sent by the server if the client previously sent `resources/subscribe` for that URI. So notification-driven read-cache invalidation is satisfied **for subscribed URIs only**; for unsubscribed reads, the TTL is the sole staleness bound. Enabling the read cache does **not** auto-subscribe (left to the caller, by design). R-2549-11 status: *satisfied for subscribed resources; TTL-bounded otherwise.* - -### Cursor invalidation (R-2549-14) - -> **Fix from review:** the draft treated *any* `-32602` on *any* cursored fetch as "drop all pages." `-32602` (InvalidParams) is generic and can mean a malformed `params` unrelated to the cursor, masking real bugs in a surprising full-refetch loop. - -Narrowed trigger: the special handling fires **only** when (a) the failing request carried a `cursor` that this cache itself issued from a prior page of the **same list traversal** (a *cache-originated* cursor), **and** (b) the server replied with `ProtocolError` code `-32602`. On both: - -1. `store.invalidate(method)` — drop all cached pages for that list. -2. Re-fetch from page 1 (no cursor). This retry runs with cursor-invalidation **structurally disabled** (it cannot recurse into another page-drop — enforced by a flag scoped to the retry, not by convention), so a second failure propagates. -3. `log`/emit a debug signal when pages are collapsed, so a genuine `-32602` bug is not silently swallowed. - -> **No snapshot isolation across a traversal (documented).** The cache provides **per-page freshness**, not a consistent snapshot of a full paginated list. Each page has its own freshness clock (R-2549-3); a mid-traversal refetch (TTL expiry or cursor invalidation) can yield a page 1 different from the one the caller already consumed. Callers needing a consistent full-list snapshot SHOULD iterate with `bypassCache` or refetch from the beginning, per the SEP. - ---- - -## Layer 4 — Polling Helpers - -**Files:** new `packages/client/src/client/pollList.ts`; export (explicit named) from `packages/client/src/index.ts`. **(PR 2)** - -```typescript -export interface PollListOptions { - onUpdate: (result: unknown) => void; - onError?: (error: unknown) => void; - signal?: AbortSignal; - minIntervalMs?: number; // floor on the poll interval; default 30_000 (see below) - jitter?: number; // fraction, default 0.2 (±20%) - backoff?: { initialMs?: number; maxMs?: number; factor?: number }; // on error - backoffOnUnchanged?: boolean; // grow interval when results are unchanged; default true -} - -// ttlMs is read internally off the response — callers don't extract it. -export function pollList( - client: Client, - method: 'tools/list' | 'prompts/list' | 'resources/list' | 'resources/templates/list', - options: PollListOptions, -): { stop: () => void }; -``` - -> **Fix from review:** the draft's `fetch: () => Promise<{ result, ttlMs }>` leaked `ttlMs` extraction to the caller. `pollList` now takes the `client` + `method` and reads `ttlMs` off the normalized response itself. - -- Base interval seeded from the last response's `ttlMs`, **clamped up** to `minIntervalMs`. -- **`minIntervalMs` default is `30_000`, not `1_000`.** A `ttlMs: 0` server (very common — every pre-2549 server) would otherwise drive a 1-req/sec hammer per list. Polling a `ttlMs: 0` server is **degenerate**; the docs warn against it and the high floor blunts the damage. -- **Jitter** (MUST per R-2549-10): each delay multiplied by `1 ± jitter*random` to avoid thundering herd. -- **Backoff** (MUST per R-2549-10): on error, exponential backoff (`initialMs * factor^n`, capped at `maxMs`) until next success. -- **Backoff on unchanged** (default on): when a poll returns a result equal to the previous one, grow the interval (same capped exponential) so a chatty poller relaxes against a static list; reset on change. Without this, a poller never backs off a never-changing list. -- Opt-in only. Reconciles the SEP's "clients SHOULD NOT use TTL as a polling interval" guidance with R-2549-10: the *default* path is freshness-on-access (Layer 3); `pollList` is a separate utility for callers who explicitly want background refresh, and when used it MUST jitter+backoff. **Docs steer hard toward `pollList`** and explicitly warn against naive `setInterval(ttlMs)` polling — the SDK cannot enforce that a caller won't hand-roll a poll loop, so guidance carries the MUST's intent. - -> **MUST vs SHOULD note (in-code comment).** The SEP MD says polling *SHOULD* jitter+backoff; the canonical `caching.mdx` spec page (and acceptance criterion R-2549-10) escalate this to *MUST*. We implement the stricter MUST. A code comment records the source of the stricter rule so it isn't "relaxed" later by someone reading only the SEP MD. - ---- - -## Layer 5 — Shared (Multi-Tenant) Cache - -**Files:** `packages/client/src/client/listCache.ts` (add `SharedListCacheStore`); export (explicit named) from `packages/client/src/index.ts`. **(PR 2)** - -A `ListCacheStore` implementation for gateways/proxies that front multiple end users through one upstream `Client`. - -### Sharing rule (Invariant B) - -- An entry is shared across principals **only if** `entry.cacheScope === 'public'` **and** `entry.scopeExplicit === true`. A defaulted-public entry (`scopeExplicit === false`) is treated as **private** and is never served to a different principal — fail-safe against pre-2549 / misconfigured servers. -- `cacheScope: 'private'` (or non-explicit) entries are namespaced by `key.principal`. A `get` for such an entry with a non-matching (or absent) principal returns `undefined`. -- Combined with Invariant A (`ttlMs: 0` never stored), this closes the leak path the first draft had: an absent-`cacheScope` user-specific response can never be served cross-user. - -### Principal contract (security-critical) - -- The **principal is supplied by the caller per request** via `ListRequestOptions.principal`. The SDK does not infer identity. -- The principal **MUST be derived from the validated auth principal** (e.g. `ctx.http.authInfo`), an **opaque, stable identifier** — never from request-supplied, attacker-influenceable headers. This is stated as an enforced contract in the API docs, not a passing comment. -- Principal strings are compared **as-is** (no normalization): the store treats distinct representations as distinct namespaces. This is the safe direction (over-isolation, never under-isolation); callers MUST pass a canonical ID. Documented. -- A `private` (or non-explicit) `set` **without** a principal is a **no-op** (the entry cannot be safely isolated, so it is not stored) — documented and tested. - -### Invalidation matrix - -`invalidate(method, opts)` semantics, made explicit (the draft's interface couldn't express these): - -| Event | `opts` | Effect | -|-------|--------|--------| -| `list_changed` for a shared/public list | `{}` | Invalidate the shared public entry for **all** principals **and** every principal's private copy of that method | -| `resources/updated` for a private resource | `{ uri, principal }` | Invalidate only that **principal's** entry for that **uri** | -| `resources/updated`, principal unknown | `{ uri }` | Invalidate that `uri` across **all** principals (conservative) | -| explicit per-principal clear | `{ principal }` | Invalidate that principal's entries for the method | - -Isolation is the security-critical property and gets dedicated tests (below), including the **absent-`cacheScope`** case — not just the explicitly-`private` case — because that is the actual leak path. - ---- - -## R-2549-4 — Notifications Coexist - -No new machinery: `list_changed` notifications already flow through the notification path independent of TTL, and Layer 3's dispatcher composes cache invalidation **with** user `onListChanged` handlers. A test asserts that with caching enabled, a server advertising `listChanged` still delivers notifications **and** the client honors TTL — the two mechanisms layer (notification invalidates immediately; TTL bounds staleness otherwise). - ---- - -## Runtime neutrality - -`packages/client/src/index.ts` is the package root entry and MUST stay runtime-neutral (browser / Cloudflare Workers — no transitive `node:*`). The new modules `listCache.ts` and `pollList.ts` are re-exported from it, so: - -- Both modules MUST be pure JS — no `node:*`, no `crypto` import for principal hashing (principals are opaque caller-supplied strings; the store does not hash). `now` defaults to `Date.now` (neutral) and is injectable. -- Add `listCache.ts` and `pollList.ts` to the package's **`barrelClean` test** so a future accidental Node import is caught. - ---- - -## Testing Strategy - -Tests are TDD-first, one behavior at a time. Layered by package. - -**Core (`packages/core`) — PR 1:** -- `spec.types.test.ts` parity for `CacheableResult` + the 5 result types (compile-time), incl. the `_meta`/looseObject key-parity check. -- Schema unit tests (the **sole** guard for the input contract): absent `ttlMs` → 0; negative → 0; NaN/Infinity → 0; non-integer floored; absent `cacheScope` → `'public'`; explicit values preserved. -- `normalizeCacheMeta`: `scopeExplicit` true when raw payload has `cacheScope`, false when absent; clamp behavior. - -**Server (integration, `test/integration/test/server/mcp.test.ts`) — PR 1:** -- Each of the 4 list endpoints + `resources/read` emits configured `ttlMs`/`cacheScope`. -- `resources/read` callback values override config. -- `resources/read` with `ttlMs > 0` configured + `cacheScope` omitted → emits `'private'` (defense-in-depth). -- Same `cacheScope` across paginated pages (R-2549-8). -- Backward compat: no config → fields present with defaults (0/'public'). -- Low-level `Server` handler can (and must) return the fields directly (ADR-001). - -**Client (`test/integration/test/client/` + unit) — PR 2:** -- Fresh hit served from cache without a wire request; stale triggers refetch (R-2549-2), using injectable `now()`. -- **Invariant A:** `ttlMs: 0` result is never stored and always refetches (R-2549-12). -- Refetch error serves stale when `serveStaleOnError` (default), propagates when off. -- Notification invalidation for each type (R-2549-11), regardless of remaining TTL — **including the no-`listChanged`-config case** (handlers register unconditionally) and the **dispatcher composition** test (cache invalidation + user `onListChanged` both fire, neither clobbers the other). -- `resources/updated` invalidation works for a subscribed URI; documented gap asserted for unsubscribed. -- Cursor `-32602` on a **cache-originated** cursor → drop pages, refetch from page 1; retry is non-recursive (second failure propagates); a `-32602` on a non-cache-originated/non-cursored request does **not** trigger page-drop. -- `pollList`: jitter bounds the interval; backoff grows on consecutive errors then resets; backoff-on-unchanged grows then resets on change; `minIntervalMs` floor honored with `ttlMs: 0` (R-2549-10) — deterministic via injected clock + seeded randomness. -- **`SharedListCacheStore` (R-2549-7):** explicitly-private entry not served cross-principal; **absent-`cacheScope` entry not served cross-principal** (the real leak path); explicit-public entry shared; private `set` without principal is a no-op; the full invalidation matrix. -- Caching disabled by default → behavior identical to today (BC). -- `barrelClean` covers `listCache.ts` / `pollList.ts` (runtime neutrality). - -**E2E requirements manifest (`test/e2e/requirements.ts`):** -Register `caching:*` requirement entries linked to scenario tests so the conformance suite tracks coverage, mirroring existing entries: `caching:ttl:emitted`, `caching:client:freshness`, `caching:invalidate:list-changed`, `caching:invalidate:list-changed:no-config`, `caching:invalidate:resource-updated`, `caching:cursor:invalid`, `caching:scope:isolation`, `caching:scope:isolation:absent-scope`, `caching:poll:jitter-backoff`. Add scenario tests under `test/e2e/scenarios/`. - -## Documentation - -- `docs/migration.md` — human-readable: new fields, McpServer cache (`CacheHints`) config, the **`resources/read` privacy warning**, low-level `Server` breaking change (ADR-001) with the exact two-field snippet, client cache opt-in, the shared-cache **principal contract**, `pollList` (and the anti-pattern warning against `setInterval(ttlMs)`). -- `docs/migration-SKILL.md` — symbol mapping table (`CacheableResult`, `CacheScope`, `CacheHints`, `McpServerOptions`, `ListCacheStore`, `InMemoryListCacheStore`, `SharedListCacheStore`, `ListRequestOptions`, `pollList`, `normalizeCacheMeta`, schema extensions). -- A changeset under `.changeset/` per PR. - -## File Summary - -| Action | File | Layer | PR | -|--------|------|-------|----| -| Modify | `packages/core/src/types/schemas.ts` | 1 | 1 | -| Modify | `packages/core/src/types/types.ts` (+ `normalizeCacheMeta`, `CacheScope`, `CacheableResult`) | 1 | 1 | -| Regenerate | `packages/core/src/types/spec.types.ts` | 1 | 1 | -| Modify | `packages/core/test/spec.types.test.ts` | 1 | 1 | -| Modify | `packages/server/src/server/mcp.ts` | 2 | 1 | -| Modify | `packages/server/src/index.ts` (explicit exports: `CacheHints`, `McpServerOptions`) | 2 | 1 | -| Create | `packages/client/src/client/listCache.ts` | 3, 5 | 2 | -| Modify | `packages/client/src/client/client.ts` (`ListRequestOptions`, read-through, dispatcher) | 3 | 2 | -| Create | `packages/client/src/client/pollList.ts` | 4 | 2 | -| Modify | `packages/client/src/index.ts` (explicit named exports) | 3, 4, 5 | 2 | -| Modify | `test/integration/test/server/mcp.test.ts` | 2 | 1 | -| Create/Modify | `test/integration/test/client/*` | 3, 4, 5 | 2 | -| Modify | `test/e2e/requirements.ts` + `test/e2e/scenarios/*` | all | 1 & 2 | -| Modify | `docs/migration.md`, `docs/migration-SKILL.md`, `.changeset/*` | all | 1 & 2 | - -> **Note:** `packages/core/src/shared/protocol.ts` is **no longer modified.** `principal`/`bypassCache` moved to the client-local `ListRequestOptions` (Layer 3) to keep core protocol pure. - -## Review Findings → Resolution - -Traceability for the backend + software architecture review (rev 1 → rev 2): - -| Finding | Resolution | -|---------|------------| -| `cacheScope` default `'public'` leaks cross-tenant (CRITICAL ×2) | Invariants A & B; `scopeExplicit` on `CachedEntry`; `SharedListCacheStore` shares only explicit-public; `resources/read` defense-in-depth `'private'` | -| Notification invalidation gated by `listChanged` config/capability (CRITICAL) | Unconditional registration when caching on; internal dispatcher composes with user handlers; `no-config` test | -| `resources/updated` only fires when subscribed | Documented as subscription-dependent; R-2549-11 status qualified; asserted by test | -| Low-level `Server` breaking change rationale | ADR-001 (keep break; reject `z.input` softening; protocol does not back-fill outbound; McpServer is internal consumer) | -| `principal`/`bypassCache` pollute core `RequestOptions` | Moved to client-local `ListRequestOptions`; core untouched | -| `.transform()` brittle / lossy | Dropped; `.default()` + `normalizeCacheMeta` helper; `scopeExplicit` preserved | -| `-32602` cursor narrowing too broad | Scoped to cache-originated cursors; non-recursive single retry; logs on collapse; no-snapshot-isolation documented | -| Shared-cache bypass paths (F2) | Principal-derivation contract (auth principal only, opaque, no normalization); private-set-without-principal no-op; invalidation matrix; absent-scope test | -| Export plan `export *` framing | Split: two core types via sanctioned `export *`; all package-barrel symbols explicit named | -| `CacheableResult` `@internal` in spec | Deliberately public; divergence noted in export comment | -| Parity covers `z.output` only | Stated; input contract guarded by unit tests | -| `_meta`/looseObject key-parity interplay | Explicit verification step in spec-test bookkeeping | -| Type vs spread manual-sync seam | Called out (mirrors `PaginatedResult`) | -| `ListCacheConfig` prefix collision | Renamed server-side to `CacheHints` | -| `pollList` leaks `ttlMs` extraction | Signature takes `client` + `method`; reads `ttlMs` internally | -| `pollList` `minIntervalMs` 1s hammer / no unchanged-backoff | Default `30_000`; degenerate `ttlMs:0` warned; `backoffOnUnchanged` default on | -| Runtime neutrality of new modules | Pure-JS requirement + `barrelClean` coverage | -| API surface too large | Split into PR 1 (wire+server) and PR 2 (client cache) | -| `ttlMs:0` invariant under-stated | Promoted to Invariant A with dedicated test | -| R-2549-8 for low-level servers | Documented as author responsibility (no runtime guard) | - -## Open Risks - -1. **Exact Zod v4 `.default()` output-type form.** `z.number().default(0)` spread into a `looseObject` result schema must yield a *required* `number` in `z.output` and pass `AssertExactKeys`. De-risked by a compile-only spike done before Layer 3 (see Layer 1). Fallback: clamp/normalize entirely outside the schema (already the plan via `normalizeCacheMeta`). -2. **Dispatcher refactor of `_setupListChangedHandlers`.** Routing cache invalidation and user `onListChanged` through one composing dispatcher touches existing notification wiring. Covered by the composition test; verify no regression for users who configured `listChanged` without caching. -3. **Manual sync between exported `CacheableResult` type and the `cacheableResultFields` spread** (no shared schema). Low risk, mirrors `PaginatedResult`; parity test catches divergence at the result-type level. diff --git a/docs/superpowers/specs/2026-06-23-sdk-shared-package-design.md b/docs/superpowers/specs/2026-06-23-sdk-shared-package-design.md deleted file mode 100644 index 4f9f4157c0..0000000000 --- a/docs/superpowers/specs/2026-06-23-sdk-shared-package-design.md +++ /dev/null @@ -1,135 +0,0 @@ -# Design: `@modelcontextprotocol/sdk-shared` — canonical Zod schemas package - -- **Date:** 2026-06-23 -- **Status:** Approved (design); implementation plan pending -- **Owner:** Konstantin Konstantinov - -## Problem - -The v1→v2 migration of runtime schema validation is non-mechanical and lossy. - -In v1, consumers validated values with the exported Zod schema constants: - -```ts -import { CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; - -const parsed = ListToolsResultSchema.parse(res.body.result); // throws on invalid, returns value -const r = CallToolResultSchema.safeParse(value); // { success, data, error } -``` - -In v2 these schemas are reached via `specTypeSchemas.X` and **typed** as `StandardSchemaV1` (to keep Zod out of the public API), even though **at runtime they are still the underlying Zod schemas**. Because the public type is Standard Schema, `.parse()`/`.safeParse()` are not visible to the type checker, so the current codemod: - -- rewrites `CallToolResultSchema` → `specTypeSchemas.CallToolResult`, -- converts `.safeParse(x)` → `specTypeSchemas.X['~standard'].validate(x)` and remaps `.success`/`.data`/`.error` (which also changes the thrown error type), and -- has **no** one-line equivalent for `.parse()` (it throws; `validate()` does not), so those sites get a manual-migration diagnostic and don't compile until hand-edited. - -Validated against `firebase/firebase-tools`, this produced 4 post-codemod typecheck errors (all `.parse()`), plus project-type-resolution warnings on type-only files. - -A prior attempt (PR #2277) surfaces `parse()`/`safeParse()` on each `specTypeSchemas.X` entry as **type-only** methods and migrates by reference rename. That works but (a) pollutes the deliberately library-agnostic Standard Schema type with Zod-specific methods, and (b) only covers `parse`/`safeParse`, not other Zod methods (`.extend()`, `.merge()`, `.shape`, …). - -## Goals - -- Make schema-validation migration a **mechanical, behavior-preserving import-path swap**: `.parse()`/`.safeParse()` and every other Zod method keep working unchanged. -- Keep the `server`/`client` main API surface **Zod-free**; Zod coupling is opt-in and explicit. -- Establish a **canonical home for shared spec primitives** (schemas + types now, room for more later). -- Keep `specTypeSchemas`/`isSpecType` (the Standard Schema, library-agnostic view) intact and recommended for library-agnostic validation. - -## Non-goals - -- Changing the Standard Schema typing of `specTypeSchemas` (we are **not** adding `parse`/`safeParse` to it — this supersedes PR #2277's approach). -- Moving `Protocol`, transports, or validators. They stay in `core` and follow existing migration rules. -- Moving `specTypeSchemas`/`isSpecType` out of `core/public` (possible later; out of scope now). - -> **Update during implementation (Option C, user-approved):** the protocol **enums** (`enums.ts` → -> `ProtocolErrorCode`), **error classes** (`errors.ts` → `ProtocolError`, …), and **type guards** -> (`guards.ts`) were *also* moved into `sdk-shared`, reversing the original "they stay in core" -> non-goal. Rationale: v1's `sdk/types.js` was a kitchen-sink exporting all of these alongside the -> spec types/schemas, so the codemod's `types.js → sdk-shared` routing is only correct if sdk-shared -> carries that whole surface. Their dependency closure (schemas/types/enums) is already in sdk-shared, -> so the move is clean and introduces no cycle. **Exception:** `SdkError`/`SdkErrorCode`/`SdkHttpError` -> (the SDK-side error split, in `core/errors/sdkErrors.ts`) deliberately stay in `core` → `server`/`client`. - -## Decisions (locked) - -| Decision | Choice | -| --- | --- | -| Package name | `@modelcontextprotocol/sdk-shared` | -| Scope of move | Spec **types + Zod `*Schema` constants** | -| Positioning | Zod schemas are **first-class** (no codemod nudge toward `specTypeSchemas`) | -| server/client bundling | Depend on `sdk-shared` as a **regular dependency**, marked **external** (not bundled) | -| server/client re-exports | Re-export **types** from `sdk-shared`; do **not** re-export the raw Zod `*Schema` constants | -| Consumer dependency | Regular `dependency` (not peer); codemod adds it | -| core churn control | `core`'s internal barrel **re-exports** schemas/types from `sdk-shared` | - -## Architecture - -### Package - -New public package `packages/sdk-shared/` (`@modelcontextprotocol/sdk-shared`): - -- Owns the canonical MCP spec data model: the Zod `*Schema` constants and their derived TS types (`Tool`, `CallToolResult`, …), extracted from `packages/core/src/types/types.ts`. -- Depends only on `zod` (catalog: `runtimeShared`). **Runtime-neutral** — no Node builtins — so browser/Cloudflare Workers bundlers can consume it (covered by a `barrelClean` test, per CLAUDE.md). -- Uses explicit named exports. - -### Dependency graph (new edges in **bold**) - -``` -zod - └── @modelcontextprotocol/sdk-shared (NEW — types + Zod *Schema constants; zod-only) - ├── @modelcontextprotocol/core (private; imports schemas/types from sdk-shared, re-exports them from its barrel) - ├── **@modelcontextprotocol/server** ─┐ regular dependency, - └── **@modelcontextprotocol/client** ─┘ marked EXTERNAL in tsdown (not bundled) -``` - -`server`/`client` today inline `core` (and thus the schemas). After this change they treat `@modelcontextprotocol/sdk-shared` as an external dependency, so there is a single runtime instance and their bundles shrink. (Instance identity is not a correctness concern — validation is structural — so "single instance" is about source-of-truth and bundle size, not behavior.) - -### What moves vs. stays - -- **Moves to `sdk-shared`:** the spec Zod `*Schema` constants and their inferred TS types. `types.ts` is **split** along this line; the exact boundary (pure spec schemas + inferred types move; protocol constants such as `LATEST_PROTOCOL_VERSION` and method-name constants stay in `core` for now) is finalized during implementation. `core`'s barrel keeps re-exporting the moved symbols so the ~hundreds of internal `core` imports don't all change. -- **Stays in `core`:** `Protocol`, transports, validators, error classes/enums, protocol constants, and `specTypeSchemas`/`isSpecType` (rebuilt from `sdk-shared`'s schemas, exported via `core/public` as today). - -### Public API surface after the change - -| Symbol kind | Canonical home | Also re-exported by | Typed as | -| --- | --- | --- | --- | -| Spec **types** (`Tool`, `CallToolResult`, …) | `sdk-shared` | `core/public`, `server`, `client` | TS types (Zod-free) | -| Zod **`*Schema` constants** | `sdk-shared` **only** | — (intentionally not on server/client) | real Zod schemas | -| `specTypeSchemas` / `isSpecType` | `core/public` | `server`, `client` | `StandardSchemaV1` (Zod-free) | - -Guidance: use `specTypeSchemas` for library-agnostic Standard Schema validation; import the Zod `*Schema` from `@modelcontextprotocol/sdk-shared` when you want Zod ergonomics (`.parse`, `.safeParse`, `.extend`, …) or are migrating v1 code. - -## Codemod changes - -Today: the `imports` transform sends `sdk/types.js` → `RESOLVE_BY_CONTEXT`; the `specSchemaAccess` transform rewrites the schema reference, converts `.safeParse()`, and emits a manual-migration diagnostic for `.parse()`. - -After: - -1. **`@modelcontextprotocol/sdk/types.js`** (and the extensionless `/types`, already handled) **→ `@modelcontextprotocol/sdk-shared`**: a fixed, context-free path swap covering both types and `*Schema` constants. Symbol names unchanged; existing `renamedSymbols` (e.g. `ResourceTemplate`→`ResourceTemplateType`) still apply. -2. **Retire `specSchemaAccess`'s schema rewriting.** Because `sdk-shared` exports real Zod schemas, `.parse()`/`.safeParse()`/`.extend()`/`.shape`/… all keep working untouched — no reference rename, no `.safeParse` result remap, no `.parse()` manual-migration diagnostic. The independent `schemaParamRemoval` transform (strips schema args from `request()`/`callTool()`) is unaffected and stays. -3. **`updatePackageJson` adds `@modelcontextprotocol/sdk-shared`** to the consumer whenever a `types.js` import is routed there. - -Expected effect on `firebase/firebase-tools`: the 4 `.parse()` errors disappear (schemas validate via Zod as before) and the project-type warnings on type-only files disappear (fixed target, no context resolution) → **zero codemod-introduced typecheck errors**, far fewer diagnostics. - -## Testing strategy - -- **`sdk-shared` package:** unit tests asserting expected exports exist; `barrelClean` test (no Node builtins); runtime-neutral. -- **`codemod`:** update `importPaths` tests (`types.js`/`/types` → `sdk-shared`); remove/trim `specSchemaAccess` tests; add coverage for the dependency addition and "schema usage passes through untouched." -- **`core`/`server`/`client`:** existing suites + typecheck stay green after the `types.ts` split (the main risk). -- **Batch test:** add `sdk-shared` to `LOCAL_PACKAGE_DIRS`; add an `overrides` entry so the transitive `server`→`sdk-shared` edge resolves to the local tarball; re-run `firebase/firebase-tools` and confirm 0 introduced typecheck errors. - -## Docs & rollout - -- Rewrite the spec-schema validation section in `docs/migration.md` and `docs/migration-SKILL.md`: schemas now import from `@modelcontextprotocol/sdk-shared`, `.parse`/`.safeParse` keep working; `specTypeSchemas` remains for library-agnostic validation. Document the new package. -- Add a changeset covering the new package and the codemod change. -- **PR #2277 coordination:** this **supersedes** #2277's `specTypeSchemas` type-only `parse`/`safeParse` approach. Its other improvements are independent and worth keeping: client/server inference (#2) and the `tasks/*` handler-map fix (#3). The extensionless-import fix (#4) is already implemented on this branch. - -## Risks & mitigations - -- **Splitting `types.ts`** is wide-reaching. Mitigation: keep `core`'s barrel re-exporting the moved symbols; land the move as its own step with full `core` typecheck/tests green before touching the codemod. -- **Transitive local-tarball resolution** in the batch test (`server`→`sdk-shared`). Mitigation: `overrides` entry pointing `sdk-shared` at the local tarball (or publish an alpha). -- **New publish/version target.** Mitigation: version `sdk-shared` in lockstep with the other v2 packages via changesets. - -## Open questions (non-blocking) - -- Final split boundary inside `types.ts` (which non-schema symbols, if any, also belong in `sdk-shared`). -- Whether `specTypeSchemas`/`isSpecType` should eventually move to `sdk-shared` too (deferred). From c2e70af1f2d5272f34b91cffc0d7668fe1701b8f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 25 Jun 2026 09:01:29 +0300 Subject: [PATCH 8/8] fix(codemod): split aliased imports per-symbol, drop undefined schema arg, preserve import comments - importPaths: aliased named imports now route through the per-symbol splitter (addOrMergeImport carries aliases), so a mixed type+schema import no longer collapses into one v2 package and mis-routes schema constants - schemaParamRemoval: drop a literal `undefined` result-schema argument from request()/callTool() when an options arg follows - importPaths: preserve the first SDK import's leading header/JSDoc comment across the rewrite - batch-test: install pnpm clones standalone (--ignore-workspace --no-frozen-lockfile, CI=true) so repos nested in this workspace actually install --- packages/codemod/src/bin/batchTest.ts | 26 ++++++- .../v1-to-v2/transforms/importPaths.ts | 75 ++++++++++--------- .../v1-to-v2/transforms/schemaParamRemoval.ts | 13 ++++ packages/codemod/src/utils/importUtils.ts | 33 ++++++-- .../v1-to-v2/transforms/importPaths.test.ts | 50 ++++++++++++- .../transforms/schemaParamRemoval.test.ts | 25 +++++++ 6 files changed, 173 insertions(+), 49 deletions(-) diff --git a/packages/codemod/src/bin/batchTest.ts b/packages/codemod/src/bin/batchTest.ts index eefe0f2df5..913db845a8 100644 --- a/packages/codemod/src/bin/batchTest.ts +++ b/packages/codemod/src/bin/batchTest.ts @@ -111,6 +111,22 @@ function detectPm(repoRoot: string): string { return 'npm'; } +function installCommand(pm: string): string { + if (pm !== 'pnpm') return `${pm} install --ignore-scripts`; + // pnpm walks up to find a workspace; clones live inside this SDK's pnpm workspace, so a plain + // `pnpm install` targets the OUTER workspace and never populates the clone's node_modules — every + // downstream check (tsc base config, tsup, vitest) then fails identically at baseline and post, + // masking real codemod signal. + // --ignore-workspace: treat the clone as a standalone project (not part of the SDK workspace). + // --no-frozen-lockfile: the codemod rewrites package.json to swap v1 → v2 deps, so the lockfile + // must be allowed to change. CI=true (set in shell()) otherwise defaults + // pnpm to a frozen lockfile and the post-codemod reinstall silently skips + // the new v2 deps, leaving the clone on v1. + // npm/yarn/bun key off a `workspaces` field in package.json (absent at this repo root), so they + // need no equivalent flags. + return 'pnpm install --ignore-scripts --ignore-workspace --no-frozen-lockfile'; +} + function detectCheckCmd(pkgDir: string, checkType: string): string | null { const pkgJsonPath = path.join(pkgDir, 'package.json'); if (!existsSync(pkgJsonPath)) return null; @@ -133,7 +149,11 @@ function shell(cmd: string, cwd?: string): { exitCode: number; stdout: string; s cwd, stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, - timeout: 5 * 60 * 1000 + timeout: 5 * 60 * 1000, + // Commands are spawned without a TTY (piped stdio). Set CI so package managers run fully + // non-interactively — without it, pnpm aborts rebuilding a clone's modules dir with + // ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY when --ignore-workspace changes its link mode. + env: { ...process.env, CI: 'true' } }).toString(); return { exitCode: 0, stdout, stderr: '' }; } catch (error: unknown) { @@ -319,7 +339,7 @@ function main(): void { // Step 3: Install console.log(' Installing dependencies...'); - const installResult = shell(`${pm} install --ignore-scripts`, clonePath); + const installResult = shell(installCommand(pm), clonePath); if (installResult.exitCode !== 0) { console.log(` ERROR: install failed, skipping\n ${installResult.stderr.split('\n')[0]}`); continue; @@ -367,7 +387,7 @@ function main(): void { console.log(` Rewrote ${rewrites} deps to local tarballs`); } console.log(' Re-installing dependencies...'); - shell(`${pm} install --ignore-scripts`, clonePath); + shell(installCommand(pm), clonePath); // Step 7: Post-codemod checks console.log(' Running post-codemod checks...'); diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index fce2edf760..7ab0b724b7 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -4,6 +4,7 @@ import { SyntaxKind } from 'ts-morph'; import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; import { renameAllReferences } from '../../../utils/astUtils.js'; import { actionRequired, info, v2Gap, warning } from '../../../utils/diagnostics.js'; +import type { NamedImportSpec } from '../../../utils/importUtils.js'; import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils.js'; import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; import type { ImportMapping } from '../mappings/importMap.js'; @@ -75,17 +76,25 @@ export const importPathsTransform: Transform = { const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); + // A leading file-header / JSDoc comment attaches to the first SDK import as leading trivia. When + // that import is removed and re-emitted (the per-symbol split/merge path calls imp.remove()), + // ts-morph drops the comment with it. Capture it now and restore it after emitting if it was lost. + const leadingCommentText = sdkImports[0]! + .getLeadingCommentRanges() + .map(r => r.getText()) + .join('\n'); + interface PendingImport { - names: string[]; + specs: NamedImportSpec[]; isTypeOnly: boolean; } const pendingImports = new Map(); - function addPending(target: string, names: string[], isTypeOnly: boolean): void { + function addPending(target: string, specs: NamedImportSpec[], isTypeOnly: boolean): void { if (!pendingImports.has(target)) { pendingImports.set(target, []); } - pendingImports.get(target)!.push({ names, isTypeOnly }); + pendingImports.get(target)!.push({ specs, isTypeOnly }); } for (const imp of sdkImports) { @@ -141,26 +150,11 @@ export const importPathsTransform: Transform = { } } - const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined); - if (defaultImport || namespaceImport || hasAlias) { - let effectiveTarget = targetPackage; - if ((mapping.symbolTargetOverrides || mapping.schemaSymbolTarget) && !namespaceImport && !defaultImport) { - const overrides = namedImports.map(n => symbolTargetOverride(n.getName(), mapping)); - const uniqueOverrides = new Set(overrides.filter((t): t is string => t !== undefined)); - const allOverridden = namedImports.length > 0 && overrides.every(t => t !== undefined); - if (allOverridden && uniqueOverrides.size === 1) { - effectiveTarget = [...uniqueOverrides][0]!; - } else if (uniqueOverrides.size > 0) { - diagnostics.push( - actionRequired( - filePath, - imp, - `Aliased import from ${specifier} mixes symbols that belong to different v2 packages. ` + - `Split the import manually so each symbol targets the correct package.` - ) - ); - } - } + // Default and namespace imports cannot be split per-symbol — the whole binding moves to one + // package. Named imports (aliased or not) fall through to the per-symbol splitter below, so a + // single aliased specifier no longer forces unrelated symbols into the wrong package. + if (defaultImport || namespaceImport) { + const effectiveTarget = targetPackage; // A namespace import (`import * as ns from '…/types.js'`) cannot be split per-symbol, so // any `ns.Schema` accesses would silently resolve against the wrong package. Flag them. if (namespaceImport && mapping.schemaSymbolTarget) { @@ -220,11 +214,12 @@ export const importPathsTransform: Transform = { for (const n of namedImports) { const name = n.getName(); + const alias = n.getAliasNode()?.getText(); const resolvedName = mapping.renamedSymbols?.[name] ?? name; const specifierTypeOnly = typeOnly || n.isTypeOnly(); const symbolTarget = symbolTargetOverride(name, mapping) ?? targetPackage; usedPackages.add(symbolTarget); - addPending(symbolTarget, [resolvedName], specifierTypeOnly); + addPending(symbolTarget, [alias ? { name: resolvedName, alias } : resolvedName], specifierTypeOnly); } imp.remove(); changesCount++; @@ -236,28 +231,34 @@ export const importPathsTransform: Transform = { } } + const specLocal = (spec: NamedImportSpec): string => (typeof spec === 'string' ? spec : (spec.alias ?? spec.name)); for (const [target, groups] of pendingImports) { - const typeOnlyNames = new Set(); - const valueNames = new Set(); + // Dedupe by local binding name (alias when present), keeping the spec so aliases survive. + const typeOnlySpecs = new Map(); + const valueSpecs = new Map(); for (const group of groups) { - for (const name of group.names) { - if (group.isTypeOnly) { - typeOnlyNames.add(name); - } else { - valueNames.add(name); - } + for (const spec of group.specs) { + (group.isTypeOnly ? typeOnlySpecs : valueSpecs).set(specLocal(spec), spec); } } - if (valueNames.size > 0) { - addOrMergeImport(sourceFile, target, [...valueNames], false, insertIndex); + if (valueSpecs.size > 0) { + addOrMergeImport(sourceFile, target, [...valueSpecs.values()], false, insertIndex); } - if (typeOnlyNames.size > 0) { - const typeInsertIndex = valueNames.size > 0 ? insertIndex + 1 : insertIndex; - addOrMergeImport(sourceFile, target, [...typeOnlyNames], true, typeInsertIndex); + if (typeOnlySpecs.size > 0) { + const typeInsertIndex = valueSpecs.size > 0 ? insertIndex + 1 : insertIndex; + addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex); } } + // Restore the captured leading comment if the rewrite dropped it (guard against duplication when + // the first import was rewritten in place and kept its comment). + if (leadingCommentText && !sourceFile.getFullText().includes(leadingCommentText)) { + const imports = sourceFile.getImportDeclarations(); + const anchor = imports[Math.min(insertIndex, imports.length - 1)]; + sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}\n`); + } + return { changesCount, diagnostics, usedPackages }; } }; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts index eea8e33fd8..2e9737d383 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts @@ -27,6 +27,19 @@ export const schemaParamRemovalTransform: Transform = { const secondArg = args[1]!; if (!Node.isIdentifier(secondArg)) continue; + // `request(req, undefined, options)` / `callTool(params, undefined, options)`: v1 passed an + // explicit `undefined` result schema before the trailing options argument. v2 removed the + // schema parameter for spec methods, so the literal `undefined` leaves the call with one + // argument too many (TS2554). Drop it only when a third argument follows — a 2-arg + // `callTool(params, undefined)` already type-checks, since `undefined` is a valid options arg. + if (secondArg.getText() === 'undefined') { + if (args.length >= 3) { + call.removeArgument(1); + changesCount++; + } + continue; + } + const schemaName = secondArg.getText(); const originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName; if (!originalName.endsWith('Schema')) continue; diff --git a/packages/codemod/src/utils/importUtils.ts b/packages/codemod/src/utils/importUtils.ts index a1981cb14f..5ad92cd25b 100644 --- a/packages/codemod/src/utils/importUtils.ts +++ b/packages/codemod/src/utils/importUtils.ts @@ -33,31 +33,52 @@ export function isTypeOnlyImport(imp: ImportDeclaration): boolean { return imp.isTypeOnly(); } +/** A named import to emit: either a bare name, or a `{ name, alias }` pair preserving an `as` alias. */ +export type NamedImportSpec = string | { name: string; alias?: string }; + +function toSpec(n: NamedImportSpec): { name: string; alias?: string } { + return typeof n === 'string' ? { name: n } : n; +} + +/** Local binding a spec introduces — the alias when present, otherwise the imported name. */ +function specLocalName(s: { name: string; alias?: string }): string { + return s.alias ?? s.name; +} + export function addOrMergeImport( sourceFile: SourceFile, moduleSpecifier: string, - namedImports: string[], + namedImports: NamedImportSpec[], isTypeOnly: boolean, insertIndex: number ): void { if (namedImports.length === 0) return; + const specs = namedImports.map(n => toSpec(n)); + const existing = sourceFile.getImportDeclarations().find(imp => { if (imp.getNamespaceImport()) return false; 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); + const existingLocals = new Set(existing.getNamedImports().map(n => n.getAliasNode()?.getText() ?? n.getName())); + const newSpecs = specs.filter(s => !existingLocals.has(specLocalName(s))); + if (newSpecs.length > 0) { + existing.addNamedImports(newSpecs.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name }))); } } else { + const seen = new Set(); + const deduped = specs.filter(s => { + const local = specLocalName(s); + if (seen.has(local)) return false; + seen.add(local); + return true; + }); const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length); sourceFile.insertImportDeclaration(clampedIndex, { moduleSpecifier, - namedImports: [...new Set(namedImports)], + namedImports: deduped.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })), isTypeOnly }); } diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index e58a699f89..326579fe79 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -131,6 +131,45 @@ describe('import-paths transform', () => { expect(result).not.toContain('@modelcontextprotocol/sdk/types'); }); + it('splits an aliased types.js import: schema constant to sdk-shared, aliased type to server', () => { + // The presence of an alias (`Tool as SDKTool`) must not force the whole import into one package; + // each symbol still routes to its correct v2 target, with the alias preserved. + const input = [ + `import { CreateMessageRequestSchema, ClientCapabilities, Tool as SDKTool } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toMatch(/import\s*\{[^}]*\bCreateMessageRequestSchema\b[^}]*\}\s*from\s*["']@modelcontextprotocol\/sdk-shared["']/); + expect(result).toMatch(/import\s*\{[^}]*\bClientCapabilities\b[^}]*\}\s*from\s*["']@modelcontextprotocol\/server["']/); + expect(result).toContain('Tool as SDKTool'); + // the schema constant must NOT end up imported from @modelcontextprotocol/server + expect(result).not.toMatch(/import\s*\{[^}]*CreateMessageRequestSchema[^}]*\}\s*from\s*["']@modelcontextprotocol\/server["']/); + expect(result).not.toContain('@modelcontextprotocol/sdk/types'); + }); + + it('does not emit a "mixes symbols" diagnostic for an aliased mixed import (it splits instead)', () => { + const input = `import { CreateMessageRequestSchema, Tool as SDKTool } 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.some(d => d.message.includes('mixes symbols'))).toBe(false); + }); + + it('preserves a leading file-header comment when rewriting the first SDK import', () => { + const input = [ + `/**`, + ` * Web-standard transport for MCP.`, + ` */`, + `import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';`, + `import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain('Web-standard transport for MCP.'); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).not.toContain('@modelcontextprotocol/sdk/'); + }); + it('does not rewrite schema .parse() usages (migrates as an import-path swap)', () => { const input = [ `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, @@ -562,7 +601,7 @@ describe('import-paths transform', () => { expect(result.diagnostics[0]!.message).toContain('RequestHandlerExtra'); }); - it('emits warning for aliased import mixing symbols from different v2 packages', () => { + it('splits an aliased import mixing symbols from different v2 packages (no longer bails)', () => { const input = [ `import { StreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, '' @@ -570,8 +609,13 @@ describe('import-paths transform', () => { 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.some(d => d.message.includes('mixes symbols') && d.message.includes('Split'))).toBe(true); + const output = sourceFile.getFullText(); + // transport (aliased + renamed) → /node; companion type → /server + expect(output).toContain('NodeStreamableHTTPServerTransport as T'); + expect(output).toContain('@modelcontextprotocol/node'); + expect(output).toMatch(/import\s*\{[^}]*\bEventStore\b[^}]*\}\s*from\s*["']@modelcontextprotocol\/server["']/); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + expect(result.diagnostics.some(d => d.message.includes('mixes symbols'))).toBe(false); }); it('emits warning for re-export mixing symbols from different v2 packages', () => { diff --git a/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts index f1a2413982..3547589e0c 100644 --- a/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts @@ -117,4 +117,29 @@ describe('schema-param-removal transform', () => { const result = applyTransform(input); expect(result).not.toMatch(/import.*CallToolResultSchema/); }); + + it('removes a literal undefined schema slot from callTool when an options argument follows', () => { + const input = [ + `const result = await client.callTool({ name: 'add', arguments: { a: 1 } }, undefined, { onprogress: cb });`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("client.callTool({ name: 'add', arguments: { a: 1 } }, { onprogress: cb })"); + expect(result).not.toContain('undefined'); + }); + + it('removes a literal undefined schema slot from request when an options argument follows', () => { + const input = [`const result = await client.request({ method: 'tools/call', params: {} }, undefined, { timeout: 5000 });`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain("client.request({ method: 'tools/call', params: {} }, { timeout: 5000 })"); + expect(result).not.toContain('undefined'); + }); + + it('leaves a 2-arg callTool(params, undefined) unchanged (already valid as options in v2)', () => { + const input = [`await client.callTool({ name: 'add' }, undefined);`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('undefined'); + }); });