@@ -7,7 +7,7 @@ import { isSdkSpecifier } from '../../../utils/importUtils';
77import { resolveTypesPackage } from '../../../utils/projectAnalyzer' ;
88import type { ImportMapping } from '../mappings/importMap' ;
99import { isAuthImport , lookupImportMapping } from '../mappings/importMap' ;
10- import { symbolTargetOverride } from '../mappings/schemaRouting' ;
10+ import { isSharedSchemaConst , resolveRenamedName , symbolTargetOverride } from '../mappings/schemaRouting' ;
1111import { 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+
244328function 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 ) ;
0 commit comments