v2: codemod iterations, canonical zod schema exports from sdk-shared (#2354)
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
parent
7ea6fde1b6
commit
0fb8406d83
19 changed files with 2954 additions and 1665 deletions
|
|
@ -85,9 +85,10 @@ const BATCH_DIR = path.resolve(SDK_ROOT, 'packages/codemod/batch-test');
|
|||
|
||||
const LOCAL_PACKAGE_DIRS: Record<string, string> = {
|
||||
'@modelcontextprotocol/client': path.join(SDK_ROOT, 'packages/client'),
|
||||
'@modelcontextprotocol/core': path.join(SDK_ROOT, 'packages/core'),
|
||||
'@modelcontextprotocol/core-internal': path.join(SDK_ROOT, 'packages/core-internal'),
|
||||
'@modelcontextprotocol/server': path.join(SDK_ROOT, 'packages/server'),
|
||||
'@modelcontextprotocol/server-legacy': path.join(SDK_ROOT, 'packages/server-legacy'),
|
||||
'@modelcontextprotocol/core': path.join(SDK_ROOT, 'packages/core'),
|
||||
'@modelcontextprotocol/express': path.join(SDK_ROOT, 'packages/middleware/express'),
|
||||
'@modelcontextprotocol/fastify': path.join(SDK_ROOT, 'packages/middleware/fastify'),
|
||||
'@modelcontextprotocol/hono': path.join(SDK_ROOT, 'packages/middleware/hono'),
|
||||
|
|
@ -110,6 +111,22 @@ function detectPm(repoRoot: string): string {
|
|||
return 'npm';
|
||||
}
|
||||
|
||||
function installCommand(pm: string): string {
|
||||
if (pm !== 'pnpm') return `${pm} install --ignore-scripts`;
|
||||
// pnpm walks up to find a workspace; clones live inside this SDK's pnpm workspace, so a plain
|
||||
// `pnpm install` targets the OUTER workspace and never populates the clone's node_modules — every
|
||||
// downstream check (tsc base config, tsup, vitest) then fails identically at baseline and post,
|
||||
// masking real codemod signal.
|
||||
// --ignore-workspace: treat the clone as a standalone project (not part of the SDK workspace).
|
||||
// --no-frozen-lockfile: the codemod rewrites package.json to swap v1 → v2 deps, so the lockfile
|
||||
// must be allowed to change. CI=true (set in shell()) otherwise defaults
|
||||
// pnpm to a frozen lockfile and the post-codemod reinstall silently skips
|
||||
// the new v2 deps, leaving the clone on v1.
|
||||
// npm/yarn/bun key off a `workspaces` field in package.json (absent at this repo root), so they
|
||||
// need no equivalent flags.
|
||||
return 'pnpm install --ignore-scripts --ignore-workspace --no-frozen-lockfile';
|
||||
}
|
||||
|
||||
function detectCheckCmd(pkgDir: string, checkType: string): string | null {
|
||||
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
||||
if (!existsSync(pkgJsonPath)) return null;
|
||||
|
|
@ -132,7 +149,11 @@ function shell(cmd: string, cwd?: string): { exitCode: number; stdout: string; s
|
|||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 5 * 60 * 1000
|
||||
timeout: 5 * 60 * 1000,
|
||||
// Commands are spawned without a TTY (piped stdio). Set CI so package managers run fully
|
||||
// non-interactively — without it, pnpm aborts rebuilding a clone's modules dir with
|
||||
// ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY when --ignore-workspace changes its link mode.
|
||||
env: { ...process.env, CI: 'true' }
|
||||
}).toString();
|
||||
return { exitCode: 0, stdout, stderr: '' };
|
||||
} catch (error: unknown) {
|
||||
|
|
@ -318,7 +339,7 @@ function main(): void {
|
|||
|
||||
// Step 3: Install
|
||||
console.log(' Installing dependencies...');
|
||||
const installResult = shell(`${pm} install --ignore-scripts`, clonePath);
|
||||
const installResult = shell(installCommand(pm), clonePath);
|
||||
if (installResult.exitCode !== 0) {
|
||||
console.log(` ERROR: install failed, skipping\n ${installResult.stderr.split('\n')[0]}`);
|
||||
continue;
|
||||
|
|
@ -366,7 +387,7 @@ function main(): void {
|
|||
console.log(` Rewrote ${rewrites} deps to local tarballs`);
|
||||
}
|
||||
console.log(' Re-installing dependencies...');
|
||||
shell(`${pm} install --ignore-scripts`, clonePath);
|
||||
shell(installCommand(pm), clonePath);
|
||||
|
||||
// Step 7: Post-codemod checks
|
||||
console.log(' Running post-codemod checks...');
|
||||
|
|
|
|||
|
|
@ -9,11 +9,36 @@ import { Command } from 'commander';
|
|||
import { listMigrations } from './migrations/index';
|
||||
import { run } from './runner';
|
||||
import { DiagnosticLevel } from './types';
|
||||
import { detectFormatter } from './utils/detectFormatter';
|
||||
import { CODEMOD_ERROR_PREFIX, formatDiagnostic } from './utils/diagnostics';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json') as { version: string };
|
||||
|
||||
function quoteArg(arg: string): string {
|
||||
return /\s/.test(arg) ? `"${arg}"` : arg;
|
||||
}
|
||||
|
||||
/**
|
||||
* The codemod transforms the AST but does not reformat — wrapped schemas and
|
||||
* generated string literals can violate a repo's lint/formatting rules. Point
|
||||
* the user at their own formatter (which respects their config) for the exact
|
||||
* files that changed.
|
||||
*/
|
||||
function printFormatGuidance(targetDir: string, changedFiles: string[]): void {
|
||||
if (changedFiles.length === 0) return;
|
||||
|
||||
const formatter = detectFormatter(targetDir);
|
||||
const fileArgs = changedFiles.map(file => quoteArg(path.relative(process.cwd(), file) || file));
|
||||
|
||||
console.log("This codemod doesn't reformat its output. Run your formatter on the changed file(s):");
|
||||
if (formatter) {
|
||||
console.log(` ${formatter.bin} ${[...formatter.writeArgs, ...fileArgs].join(' ')}\n`);
|
||||
} else {
|
||||
console.log(` e.g. prettier --write ${fileArgs.join(' ')}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name('mcp-codemod').description('Codemod to migrate MCP TypeScript SDK code between versions').version(version);
|
||||
|
|
@ -150,6 +175,8 @@ for (const [name, migration] of listMigrations()) {
|
|||
if (opts['dryRun']) {
|
||||
console.log('Run without --dry-run to apply changes.\n');
|
||||
} else {
|
||||
const changedFiles = result.fileResults.filter(fr => fr.changes > 0).map(fr => fr.filePath);
|
||||
printFormatGuidance(resolvedDir, changedFiles);
|
||||
if (result.packageJsonChanges) {
|
||||
console.log('Run your package manager to install the new packages.\n');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ export const V2_PACKAGE_VERSIONS: Record<string, string> = {
|
|||
'@modelcontextprotocol/server': '^2.0.0-alpha.2',
|
||||
'@modelcontextprotocol/node': '^2.0.0-alpha.2',
|
||||
'@modelcontextprotocol/express': '^2.0.0-alpha.2',
|
||||
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2'
|
||||
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2',
|
||||
'@modelcontextprotocol/core': '^2.0.0-alpha.0'
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
// The auth (OAuth / OpenID) Zod schema CONSTANTS that v1 exported from
|
||||
// `@modelcontextprotocol/sdk/shared/auth.js` and that core still re-exports in v2. The import
|
||||
// transform routes a `*Schema` symbol imported from that v1 path to core when its name is in this
|
||||
// set (the corresponding TYPES, e.g. OAuthTokens, resolve by context to @modelcontextprotocol/client |
|
||||
// /server). This is the v1 auth-schema set — a SUBSET of core's auth exports. v2-only auth schemas
|
||||
// (e.g. IdJagTokenExchangeResponseSchema) are exported by core but NOT listed here: v1 never had
|
||||
// them, so there is nothing to migrate. test/v1-to-v2/authSchemaNames.test.ts asserts every name here is
|
||||
// exported by core (so the rewritten import resolves). Keep alphabetized.
|
||||
export const AUTH_SCHEMA_NAMES: ReadonlySet<string> = new Set([
|
||||
'OAuthClientInformationFullSchema',
|
||||
'OAuthClientInformationSchema',
|
||||
'OAuthClientMetadataSchema',
|
||||
'OAuthClientRegistrationErrorSchema',
|
||||
'OAuthErrorResponseSchema',
|
||||
'OAuthMetadataSchema',
|
||||
'OAuthProtectedResourceMetadataSchema',
|
||||
'OAuthTokenRevocationRequestSchema',
|
||||
'OAuthTokensSchema',
|
||||
'OpenIdProviderDiscoveryMetadataSchema',
|
||||
'OpenIdProviderMetadataSchema'
|
||||
]);
|
||||
|
||||
// v1's `@modelcontextprotocol/sdk/shared/auth.js` also exported these as Zod schema CONSTANTS, but they
|
||||
// are typeless internal URL field-validators (no public spec type), so v2's core deliberately does
|
||||
// NOT re-export them (see packages/core/src/index.ts) and no other public v2 package exports them.
|
||||
// They therefore have no v2 home: routed by context they would produce a codemod-introduced "has no
|
||||
// exported member" error. importPaths emits an actionRequired diagnostic instead of silently breaking.
|
||||
// test/v1-to-v2/authSchemaNames.test.ts asserts these are NOT in AUTH_SCHEMA_NAMES (and so are not
|
||||
// claimed to be core exports).
|
||||
export const AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT: ReadonlySet<string> = new Set(['SafeUrlSchema', 'OptionalSafeUrlSchema']);
|
||||
|
|
@ -4,6 +4,17 @@ export interface ImportMapping {
|
|||
renamedSymbols?: Record<string, string>;
|
||||
/** Route specific symbols to a different target package than `target`. */
|
||||
symbolTargetOverrides?: Record<string, string>;
|
||||
/**
|
||||
* Route an imported symbol to this package (instead of `target`) when its rename-resolved name is
|
||||
* a Zod schema constant re-exported by core — a member of `SPEC_SCHEMA_NAMES` (spec schemas,
|
||||
* for `sdk/types.js`) or `AUTH_SCHEMA_NAMES` (OAuth/OpenID schemas, for `sdk/shared/auth.js`). The
|
||||
* schemas now live in `@modelcontextprotocol/core` (so `<Name>Schema.parse(...)` keeps
|
||||
* working), while the corresponding types/constants/guards resolve by context. Matching on
|
||||
* membership (not a `*Schema` suffix) keeps TYPES whose name ends in `Schema` — e.g. the
|
||||
* elicitation primitives `BooleanSchema`/`StringSchema`/`EnumSchema` — routed by context, where
|
||||
* their types live. `symbolTargetOverrides` (exact-name) takes precedence.
|
||||
*/
|
||||
schemaSymbolTarget?: string;
|
||||
removalMessage?: string;
|
||||
/** No entries currently set this; scaffolding for when a v1 symbol has no v2 equivalent yet. */
|
||||
isV2Gap?: boolean;
|
||||
|
|
@ -119,6 +130,7 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
|
|||
'@modelcontextprotocol/sdk/types.js': {
|
||||
target: 'RESOLVE_BY_CONTEXT',
|
||||
status: 'moved',
|
||||
schemaSymbolTarget: '@modelcontextprotocol/core',
|
||||
renamedSymbols: {
|
||||
ResourceTemplate: 'ResourceTemplateType'
|
||||
}
|
||||
|
|
@ -137,7 +149,12 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
|
|||
},
|
||||
'@modelcontextprotocol/sdk/shared/auth.js': {
|
||||
target: 'RESOLVE_BY_CONTEXT',
|
||||
status: 'moved'
|
||||
status: 'moved',
|
||||
// OAuth/OpenID Zod schema constants (AUTH_SCHEMA_NAMES) are re-exported by core as a
|
||||
// separate group, so route them there (keeping `OAuthTokensSchema.parse(...)` working). The
|
||||
// OAuth/OpenID TYPES (OAuthTokens, etc.) carry no `schemaSymbolTarget` match and resolve by
|
||||
// context to @modelcontextprotocol/client | /server.
|
||||
schemaSymbolTarget: '@modelcontextprotocol/core'
|
||||
},
|
||||
'@modelcontextprotocol/sdk/shared/stdio.js': {
|
||||
target: 'RESOLVE_BY_CONTEXT',
|
||||
|
|
@ -190,3 +207,28 @@ for (const barrelSpecifier of ['@modelcontextprotocol/sdk/validation/index.js',
|
|||
export function isAuthImport(specifier: string): boolean {
|
||||
return specifier.includes('/server/auth/') || specifier.includes('/server/auth.');
|
||||
}
|
||||
|
||||
// SDK subpath specifiers can be written with or without a JS extension
|
||||
// (e.g. `@modelcontextprotocol/sdk/types` vs `.../types.js`) depending on the
|
||||
// consumer's module resolution (`bundler`/`nodenext` allow the extensionless form).
|
||||
// Normalize the extension so both spellings resolve to the same mapping. Built
|
||||
// after every IMPORT_MAP entry above is populated; entries whose `.js` and
|
||||
// extensionless forms coexist (e.g. `experimental/tasks`) share an identical
|
||||
// mapping, so the collapse is lossless.
|
||||
function stripJsExtension(specifier: string): string {
|
||||
return specifier.replace(/\.(?:js|mjs|cjs)$/, '');
|
||||
}
|
||||
|
||||
const NORMALIZED_IMPORT_MAP: Record<string, ImportMapping> = {};
|
||||
for (const [key, mapping] of Object.entries(IMPORT_MAP)) {
|
||||
NORMALIZED_IMPORT_MAP[stripJsExtension(key)] = mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the v2 mapping for a v1 SDK import/export/mock specifier, tolerating
|
||||
* JS extension variance. An exact match always wins; otherwise the specifier is
|
||||
* matched ignoring a trailing `.js`/`.mjs`/`.cjs` (or its absence).
|
||||
*/
|
||||
export function lookupImportMapping(specifier: string): ImportMapping | undefined {
|
||||
return IMPORT_MAP[specifier] ?? NORMALIZED_IMPORT_MAP[stripJsExtension(specifier)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
// Shared per-symbol routing logic for v1→v2 import/export/mock rewrites. Centralized here so the
|
||||
// import-path transform (static imports/re-exports) and the mock-path transform (vi.mock/jest.mock
|
||||
// factories, dynamic import() destructurings) route a given symbol to exactly the same v2 package.
|
||||
import { AUTH_SCHEMA_NAMES } from './authSchemaNames';
|
||||
import type { ImportMapping } from './importMap';
|
||||
import { SPEC_SCHEMA_NAMES } from './specSchemaNames';
|
||||
import { SIMPLE_RENAMES } from './symbolMap';
|
||||
|
||||
/** The v2 name a symbol resolves to after renames (per-mapping override, then global SIMPLE_RENAMES). */
|
||||
export function resolveRenamedName(name: string, mapping: ImportMapping): string {
|
||||
return mapping.renamedSymbols?.[name] ?? SIMPLE_RENAMES[name] ?? name;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `name` (after renames) is a Zod schema CONSTANT that core re-exports — either a spec
|
||||
* schema (`SPEC_SCHEMA_NAMES`) or an OAuth/OpenID schema (`AUTH_SCHEMA_NAMES`). Membership (not a
|
||||
* `*Schema` suffix) is what keeps TYPES whose name ends in `Schema` — e.g. `BooleanSchema` — out.
|
||||
*/
|
||||
export function isSharedSchemaConst(name: string, mapping: ImportMapping): boolean {
|
||||
const resolved = resolveRenamedName(name, mapping);
|
||||
return SPEC_SCHEMA_NAMES.has(resolved) || AUTH_SCHEMA_NAMES.has(resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-symbol target package for a symbol imported/re-exported/mocked from `mapping`'s module, or
|
||||
* `undefined` when the symbol should use the mapping's resolved `target`. Exact-name
|
||||
* `symbolTargetOverrides` win over `schemaSymbolTarget`, which routes a symbol to the shared-schemas
|
||||
* package only when its rename-resolved name is a schema constant re-exported by core (see
|
||||
* `isSharedSchemaConst`).
|
||||
*/
|
||||
export function symbolTargetOverride(name: string, mapping: ImportMapping): string | undefined {
|
||||
if (mapping.symbolTargetOverrides && name in mapping.symbolTargetOverrides) {
|
||||
return mapping.symbolTargetOverrides[name];
|
||||
}
|
||||
if (mapping.schemaSymbolTarget && isSharedSchemaConst(name, mapping)) {
|
||||
return mapping.schemaSymbolTarget;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -14,7 +14,11 @@ export const SCHEMA_TO_METHOD: Record<string, string> = {
|
|||
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<string, string> = {
|
||||
|
|
@ -27,5 +31,6 @@ export const NOTIFICATION_SCHEMA_TO_METHOD: Record<string, string> = {
|
|||
CancelledNotificationSchema: 'notifications/cancelled',
|
||||
InitializedNotificationSchema: 'notifications/initialized',
|
||||
RootsListChangedNotificationSchema: 'notifications/roots/list_changed',
|
||||
ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete'
|
||||
ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete',
|
||||
TaskStatusNotificationSchema: 'notifications/tasks/status'
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
// AUTO-VERIFIED against @modelcontextprotocol/core's public exports by
|
||||
// test/v1-to-v2/specSchemaNames.test.ts (drift guard). These are the spec Zod schema CONSTANTS that
|
||||
// core re-exports as standalone values; the v1->v2 import transform routes a `*Schema` symbol
|
||||
// imported from `@modelcontextprotocol/sdk/types.js` to core ONLY when its (rename-resolved)
|
||||
// name is in this set. Names that merely END in `Schema` but are NOT here — e.g. the elicitation
|
||||
// primitive TYPES `BooleanSchema`/`StringSchema`/`EnumSchema` (whose Zod const is `<Name>SchemaSchema`)
|
||||
// — fall through to context resolution (@modelcontextprotocol/client | /server), where their TYPES
|
||||
// live. Keep alphabetized.
|
||||
export const SPEC_SCHEMA_NAMES: ReadonlySet<string> = new Set([
|
||||
'AnnotationsSchema',
|
||||
'AudioContentSchema',
|
||||
'BaseMetadataSchema',
|
||||
'BlobResourceContentsSchema',
|
||||
'BooleanSchemaSchema',
|
||||
'CallToolRequestParamsSchema',
|
||||
'CallToolRequestSchema',
|
||||
'CallToolResultSchema',
|
||||
'CancelTaskRequestSchema',
|
||||
'CancelTaskResultSchema',
|
||||
'CancelledNotificationParamsSchema',
|
||||
'CancelledNotificationSchema',
|
||||
'ClientCapabilitiesSchema',
|
||||
'ClientNotificationSchema',
|
||||
'ClientRequestSchema',
|
||||
'ClientResultSchema',
|
||||
'CompatibilityCallToolResultSchema',
|
||||
'CompleteRequestParamsSchema',
|
||||
'CompleteRequestSchema',
|
||||
'CompleteResultSchema',
|
||||
'ContentBlockSchema',
|
||||
'CreateMessageRequestParamsSchema',
|
||||
'CreateMessageRequestSchema',
|
||||
'CreateMessageResultSchema',
|
||||
'CreateMessageResultWithToolsSchema',
|
||||
'CreateTaskResultSchema',
|
||||
'CursorSchema',
|
||||
'DiscoverRequestSchema',
|
||||
'DiscoverResultSchema',
|
||||
'ElicitRequestFormParamsSchema',
|
||||
'ElicitRequestParamsSchema',
|
||||
'ElicitRequestSchema',
|
||||
'ElicitRequestURLParamsSchema',
|
||||
'ElicitResultSchema',
|
||||
'ElicitationCompleteNotificationParamsSchema',
|
||||
'ElicitationCompleteNotificationSchema',
|
||||
'EmbeddedResourceSchema',
|
||||
'EmptyResultSchema',
|
||||
'EnumSchemaSchema',
|
||||
'GetPromptRequestParamsSchema',
|
||||
'GetPromptRequestSchema',
|
||||
'GetPromptResultSchema',
|
||||
'GetTaskPayloadRequestSchema',
|
||||
'GetTaskPayloadResultSchema',
|
||||
'GetTaskRequestSchema',
|
||||
'GetTaskResultSchema',
|
||||
'IconSchema',
|
||||
'IconsSchema',
|
||||
'ImageContentSchema',
|
||||
'ImplementationSchema',
|
||||
'InitializeRequestParamsSchema',
|
||||
'InitializeRequestSchema',
|
||||
'InitializeResultSchema',
|
||||
'InitializedNotificationSchema',
|
||||
'JSONArraySchema',
|
||||
'JSONObjectSchema',
|
||||
'JSONRPCErrorResponseSchema',
|
||||
'JSONRPCMessageSchema',
|
||||
'JSONRPCNotificationSchema',
|
||||
'JSONRPCRequestSchema',
|
||||
'JSONRPCResponseSchema',
|
||||
'JSONRPCResultResponseSchema',
|
||||
'JSONValueSchema',
|
||||
'LegacyTitledEnumSchemaSchema',
|
||||
'ListPromptsRequestSchema',
|
||||
'ListPromptsResultSchema',
|
||||
'ListResourceTemplatesRequestSchema',
|
||||
'ListResourceTemplatesResultSchema',
|
||||
'ListResourcesRequestSchema',
|
||||
'ListResourcesResultSchema',
|
||||
'ListRootsRequestSchema',
|
||||
'ListRootsResultSchema',
|
||||
'ListTasksRequestSchema',
|
||||
'ListTasksResultSchema',
|
||||
'ListToolsRequestSchema',
|
||||
'ListToolsResultSchema',
|
||||
'LoggingLevelSchema',
|
||||
'LoggingMessageNotificationParamsSchema',
|
||||
'LoggingMessageNotificationSchema',
|
||||
'ModelHintSchema',
|
||||
'ModelPreferencesSchema',
|
||||
'MultiSelectEnumSchemaSchema',
|
||||
'NotificationSchema',
|
||||
'NumberSchemaSchema',
|
||||
'PaginatedRequestParamsSchema',
|
||||
'PaginatedRequestSchema',
|
||||
'PaginatedResultSchema',
|
||||
'PingRequestSchema',
|
||||
'PrimitiveSchemaDefinitionSchema',
|
||||
'ProgressNotificationParamsSchema',
|
||||
'ProgressNotificationSchema',
|
||||
'ProgressSchema',
|
||||
'ProgressTokenSchema',
|
||||
'PromptArgumentSchema',
|
||||
'PromptListChangedNotificationSchema',
|
||||
'PromptMessageSchema',
|
||||
'PromptReferenceSchema',
|
||||
'PromptSchema',
|
||||
'ReadResourceRequestParamsSchema',
|
||||
'ReadResourceRequestSchema',
|
||||
'ReadResourceResultSchema',
|
||||
'RelatedTaskMetadataSchema',
|
||||
'RequestIdSchema',
|
||||
'RequestMetaEnvelopeSchema',
|
||||
'RequestMetaSchema',
|
||||
'RequestSchema',
|
||||
'ResourceContentsSchema',
|
||||
'ResourceLinkSchema',
|
||||
'ResourceListChangedNotificationSchema',
|
||||
'ResourceRequestParamsSchema',
|
||||
'ResourceSchema',
|
||||
'ResourceTemplateReferenceSchema',
|
||||
'ResourceTemplateSchema',
|
||||
'ResourceUpdatedNotificationParamsSchema',
|
||||
'ResourceUpdatedNotificationSchema',
|
||||
'ResultSchema',
|
||||
'RoleSchema',
|
||||
'RootSchema',
|
||||
'RootsListChangedNotificationSchema',
|
||||
'SamplingContentSchema',
|
||||
'SamplingMessageContentBlockSchema',
|
||||
'SamplingMessageSchema',
|
||||
'ServerCapabilitiesSchema',
|
||||
'ServerNotificationSchema',
|
||||
'ServerRequestSchema',
|
||||
'ServerResultSchema',
|
||||
'SetLevelRequestParamsSchema',
|
||||
'SetLevelRequestSchema',
|
||||
'SingleSelectEnumSchemaSchema',
|
||||
'StringSchemaSchema',
|
||||
'SubscribeRequestParamsSchema',
|
||||
'SubscribeRequestSchema',
|
||||
'TaskAugmentedRequestParamsSchema',
|
||||
'TaskCreationParamsSchema',
|
||||
'TaskMetadataSchema',
|
||||
'TaskSchema',
|
||||
'TaskStatusNotificationParamsSchema',
|
||||
'TaskStatusNotificationSchema',
|
||||
'TaskStatusSchema',
|
||||
'TextContentSchema',
|
||||
'TextResourceContentsSchema',
|
||||
'TitledMultiSelectEnumSchemaSchema',
|
||||
'TitledSingleSelectEnumSchemaSchema',
|
||||
'ToolAnnotationsSchema',
|
||||
'ToolChoiceSchema',
|
||||
'ToolExecutionSchema',
|
||||
'ToolListChangedNotificationSchema',
|
||||
'ToolResultContentSchema',
|
||||
'ToolSchema',
|
||||
'ToolUseContentSchema',
|
||||
'UnsubscribeRequestParamsSchema',
|
||||
'UnsubscribeRequestSchema',
|
||||
'UntitledMultiSelectEnumSchemaSchema',
|
||||
'UntitledSingleSelectEnumSchemaSchema'
|
||||
]);
|
||||
|
|
@ -4,6 +4,15 @@ export const SIMPLE_RENAMES: Record<string, string> = {
|
|||
JSONRPCErrorSchema: 'JSONRPCErrorResponseSchema',
|
||||
isJSONRPCError: 'isJSONRPCErrorResponse',
|
||||
isJSONRPCResponse: 'isJSONRPCResultResponse',
|
||||
// v1's JSONRPCResponse type / JSONRPCResponseSchema constant both validated only *result*
|
||||
// responses. v2 reuses each name for the result|error form — the type becomes
|
||||
// `Infer<z.union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema])>` and the schema the
|
||||
// matching `z.union([...])` — so a migrated `JSONRPCResponseSchema.parse(...)` (or a typed
|
||||
// `JSONRPCResponse` value) would silently widen. Rename both to the result-only equivalents to
|
||||
// preserve v1 behavior — mirroring the isJSONRPCResponse guard rename above. Both the type and the
|
||||
// schema are public in v2 (re-exported from core via @modelcontextprotocol/client | /server).
|
||||
JSONRPCResponse: 'JSONRPCResultResponse',
|
||||
JSONRPCResponseSchema: 'JSONRPCResultResponseSchema',
|
||||
ResourceReference: 'ResourceTemplateReference',
|
||||
ResourceReferenceSchema: 'ResourceTemplateReferenceSchema'
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import type { SourceFile } from 'ts-morph';
|
||||
import { SyntaxKind } from 'ts-morph';
|
||||
|
||||
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
|
||||
import { renameAllReferences } from '../../../utils/astUtils';
|
||||
import { actionRequired, info, v2Gap, warning } from '../../../utils/diagnostics';
|
||||
import type { NamedImportSpec } from '../../../utils/importUtils';
|
||||
import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils';
|
||||
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
|
||||
import { IMPORT_MAP, isAuthImport } from '../mappings/importMap';
|
||||
import { AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT } from '../mappings/authSchemaNames';
|
||||
import { isAuthImport, lookupImportMapping } from '../mappings/importMap';
|
||||
import { isSharedSchemaConst, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
|
||||
import { SIMPLE_RENAMES } from '../mappings/symbolMap';
|
||||
|
||||
const REEXPORT_WARNINGS: Record<string, string> = {
|
||||
|
|
@ -50,17 +54,29 @@ export const importPathsTransform: Transform = {
|
|||
|
||||
const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!);
|
||||
|
||||
// A leading file-header / JSDoc comment attaches to the first SDK import as leading trivia. When
|
||||
// that import is removed and re-emitted (the per-symbol split/merge path calls imp.remove()),
|
||||
// ts-morph drops the comment with it. Capture it now and restore it after emitting if it was lost.
|
||||
// Capture the EXACT source bytes spanning all leading comment ranges (first range's start to last
|
||||
// range's end) rather than re-joining each range with '\n' — a join drops the original separators
|
||||
// (a blank line, or CRLF in CRLF files), so the later survival check would never match a header
|
||||
// that actually survived (in-place setModuleSpecifier rewrite) and would re-insert it, duplicating
|
||||
// it. The slice reproduces the block verbatim, so the includes() guard below is byte-exact.
|
||||
const leadingRanges = sdkImports[0]!.getLeadingCommentRanges();
|
||||
const leadingCommentText =
|
||||
leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges[0]!.getPos(), leadingRanges.at(-1)!.getEnd()) : '';
|
||||
|
||||
interface PendingImport {
|
||||
names: string[];
|
||||
specs: NamedImportSpec[];
|
||||
isTypeOnly: boolean;
|
||||
}
|
||||
const pendingImports = new Map<string, PendingImport[]>();
|
||||
|
||||
function addPending(target: string, names: string[], isTypeOnly: boolean): void {
|
||||
function addPending(target: string, specs: NamedImportSpec[], isTypeOnly: boolean): void {
|
||||
if (!pendingImports.has(target)) {
|
||||
pendingImports.set(target, []);
|
||||
}
|
||||
pendingImports.get(target)!.push({ names, isTypeOnly });
|
||||
pendingImports.get(target)!.push({ specs, isTypeOnly });
|
||||
}
|
||||
|
||||
for (const imp of sdkImports) {
|
||||
|
|
@ -71,7 +87,7 @@ export const importPathsTransform: Transform = {
|
|||
const defaultImport = imp.getDefaultImport();
|
||||
const namespaceImport = imp.getNamespaceImport();
|
||||
|
||||
let mapping = IMPORT_MAP[specifier];
|
||||
let mapping = lookupImportMapping(specifier);
|
||||
|
||||
if (!mapping && isAuthImport(specifier)) {
|
||||
mapping = {
|
||||
|
|
@ -94,15 +110,22 @@ export const importPathsTransform: Transform = {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Resolve a RESOLVE_BY_CONTEXT mapping (sdk/types.js, sdk/shared/auth.js) only when a binding
|
||||
// actually routes to the context package. resolveTypesPackage's diagnostic sink emits a "could
|
||||
// not determine project type" warning (or, for a 'both' project, an info note), so resolving
|
||||
// eagerly would emit that note even for an import of nothing but `*Schema` constants — which
|
||||
// routes entirely to core and never uses the context package. A namespace or default
|
||||
// binding always needs context; a named symbol needs it only when it has no per-symbol override
|
||||
// (i.e. it is not a `*Schema` routed to core).
|
||||
let targetPackage = mapping.target;
|
||||
if (targetPackage === 'RESOLVE_BY_CONTEXT') {
|
||||
targetPackage = resolveTypesPackage(context, hasClientImport, hasServerImport, {
|
||||
filePath,
|
||||
line,
|
||||
diagnostics
|
||||
});
|
||||
if (mapping.subpathSuffix) {
|
||||
targetPackage = `${targetPackage}${mapping.subpathSuffix}`;
|
||||
const needsContext =
|
||||
namespaceImport != null ||
|
||||
defaultImport != null ||
|
||||
namedImports.some(n => symbolTargetOverride(n.getName(), mapping!) === undefined);
|
||||
if (needsContext) {
|
||||
const base = resolveTypesPackage(context, hasClientImport, hasServerImport, { filePath, line, diagnostics });
|
||||
targetPackage = mapping.subpathSuffix ? `${base}${mapping.subpathSuffix}` : base;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,20 +139,42 @@ export const importPathsTransform: Transform = {
|
|||
}
|
||||
}
|
||||
|
||||
const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined);
|
||||
if (defaultImport || namespaceImport || hasAlias) {
|
||||
let effectiveTarget = targetPackage;
|
||||
if (mapping.symbolTargetOverrides && !namespaceImport && !defaultImport) {
|
||||
const allOverridden = namedImports.length > 0 && namedImports.every(n => n.getName() in mapping.symbolTargetOverrides!);
|
||||
if (allOverridden) {
|
||||
effectiveTarget = mapping.symbolTargetOverrides[namedImports[0]!.getName()]!;
|
||||
} else if (namedImports.some(n => n.getName() in mapping.symbolTargetOverrides!)) {
|
||||
// A namespace import (`import * as ns from …`) cannot be split per-symbol — usages are
|
||||
// qualified (`ns.Foo`), so the whole binding moves to one package. Named imports (aliased or
|
||||
// not), including the named siblings of a default import, DO fall through to the per-symbol
|
||||
// splitter below — so an all-`*Schema` import routes entirely to core, a single aliased
|
||||
// specifier no longer forces unrelated symbols into the wrong package, and a mixed
|
||||
// `import sdk, { CallToolResultSchema }` routes the schema to core while the default
|
||||
// binding (handled at the end of the per-symbol path) moves to the context package.
|
||||
if (namespaceImport) {
|
||||
const effectiveTarget = targetPackage;
|
||||
// Any `ns.<Name>Schema` accesses would silently resolve against the wrong package (the
|
||||
// namespace can't be split), so flag them.
|
||||
if (mapping.schemaSymbolTarget) {
|
||||
const nsName = namespaceImport.getText();
|
||||
// Map each accessed v1 name to the v2 name core actually exports — some are
|
||||
// renamed (e.g. JSONRPCErrorSchema → JSONRPCErrorResponseSchema), and core only
|
||||
// exports the v2 name. Dedupe by the accessed (v1) name.
|
||||
const schemaAccesses = [
|
||||
...new Map(
|
||||
sourceFile
|
||||
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
|
||||
.filter(pa => pa.getExpression().getText() === nsName && isSharedSchemaConst(pa.getName(), mapping))
|
||||
.map(pa => [pa.getName(), resolveRenamedName(pa.getName(), mapping)] as const)
|
||||
)
|
||||
];
|
||||
if (schemaAccesses.length > 0) {
|
||||
const accessed = schemaAccesses.map(([v1]) => v1).join(', ');
|
||||
const importName = schemaAccesses[0]![1];
|
||||
const renamed = schemaAccesses.filter(([v1, v2]) => v1 !== v2);
|
||||
const renameNote =
|
||||
renamed.length > 0 ? ` Renamed in v2: ${renamed.map(([v1, v2]) => `${v1} → ${v2}`).join(', ')}.` : '';
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
imp,
|
||||
`Aliased import from ${specifier} mixes symbols that belong to different v2 packages. ` +
|
||||
`Split the import manually so each symbol targets the correct package.`
|
||||
`Namespace import of ${specifier} is used to access Zod schema(s) (${accessed}) that moved to ${mapping.schemaSymbolTarget}.${renameNote} ` +
|
||||
`Import them with a named import (e.g. \`import { ${importName} } from '${mapping.schemaSymbolTarget}'\`) and update the qualified usages.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -137,22 +182,14 @@ export const importPathsTransform: Transform = {
|
|||
usedPackages.add(effectiveTarget);
|
||||
imp.setModuleSpecifier(effectiveTarget);
|
||||
if (mapping.renamedSymbols) {
|
||||
for (const n of namedImports) {
|
||||
const newName = mapping.renamedSymbols[n.getName()];
|
||||
if (newName) {
|
||||
n.setName(newName);
|
||||
}
|
||||
}
|
||||
if (namespaceImport) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
imp,
|
||||
`Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` +
|
||||
`were renamed in ${effectiveTarget}. Update qualified accesses manually.`
|
||||
)
|
||||
);
|
||||
}
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
imp,
|
||||
`Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` +
|
||||
`were renamed in ${effectiveTarget}. Update qualified accesses manually.`
|
||||
)
|
||||
);
|
||||
}
|
||||
changesCount++;
|
||||
if (mapping.migrationHint) {
|
||||
|
|
@ -166,13 +203,40 @@ export const importPathsTransform: Transform = {
|
|||
|
||||
for (const n of namedImports) {
|
||||
const name = n.getName();
|
||||
const alias = n.getAliasNode()?.getText();
|
||||
const resolvedName = mapping.renamedSymbols?.[name] ?? name;
|
||||
const specifierTypeOnly = typeOnly || n.isTypeOnly();
|
||||
const symbolTarget = mapping.symbolTargetOverrides?.[name] ?? targetPackage;
|
||||
const symbolTarget = symbolTargetOverride(name, mapping) ?? targetPackage;
|
||||
// A v1 auth-schema constant with no public v2 home (SafeUrlSchema/OptionalSafeUrlSchema)
|
||||
// routes by context to a package that doesn't export it. Flag it so the user inlines the
|
||||
// validation instead of hitting a silent "has no exported member" error.
|
||||
if (mapping.schemaSymbolTarget && AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT.has(name)) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
imp,
|
||||
`${name} was an internal URL field-validator in v1's ${specifier} with no public v2 equivalent ` +
|
||||
`(it is not re-exported by @modelcontextprotocol/core). Remove this import and inline the ` +
|
||||
`validation (e.g. validate the URL with the WHATWG \`URL\` constructor or your own Zod schema).`
|
||||
)
|
||||
);
|
||||
}
|
||||
usedPackages.add(symbolTarget);
|
||||
addPending(symbolTarget, [resolvedName], specifierTypeOnly);
|
||||
addPending(symbolTarget, [alias ? { name: resolvedName, alias } : resolvedName], specifierTypeOnly);
|
||||
}
|
||||
if (defaultImport) {
|
||||
// The default binding can't be split per-symbol, so move it (and the module specifier) to
|
||||
// the resolved context/target package. The named siblings were just routed per-symbol
|
||||
// above, so drop them from this now default-only import.
|
||||
const effectiveTarget = targetPackage;
|
||||
usedPackages.add(effectiveTarget);
|
||||
if (namedImports.length > 0) {
|
||||
imp.removeNamedImports();
|
||||
}
|
||||
imp.setModuleSpecifier(effectiveTarget);
|
||||
} else {
|
||||
imp.remove();
|
||||
}
|
||||
imp.remove();
|
||||
changesCount++;
|
||||
if (mapping.migrationHint) {
|
||||
diagnostics.push(info(filePath, line, mapping.migrationHint));
|
||||
|
|
@ -182,28 +246,34 @@ export const importPathsTransform: Transform = {
|
|||
}
|
||||
}
|
||||
|
||||
const specLocal = (spec: NamedImportSpec): string => (typeof spec === 'string' ? spec : (spec.alias ?? spec.name));
|
||||
for (const [target, groups] of pendingImports) {
|
||||
const typeOnlyNames = new Set<string>();
|
||||
const valueNames = new Set<string>();
|
||||
// Dedupe by local binding name (alias when present), keeping the spec so aliases survive.
|
||||
const typeOnlySpecs = new Map<string, NamedImportSpec>();
|
||||
const valueSpecs = new Map<string, NamedImportSpec>();
|
||||
for (const group of groups) {
|
||||
for (const name of group.names) {
|
||||
if (group.isTypeOnly) {
|
||||
typeOnlyNames.add(name);
|
||||
} else {
|
||||
valueNames.add(name);
|
||||
}
|
||||
for (const spec of group.specs) {
|
||||
(group.isTypeOnly ? typeOnlySpecs : valueSpecs).set(specLocal(spec), spec);
|
||||
}
|
||||
}
|
||||
|
||||
if (valueNames.size > 0) {
|
||||
addOrMergeImport(sourceFile, target, [...valueNames], false, insertIndex);
|
||||
if (valueSpecs.size > 0) {
|
||||
addOrMergeImport(sourceFile, target, [...valueSpecs.values()], false, insertIndex);
|
||||
}
|
||||
if (typeOnlyNames.size > 0) {
|
||||
const typeInsertIndex = valueNames.size > 0 ? insertIndex + 1 : insertIndex;
|
||||
addOrMergeImport(sourceFile, target, [...typeOnlyNames], true, typeInsertIndex);
|
||||
if (typeOnlySpecs.size > 0) {
|
||||
const typeInsertIndex = valueSpecs.size > 0 ? insertIndex + 1 : insertIndex;
|
||||
addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the captured leading comment if the rewrite dropped it (guard against duplication when
|
||||
// the first import was rewritten in place and kept its comment).
|
||||
if (leadingCommentText && !sourceFile.getFullText().includes(leadingCommentText)) {
|
||||
const imports = sourceFile.getImportDeclarations();
|
||||
const anchor = imports[Math.min(insertIndex, imports.length - 1)];
|
||||
sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}\n`);
|
||||
}
|
||||
|
||||
return { changesCount, diagnostics, usedPackages };
|
||||
}
|
||||
};
|
||||
|
|
@ -223,7 +293,7 @@ function rewriteExportDeclarations(
|
|||
if (!specifier) continue;
|
||||
|
||||
const line = exp.getStartLineNumber();
|
||||
let mapping = IMPORT_MAP[specifier];
|
||||
let mapping = lookupImportMapping(specifier);
|
||||
|
||||
if (!mapping && isAuthImport(specifier)) {
|
||||
mapping = {
|
||||
|
|
@ -262,12 +332,30 @@ function rewriteExportDeclarations(
|
|||
}
|
||||
}
|
||||
|
||||
if (mapping.symbolTargetOverrides) {
|
||||
if (mapping.symbolTargetOverrides || mapping.schemaSymbolTarget) {
|
||||
const namedExports = exp.getNamedExports();
|
||||
const allOverridden = namedExports.length > 0 && namedExports.every(s => s.getName() in mapping.symbolTargetOverrides!);
|
||||
if (allOverridden) {
|
||||
targetPackage = mapping.symbolTargetOverrides[namedExports[0]!.getName()]!;
|
||||
} else if (namedExports.some(s => s.getName() in mapping.symbolTargetOverrides!)) {
|
||||
// A star re-export (`export * from …`, including `export * as ns from …`) has no named
|
||||
// exports to route per-symbol, so it moves wholesale to the context package — which exports
|
||||
// none of the Zod `*Schema` constants the v1 module re-exported. Downstream consumers of this
|
||||
// barrel would hit "has no exported member" with no pointer to where the schemas went, so flag
|
||||
// it (mirroring the namespace-import diagnostic on the import side).
|
||||
if (mapping.schemaSymbolTarget && namedExports.length === 0) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
exp,
|
||||
`Star re-export of ${specifier} will not include the Zod schema constants that moved to ` +
|
||||
`${mapping.schemaSymbolTarget} (they are no longer exported by ${targetPackage}). ` +
|
||||
`Add an explicit \`export { … } from '${mapping.schemaSymbolTarget}'\` for any re-exported \`*Schema\` constants.`
|
||||
)
|
||||
);
|
||||
}
|
||||
const overrides = namedExports.map(s => symbolTargetOverride(s.getName(), mapping));
|
||||
const uniqueOverrides = new Set(overrides.filter((t): t is string => t !== undefined));
|
||||
const allOverridden = namedExports.length > 0 && overrides.every(t => t !== undefined);
|
||||
if (allOverridden && uniqueOverrides.size === 1) {
|
||||
targetPackage = [...uniqueOverrides][0]!;
|
||||
} else if (uniqueOverrides.size > 0) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { mcpServerApiTransform } from './mcpServerApi';
|
|||
import { mockPathsTransform } from './mockPaths';
|
||||
import { removedApisTransform } from './removedApis';
|
||||
import { schemaParamRemovalTransform } from './schemaParamRemoval';
|
||||
import { specSchemaAccessTransform } from './specSchemaAccess';
|
||||
import { symbolRenamesTransform } from './symbolRenames';
|
||||
|
||||
// Ordering matters — do not reorder without understanding dependencies:
|
||||
|
|
@ -31,11 +30,7 @@ import { symbolRenamesTransform } from './symbolRenames';
|
|||
// 5. handlerRegistration, schemaParamRemoval, and expressMiddleware are
|
||||
// independent of each other but all depend on importPaths having run.
|
||||
//
|
||||
// 6. specSchemaAccess runs after handlerRegistration and schemaParamRemoval:
|
||||
// those transforms remove spec schema references they handle. specSchemaAccess
|
||||
// then processes remaining standalone usages (safeParse, parse, z.infer, etc.).
|
||||
//
|
||||
// 7. mockPaths runs last: handles test mocks and dynamic imports,
|
||||
// 6. mockPaths runs last: handles test mocks and dynamic imports,
|
||||
// independent of the other transforms.
|
||||
export const v1ToV2Transforms: Transform[] = [
|
||||
importPathsTransform,
|
||||
|
|
@ -44,7 +39,6 @@ export const v1ToV2Transforms: Transform[] = [
|
|||
mcpServerApiTransform,
|
||||
handlerRegistrationTransform,
|
||||
schemaParamRemovalTransform,
|
||||
specSchemaAccessTransform,
|
||||
expressMiddlewareTransform,
|
||||
contextTypesTransform,
|
||||
mockPathsTransform
|
||||
|
|
|
|||
|
|
@ -5,9 +5,29 @@ import type { Diagnostic, Transform, TransformContext, TransformResult } from '.
|
|||
import { actionRequired, v2Gap, warning } from '../../../utils/diagnostics';
|
||||
import { isSdkSpecifier } from '../../../utils/importUtils';
|
||||
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
|
||||
import { IMPORT_MAP, isAuthImport } from '../mappings/importMap';
|
||||
import type { ImportMapping } from '../mappings/importMap';
|
||||
import { isAuthImport, lookupImportMapping } from '../mappings/importMap';
|
||||
import { isSharedSchemaConst, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
|
||||
import { SIMPLE_RENAMES } from '../mappings/symbolMap';
|
||||
|
||||
/**
|
||||
* Resolve the single per-symbol target package shared by every `symbol` (mocked factory keys or
|
||||
* destructured `import()` bindings), or report that they mix v2 packages. A mock/dynamic-import
|
||||
* specifier is a single string and cannot be split, so a mix can only be flagged, not rewritten.
|
||||
* Returns `target: undefined` when no symbol carries a per-symbol override (the caller keeps the
|
||||
* mapping's resolved context/`target` package). Mirrors `symbolTargetOverride` routing used by the
|
||||
* static import/export transform so e.g. a factory of only `*Schema` constants routes to core.
|
||||
*/
|
||||
function routeSymbols(symbols: string[], mapping: ImportMapping): { target?: string; mixed: boolean } {
|
||||
if (symbols.length === 0) return { mixed: false };
|
||||
const targets = symbols.map(s => symbolTargetOverride(s, mapping));
|
||||
const overridden = targets.filter((t): t is string => t !== undefined);
|
||||
const unique = new Set(overridden);
|
||||
if (overridden.length === symbols.length && unique.size === 1) return { target: [...unique][0]!, mixed: false };
|
||||
if (unique.size > 0) return { mixed: true };
|
||||
return { mixed: false };
|
||||
}
|
||||
|
||||
const MOCK_METHODS = new Set([
|
||||
'mock',
|
||||
'doMock',
|
||||
|
|
@ -53,13 +73,14 @@ function resolveTarget(
|
|||
specifier: string,
|
||||
context: TransformContext,
|
||||
sourceFile: SourceFile,
|
||||
symbols: string[],
|
||||
diagnosticSink?: { filePath: string; line: number; diagnostics: Diagnostic[] }
|
||||
):
|
||||
| { target: string; renamedSymbols?: Record<string, string>; symbolTargetOverrides?: Record<string, string> }
|
||||
| { removed: true; isV2Gap?: boolean; removalMessage?: string }
|
||||
| null {
|
||||
const mapping = IMPORT_MAP[specifier];
|
||||
if (!mapping && isAuthImport(specifier)) return { target: '@modelcontextprotocol/server-legacy/auth' };
|
||||
): { target: string; mapping: ImportMapping } | { removed: true; isV2Gap?: boolean; removalMessage?: string } | null {
|
||||
const mapping = lookupImportMapping(specifier);
|
||||
if (!mapping && isAuthImport(specifier)) {
|
||||
const authMapping: ImportMapping = { target: '@modelcontextprotocol/server-legacy/auth', status: 'moved' };
|
||||
return { target: authMapping.target, mapping: authMapping };
|
||||
}
|
||||
if (!mapping) return null;
|
||||
if (mapping.status === 'removed') return { removed: true, isV2Gap: mapping.isV2Gap, removalMessage: mapping.removalMessage };
|
||||
|
||||
|
|
@ -73,13 +94,24 @@ function resolveTarget(
|
|||
const s = i.getModuleSpecifierValue();
|
||||
return s.includes('/server/') || s === '@modelcontextprotocol/server';
|
||||
});
|
||||
target = resolveTypesPackage(context, hasClient, hasServer, diagnosticSink);
|
||||
// Resolve lazily: only pass the diagnostic sink to resolveTypesPackage when the routed target
|
||||
// actually falls back to the context package. A factory/destructuring whose symbols all route
|
||||
// elsewhere (e.g. only `*Schema` constants → core) never uses the context package, so emitting a
|
||||
// "could not determine project type" warning (or a 'both'-project info note) for it would be
|
||||
// spurious. Mirrors the lazy `needsContext` guard in the static import transform. A
|
||||
// non-destructured/non-routable binding has no symbols, so `routeSymbols` returns no target and
|
||||
// context is (correctly) treated as needed.
|
||||
const needsContext = routeSymbols(symbols, mapping).target === undefined;
|
||||
target = resolveTypesPackage(context, hasClient, hasServer, needsContext ? diagnosticSink : undefined);
|
||||
if (mapping.subpathSuffix) {
|
||||
target = `${target}${mapping.subpathSuffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { target, renamedSymbols: mapping.renamedSymbols, symbolTargetOverrides: mapping.symbolTargetOverrides };
|
||||
// Return the original mapping (not just `renamedSymbols`/`symbolTargetOverrides`) so per-symbol
|
||||
// routing can consult `schemaSymbolTarget` via the shared `symbolTargetOverride`/`routeSymbols`,
|
||||
// matching how the static import transform routes `*Schema` constants to core.
|
||||
return { target, mapping };
|
||||
}
|
||||
|
||||
function rewriteMockCall(
|
||||
|
|
@ -98,7 +130,8 @@ function rewriteMockCall(
|
|||
const specifier = firstArg.getLiteralValue();
|
||||
if (!isSdkSpecifier(specifier)) return 0;
|
||||
|
||||
const resolved = resolveTarget(specifier, context, sourceFile, {
|
||||
const factorySymbols = args.length >= 2 ? collectFactorySymbols(args[1]!) : [];
|
||||
const resolved = resolveTarget(specifier, context, sourceFile, factorySymbols, {
|
||||
filePath: sourceFile.getFilePath(),
|
||||
line: call.getStartLineNumber(),
|
||||
diagnostics
|
||||
|
|
@ -122,13 +155,15 @@ function rewriteMockCall(
|
|||
let changes = 0;
|
||||
|
||||
let effectiveTarget = resolved.target;
|
||||
if (resolved.symbolTargetOverrides && args.length >= 2) {
|
||||
const factorySymbols = collectFactorySymbols(args[1]!);
|
||||
const allOverridden = factorySymbols.length > 0 && factorySymbols.every(s => s in resolved.symbolTargetOverrides!);
|
||||
const someOverridden = factorySymbols.some(s => s in resolved.symbolTargetOverrides!);
|
||||
if (allOverridden) {
|
||||
effectiveTarget = resolved.symbolTargetOverrides[factorySymbols[0]!]!;
|
||||
} else if (someOverridden) {
|
||||
if (args.length >= 2) {
|
||||
// Route the factory's mocked symbols the same way the static import transform would: a factory of
|
||||
// only `*Schema` constants (from sdk/types.js or sdk/shared/auth.js) moves to core; a factory
|
||||
// of only `StreamableHTTPServerTransport` moves to @modelcontextprotocol/node. A single mock path
|
||||
// can't be split, so a mix of packages is flagged for manual migration.
|
||||
const { target: routedTarget, mixed } = routeSymbols(factorySymbols, resolved.mapping);
|
||||
if (routedTarget) {
|
||||
effectiveTarget = routedTarget;
|
||||
} else if (mixed) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
|
|
@ -144,7 +179,7 @@ function rewriteMockCall(
|
|||
firstArg.setLiteralValue(effectiveTarget);
|
||||
changes++;
|
||||
|
||||
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols };
|
||||
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.mapping.renamedSymbols };
|
||||
if (args.length >= 2) {
|
||||
changes += renameSymbolsInFactory(args[1]!, allRenames);
|
||||
}
|
||||
|
|
@ -216,6 +251,106 @@ function renameSymbolsInFactory(factoryArg: import('ts-morph').Node, renamedSymb
|
|||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The object binding pattern through which a dynamic import's named symbols are pulled — either
|
||||
* `const { … } = await import('…')` or `import('…').then(({ … }) => …)`. Returns undefined for a
|
||||
* non-destructured binding (`const mod = await import()`), an identifier `.then` param (`m => …`), or
|
||||
* an unassigned `await import()`. Both destructured shapes expose named symbols that can be routed and
|
||||
* renamed per-symbol (the specifier itself can't be split).
|
||||
*/
|
||||
function getModuleBindingPattern(node: import('ts-morph').CallExpression): import('ts-morph').ObjectBindingPattern | undefined {
|
||||
const parent = node.getParent();
|
||||
if (parent && Node.isAwaitExpression(parent)) {
|
||||
const grandParent = parent.getParent();
|
||||
if (grandParent && Node.isVariableDeclaration(grandParent)) {
|
||||
const nameNode = grandParent.getNameNode();
|
||||
if (Node.isObjectBindingPattern(nameNode)) return nameNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (parent && Node.isPropertyAccessExpression(parent) && parent.getName() === 'then') {
|
||||
const thenCall = parent.getParent();
|
||||
if (thenCall && Node.isCallExpression(thenCall)) {
|
||||
const cb = thenCall.getArguments()[0];
|
||||
if (cb && (Node.isArrowFunction(cb) || Node.isFunctionExpression(cb))) {
|
||||
const paramName = cb.getParameters()[0]?.getNameNode();
|
||||
if (paramName && Node.isObjectBindingPattern(paramName)) return paramName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The destructured binding keys of a dynamic import — for both `const { … } = await import('…')` and
|
||||
* `import('…').then(({ … }) => …)` — or `[]` for a non-destructured binding, an identifier `.then`
|
||||
* param, or an unassigned `await import()`. The keys feed per-symbol routing; the specifier itself
|
||||
* can't be split.
|
||||
*/
|
||||
function getDestructuredKeys(node: import('ts-morph').CallExpression): string[] {
|
||||
const pattern = getModuleBindingPattern(node);
|
||||
if (!pattern) return [];
|
||||
return pattern.getElements().map(el => el.getPropertyNameNode()?.getText() ?? el.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* For a dynamic import whose module binding is NOT a destructurable object pattern — a non-destructured
|
||||
* `const mod = await import('…')` or a `.then(m => …)` chain — collect the Zod schema constants
|
||||
* accessed off that binding (e.g. `mod.OAuthTokensSchema`). The destructured form is routed/renamed
|
||||
* elsewhere; these forms can't be split per-symbol, so the schema accesses are surfaced as a diagnostic
|
||||
* (mirroring the namespace-import branch of the static import transform — see `importPaths.ts`). Returns
|
||||
* deduped `[v1Name, v2Name]` pairs (a schema may be renamed, e.g. JSONRPCResponseSchema →
|
||||
* JSONRPCResultResponseSchema, and core only exports the v2 name). Empty unless the mapping carries a
|
||||
* `schemaSymbolTarget`.
|
||||
*/
|
||||
function collectModuleSchemaAccesses(
|
||||
node: import('ts-morph').CallExpression,
|
||||
mapping: ImportMapping,
|
||||
sourceFile: SourceFile
|
||||
): Array<readonly [string, string]> {
|
||||
if (!mapping.schemaSymbolTarget) return [];
|
||||
|
||||
let bindingName: string | undefined;
|
||||
let scope: import('ts-morph').Node | undefined;
|
||||
|
||||
const parent = node.getParent();
|
||||
if (parent && Node.isAwaitExpression(parent)) {
|
||||
// const mod = await import('…') → `mod` is in scope for the rest of the file.
|
||||
const grandParent = parent.getParent();
|
||||
if (grandParent && Node.isVariableDeclaration(grandParent)) {
|
||||
const nameNode = grandParent.getNameNode();
|
||||
if (Node.isIdentifier(nameNode)) {
|
||||
bindingName = nameNode.getText();
|
||||
scope = sourceFile;
|
||||
}
|
||||
}
|
||||
} else if (parent && Node.isPropertyAccessExpression(parent) && parent.getName() === 'then') {
|
||||
// import('…').then(m => m.XxxSchema…) → the module is the `.then` callback's first parameter.
|
||||
const thenCall = parent.getParent();
|
||||
if (thenCall && Node.isCallExpression(thenCall)) {
|
||||
const cb = thenCall.getArguments()[0];
|
||||
if (cb && (Node.isArrowFunction(cb) || Node.isFunctionExpression(cb))) {
|
||||
const paramName = cb.getParameters()[0]?.getNameNode();
|
||||
if (paramName && Node.isIdentifier(paramName)) {
|
||||
bindingName = paramName.getText();
|
||||
scope = cb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bindingName || !scope) return [];
|
||||
|
||||
return [
|
||||
...new Map(
|
||||
scope
|
||||
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
|
||||
.filter(pa => pa.getExpression().getText() === bindingName && isSharedSchemaConst(pa.getName(), mapping))
|
||||
.map(pa => [pa.getName(), resolveRenamedName(pa.getName(), mapping)] as const)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function rewriteDynamicImports(
|
||||
sourceFile: SourceFile,
|
||||
context: TransformContext,
|
||||
|
|
@ -239,7 +374,8 @@ function rewriteDynamicImports(
|
|||
const specifier = firstArg.getLiteralValue();
|
||||
if (!isSdkSpecifier(specifier)) return;
|
||||
|
||||
const resolved = resolveTarget(specifier, context, sourceFile, {
|
||||
const destructuredKeys = getDestructuredKeys(node);
|
||||
const resolved = resolveTarget(specifier, context, sourceFile, destructuredKeys, {
|
||||
filePath: sourceFile.getFilePath(),
|
||||
line: node.getStartLineNumber(),
|
||||
diagnostics
|
||||
|
|
@ -263,59 +399,81 @@ function rewriteDynamicImports(
|
|||
}
|
||||
|
||||
let effectiveTarget = resolved.target;
|
||||
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols };
|
||||
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.mapping.renamedSymbols };
|
||||
|
||||
// Check if destructured symbols should route to an override target
|
||||
if (resolved.symbolTargetOverrides) {
|
||||
const parent = node.getParent();
|
||||
if (parent && Node.isAwaitExpression(parent)) {
|
||||
const grandParent = parent.getParent();
|
||||
if (grandParent && Node.isVariableDeclaration(grandParent)) {
|
||||
const nameNode = grandParent.getNameNode();
|
||||
if (Node.isObjectBindingPattern(nameNode)) {
|
||||
const elements = nameNode.getElements();
|
||||
const allOverridden =
|
||||
elements.length > 0 &&
|
||||
elements.every(el => {
|
||||
const key = el.getPropertyNameNode()?.getText() ?? el.getName();
|
||||
return key in resolved.symbolTargetOverrides!;
|
||||
});
|
||||
if (allOverridden) {
|
||||
effectiveTarget =
|
||||
resolved.symbolTargetOverrides[elements[0]!.getPropertyNameNode()?.getText() ?? elements[0]!.getName()]!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Route the destructured bindings the same way the static import transform would: a destructuring
|
||||
// of only `*Schema` constants (e.g. `const { CallToolResultSchema } = await import('…/types.js')`)
|
||||
// moves to core, and `StreamableHTTPServerTransport` moves to @modelcontextprotocol/node. A
|
||||
// single import() specifier can't be split, so a mix of packages is flagged for manual migration.
|
||||
const { target: routedTarget, mixed } = routeSymbols(destructuredKeys, resolved.mapping);
|
||||
if (routedTarget) {
|
||||
effectiveTarget = routedTarget;
|
||||
} else if (mixed) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
node,
|
||||
`Dynamic import of ${specifier} destructures symbols that belong to different v2 packages. ` +
|
||||
`Split the import manually so each symbol targets the correct package.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// A non-destructured binding (`const mod = await import('…')`) or a `.then(m => …)` chain can't be
|
||||
// routed per-symbol, so the specifier moves to the context package — which does NOT export the
|
||||
// Zod `*Schema` constants (those live in `schemaSymbolTarget`/core). Any `mod.<Name>Schema` /
|
||||
// `m.<Name>Schema` accesses would silently break, so flag them (mirroring the namespace-import
|
||||
// branch of the static import transform). The destructured form is handled by `routeSymbols` above.
|
||||
const schemaAccesses = collectModuleSchemaAccesses(node, resolved.mapping, sourceFile);
|
||||
if (schemaAccesses.length > 0) {
|
||||
const accessed = schemaAccesses.map(([v1]) => v1).join(', ');
|
||||
const importName = schemaAccesses[0]![1];
|
||||
const renamed = schemaAccesses.filter(([v1, v2]) => v1 !== v2);
|
||||
const renameNote = renamed.length > 0 ? ` Renamed in v2: ${renamed.map(([v1, v2]) => `${v1} → ${v2}`).join(', ')}.` : '';
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
node,
|
||||
`Dynamic import of ${specifier} is used to access Zod schema(s) (${accessed}) that moved to ${resolved.mapping.schemaSymbolTarget}.${renameNote} ` +
|
||||
`Import them with a named import (e.g. \`import { ${importName} } from '${resolved.mapping.schemaSymbolTarget}'\`) and update the qualified usages.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
usedPackages.add(effectiveTarget);
|
||||
firstArg.setLiteralValue(effectiveTarget);
|
||||
changes++;
|
||||
|
||||
const parent = node.getParent();
|
||||
if (parent && Node.isAwaitExpression(parent)) {
|
||||
const grandParent = parent.getParent();
|
||||
if (grandParent && Node.isVariableDeclaration(grandParent)) {
|
||||
const nameNode = grandParent.getNameNode();
|
||||
if (Node.isObjectBindingPattern(nameNode)) {
|
||||
for (const element of nameNode.getElements()) {
|
||||
const propertyName = element.getPropertyNameNode()?.getText();
|
||||
const bindingName = element.getName();
|
||||
const lookupKey = propertyName ?? bindingName;
|
||||
const newName = allRenames[lookupKey];
|
||||
if (newName) {
|
||||
if (propertyName) {
|
||||
element.getPropertyNameNode()!.replaceWithText(newName);
|
||||
} else {
|
||||
element.replaceWithText(`${newName}: ${bindingName}`);
|
||||
}
|
||||
changes++;
|
||||
}
|
||||
// Apply symbol renames to the destructured binding elements — for both `await import()`
|
||||
// destructuring and a `.then(({ … }) => …)` param (both routed per-symbol above when their
|
||||
// symbols share a target, e.g. schema-only → core).
|
||||
const bindingPattern = getModuleBindingPattern(node);
|
||||
if (bindingPattern) {
|
||||
for (const element of bindingPattern.getElements()) {
|
||||
const propertyName = element.getPropertyNameNode()?.getText();
|
||||
const bindingName = element.getName();
|
||||
const lookupKey = propertyName ?? bindingName;
|
||||
const newName = allRenames[lookupKey];
|
||||
if (newName) {
|
||||
if (propertyName) {
|
||||
element.getPropertyNameNode()!.replaceWithText(newName);
|
||||
} else {
|
||||
element.replaceWithText(`${newName}: ${bindingName}`);
|
||||
}
|
||||
changes++;
|
||||
}
|
||||
const moduleRenames = resolved.renamedSymbols ?? {};
|
||||
if (!Node.isObjectBindingPattern(nameNode) && Object.keys(moduleRenames).length > 0) {
|
||||
}
|
||||
}
|
||||
|
||||
// A non-destructured awaited binding (`const mod = await import('…')`) can't have per-symbol
|
||||
// renames applied, so flag them if the mapping carries any. (Identifier `.then` params and bare
|
||||
// `mod.<Name>Schema` accesses are surfaced by `collectModuleSchemaAccesses` above.)
|
||||
const awaitParent = node.getParent();
|
||||
if (awaitParent && Node.isAwaitExpression(awaitParent)) {
|
||||
const decl = awaitParent.getParent();
|
||||
if (decl && Node.isVariableDeclaration(decl) && !Node.isObjectBindingPattern(decl.getNameNode())) {
|
||||
const moduleRenames = resolved.mapping.renamedSymbols ?? {};
|
||||
if (Object.keys(moduleRenames).length > 0) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { SourceFile } from 'ts-morph';
|
|||
import { Node, SyntaxKind } from 'ts-morph';
|
||||
|
||||
import type { Transform, TransformContext, TransformResult } from '../../../types';
|
||||
import { isImportedFromMcp, removeUnusedImport, resolveOriginalImportName } from '../../../utils/importUtils';
|
||||
import { hasMcpImports, isImportedFromMcp, removeUnusedImport, resolveOriginalImportName } from '../../../utils/importUtils';
|
||||
|
||||
const TARGET_METHODS = new Set(['request', 'callTool']);
|
||||
|
||||
|
|
@ -12,6 +12,11 @@ export const schemaParamRemovalTransform: Transform = {
|
|||
apply(sourceFile: SourceFile, _context: TransformContext): TransformResult {
|
||||
let changesCount = 0;
|
||||
|
||||
// `request`/`callTool` are common method names on non-MCP receivers too. The schema-identifier
|
||||
// path guards per-symbol via `isImportedFromMcp`; the `undefined` path has no symbol to check, so
|
||||
// gate it on a file-level MCP signal to avoid rewriting unrelated calls.
|
||||
const fileHasMcpImports = hasMcpImports(sourceFile);
|
||||
|
||||
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
|
||||
|
||||
for (const call of calls) {
|
||||
|
|
@ -27,6 +32,19 @@ export const schemaParamRemovalTransform: Transform = {
|
|||
const secondArg = args[1]!;
|
||||
if (!Node.isIdentifier(secondArg)) continue;
|
||||
|
||||
// `request(req, undefined, options)` / `callTool(params, undefined, options)`: v1 passed an
|
||||
// explicit `undefined` result schema before the trailing options argument. v2 removed the
|
||||
// schema parameter for spec methods, so the literal `undefined` leaves the call with one
|
||||
// argument too many (TS2554). Drop it only when a third argument follows — a 2-arg
|
||||
// `callTool(params, undefined)` already type-checks, since `undefined` is a valid options arg.
|
||||
if (secondArg.getText() === 'undefined') {
|
||||
if (fileHasMcpImports && args.length >= 3) {
|
||||
call.removeArgument(1);
|
||||
changesCount++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const schemaName = secondArg.getText();
|
||||
const originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName;
|
||||
if (!originalName.endsWith('Schema')) continue;
|
||||
|
|
|
|||
|
|
@ -1,392 +0,0 @@
|
|||
import type { SourceFile } from 'ts-morph';
|
||||
import { Node, SyntaxKind } from 'ts-morph';
|
||||
|
||||
import { SPEC_SCHEMA_NAMES, specSchemaToTypeName } from '../../../generated/specSchemaMap';
|
||||
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
|
||||
import { isKeyPositionIdentifier } from '../../../utils/astUtils';
|
||||
import { actionRequired, warning } from '../../../utils/diagnostics';
|
||||
import { addOrMergeImport, isAnyMcpSpecifier, removeUnusedImport } from '../../../utils/importUtils';
|
||||
|
||||
export const specSchemaAccessTransform: Transform = {
|
||||
name: 'Spec schema standalone usage',
|
||||
id: 'spec-schemas',
|
||||
apply(sourceFile: SourceFile, _context: TransformContext): TransformResult {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
let changesCount = 0;
|
||||
|
||||
const schemaImports = collectSpecSchemaImports(sourceFile);
|
||||
if (schemaImports.size === 0) return { changesCount: 0, diagnostics: [] };
|
||||
|
||||
for (const [localName, originalName] of schemaImports) {
|
||||
const typeName = specSchemaToTypeName(originalName);
|
||||
if (!typeName) continue;
|
||||
|
||||
const refs = findNonImportReferences(sourceFile, localName);
|
||||
if (refs.length === 0) continue;
|
||||
|
||||
for (const ref of refs) {
|
||||
const result = handleReference(ref, localName, typeName, sourceFile, diagnostics);
|
||||
if (result) changesCount++;
|
||||
}
|
||||
removeUnusedImport(sourceFile, localName, true);
|
||||
}
|
||||
|
||||
return { changesCount, diagnostics };
|
||||
}
|
||||
};
|
||||
|
||||
function collectSpecSchemaImports(sourceFile: SourceFile): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const imp of sourceFile.getImportDeclarations()) {
|
||||
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue;
|
||||
for (const n of imp.getNamedImports()) {
|
||||
const exportName = n.getName();
|
||||
if (!SPEC_SCHEMA_NAMES.has(exportName)) continue;
|
||||
const localName = n.getAliasNode()?.getText() ?? exportName;
|
||||
result.set(localName, exportName);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findNonImportReferences(sourceFile: SourceFile, localName: string): import('ts-morph').Node[] {
|
||||
const refs: import('ts-morph').Node[] = [];
|
||||
sourceFile.forEachDescendant(node => {
|
||||
if (!Node.isIdentifier(node)) return;
|
||||
if (node.getText() !== localName) return;
|
||||
const parent = node.getParent();
|
||||
if (parent && Node.isImportSpecifier(parent)) return;
|
||||
refs.push(node);
|
||||
});
|
||||
return refs;
|
||||
}
|
||||
|
||||
function handleReference(
|
||||
ref: import('ts-morph').Node,
|
||||
localName: string,
|
||||
typeName: string,
|
||||
sourceFile: SourceFile,
|
||||
diagnostics: Diagnostic[]
|
||||
): boolean {
|
||||
// Pattern: z.infer<typeof XSchema> — type position
|
||||
if (isTypeofInTypePosition(ref)) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
ref,
|
||||
`Replace \`z.infer<typeof ${localName}>\` with the \`${typeName}\` type (already exported from the same v2 package).`
|
||||
)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pattern: XSchema.safeParse(v).success — auto-transform to isSpecType.X(v)
|
||||
if (isSafeParseSuccessPattern(ref)) {
|
||||
const safeParseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression;
|
||||
const safeParseCall = safeParseAccess.getParent() as import('ts-morph').CallExpression;
|
||||
const successAccess = safeParseCall.getParent() as import('ts-morph').PropertyAccessExpression;
|
||||
const args = safeParseCall.getArguments();
|
||||
const argText = args.length > 0 ? args[0]!.getText() : '';
|
||||
successAccess.replaceWithText(`isSpecType.${typeName}(${argText})`);
|
||||
ensureImport(sourceFile, 'isSpecType');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Pattern: const x = XSchema.safeParse(v) — auto-transform when result is captured in a variable
|
||||
if (isSafeParsePattern(ref)) {
|
||||
const safeParseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression;
|
||||
const safeParseCall = safeParseAccess.getParent() as import('ts-morph').CallExpression;
|
||||
|
||||
if (isCapturedSafeParsePattern(safeParseCall)) {
|
||||
return rewriteCapturedSafeParse(safeParseCall, localName, typeName, sourceFile, diagnostics);
|
||||
}
|
||||
|
||||
return rewriteUnsupportedSchemaCall(ref, safeParseCall, localName, typeName, 'safeParse', sourceFile, diagnostics);
|
||||
}
|
||||
|
||||
// Pattern: XSchema.parse(v) — rewrite to the StandardSchema validate() primitive (or, when the
|
||||
// result is used, swap the identifier) so we never leave behind an import of a non-exported schema.
|
||||
if (isParsePattern(ref)) {
|
||||
const parseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression;
|
||||
const parseCall = parseAccess.getParent() as import('ts-morph').CallExpression;
|
||||
return rewriteUnsupportedSchemaCall(ref, parseCall, localName, typeName, 'parse', sourceFile, diagnostics);
|
||||
}
|
||||
|
||||
// Pattern: XSchema used as value (function arg, assignment, etc.)
|
||||
const parent = ref.getParent();
|
||||
if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) {
|
||||
const line = ref.getStartLineNumber();
|
||||
ref.replaceWithText(`specTypeSchemas.${typeName}`);
|
||||
ensureImport(sourceFile, 'specTypeSchemas');
|
||||
diagnostics.push(
|
||||
warning(
|
||||
sourceFile.getFilePath(),
|
||||
line,
|
||||
`Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse()/.parseAsync() are not available. Manual rewrite required.`
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parent && Node.isExportSpecifier(parent)) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
ref,
|
||||
`Re-export of ${localName} requires manual update: replace with specTypeSchemas.${typeName} or remove.`
|
||||
)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parent && Node.isShorthandPropertyAssignment(parent)) {
|
||||
const line = ref.getStartLineNumber();
|
||||
parent.replaceWithText(`'${localName}': specTypeSchemas.${typeName}`);
|
||||
ensureImport(sourceFile, 'specTypeSchemas');
|
||||
diagnostics.push(
|
||||
warning(
|
||||
sourceFile.getFilePath(),
|
||||
line,
|
||||
`Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.`
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parent && isKeyPositionIdentifier(ref)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Value position: replace identifier with specTypeSchemas.X
|
||||
const line = ref.getStartLineNumber();
|
||||
ref.replaceWithText(`specTypeSchemas.${typeName}`);
|
||||
ensureImport(sourceFile, 'specTypeSchemas');
|
||||
diagnostics.push(
|
||||
warning(
|
||||
sourceFile.getFilePath(),
|
||||
line,
|
||||
`Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.`
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSafeParseSuccessPattern(ref: import('ts-morph').Node): boolean {
|
||||
const parent = ref.getParent();
|
||||
if (!parent || !Node.isPropertyAccessExpression(parent)) return false;
|
||||
if (parent.getName() !== 'safeParse' || parent.getExpression() !== ref) return false;
|
||||
const grandParent = parent.getParent();
|
||||
if (!grandParent || !Node.isCallExpression(grandParent)) return false;
|
||||
const greatGrandParent = grandParent.getParent();
|
||||
if (!greatGrandParent || !Node.isPropertyAccessExpression(greatGrandParent)) return false;
|
||||
return greatGrandParent.getName() === 'success';
|
||||
}
|
||||
|
||||
function isSafeParsePattern(ref: import('ts-morph').Node): boolean {
|
||||
const parent = ref.getParent();
|
||||
if (!parent || !Node.isPropertyAccessExpression(parent)) return false;
|
||||
if (parent.getName() !== 'safeParse' || parent.getExpression() !== ref) return false;
|
||||
const grandParent = parent.getParent();
|
||||
return !!grandParent && Node.isCallExpression(grandParent);
|
||||
}
|
||||
|
||||
function isParsePattern(ref: import('ts-morph').Node): boolean {
|
||||
const parent = ref.getParent();
|
||||
if (!parent || !Node.isPropertyAccessExpression(parent)) return false;
|
||||
if (parent.getName() !== 'parse' || parent.getExpression() !== ref) return false;
|
||||
const grandParent = parent.getParent();
|
||||
return !!grandParent && Node.isCallExpression(grandParent);
|
||||
}
|
||||
|
||||
function isTypeofInTypePosition(ref: import('ts-morph').Node): boolean {
|
||||
const parent = ref.getParent();
|
||||
if (!parent) return false;
|
||||
return Node.isTypeQuery(parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a safeParse call result is captured in a `const` variable declaration.
|
||||
* Pattern: `const x = Schema.safeParse(v);`
|
||||
*/
|
||||
function isCapturedSafeParsePattern(safeParseCall: import('ts-morph').CallExpression): boolean {
|
||||
const parent = safeParseCall.getParent();
|
||||
if (!parent || !Node.isVariableDeclaration(parent)) return false;
|
||||
const nameNode = parent.getNameNode();
|
||||
if (!Node.isIdentifier(nameNode)) return false;
|
||||
const declList = parent.getParent();
|
||||
if (!declList || !Node.isVariableDeclarationList(declList)) return false;
|
||||
const flags = declList.getDeclarationKind();
|
||||
return flags === 'const' || flags === 'let';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a captured safeParse pattern:
|
||||
* const x = Schema.safeParse(v) → const x = specTypeSchemas.T['~standard'].validate(v)
|
||||
* x.success → x.issues === undefined
|
||||
* x.data → x.value
|
||||
* x.error → x.issues
|
||||
*/
|
||||
function rewriteCapturedSafeParse(
|
||||
safeParseCall: import('ts-morph').CallExpression,
|
||||
localName: string,
|
||||
typeName: string,
|
||||
sourceFile: SourceFile,
|
||||
diagnostics: Diagnostic[]
|
||||
): boolean {
|
||||
const varDecl = safeParseCall.getParent() as import('ts-morph').VariableDeclaration;
|
||||
const varName = varDecl.getName();
|
||||
|
||||
const args = safeParseCall.getArguments();
|
||||
const argText = args.length > 0 ? args[0]!.getText() : '';
|
||||
|
||||
// Rewrite the safeParse call
|
||||
safeParseCall.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`);
|
||||
ensureImport(sourceFile, 'specTypeSchemas');
|
||||
|
||||
// Find and rewrite all property accesses on the result variable (scoped to declaring block)
|
||||
const replacements: { node: import('ts-morph').Node; newText: string }[] = [];
|
||||
const scope = varDecl.getFirstAncestorByKind(SyntaxKind.Block) ?? sourceFile;
|
||||
scope.forEachDescendant(node => {
|
||||
if (!Node.isPropertyAccessExpression(node)) return;
|
||||
const expr = node.getExpression();
|
||||
if (!Node.isIdentifier(expr) || expr.getText() !== varName) return;
|
||||
|
||||
const propName = node.getName();
|
||||
switch (propName) {
|
||||
case 'success': {
|
||||
// Check for !x.success → x.issues !== undefined
|
||||
const parentNode = node.getParent();
|
||||
if (
|
||||
parentNode &&
|
||||
Node.isPrefixUnaryExpression(parentNode) &&
|
||||
parentNode.getOperatorToken() === SyntaxKind.ExclamationToken
|
||||
) {
|
||||
replacements.push({ node: parentNode, newText: `${varName}.issues !== undefined` });
|
||||
} else {
|
||||
replacements.push({ node, newText: `(${varName}.issues === undefined)` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'data': {
|
||||
replacements.push({ node, newText: `${varName}.value` });
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
const errorParent = node.getParent();
|
||||
if (errorParent && Node.isPropertyAccessExpression(errorParent) && errorParent.getExpression() === node) {
|
||||
const subProp = errorParent.getName();
|
||||
if (subProp === 'issues') {
|
||||
replacements.push({ node: errorParent, newText: `${varName}.issues` });
|
||||
} else if (subProp === 'message') {
|
||||
replacements.push({ node: errorParent, newText: `${varName}.issues?.map(i => i.message).join(', ')` });
|
||||
} else {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
errorParent,
|
||||
`${varName}.error.${subProp} has no StandardSchema equivalent. Manual migration required.`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
replacements.push({ node, newText: `${varName}.issues` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply in reverse order to avoid position shifts
|
||||
const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart());
|
||||
for (const { node, newText } of sorted) {
|
||||
node.replaceWithText(newText);
|
||||
}
|
||||
|
||||
diagnostics.push(
|
||||
warning(
|
||||
sourceFile.getFilePath(),
|
||||
varDecl.getStartLineNumber(),
|
||||
`Rewrote ${localName}.safeParse() to specTypeSchemas.${typeName}['~standard'].validate(). ` +
|
||||
`Result properties remapped: .success → .issues === undefined, .data → .value, .error → .issues.`
|
||||
)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles spec-schema usages that have no behavior-preserving v2 equivalent: the Zod-only
|
||||
* methods `.parse()` and (uncaptured) `.safeParse()`. In v2 these schemas are StandardSchemaV1
|
||||
* values that are NOT named public exports, so leaving the original import in place produces an
|
||||
* unresolved-import error (e.g. `PromptSchema` is not exported by `@modelcontextprotocol/server`).
|
||||
*
|
||||
* - Result discarded (validation for side-effect only): rewrite `XSchema.parse(v)` →
|
||||
* `specTypeSchemas.T['~standard'].validate(v)` so the code compiles. NOTE: `validate()` does not
|
||||
* throw, so `.parse()`'s throw-on-invalid behavior is lost — flagged via an actionRequired comment.
|
||||
* - Result used: swap only the identifier to `specTypeSchemas.T` so the import resolves; the
|
||||
* `.parse()`/`.safeParse()` call and its result shape still need a manual fix (flagged).
|
||||
*
|
||||
* Either way the original (now non-exported) schema import is dropped by the caller's
|
||||
* removeUnusedImport, so no dangling import survives.
|
||||
*/
|
||||
function rewriteUnsupportedSchemaCall(
|
||||
ref: import('ts-morph').Node,
|
||||
callNode: import('ts-morph').CallExpression,
|
||||
localName: string,
|
||||
typeName: string,
|
||||
method: 'parse' | 'safeParse',
|
||||
sourceFile: SourceFile,
|
||||
diagnostics: Diagnostic[]
|
||||
): boolean {
|
||||
const resultDiscarded = Node.isExpressionStatement(callNode.getParent());
|
||||
|
||||
if (resultDiscarded) {
|
||||
const argText = callNode
|
||||
.getArguments()
|
||||
.map(a => a.getText())
|
||||
.join(', ');
|
||||
const semantics =
|
||||
method === 'parse'
|
||||
? 'validate() does NOT throw on invalid input (parse() did) — if you relied on that, add `if (result.issues) throw …`.'
|
||||
: 'the result shape changed from { success, data, error } to { value, issues }.';
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
callNode,
|
||||
`Rewrote ${localName}.${method}() to specTypeSchemas.${typeName}['~standard'].validate(): ` +
|
||||
`v2 spec schemas are StandardSchemaV1, not Zod. Note: ${semantics}`
|
||||
)
|
||||
);
|
||||
callNode.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`);
|
||||
ensureImport(sourceFile, 'specTypeSchemas');
|
||||
return true;
|
||||
}
|
||||
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
ref,
|
||||
`${localName}.${method}() is not available on v2 spec schemas (StandardSchemaV1, not Zod). ` +
|
||||
`Replaced ${localName} with specTypeSchemas.${typeName}; rewrite the .${method}(...) call using ` +
|
||||
`specTypeSchemas.${typeName}['~standard'].validate(...) (returns { value, issues }, does not throw).`
|
||||
)
|
||||
);
|
||||
ref.replaceWithText(`specTypeSchemas.${typeName}`);
|
||||
ensureImport(sourceFile, 'specTypeSchemas');
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureImport(sourceFile: SourceFile, symbol: string): void {
|
||||
const existingImport = sourceFile.getImportDeclarations().find(imp => {
|
||||
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;
|
||||
return imp.getNamedImports().some(n => n.getName() === symbol);
|
||||
});
|
||||
if (existingImport) return;
|
||||
|
||||
const targetPkg = sourceFile.getImportDeclarations().find(imp => {
|
||||
const spec = imp.getModuleSpecifierValue();
|
||||
return spec === '@modelcontextprotocol/server' || spec === '@modelcontextprotocol/client';
|
||||
});
|
||||
const target = targetPkg?.getModuleSpecifierValue() ?? '@modelcontextprotocol/server';
|
||||
addOrMergeImport(sourceFile, target, [symbol], false, sourceFile.getImportDeclarations().length);
|
||||
}
|
||||
|
|
@ -143,7 +143,7 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
const fileDiagnostics: Diagnostic[] = [];
|
||||
const originalText = sourceFile.getFullText();
|
||||
|
||||
const fileUsedPackages = new Set<string>();
|
||||
const fileClaimedPackages = new Set<string>();
|
||||
try {
|
||||
for (const transform of enabledTransforms) {
|
||||
const result = transform.apply(sourceFile, context);
|
||||
|
|
@ -151,12 +151,25 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
fileDiagnostics.push(...result.diagnostics);
|
||||
if (result.usedPackages) {
|
||||
for (const pkg of result.usedPackages) {
|
||||
fileUsedPackages.add(pkg);
|
||||
fileClaimedPackages.add(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const pkg of fileUsedPackages) {
|
||||
allUsedPackages.add(pkg);
|
||||
// A transform records a package as "used" when it routes a binding there — but importPaths does
|
||||
// so the moment it rewrites an import, and later transforms (handlerRegistration,
|
||||
// schemaParamRemoval) routinely rewrite the schema usage away and delete that very import.
|
||||
// Honouring a claim whose import did not survive would add an unused dependency to package.json,
|
||||
// so a claim counts only when the FINAL file still references the specifier. Every claim
|
||||
// originates from a string literal the transform wrote (an import/export module specifier, or a
|
||||
// vi.mock()/dynamic import() argument), so a surviving string-literal match is the ground truth.
|
||||
const survivingSpecifiers = new Set<string>();
|
||||
for (const literal of sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
|
||||
survivingSpecifiers.add(literal.getLiteralValue());
|
||||
}
|
||||
for (const pkg of fileClaimedPackages) {
|
||||
if (survivingSpecifiers.has(pkg)) {
|
||||
allUsedPackages.add(pkg);
|
||||
}
|
||||
}
|
||||
} catch (error_) {
|
||||
const filePath = sourceFile.getFilePath();
|
||||
|
|
@ -164,7 +177,7 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
fileDiagnostics.push(error(filePath, 1, `Transform failed: ${error_ instanceof Error ? error_.message : String(error_)}`));
|
||||
sourceFile.replaceWithText(originalText);
|
||||
fileChanges = 0;
|
||||
fileUsedPackages.clear();
|
||||
fileClaimedPackages.clear();
|
||||
}
|
||||
|
||||
for (const d of fileDiagnostics) {
|
||||
|
|
|
|||
126
packages/codemod/src/utils/detectFormatter.ts
Normal file
126
packages/codemod/src/utils/detectFormatter.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/** A code formatter the codemod can recommend running after a migration. */
|
||||
export interface DetectedFormatter {
|
||||
/** Display name, e.g. `Prettier`. */
|
||||
name: string;
|
||||
/** Executable name, e.g. `prettier`. */
|
||||
bin: string;
|
||||
/** Arguments that write formatting in place; changed file paths are appended after these. */
|
||||
writeArgs: readonly string[];
|
||||
}
|
||||
|
||||
const BIOME_CONFIG_FILES = ['biome.json', 'biome.jsonc'];
|
||||
const PRETTIER_CONFIG_FILES = [
|
||||
'.prettierrc',
|
||||
'.prettierrc.json',
|
||||
'.prettierrc.json5',
|
||||
'.prettierrc.yaml',
|
||||
'.prettierrc.yml',
|
||||
'.prettierrc.toml',
|
||||
'.prettierrc.js',
|
||||
'.prettierrc.cjs',
|
||||
'.prettierrc.mjs',
|
||||
'.prettierrc.ts',
|
||||
'prettier.config.js',
|
||||
'prettier.config.cjs',
|
||||
'prettier.config.mjs',
|
||||
'prettier.config.ts'
|
||||
];
|
||||
const ESLINT_CONFIG_FILES = [
|
||||
'eslint.config.js',
|
||||
'eslint.config.mjs',
|
||||
'eslint.config.cjs',
|
||||
'eslint.config.ts',
|
||||
'eslint.config.mts',
|
||||
'eslint.config.cts',
|
||||
'.eslintrc',
|
||||
'.eslintrc.js',
|
||||
'.eslintrc.cjs',
|
||||
'.eslintrc.json',
|
||||
'.eslintrc.yml',
|
||||
'.eslintrc.yaml'
|
||||
];
|
||||
|
||||
// Precedence order: a configured dedicated formatter wins over ESLint's --fix.
|
||||
const FORMATTERS = {
|
||||
biome: { name: 'Biome', bin: 'biome', writeArgs: ['format', '--write'] },
|
||||
prettier: { name: 'Prettier', bin: 'prettier', writeArgs: ['--write'] },
|
||||
eslint: { name: 'ESLint', bin: 'eslint', writeArgs: ['--fix'] }
|
||||
} as const satisfies Record<string, DetectedFormatter>;
|
||||
|
||||
function hasAnyFile(dir: string, files: readonly string[]): boolean {
|
||||
return files.some(file => existsSync(path.join(dir, file)));
|
||||
}
|
||||
|
||||
interface PackageJsonSignals {
|
||||
prettier: boolean;
|
||||
eslint: boolean;
|
||||
}
|
||||
|
||||
function readPackageJsonSignals(dir: string): PackageJsonSignals {
|
||||
const pkgJsonPath = path.join(dir, 'package.json');
|
||||
if (!existsSync(pkgJsonPath)) return { prettier: false, eslint: false };
|
||||
try {
|
||||
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as Record<string, unknown>;
|
||||
const allDeps = {
|
||||
...(pkgJson.dependencies as Record<string, string> | undefined),
|
||||
...(pkgJson.devDependencies as Record<string, string> | undefined)
|
||||
};
|
||||
return {
|
||||
prettier: 'prettier' in pkgJson || 'prettier' in allDeps,
|
||||
eslint: 'eslint' in allDeps
|
||||
};
|
||||
} catch {
|
||||
return { prettier: false, eslint: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks up from `startDir` looking for a configured code formatter, so the CLI can suggest the right
|
||||
* "format your changed files" command after a migration.
|
||||
*
|
||||
* The walk is bounded so a user-level global config is never mistaken for the project's. It stops at the
|
||||
* repository root (a `.git` directory) or the filesystem root, and — for a project that is not a git
|
||||
* checkout (tarball, fresh scaffold, CI workspace) — never ascends into or above `$HOME`, so a
|
||||
* `~/.prettierrc`, `~/biome.json`, or `~/package.json` with formatter deps is never matched. (A `.git`
|
||||
* boundary alone did not hold this guarantee for non-git projects, which would otherwise walk to `$HOME`.)
|
||||
*
|
||||
* Detection is config-based and runs nothing. When multiple formatters are configured, precedence is
|
||||
* Biome > Prettier > ESLint.
|
||||
*
|
||||
* @param startDir the directory to start the upward search from.
|
||||
* @param homeDir the user's home directory; the walk never reads it or any ancestor. Injectable for tests;
|
||||
* defaults to `os.homedir()`.
|
||||
* @returns the detected formatter, or `null` if none is configured.
|
||||
*/
|
||||
export function detectFormatter(startDir: string, homeDir: string = os.homedir()): DetectedFormatter | null {
|
||||
let dir = path.resolve(startDir);
|
||||
const root = path.parse(dir).root;
|
||||
const home = path.resolve(homeDir);
|
||||
const found = { biome: false, prettier: false, eslint: false };
|
||||
|
||||
while (true) {
|
||||
if (hasAnyFile(dir, BIOME_CONFIG_FILES)) found.biome = true;
|
||||
if (hasAnyFile(dir, PRETTIER_CONFIG_FILES)) found.prettier = true;
|
||||
if (hasAnyFile(dir, ESLINT_CONFIG_FILES)) found.eslint = true;
|
||||
|
||||
const signals = readPackageJsonSignals(dir);
|
||||
if (signals.prettier) found.prettier = true;
|
||||
if (signals.eslint) found.eslint = true;
|
||||
|
||||
// Stop at the repository root (a `.git` dir) or the filesystem root. For a project that is not a
|
||||
// git checkout (tarball, fresh scaffold, CI workspace), also stop before ascending into `$HOME`:
|
||||
// the project is a descendant of `$HOME`, so a user-level `~/.prettierrc`, `~/biome.json`, or
|
||||
// `~/package.json` with formatter deps must never be read as the project's own config.
|
||||
if (existsSync(path.join(dir, '.git')) || dir === root || dir === home || path.dirname(dir) === home) break;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
if (found.biome) return FORMATTERS.biome;
|
||||
if (found.prettier) return FORMATTERS.prettier;
|
||||
if (found.eslint) return FORMATTERS.eslint;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ const SDK_PREFIX = '@modelcontextprotocol/sdk';
|
|||
const V2_PACKAGES = new Set([
|
||||
'@modelcontextprotocol/client',
|
||||
'@modelcontextprotocol/server',
|
||||
'@modelcontextprotocol/core-internal',
|
||||
'@modelcontextprotocol/core',
|
||||
'@modelcontextprotocol/node',
|
||||
'@modelcontextprotocol/express'
|
||||
|
|
@ -32,31 +33,52 @@ export function isTypeOnlyImport(imp: ImportDeclaration): boolean {
|
|||
return imp.isTypeOnly();
|
||||
}
|
||||
|
||||
/** A named import to emit: either a bare name, or a `{ name, alias }` pair preserving an `as` alias. */
|
||||
export type NamedImportSpec = string | { name: string; alias?: string };
|
||||
|
||||
function toSpec(n: NamedImportSpec): { name: string; alias?: string } {
|
||||
return typeof n === 'string' ? { name: n } : n;
|
||||
}
|
||||
|
||||
/** Local binding a spec introduces — the alias when present, otherwise the imported name. */
|
||||
function specLocalName(s: { name: string; alias?: string }): string {
|
||||
return s.alias ?? s.name;
|
||||
}
|
||||
|
||||
export function addOrMergeImport(
|
||||
sourceFile: SourceFile,
|
||||
moduleSpecifier: string,
|
||||
namedImports: string[],
|
||||
namedImports: NamedImportSpec[],
|
||||
isTypeOnly: boolean,
|
||||
insertIndex: number
|
||||
): void {
|
||||
if (namedImports.length === 0) return;
|
||||
|
||||
const specs = namedImports.map(n => toSpec(n));
|
||||
|
||||
const existing = sourceFile.getImportDeclarations().find(imp => {
|
||||
if (imp.getNamespaceImport()) return false;
|
||||
return imp.getModuleSpecifierValue() === moduleSpecifier && imp.isTypeOnly() === isTypeOnly;
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const existingNames = new Set(existing.getNamedImports().map(n => n.getName()));
|
||||
const newNames = namedImports.filter(n => !existingNames.has(n));
|
||||
if (newNames.length > 0) {
|
||||
existing.addNamedImports(newNames);
|
||||
const existingLocals = new Set(existing.getNamedImports().map(n => n.getAliasNode()?.getText() ?? n.getName()));
|
||||
const newSpecs = specs.filter(s => !existingLocals.has(specLocalName(s)));
|
||||
if (newSpecs.length > 0) {
|
||||
existing.addNamedImports(newSpecs.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })));
|
||||
}
|
||||
} else {
|
||||
const seen = new Set<string>();
|
||||
const deduped = specs.filter(s => {
|
||||
const local = specLocalName(s);
|
||||
if (seen.has(local)) return false;
|
||||
seen.add(local);
|
||||
return true;
|
||||
});
|
||||
const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length);
|
||||
sourceFile.insertImportDeclaration(clampedIndex, {
|
||||
moduleSpecifier,
|
||||
namedImports: [...new Set(namedImports)],
|
||||
namedImports: deduped.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })),
|
||||
isTypeOnly
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { PackageJsonChange } from '../types';
|
|||
import { findPackageJson } from './projectAnalyzer';
|
||||
|
||||
const V1_PACKAGE = '@modelcontextprotocol/sdk';
|
||||
const PRIVATE_PACKAGES = new Set(['@modelcontextprotocol/core']);
|
||||
const PRIVATE_PACKAGES = new Set(['@modelcontextprotocol/core-internal']);
|
||||
|
||||
function normalizeToRoot(pkg: string): string {
|
||||
const secondSlash = pkg.indexOf('/', pkg.indexOf('/') + 1);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Diagnostic, TransformContext } from '../types';
|
||||
import { warning } from './diagnostics';
|
||||
import { info, warning } from './diagnostics';
|
||||
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'node_modules'];
|
||||
|
||||
const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
|
||||
const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'build', '.next', '.nuxt', 'coverage']);
|
||||
const SCAN_FILE_BUDGET = 5000;
|
||||
|
||||
// Matches a quoted v1 SDK client/server subpath import specifier — e.g.
|
||||
// '@modelcontextprotocol/sdk/client/index.js' "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
// '@modelcontextprotocol/sdk/client' (extensionless / bare subpath; see the extensionless
|
||||
// import matching the codemod already supports)
|
||||
// Anchored to the opening quote and a trailing `/` or closing quote so that comments or prose that
|
||||
// merely mention the path do not count, and `…/client` is not confused with `…/clientfoo`.
|
||||
const CLIENT_IMPORT_RE = /['"`]@modelcontextprotocol\/sdk\/client(?:\/|['"`])/;
|
||||
const SERVER_IMPORT_RE = /['"`]@modelcontextprotocol\/sdk\/server(?:\/|['"`])/;
|
||||
|
||||
export function findPackageJson(startDir: string): string | undefined {
|
||||
let dir = path.resolve(startDir);
|
||||
const root = path.parse(dir).root;
|
||||
|
|
@ -20,27 +33,86 @@ export function findPackageJson(startDir: string): string | undefined {
|
|||
|
||||
export function analyzeProject(targetDir: string): TransformContext {
|
||||
const pkgJsonPath = findPackageJson(targetDir);
|
||||
if (!pkgJsonPath) {
|
||||
return { projectType: 'unknown' };
|
||||
if (pkgJsonPath) {
|
||||
try {
|
||||
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
||||
const allDeps = {
|
||||
...pkgJson.dependencies,
|
||||
...pkgJson.devDependencies
|
||||
};
|
||||
|
||||
const hasClient = '@modelcontextprotocol/client' in allDeps;
|
||||
const hasServer = '@modelcontextprotocol/server' in allDeps;
|
||||
|
||||
if (hasClient && hasServer) return { projectType: 'both' };
|
||||
if (hasClient) return { projectType: 'client' };
|
||||
if (hasServer) return { projectType: 'server' };
|
||||
// No v2 split deps — this is almost always a v1 project mid-migration (v1 ships as the single
|
||||
// `@modelcontextprotocol/sdk` package). Fall through to inferring the type from source usage.
|
||||
} catch {
|
||||
// Malformed package.json — fall through to source inference.
|
||||
}
|
||||
}
|
||||
|
||||
return { projectType: inferProjectTypeFromSource(targetDir) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer client vs server vs both by scanning the source for v1 SDK subpath imports: a
|
||||
* `@modelcontextprotocol/sdk/client/...` specifier means the project will need
|
||||
* `@modelcontextprotocol/client`; a `.../server/...` specifier means it needs `@modelcontextprotocol/server`.
|
||||
* Files that import only shared paths (`types.js`, `shared/...`) give no signal. The scan matches quoted
|
||||
* specifiers (not bare substrings), so comments/prose are ignored. Bounded: skips heavy dirs, caps the
|
||||
* file count, and early-exits once both signals are seen.
|
||||
*/
|
||||
function inferProjectTypeFromSource(targetDir: string): TransformContext['projectType'] {
|
||||
let usesClient = false;
|
||||
let usesServer = false;
|
||||
let scanned = 0;
|
||||
|
||||
const visit = (dir: string): void => {
|
||||
if (usesClient && usesServer) return;
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (usesClient && usesServer) return;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SCAN_SKIP_DIRS.has(entry.name)) continue;
|
||||
visit(full);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTENSIONS.has(ext) || entry.name.endsWith('.d.ts')) continue;
|
||||
if (scanned >= SCAN_FILE_BUDGET) return;
|
||||
scanned++;
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(full, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!usesClient && CLIENT_IMPORT_RE.test(content)) usesClient = true;
|
||||
if (!usesServer && SERVER_IMPORT_RE.test(content)) usesServer = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let root = targetDir;
|
||||
try {
|
||||
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' };
|
||||
return { projectType: 'unknown' };
|
||||
if (!statSync(targetDir).isDirectory()) root = path.dirname(targetDir);
|
||||
} catch {
|
||||
return { projectType: 'unknown' };
|
||||
return 'unknown';
|
||||
}
|
||||
visit(root);
|
||||
|
||||
if (usesClient && usesServer) return 'both';
|
||||
if (usesClient) return 'client';
|
||||
if (usesServer) return 'server';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function resolveTypesPackage(
|
||||
|
|
@ -61,6 +133,22 @@ export function resolveTypesPackage(
|
|||
if (context.projectType === 'server') {
|
||||
return '@modelcontextprotocol/server';
|
||||
}
|
||||
if (context.projectType === 'both') {
|
||||
// Both packages are present and both re-export the shared protocol types (from core), so importing
|
||||
// from either compiles. This file has no client/server-specific signal — default to server and note
|
||||
// it as an optional preference, not an action-required warning.
|
||||
if (diagnosticSink) {
|
||||
diagnosticSink.diagnostics.push(
|
||||
info(
|
||||
diagnosticSink.filePath,
|
||||
diagnosticSink.line,
|
||||
'Shared protocol types imported from @modelcontextprotocol/server (both client and server ' +
|
||||
're-export them). Switch to @modelcontextprotocol/client if this is client-only code.'
|
||||
)
|
||||
);
|
||||
}
|
||||
return '@modelcontextprotocol/server';
|
||||
}
|
||||
if (diagnosticSink) {
|
||||
diagnosticSink.diagnostics.push(
|
||||
warning(
|
||||
|
|
|
|||
Loading…
Reference in a new issue