Skip to content

Commit 1772473

Browse files
codemod iterations 5 (modelcontextprotocol#2398)
1 parent ebca273 commit 1772473

8 files changed

Lines changed: 441 additions & 28 deletions

File tree

common/eslint-config/eslint.config.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ export default defineConfig(
9494
'no-console': 'off'
9595
}
9696
},
97+
{
98+
// Ignore build artifacts everywhere (mirrors .prettierignore). A flat-config
99+
// object with only `ignores` is a global ignore; ESLint does not skip dist by default.
100+
ignores: ['**/dist/**', '**/build/**', '**/coverage/**']
101+
},
97102
{
98103
// Ignore generated protocol types everywhere
99104
ignores: ['**/spec.types.2025-11-25.ts', '**/spec.types.2026-07-28.ts']

packages/codemod/src/bin/batchTest.ts

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -157,19 +157,30 @@ function detectPm(repoRoot: string): string {
157157
return 'npm';
158158
}
159159

160-
function installCommand(pm: string): string {
160+
export function installCommand(pm: string, opts: { hasOwnPnpmWorkspace: boolean; packageDirs: string[] }): string {
161161
if (pm !== 'pnpm') return `${pm} install --ignore-scripts`;
162-
// pnpm walks up to find a workspace; clones live inside this SDK's pnpm workspace, so a plain
163-
// `pnpm install` targets the OUTER workspace and never populates the clone's node_modules — every
164-
// downstream check (tsc base config, tsup, vitest) then fails identically at baseline and post,
165-
// masking real codemod signal.
166-
// --ignore-workspace: treat the clone as a standalone project (not part of the SDK workspace).
167-
// --no-frozen-lockfile: the codemod rewrites package.json to swap v1 → v2 deps, so the lockfile
168-
// must be allowed to change. CI=true (set in shell()) otherwise defaults
169-
// pnpm to a frozen lockfile and the post-codemod reinstall silently skips
170-
// the new v2 deps, leaving the clone on v1.
171-
// npm/yarn/bun key off a `workspaces` field in package.json (absent at this repo root), so they
172-
// need no equivalent flags.
162+
// --no-frozen-lockfile: the codemod rewrites package.json to swap v1 → v2 deps, so the lockfile must
163+
// be allowed to change. CI=true (set in shell()) otherwise defaults pnpm to a frozen lockfile and the
164+
// post-codemod reinstall silently skips the new v2 deps, leaving the clone on v1.
165+
if (opts.hasOwnPnpmWorkspace) {
166+
// The clone is its OWN pnpm workspace — pnpm-workspace.yaml defines catalog:/workspace: deps
167+
// (e.g. mastra). `--ignore-workspace` would discard that file and pnpm would fail to resolve them
168+
// (ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC; unresolved workspace: links → repo skipped). We don't
169+
// need it: the SDK workspace excludes the clones (`!packages/codemod/batch-test/**`) and pnpm uses
170+
// the clone's own pnpm-workspace.yaml as the nearest root. Scope the install to the target packages
171+
// and their dependencies (`{./<dir>}...`) so a monorepo target installs only what the checks need
172+
// (e.g. 2 of mastra's 161 projects) instead of the whole tree. The braces are load-bearing: pnpm
173+
// silently ignores the trailing `...` on a bare `./<dir>...` path selector (installing the package
174+
// without its workspace deps); the `{./<dir>}...` form honors it.
175+
const filters = opts.packageDirs
176+
.filter(dir => dir !== '.')
177+
.map(dir => `--filter ${JSON.stringify(`{./${dir}}...`)}`)
178+
.join(' ');
179+
return `pnpm install --ignore-scripts --no-frozen-lockfile${filters ? ` ${filters}` : ''}`;
180+
}
181+
// Single-package clone with no workspace of its own: pnpm would walk up to the (clone-excluding) SDK
182+
// workspace and never populate the clone's node_modules. `--ignore-workspace` treats it as standalone.
183+
// npm/yarn/bun key off a `workspaces` field in package.json (absent at this repo root).
173184
return 'pnpm install --ignore-scripts --ignore-workspace --no-frozen-lockfile';
174185
}
175186
@@ -643,18 +654,23 @@ function main(): void {
643654
const pm = detectPm(clonePath);
644655
console.log(` Package manager: ${pm}`);
645656

646-
// Step 3: Install
657+
// Process packages
658+
const packages: PackageEntry[] = entry.packages ?? [{ dir: '.', sourceDir: 'src' }];
659+
const repoPkgResults: PackageReport[] = [];
660+
661+
// Step 3: Install. A clone that is its own pnpm workspace (catalog:/workspace: deps) must keep its
662+
// pnpm-workspace.yaml — see installCommand. Computed once and reused for the post-codemod reinstall.
663+
const installCmd = installCommand(pm, {
664+
hasOwnPnpmWorkspace: existsSync(path.join(clonePath, 'pnpm-workspace.yaml')),
665+
packageDirs: packages.map(p => p.dir)
666+
});
647667
console.log(' Installing dependencies...');
648-
const installResult = shell(installCommand(pm), clonePath);
668+
const installResult = shell(installCmd, clonePath);
649669
if (installResult.exitCode !== 0) {
650670
console.log(` ERROR: install failed, skipping\n ${installResult.stderr.split('\n')[0]}`);
651671
continue;
652672
}
653673

654-
// Process packages
655-
const packages: PackageEntry[] = entry.packages ?? [{ dir: '.', sourceDir: 'src' }];
656-
const repoPkgResults: PackageReport[] = [];
657-
658674
for (const pkg of packages) {
659675
const sourceDir = pkg.sourceDir ?? 'src';
660676
const fullPkgDir = path.join(clonePath, pkg.dir);
@@ -697,7 +713,7 @@ function main(): void {
697713
if (rewrites > 0) console.log(` Pinned ${rewrites} deps to resolved published versions`);
698714
}
699715
console.log(' Re-installing dependencies...');
700-
shell(installCommand(pm), clonePath);
716+
shell(installCmd, clonePath);
701717

702718
// Step 7: Post-codemod checks
703719
console.log(' Running post-codemod checks...');
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate.
22
export const V2_PACKAGE_VERSIONS: Record<string, string> = {
3-
'@modelcontextprotocol/client': '^2.0.0-alpha.3',
4-
'@modelcontextprotocol/server': '^2.0.0-alpha.3',
5-
'@modelcontextprotocol/node': '^2.0.0-alpha.3',
6-
'@modelcontextprotocol/express': '^2.0.0-alpha.3',
7-
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3',
8-
'@modelcontextprotocol/core': '^2.0.0-alpha.1'
3+
'@modelcontextprotocol/client': '^2.0.0-beta.1',
4+
'@modelcontextprotocol/server': '^2.0.0-beta.1',
5+
'@modelcontextprotocol/node': '^2.0.0-beta.1',
6+
'@modelcontextprotocol/express': '^2.0.0-beta.1',
7+
'@modelcontextprotocol/server-legacy': '^2.0.0-beta.1',
8+
'@modelcontextprotocol/core': '^2.0.0-beta.1'
99
};

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

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { SourceFile } from 'ts-morph';
1+
import type { ObjectLiteralExpression, SourceFile } from 'ts-morph';
22
import { Node, SyntaxKind } from 'ts-morph';
33

44
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
@@ -9,8 +9,33 @@ import { CONTEXT_PROPERTY_MAP, CTX_PARAM_NAME, EXTRA_PARAM_NAME } from '../mappi
99

1010
const CONTEXT_LIKE_KEYS = new Set(CONTEXT_PROPERTY_MAP.map(mapping => mapping.from.slice(1)));
1111

12+
/**
13+
* v1 context keys distinctive enough that a single one on an object literal is a strong
14+
* signal it's a hand-built handler-context mock (vs. generic keys like `signal`/`sessionId`/
15+
* `requestId` — a bare correlation-ID literal such as `logger.info(msg, { requestId })` is
16+
* not a context mock — which appear on unrelated objects and only count in aggregate).
17+
*/
18+
const DISTINCTIVE_CONTEXT_KEYS = new Set([
19+
'sendRequest',
20+
'sendNotification',
21+
'requestInfo',
22+
'authInfo',
23+
'closeSSEStream',
24+
'closeStandaloneSSEStream'
25+
]);
26+
27+
/** A literal already carrying one of these is in the v2 nested shape — not a stale v1 mock. */
28+
const V2_SHAPE_KEYS = new Set(['mcpReq', 'http', 'task']);
29+
1230
const HANDLER_METHODS = new Set(['setRequestHandler', 'setNotificationHandler']);
1331

32+
/**
33+
* Transport ingestion methods whose second argument is a flat `MessageExtraInfo`
34+
* (authInfo/request/closeSSEStream/… stay top-level in v2), NOT a handler context —
35+
* so a literal handed to them must never get handler-context reshape guidance.
36+
*/
37+
const TRANSPORT_MESSAGE_METHODS = new Set(['onmessage', 'handleMessage']);
38+
1439
const REGISTER_METHODS = new Set(['registerTool', 'registerPrompt', 'registerResource', 'tool', 'prompt', 'resource']);
1540

1641
/**
@@ -303,6 +328,7 @@ export const contextTypesTransform: Transform = {
303328

304329
changesCount += processFallbackHandlerAssignments(sourceFile, diagnostics);
305330
changesCount += remapAnnotatedContextParams(sourceFile, diagnostics);
331+
flagV1MockContextLiterals(sourceFile, diagnostics);
306332

307333
return { changesCount, diagnostics };
308334
}
@@ -329,6 +355,108 @@ function processFallbackHandlerAssignments(sourceFile: SourceFile, diagnostics:
329355
return changes;
330356
}
331357

358+
/**
359+
* Render a v1 context key as a reshape hint, e.g. `sendRequest` → `mcpReq.send`. Returns
360+
* undefined for `sessionId` (a no-op — it stays top-level in v2) and for non-context keys.
361+
*/
362+
function contextKeyReshapeHint(key: string): string | undefined {
363+
const mapping = CONTEXT_PROPERTY_MAP.find(m => m.from === '.' + key);
364+
if (mapping === undefined || mapping.from === mapping.to) return undefined;
365+
// Render the target as a plain object path: '.http?.authInfo' → 'http.authInfo'.
366+
return `${key}${mapping.to.replace(/^\./, '').replaceAll('?', '')}`;
367+
}
368+
369+
/**
370+
* Flag hand-built mocks of the handler context (common in tests). The call-site scan above
371+
* only reshapes `extra.X` inside handler definitions it can anchor on (registerTool,
372+
* setRequestHandler, fallback handlers, annotated params). A test hands its mock to a bare
373+
* `handler(args, mockCtx)` invocation, so the object literal is never reached and keeps the
374+
* flat v1 shape — at runtime the migrated handler reads `ctx.mcpReq.send` / `.id` / … against
375+
* it and throws "Cannot read properties of undefined (reading 'send')". Advisory only: an
376+
* untyped literal that merely shares a key name might not be a context mock, so never rewrite.
377+
*/
378+
function flagV1MockContextLiterals(sourceFile: SourceFile, diagnostics: Diagnostic[]): void {
379+
for (const obj of sourceFile.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
380+
// Only literals in a mock-context position: a call argument or a variable initializer.
381+
// Covers both `handler({}, { sendRequest: fn })` and `const extra = { … }; handler(a, extra)`.
382+
// Unwrap casts/parens first so typed mocks — `{ … } as unknown as RequestHandlerExtra`,
383+
// `({ … })`, `{ … } satisfies X` — are anchored by what encloses the cast, not the
384+
// AsExpression that directly wraps the literal.
385+
let expr: Node = obj;
386+
let parent = expr.getParent();
387+
while (
388+
parent !== undefined &&
389+
(Node.isAsExpression(parent) || Node.isSatisfiesExpression(parent) || Node.isParenthesizedExpression(parent))
390+
) {
391+
expr = parent;
392+
parent = parent.getParent();
393+
}
394+
const isCallArg = parent !== undefined && Node.isCallExpression(parent) && parent.getArguments().includes(expr);
395+
const isVarInit = parent !== undefined && Node.isVariableDeclaration(parent) && parent.getInitializer() === expr;
396+
if (!isCallArg && !isVarInit) continue;
397+
398+
// A literal handed to a transport ingestion method (`transport.onmessage(msg, { … })`,
399+
// `transport.handleMessage(msg, { … })`) is a flat MessageExtraInfo, not a handler-context
400+
// mock — reshaping its authInfo/request/closeSSEStream/… under http/mcpReq would be wrong.
401+
if (isCallArg && Node.isCallExpression(parent)) {
402+
const callee = parent.getExpression();
403+
if (Node.isPropertyAccessExpression(callee) && TRANSPORT_MESSAGE_METHODS.has(callee.getName())) continue;
404+
}
405+
406+
// The codemod's OWN output: an options object inside a just-rewritten handler whose values
407+
// now read `ctx.mcpReq.*` / `ctx.http.*`. The keys keep their v1 names — so V2_SHAPE_KEYS
408+
// below, which only inspects key names, won't catch it — but the values are already v2, not
409+
// a stale mock. Flagging it would insert a comment above code the codemod itself produced.
410+
if (readsFromMigratedContext(obj)) continue;
411+
412+
// Collect named property keys (skip spreads and computed names).
413+
const keys: string[] = [];
414+
for (const prop of obj.getProperties()) {
415+
if (!Node.isPropertyAssignment(prop) && !Node.isShorthandPropertyAssignment(prop) && !Node.isMethodDeclaration(prop)) continue;
416+
const nameNode = prop.getNameNode();
417+
if (Node.isIdentifier(nameNode)) keys.push(nameNode.getText());
418+
else if (Node.isStringLiteral(nameNode)) keys.push(nameNode.getLiteralText());
419+
}
420+
if (keys.some(key => V2_SHAPE_KEYS.has(key))) continue; // already v2-shaped
421+
422+
const contextKeys = keys.filter(key => CONTEXT_LIKE_KEYS.has(key));
423+
const hasDistinctive = contextKeys.some(key => DISTINCTIVE_CONTEXT_KEYS.has(key));
424+
if (!hasDistinctive && contextKeys.length < 2) continue;
425+
426+
const reshapes = contextKeys.map(key => contextKeyReshapeHint(key)).filter((hint): hint is string => hint !== undefined);
427+
const sessionNote = contextKeys.includes('sessionId') ? '; sessionId stays top-level' : '';
428+
diagnostics.push({
429+
...actionRequired(
430+
sourceFile.getFilePath(),
431+
obj,
432+
`This object looks like a v1 handler-context mock (${contextKeys.join(', ')}). v2 nests the context — ` +
433+
`reshape it (${reshapes.join('; ')}${sessionNote}), e.g. { sendRequest: fn } → { mcpReq: { send: fn } }. ` +
434+
`Passed as-is to a migrated handler that reads ctx.mcpReq.*, the v1 shape throws ` +
435+
`"Cannot read properties of undefined".`
436+
),
437+
advisoryOnly: true
438+
});
439+
}
440+
}
441+
442+
/**
443+
* True when any property value already reads from the v2-nested context — a `.mcpReq` or `.http`
444+
* property access (e.g. `ctx.mcpReq.signal`, `ctx.http?.authInfo`). Such a literal is migrated
445+
* output, not a hand-built v1 mock: a real v1 mock supplies raw values (`fn`, `ac.signal`, a
446+
* literal), never the nested v2 shape the codemod itself just wrote.
447+
*/
448+
function readsFromMigratedContext(obj: ObjectLiteralExpression): boolean {
449+
for (const prop of obj.getProperties()) {
450+
if (!Node.isPropertyAssignment(prop)) continue;
451+
const init = prop.getInitializer();
452+
if (init === undefined) continue;
453+
const accesses = init.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression);
454+
if (Node.isPropertyAccessExpression(init)) accesses.push(init);
455+
if (accesses.some(access => access.getName() === 'mcpReq' || access.getName() === 'http')) return true;
456+
}
457+
return false;
458+
}
459+
332460
const CONTEXT_TYPE_NAMES = new Set(['RequestHandlerExtra', 'ServerContext', 'ClientContext']);
333461
/**
334462
* Split a type's text on top-level `|` only (angle brackets tracked). Returns

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,54 @@ export const handlerRegistrationTransform: Transform = {
173173
removeUnusedImport(sourceFile, schemaName, true);
174174
}
175175

176+
// Flag surviving registration-schema references. A method-mapped *RequestSchema/*NotificationSchema
177+
// whose import survived the conversion above, used as a VALUE (a call argument or an `===`/`!==`
178+
// operand), is almost always a stale v1 registration assertion/lookup — in v2 the registration key is
179+
// the method string, not the schema. Advisory, not rewritten: the same constant is still valid for
180+
// `.parse()` etc., so we flag rather than risk breaking a legitimate schema use.
181+
//
182+
// Gate on a registration MOCK/lookup, not on any setRequestHandler access. A file that only *calls*
183+
// setRequestHandler/setNotificationHandler (real registrations, which the pass above rewrites) tells us
184+
// nothing about its other schema references — those may be plain schema uses (`validateSchema(S)`,
185+
// `zodToJsonSchema(S)`, `ajv.compile(S)`). Only a NON-call reference — `inner.setRequestHandler.mock`,
186+
// `expect(x.setRequestHandler)…`, a comparison operand — signals the file makes registration
187+
// assertions, where a surviving schema key is likely stale.
188+
const usesRegistrationMock = sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).some(pa => {
189+
if (pa.getName() !== 'setRequestHandler' && pa.getName() !== 'setNotificationHandler') return false;
190+
const paParent = pa.getParent();
191+
// Exclude real registration calls `x.setRequestHandler(…)`, where pa is the callee.
192+
return !(Node.isCallExpression(paParent) && paParent.getExpression() === pa);
193+
});
194+
if (usesRegistrationMock) {
195+
const flagged = new Set<string>();
196+
for (const id of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
197+
const local = id.getText();
198+
if (flagged.has(local)) continue;
199+
const original = resolveOriginalImportName(sourceFile, local) ?? local;
200+
const method = ALL_SCHEMA_TO_METHOD[original];
201+
if (method === undefined || !isImportedFromMcp(sourceFile, local)) continue;
202+
const parent = id.getParent();
203+
if (parent === undefined) continue;
204+
const isCallArg = Node.isCallExpression(parent) && parent.getArguments().includes(id);
205+
const isComparand =
206+
Node.isBinaryExpression(parent) &&
207+
['===', '!==', '==', '!='].includes(parent.getOperatorToken().getText()) &&
208+
(parent.getLeft() === id || parent.getRight() === id);
209+
if (!isCallArg && !isComparand) continue;
210+
flagged.add(local);
211+
diagnostics.push({
212+
...actionRequired(
213+
sourceFile.getFilePath(),
214+
id,
215+
`${local} is no longer the setRequestHandler/setNotificationHandler key in v2 — handlers ` +
216+
`register by the method string '${method}'. Update registration assertions/lookups ` +
217+
`(e.g. against a setRequestHandler mock) to compare against '${method}'.`
218+
),
219+
advisoryOnly: true
220+
});
221+
}
222+
}
223+
176224
return { changesCount, diagnostics };
177225
}
178226
};

0 commit comments

Comments
 (0)