From f77998cfd68cbc59090bc1e7240a3fdc7191c3e2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 10 Jun 2026 22:00:04 +0000 Subject: [PATCH 1/7] feat(core): expose Zod-compatible parse/safeParse on specTypeSchemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec schemas in specTypeSchemas are typed as Standard Schema, which only exposes the ['~standard'].validate() interface. The underlying runtime values are still Zod schemas, so .parse()/.safeParse() exist but are hidden by the type. Code written against the previous API validated with SomeSchema.parse(value) and SomeSchema.safeParse(value). With the schemas now reached through the specTypeSchemas map and typed as Standard Schema, those calls no longer compile, forcing every validation site to be rewritten to ['~standard'].validate() with manual remapping of .success/.data/.error — and .parse() (which throws) has no one-line Standard Schema equivalent at all, so those sites must hand-roll a validate-then-throw block, changing the thrown error type from a structured ZodError to a generic Error. Surface parse() and safeParse() on each specTypeSchemas entry (type-only; the runtime values already carry these methods) so existing validation code migrates by a reference rename — SomeSchema.parse(x) becomes specTypeSchemas.Some.parse(x) — with identical runtime behavior, including the ZodError thrown on invalid input. Only these two methods are exposed; the rest of the Zod schema surface stays internal. New code should still prefer the library-agnostic Standard Schema interface or isSpecType. Adds tests covering parse() success/throw and safeParse() result shape. --- packages/core/src/types/specTypeSchema.ts | 31 +++++++++++++-- .../core/test/types/specTypeSchema.test.ts | 39 +++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index e538da8fa5..50d25e389d 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -13,7 +13,7 @@ import { OpenIdProviderDiscoveryMetadataSchema, OpenIdProviderMetadataSchema } from '../shared/auth.js'; -import type { StandardSchemaV1, StandardSchemaV1Sync } from '../util/standardSchema.js'; +import type { StandardSchemaV1Sync } from '../util/standardSchema.js'; import * as schemas from './schemas.js'; /** @@ -238,10 +238,33 @@ type SpecTypeInputs = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.input> : never; }; -type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync }; +/** + * Zod-compatible validation methods retained on every {@linkcode specTypeSchemas} entry. + * + * In the v1 SDK these spec schemas were exported as Zod schemas, so consumer code validated with + * `SomeSchema.parse(value)` / `SomeSchema.safeParse(value)`. v2 routes the schemas through the + * curated {@linkcode specTypeSchemas} map and types them as Standard Schema, but the underlying + * runtime values are still the same Zod schemas. Surfacing `parse`/`safeParse` lets v1 validation + * code migrate by a reference rename — `SomeSchema.parse(x)` becomes `specTypeSchemas.Some.parse(x)` + * — with identical runtime behavior, including the `ZodError` thrown by `parse` on invalid input. + * + * These are the only two Zod methods exposed; the rest of the Zod schema surface (`.extend`, + * `.optional`, …) stays internal. New code should prefer the library-agnostic Standard Schema + * interface (`specTypeSchemas.Some['~standard'].validate(x)`) or {@linkcode isSpecType}. + */ +export interface ZodCompatValidation { + /** Validate `value`, returning the parsed output or throwing a `ZodError` if it is invalid. */ + parse(value: unknown): Output; + /** Validate `value` without throwing; returns `{ success: true, data }` or `{ success: false, error }`. */ + safeParse(value: unknown): z.ZodSafeParseResult; +} + +type SchemaRecord = { + readonly [K in SpecTypeName]: StandardSchemaV1Sync & ZodCompatValidation; +}; type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] }; -const _specTypeSchemas: Record = {}; +const _specTypeSchemas: Record = {}; const _isSpecType: Record boolean> = {}; function register(key: string, schema: z.ZodType): void { const name = key.slice(0, -'Schema'.length); @@ -274,7 +297,7 @@ for (const [key, schema] of Object.entries(authSchemas)) { * } * ``` */ -export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas as SchemaRecord); +export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas as unknown as SchemaRecord); /** * Type predicates for every MCP spec type, keyed by type name. diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 198e104f9f..7ec004de96 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -42,6 +42,45 @@ describe('specTypeSchemas', () => { const bad = specTypeSchemas.OAuthTokens['~standard'].validate({ token_type: 'Bearer' }); expect(bad.issues?.length).toBeGreaterThan(0); }); + + describe('Zod-compatible parse/safeParse (v1 migration shim)', () => { + it('parse() returns the typed value for valid input', () => { + const tokens = specTypeSchemas.OAuthTokens.parse({ access_token: 'x', token_type: 'Bearer' }); + expect(tokens.access_token).toBe('x'); + expectTypeOf(tokens).toEqualTypeOf(); + }); + + it('parse() throws a ZodError on invalid input (same behavior as v1)', () => { + expect(() => specTypeSchemas.OAuthTokens.parse({ token_type: 'Bearer' })).toThrow(); + try { + specTypeSchemas.OAuthTokens.parse({ token_type: 'Bearer' }); + expect.unreachable('parse should have thrown'); + } catch (err) { + // v1 callers relied on a structured Zod error with an `issues` array. + expect((err as { issues?: unknown[] }).issues?.length).toBeGreaterThan(0); + } + }); + + it('safeParse() returns the Zod-shaped discriminated result', () => { + const ok = specTypeSchemas.OAuthTokens.safeParse({ access_token: 'x', token_type: 'Bearer' }); + expect(ok.success).toBe(true); + if (ok.success) { + expect(ok.data.access_token).toBe('x'); + expectTypeOf(ok.data).toEqualTypeOf(); + } + const bad = specTypeSchemas.OAuthTokens.safeParse({ token_type: 'Bearer' }); + expect(bad.success).toBe(false); + if (!bad.success) { + expect(bad.error.issues.length).toBeGreaterThan(0); + } + }); + + it('parse() applies schema defaults, matching the named output type', () => { + // CallToolResultSchema has `content: z.array(...).default([])`. + const result = specTypeSchemas.CallToolResult.parse({}); + expect(result.content).toEqual([]); + }); + }); }); describe('isSpecType', () => { From c877b3b3831d4646675e1e58f6e2b4f673575bbc Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 10 Jun 2026 22:07:28 +0000 Subject: [PATCH 2/7] fix(codemod): rename spec schema .parse()/.safeParse() instead of rewriting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec-schema-access transform special-cased .parse() and .safeParse(): .parse() was left in place with an action-required diagnostic (the call did not compile against v2 and the import was dead), and .safeParse() was either collapsed to isSpecType.X() or rewritten to ['~standard'].validate() with the result's .success/.data/.error accesses remapped across the enclosing block. The remapping is fragile and changes behavior — .error became .issues, losing the structured Zod error, and .parse() sites had no automatic migration at all. Now that specTypeSchemas entries expose Zod-compatible .parse()/.safeParse(), collapse all of this into the same rename the transform already used for other methods: XSchema.(...) becomes specTypeSchemas.X.(...), leaving the call and any result-property access untouched. For .parse()/.safeParse() this is a behavior-preserving rename (emit an info diagnostic); for Zod methods that are not exposed on the entry (.extend, .parseAsync, …) the renamed call will not typecheck, so flag it inline with an action-required comment. Removes the captured-safeParse remapping machinery and its helpers. Updates the transform tests to the rename contract and retargets the comment-insertion mechanism tests onto .parseAsync() (still action-required). --- .../v1-to-v2/transforms/specSchemaAccess.ts | 287 +++--------------- .../codemod/test/commentInsertion.test.ts | 34 +-- .../transforms/specSchemaAccess.test.ts | 237 +++++---------- 3 files changed, 140 insertions(+), 418 deletions(-) diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts index 79f4a0a707..b50faeee79 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts @@ -1,12 +1,20 @@ import type { SourceFile } from 'ts-morph'; -import { Node, SyntaxKind } from 'ts-morph'; +import { Node } 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 { actionRequired, info } from '../../../utils/diagnostics.js'; import { addOrMergeImport, isAnyMcpSpecifier, removeUnusedImport } from '../../../utils/importUtils.js'; +/** + * Methods that the v2 `specTypeSchemas.X` map exposes with the same behavior they had on the v1 + * top-level Zod schemas. Renaming `XSchema.(...)` to `specTypeSchemas.X.(...)` for these is a + * pure, behavior-preserving substitution; other Zod methods (`.extend`, `.or`, `.parseAsync`, …) are + * not on the Standard-Schema-typed entry and need manual attention. + */ +const ZOD_COMPATIBLE_METHODS = new Set(['parse', 'safeParse']); + export const specSchemaAccessTransform: Transform = { name: 'Spec schema standalone usage', id: 'spec-schemas', @@ -80,51 +88,37 @@ function handleReference( 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.) + // Pattern: XSchema.(...) — rename the schema reference to specTypeSchemas.X and keep the + // method call. For `.parse()`/`.safeParse()` this is a behavior-preserving rename (those methods + // are exposed on the v2 entry); for other Zod methods the call will not typecheck and needs a + // manual rewrite, so the diagnostic severity reflects which case applies. const parent = ref.getParent(); if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) { + const methodName = parent.getName(); 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.` - ) - ); + if (ZOD_COMPATIBLE_METHODS.has(methodName)) { + diagnostics.push( + info( + sourceFile.getFilePath(), + line, + `Renamed ${localName} to specTypeSchemas.${typeName}. .${methodName}() is preserved and behaves as before ` + + `(throws a ZodError on invalid input for .parse()); no result remapping needed.` + ) + ); + } else { + // .${methodName}() is not exposed on the Standard-Schema-typed entry, so the renamed call + // will not typecheck — flag it inline for manual migration. + diagnostics.push( + actionRequired( + sourceFile.getFilePath(), + parent, + `${localName}.${methodName}() has no equivalent on specTypeSchemas.${typeName}. Only .parse()/.safeParse() and ` + + `the Standard Schema interface (['~standard']) are exposed — rewrite this call manually.` + ) + ); + } return true; } @@ -144,10 +138,11 @@ function handleReference( parent.replaceWithText(`'${localName}': specTypeSchemas.${typeName}`); ensureImport(sourceFile, 'specTypeSchemas'); diagnostics.push( - warning( + info( sourceFile.getFilePath(), line, - `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.` + `Renamed ${localName} to specTypeSchemas.${typeName}. It exposes .parse()/.safeParse() and the Standard Schema ` + + `interface; other Zod schema methods are not available.` ) ); return true; @@ -162,220 +157,22 @@ function handleReference( ref.replaceWithText(`specTypeSchemas.${typeName}`); ensureImport(sourceFile, 'specTypeSchemas'); diagnostics.push( - warning( + info( sourceFile.getFilePath(), line, - `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.` + `Renamed ${localName} to specTypeSchemas.${typeName}. It exposes .parse()/.safeParse() and the Standard Schema ` + + `interface; other Zod schema methods 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; diff --git a/packages/codemod/test/commentInsertion.test.ts b/packages/codemod/test/commentInsertion.test.ts index a50c4698be..9fb8a78e7c 100644 --- a/packages/codemod/test/commentInsertion.test.ts +++ b/packages/codemod/test/commentInsertion.test.ts @@ -87,11 +87,11 @@ 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 .parseAsync() calls on different schemas trigger two actionRequired diagnostics const input = [ `import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const a = CallToolRequestSchema.parse(data1);`, - `const b = ListToolsRequestSchema.parse(data2);`, + `const a = CallToolRequestSchema.parseAsync(data1);`, + `const b = ListToolsRequestSchema.parseAsync(data2);`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -109,7 +109,7 @@ describe('comment insertion', () => { const input = [ `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, `function validate() {`, - ` const a = CallToolRequestSchema.parse(data);`, + ` const a = CallToolRequestSchema.parseAsync(data);`, `}`, `` ].join('\n'); @@ -126,7 +126,7 @@ describe('comment insertion', () => { const dir = createTempDir(); const input = [ `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const a = CallToolRequestSchema.parse(data);`, + `const a = CallToolRequestSchema.parseAsync(data);`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -146,10 +146,10 @@ 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 .parseAsync() 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);`, + `const a = CallToolRequestSchema.parseAsync(data);`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -170,7 +170,7 @@ describe('comment insertion', () => { `import { McpServer, CallToolRequestSchema } from '@modelcontextprotocol/sdk/server/mcp.js';`, ``, `const server = new McpServer({ name: 'test', version: '1.0' });`, - `const a = CallToolRequestSchema.parse(data);`, + `const a = CallToolRequestSchema.parseAsync(data);`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -183,14 +183,14 @@ describe('comment insertion', () => { expect(commentIdx).toBeGreaterThan(-1); // The comment should be directly above the parse() line (which may have moved) const nextLine = lines[commentIdx + 1]!; - expect(nextLine).toContain('.parse(data)'); + expect(nextLine).toContain('.parseAsync(data)'); }); it('merges same-line diagnostics into a single comment', () => { const dir = createTempDir(); const input = [ `import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const a = CallToolRequestSchema.parse(data1); const b = ListToolsRequestSchema.parse(data2);`, + `const a = CallToolRequestSchema.parseAsync(data1); const b = ListToolsRequestSchema.parseAsync(data2);`, `` ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -209,7 +209,7 @@ describe('comment insertion', () => { const input = [ "import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';", 'const msg = `', - ' Result: ${CallToolRequestSchema.parse(data).method}', + ' Result: ${CallToolRequestSchema.parseAsync(data).method}', '`;', '' ].join('\n'); @@ -232,8 +232,8 @@ describe('comment insertion', () => { const input = [ "import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';", 'const msg = `${somePrefix}', - ' A: ${CallToolRequestSchema.parse(d1)}', - ' B: ${ListToolsRequestSchema.parse(d2)}', + ' A: ${CallToolRequestSchema.parseAsync(d1)}', + ' B: ${ListToolsRequestSchema.parseAsync(d2)}', '`;', '' ].join('\n'); @@ -249,11 +249,11 @@ 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 .parseAsync() and template are on the same line, but lineStart is at "const", // which is outside the template literal. const input = [ "import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';", - 'const a = CallToolRequestSchema.parse(`template ${data}`);', + 'const a = CallToolRequestSchema.parseAsync(`template ${data}`);', '' ].join('\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -273,7 +273,7 @@ describe('comment insertion', () => { const dir = createTempDir(); const input = [ `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, - `const a = CallToolRequestSchema.parse(data);`, + `const a = CallToolRequestSchema.parseAsync(data);`, `` ].join('\r\n'); writeFileSync(path.join(dir, 'server.ts'), input); @@ -286,6 +286,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('.parseAsync(data)'); }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts b/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts index 2c7f592e1f..70d32c6874 100644 --- a/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts @@ -14,16 +14,19 @@ function applyTransform(code: string) { } describe('spec-schema-access transform', () => { - describe('auto-transform: .safeParse(v).success → isSpecType.X(v)', () => { - it('rewrites XSchema.safeParse(v).success to isSpecType.X(v)', () => { + // The v2 specTypeSchemas entries expose Zod-compatible .parse()/.safeParse(), so every spec + // schema reference — including .parse()/.safeParse() calls — is migrated by the same rename: + // `XSchema` → `specTypeSchemas.X`, leaving the call and any result-property access untouched. + describe('rename: .safeParse(v) and its result are preserved', () => { + it('renames XSchema.safeParse(v).success to specTypeSchemas.X.safeParse(v).success', () => { 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(text).toContain('specTypeSchemas.CallToolRequest.safeParse(data).success'); + expect(text).not.toContain('isSpecType'); expect(result.changesCount).toBeGreaterThan(0); }); @@ -34,46 +37,21 @@ describe('spec-schema-access transform', () => { '' ].join('\n'); const { text } = applyTransform(input); - expect(text).toContain('isSpecType.Tool(obj)'); - expect(text).not.toContain('safeParse'); + expect(text).toContain('specTypeSchemas.Tool.safeParse(obj).success'); }); - it('adds isSpecType import when transforming safeParse().success', () => { + it('adds specTypeSchemas 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).toContain('specTypeSchemas'); expect(text).toMatch(/import.*specTypeSchemas.*from/); }); - }); - describe('auto-transform: captured safeParse result', () => { - it('rewrites captured safeParse call and result property accesses', () => { + it('preserves captured safeParse result properties (.success/.data/.error) unchanged', () => { const input = [ `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, `const parsed = CallToolResultSchema.safeParse(data);`, @@ -81,43 +59,29 @@ describe('spec-schema-access transform', () => { '' ].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(text).toContain('specTypeSchemas.CallToolResult.safeParse(data)'); + expect(text).toContain('parsed.success'); + expect(text).toContain('parsed.data'); + // No Standard Schema remapping is performed — the Zod-shaped result is retained. + expect(text).not.toContain("['~standard']"); + expect(text).not.toContain('parsed.issues'); 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', () => { + it('preserves .error access (Zod error shape is retained)', () => { const input = [ `import { ToolSchema } from '@modelcontextprotocol/server';`, `const result = ToolSchema.safeParse(raw);`, - `if (!result.success) { console.log(result.error); }`, + `if (!result.success) { console.log(result.error.issues); }`, '' ].join('\n'); const { text } = applyTransform(input); - expect(text).toContain('result.issues'); - expect(text).not.toContain('result.error'); + expect(text).toContain('specTypeSchemas.Tool.safeParse(raw)'); + expect(text).toContain('result.error.issues'); + expect(text).not.toContain("['~standard']"); }); - it('handles ternary pattern: x.success ? x.data : fallback', () => { + it('preserves ternary pattern: x.success ? x.data : fallback', () => { const input = [ `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, `const parsed = CallToolResultSchema.safeParse(toolResult);`, @@ -125,73 +89,18 @@ describe('spec-schema-access transform', () => { '' ].join('\n'); const { text } = applyTransform(input); - expect(text).toContain("specTypeSchemas.CallToolResult['~standard'].validate(toolResult)"); - expect(text).toContain('(parsed.issues === undefined) ? parsed.value : undefined'); + expect(text).toContain('specTypeSchemas.CallToolResult.safeParse(toolResult)'); + expect(text).toContain('parsed.success ? parsed.data : 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'); + it('renames bare (non-captured) safeParse expression', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `ToolSchema.safeParse(data);`, ''].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); + expect(text).toContain('specTypeSchemas.Tool.safeParse(data)'); + expect(result.changesCount).toBe(1); }); - 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', () => { + it('does not rewrite a same-named result variable in a sibling function', () => { const input = [ `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, `function validate(d: unknown) {`, @@ -205,18 +114,53 @@ describe('spec-schema-access transform', () => { '' ].join('\n'); const { text } = applyTransform(input); - expect(text).toContain('result.issues === undefined'); + expect(text).toContain('specTypeSchemas.CallToolRequest.safeParse(d)'); + expect(text).toContain('return result.success'); 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'); + describe('rename: .parse(v) is preserved', () => { + it('renames XSchema.parse(v) to specTypeSchemas.X.parse(v)', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const tool = ToolSchema.parse(raw);`, ''].join( + '\n' + ); const { text, result } = applyTransform(input); - expect(text).toContain("specTypeSchemas.Tool['~standard'].validate(data)"); + expect(text).toContain('specTypeSchemas.Tool.parse(raw)'); expect(text).not.toMatch(/import\s*\{[^}]*ToolSchema[^}]*\}/); + expect(result.changesCount).toBe(1); + }); + + it('emits an info diagnostic (not action-required) for the parse rename', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const tool = ToolSchema.parse(raw);`, ''].join( + '\n' + ); + const { result } = applyTransform(input); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.level).toBe('info'); + expect(result.diagnostics[0]!.message).toContain('specTypeSchemas.Tool'); + }); + }); + + describe('rename: 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).toBe(1); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('specTypeSchemas.ListToolsRequest'); + }); + + 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/); }); }); @@ -281,7 +225,7 @@ describe('spec-schema-access transform', () => { }); }); - describe('auto-transform: generic property access → specTypeSchemas.X', () => { + describe('rename: other Zod methods are renamed but flagged (not exposed in v2)', () => { it('replaces schema identifier in .parseAsync() call', () => { const input = [ `import { OAuthTokensSchema } from '@modelcontextprotocol/server';`, @@ -292,7 +236,8 @@ describe('spec-schema-access transform', () => { 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); + // .parseAsync is not exposed on the v2 entry → warns to migrate manually. + expect(result.diagnostics.some(d => d.level === 'warning' && d.message.includes('parseAsync'))).toBe(true); }); it('replaces schema identifier in .or() call', () => { @@ -329,27 +274,6 @@ describe('spec-schema-access transform', () => { }); }); - 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 = [ @@ -391,18 +315,18 @@ describe('spec-schema-access transform', () => { }); describe('import cleanup after transform', () => { - it('removes original schema import after all refs are auto-transformed', () => { + it('removes original schema import after all refs are renamed', () => { 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).toContain('specTypeSchemas.CallToolRequest.safeParse(data)'); expect(text).not.toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/); }); - it('removes the schema import even when a ref falls back to a parse()/safeParse() rewrite', () => { + it('removes original schema import when refs mix safeParse and parse', () => { const input = [ `import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, `const valid = CallToolRequestSchema.safeParse(data).success;`, @@ -410,7 +334,7 @@ describe('spec-schema-access transform', () => { '' ].join('\n'); const { text } = applyTransform(input); - expect(text).toContain('isSpecType.CallToolRequest(data)'); + expect(text).toContain('specTypeSchemas.CallToolRequest.safeParse(data)'); expect(text).toContain('specTypeSchemas.CallToolRequest.parse(data)'); expect(text).not.toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/); }); @@ -500,7 +424,7 @@ describe('spec-schema-access transform', () => { }); describe('aliased imports', () => { - it('handles aliased import and auto-transforms captured safeParse', () => { + it('handles aliased import and renames captured safeParse', () => { const input = [ `import { CallToolRequestSchema as CTRS } from '@modelcontextprotocol/server';`, `const result = CTRS.safeParse(data);`, @@ -508,7 +432,8 @@ describe('spec-schema-access transform', () => { '' ].join('\n'); const { text, result } = applyTransform(input); - expect(text).toContain("specTypeSchemas.CallToolRequest['~standard'].validate(data)"); + expect(text).toContain('specTypeSchemas.CallToolRequest.safeParse(data)'); + expect(text).toContain('result.success'); expect(text).not.toContain('CTRS.safeParse'); expect(result.changesCount).toBeGreaterThan(0); expect(result.diagnostics[0]!.message).toContain('specTypeSchemas.CallToolRequest'); From 77423fef4e92bb257f4c45dc06f91393c0ea321d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 10 Jun 2026 22:18:22 +0000 Subject: [PATCH 3/7] fix(codemod): infer client/server project type from source for v1 projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared protocol types (types.js, shared/*) live in both the client and server packages, so the codemod has to decide which package to import them from. It read that decision from package.json — but a project being migrated from v1 still has the single `@modelcontextprotocol/sdk` dependency, not the split `client`/`server` packages, so the project type came back "unknown" for the whole run. Every file that imported only shared types then defaulted to the server package with an action-required warning telling the user to pick by hand. Infer the project type from the source instead: when the split deps are absent, scan for `@modelcontextprotocol/sdk/client/` and `.../server/` imports. Using both → "both", one → that side, neither → "unknown". A project that uses both client and server APIs is now correctly "both". For a "both" project, importing shared types from either package compiles (both re-export them from core), so resolve to server and emit an info note rather than an action-required warning. "unknown" still warns. For a client-only or server-only project the inferred type now routes shared types to the installed package instead of defaulting to a server package that was never added. The scan is bounded (skips node_modules/dist/etc, file budget, early-exit once both signals are seen). --- packages/codemod/src/utils/projectAnalyzer.ts | 113 +++++++++++++++--- packages/codemod/test/projectAnalyzer.test.ts | 80 ++++++++++++- 2 files changed, 175 insertions(+), 18 deletions(-) diff --git a/packages/codemod/src/utils/projectAnalyzer.ts b/packages/codemod/src/utils/projectAnalyzer.ts index daf4088876..a74c48f410 100644 --- a/packages/codemod/src/utils/projectAnalyzer.ts +++ b/packages/codemod/src/utils/projectAnalyzer.ts @@ -1,11 +1,15 @@ -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; + export function findPackageJson(startDir: string): string | undefined { let dir = path.resolve(startDir); const root = path.parse(dir).root; @@ -20,27 +24,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 yet — this is almost always a v1 project mid-migration (the v1 SDK is + // a single `@modelcontextprotocol/sdk` package). Fall through to inferring the type from + // how the source actually uses the SDK. + } catch { + // 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/...` import means the project ends up needing + * `@modelcontextprotocol/client`; a `.../server/...` import means it needs `@modelcontextprotocol/server`. + * Files that import only shared paths (`types.js`, `shared/...`) give no signal. Bounded 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 (content.includes('@modelcontextprotocol/sdk/client/')) usesClient = true; + if (content.includes('@modelcontextprotocol/sdk/server/')) 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 +124,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 them from either compiles. This file has no client/server-specific signal, so + // default to server and note it — no manual action is required, only an optional preference. + 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..1f88f33541 100644 --- a/packages/codemod/test/projectAnalyzer.test.ts +++ b/packages/codemod/test/projectAnalyzer.test.ts @@ -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,82 @@ 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('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('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('info (not warning) for a both-project ambiguous file', async () => { + const { resolveTypesPackage } = await import('../src/utils/projectAnalyzer.js'); + 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('action-required warning for a genuinely unknown project', async () => { + const { resolveTypesPackage } = await import('../src/utils/projectAnalyzer.js'); + 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', async () => { + const { resolveTypesPackage } = await import('../src/utils/projectAnalyzer.js'); + expect(resolveTypesPackage({ projectType: 'both' }, true, false)).toBe('@modelcontextprotocol/client'); + expect(resolveTypesPackage({ projectType: 'both' }, false, true)).toBe('@modelcontextprotocol/server'); + }); }); From 15a5d21640204f57af08ff87ae979cd5616007c1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 10 Jun 2026 22:22:50 +0000 Subject: [PATCH 4/7] 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 by looking the schema up in a schema→method table. The task schemas added to the spec were missing from that table, so a handler like setNotificationHandler(TaskStatusNotificationSchema, …) fell through to the generic "use the 3-arg form" diagnostic and was left for manual migration. Add the task entries: tasks/get, tasks/result, tasks/list, tasks/cancel, and the notifications/tasks/status notification. These are spec methods, so the rewritten two-argument call (method string + handler) resolves to the spec overload of setRequestHandler/setNotificationHandler and typechecks. --- .../v1-to-v2/mappings/schemaToMethodMap.ts | 9 ++++++-- .../transforms/handlerRegistration.test.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts index 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 240f32b4a5208852c6c70ad6ba47c94707d0ae16 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 10 Jun 2026 22:48:54 +0000 Subject: [PATCH 5/7] docs(migration): document the parse/safeParse rename path for spec schemas specTypeSchemas entries now expose Zod-compatible parse()/safeParse(), so code that validated with a *Schema constant migrates by renaming the reference rather than rewriting to the Standard Schema validate() call and remapping its result. Show that path alongside the existing isSpecType and ['~standard'].validate() options, and note that new code should still prefer the library-agnostic forms. --- docs/migration.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..9cfb3d4676 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -527,8 +527,24 @@ 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. +If your v1 code called `.parse()` or `.safeParse()` on a `*Schema` constant, the smallest migration is +to rename the reference — `specTypeSchemas.X` retains Zod-compatible `.parse()` and `.safeParse()` with +identical behavior (`.parse()` still throws a `ZodError` on invalid input): + +```typescript +// v1 +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +const tool = CallToolResultSchema.parse(value); +const r = CallToolResultSchema.safeParse(value); // { success, data } | { success, error } + +// v2 — rename only; .parse()/.safeParse() and their result shapes are unchanged +import { specTypeSchemas } from '@modelcontextprotocol/client'; +const tool = specTypeSchemas.CallToolResult.parse(value); +const r = specTypeSchemas.CallToolResult.safeParse(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` (also exposing the `.parse()`/`.safeParse()` shown above) — `validate()` returns the result synchronously, +so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. New code should prefer the library-agnostic `['~standard'].validate()` or `isSpecType`. The pre-existing `isCallToolResult(value)` guard still works. ### Client list methods return empty results for missing capabilities From c3b3845e2162a71cfb66c21741f7a5dd4c148ec4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 10 Jun 2026 23:29:53 +0000 Subject: [PATCH 6/7] fix(codemod): normalize import specifiers before the import-map lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import-path and mock-path transforms looked up specifiers with an exact match against IMPORT_MAP, which is keyed on the canonical .js-suffixed file form (e.g. .../types.js, .../server/index.js). Consumers using bundler/node16 module resolution import the same modules without the extension (.../types) or in directory form (.../server, which resolves to server/index.js). Those fell through to 'Unknown SDK import path: ... Manual migration required' even though the .js twin was mapped — and a single file could migrate .../server/index.js fine while failing on .../inMemory. Add resolveImportMapping(): try the literal key, then the .js-file form, then the /index.js directory form. Wire it into both transforms. On a codebase that mixes extension styles this clears the great majority of the otherwise-manual import diagnostics (measured: 18 -> 0 on one such consumer). Adds tests for extensionless (/types, /shared/transport, /inMemory) and directory-style (/server, /client) specifiers, plus a guard that genuinely unknown subpaths still report. --- .../migrations/v1-to-v2/mappings/importMap.ts | 19 +++++++ .../v1-to-v2/transforms/importPaths.ts | 6 +-- .../v1-to-v2/transforms/mockPaths.ts | 4 +- .../v1-to-v2/transforms/importPaths.test.ts | 49 +++++++++++++++++++ 4 files changed, 73 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..095da9487a 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,22 @@ for (const barrelSpecifier of ['@modelcontextprotocol/sdk/validation/index.js', export function isAuthImport(specifier: string): boolean { return specifier.includes('/server/auth/') || specifier.includes('/server/auth.'); } + +/** + * Look up a v1 import specifier in {@link IMPORT_MAP}, tolerating module-resolution variants. + * + * `IMPORT_MAP` is keyed on the canonical `.js`-suffixed file form (e.g. `.../types.js`, + * `.../server/index.js`). But v1 consumers using bundler/`node16` resolution frequently import the + * same module without the extension (`.../types`) or in directory form (`.../server`, which resolves + * to `server/index.js`). An exact-string lookup misses those and reports "Unknown SDK import path" + * even though the `.js` twin is mapped. Normalize before giving up: try the literal key, then the + * `.js`-file form, then the `/index.js` directory form. + */ +export function resolveImportMapping(specifier: string): ImportMapping | undefined { + const direct = IMPORT_MAP[specifier]; + if (direct) return direct; + if (!specifier.startsWith('@modelcontextprotocol/sdk') || /\.(js|mjs|cjs)$/.test(specifier)) { + return undefined; + } + return IMPORT_MAP[`${specifier}.js`] ?? IMPORT_MAP[`${specifier}/index.js`]; +} 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..212f825981 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, resolveImportMapping } 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 = resolveImportMapping(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 = resolveImportMapping(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..747e7187b6 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, resolveImportMapping } 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 = resolveImportMapping(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..3052f5b385 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -603,4 +603,53 @@ describe('import-paths transform', () => { expect(result).toContain('jsonSchemaValidator'); }); }); + + describe('extensionless and directory-style specifiers', () => { + it('rewrites an extensionless file specifier (/types) the same as /types.js', () => { + const input = `import type { CallToolResult } from '@modelcontextprotocol/sdk/types';\n`; + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain(`from "@modelcontextprotocol/server"`); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('rewrites a directory-style server specifier (/server resolves to server/index.js)', () => { + const input = `import { Server } from '@modelcontextprotocol/sdk/server';\n`; + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain(`from "@modelcontextprotocol/server"`); + expect(result).toContain('Server'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('rewrites a directory-style client specifier (/client)', () => { + const input = `import { Client } from '@modelcontextprotocol/sdk/client';\n`; + const result = applyTransform(input, { projectType: 'client' }); + expect(result).toContain(`from "@modelcontextprotocol/client"`); + expect(result).toContain('Client'); + }); + + it('rewrites extensionless shared/* and inMemory specifiers', () => { + const input = [ + `import type { Transport } from '@modelcontextprotocol/sdk/shared/transport';`, + `import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + expect(result).not.toMatch(/Unknown SDK import path/); + }); + + it('does not emit "Unknown SDK import path" for the extensionless twin of a mapped specifier', () => { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('t.ts', `import type { Tool } from '@modelcontextprotocol/sdk/types';\n`); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.some(d => d.message.includes('Unknown SDK import path'))).toBe(false); + }); + + it('still reports genuinely unknown subpaths', () => { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('t.ts', `import { Thing } from '@modelcontextprotocol/sdk/does/not/exist';\n`); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.some(d => d.message.includes('Unknown SDK import path'))).toBe(true); + }); + }); }); From e9965a5e244019a8b9998cba1d324bf4ed8a4190 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 11 Jun 2026 05:06:12 +0000 Subject: [PATCH 7/7] chore: add changesets for v1->v2 migration ergonomics --- .changeset/codemod-import-specifier-normalization.md | 5 +++++ .changeset/codemod-project-type-inference.md | 5 +++++ .changeset/codemod-spec-schema-rename.md | 5 +++++ .changeset/codemod-task-method-strings.md | 5 +++++ .changeset/specschemas-zod-compat-parse.md | 7 +++++++ 5 files changed, 27 insertions(+) create mode 100644 .changeset/codemod-import-specifier-normalization.md create mode 100644 .changeset/codemod-project-type-inference.md create mode 100644 .changeset/codemod-spec-schema-rename.md create mode 100644 .changeset/codemod-task-method-strings.md create mode 100644 .changeset/specschemas-zod-compat-parse.md diff --git a/.changeset/codemod-import-specifier-normalization.md b/.changeset/codemod-import-specifier-normalization.md new file mode 100644 index 0000000000..156ab221b9 --- /dev/null +++ b/.changeset/codemod-import-specifier-normalization.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/codemod": patch +--- + +The v1→v2 codemod now normalizes import specifiers before the import-map lookup, so extensionless (`@modelcontextprotocol/sdk/types`) and directory-style (`@modelcontextprotocol/sdk/server`) specifiers resolve the same as their canonical `.js` form. Projects using bundler/node16 module resolution that imported SDK modules without the `.js` extension previously hit "Unknown SDK import path: ... Manual migration required" even though the `.js` twin was mapped; those now migrate automatically. Genuinely unknown subpaths still report. diff --git a/.changeset/codemod-project-type-inference.md b/.changeset/codemod-project-type-inference.md new file mode 100644 index 0000000000..134325d729 --- /dev/null +++ b/.changeset/codemod-project-type-inference.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/codemod": patch +--- + +The v1→v2 codemod now infers whether a project is client, server, or both by scanning the source for `@modelcontextprotocol/sdk/client/` and `.../server/` imports when the split v2 dependencies are not yet present in `package.json`. A v1 project (single `@modelcontextprotocol/sdk` dependency) previously resolved to `unknown`, so every file importing only shared protocol types defaulted to `@modelcontextprotocol/server` with an action-required warning. Now a project that uses both client and server APIs is detected as `both` and resolves shared types to the server package with an informational note (both packages re-export them); a client-only or server-only project routes shared types to the package it actually installs. diff --git a/.changeset/codemod-spec-schema-rename.md b/.changeset/codemod-spec-schema-rename.md new file mode 100644 index 0000000000..269cd4b5e8 --- /dev/null +++ b/.changeset/codemod-spec-schema-rename.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/codemod": patch +--- + +The v1→v2 codemod now migrates spec-schema `.parse()` / `.safeParse()` usage by renaming the schema reference to `specTypeSchemas.X` and leaving the call and its result access untouched, instead of rewriting to `['~standard'].validate()` and remapping `.success`/`.data`/`.error`. This pairs with `specTypeSchemas` entries now exposing those Zod-compatible methods, so the migration is a behavior-preserving rename: `.parse()` still throws on invalid input and `.safeParse()` keeps its discriminated result, with no `.parse()` sites left unmigrated. Other Zod methods that are not exposed on the entry (e.g. `.extend`, `.parseAsync`) are renamed and flagged inline for manual rewrite. diff --git a/.changeset/codemod-task-method-strings.md b/.changeset/codemod-task-method-strings.md new file mode 100644 index 0000000000..b30f9cc97a --- /dev/null +++ b/.changeset/codemod-task-method-strings.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/codemod": patch +--- + +The v1→v2 codemod's handler-registration transform now recognizes the task spec methods (`tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`, and the `notifications/tasks/status` notification). `setRequestHandler`/`setNotificationHandler` calls passing a task schema are rewritten to the v2 method-string form instead of falling through to a manual-migration diagnostic. diff --git a/.changeset/specschemas-zod-compat-parse.md b/.changeset/specschemas-zod-compat-parse.md new file mode 100644 index 0000000000..c87b0b88d1 --- /dev/null +++ b/.changeset/specschemas-zod-compat-parse.md @@ -0,0 +1,7 @@ +--- +"@modelcontextprotocol/core": minor +"@modelcontextprotocol/client": minor +"@modelcontextprotocol/server": minor +--- + +Expose Zod-compatible `parse()` / `safeParse()` on every `specTypeSchemas` entry. The schemas are still typed as Standard Schema (`['~standard'].validate()` remains the recommended, library-agnostic API), but the underlying runtime values are Zod schemas, so these two methods are now surfaced with their original behavior — `parse()` returns the typed value or throws a `ZodError`, `safeParse()` returns the `{ success, data } | { success, error }` result. This lets code written against the previous top-level `*Schema` exports migrate by a reference rename (`CallToolResultSchema.parse(x)` → `specTypeSchemas.CallToolResult.parse(x)`) with identical behavior, instead of being rewritten to `['~standard'].validate()` with manual remapping of `.success`/`.data`/`.error`. Only these two methods are exposed; the rest of the Zod schema surface stays internal.