From 9d5cbb208743d35daab1b465bd7faaa0d1848962 Mon Sep 17 00:00:00 2001 From: Jason G <41053218+gianghungtien@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:09:54 +0700 Subject: [PATCH] fix(memory, sequentialthinking, filesystem): declare zod as a dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The memory, sequentialthinking and filesystem servers all `import { z } from "zod"` but never declared zod in their package.json. The import resolved only by accident: the MCP SDK depends on zod, and npm's hoisted node_modules layout happens to place it where these packages can reach it. Under a strict node_modules layout (pnpm without hoisting, yarn PnP) the phantom dependency is not reachable and every one of these servers dies on startup: Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'zod' imported from .../@modelcontextprotocol/server-memory/dist/index.js Declare zod explicitly in all three packages. The range `^4.0.0` matches the everything server, which already declared it, and sits inside the SDK's `^3.25 || ^4.0` peer range, so zod still dedupes to a single instance shared with the SDK — schemas built here stay recognisable to the SDK. Add a per-package regression test that scans the shipped sources for bare import specifiers and asserts each one is declared, so a phantom dependency cannot silently return. Fixes #4330 --- package-lock.json | 9 ++- src/filesystem/__tests__/dependencies.test.ts | 56 +++++++++++++++++++ src/filesystem/package.json | 3 +- src/memory/__tests__/dependencies.test.ts | 56 +++++++++++++++++++ src/memory/package.json | 3 +- .../__tests__/dependencies.test.ts | 56 +++++++++++++++++++ src/sequentialthinking/package.json | 3 +- 7 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 src/filesystem/__tests__/dependencies.test.ts create mode 100644 src/memory/__tests__/dependencies.test.ts create mode 100644 src/sequentialthinking/__tests__/dependencies.test.ts diff --git a/package-lock.json b/package-lock.json index 26261a7ade..83d862664b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3835,7 +3835,8 @@ "@modelcontextprotocol/sdk": "^1.29.0", "diff": "^8.0.3", "glob": "^10.5.0", - "minimatch": "^10.0.1" + "minimatch": "^10.0.1", + "zod": "^4.0.0" }, "bin": { "mcp-server-filesystem": "dist/index.js" @@ -3855,7 +3856,8 @@ "version": "0.6.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" }, "bin": { "mcp-server-memory": "dist/index.js" @@ -3875,7 +3877,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "chalk": "^5.3.0", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "zod": "^4.0.0" }, "bin": { "mcp-server-sequential-thinking": "dist/index.js" diff --git a/src/filesystem/__tests__/dependencies.test.ts b/src/filesystem/__tests__/dependencies.test.ts new file mode 100644 index 0000000000..5624195872 --- /dev/null +++ b/src/filesystem/__tests__/dependencies.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { builtinModules } from 'module'; + +/** + * Guards against phantom dependencies: modules that are imported by the shipped + * source but not declared in package.json. Such imports resolve by accident + * under npm's hoisted node_modules layout, then fail at startup for anyone + * using a strict layout (pnpm, yarn PnP) with ERR_MODULE_NOT_FOUND. + */ +describe('declared dependencies', () => { + const packageDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + + const packageJson = JSON.parse( + readFileSync(path.join(packageDir, 'package.json'), 'utf-8') + ) as { dependencies?: Record }; + + // Source files that get compiled into dist/, i.e. everything except tests and configs. + const sourceFiles = readdirSync(packageDir) + .filter((file) => file.endsWith('.ts')) + .filter((file) => !file.endsWith('.test.ts') && file !== 'vitest.config.ts'); + + const IMPORT_PATTERN = /(?:import|export)[\s\S]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + + /** Reduces a specifier like `zod/v4` or `@scope/pkg/sub` to its package name. */ + const toPackageName = (specifier: string): string => { + const segments = specifier.split('/'); + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; + }; + + const isBareSpecifier = (specifier: string): boolean => + !specifier.startsWith('.') && + !specifier.startsWith('node:') && + !builtinModules.includes(toPackageName(specifier)); + + it('finds source files to check', () => { + expect(sourceFiles.length).toBeGreaterThan(0); + }); + + it.each(sourceFiles)('%s imports only declared packages', (file) => { + const contents = readFileSync(path.join(packageDir, file), 'utf-8'); + const declared = Object.keys(packageJson.dependencies ?? {}); + + const imported = new Set(); + for (const match of contents.matchAll(IMPORT_PATTERN)) { + const specifier = match[1] ?? match[2]; + if (specifier && isBareSpecifier(specifier)) { + imported.add(toPackageName(specifier)); + } + } + + expect(declared).toEqual(expect.arrayContaining([...imported])); + }); +}); diff --git a/src/filesystem/package.json b/src/filesystem/package.json index c943ce24d3..fbc8e58f8b 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -28,7 +28,8 @@ "@modelcontextprotocol/sdk": "^1.29.0", "diff": "^8.0.3", "glob": "^10.5.0", - "minimatch": "^10.0.1" + "minimatch": "^10.0.1", + "zod": "^4.0.0" }, "devDependencies": { "@types/diff": "^5.0.9", diff --git a/src/memory/__tests__/dependencies.test.ts b/src/memory/__tests__/dependencies.test.ts new file mode 100644 index 0000000000..5624195872 --- /dev/null +++ b/src/memory/__tests__/dependencies.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { builtinModules } from 'module'; + +/** + * Guards against phantom dependencies: modules that are imported by the shipped + * source but not declared in package.json. Such imports resolve by accident + * under npm's hoisted node_modules layout, then fail at startup for anyone + * using a strict layout (pnpm, yarn PnP) with ERR_MODULE_NOT_FOUND. + */ +describe('declared dependencies', () => { + const packageDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + + const packageJson = JSON.parse( + readFileSync(path.join(packageDir, 'package.json'), 'utf-8') + ) as { dependencies?: Record }; + + // Source files that get compiled into dist/, i.e. everything except tests and configs. + const sourceFiles = readdirSync(packageDir) + .filter((file) => file.endsWith('.ts')) + .filter((file) => !file.endsWith('.test.ts') && file !== 'vitest.config.ts'); + + const IMPORT_PATTERN = /(?:import|export)[\s\S]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + + /** Reduces a specifier like `zod/v4` or `@scope/pkg/sub` to its package name. */ + const toPackageName = (specifier: string): string => { + const segments = specifier.split('/'); + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; + }; + + const isBareSpecifier = (specifier: string): boolean => + !specifier.startsWith('.') && + !specifier.startsWith('node:') && + !builtinModules.includes(toPackageName(specifier)); + + it('finds source files to check', () => { + expect(sourceFiles.length).toBeGreaterThan(0); + }); + + it.each(sourceFiles)('%s imports only declared packages', (file) => { + const contents = readFileSync(path.join(packageDir, file), 'utf-8'); + const declared = Object.keys(packageJson.dependencies ?? {}); + + const imported = new Set(); + for (const match of contents.matchAll(IMPORT_PATTERN)) { + const specifier = match[1] ?? match[2]; + if (specifier && isBareSpecifier(specifier)) { + imported.add(toPackageName(specifier)); + } + } + + expect(declared).toEqual(expect.arrayContaining([...imported])); + }); +}); diff --git a/src/memory/package.json b/src/memory/package.json index e32d25a3e2..1a57ccc5dc 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -25,7 +25,8 @@ "test": "vitest run --coverage" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" }, "devDependencies": { "@types/node": "^22", diff --git a/src/sequentialthinking/__tests__/dependencies.test.ts b/src/sequentialthinking/__tests__/dependencies.test.ts new file mode 100644 index 0000000000..5624195872 --- /dev/null +++ b/src/sequentialthinking/__tests__/dependencies.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { builtinModules } from 'module'; + +/** + * Guards against phantom dependencies: modules that are imported by the shipped + * source but not declared in package.json. Such imports resolve by accident + * under npm's hoisted node_modules layout, then fail at startup for anyone + * using a strict layout (pnpm, yarn PnP) with ERR_MODULE_NOT_FOUND. + */ +describe('declared dependencies', () => { + const packageDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + + const packageJson = JSON.parse( + readFileSync(path.join(packageDir, 'package.json'), 'utf-8') + ) as { dependencies?: Record }; + + // Source files that get compiled into dist/, i.e. everything except tests and configs. + const sourceFiles = readdirSync(packageDir) + .filter((file) => file.endsWith('.ts')) + .filter((file) => !file.endsWith('.test.ts') && file !== 'vitest.config.ts'); + + const IMPORT_PATTERN = /(?:import|export)[\s\S]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + + /** Reduces a specifier like `zod/v4` or `@scope/pkg/sub` to its package name. */ + const toPackageName = (specifier: string): string => { + const segments = specifier.split('/'); + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; + }; + + const isBareSpecifier = (specifier: string): boolean => + !specifier.startsWith('.') && + !specifier.startsWith('node:') && + !builtinModules.includes(toPackageName(specifier)); + + it('finds source files to check', () => { + expect(sourceFiles.length).toBeGreaterThan(0); + }); + + it.each(sourceFiles)('%s imports only declared packages', (file) => { + const contents = readFileSync(path.join(packageDir, file), 'utf-8'); + const declared = Object.keys(packageJson.dependencies ?? {}); + + const imported = new Set(); + for (const match of contents.matchAll(IMPORT_PATTERN)) { + const specifier = match[1] ?? match[2]; + if (specifier && isBareSpecifier(specifier)) { + imported.add(toPackageName(specifier)); + } + } + + expect(declared).toEqual(expect.arrayContaining([...imported])); + }); +}); diff --git a/src/sequentialthinking/package.json b/src/sequentialthinking/package.json index a88de803b5..e6a81dafbc 100644 --- a/src/sequentialthinking/package.json +++ b/src/sequentialthinking/package.json @@ -27,7 +27,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "chalk": "^5.3.0", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "zod": "^4.0.0" }, "devDependencies": { "@types/node": "^22",