Skip to content

Commit 51bf8bc

Browse files
committed
fixes
1 parent 5621119 commit 51bf8bc

12 files changed

Lines changed: 247 additions & 41 deletions

File tree

docs/migration-SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Notes:
9090
| `isJSONRPCError` | `isJSONRPCErrorResponse` |
9191
| `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` (**not** v2's new `isJSONRPCResponse`, which correctly matches both result and error) |
9292
| `JSONRPCResponseSchema` (result-only in v1) | `JSONRPCResultResponseSchema` (from `@modelcontextprotocol/core`; **not** v2's new `JSONRPCResponseSchema`, a `z.union` that also accepts error responses) |
93+
| `JSONRPCResponse` (type, result-only in v1) | `JSONRPCResultResponse` (**not** v2's new `JSONRPCResponse` type, which is the result\|error union) |
9394
| `ResourceReference` | `ResourceTemplateReference` |
9495
| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` |
9596
| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) |

docs/migration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,9 @@ All other symbols exported from `@modelcontextprotocol/sdk/types.js` retain thei
610610
> **Note on `JSONRPCResponseSchema`:** the Zod schema follows the same pattern. v1's `JSONRPCResponseSchema` validated only _result_ responses; v2 reuses the name for a `z.union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema])` that also accepts error responses. If you
611611
> are migrating v1 code that called `JSONRPCResponseSchema.parse()`/`.safeParse()`, rename it to `JSONRPCResultResponseSchema` (re-exported by `@modelcontextprotocol/core`) to preserve the original validation. The codemod performs this rename automatically.
612612
613+
> **Note on the `JSONRPCResponse` type:** the TypeScript type follows the same pattern. v1's `JSONRPCResponse` was the _result-only_ response type; v2 reuses the name for the result\|error union (`Infer<typeof JSONRPCResponseSchema>`). If you are migrating v1 code that annotated values with
614+
> `JSONRPCResponse`, rename it to `JSONRPCResultResponse` (re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) to preserve the original narrower type. The codemod performs this rename automatically.
615+
613616
**Before (v1):**
614617

615618
```typescript

packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ export const SIMPLE_RENAMES: Record<string, string> = {
44
JSONRPCErrorSchema: 'JSONRPCErrorResponseSchema',
55
isJSONRPCError: 'isJSONRPCErrorResponse',
66
isJSONRPCResponse: 'isJSONRPCResultResponse',
7-
// v1's JSONRPCResponseSchema validated only *result* responses. v2 reuses the name for a
8-
// z.union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema]) that also accepts error
9-
// responses, so a migrated `JSONRPCResponseSchema.parse(...)` would silently widen. Rename to the
10-
// result-only schema to preserve v1 behavior — mirroring the isJSONRPCResponse guard rename above.
11-
// (The TYPE JSONRPCResponse/JSONRPCResultResponse is not part of the public v2 surface, so only the
12-
// schema constant — re-exported by core — is renamed here.)
7+
// v1's JSONRPCResponse type / JSONRPCResponseSchema constant both validated only *result*
8+
// responses. v2 reuses each name for the result|error form — the type becomes
9+
// `Infer<z.union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema])>` and the schema the
10+
// matching `z.union([...])` — so a migrated `JSONRPCResponseSchema.parse(...)` (or a typed
11+
// `JSONRPCResponse` value) would silently widen. Rename both to the result-only equivalents to
12+
// preserve v1 behavior — mirroring the isJSONRPCResponse guard rename above. Both the type and the
13+
// schema are public in v2 (re-exported from core via @modelcontextprotocol/client | /server).
14+
JSONRPCResponse: 'JSONRPCResultResponse',
1315
JSONRPCResponseSchema: 'JSONRPCResultResponseSchema',
1416
ResourceReference: 'ResourceTemplateReference',
1517
ResourceReferenceSchema: 'ResourceTemplateReferenceSchema'

packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts

Lines changed: 123 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { isSdkSpecifier } from '../../../utils/importUtils';
77
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
88
import type { ImportMapping } from '../mappings/importMap';
99
import { isAuthImport, lookupImportMapping } from '../mappings/importMap';
10-
import { symbolTargetOverride } from '../mappings/schemaRouting';
10+
import { isSharedSchemaConst, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
1111
import { SIMPLE_RENAMES } from '../mappings/symbolMap';
1212

1313
/**
@@ -73,6 +73,7 @@ function resolveTarget(
7373
specifier: string,
7474
context: TransformContext,
7575
sourceFile: SourceFile,
76+
symbols: string[],
7677
diagnosticSink?: { filePath: string; line: number; diagnostics: Diagnostic[] }
7778
): { target: string; mapping: ImportMapping } | { removed: true; isV2Gap?: boolean; removalMessage?: string } | null {
7879
const mapping = lookupImportMapping(specifier);
@@ -93,7 +94,15 @@ function resolveTarget(
9394
const s = i.getModuleSpecifierValue();
9495
return s.includes('/server/') || s === '@modelcontextprotocol/server';
9596
});
96-
target = resolveTypesPackage(context, hasClient, hasServer, diagnosticSink);
97+
// Resolve lazily: only pass the diagnostic sink to resolveTypesPackage when the routed target
98+
// actually falls back to the context package. A factory/destructuring whose symbols all route
99+
// elsewhere (e.g. only `*Schema` constants → core) never uses the context package, so emitting a
100+
// "could not determine project type" warning (or a 'both'-project info note) for it would be
101+
// spurious. Mirrors the lazy `needsContext` guard in the static import transform. A
102+
// non-destructured/non-routable binding has no symbols, so `routeSymbols` returns no target and
103+
// context is (correctly) treated as needed.
104+
const needsContext = routeSymbols(symbols, mapping).target === undefined;
105+
target = resolveTypesPackage(context, hasClient, hasServer, needsContext ? diagnosticSink : undefined);
97106
if (mapping.subpathSuffix) {
98107
target = `${target}${mapping.subpathSuffix}`;
99108
}
@@ -121,7 +130,8 @@ function rewriteMockCall(
121130
const specifier = firstArg.getLiteralValue();
122131
if (!isSdkSpecifier(specifier)) return 0;
123132

124-
const resolved = resolveTarget(specifier, context, sourceFile, {
133+
const factorySymbols = args.length >= 2 ? collectFactorySymbols(args[1]!) : [];
134+
const resolved = resolveTarget(specifier, context, sourceFile, factorySymbols, {
125135
filePath: sourceFile.getFilePath(),
126136
line: call.getStartLineNumber(),
127137
diagnostics
@@ -150,7 +160,7 @@ function rewriteMockCall(
150160
// only `*Schema` constants (from sdk/types.js or sdk/shared/auth.js) moves to core; a factory
151161
// of only `StreamableHTTPServerTransport` moves to @modelcontextprotocol/node. A single mock path
152162
// can't be split, so a mix of packages is flagged for manual migration.
153-
const { target: routedTarget, mixed } = routeSymbols(collectFactorySymbols(args[1]!), resolved.mapping);
163+
const { target: routedTarget, mixed } = routeSymbols(factorySymbols, resolved.mapping);
154164
if (routedTarget) {
155165
effectiveTarget = routedTarget;
156166
} else if (mixed) {
@@ -241,6 +251,80 @@ function renameSymbolsInFactory(factoryArg: import('ts-morph').Node, renamedSymb
241251
return changes;
242252
}
243253

254+
/**
255+
* The destructured binding keys of an `await import()` assigned to an object binding pattern (e.g.
256+
* `const { CallToolResultSchema, McpError } = await import('…')`), or `[]` for a non-destructured
257+
* binding, a `.then()` chain, or an unassigned `await import()`. The keys feed per-symbol routing —
258+
* the specifier itself can't be split.
259+
*/
260+
function getDestructuredKeys(node: import('ts-morph').CallExpression): string[] {
261+
const parent = node.getParent();
262+
if (!parent || !Node.isAwaitExpression(parent)) return [];
263+
const grandParent = parent.getParent();
264+
if (!grandParent || !Node.isVariableDeclaration(grandParent)) return [];
265+
const nameNode = grandParent.getNameNode();
266+
if (!Node.isObjectBindingPattern(nameNode)) return [];
267+
return nameNode.getElements().map(el => el.getPropertyNameNode()?.getText() ?? el.getName());
268+
}
269+
270+
/**
271+
* For a dynamic import whose module binding is NOT a destructurable object pattern — a non-destructured
272+
* `const mod = await import('…')` or a `.then(m => …)` chain — collect the Zod schema constants
273+
* accessed off that binding (e.g. `mod.OAuthTokensSchema`). The destructured form is routed/renamed
274+
* elsewhere; these forms can't be split per-symbol, so the schema accesses are surfaced as a diagnostic
275+
* (mirroring the namespace-import branch of the static import transform — see `importPaths.ts`). Returns
276+
* deduped `[v1Name, v2Name]` pairs (a schema may be renamed, e.g. JSONRPCResponseSchema →
277+
* JSONRPCResultResponseSchema, and core only exports the v2 name). Empty unless the mapping carries a
278+
* `schemaSymbolTarget`.
279+
*/
280+
function collectModuleSchemaAccesses(
281+
node: import('ts-morph').CallExpression,
282+
mapping: ImportMapping,
283+
sourceFile: SourceFile
284+
): Array<readonly [string, string]> {
285+
if (!mapping.schemaSymbolTarget) return [];
286+
287+
let bindingName: string | undefined;
288+
let scope: import('ts-morph').Node | undefined;
289+
290+
const parent = node.getParent();
291+
if (parent && Node.isAwaitExpression(parent)) {
292+
// const mod = await import('…') → `mod` is in scope for the rest of the file.
293+
const grandParent = parent.getParent();
294+
if (grandParent && Node.isVariableDeclaration(grandParent)) {
295+
const nameNode = grandParent.getNameNode();
296+
if (Node.isIdentifier(nameNode)) {
297+
bindingName = nameNode.getText();
298+
scope = sourceFile;
299+
}
300+
}
301+
} else if (parent && Node.isPropertyAccessExpression(parent) && parent.getName() === 'then') {
302+
// import('…').then(m => m.XxxSchema…) → the module is the `.then` callback's first parameter.
303+
const thenCall = parent.getParent();
304+
if (thenCall && Node.isCallExpression(thenCall)) {
305+
const cb = thenCall.getArguments()[0];
306+
if (cb && (Node.isArrowFunction(cb) || Node.isFunctionExpression(cb))) {
307+
const paramName = cb.getParameters()[0]?.getNameNode();
308+
if (paramName && Node.isIdentifier(paramName)) {
309+
bindingName = paramName.getText();
310+
scope = cb;
311+
}
312+
}
313+
}
314+
}
315+
316+
if (!bindingName || !scope) return [];
317+
318+
return [
319+
...new Map(
320+
scope
321+
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
322+
.filter(pa => pa.getExpression().getText() === bindingName && isSharedSchemaConst(pa.getName(), mapping))
323+
.map(pa => [pa.getName(), resolveRenamedName(pa.getName(), mapping)] as const)
324+
)
325+
];
326+
}
327+
244328
function rewriteDynamicImports(
245329
sourceFile: SourceFile,
246330
context: TransformContext,
@@ -264,7 +348,8 @@ function rewriteDynamicImports(
264348
const specifier = firstArg.getLiteralValue();
265349
if (!isSdkSpecifier(specifier)) return;
266350

267-
const resolved = resolveTarget(specifier, context, sourceFile, {
351+
const destructuredKeys = getDestructuredKeys(node);
352+
const resolved = resolveTarget(specifier, context, sourceFile, destructuredKeys, {
268353
filePath: sourceFile.getFilePath(),
269354
line: node.getStartLineNumber(),
270355
diagnostics
@@ -294,28 +379,39 @@ function rewriteDynamicImports(
294379
// of only `*Schema` constants (e.g. `const { CallToolResultSchema } = await import('…/types.js')`)
295380
// moves to core, and `StreamableHTTPServerTransport` moves to @modelcontextprotocol/node. A
296381
// single import() specifier can't be split, so a mix of packages is flagged for manual migration.
297-
const parentExpr = node.getParent();
298-
if (parentExpr && Node.isAwaitExpression(parentExpr)) {
299-
const grandParent = parentExpr.getParent();
300-
if (grandParent && Node.isVariableDeclaration(grandParent)) {
301-
const nameNode = grandParent.getNameNode();
302-
if (Node.isObjectBindingPattern(nameNode)) {
303-
const keys = nameNode.getElements().map(el => el.getPropertyNameNode()?.getText() ?? el.getName());
304-
const { target: routedTarget, mixed } = routeSymbols(keys, resolved.mapping);
305-
if (routedTarget) {
306-
effectiveTarget = routedTarget;
307-
} else if (mixed) {
308-
diagnostics.push(
309-
actionRequired(
310-
sourceFile.getFilePath(),
311-
node,
312-
`Dynamic import of ${specifier} destructures symbols that belong to different v2 packages. ` +
313-
`Split the import manually so each symbol targets the correct package.`
314-
)
315-
);
316-
}
317-
}
318-
}
382+
const { target: routedTarget, mixed } = routeSymbols(destructuredKeys, resolved.mapping);
383+
if (routedTarget) {
384+
effectiveTarget = routedTarget;
385+
} else if (mixed) {
386+
diagnostics.push(
387+
actionRequired(
388+
sourceFile.getFilePath(),
389+
node,
390+
`Dynamic import of ${specifier} destructures symbols that belong to different v2 packages. ` +
391+
`Split the import manually so each symbol targets the correct package.`
392+
)
393+
);
394+
}
395+
396+
// A non-destructured binding (`const mod = await import('…')`) or a `.then(m => …)` chain can't be
397+
// routed per-symbol, so the specifier moves to the context package — which does NOT export the
398+
// Zod `*Schema` constants (those live in `schemaSymbolTarget`/core). Any `mod.<Name>Schema` /
399+
// `m.<Name>Schema` accesses would silently break, so flag them (mirroring the namespace-import
400+
// branch of the static import transform). The destructured form is handled by `routeSymbols` above.
401+
const schemaAccesses = collectModuleSchemaAccesses(node, resolved.mapping, sourceFile);
402+
if (schemaAccesses.length > 0) {
403+
const accessed = schemaAccesses.map(([v1]) => v1).join(', ');
404+
const importName = schemaAccesses[0]![1];
405+
const renamed = schemaAccesses.filter(([v1, v2]) => v1 !== v2);
406+
const renameNote = renamed.length > 0 ? ` Renamed in v2: ${renamed.map(([v1, v2]) => `${v1}${v2}`).join(', ')}.` : '';
407+
diagnostics.push(
408+
actionRequired(
409+
sourceFile.getFilePath(),
410+
node,
411+
`Dynamic import of ${specifier} is used to access Zod schema(s) (${accessed}) that moved to ${resolved.mapping.schemaSymbolTarget}.${renameNote} ` +
412+
`Import them with a named import (e.g. \`import { ${importName} } from '${resolved.mapping.schemaSymbolTarget}'\`) and update the qualified usages.`
413+
)
414+
);
319415
}
320416

321417
usedPackages.add(effectiveTarget);

packages/codemod/test/detectFormatter.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
33
import path from 'node:path';
44
import { describe, it, expect, afterEach } from 'vitest';
55

6-
import { detectFormatter } from '../src/utils/detectFormatter.js';
6+
import { detectFormatter } from '../src/utils/detectFormatter';
77

88
let tempDir: string;
99

packages/codemod/test/projectAnalyzer.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,15 +202,15 @@ describe('analyzeProject', () => {
202202

203203
describe('resolveTypesPackage', () => {
204204
it('emits an info note (not a warning) for a both-project ambiguous file', () => {
205-
const sink = { filePath: 'f.ts', line: 1, diagnostics: [] as import('../src/types.js').Diagnostic[] };
205+
const sink = { filePath: 'f.ts', line: 1, diagnostics: [] as import('../src/types').Diagnostic[] };
206206
const target = resolveTypesPackage({ projectType: 'both' }, false, false, sink);
207207
expect(target).toBe('@modelcontextprotocol/server');
208208
expect(sink.diagnostics).toHaveLength(1);
209209
expect(sink.diagnostics[0]!.level).toBe('info');
210210
});
211211

212212
it('emits an action-required warning for a genuinely unknown project', () => {
213-
const sink = { filePath: 'f.ts', line: 1, diagnostics: [] as import('../src/types.js').Diagnostic[] };
213+
const sink = { filePath: 'f.ts', line: 1, diagnostics: [] as import('../src/types').Diagnostic[] };
214214
resolveTypesPackage({ projectType: 'unknown' }, false, false, sink);
215215
expect(sink.diagnostics).toHaveLength(1);
216216
expect(sink.diagnostics[0]!.level).toBe('warning');

packages/codemod/test/v1-to-v2/authSchemaNames.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
33

44
import { describe, expect, it } from 'vitest';
55

6-
import { AUTH_SCHEMA_NAMES, AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT } from '../../src/migrations/v1-to-v2/mappings/authSchemaNames.js';
6+
import { AUTH_SCHEMA_NAMES, AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT } from '../../src/migrations/v1-to-v2/mappings/authSchemaNames';
77

88
describe('AUTH_SCHEMA_NAMES (codemod auth schema-routing allowlist)', () => {
99
it('routes only auth schemas that @modelcontextprotocol/core exports (drift guard)', () => {

packages/codemod/test/v1-to-v2/specSchemaNames.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
33

44
import { describe, expect, it } from 'vitest';
55

6-
import { SPEC_SCHEMA_NAMES } from '../../src/migrations/v1-to-v2/mappings/specSchemaNames.js';
6+
import { SPEC_SCHEMA_NAMES } from '../../src/migrations/v1-to-v2/mappings/specSchemaNames';
77

88
describe('SPEC_SCHEMA_NAMES (codemod schema-routing allowlist)', () => {
99
it("matches @modelcontextprotocol/core's exported schema set exactly (drift guard)", () => {

0 commit comments

Comments
 (0)