Skip to content

Commit 1f1feca

Browse files
committed
fix(scanner): resolve uvx source from ephemeral archive cache (MCP-2400)
resolveUvxCache only checked the persistent `uv tool install` tools dir and git checkouts, so a server launched via plain `uvx <pkg>` — whose wheel is unpacked into the content-addressed ~/.cache/uv/archive-v0 cache, keyed by an opaque hash rather than package name — always missed and fell through to a tool-definitions-only scan. Add a third local-cache strategy that scans archive-v0 entries and matches the target distribution by its wheel .dist-info (robust to dist-name vs import-name differences), supporting both the flat and venv-style layouts and picking the newest entry when several versions are cached. Also strip PEP 508 version specifiers (==, >=, [extras]) from the spec before matching. The container resolver (findContainerTargetDir) already searched archive-v0; this brings the host resolver to parity. Complements the published-source fetch in #658 (MCP-2206) by resolving locally first, avoiding a network round-trip and working in air-gapped deployments. Related #658 Related MCP-2400
1 parent 9f4930c commit 1f1feca

3 files changed

Lines changed: 309 additions & 0 deletions

File tree

docs/features/security-scanner-plugins.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@ Scanners communicate results via SARIF 2.1.0. Exit code `0` indicates "scan comp
244244
5. Tool definitions only — last resort (HTTP / SSE / unresolvable)
245245
```
246246

247+
For `uvx` servers the package cache lookup covers persistent `uv tool install`
248+
locations, git checkouts, **and** the ephemeral `~/.cache/uv/archive-v0` wheel
249+
cache that a plain `uvx <pkg>` populates — so a locally-run Python MCP server
250+
resolves to its real source instead of falling through to a tool-definitions-only
251+
scan.
252+
247253
The resolved method and path are recorded on the scan job and visible via both the text and JSON report. See [Security Commands → scan](/cli/security-commands#security-scan) for more.
248254

249255
## SARIF normalization

internal/security/scanner/source_resolver.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,9 +1266,151 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro
12661266
}, nil
12671267
}
12681268

1269+
// Strategy 3: ephemeral uvx archive cache (~/.cache/uv/archive-v0/<hash>/).
1270+
// `uvx <pkg>` (the common case) never populates the persistent tools dir
1271+
// above — it unpacks the published wheel into a content-addressed archive
1272+
// entry keyed by an opaque hash, not by package name. So a server that HAS
1273+
// been run locally still missed both strategies above and fell through to a
1274+
// tool-definitions-only scan (or, post-MCP-2206, a redundant network fetch).
1275+
// The container resolver (findContainerTargetDir) already searches this
1276+
// cache; the host resolver now does too. Found by globbing every archive
1277+
// entry and matching the wheel's `.dist-info` (robust to dist-name vs
1278+
// import-name differences). git+URL packages live in git-v0 (Strategy 1),
1279+
// not archive-v0, so they are skipped here.
1280+
if !isGitURL {
1281+
archiveRoot := filepath.Join(homeDir, ".cache", "uv", "archive-v0")
1282+
if dir, ok := findUvxArchiveDir(archiveRoot, stripPkgVersion(cleanPkg)); ok {
1283+
r.logger.Info("Resolved source from UV archive cache",
1284+
zap.String("server", info.Name),
1285+
zap.String("package", cleanPkg),
1286+
zap.String("path", dir),
1287+
)
1288+
return &ResolvedSource{
1289+
SourceDir: dir,
1290+
Method: "uvx_cache",
1291+
Cleanup: func() {},
1292+
}, nil
1293+
}
1294+
}
1295+
12691296
return nil, fmt.Errorf("package %q not found in UV cache", pkgName)
12701297
}
12711298

1299+
// stripPkgVersion trims a PEP 508 version specifier and/or extras from a
1300+
// package spec, leaving the bare distribution name. Handles `pkg==1.0`,
1301+
// `pkg>=1.0`, `pkg~=1.0`, `pkg!=1.0`, `pkg[extra]`, and a trailing space/marker.
1302+
// (The `@version` form is already stripped earlier by the caller.)
1303+
func stripPkgVersion(spec string) string {
1304+
cut := len(spec)
1305+
for _, c := range []string{"==", ">=", "<=", "~=", "!=", ">", "<", "[", " ", ";"} {
1306+
if idx := strings.Index(spec, c); idx >= 0 && idx < cut {
1307+
cut = idx
1308+
}
1309+
}
1310+
return strings.TrimSpace(spec[:cut])
1311+
}
1312+
1313+
// normalizeDistName applies PEP 503 / wheel normalization to a Python
1314+
// distribution name: lowercase, with runs of "-", "_" and "." collapsed to a
1315+
// single "_". This matches the form uv writes for `.dist-info` directory names
1316+
// (e.g. "My.Cool-Server" → "my_cool_server"), so a config's free-form package
1317+
// spec can be matched against on-disk wheels.
1318+
func normalizeDistName(name string) string {
1319+
n := strings.ToLower(name)
1320+
n = strings.NewReplacer("-", "_", ".", "_").Replace(n)
1321+
for strings.Contains(n, "__") {
1322+
n = strings.ReplaceAll(n, "__", "_")
1323+
}
1324+
return strings.Trim(n, "_")
1325+
}
1326+
1327+
// findUvxArchiveDir locates the unpacked wheel for distribution pkg inside the
1328+
// uv ephemeral archive cache. Each archive entry (~/.cache/uv/archive-v0/<hash>)
1329+
// holds exactly one unpacked wheel, content-addressed by an opaque hash — so the
1330+
// package is found by scanning entries and matching the wheel's `.dist-info`
1331+
// directory rather than by constructing a name-based path. Both the flat layout
1332+
// (<hash>/<dist>-<ver>.dist-info) and the venv-style layout
1333+
// (<hash>/lib/python*/site-packages/<dist>-<ver>.dist-info) are supported. When
1334+
// multiple entries match (e.g. several cached versions) the most recently
1335+
// modified entry wins, consistent with resolveNpxCache. Returns the archive
1336+
// entry root (the <hash> dir) and ok=true on a hit.
1337+
func findUvxArchiveDir(archiveRoot, pkg string) (string, bool) {
1338+
norm := normalizeDistName(pkg)
1339+
if norm == "" {
1340+
return "", false
1341+
}
1342+
entries, err := os.ReadDir(archiveRoot)
1343+
if err != nil {
1344+
return "", false
1345+
}
1346+
1347+
var best string
1348+
var bestMod int64
1349+
for _, entry := range entries {
1350+
if !entry.IsDir() {
1351+
continue
1352+
}
1353+
entryPath := filepath.Join(archiveRoot, entry.Name())
1354+
// Check the flat layout, then any venv-style site-packages dirs.
1355+
matchDirs := []string{entryPath}
1356+
if sp, _ := filepath.Glob(filepath.Join(entryPath, "lib", "python*", "site-packages")); len(sp) > 0 {
1357+
matchDirs = append(matchDirs, sp...)
1358+
}
1359+
matched := false
1360+
for _, d := range matchDirs {
1361+
if dirHasDistInfo(d, norm) {
1362+
matched = true
1363+
break
1364+
}
1365+
}
1366+
if !matched {
1367+
continue
1368+
}
1369+
modTime := int64(0)
1370+
if fi, err := entry.Info(); err == nil {
1371+
modTime = fi.ModTime().Unix()
1372+
}
1373+
if best == "" || modTime > bestMod {
1374+
best = entryPath
1375+
bestMod = modTime
1376+
}
1377+
}
1378+
if best == "" {
1379+
return "", false
1380+
}
1381+
return best, true
1382+
}
1383+
1384+
// dirHasDistInfo reports whether dir directly contains a `<name>-<version>.dist-info`
1385+
// directory whose normalized distribution name equals norm. A wheel's `.dist-info`
1386+
// name is `{normalized-distribution}-{version}.dist-info`, and the normalized
1387+
// distribution name contains no "-" (those become "_"), so the FIRST "-" cleanly
1388+
// separates name from version.
1389+
func dirHasDistInfo(dir, norm string) bool {
1390+
es, err := os.ReadDir(dir)
1391+
if err != nil {
1392+
return false
1393+
}
1394+
for _, e := range es {
1395+
if !e.IsDir() {
1396+
continue
1397+
}
1398+
name := e.Name()
1399+
if !strings.HasSuffix(strings.ToLower(name), ".dist-info") {
1400+
continue
1401+
}
1402+
base := name[:len(name)-len(".dist-info")]
1403+
idx := strings.Index(base, "-")
1404+
if idx <= 0 {
1405+
continue
1406+
}
1407+
if normalizeDistName(base[:idx]) == norm {
1408+
return true
1409+
}
1410+
}
1411+
return false
1412+
}
1413+
12721414
// findGitCheckoutByRepo searches UV git checkouts for a directory that matches the given repo.
12731415
// It walks checkouts/<hash>/<rev>/ directories and checks for repo-identifying markers
12741416
// (pyproject.toml with matching name, or .git/config with matching URL).

internal/security/scanner/source_resolver_test.go

Lines changed: 161 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
)
@@ -508,6 +509,166 @@ func TestResolveUvxCacheGitCheckout(t *testing.T) {
508509
}
509510
}
510511

512+
// writeUvArchiveWheel materializes a fake unpacked wheel inside a uv archive-v0
513+
// entry. uv content-addresses each wheel by an opaque hash, so the package is
514+
// found by globbing all entries and matching the .dist-info directory — never
515+
// by constructing a name-based path. distName is the (un-normalized) PyPI
516+
// distribution name; it gets PEP 503 normalized (lowercased, -._ → _) for the
517+
// .dist-info name, mirroring what uv writes to disk.
518+
func writeUvArchiveWheel(t *testing.T, homeDir, hash, distName, version, importPkg string, venvStyle bool) string {
519+
t.Helper()
520+
norm := strings.ToLower(distName)
521+
norm = strings.NewReplacer("-", "_", ".", "_").Replace(norm)
522+
entry := filepath.Join(homeDir, ".cache", "uv", "archive-v0", hash)
523+
base := entry
524+
if venvStyle {
525+
base = filepath.Join(entry, "lib", "python3.11", "site-packages")
526+
}
527+
pkgDir := filepath.Join(base, importPkg)
528+
if err := os.MkdirAll(pkgDir, 0755); err != nil {
529+
t.Fatal(err)
530+
}
531+
if err := os.WriteFile(filepath.Join(pkgDir, "__init__.py"), []byte("# server source"), 0644); err != nil {
532+
t.Fatal(err)
533+
}
534+
distInfo := filepath.Join(base, norm+"-"+version+".dist-info")
535+
if err := os.MkdirAll(distInfo, 0755); err != nil {
536+
t.Fatal(err)
537+
}
538+
if err := os.WriteFile(filepath.Join(distInfo, "METADATA"), []byte("Name: "+distName+"\n"), 0644); err != nil {
539+
t.Fatal(err)
540+
}
541+
return entry
542+
}
543+
544+
// TestResolveUvxCacheArchiveFlat verifies the COMMON `uvx <pkg>` case: the
545+
// package was run locally (so its wheel sits unpacked in the ephemeral
546+
// ~/.cache/uv/archive-v0 cache) but was never `uv tool install`-ed, so the
547+
// tools dir is empty. Before MCP-2400 this fell through to
548+
// tool_definitions_only / a network fetch; now it resolves from local cache.
549+
func TestResolveUvxCacheArchiveFlat(t *testing.T) {
550+
homeDir := t.TempDir()
551+
t.Setenv("HOME", homeDir)
552+
t.Setenv("USERPROFILE", homeDir)
553+
554+
entry := writeUvArchiveWheel(t, homeDir, "Ab12Cd34Ef56", "mcp-server-fetch", "1.2.3", "mcp_server_fetch", false)
555+
556+
r := NewSourceResolver(zap.NewNop())
557+
result, err := r.resolveUvxCache(ServerInfo{
558+
Name: "fetch",
559+
Command: "uvx",
560+
Args: []string{"mcp-server-fetch"},
561+
})
562+
if err != nil {
563+
t.Fatalf("resolveUvxCache: %v", err)
564+
}
565+
if result.SourceDir != entry {
566+
t.Errorf("expected source_dir %q, got %q", entry, result.SourceDir)
567+
}
568+
if result.Method != "uvx_cache" {
569+
t.Errorf("expected method 'uvx_cache', got %q", result.Method)
570+
}
571+
}
572+
573+
// TestResolveUvxCacheArchiveVenvStyle verifies the venv-style archive layout
574+
// (<hash>/lib/python*/site-packages/<pkg>) is also discovered.
575+
func TestResolveUvxCacheArchiveVenvStyle(t *testing.T) {
576+
homeDir := t.TempDir()
577+
t.Setenv("HOME", homeDir)
578+
t.Setenv("USERPROFILE", homeDir)
579+
580+
entry := writeUvArchiveWheel(t, homeDir, "Zz99Yy88Xx77", "some-tool", "0.4.0", "some_tool", true)
581+
582+
r := NewSourceResolver(zap.NewNop())
583+
result, err := r.resolveUvxCache(ServerInfo{
584+
Name: "some-tool",
585+
Command: "uvx",
586+
Args: []string{"--from", "some-tool", "some-tool"},
587+
})
588+
if err != nil {
589+
t.Fatalf("resolveUvxCache: %v", err)
590+
}
591+
if result.SourceDir != entry {
592+
t.Errorf("expected source_dir %q, got %q", entry, result.SourceDir)
593+
}
594+
}
595+
596+
// TestResolveUvxCacheArchiveNameNormalization verifies a uvx spec with a
597+
// version pin and hyphen/case differences still matches the underscore-
598+
// normalized .dist-info on disk.
599+
func TestResolveUvxCacheArchiveNameNormalization(t *testing.T) {
600+
homeDir := t.TempDir()
601+
t.Setenv("HOME", homeDir)
602+
t.Setenv("USERPROFILE", homeDir)
603+
604+
entry := writeUvArchiveWheel(t, homeDir, "Hash1234", "My.Cool-Server", "2.0.0", "my_cool_server", false)
605+
606+
r := NewSourceResolver(zap.NewNop())
607+
result, err := r.resolveUvxCache(ServerInfo{
608+
Name: "cool",
609+
Command: "uvx",
610+
Args: []string{"My.Cool-Server==2.0.0"},
611+
})
612+
if err != nil {
613+
t.Fatalf("resolveUvxCache: %v", err)
614+
}
615+
if result.SourceDir != entry {
616+
t.Errorf("expected source_dir %q, got %q", entry, result.SourceDir)
617+
}
618+
}
619+
620+
// TestResolveUvxCacheArchiveNewestWins verifies that when the same distribution
621+
// is cached at multiple versions (multiple archive hashes), the most recently
622+
// modified entry is chosen — consistent with resolveNpxCache.
623+
func TestResolveUvxCacheArchiveNewestWins(t *testing.T) {
624+
homeDir := t.TempDir()
625+
t.Setenv("HOME", homeDir)
626+
t.Setenv("USERPROFILE", homeDir)
627+
628+
old := writeUvArchiveWheel(t, homeDir, "old0000", "dup-pkg", "1.0.0", "dup_pkg", false)
629+
newer := writeUvArchiveWheel(t, homeDir, "new1111", "dup-pkg", "2.0.0", "dup_pkg", false)
630+
// Force a deterministic ordering of mod times.
631+
past := time.Now().Add(-2 * time.Hour)
632+
if err := os.Chtimes(old, past, past); err != nil {
633+
t.Fatal(err)
634+
}
635+
636+
r := NewSourceResolver(zap.NewNop())
637+
result, err := r.resolveUvxCache(ServerInfo{
638+
Name: "dup",
639+
Command: "uvx",
640+
Args: []string{"dup-pkg"},
641+
})
642+
if err != nil {
643+
t.Fatalf("resolveUvxCache: %v", err)
644+
}
645+
if result.SourceDir != newer {
646+
t.Errorf("expected newest entry %q, got %q", newer, result.SourceDir)
647+
}
648+
_ = old
649+
}
650+
651+
// TestResolveUvxCacheArchiveNoMatch verifies a clean error (fall-through) when
652+
// the package is not present in any local uv cache.
653+
func TestResolveUvxCacheArchiveNoMatch(t *testing.T) {
654+
homeDir := t.TempDir()
655+
t.Setenv("HOME", homeDir)
656+
t.Setenv("USERPROFILE", homeDir)
657+
658+
// A different package is cached; the requested one is absent.
659+
writeUvArchiveWheel(t, homeDir, "other999", "unrelated-pkg", "1.0.0", "unrelated_pkg", false)
660+
661+
r := NewSourceResolver(zap.NewNop())
662+
_, err := r.resolveUvxCache(ServerInfo{
663+
Name: "missing",
664+
Command: "uvx",
665+
Args: []string{"definitely-not-cached"},
666+
})
667+
if err == nil {
668+
t.Error("expected error when package absent from uv cache")
669+
}
670+
}
671+
511672
// TestSourceResolverNpxFilesystemArgNotPicked verifies that a filesystem-style
512673
// positional argument (e.g. `/tmp/mydata` for @modelcontextprotocol/server-filesystem)
513674
// is NOT picked up as the server's source directory. Package-runner commands

0 commit comments

Comments
 (0)