Skip to content

Commit 28b4143

Browse files
fix(scanner): npx resolver prefers real package source over tools.json stub (#661)
The npx cache can hold the same package under multiple hashes: one with the real installed source (package.json + dist/*.js) and another with only a tools.json stub that mcpproxy writes when dumping tool definitions. The stub's mtime is usually newer, so resolveNpxCache's newest-mtime heuristic picked it and the scan reported false coverage (1 stub file instead of ~30 real files). Rank candidates by real-source-ness first (package.json, a dist/lib/build/src subdir, or a JS/TS file at the root), falling back to the newest-mtime tiebreak only within the same class. When EVERY candidate is a bare stub, return an error instead of the stub so the published-source fetch fallback added in MCP-2206 (#658) fetches the real source — rather than short-circuiting it with false 1-file coverage. Related MCP-2397 Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 5889ac8 commit 28b4143

2 files changed

Lines changed: 154 additions & 7 deletions

File tree

internal/security/scanner/source_resolver.go

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,8 +1177,18 @@ func (r *SourceResolver) resolveNpxCache(info ServerInfo) (*ResolvedSource, erro
11771177
}
11781178

11791179
// Search for the package in npx cache: ~/.npm/_npx/<hash>/node_modules/<package>/
1180+
//
1181+
// The SAME package name can appear under multiple npx cache hashes. One may
1182+
// hold the real installed source (package.json + dist/lib/*.js) while another
1183+
// holds only a tools.json stub that mcpproxy itself wrote into the cache when
1184+
// it dumped tool definitions. The stub's mtime is frequently NEWER than the
1185+
// real source (it was just written), so a naive newest-mtime pick selects the
1186+
// stub and the scan reports a false "1 file" coverage (MCP-2397). We therefore
1187+
// prefer candidates that look like real package source, and only fall back to
1188+
// the newest-mtime tiebreak among candidates of the same class.
11801189
var bestMatch string
11811190
var bestModTime int64
1191+
var bestReal bool
11821192

11831193
entries, err := os.ReadDir(npxCacheDir)
11841194
if err != nil {
@@ -1191,22 +1201,51 @@ func (r *SourceResolver) resolveNpxCache(info ServerInfo) (*ResolvedSource, erro
11911201
}
11921202
candidatePath := filepath.Join(npxCacheDir, entry.Name(), "node_modules", pkgName)
11931203
stat, err := os.Stat(candidatePath)
1194-
if err != nil {
1204+
if err != nil || !stat.IsDir() {
11951205
continue
11961206
}
1197-
if stat.IsDir() {
1198-
modTime := stat.ModTime().Unix()
1199-
if modTime > bestModTime {
1200-
bestModTime = modTime
1201-
bestMatch = candidatePath
1202-
}
1207+
real := npxCacheLooksReal(candidatePath)
1208+
modTime := stat.ModTime().Unix()
1209+
1210+
// Selection order:
1211+
// 1. any real-source candidate beats any stub;
1212+
// 2. within the same class, the newest mtime wins.
1213+
better := false
1214+
switch {
1215+
case bestMatch == "":
1216+
better = true
1217+
case real && !bestReal:
1218+
better = true
1219+
case real == bestReal && modTime > bestModTime:
1220+
better = true
1221+
}
1222+
if better {
1223+
bestMatch = candidatePath
1224+
bestModTime = modTime
1225+
bestReal = real
12031226
}
12041227
}
12051228

12061229
if bestMatch == "" {
12071230
return nil, fmt.Errorf("package %q not found in npx cache (%s)", pkgName, npxCacheDir)
12081231
}
12091232

1233+
if !bestReal {
1234+
// Every candidate was a bare stub (e.g. a lone tools.json mcpproxy wrote
1235+
// into the cache) — no real installed source exists locally. Returning the
1236+
// stub here would report false "1 file" coverage AND, post-MCP-2206, would
1237+
// short-circuit the caller's published-source fetch fallback in Resolve(),
1238+
// which can fetch the REAL package source without executing it. So we treat
1239+
// stub-only as "not found" and let that fallback (or a tool_definitions_only
1240+
// degrade when fetch is disabled) take over instead.
1241+
r.logger.Warn("npx cache held only stub directories (no real package source); deferring to published-source fetch fallback",
1242+
zap.String("server", info.Name),
1243+
zap.String("package", pkgName),
1244+
zap.String("stub_path", bestMatch),
1245+
)
1246+
return nil, fmt.Errorf("npx cache for %q contained only stub directories (no real source) in %s", pkgName, npxCacheDir)
1247+
}
1248+
12101249
r.logger.Info("Resolved source from npx cache",
12111250
zap.String("server", info.Name),
12121251
zap.String("package", pkgName),
@@ -1220,6 +1259,38 @@ func (r *SourceResolver) resolveNpxCache(info ServerInfo) (*ResolvedSource, erro
12201259
}, nil
12211260
}
12221261

1262+
// npxCacheLooksReal reports whether an npx cache package directory holds the
1263+
// real installed package rather than a bare stub. Every npm package ships a
1264+
// package.json, so its presence is the canonical marker of real source. A stub
1265+
// directory mcpproxy creates just to hold a dumped tools.json has none. As a
1266+
// secondary signal we also accept a directory that contains a conventional
1267+
// build/source subdirectory (dist/lib/build/src) or any JavaScript/TypeScript
1268+
// source file, in case an unusual package omits package.json at the cache root.
1269+
func npxCacheLooksReal(dir string) bool {
1270+
if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil {
1271+
return true
1272+
}
1273+
for _, sub := range []string{"dist", "lib", "build", "src"} {
1274+
if stat, err := os.Stat(filepath.Join(dir, sub)); err == nil && stat.IsDir() {
1275+
return true
1276+
}
1277+
}
1278+
entries, err := os.ReadDir(dir)
1279+
if err != nil {
1280+
return false
1281+
}
1282+
for _, e := range entries {
1283+
if e.IsDir() {
1284+
continue
1285+
}
1286+
switch strings.ToLower(filepath.Ext(e.Name())) {
1287+
case ".js", ".mjs", ".cjs", ".ts":
1288+
return true
1289+
}
1290+
}
1291+
return false
1292+
}
1293+
12231294
// resolveUvxCache finds a uvx package's source in ~/.cache/uv/ or ~/.local/share/uv/tools/
12241295
func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, error) {
12251296
// Extract package name from args

internal/security/scanner/source_resolver_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path/filepath"
77
"strings"
88
"testing"
9+
"time"
910

1011
"go.uber.org/zap"
1112
)
@@ -434,6 +435,81 @@ func TestResolveNpxCache(t *testing.T) {
434435
}
435436
}
436437

438+
// TestResolveNpxCachePrefersRealSourceOverStub reproduces MCP-2397: when the
439+
// same package name exists under multiple npx cache hashes, one holding the
440+
// real installed source (package.json + dist) and another holding only a
441+
// tools.json stub that mcpproxy itself wrote, the resolver must scan the real
442+
// source even when the stub directory has a NEWER mtime. The old newest-mtime
443+
// heuristic would pick the stub and report false "1 file scanned" coverage.
444+
func TestResolveNpxCachePrefersRealSourceOverStub(t *testing.T) {
445+
homeDir := t.TempDir()
446+
t.Setenv("HOME", homeDir)
447+
t.Setenv("USERPROFILE", homeDir) // Windows uses USERPROFILE
448+
449+
pkg := filepath.Join("node_modules", "@modelcontextprotocol", "server-everything")
450+
451+
// Real source under one hash: package.json + dist/*.js (older mtime).
452+
realDir := filepath.Join(homeDir, ".npm", "_npx", "realhash", pkg)
453+
os.MkdirAll(filepath.Join(realDir, "dist"), 0755)
454+
os.WriteFile(filepath.Join(realDir, "package.json"), []byte(`{"name":"@modelcontextprotocol/server-everything"}`), 0644)
455+
os.WriteFile(filepath.Join(realDir, "dist", "index.js"), []byte("// real server source"), 0644)
456+
457+
// Stub under a different hash: only a tools.json (newer mtime → would win
458+
// under the old heuristic).
459+
stubDir := filepath.Join(homeDir, ".npm", "_npx", "stubhash", pkg)
460+
os.MkdirAll(stubDir, 0755)
461+
os.WriteFile(filepath.Join(stubDir, "tools.json"), []byte(`{"tools":[]}`), 0644)
462+
463+
// Force the stub directory to be strictly newer than the real one so the
464+
// legacy newest-mtime selection would choose the stub.
465+
older := time.Now().Add(-2 * time.Hour)
466+
newer := time.Now()
467+
if err := os.Chtimes(realDir, older, older); err != nil {
468+
t.Fatalf("chtimes real: %v", err)
469+
}
470+
if err := os.Chtimes(stubDir, newer, newer); err != nil {
471+
t.Fatalf("chtimes stub: %v", err)
472+
}
473+
474+
r := NewSourceResolver(zap.NewNop())
475+
result, err := r.resolveNpxCache(ServerInfo{
476+
Name: "everything-server",
477+
Command: "npx",
478+
Args: []string{"-y", "@modelcontextprotocol/server-everything"},
479+
})
480+
if err != nil {
481+
t.Fatalf("resolveNpxCache: %v", err)
482+
}
483+
if result.SourceDir != realDir {
484+
t.Errorf("expected real source %q, got stub %q", realDir, result.SourceDir)
485+
}
486+
}
487+
488+
// TestResolveNpxCacheStubOnlyReturnsError verifies the post-MCP-2206
489+
// interaction: when the npx cache holds ONLY a tools.json stub (no real
490+
// package source anywhere locally), resolveNpxCache returns an error rather
491+
// than the stub. This lets Resolve()'s published-source fetch fallback take
492+
// over and fetch the real source, instead of reporting false "1 file" coverage.
493+
func TestResolveNpxCacheStubOnlyReturnsError(t *testing.T) {
494+
homeDir := t.TempDir()
495+
t.Setenv("HOME", homeDir)
496+
t.Setenv("USERPROFILE", homeDir) // Windows uses USERPROFILE
497+
498+
stubDir := filepath.Join(homeDir, ".npm", "_npx", "stubhash", "node_modules", "@modelcontextprotocol", "server-everything")
499+
os.MkdirAll(stubDir, 0755)
500+
os.WriteFile(filepath.Join(stubDir, "tools.json"), []byte(`{"tools":[]}`), 0644)
501+
502+
r := NewSourceResolver(zap.NewNop())
503+
_, err := r.resolveNpxCache(ServerInfo{
504+
Name: "everything-server",
505+
Command: "npx",
506+
Args: []string{"-y", "@modelcontextprotocol/server-everything"},
507+
})
508+
if err == nil {
509+
t.Fatal("expected error when npx cache contains only a stub, got nil (would short-circuit the published-fetch fallback)")
510+
}
511+
}
512+
437513
func TestResolveNpxCacheNotFound(t *testing.T) {
438514
homeDir := t.TempDir()
439515
t.Setenv("HOME", homeDir)

0 commit comments

Comments
 (0)