Skip to content

Commit be485ad

Browse files
authored
fix(scanner): resolve uvx source from ephemeral archive cache (MCP-2400) (#664)
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 7553b44 commit be485ad

3 files changed

Lines changed: 308 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
@@ -245,6 +245,12 @@ Scanners communicate results via SARIF 2.1.0. Exit code `0` indicates "scan comp
245245
6. Tool definitions only — last resort (HTTP / SSE / unresolvable)
246246
```
247247

248+
For `uvx` servers the package cache lookup (step 2) covers persistent `uv tool install`
249+
locations, git checkouts, **and** the ephemeral `~/.cache/uv/archive-v0` wheel
250+
cache that a plain `uvx <pkg>` populates — so a locally-run Python MCP server
251+
resolves to its real source from the local cache (no network) before step 5's
252+
published fetch is reached, and works in air-gapped deployments.
253+
248254
The resolved method and path are recorded on the scan job and visible via both the text and JSON report. The `source_method` field reports how source was obtained: `docker_extract`, `npx_cache`, `uvx_cache`, `working_dir`, `npm_pack`, `pip_download`, `url`, or `tool_definitions_only`. See [Security Commands → scan](/cli/security-commands#security-scan) for more.
249255

250256
#### Published package fetch (npx / uvx)

internal/security/scanner/source_resolver.go

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

1490+
// Strategy 3: ephemeral uvx archive cache (~/.cache/uv/archive-v0/<hash>/).
1491+
// `uvx <pkg>` (the common case) never populates the persistent tools dir
1492+
// above — it unpacks the published wheel into a content-addressed archive
1493+
// entry keyed by an opaque hash, not by package name. So a server that HAS
1494+
// been run locally still missed both strategies above and fell through to a
1495+
// tool-definitions-only scan (or, post-MCP-2206, a redundant network fetch).
1496+
// The container resolver (findContainerTargetDir) already searches this
1497+
// cache; the host resolver now does too. Found by globbing every archive
1498+
// entry and matching the wheel's `.dist-info` (robust to dist-name vs
1499+
// import-name differences). git+URL packages live in git-v0 (Strategy 1),
1500+
// not archive-v0, so they are skipped here.
1501+
if !isGitURL {
1502+
archiveRoot := filepath.Join(homeDir, ".cache", "uv", "archive-v0")
1503+
if dir, ok := findUvxArchiveDir(archiveRoot, stripPkgVersion(cleanPkg)); ok {
1504+
r.logger.Info("Resolved source from UV archive cache",
1505+
zap.String("server", info.Name),
1506+
zap.String("package", cleanPkg),
1507+
zap.String("path", dir),
1508+
)
1509+
return &ResolvedSource{
1510+
SourceDir: dir,
1511+
Method: "uvx_cache",
1512+
Cleanup: func() {},
1513+
}, nil
1514+
}
1515+
}
1516+
14901517
return nil, fmt.Errorf("package %q not found in UV cache", pkgName)
14911518
}
14921519

1520+
// stripPkgVersion trims a PEP 508 version specifier and/or extras from a
1521+
// package spec, leaving the bare distribution name. Handles `pkg==1.0`,
1522+
// `pkg>=1.0`, `pkg~=1.0`, `pkg!=1.0`, `pkg[extra]`, and a trailing space/marker.
1523+
// (The `@version` form is already stripped earlier by the caller.)
1524+
func stripPkgVersion(spec string) string {
1525+
cut := len(spec)
1526+
for _, c := range []string{"==", ">=", "<=", "~=", "!=", ">", "<", "[", " ", ";"} {
1527+
if idx := strings.Index(spec, c); idx >= 0 && idx < cut {
1528+
cut = idx
1529+
}
1530+
}
1531+
return strings.TrimSpace(spec[:cut])
1532+
}
1533+
1534+
// normalizeDistName applies PEP 503 / wheel normalization to a Python
1535+
// distribution name: lowercase, with runs of "-", "_" and "." collapsed to a
1536+
// single "_". This matches the form uv writes for `.dist-info` directory names
1537+
// (e.g. "My.Cool-Server" → "my_cool_server"), so a config's free-form package
1538+
// spec can be matched against on-disk wheels.
1539+
func normalizeDistName(name string) string {
1540+
n := strings.ToLower(name)
1541+
n = strings.NewReplacer("-", "_", ".", "_").Replace(n)
1542+
for strings.Contains(n, "__") {
1543+
n = strings.ReplaceAll(n, "__", "_")
1544+
}
1545+
return strings.Trim(n, "_")
1546+
}
1547+
1548+
// findUvxArchiveDir locates the unpacked wheel for distribution pkg inside the
1549+
// uv ephemeral archive cache. Each archive entry (~/.cache/uv/archive-v0/<hash>)
1550+
// holds exactly one unpacked wheel, content-addressed by an opaque hash — so the
1551+
// package is found by scanning entries and matching the wheel's `.dist-info`
1552+
// directory rather than by constructing a name-based path. Both the flat layout
1553+
// (<hash>/<dist>-<ver>.dist-info) and the venv-style layout
1554+
// (<hash>/lib/python*/site-packages/<dist>-<ver>.dist-info) are supported. When
1555+
// multiple entries match (e.g. several cached versions) the most recently
1556+
// modified entry wins, consistent with resolveNpxCache. Returns the archive
1557+
// entry root (the <hash> dir) and ok=true on a hit.
1558+
func findUvxArchiveDir(archiveRoot, pkg string) (string, bool) {
1559+
norm := normalizeDistName(pkg)
1560+
if norm == "" {
1561+
return "", false
1562+
}
1563+
entries, err := os.ReadDir(archiveRoot)
1564+
if err != nil {
1565+
return "", false
1566+
}
1567+
1568+
var best string
1569+
var bestMod int64
1570+
for _, entry := range entries {
1571+
if !entry.IsDir() {
1572+
continue
1573+
}
1574+
entryPath := filepath.Join(archiveRoot, entry.Name())
1575+
// Check the flat layout, then any venv-style site-packages dirs.
1576+
matchDirs := []string{entryPath}
1577+
if sp, _ := filepath.Glob(filepath.Join(entryPath, "lib", "python*", "site-packages")); len(sp) > 0 {
1578+
matchDirs = append(matchDirs, sp...)
1579+
}
1580+
matched := false
1581+
for _, d := range matchDirs {
1582+
if dirHasDistInfo(d, norm) {
1583+
matched = true
1584+
break
1585+
}
1586+
}
1587+
if !matched {
1588+
continue
1589+
}
1590+
modTime := int64(0)
1591+
if fi, err := entry.Info(); err == nil {
1592+
modTime = fi.ModTime().Unix()
1593+
}
1594+
if best == "" || modTime > bestMod {
1595+
best = entryPath
1596+
bestMod = modTime
1597+
}
1598+
}
1599+
if best == "" {
1600+
return "", false
1601+
}
1602+
return best, true
1603+
}
1604+
1605+
// dirHasDistInfo reports whether dir directly contains a `<name>-<version>.dist-info`
1606+
// directory whose normalized distribution name equals norm. A wheel's `.dist-info`
1607+
// name is `{normalized-distribution}-{version}.dist-info`, and the normalized
1608+
// distribution name contains no "-" (those become "_"), so the FIRST "-" cleanly
1609+
// separates name from version.
1610+
func dirHasDistInfo(dir, norm string) bool {
1611+
es, err := os.ReadDir(dir)
1612+
if err != nil {
1613+
return false
1614+
}
1615+
for _, e := range es {
1616+
if !e.IsDir() {
1617+
continue
1618+
}
1619+
name := e.Name()
1620+
if !strings.HasSuffix(strings.ToLower(name), ".dist-info") {
1621+
continue
1622+
}
1623+
base := name[:len(name)-len(".dist-info")]
1624+
idx := strings.Index(base, "-")
1625+
if idx <= 0 {
1626+
continue
1627+
}
1628+
if normalizeDistName(base[:idx]) == norm {
1629+
return true
1630+
}
1631+
}
1632+
return false
1633+
}
1634+
14931635
// findGitCheckoutByRepo searches UV git checkouts for a directory that matches the given repo.
14941636
// It walks checkouts/<hash>/<rev>/ directories and checks for repo-identifying markers
14951637
// (pyproject.toml with matching name, or .git/config with matching URL).

internal/security/scanner/source_resolver_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,166 @@ func TestResolveUvxCacheGitCheckout(t *testing.T) {
584584
}
585585
}
586586

587+
// writeUvArchiveWheel materializes a fake unpacked wheel inside a uv archive-v0
588+
// entry. uv content-addresses each wheel by an opaque hash, so the package is
589+
// found by globbing all entries and matching the .dist-info directory — never
590+
// by constructing a name-based path. distName is the (un-normalized) PyPI
591+
// distribution name; it gets PEP 503 normalized (lowercased, -._ → _) for the
592+
// .dist-info name, mirroring what uv writes to disk.
593+
func writeUvArchiveWheel(t *testing.T, homeDir, hash, distName, version, importPkg string, venvStyle bool) string {
594+
t.Helper()
595+
norm := strings.ToLower(distName)
596+
norm = strings.NewReplacer("-", "_", ".", "_").Replace(norm)
597+
entry := filepath.Join(homeDir, ".cache", "uv", "archive-v0", hash)
598+
base := entry
599+
if venvStyle {
600+
base = filepath.Join(entry, "lib", "python3.11", "site-packages")
601+
}
602+
pkgDir := filepath.Join(base, importPkg)
603+
if err := os.MkdirAll(pkgDir, 0755); err != nil {
604+
t.Fatal(err)
605+
}
606+
if err := os.WriteFile(filepath.Join(pkgDir, "__init__.py"), []byte("# server source"), 0644); err != nil {
607+
t.Fatal(err)
608+
}
609+
distInfo := filepath.Join(base, norm+"-"+version+".dist-info")
610+
if err := os.MkdirAll(distInfo, 0755); err != nil {
611+
t.Fatal(err)
612+
}
613+
if err := os.WriteFile(filepath.Join(distInfo, "METADATA"), []byte("Name: "+distName+"\n"), 0644); err != nil {
614+
t.Fatal(err)
615+
}
616+
return entry
617+
}
618+
619+
// TestResolveUvxCacheArchiveFlat verifies the COMMON `uvx <pkg>` case: the
620+
// package was run locally (so its wheel sits unpacked in the ephemeral
621+
// ~/.cache/uv/archive-v0 cache) but was never `uv tool install`-ed, so the
622+
// tools dir is empty. Before MCP-2400 this fell through to
623+
// tool_definitions_only / a network fetch; now it resolves from local cache.
624+
func TestResolveUvxCacheArchiveFlat(t *testing.T) {
625+
homeDir := t.TempDir()
626+
t.Setenv("HOME", homeDir)
627+
t.Setenv("USERPROFILE", homeDir)
628+
629+
entry := writeUvArchiveWheel(t, homeDir, "Ab12Cd34Ef56", "mcp-server-fetch", "1.2.3", "mcp_server_fetch", false)
630+
631+
r := NewSourceResolver(zap.NewNop())
632+
result, err := r.resolveUvxCache(ServerInfo{
633+
Name: "fetch",
634+
Command: "uvx",
635+
Args: []string{"mcp-server-fetch"},
636+
})
637+
if err != nil {
638+
t.Fatalf("resolveUvxCache: %v", err)
639+
}
640+
if result.SourceDir != entry {
641+
t.Errorf("expected source_dir %q, got %q", entry, result.SourceDir)
642+
}
643+
if result.Method != "uvx_cache" {
644+
t.Errorf("expected method 'uvx_cache', got %q", result.Method)
645+
}
646+
}
647+
648+
// TestResolveUvxCacheArchiveVenvStyle verifies the venv-style archive layout
649+
// (<hash>/lib/python*/site-packages/<pkg>) is also discovered.
650+
func TestResolveUvxCacheArchiveVenvStyle(t *testing.T) {
651+
homeDir := t.TempDir()
652+
t.Setenv("HOME", homeDir)
653+
t.Setenv("USERPROFILE", homeDir)
654+
655+
entry := writeUvArchiveWheel(t, homeDir, "Zz99Yy88Xx77", "some-tool", "0.4.0", "some_tool", true)
656+
657+
r := NewSourceResolver(zap.NewNop())
658+
result, err := r.resolveUvxCache(ServerInfo{
659+
Name: "some-tool",
660+
Command: "uvx",
661+
Args: []string{"--from", "some-tool", "some-tool"},
662+
})
663+
if err != nil {
664+
t.Fatalf("resolveUvxCache: %v", err)
665+
}
666+
if result.SourceDir != entry {
667+
t.Errorf("expected source_dir %q, got %q", entry, result.SourceDir)
668+
}
669+
}
670+
671+
// TestResolveUvxCacheArchiveNameNormalization verifies a uvx spec with a
672+
// version pin and hyphen/case differences still matches the underscore-
673+
// normalized .dist-info on disk.
674+
func TestResolveUvxCacheArchiveNameNormalization(t *testing.T) {
675+
homeDir := t.TempDir()
676+
t.Setenv("HOME", homeDir)
677+
t.Setenv("USERPROFILE", homeDir)
678+
679+
entry := writeUvArchiveWheel(t, homeDir, "Hash1234", "My.Cool-Server", "2.0.0", "my_cool_server", false)
680+
681+
r := NewSourceResolver(zap.NewNop())
682+
result, err := r.resolveUvxCache(ServerInfo{
683+
Name: "cool",
684+
Command: "uvx",
685+
Args: []string{"My.Cool-Server==2.0.0"},
686+
})
687+
if err != nil {
688+
t.Fatalf("resolveUvxCache: %v", err)
689+
}
690+
if result.SourceDir != entry {
691+
t.Errorf("expected source_dir %q, got %q", entry, result.SourceDir)
692+
}
693+
}
694+
695+
// TestResolveUvxCacheArchiveNewestWins verifies that when the same distribution
696+
// is cached at multiple versions (multiple archive hashes), the most recently
697+
// modified entry is chosen — consistent with resolveNpxCache.
698+
func TestResolveUvxCacheArchiveNewestWins(t *testing.T) {
699+
homeDir := t.TempDir()
700+
t.Setenv("HOME", homeDir)
701+
t.Setenv("USERPROFILE", homeDir)
702+
703+
old := writeUvArchiveWheel(t, homeDir, "old0000", "dup-pkg", "1.0.0", "dup_pkg", false)
704+
newer := writeUvArchiveWheel(t, homeDir, "new1111", "dup-pkg", "2.0.0", "dup_pkg", false)
705+
// Force a deterministic ordering of mod times.
706+
past := time.Now().Add(-2 * time.Hour)
707+
if err := os.Chtimes(old, past, past); err != nil {
708+
t.Fatal(err)
709+
}
710+
711+
r := NewSourceResolver(zap.NewNop())
712+
result, err := r.resolveUvxCache(ServerInfo{
713+
Name: "dup",
714+
Command: "uvx",
715+
Args: []string{"dup-pkg"},
716+
})
717+
if err != nil {
718+
t.Fatalf("resolveUvxCache: %v", err)
719+
}
720+
if result.SourceDir != newer {
721+
t.Errorf("expected newest entry %q, got %q", newer, result.SourceDir)
722+
}
723+
_ = old
724+
}
725+
726+
// TestResolveUvxCacheArchiveNoMatch verifies a clean error (fall-through) when
727+
// the package is not present in any local uv cache.
728+
func TestResolveUvxCacheArchiveNoMatch(t *testing.T) {
729+
homeDir := t.TempDir()
730+
t.Setenv("HOME", homeDir)
731+
t.Setenv("USERPROFILE", homeDir)
732+
733+
// A different package is cached; the requested one is absent.
734+
writeUvArchiveWheel(t, homeDir, "other999", "unrelated-pkg", "1.0.0", "unrelated_pkg", false)
735+
736+
r := NewSourceResolver(zap.NewNop())
737+
_, err := r.resolveUvxCache(ServerInfo{
738+
Name: "missing",
739+
Command: "uvx",
740+
Args: []string{"definitely-not-cached"},
741+
})
742+
if err == nil {
743+
t.Error("expected error when package absent from uv cache")
744+
}
745+
}
746+
587747
// TestSourceResolverNpxFilesystemArgNotPicked verifies that a filesystem-style
588748
// positional argument (e.g. `/tmp/mydata` for @modelcontextprotocol/server-filesystem)
589749
// is NOT picked up as the server's source directory. Package-runner commands

0 commit comments

Comments
 (0)