v2: codemod iterations, canonical zod schema exports from sdk-shared (#2354)

Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
Konstantin Konstantinov 2026-06-25 21:42:44 +03:00 • committed by GitHub
commit 0fb8406d83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2954 additions and 1665 deletions

View file

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

View file

@ -0,0 +1,153 @@
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';
let tempDir: string;
function createTempDir(): string {
tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-formatter-'));
return tempDir;
}
function writePkg(dir: string, pkg: Record<string, unknown>): 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();
});
it('does not match a user-level $HOME config for a non-git project (stops at $HOME)', () => {
const home = createTempDir();
const projectSrc = path.join(home, 'projects', 'app', 'src');
mkdirSync(projectSrc, { recursive: true });
// A user-level config sits at $HOME, above a non-git project — it must never be read as the
// project's config (the walk must stop before ascending into $HOME).
writeFileSync(path.join(home, 'prettier.config.mjs'), 'export default {};');
expect(detectFormatter(projectSrc, home)).toBeNull();
});
it('reads the project root directly under $HOME but never $HOME itself', () => {
const home = createTempDir();
const projectRoot = path.join(home, 'app');
const src = path.join(projectRoot, 'src');
mkdirSync(src, { recursive: true });
// A higher-precedence formatter configured at $HOME must NOT shadow the project's own.
writeFileSync(path.join(home, 'biome.json'), '{}');
writeFileSync(path.join(projectRoot, 'eslint.config.js'), 'export default [];');
// Only the project's ESLint is detected; $HOME's Biome is never read.
expect(detectFormatter(src, home)?.name).toBe('ESLint');
});
it('detects a project config below $HOME (the boundary only blocks $HOME and above)', () => {
const home = createTempDir();
const projectRoot = path.join(home, 'projects', 'app');
const src = path.join(projectRoot, 'src');
mkdirSync(src, { recursive: true });
writeFileSync(path.join(projectRoot, 'biome.json'), '{}');
expect(detectFormatter(src, home)?.name).toBe('Biome');
});
});

View file

@ -379,6 +379,70 @@ describe('integration', () => {
expect(pkgJson.dependencies['express']).toBe('^4.0.0');
});
it('package.json: does not add core when every schema import is rewritten away', () => {
// The dominant v1 pattern: a `*Schema` constant used ONLY as a setRequestHandler first arg.
// importPaths routes it to @modelcontextprotocol/core (recording the package), but
// handlerRegistration then rewrites the call to a method string and deletes the now-unused
// import. No core import survives, so package.json must NOT gain a core dependency.
const dir = createTempDir();
writePkgJson(dir, { dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } });
writeFileSync(
path.join(dir, 'server.ts'),
[
`import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`,
`import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`,
`const server = new McpServer({ name: 'test', version: '1.0' });`,
`server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [] }));`,
``
].join('\n')
);
const result = run(migration, { targetDir: dir });
// The schema usage was rewritten and its import deleted.
const output = readFileSync(path.join(dir, 'server.ts'), 'utf8');
expect(output).toContain("setRequestHandler('tools/call'");
expect(output).not.toContain('core');
// So core must not be added; the package actually imported (server) still is.
expect(result.packageJsonChanges).toBeDefined();
expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server');
expect(result.packageJsonChanges!.added).not.toContain('@modelcontextprotocol/core');
const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
expect(pkgJson.dependencies['@modelcontextprotocol/core']).toBeUndefined();
expect(pkgJson.dependencies['@modelcontextprotocol/server']).toBeDefined();
});
it('package.json: still adds core when a schema import survives as a value', () => {
// Guard against over-correcting: a schema used as a value (e.g. `.parse(...)`) keeps its import,
// so core remains a real dependency and must still be added.
const dir = createTempDir();
writePkgJson(dir, { dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } });
writeFileSync(
path.join(dir, 'lib.ts'),
[
`import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`,
`export function parseResult(x: unknown) {`,
` return CallToolResultSchema.parse(x);`,
`}`,
``
].join('\n')
);
const result = run(migration, { targetDir: dir });
const output = readFileSync(path.join(dir, 'lib.ts'), 'utf8');
expect(output).toContain('@modelcontextprotocol/core');
expect(output).toContain('CallToolResultSchema.parse');
expect(result.packageJsonChanges).toBeDefined();
expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/core');
const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
expect(pkgJson.dependencies['@modelcontextprotocol/core']).toBeDefined();
});
it('does not modify package.json in dry-run mode', () => {
const dir = createTempDir();
writePkgJson(dir, {

View file

@ -145,7 +145,7 @@ describe('updatePackageJson', () => {
expect(deps['@modelcontextprotocol/server']).toBeUndefined();
});
it('filters out @modelcontextprotocol/core (private package)', () => {
it('filters out @modelcontextprotocol/core-internal (private package)', () => {
const dir = createTempDir();
writePkgJson(dir, {
dependencies: {
@ -153,15 +153,15 @@ describe('updatePackageJson', () => {
}
});
const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/core', '@modelcontextprotocol/server']), false);
const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/core-internal', '@modelcontextprotocol/server']), false);
expect(result).toBeDefined();
expect(result!.added).not.toContain('@modelcontextprotocol/core');
expect(result!.added).not.toContain('@modelcontextprotocol/core-internal');
expect(result!.added).toContain('@modelcontextprotocol/server');
const pkg = readPkgJson(dir);
const deps = pkg.dependencies as Record<string, string>;
expect(deps['@modelcontextprotocol/core']).toBeUndefined();
expect(deps['@modelcontextprotocol/core-internal']).toBeUndefined();
});
it('preserves 4-space indentation', () => {

View file

@ -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';
import { analyzeProject, resolveTypesPackage } from '../src/utils/projectAnalyzer';
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, string>): 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').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').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');
});
});

View file

@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { AUTH_SCHEMA_NAMES, AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT } from '../../src/migrations/v1-to-v2/mappings/authSchemaNames';
describe('AUTH_SCHEMA_NAMES (codemod auth schema-routing allowlist)', () => {
it('routes only auth schemas that @modelcontextprotocol/core exports (drift guard)', () => {
// The import transform routes a `*Schema` symbol from sdk/shared/auth.js to core only when
// its name is in AUTH_SCHEMA_NAMES, so EVERY name here MUST be exported by core — otherwise
// the rewritten import would have no exported member. AUTH_SCHEMA_NAMES is the v1 auth-schema set,
// a SUBSET of core's auth exports: core may export more (v2-only schemas such as
// IdJagTokenExchangeResponseSchema) that v1 never had and the codemod never encounters. Read
// core's barrel directly (the `export { … } from '…/core-internal/auth'` block) so they cannot drift.
const src = readFileSync(fileURLToPath(new URL('../../../core/src/index.ts', import.meta.url)), 'utf8');
const closeIdx = src.indexOf("} from '@modelcontextprotocol/core-internal/auth'");
const openIdx = src.lastIndexOf('export {', closeIdx);
const block = src.slice(openIdx + 'export {'.length, closeIdx);
const coreAuthExports = new Set([...block.matchAll(/\b(\w+Schema)\b/g)].map(m => m[1]));
const notExportedByCore = [...AUTH_SCHEMA_NAMES].filter(name => !coreAuthExports.has(name));
expect(notExportedByCore).toEqual([]);
// The v1 auth-schema set is frozen; pin its size so an accidental add/remove is caught.
expect(AUTH_SCHEMA_NAMES.size).toBe(11);
});
it('keeps the no-v2-home auth schemas OUT of the routing allowlist', () => {
// SafeUrlSchema/OptionalSafeUrlSchema have no public v2 export, so they must NOT be routed to
// core (the import transform flags them instead). Guard the two sets stay disjoint.
for (const name of AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT) {
expect(AUTH_SCHEMA_NAMES.has(name)).toBe(false);
}
expect([...AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT].sort()).toEqual(['OptionalSafeUrlSchema', 'SafeUrlSchema']);
});
});

View file

@ -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';
describe('SPEC_SCHEMA_NAMES (codemod schema-routing allowlist)', () => {
it("matches @modelcontextprotocol/core's exported schema set exactly (drift guard)", () => {
// The import transform routes a `*Schema` symbol from sdk/types.js to core only when the
// symbol's (rename-resolved) name is in this set. It must therefore equal core'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 core does not export would produce a broken import.
// Read core's barrel directly so the two cannot silently drift.
const src = readFileSync(fileURLToPath(new URL('../../../core/src/index.ts', import.meta.url)), 'utf8');
const block = src.slice(src.indexOf('export {') + 'export {'.length, src.indexOf('} from'));
const coreExports = [...new Set([...block.matchAll(/\b(\w+Schema)\b/g)].map(m => m[1]))].sort();
expect([...SPEC_SCHEMA_NAMES].sort()).toEqual(coreExports);
expect(coreExports.length).toBeGreaterThanOrEqual(154);
});
});

View file

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

View file

@ -74,25 +74,364 @@ 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('CallToolResultSchema');
expect(result).toContain('CallToolResult');
expect(result).not.toContain('@modelcontextprotocol/core');
});
it('resolves sdk/types.js based on sibling server imports', () => {
it('resolves a sdk/types.js TYPE import based on sibling server imports', () => {
const input = [
`import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.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/server"`);
expect(result).toContain('CallToolResult');
});
it('routes *Schema imports from sdk/types.js to @modelcontextprotocol/core', () => {
const input = `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';\n`;
const result = applyTransform(input, { projectType: 'server' });
expect(result).toContain(`from "@modelcontextprotocol/core"`);
expect(result).toContain('CallToolResultSchema');
expect(result).not.toContain('@modelcontextprotocol/sdk/types');
});
it('routes schemas to core regardless of client/server sibling context', () => {
// The only sibling is a client import, but the schema must still go to core.
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/core"`);
expect(result).toContain('ListToolsResultSchema');
});
it('splits a mixed type + schema import: type resolves by context, schema to core', () => {
const input = [
`import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`,
`import { CallToolResult, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`,
''
].join('\n');
const result = applyTransform(input, { projectType: 'both' });
expect(result).toContain(`from "@modelcontextprotocol/core"`);
expect(result).toContain(`from "@modelcontextprotocol/server"`);
expect(result).toContain('CallToolResult');
expect(result).toContain('CallToolResultSchema');
expect(result).not.toContain('@modelcontextprotocol/sdk/types');
});
it('splits an aliased types.js import: schema constant to core, 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\/core["']/);
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 duplicate a multi-block leading header (blank line) when rewriting the first import in place', () => {
// The first SDK import is a namespace import, so it is rewritten in place (setModuleSpecifier) and
// its leading comments survive. The header is two // blocks separated by a BLANK line. A `\n`-join
// of the comment ranges loses that blank line, so the survival check would mis-fire and re-insert
// the header — duplicating it. The captured text must match the file's bytes exactly.
const input = [
`// Copyright ACME`,
``,
`// Notes about the types module`,
`import * as types from '@modelcontextprotocol/sdk/types.js';`,
''
].join('\n');
const result = applyTransform(input, { projectType: 'server' });
expect(result.split('// Copyright ACME').length - 1).toBe(1);
expect(result).toContain('@modelcontextprotocol/server');
});
it('does not duplicate a CRLF leading header when rewriting the first import in place', () => {
// Same in-place rewrite, but the two // header lines are separated by CRLF. A `\n`-join never
// matches the file's `\r\n`, so the survival check would mis-fire and duplicate the header.
const input = `// Copyright ACME\r\n// Licensed MIT\r\n\r\nimport * as types from '@modelcontextprotocol/sdk/types.js';\r\n`;
const result = applyTransform(input, { projectType: 'server' });
expect(result.split('// Copyright ACME').length - 1).toBe(1);
expect(result).toContain('@modelcontextprotocol/server');
});
it('routes OAuth *Schema from sdk/shared/auth.js to core; the TYPE resolves by context', () => {
// OAuthTokensSchema is a Zod schema re-exported by core (AUTH_SCHEMA_NAMES), so route it
// there — `OAuthTokensSchema.parse(...)` keeps working. OAuthTokens (the type) has no schema-name
// match and resolves by context to @modelcontextprotocol/client.
const input = [
`import { OAuthTokensSchema, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';`,
`const t = OAuthTokensSchema.parse(raw);`,
`let x: OAuthTokens;`,
''
].join('\n');
const result = applyTransform(input, { projectType: 'client' });
expect(result).toMatch(/import\s*\{[^}]*\bOAuthTokensSchema\b[^}]*\}\s*from\s*["']@modelcontextprotocol\/core["']/);
expect(result).toMatch(/import\s*\{[^}]*\bOAuthTokens\b[^}]*\}\s*from\s*["']@modelcontextprotocol\/client["']/);
expect(result).toContain('OAuthTokensSchema.parse(raw)');
expect(result).not.toContain('@modelcontextprotocol/sdk/shared/auth');
});
it('does not emit a project-type note when every symbol routes to core (both project)', () => {
// A types.js import of nothing but `*Schema` constants routes entirely to core, so the
// context package is never used — resolveTypesPackage must not be called, and no "both"-project
// info note should be emitted.
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile(
'test.ts',
`import { CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';\n`
);
const result = importPathsTransform.apply(sourceFile, { projectType: 'both' });
expect(result.diagnostics.some(d => /both client and server|determine project type/i.test(d.message))).toBe(false);
expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/core');
expect(sourceFile.getFullText()).not.toContain('@modelcontextprotocol/sdk/types');
});
it('does not warn about project type when an auth-schema-only import routes entirely to core (unknown project)', () => {
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile(
'test.ts',
`import { OAuthTokensSchema, OAuthMetadataSchema } from '@modelcontextprotocol/sdk/shared/auth.js';\n`
);
const result = importPathsTransform.apply(sourceFile, { projectType: 'unknown' });
expect(result.diagnostics.some(d => /determine project type/i.test(d.message))).toBe(false);
expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/core');
});
it('still warns about project type when a non-schema symbol falls through to context (unknown project)', () => {
// Control: `Tool` is a type with no schema-name match, so it falls through to context resolution —
// the warning must still fire (lazy resolution must not suppress genuine fall-throughs).
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile(
'test.ts',
`import { CallToolResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js';\n`
);
const result = importPathsTransform.apply(sourceFile, { projectType: 'unknown' });
expect(result.diagnostics.some(d => /determine project type/i.test(d.message))).toBe(true);
});
it('splits a mixed default + named schema import — schema to core, default to context', () => {
// The named `CallToolResultSchema` must route to core even though a default import is present;
// the default binding (which can't be split) moves to the context package. Pre-fix the whole import
// moved to context and the schema silently became a "no exported member" error.
const result = applyTransform(`import sdk, { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';\n`, {
projectType: 'server'
});
expect(result).toMatch(/import\s*\{[^}]*\bCallToolResultSchema\b[^}]*\}\s*from\s*["']@modelcontextprotocol\/core["']/);
expect(result).toMatch(/import\s+sdk\s+from\s*["']@modelcontextprotocol\/server["']/);
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/core"`);
});
it('routes elicitation primitive *Schema TYPE names from sdk/types.js by context, not to core', () => {
// These names END in `Schema` but are TYPES; their Zod constant is `<Name>SchemaSchema`. They
// must resolve to the context package (where the types live), never to core (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/core');
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 core; 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/core"`);
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 core', () => {
// JSONRPCErrorSchema → JSONRPCErrorResponseSchema, a core 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 core.
const input = `import { JSONRPCErrorSchema } from '@modelcontextprotocol/sdk/types.js';\n`;
const result = applyTransform(input, { projectType: 'server' });
expect(result).toContain(`from "@modelcontextprotocol/core"`);
expect(result).toContain('JSONRPCErrorSchema');
expect(result).not.toContain('@modelcontextprotocol/sdk/types');
});
it('routes JSONRPCResponseSchema (result-only in v1) from sdk/types.js to core', () => {
// v1's JSONRPCResponseSchema validated only result responses; v2 reuses the name for a union.
// The rename to JSONRPCResultResponseSchema (a core export) preserves v1 behavior; importPaths
// routes it to core against the rename-resolved name (symbolRenames applies the rename after).
const input = `import { JSONRPCResponseSchema } from '@modelcontextprotocol/sdk/types.js';\n`;
const result = applyTransform(input, { projectType: 'server' });
expect(result).toContain(`from "@modelcontextprotocol/core"`);
expect(result).not.toContain('@modelcontextprotocol/sdk/types');
expect(result).not.toContain(`from "@modelcontextprotocol/server"`);
});
it('flags a SafeUrlSchema import from sdk/shared/auth.js (no public v2 equivalent)', () => {
// SafeUrlSchema/OptionalSafeUrlSchema were internal URL field-validators in v1; v2's core
// deliberately does not re-export them, so there is no v2 home — emit guidance instead of silently
// routing to a package that has no such export.
const input = `import { SafeUrlSchema, OptionalSafeUrlSchema } from '@modelcontextprotocol/sdk/shared/auth.js';\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);
expect(messages.some(m => m.includes('SafeUrlSchema') && m.includes('no public v2 equivalent'))).toBe(true);
expect(messages.some(m => m.includes('OptionalSafeUrlSchema') && m.includes('no public v2 equivalent'))).toBe(true);
});
it('flags a star re-export of sdk/types.js that drops the moved schema constants', () => {
// `export * from '…/types.js'` cannot be routed per-symbol, so the Zod *Schema constants (now in
// core) silently disappear from the re-exporting barrel. Surface that for the user.
const input = `export * 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' });
const messages = result.diagnostics.map(d => d.message).join('\n');
expect(messages).toContain('@modelcontextprotocol/core');
expect(messages).toMatch(/Star re-export/i);
});
it('flags a star re-export of sdk/shared/auth.js (schema constants move to core)', () => {
const input = `export * from '@modelcontextprotocol/sdk/shared/auth.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.map(d => d.message).join('\n')).toContain('@modelcontextprotocol/core');
});
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 core 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';`,
`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/core');
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('suggests the v2 (rename-resolved) name in the namespace schema-access diagnostic', () => {
// JSONRPCErrorSchema is re-exported by core as JSONRPCErrorResponseSchema; the suggested
// import must use the v2 name (the v1 name has no exported member), and mention the rename.
const input = [
`import * as types from '@modelcontextprotocol/sdk/types.js';`,
`const r = types.JSONRPCErrorSchema.safeParse(value);`,
''
].join('\n');
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = importPathsTransform.apply(sourceFile, { projectType: 'server' });
const msg = result.diagnostics.map(d => d.message).join('\n');
expect(msg).toContain("import { JSONRPCErrorResponseSchema } from '@modelcontextprotocol/core'");
expect(msg).toContain('JSONRPCErrorSchema → JSONRPCErrorResponseSchema');
expect(msg).not.toContain('import { JSONRPCErrorSchema } from');
});
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/core');
});
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', () => {
@ -339,6 +678,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(
@ -405,7 +755,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';`,
''
@ -413,8 +763,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', () => {

View file

@ -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', () => {
@ -325,6 +338,190 @@ describe('mock-paths transform', () => {
});
});
describe('schema constant routing (schemaSymbolTarget)', () => {
it('routes a vi.mock factory of only spec *Schema constants to core', () => {
const input = [`vi.mock('@modelcontextprotocol/sdk/types.js', () => ({`, ` CallToolResultSchema: vi.fn()`, `}));`, ''].join(
'\n'
);
const result = applyTransform(input);
expect(result).toContain(`'@modelcontextprotocol/core'`);
expect(result).not.toContain('@modelcontextprotocol/sdk/types');
// The schema constant lives in core, never the context (server) package.
expect(result).not.toContain(`'@modelcontextprotocol/server'`);
});
it('routes a vi.mock factory of only auth *Schema constants to core', () => {
const input = [
`vi.mock('@modelcontextprotocol/sdk/shared/auth.js', () => ({`,
` OAuthTokensSchema: vi.fn()`,
`}));`,
''
].join('\n');
const result = applyTransform(input);
expect(result).toContain(`'@modelcontextprotocol/core'`);
expect(result).not.toContain('@modelcontextprotocol/sdk/shared/auth');
});
it('routes a destructured dynamic import of only *Schema constants to core', () => {
const input = [`const { CallToolResultSchema } = await import('@modelcontextprotocol/sdk/types.js');`, ''].join('\n');
const result = applyTransform(input);
expect(result).toContain(`import('@modelcontextprotocol/core')`);
expect(result).not.toContain('@modelcontextprotocol/sdk/types');
});
it('renames JSONRPCResponseSchema and routes it to core in a mock factory', () => {
const input = [`vi.mock('@modelcontextprotocol/sdk/types.js', () => ({`, ` JSONRPCResponseSchema: vi.fn()`, `}));`, ''].join(
'\n'
);
const result = applyTransform(input);
expect(result).toContain(`'@modelcontextprotocol/core'`);
expect(result).toContain('JSONRPCResultResponseSchema');
expect(result).not.toMatch(/(?<!Result)JSONRPCResponseSchema/);
});
it('flags a vi.mock factory mixing a *Schema constant and a type (cannot be split)', () => {
const input = [
`vi.mock('@modelcontextprotocol/sdk/types.js', () => ({`,
` CallToolResultSchema: vi.fn(),`,
` McpError: vi.fn()`,
`}));`,
''
].join('\n');
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, ctx);
expect(result.diagnostics.some(d => d.message.includes('mixes symbols that belong to different v2 packages'))).toBe(true);
});
it('flags a destructured dynamic import mixing a *Schema constant and a type', () => {
const input = [`const { CallToolResultSchema, McpError } = await import('@modelcontextprotocol/sdk/types.js');`, ''].join('\n');
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, ctx);
expect(result.diagnostics.some(d => d.message.includes('belong to different v2 packages'))).toBe(true);
});
it('routes a destructured .then() param of only *Schema constants to core', () => {
const input = [
`import('@modelcontextprotocol/sdk/types.js').then(({ CallToolResultSchema }) => CallToolResultSchema.parse(value));`,
''
].join('\n');
const result = applyTransform(input);
expect(result).toContain(`import('@modelcontextprotocol/core')`);
expect(result).not.toContain('@modelcontextprotocol/sdk/types');
});
it('renames a *Schema in a destructured .then() param and routes it to core', () => {
const input = [
`import('@modelcontextprotocol/sdk/types.js').then(({ JSONRPCResponseSchema }) => JSONRPCResponseSchema.parse(value));`,
''
].join('\n');
const result = applyTransform(input);
expect(result).toContain(`import('@modelcontextprotocol/core')`);
expect(result).toContain('JSONRPCResultResponseSchema');
});
it('flags a destructured .then() param mixing a *Schema constant and a type', () => {
const input = [
`import('@modelcontextprotocol/sdk/types.js').then(({ CallToolResultSchema, McpError }) => CallToolResultSchema.parse(value));`,
''
].join('\n');
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, ctx);
expect(result.diagnostics.some(d => d.message.includes('belong to different v2 packages'))).toBe(true);
});
});
describe('lazy context resolution (no spurious project-type diagnostic)', () => {
it('does not warn about project type for a schema-only vi.mock factory (unknown project)', () => {
// The factory routes entirely to core, so the context package is never used — resolveTypesPackage
// must not emit a "could not determine project type" warning.
const input = [`vi.mock('@modelcontextprotocol/sdk/types.js', () => ({`, ` CallToolResultSchema: vi.fn()`, `}));`, ''].join(
'\n'
);
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, { projectType: 'unknown' });
expect(result.diagnostics.some(d => /determine project type/i.test(d.message))).toBe(false);
expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/core');
});
it('does not emit a both-project note for a schema-only destructured dynamic import (both project)', () => {
const input = [`const { OAuthTokensSchema } = await import('@modelcontextprotocol/sdk/shared/auth.js');`, ''].join('\n');
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, { projectType: 'both' });
expect(result.diagnostics.some(d => /both client and server|determine project type/i.test(d.message))).toBe(false);
expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/core');
});
it('still warns about project type for a non-schema vi.mock factory (unknown project)', () => {
// Control: isInitializeRequest is a guard (not a schema constant), so the factory falls through to
// context resolution — the warning must still fire (lazy resolution must not suppress real fall-throughs).
const input = [`vi.mock('@modelcontextprotocol/sdk/types.js', () => ({`, ` isInitializeRequest: vi.fn()`, `}));`, ''].join(
'\n'
);
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, { projectType: 'unknown' });
expect(result.diagnostics.some(d => /determine project type/i.test(d.message))).toBe(true);
});
});
describe('non-destructured / .then dynamic import schema access (schemaSymbolTarget)', () => {
it('flags schema access on a non-destructured awaited dynamic import (types.js)', () => {
const input = [
`const mod = await import('@modelcontextprotocol/sdk/types.js');`,
`const r = mod.CallToolResultSchema.parse(value);`,
''
].join('\n');
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, ctx);
expect(
result.diagnostics.some(d => d.message.includes('@modelcontextprotocol/core') && d.message.includes('CallToolResultSchema'))
).toBe(true);
});
it('flags schema access in a .then() chain (shared/auth.js)', () => {
const input = [`import('@modelcontextprotocol/sdk/shared/auth.js').then(m => m.OAuthTokensSchema.parse(value));`, ''].join(
'\n'
);
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, ctx);
expect(
result.diagnostics.some(d => d.message.includes('@modelcontextprotocol/core') && d.message.includes('OAuthTokensSchema'))
).toBe(true);
});
it('notes the rename for a renamed schema accessed in a .then() chain', () => {
const input = [`import('@modelcontextprotocol/sdk/types.js').then(m => m.JSONRPCResponseSchema.parse(value));`, ''].join('\n');
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, ctx);
expect(
result.diagnostics.some(
d => d.message.includes('JSONRPCResponseSchema') && d.message.includes('JSONRPCResultResponseSchema')
)
).toBe(true);
});
it('does not flag a non-destructured dynamic import with no schema access', () => {
// Control: `mod` is only used for a guard (not a schema constant), so no schema-moved-to-core note.
const input = [
`const mod = await import('@modelcontextprotocol/sdk/types.js');`,
`const ok = mod.isInitializeRequest(value);`,
''
].join('\n');
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', input);
const result = mockPathsTransform.apply(sourceFile, ctx);
expect(result.diagnostics.some(d => d.message.includes('moved to @modelcontextprotocol/core'))).toBe(false);
});
});
describe('validator subpath rewrites', () => {
it('rewrites vi.mock of validator provider to the subpath', () => {
const input = [

View file

@ -117,4 +117,40 @@ 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 = [
`import { Client } from '@modelcontextprotocol/sdk/client/index.js';`,
`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 = [
`import { Client } from '@modelcontextprotocol/sdk/client/index.js';`,
`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('does not strip undefined from request()/callTool() in a file with no MCP imports', () => {
// `request`/`callTool` are common non-MCP method names; without an MCP signal in the file the
// codemod must not touch them, or it would shift `someHttpClient.request(payload, undefined, opts)`.
const input = [`const r = await someHttpClient.request(payload, undefined, { timeout: 5000 });`, ''].join('\n');
const result = applyTransform(input);
expect(result).toContain('someHttpClient.request(payload, undefined, { timeout: 5000 })');
});
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');
});
});

View file

@ -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';
import type { TransformContext } from '../../../src/types';
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<typeof XSchema>', () => {
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');
});
});
});

View file

@ -44,6 +44,34 @@ describe('symbol-renames transform', () => {
expect(result).toContain('isJSONRPCResultResponse');
});
it('renames JSONRPCResponseSchema to JSONRPCResultResponseSchema (result-only in v1)', () => {
// v1's JSONRPCResponseSchema validated only result responses; v2 reuses the name for a union that
// also accepts errors. Rename to the result-only schema to preserve v1 parse/safeParse behavior.
const input = [
`import { JSONRPCResponseSchema } from '@modelcontextprotocol/sdk/types.js';`,
`const r = JSONRPCResponseSchema.parse(value);`,
''
].join('\n');
const result = applyTransform(input);
expect(result).toContain('JSONRPCResultResponseSchema.parse(value)');
expect(result).not.toMatch(/(?<!Result)JSONRPCResponseSchema/);
});
it('renames JSONRPCResponse type to JSONRPCResultResponse (result-only in v1)', () => {
// v1's JSONRPCResponse type was the result-only response; v2 reuses the name for a
// result|error union (Infer<typeof JSONRPCResponseSchema>). Leaving the type unrenamed would
// silently widen a migrated v1 type import — mirror the schema/guard renames.
const input = [
`import type { JSONRPCResponse } from '@modelcontextprotocol/server';`,
`function handle(r: JSONRPCResponse) { return r; }`,
''
].join('\n');
const result = applyTransform(input);
expect(result).toContain('import type { JSONRPCResultResponse }');
expect(result).toContain('r: JSONRPCResultResponse');
expect(result).not.toMatch(/(?<!Result)JSONRPCResponse(?!Schema)/);
});
it('renames ResourceReference to ResourceTemplateReference', () => {
const input = [
`import { ResourceReference } from '@modelcontextprotocol/sdk/types.js';`,