Skip to content

fix(scripts): stop the snippet sync walking node_modules and cloned f… - #2664

Merged
felixweinberger merged 1 commit into
mainfrom
fix/sync-snippets-symlink-walk
Aug 14, 2026
Merged

fix(scripts): stop the snippet sync walking node_modules and cloned f…#2664
felixweinberger merged 1 commit into
mainfrom
fix/sync-snippets-symlink-walk

Conversation

@KKonstantinov

Copy link
Copy Markdown
Contributor

…ixture repos

findPackageSrcDirs walked packages/ with readdirSync's recursive option and filtered node_modules out of the result. The filter came too late: recursive follows symlinks, so the walk entered every packages/*/node_modules link back into the pnpm store and re-traversed the dependency graph along every path, collecting each entry into one array until it exhausted the V8 heap. It also descended into packages/codemod/batch-test/repos and returned src dirs from the cloned fixture monorepos, whose sources the script would then process.

Descend explicitly and skip node_modules, batch-test and dist before entering them. pnpm-workspace.yaml already excludes batch-test from the workspace.

Severity depends on the local checkout: batch-test/repos is gitignored, so a developer who has run the codemod batch tests carries whole external monorepos under packages/, and there the check aborts out of memory and takes the pre-push hook down with it.

Motivation and Context

How Has This Been Tested?

Breaking Changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

…ixture repos

findPackageSrcDirs walked packages/ with readdirSync's recursive option and
filtered node_modules out of the result. The filter came too late: recursive
follows symlinks, so the walk entered every packages/*/node_modules link back
into the pnpm store and re-traversed the dependency graph along every path,
collecting each entry into one array until it exhausted the V8 heap. It also
descended into packages/codemod/batch-test/repos and returned src dirs from
the cloned fixture monorepos, whose sources the script would then process.

Descend explicitly and skip node_modules, batch-test and dist before entering
them. pnpm-workspace.yaml already excludes batch-test from the workspace.

Severity depends on the local checkout: batch-test/repos is gitignored, so a
developer who has run the codemod batch tests carries whole external monorepos
under packages/, and there the check aborts out of memory and takes the
pre-push hook down with it.
@KKonstantinov
KKonstantinov requested a review from a team as a code owner August 14, 2026 11:19
@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 44a963a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2664

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2664

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2664

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2664

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2664

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2664

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2664

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2664

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2664

commit: 44a963a

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — dev-tooling fix, verified locally: the rewritten walker returns exactly the 10 workspace src dirs (client, codemod, core, core-internal, middleware/{express,fastify,hono,node}, server, server-legacy), matching what the old traversal produced minus the node_modules/batch-test noise it exists to eliminate. The inline nit about the sibling walkers is a follow-up cleanup, not a blocker.

Extended reasoning...

Overview

Single-file change to scripts/sync-snippets.ts, a repo-internal build/docs script (run via pnpm sync:snippets and the pre-push hook). It replaces readdirSync's recursive option in findPackageSrcDirs with an explicit descent that skips node_modules, dist, and batch-test before entering them, and never follows symlinks (Dirent.isDirectory() is false for links). No changeset is needed — nothing published changes.

Verification

I executed the new walker logic directly against this checkout: it returns exactly the 10 workspace src dirs (including the nested packages/middleware/*/src ones), so no package is lost relative to the old traversal. The claim in the PR description that pnpm-workspace.yaml excludes batch-test checks out (!packages/codemod/batch-test/**). The added dist skip is a strict improvement — the old code could pick up src dirs inside build output.

Security risks

None. The script only reads/writes files inside the repo checkout, and this change narrows the set of directories it touches (notably it can no longer rewrite sources inside cloned fixture repos under batch-test).

Level of scrutiny

Low. This is developer tooling, not shipped SDK code; no public API, protocol, transport, or auth surface is involved. The main risk would be silently dropping a package src dir from the sync, which I ruled out empirically.

Other factors

The bug-hunting system found one nit: findSourceFiles and findMarkdownFiles in the same file still use the recursive-readdir + late-filter pattern. That is a real consistency/robustness gap (a symlink inside a src tree or docs/ could reintroduce the unbounded walk), but those trees contain no symlinks today, so it does not block the fix this PR ships — it is posted inline for the author as follow-up material.

Comment thread scripts/sync-snippets.ts
Comment on lines +528 to +535
*
* @param packagesDir The packages directory
* @returns Array of absolute paths to src directories
*/
function findPackageSrcDirs(packagesDir: string): string[] {
const srcDirs: string[] = [];
const entries = readdirSync(packagesDir, {
withFileTypes: true,
recursive: true,
});

for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name !== 'src') continue;
const descend = (dir: string): void => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new doc comment on findPackageSrcDirs explains that recursive readdirSync follows symlinks and that filtering results post-traversal is too late — yet the same pattern survives at two sibling sites in this file: findSourceFiles (line 450) and findMarkdownFiles (line 485). Since findSourceFiles runs on every src dir the new walker returns, a stray symlink inside a package src tree (or under docs/) would re-create the unbounded walk this PR fixes; consider extracting the explicit descend/SKIPPED_DIRS walk into a shared helper used by all three walkers.

Extended reasoning...

What the finding is. This PR rewrites findPackageSrcDirs to descend explicitly and skip node_modules/dist/batch-test before entering them, and its new doc comment documents why: readdirSync's recursive option "follows symlinks and collects every entry it visits into a single array before returning, so filtering unwanted directories out of the result is too late to keep the walk bounded." But that exact pattern survives at two sibling sites in the same file: findSourceFiles (scripts/sync-snippets.ts:450) still calls readdirSync(dir, { withFileTypes: true, recursive: true }) and filters /generated paths out of the collected results afterward, and findMarkdownFiles (scripts/sync-snippets.ts:485) does the same recursive readdir over docs/ with a post-hoc startsWith(generatedApiDir) filter.\n\nThe code path. main() calls findPackageSrcDirs(PACKAGES_DIR) and then invokes findSourceFiles(srcDir) on every src dir the new walker returns, plus findMarkdownFiles(DOCS_DIR) once. So while the outer packages/ scan is now bounded by SKIPPED_DIRS and the symlink-safe entry.isDirectory() check, every inner walk immediately re-enters the unbounded recursive-readdir mode.\n\nWhy the new guard doesn't cover it. SKIPPED_DIRS and the explicit descend closure are local to findPackageSrcDirs — nothing prevents the inner walkers from following a symlink. Verifiers empirically confirmed that Node's recursive readdirSync does follow directory symlinks (a self-referencing link is re-entered until the symloop limit), so one stray symlink inside a package's src/ tree or under docs/ would re-create the very unbounded symlink-graph walk (and heap exhaustion in the pre-push hook) this PR exists to fix.\n\nStep-by-step example. (1) A developer (or a tool) drops a symlink at packages/client/src/some-link pointing at a large tree — or even back up the tree. (2) findPackageSrcDirs correctly returns packages/client/src (the link itself is irrelevant to the outer walk). (3) main() calls findSourceFiles('packages/client/src'), which runs readdirSync(dir, { recursive: true }) — this traversal follows the symlink and collects everything reachable through it into one array before any filter runs. (4) The /generated filter executes only after the full traversal, exactly the "too late" failure mode the PR's own comment describes.\n\nImpact and why it's a nit. Today src/ trees and docs/ contain no symlinks and no node_modules, so nothing breaks on merge — this is a consistency/robustness cleanup, not a live bug. The concrete cost is that the fix lands at one of three call sites of the same pattern in the same file, and future readers get contradictory guidance: a comment declaring the option unsafe sitting next to two live uses of it. This matches the repository's Completeness review convention (partial migrations leaving sibling code paths with the pattern the PR replaces).\n\nHow to fix. Extract the explicit-descend walk into a shared helper — e.g. a walk(dir, { skip: SKIPPED_DIRS, onFile }) that uses withFileTypes: true per level and never enters symlinks — and have all three walkers (findPackageSrcDirs, findSourceFiles, findMarkdownFiles) use it, moving the .examples.ts/.test.ts/generated and docs/api exclusions into the descend/file callbacks so they run before traversal rather than after.

@felixweinberger
felixweinberger merged commit 27a94b5 into main Aug 14, 2026
22 checks passed
@felixweinberger
felixweinberger deleted the fix/sync-snippets-symlink-walk branch August 14, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants