Skip to content

Commit e5ed77d

Browse files
matte1782claude
andcommitted
feat: check_packages batch tool with semaphore concurrency (W13.3, S301)
Day 4: Add check_packages (S301, 7 tests including concurrency validation). PackageRequest extends NameEcosystemInput for INV304 at batch boundary. Semaphore-bounded concurrency (INV306) with real verification test. Per-package error isolation via try/except in asyncio.gather. Triple hostile review CONDITIONAL_GO — all H/M fixes applied. 36/36 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9d0a50a commit e5ed77d

3 files changed

Lines changed: 280 additions & 0 deletions

File tree

mcp/src/phantom_guard_mcp/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ def create_server() -> FastMCP:
3636
server.tool()(is_hallucinated)
3737
from phantom_guard_mcp.tools.check_typosquat import check_typosquat
3838
server.tool()(check_typosquat)
39+
from phantom_guard_mcp.tools.check_packages import check_packages
40+
server.tool()(check_packages)
3941

4042
return server
4143

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
check_packages MCP tool -- Batch validation with configurable concurrency.
3+
IMPLEMENTS: S301
4+
INVARIANTS: INV300, INV304, INV306
5+
TESTS: T301.1, T301.2, T301.3, T301.4, T301.5, T301.6
6+
"""
7+
from __future__ import annotations
8+
9+
import asyncio
10+
import time
11+
12+
from phantom_guard_mcp.tools._validation import NameEcosystemInput
13+
from phantom_guard_mcp.tools.check_package import check_package
14+
15+
MAX_BATCH_SIZE = 100
16+
17+
18+
class PackageRequest(NameEcosystemInput):
19+
"""A single package to validate in a batch request.
20+
IMPLEMENTS: S301, INV304
21+
Inherits name/ecosystem validation from NameEcosystemInput.
22+
"""
23+
24+
25+
async def check_packages(
26+
packages: list[PackageRequest],
27+
concurrency: int = 10,
28+
) -> dict:
29+
"""Check multiple packages concurrently with bounded parallelism.
30+
31+
IMPLEMENTS: S301
32+
INVARIANTS: INV300, INV304, INV306
33+
34+
Validates a batch of packages using configurable concurrency via
35+
asyncio.Semaphore. Delegates each package to check_package and
36+
aggregates results with a summary.
37+
38+
MCP Annotations: readOnlyHint=true, idempotentHint=true, destructiveHint=false
39+
"""
40+
# 1. Validate inputs (INV304)
41+
if not packages:
42+
raise ValueError("At least one package required")
43+
if len(packages) > MAX_BATCH_SIZE:
44+
raise ValueError(f"Maximum {MAX_BATCH_SIZE} packages per batch")
45+
if not 1 <= concurrency <= 50:
46+
raise ValueError(f"concurrency must be between 1 and 50, got {concurrency}")
47+
48+
# 2. Start timer
49+
start = time.perf_counter()
50+
51+
# 3. Create semaphore for bounded concurrency (INV306)
52+
semaphore = asyncio.Semaphore(concurrency)
53+
54+
async def _check_one(pkg: PackageRequest) -> dict:
55+
async with semaphore:
56+
try:
57+
return await check_package(name=pkg.name, ecosystem=pkg.ecosystem)
58+
except Exception as exc:
59+
return {
60+
"package": pkg.name.lower().replace("_", "-"),
61+
"ecosystem": pkg.ecosystem,
62+
"risk_score": 0.0,
63+
"recommendation": "ERROR",
64+
"signals": [],
65+
"evaluation_depth": "none",
66+
"latency_ms": 0.0,
67+
"error": str(exc),
68+
}
69+
70+
# 4. Launch all checks concurrently
71+
results = list(await asyncio.gather(*[_check_one(pkg) for pkg in packages]))
72+
73+
# 5. Compute summary
74+
safe = sum(1 for r in results if r["recommendation"] == "SAFE")
75+
suspicious = sum(1 for r in results if r["recommendation"] == "SUSPICIOUS")
76+
high_risk = sum(1 for r in results if r["recommendation"] == "HIGH_RISK")
77+
78+
latency_ms = (time.perf_counter() - start) * 1000
79+
80+
return {
81+
"results": results,
82+
"summary": {
83+
"total": len(results),
84+
"safe": safe,
85+
"suspicious": suspicious,
86+
"high_risk": high_risk,
87+
},
88+
"latency_ms": latency_ms,
89+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""
2+
Tests for check_packages MCP tool.
3+
SPEC: S301
4+
INVARIANTS: INV300, INV304, INV306
5+
TESTS: T301.1, T301.2, T301.3, T301.4, T301.5, T301.6
6+
EDGE CASES: EC310, EC312, EC314, EC315, EC316, EC318
7+
"""
8+
import asyncio
9+
10+
import pytest
11+
12+
13+
# ---------------------------------------------------------------------------
14+
# Validation tests (INV304)
15+
# ---------------------------------------------------------------------------
16+
17+
@pytest.mark.unit
18+
async def test_empty_batch_produces_error():
19+
"""TEST_ID: T301.1 | SPEC: S301 | INV: INV304
20+
Empty batch produces error with correct message.
21+
EC: EC310
22+
"""
23+
from phantom_guard_mcp.tools.check_packages import check_packages
24+
25+
with pytest.raises(ValueError, match="At least one package required"):
26+
await check_packages(packages=[])
27+
28+
29+
@pytest.mark.unit
30+
async def test_over_100_packages_produces_error(hallucination_db_stub):
31+
"""TEST_ID: T301.3 | SPEC: S301 | INV: INV304
32+
Over 100 packages produces error with '100' in message.
33+
EC: EC314
34+
"""
35+
from phantom_guard_mcp.tools.check_packages import (
36+
PackageRequest,
37+
check_packages,
38+
)
39+
40+
packages = [PackageRequest(name=f"pkg-{i}", ecosystem="pypi") for i in range(101)]
41+
with pytest.raises(ValueError, match="100"):
42+
await check_packages(packages=packages)
43+
44+
45+
@pytest.mark.unit
46+
async def test_invalid_concurrency_produces_error():
47+
"""TEST_ID: T301.3b | SPEC: S301 | INV: INV304
48+
Invalid concurrency values (0, 51) produce errors.
49+
"""
50+
from phantom_guard_mcp.tools.check_packages import (
51+
PackageRequest,
52+
check_packages,
53+
)
54+
55+
packages = [PackageRequest(name="flask", ecosystem="pypi")]
56+
with pytest.raises(ValueError, match="concurrency"):
57+
await check_packages(packages=packages, concurrency=0)
58+
with pytest.raises(ValueError, match="concurrency"):
59+
await check_packages(packages=packages, concurrency=51)
60+
61+
62+
# ---------------------------------------------------------------------------
63+
# Concurrency / batch tests (INV306)
64+
# ---------------------------------------------------------------------------
65+
66+
@pytest.mark.integration
67+
async def test_50_packages_within_5s(
68+
timer, mock_all_registries, hallucination_db_stub
69+
):
70+
"""TEST_ID: T301.2 | SPEC: S301 | INV: INV306
71+
50 packages complete within 5s budget.
72+
EC: EC312
73+
"""
74+
from phantom_guard_mcp.tools.check_packages import (
75+
PackageRequest,
76+
check_packages,
77+
)
78+
79+
packages = [
80+
PackageRequest(name=f"pkg-{i}", ecosystem="pypi") for i in range(50)
81+
]
82+
t = timer()
83+
with t:
84+
result = await check_packages(packages=packages)
85+
86+
assert result["summary"]["total"] == 50
87+
assert len(result["results"]) == 50
88+
assert t.elapsed_ms < 5000, f"Took {t.elapsed_ms:.1f}ms, budget is 5000ms"
89+
assert isinstance(result["latency_ms"], float)
90+
91+
92+
@pytest.mark.unit
93+
async def test_semaphore_enforced_at_concurrency_limit(
94+
hallucination_db_stub, monkeypatch
95+
):
96+
"""TEST_ID: T301.4 | SPEC: S301 | INV: INV306
97+
Semaphore enforced: concurrent active tasks never exceed concurrency limit.
98+
EC: EC318
99+
"""
100+
import phantom_guard_mcp.tools.check_packages as cp_mod
101+
102+
max_concurrent = 0
103+
current = 0
104+
105+
_original_check = cp_mod.check_package
106+
107+
async def counting_check(name: str, ecosystem: str = "pypi") -> dict:
108+
nonlocal max_concurrent, current
109+
current += 1
110+
if current > max_concurrent:
111+
max_concurrent = current
112+
await asyncio.sleep(0.01)
113+
result = await _original_check(name=name, ecosystem=ecosystem)
114+
current -= 1
115+
return result
116+
117+
monkeypatch.setattr(cp_mod, "check_package", counting_check)
118+
119+
from phantom_guard_mcp.tools.check_packages import PackageRequest, check_packages
120+
121+
concurrency_limit = 3
122+
packages = [
123+
PackageRequest(name=f"pkg-{i}", ecosystem="pypi") for i in range(10)
124+
]
125+
result = await check_packages(packages=packages, concurrency=concurrency_limit)
126+
127+
assert result["summary"]["total"] == 10
128+
assert len(result["results"]) == 10
129+
assert max_concurrent <= concurrency_limit, (
130+
f"Max concurrent was {max_concurrent}, limit is {concurrency_limit}"
131+
)
132+
133+
134+
# ---------------------------------------------------------------------------
135+
# Functional tests
136+
# ---------------------------------------------------------------------------
137+
138+
@pytest.mark.unit
139+
async def test_duplicate_packages_both_processed(
140+
mock_all_registries, hallucination_db_stub
141+
):
142+
"""TEST_ID: T301.5 | SPEC: S301 | INV: None
143+
Duplicate packages are both processed (no dedup).
144+
EC: EC315
145+
"""
146+
from phantom_guard_mcp.tools.check_packages import (
147+
PackageRequest,
148+
check_packages,
149+
)
150+
151+
packages = [
152+
PackageRequest(name="flask", ecosystem="pypi"),
153+
PackageRequest(name="flask", ecosystem="pypi"),
154+
]
155+
result = await check_packages(packages=packages)
156+
157+
assert result["summary"]["total"] == 2
158+
assert len(result["results"]) == 2
159+
assert result["results"][0]["package"] == "flask"
160+
assert result["results"][1]["package"] == "flask"
161+
162+
163+
@pytest.mark.unit
164+
async def test_mixed_ecosystems_routed_correctly(
165+
mock_all_registries, hallucination_db_stub
166+
):
167+
"""TEST_ID: T301.6 | SPEC: S301 | INV: None
168+
Mixed ecosystems routed correctly.
169+
EC: EC316
170+
"""
171+
from phantom_guard_mcp.tools.check_packages import (
172+
PackageRequest,
173+
check_packages,
174+
)
175+
176+
packages = [
177+
PackageRequest(name="flask", ecosystem="pypi"),
178+
PackageRequest(name="express", ecosystem="npm"),
179+
PackageRequest(name="serde", ecosystem="crates"),
180+
]
181+
result = await check_packages(packages=packages)
182+
183+
assert result["summary"]["total"] == 3
184+
assert len(result["results"]) == 3
185+
186+
ecosystems_in_results = {r["ecosystem"] for r in result["results"]}
187+
assert "pypi" in ecosystems_in_results
188+
assert "npm" in ecosystems_in_results
189+
assert "crates" in ecosystems_in_results

0 commit comments

Comments
 (0)