@@ -174,7 +174,7 @@ func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*Resolve
174174 // a server like `npx @modelcontextprotocol/server-filesystem /tmp/data`
175175 // from picking up the user's data dir (`/tmp/data`) as the server
176176 // source — the arg is the filesystem server's allowed root, not code.
177- if info .Command != "" && isPackageRunnerCommand (info .Command ) {
177+ if info .Command != "" && isPackageRunnerCommand (info .Command , info . Args ) {
178178 if resolved , err := r .resolveFromPackageCache (ctx , info ); err == nil {
179179 return resolved , nil
180180 } else {
@@ -256,7 +256,7 @@ func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*Resolve
256256
257257 // Last resort: try the package cache for any other command (e.g. the user
258258 // has an absolute path to node_modules that we didn't match above).
259- if info .Command != "" && ! isPackageRunnerCommand (info .Command ) {
259+ if info .Command != "" && ! isPackageRunnerCommand (info .Command , info . Args ) {
260260 if resolved , err := r .resolveFromPackageCache (ctx , info ); err == nil {
261261 return resolved , nil
262262 }
@@ -267,7 +267,7 @@ func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*Resolve
267267 // a quarantined-on-add server is never run locally, so the local cache above
268268 // always misses (MCP-2206). Fetching real source lets the AI + supply-chain
269269 // scanners run instead of degrading to tool_definitions_only.
270- if r .fetchPackageSource && info .Command != "" && isPackageRunnerCommand (info .Command ) {
270+ if r .fetchPackageSource && info .Command != "" && isPackageRunnerCommand (info .Command , info . Args ) {
271271 if resolved , err := r .resolveFromPackageFetch (ctx , info ); err == nil {
272272 return resolved , nil
273273 } else {
@@ -286,15 +286,22 @@ func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*Resolve
286286// a remote registry rather than running local source code. For these, the
287287// server source lives in the package manager's cache, not in any positional
288288// argument.
289- func isPackageRunnerCommand (command string ) bool {
289+ //
290+ // Classification is ARGUMENT-AWARE for subcommand-style runners (MCP-2445):
291+ // npx/uvx/bunx always run a remote package by name, but pnpm/yarn/bun/pipx are
292+ // general multi-command tools — they are runners ONLY when their ephemeral-run
293+ // keyword (`dlx`/`x`/`run`) is actually present. Classifying `pnpm start` or
294+ // `bun server.ts` as a runner by name alone would route a local invocation into
295+ // the package cache/fetch path and scan the wrong token. We decide this up front
296+ // rather than relying on a later parser failure.
297+ func isPackageRunnerCommand (command string , args []string ) bool {
290298 base := strings .ToLower (filepath .Base (command ))
291299 switch base {
292- // pnpm/yarn are runners only in their `dlx` form; that is the only way an MCP
293- // server is launched from them, and resolveFromPackageFetch dispatches pnpm to
294- // the npm fetch path. Including them here lets that fetch fallback actually run
295- // (previously pnpm was dispatched but never gated true here — a dead branch).
296- case "npx" , "uvx" , "pipx" , "bunx" , "bun" , "pnpm" , "yarn" :
300+ case "npx" , "uvx" , "bunx" :
297301 return true
302+ case "pipx" , "pnpm" , "yarn" , "bun" :
303+ // Runner only when the ephemeral-run keyword yields a resolvable package.
304+ return runnerPackageSpec (base , args ) != ""
298305 }
299306 return false
300307}
@@ -792,19 +799,20 @@ func uvxTargetPackage(info ServerInfo) string {
792799// embedding. Factored out so the shell logic is unit-testable on the host.
793800func npxContainerLocateScript (npxGlobRoot , pkg , version string ) string {
794801 pkgEsc := strings .ReplaceAll (pkg , "'" , `'\''` )
795- verClause := ""
796802 if version != "" {
797- verEsc := strings .ReplaceAll (version , "'" , `'\''` )
798- // The .dist-info-free npm case: match the version inside package.json. The
803+ // Pinned: return ONLY a bucket whose package.json declares that exact
804+ // version. A pin-miss returns nothing — never a mismatched version, which
805+ // would scan the wrong code and report false coverage (MCP-2445). The
799806 // pattern tolerates arbitrary JSON spacing around the colon.
800- verClause = "if grep -Eq '\" version\" [[:space:]]*:[[:space:]]*\" " + verEsc + "\" ' \" $d/package.json\" 2>/dev/null; then printf '%s\\ n' \" $d\" ; exit 0; fi; "
801- }
802- return "first=''; for d in " + npxGlobRoot + "/*/node_modules/'" + pkgEsc + "'; do " +
803- "[ -d \" $d\" ] || continue; " +
804- "[ -z \" $first\" ] && first=\" $d\" ; " +
805- verClause +
806- "done; " +
807- "[ -n \" $first\" ] && printf '%s\\ n' \" $first\" "
807+ verEsc := strings .ReplaceAll (version , "'" , `'\''` )
808+ return "for d in " + npxGlobRoot + "/*/node_modules/'" + pkgEsc + "'; do " +
809+ "[ -d \" $d\" ] || continue; " +
810+ "if grep -Eq '\" version\" [[:space:]]*:[[:space:]]*\" " + verEsc + "\" ' \" $d/package.json\" 2>/dev/null; then printf '%s\\ n' \" $d\" ; exit 0; fi; " +
811+ "done"
812+ }
813+ // Unpinned: first matching bucket.
814+ return "for d in " + npxGlobRoot + "/*/node_modules/'" + pkgEsc + "'; do " +
815+ "[ -d \" $d\" ] || continue; printf '%s\\ n' \" $d\" ; exit 0; done"
808816}
809817
810818// findContainerTargetDir locates the target package's directory inside a
@@ -951,7 +959,7 @@ func (r *SourceResolver) ResolveFullSource(ctx context.Context, info ServerInfo)
951959 // source without executing it (MCP-2206). Same rationale as Pass 1 — without
952960 // a local container or cache, Pass 2 supply-chain scanning would otherwise
953961 // have nothing to analyze for npx/uvx servers.
954- if r .fetchPackageSource && info .Command != "" && isPackageRunnerCommand (info .Command ) {
962+ if r .fetchPackageSource && info .Command != "" && isPackageRunnerCommand (info .Command , info . Args ) {
955963 if resolved , err := r .resolveFromPackageFetch (ctx , info ); err == nil {
956964 return resolved , nil
957965 }
@@ -1347,6 +1355,19 @@ func (r *SourceResolver) resolveNpxCache(info ServerInfo) (*ResolvedSource, erro
13471355 return nil , fmt .Errorf ("package %q not found in npx cache (%s)" , pkgName , npxCacheDir )
13481356 }
13491357
1358+ if wantVersion != "" && ! bestVerMatch {
1359+ // An exact version was pinned but no cached candidate declares it. Do NOT
1360+ // substitute a different cached version — scanning the wrong code reports
1361+ // false coverage (MCP-2445). Defer to the published-source fetch fallback,
1362+ // which fetches the PINNED version, or degrade to tool_definitions_only.
1363+ r .logger .Warn ("npx cache has the package but not the pinned version; deferring to published-source fetch" ,
1364+ zap .String ("server" , info .Name ),
1365+ zap .String ("package" , pkgName ),
1366+ zap .String ("pinned_version" , wantVersion ),
1367+ )
1368+ return nil , fmt .Errorf ("npx cache for %q has no entry matching pinned version %q (avoiding mismatched-version scan)" , pkgName , wantVersion )
1369+ }
1370+
13501371 if ! bestReal {
13511372 // Every candidate was a bare stub (e.g. a lone tools.json mcpproxy wrote
13521373 // into the cache) — no real installed source exists locally. Returning the
@@ -1443,6 +1464,13 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro
14431464 // Check if it's a git URL: git+https://github.com/...
14441465 isGitURL := strings .HasPrefix (pkgName , "git+" )
14451466
1467+ // Exact version pin (if any). Only meaningful for registry packages — git
1468+ // specs pin via the URL ref, not a PEP 440 version.
1469+ wantVersion := ""
1470+ if ! isGitURL {
1471+ _ , wantVersion = parsePackageSpec (pkgName )
1472+ }
1473+
14461474 homeDir , err := os .UserHomeDir ()
14471475 if err != nil {
14481476 return nil , fmt .Errorf ("cannot determine home directory: %w" , err )
@@ -1474,33 +1502,48 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro
14741502 }
14751503 }
14761504
1477- // Strategy 2: UV tools directory (for regular packages)
1478- // Strip version: package@version → package
1505+ // Strategy 2: UV tools directory (for regular packages). Reduce the spec to a
1506+ // bare distribution name: strip a git+ URL to its repo, otherwise strip the
1507+ // `@version` form AND any PEP 440 specifier (`==`, `>=`, extras …) — the latter
1508+ // was previously missed, so a `pkg==1.0` spec produced a bogus `tools/pkg==1.0`
1509+ // path that never matched.
14791510 cleanPkg := pkgName
1480- if idx := strings .LastIndex (cleanPkg , "@" ); idx > 0 {
1481- cleanPkg = cleanPkg [:idx ]
1482- }
1483- // Also strip git+ prefix and URL
14841511 if strings .HasPrefix (cleanPkg , "git+" ) {
14851512 // Extract package name from URL: git+https://github.com/org/repo → repo
14861513 parts := strings .Split (cleanPkg , "/" )
14871514 if len (parts ) > 0 {
14881515 cleanPkg = parts [len (parts )- 1 ]
14891516 }
1517+ } else {
1518+ if idx := strings .LastIndex (cleanPkg , "@" ); idx > 0 {
1519+ cleanPkg = cleanPkg [:idx ]
1520+ }
1521+ cleanPkg = stripPkgVersion (cleanPkg )
14901522 }
14911523
14921524 toolsDir := filepath .Join (homeDir , ".local" , "share" , "uv" , "tools" , cleanPkg )
14931525 if stat , err := os .Stat (toolsDir ); err == nil && stat .IsDir () {
1494- r .logger .Info ("Resolved source from UV tools directory" ,
1526+ // When a version is pinned, only accept the tools dir if it actually holds
1527+ // that version — otherwise fall through so the published-source fetch gets
1528+ // the PINNED version instead of scanning whatever was `uv tool install`-ed
1529+ // (MCP-2445: never substitute a mismatched version).
1530+ if wantVersion == "" || uvDirHasVersion (toolsDir , stripPkgVersion (cleanPkg ), wantVersion ) {
1531+ r .logger .Info ("Resolved source from UV tools directory" ,
1532+ zap .String ("server" , info .Name ),
1533+ zap .String ("package" , cleanPkg ),
1534+ zap .String ("path" , toolsDir ),
1535+ )
1536+ return & ResolvedSource {
1537+ SourceDir : toolsDir ,
1538+ Method : "uvx_cache" ,
1539+ Cleanup : func () {},
1540+ }, nil
1541+ }
1542+ r .logger .Warn ("UV tools dir present but not the pinned version; deferring to archive/published-source fetch" ,
14951543 zap .String ("server" , info .Name ),
14961544 zap .String ("package" , cleanPkg ),
1497- zap .String ("path " , toolsDir ),
1545+ zap .String ("pinned_version " , wantVersion ),
14981546 )
1499- return & ResolvedSource {
1500- SourceDir : toolsDir ,
1501- Method : "uvx_cache" ,
1502- Cleanup : func () {},
1503- }, nil
15041547 }
15051548
15061549 // Strategy 3: ephemeral uvx archive cache (~/.cache/uv/archive-v0/<hash>/).
@@ -1516,9 +1559,8 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro
15161559 // not archive-v0, so they are skipped here.
15171560 if ! isGitURL {
15181561 archiveRoot := filepath .Join (homeDir , ".cache" , "uv" , "archive-v0" )
1519- // Honor an exact version pin: prefer the archive entry whose .dist-info
1520- // declares the pinned version over a newer-but-mismatched one (MCP-2445).
1521- _ , wantVersion := parsePackageSpec (pkgName )
1562+ // Honor an exact version pin: select the archive entry whose .dist-info
1563+ // declares the pinned version; a pin-miss resolves nothing (MCP-2445).
15221564 if dir , ok := findUvxArchiveDir (archiveRoot , stripPkgVersion (cleanPkg ), wantVersion ); ok {
15231565 r .logger .Info ("Resolved source from UV archive cache" ,
15241566 zap .String ("server" , info .Name ),
@@ -1635,9 +1677,36 @@ func findUvxArchiveDir(archiveRoot, pkg, wantVersion string) (string, bool) {
16351677 if best == "" {
16361678 return "" , false
16371679 }
1680+ if wantVersion != "" && ! bestVerMatch {
1681+ // Pinned version requested but no archive entry declares it. Do not
1682+ // substitute a different cached version (false coverage) — report not found
1683+ // so the caller fetches the pinned version or degrades (MCP-2445).
1684+ return "" , false
1685+ }
16381686 return best , true
16391687}
16401688
1689+ // uvDirHasVersion reports whether a uv venv-style directory (a `uv tool install`
1690+ // tools dir) holds distribution pkg at exactly version. It looks for a matching
1691+ // `<dist>-<version>.dist-info` under any `lib/python*/site-packages`. Used to keep
1692+ // a version pin honest against the persistent tools dir.
1693+ func uvDirHasVersion (root , pkg , version string ) bool {
1694+ if version == "" {
1695+ return false
1696+ }
1697+ norm := normalizeDistName (pkg )
1698+ if norm == "" {
1699+ return false
1700+ }
1701+ sps , _ := filepath .Glob (filepath .Join (root , "lib" , "python*" , "site-packages" ))
1702+ for _ , d := range sps {
1703+ if _ , vm := distInfoMatch (d , norm , version ); vm {
1704+ return true
1705+ }
1706+ }
1707+ return false
1708+ }
1709+
16411710// distInfoMatch reports whether dir directly contains a `<name>-<version>.dist-info`
16421711// directory whose normalized distribution name equals norm (nameMatch), and — when
16431712// wantVersion is non-empty — whether that directory's version equals wantVersion
0 commit comments