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. 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 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/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/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/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/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/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/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'); + }); }); 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 }); 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); + }); + }); }); 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'); 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', () => {