diff --git a/CLAUDE.md b/CLAUDE.md index 2083a53..3744aac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ On Windows in this repo use `.venv/Scripts/python -m pytest` etc. — the venv w ## Architecture - `src/patch_tuesday_mcp/server.py` — FastMCP app + `main()`. stdio by default; `MCP_TRANSPORT=http` serves `/mcp` + `/health` with the ASGI app from `build_http_app()` (middleware innermost→outermost: client-cleanup lifespan wrapper → body limit → rate limit → CORS; the factory exists so tests can exercise the exact production composition). uvicorn runs with `MCP_LIMIT_CONCURRENCY` (default 40) and `timeout_keep_alive=15`; `MCP_LOG_LEVEL` controls root logging (stderr). The startup settings log reports pinned trusted proxies as a count only — never echo `MCP_TRUSTED_PROXIES` values (CodeQL flags clear-text logging of them; issue #10, pinned by a test). -- `tools/search.py` — the single `msrc_search` tool and all its routing: CVE fast path (cross-month lookup), KB fast path (with optional supersedence chain walk), `list_months=True` catalog fast path, single-month filtered search, and historical trend search (`months_back` / `start_month`+`end_month`, capped at `MAX_TREND_MONTHS = 12`). A top-level catch-all converts unexpected exceptions to `error_kind="internal"` — `msrc_search` never raises. +- `tools/search.py` — the single `msrc_search` tool and all its routing: CVE fast path (cross-month lookup), KB fast path (single KB or a batched `kb=[...]` list capped at `MAX_KB_BATCH = 30` returning grouped per-KB results, with optional supersedence chain walk), `list_months=True` catalog fast path, single-month filtered search, and historical trend search (`months_back` / `start_month`+`end_month`, capped at `MAX_TREND_MONTHS = 12`). A top-level catch-all converts unexpected exceptions to `error_kind="internal"` — `msrc_search` never raises. - `tools/formatters.py` — optional `format="markdown"|"csv"` triage renderings; JSON is always included and unchanged. - `tools/profiles.py` — named product watchlists for `msrc_search` (`product_profile=`, plus ad-hoc `products=[...]`/`product_families=[...]`). Built-ins (`identity-core`, `endpoint`, `server-infrastructure`) merged under a `MSRC_PROFILES_PATH` JSON override with strict validation; unknown/invalid → `invalid_input`, never an unscoped fallback. Matching is local substring (union: any product OR family); profile contents are never sent upstream or to telemetry. - `tools/prompts.py` — the `monthly_triage` MCP prompt (registered in `server.py` via `mcp.prompt`), a guided analyst workflow built entirely on `msrc_search`; optional `product_profile`/`month` args. Portable copies live in `prompts/` (plain-text prompt) and `skills/patch-tuesday-triage/` (agent skill) — keep them in sync with `tools/prompts.py` when the workflow changes. diff --git a/README.md b/README.md index e728c24..83317b3 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ pip install --upgrade patch-tuesday-mcp ## Features -- **msrc_search** – Search and filter Microsoft security updates by keyword, CVE, KB number, month, product, severity, CVSS score, exploited-in-the-wild status, or public disclosure. When no month is given, results default to the most recent release whose Patch Tuesday has already occurred — the upcoming month's pre-release document (early Chromium/out-of-band entries only) is skipped by default and available explicitly via `month=`. Results are enriched with **EPSS scores** (FIRST.org 30-day exploitation probability, `min_epss=0.5` filter) and **CISA KEV** catalog status with federal remediation due dates (`kev=True` filter) — both public, keyless sources. Filter by the **parsed CVSS v3.x exposure fields** — `attack_vector` (N/A/L/P), `privileges_required` (N/L/H), `user_interaction` (N/R), and `scope` (U/C) — to isolate, for example, network-reachable zero-click criticals; matching results surface a structured `cvss` object broken out from the raw vector string. Every CVE detail also includes a **references** block of ready-to-open links (MSRC update guide, NVD, EPSS API, and the CISA KEV catalog when the CVE is listed). Add `include_chain=True` to a KB lookup to walk Microsoft-stated **supersedence chains** (which KBs it replaces, newest → oldest). Add `include_guidance=True` to a CVE lookup to surface Microsoft-provided **mitigations, workarounds, and will-not-fix advisories** alongside the vendor-fix KBs. Pass `format="markdown"` or `format="csv"` to a monthly/filtered search to get an additive **triage briefing** — a prioritized executive summary and table (Markdown) or a spreadsheet-ready export with stable columns (CSV) — rendered from the same urgency ranking; the JSON `vulnerabilities` list is always included. Use `force_refresh=True` to bypass the in-process caches and re-fetch the MSRC document and EPSS/KEV enrichment for the request, and `include_freshness=True` to add a **freshness** block reporting the cache age and TTL of the MSRC document and enrichment data. Search a **historical range** instead of a single month with `months_back=N` (the N most recent released months) or `start_month`/`end_month` — the response aggregates matching CVEs across the range and adds per-month **trend** counts; ranges are capped at 12 months and reuse the existing cache/concurrency controls. Set `include_stats=True` for aggregate counts (by severity, impact, product family, exploited, KEV). Use `limit=0` with `include_stats=True` for a stats-only month overview. Filter on **Microsoft's exploitation-likelihood assessment** with `exploitation_likely=True` ("Exploitation More Likely"/"Exploitation Detected"; matches carry an `exploitation_assessment` field) and on **known ransomware campaign use** with `ransomware=True` (from the CISA KEV catalog). Opt into richer rows where you need them: `include_references=True` adds the MSRC/NVD/EPSS/KEV link block to month/KB/trend list results, `include_kev_details=True` replaces the boolean KEV flag with the full catalog entry (due date, required action, vendor/product, ransomware use), `include_kb_details=True` expands KB numbers into full objects with per-KB URL, fixed build, supersedence, sub-type, and **restart-required** status, and `include_temporal=True` adds Microsoft's **CVSS temporal score** to cvss blocks. Filter by **weakness class** with `cwe=` (ID or name substring, e.g. `cwe="CWE-416"`). Use `list_months=True` to fetch the **release catalog** (every available month with initial/current release dates — handy for valid `month=` values and spotting same-month revisions). All new fields are opt-in: the default JSON shape is unchanged. +- **msrc_search** – Search and filter Microsoft security updates by keyword, CVE, KB number, month, product, severity, CVSS score, exploited-in-the-wild status, or public disclosure. When no month is given, results default to the most recent release whose Patch Tuesday has already occurred — the upcoming month's pre-release document (early Chromium/out-of-band entries only) is skipped by default and available explicitly via `month=`. Results are enriched with **EPSS scores** (FIRST.org 30-day exploitation probability, `min_epss=0.5` filter) and **CISA KEV** catalog status with federal remediation due dates (`kev=True` filter) — both public, keyless sources. Filter by the **parsed CVSS v3.x exposure fields** — `attack_vector` (N/A/L/P), `privileges_required` (N/L/H), `user_interaction` (N/R), and `scope` (U/C) — to isolate, for example, network-reachable zero-click criticals; matching results surface a structured `cvss` object broken out from the raw vector string. Every CVE detail also includes a **references** block of ready-to-open links (MSRC update guide, NVD, EPSS API, and the CISA KEV catalog when the CVE is listed). Add `include_chain=True` to a KB lookup to walk Microsoft-stated **supersedence chains** (which KBs it replaces, newest → oldest). Pass a **list of KB numbers** (`kb=["5094123", "KB5094127", ...]`, up to 30) to resolve them all in one call — e.g. a machine's installed-update list as context for a patch report — and get a grouped response with one per-KB entry (found or not-found, each with the same body as a single-KB lookup); every monthly document is still fetched upstream at most once for the whole batch. Add `include_guidance=True` to a CVE lookup to surface Microsoft-provided **mitigations, workarounds, and will-not-fix advisories** alongside the vendor-fix KBs. Pass `format="markdown"` or `format="csv"` to a monthly/filtered search to get an additive **triage briefing** — a prioritized executive summary and table (Markdown) or a spreadsheet-ready export with stable columns (CSV) — rendered from the same urgency ranking; the JSON `vulnerabilities` list is always included. Use `force_refresh=True` to bypass the in-process caches and re-fetch the MSRC document and EPSS/KEV enrichment for the request, and `include_freshness=True` to add a **freshness** block reporting the cache age and TTL of the MSRC document and enrichment data. Search a **historical range** instead of a single month with `months_back=N` (the N most recent released months) or `start_month`/`end_month` — the response aggregates matching CVEs across the range and adds per-month **trend** counts; ranges are capped at 12 months and reuse the existing cache/concurrency controls. Set `include_stats=True` for aggregate counts (by severity, impact, product family, exploited, KEV). Use `limit=0` with `include_stats=True` for a stats-only month overview. Filter on **Microsoft's exploitation-likelihood assessment** with `exploitation_likely=True` ("Exploitation More Likely"/"Exploitation Detected"; matches carry an `exploitation_assessment` field) and on **known ransomware campaign use** with `ransomware=True` (from the CISA KEV catalog). Opt into richer rows where you need them: `include_references=True` adds the MSRC/NVD/EPSS/KEV link block to month/KB/trend list results, `include_kev_details=True` replaces the boolean KEV flag with the full catalog entry (due date, required action, vendor/product, ransomware use), `include_kb_details=True` expands KB numbers into full objects with per-KB URL, fixed build, supersedence, sub-type, and **restart-required** status, and `include_temporal=True` adds Microsoft's **CVSS temporal score** to cvss blocks. Filter by **weakness class** with `cwe=` (ID or name substring, e.g. `cwe="CWE-416"`). Use `list_months=True` to fetch the **release catalog** (every available month with initial/current release dates — handy for valid `month=` values and spotting same-month revisions). All new fields are opt-in: the default JSON shape is unchanged. ## Product profiles (watchlists) @@ -173,24 +173,25 @@ Once connected to an MCP client, you can ask questions like: 2. **Exploited vulnerabilities**: "Which Microsoft vulnerabilities are being actively exploited?" 3. **CVE lookup**: "What is CVE-2026-41108 and which KB fixes it?" 4. **KB lookup**: "What does KB5094123 patch?" -5. **Product filter**: "Show me Critical vulnerabilities affecting Exchange Server this month" -6. **Patch prioritization**: "What should I patch first from the June 2026 updates?" -7. **CISA KEV**: "Which of this month's CVEs are on the CISA KEV list?" -8. **EPSS**: "Show me CVEs with EPSS above 50%" -9. **Exposure filtering**: "Which Critical CVEs are network-reachable with no privileges and no user interaction?" -10. **Reference links**: "Give me the MSRC, NVD, and EPSS links for CVE-2026-41108" -11. **Mitigations & workarounds**: "Are there any mitigations or workarounds for CVE-2026-41108?" -12. **Triage report**: "Give me this month's Critical CVEs as a Markdown briefing" (or "…export them as CSV") -13. **Fresh data on demand**: "Re-pull this month's updates fresh and tell me how current the data is" (`force_refresh=True`, `include_freshness=True`) -14. **Historical trends**: "How many HTTP.sys RCE CVEs shipped over the last 6 months?" (`query="HTTP.sys"`, `months_back=6`) -15. **Supersedence**: "Is KB5087538 superseded by anything newer?" -16. **Exploitation forecast**: "Which CVEs does Microsoft rate 'Exploitation More Likely' this month?" -17. **Ransomware**: "Which of this month's CVEs are used in known ransomware campaigns?" -18. **Deployment planning**: "Does KB5094123 require a restart, and which build fixes it?" -19. **Weakness class**: "Show me this month's use-after-free vulnerabilities" (`cwe="CWE-416"`) -20. **Release catalog**: "Which Patch Tuesday months are available to query?" -21. **Product watchlist**: "Show me this month's Critical CVEs across my estate" (`product_profile="identity-core"`, `severity="Critical"`) -22. **Guided triage**: "Walk me through this month's triage for my identity estate" (selects the `monthly_triage` prompt with `product_profile="identity-core"`) +5. **Machine patch report**: "Here are the KBs installed on this server: KB5094123, KB5094127, KB5093998 — what do they fix and is anything superseded?" +6. **Product filter**: "Show me Critical vulnerabilities affecting Exchange Server this month" +7. **Patch prioritization**: "What should I patch first from the June 2026 updates?" +8. **CISA KEV**: "Which of this month's CVEs are on the CISA KEV list?" +9. **EPSS**: "Show me CVEs with EPSS above 50%" +10. **Exposure filtering**: "Which Critical CVEs are network-reachable with no privileges and no user interaction?" +11. **Reference links**: "Give me the MSRC, NVD, and EPSS links for CVE-2026-41108" +12. **Mitigations & workarounds**: "Are there any mitigations or workarounds for CVE-2026-41108?" +13. **Triage report**: "Give me this month's Critical CVEs as a Markdown briefing" (or "…export them as CSV") +14. **Fresh data on demand**: "Re-pull this month's updates fresh and tell me how current the data is" (`force_refresh=True`, `include_freshness=True`) +15. **Historical trends**: "How many HTTP.sys RCE CVEs shipped over the last 6 months?" (`query="HTTP.sys"`, `months_back=6`) +16. **Supersedence**: "Is KB5087538 superseded by anything newer?" +17. **Exploitation forecast**: "Which CVEs does Microsoft rate 'Exploitation More Likely' this month?" +18. **Ransomware**: "Which of this month's CVEs are used in known ransomware campaigns?" +19. **Deployment planning**: "Does KB5094123 require a restart, and which build fixes it?" +20. **Weakness class**: "Show me this month's use-after-free vulnerabilities" (`cwe="CWE-416"`) +21. **Release catalog**: "Which Patch Tuesday months are available to query?" +22. **Product watchlist**: "Show me this month's Critical CVEs across my estate" (`product_profile="identity-core"`, `severity="Critical"`) +23. **Guided triage**: "Walk me through this month's triage for my identity estate" (selects the `monthly_triage` prompt with `product_profile="identity-core"`) ## Usage diff --git a/pyproject.toml b/pyproject.toml index 13328e2..26e239a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "patch-tuesday-mcp" -version = "0.6.3" +version = "0.7.0" description = "MCP server for Microsoft Patch Tuesday security updates (MSRC Security Update Guide)" requires-python = ">=3.11" readme = "README.md" diff --git a/src/patch_tuesday_mcp/__init__.py b/src/patch_tuesday_mcp/__init__.py index e420480..d4ddb7c 100644 --- a/src/patch_tuesday_mcp/__init__.py +++ b/src/patch_tuesday_mcp/__init__.py @@ -1,3 +1,3 @@ """Patch Tuesday MCP Server - Query MSRC security updates via MCP.""" -__version__ = "0.6.3" +__version__ = "0.7.0" diff --git a/src/patch_tuesday_mcp/server.py b/src/patch_tuesday_mcp/server.py index be2a993..30429ff 100644 --- a/src/patch_tuesday_mcp/server.py +++ b/src/patch_tuesday_mcp/server.py @@ -93,7 +93,9 @@ def _trusted_proxies() -> frozenset[str]: "MSRC Security Update Guide API. Use msrc_search to find, filter, and " "retrieve vulnerabilities and their fixes. Look up a specific CVE with " "cve='CVE-...' (full detail, works across all months), find what a KB " - "fixes with kb='5094123', or filter the latest month by product, " + "fixes with kb='5094123' — or pass a list (kb=['5094123', ...], up to " + "30) to resolve a whole machine's update list in one call — or filter " + "the latest month by product, " "severity, exploited=True, or min_cvss. Scope any search to a product " "watchlist with product_profile='identity-core' (built-ins: " "identity-core, endpoint, server-infrastructure; extend via a JSON " diff --git a/src/patch_tuesday_mcp/tools/search.py b/src/patch_tuesday_mcp/tools/search.py index f2d0461..f64aed5 100644 --- a/src/patch_tuesday_mcp/tools/search.py +++ b/src/patch_tuesday_mcp/tools/search.py @@ -28,6 +28,9 @@ # How many recent months a KB lookup scans before giving up KB_SCAN_MONTHS = 6 +# How many KB numbers a single batched kb=[...] lookup accepts +MAX_KB_BATCH = 30 + # Supersedence chain walking: max hops followed and how many monthly docs # (starting at the queried KB's month) may be scanned for predecessors CHAIN_MAX_DEPTH = 12 @@ -55,7 +58,7 @@ async def msrc_search( query: str | None = None, cve: str | None = None, - kb: str | None = None, + kb: str | list[str] | None = None, month: str | None = None, product: str | None = None, product_profile: str | None = None, @@ -114,6 +117,9 @@ async def msrc_search( FAQs, EPSS score, and KEV status - Find which CVEs a KB article fixes (kb="5094123" or kb="KB5094123") -- scans recent months, or a specific month when combined with month= + - Look up many KBs in one call (kb=["5094123", "KB5094127", ...], up to + 30) -- e.g. a machine's installed-update list; returns one grouped + per-KB result entry each, with per-KB found/not-found status - Check whether a KB has been superseded by newer patches (kb="5087538", include_chain=True) -- walks Microsoft-stated supersedence links - Find KEV-listed CVEs this month (kev=True) -- confirmed exploited, with @@ -142,12 +148,18 @@ async def msrc_search( cve: Optional CVE ID (e.g. "CVE-2026-41108"). Fast path: ignores other filters and returns full detail for that single CVE, searching across all months automatically. - kb: Optional KB article number (e.g. "5094123" or "KB5094123"). Fast - path: returns the CVEs fixed by that KB, scanning the most recent - months (up to 6), or only the given month when month= is also set. - Honors limit/offset; other filters are ignored. Accepts numeric KB - ids only; kb_articles in results may also contain non-KB vendor-fix - labels such as "Release Notes", which cannot be looked up here. + kb: Optional KB article number (e.g. "5094123" or "KB5094123") or a + list of up to 30 of them for a batched lookup. Fast path: returns + the CVEs fixed by that KB, scanning the most recent months (up to + 6), or only the given month when month= is also set. A list input + returns a grouped response instead: a results array with one entry + per KB ("kb", "found", and on success the same body as a + single-KB lookup; on a miss "error"/"error_kind"), deduplicated, + order preserved. limit/offset apply per KB; other filters are + ignored. Accepts numeric KB ids only (any malformed list entry + fails the whole call); kb_articles in results may also contain + non-KB vendor-fix labels such as "Release Notes", which cannot be + looked up here. month: Optional monthly release to search, formatted "2026-Apr" or "2026-04". Defaults to the most recent release whose Patch Tuesday (second Tuesday of the month) has already occurred; pass @@ -265,6 +277,10 @@ async def msrc_search( - stats: (only when include_stats=True) aggregate counts - supersedence_chain / chain_complete: (only for kb= lookups with include_chain=True) the walked chain, newest to oldest + - total_kbs / results: (only when kb= is a list) grouped batch output; + results holds one entry per KB with kb, found, and either the + single-KB response body or a per-KB error/error_kind, while the + top-level total_found sums across all KBs - guidance: (only for cve= lookups with include_guidance=True) list of mitigation/workaround/will-not-fix advisories, when Microsoft provides them @@ -353,7 +369,7 @@ async def msrc_search( async def _search_impl( query: str | None, cve: str | None, - kb: str | None, + kb: str | list[str] | None, month: str | None, product: str | None, product_profile: str | None, @@ -402,6 +418,18 @@ async def _search_impl( ) # --- KB fast path: which CVEs does this KB fix --- + if isinstance(kb, list): + return await _lookup_kbs( + kb, + include_chain, + month, + limit, + offset, + force_refresh, + include_references=include_references, + include_kb_details=include_kb_details, + include_kev_details=include_kev_details, + ) if kb: return await _lookup_kb( kb, @@ -1031,6 +1059,91 @@ async def _lookup_kb( ) +async def _lookup_kbs( + kbs: list[str], + include_chain: bool = False, + month: str | None = None, + limit: int = 10, + offset: int = 0, + force_refresh: bool = False, + include_references: bool = False, + include_kb_details: bool = False, + include_kev_details: bool = False, +) -> dict: + """Batched KB lookup: one grouped response with a per-KB result entry. + + Each KB resolves through the same scan as a single-KB lookup; the + in-process month cache guarantees every monthly document is fetched + upstream at most once for the whole batch. A KB that is not found (or + whose scan hit upstream failures) becomes a found=False entry rather + than failing the batch; malformed input fails the whole call because a + silently dropped KB would corrupt any report built on the result. + """ + normalized = [str(raw).strip().upper().removeprefix("KB").strip() for raw in kbs] + seen: set[str] = set() + kb_numbers = [n for n in normalized if not (n in seen or seen.add(n))] + + filters_applied: dict = {"kb": [f"KB{n}" for n in kb_numbers]} + if month is not None: + filters_applied["month"] = month + for name, flag in ( + ("include_chain", include_chain), + ("include_references", include_references), + ("include_kb_details", include_kb_details), + ("include_kev_details", include_kev_details), + ): + if flag: + filters_applied[name] = True + if force_refresh: + filters_applied["force_refresh"] = True + + if not kb_numbers: + return _error( + "kb= was an empty list. Provide at least one KB number, e.g. ['5094123'].", + filters_applied, + ) + invalid = sorted({raw for raw, n in zip(kbs, normalized) if not n.isdigit()}) + if invalid: + return _error( + f"Invalid KB number(s): {', '.join(repr(v) for v in invalid)}. Expected " + "numeric KB ids like '5094123' or 'KB5094123'. kb_articles entries such as " + "'Release Notes' are vendor-fix labels, not KB ids.", + filters_applied, + ) + if len(kb_numbers) > MAX_KB_BATCH: + return _error( + f"Too many KB numbers ({len(kb_numbers)} after deduplication); a single " + f"call accepts at most {MAX_KB_BATCH}. Split the list across calls.", + filters_applied, + ) + + results: list[dict] = [] + total_found = 0 + for number in kb_numbers: + single = await _lookup_kb( + number, + include_chain, + month, + limit, + offset, + force_refresh, + include_references=include_references, + include_kb_details=include_kb_details, + include_kev_details=include_kev_details, + ) + single.pop("filters_applied", None) + entry = {"kb": f"KB{number}", "found": "error" not in single, **single} + total_found += entry.get("total_found", 0) + results.append(entry) + + return { + "total_kbs": len(results), + "total_found": total_found, + "results": results, + "filters_applied": filters_applied, + } + + async def _list_months(force_refresh: bool) -> dict: """Return the catalog of available monthly releases, newest first.""" filters_applied: dict = {"list_months": True} diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py index 40c8409..41d862c 100644 --- a/tests/test_endpoint.py +++ b/tests/test_endpoint.py @@ -204,6 +204,32 @@ async def test_kb_fast_path_finds_fixing_cves(endpoint_url): assert payload["filters_applied"]["kb"] == f"KB{kbs[0]}" +@pytest.mark.endpoint_upstream +async def test_kb_batch_lookup_groups_results(endpoint_url): + async with _mcp_client(endpoint_url) as client: + search = await _default_search(client) + kbs = list( + dict.fromkeys( + kb + for v in search["vulnerabilities"] + for kb in v.get("kb_articles", []) + if str(kb).isdigit() + ) + ) + if not kbs: + pytest.skip("no numeric KB articles on the sampled vulnerabilities this month") + batch = kbs[:2] + payload = _payload(await client.call_tool("msrc_search", {"kb": batch, "limit": 3})) + assert "error" not in payload, payload.get("error") + assert payload["total_kbs"] == len(batch) + assert payload["filters_applied"]["kb"] == [f"KB{k}" for k in batch] + assert [entry["kb"] for entry in payload["results"]] == [f"KB{k}" for k in batch] + for entry in payload["results"]: + assert entry["found"] is True + assert entry["total_found"] > 0 + assert entry["vulnerabilities"] + + @pytest.mark.endpoint_upstream async def test_list_months_catalog(endpoint_url): async with _mcp_client(endpoint_url) as client: diff --git a/tests/test_tools.py b/tests/test_tools.py index e3247d7..57ebb4f 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -194,6 +194,143 @@ async def test_kb_with_month(): assert "Invalid month" in result["error"] +# --- Multi-KB batch lookup (kb=[...]) --- + + +async def test_kb_list_returns_grouped_results(): + result = await msrc_search(kb=["5094123", "KB5094127"]) + assert "error" not in result + assert result["total_kbs"] == 2 + assert result["filters_applied"]["kb"] == ["KB5094123", "KB5094127"] + assert [r["kb"] for r in result["results"]] == ["KB5094123", "KB5094127"] + for entry in result["results"]: + assert entry["found"] is True + assert entry["month"] == "2026-Jun" + assert entry["total_found"] >= 1 + assert entry["vulnerabilities"] + # per-entry filters_applied would be redundant with the top-level one + assert "filters_applied" not in entry + # top-level total_found aggregates across KBs (telemetry + report use) + assert result["total_found"] == sum(e["total_found"] for e in result["results"]) + + +async def test_kb_list_single_element_still_grouped(): + """Shape is determined by input type, not element count.""" + result = await msrc_search(kb=["5094123"]) + assert result["total_kbs"] == 1 + assert result["results"][0]["kb"] == "KB5094123" + assert result["results"][0]["found"] is True + + +async def test_kb_string_shape_unchanged_by_batch_feature(): + """Compatibility contract: a plain-string kb keeps today's flat shape.""" + result = await msrc_search(kb="5094123") + assert "results" not in result + assert "total_kbs" not in result + assert result["filters_applied"]["kb"] == "KB5094123" + assert result["vulnerabilities"] + + +async def test_kb_list_mixed_found_and_not_found(): + result = await msrc_search(kb=["5094123", "5999999"]) + assert "error" not in result # per-KB misses are not a whole-call failure + found, missing = result["results"] + assert found["kb"] == "KB5094123" + assert found["found"] is True + assert missing["kb"] == "KB5999999" + assert missing["found"] is False + assert missing["error_kind"] == "not_found" + assert "KB5999999" in missing["error"] + + +async def test_kb_list_upstream_failure_reported_per_kb(monkeypatch): + """An upstream outage must not masquerade as per-KB 'not found'.""" + + async def fake_get_json(url, timeout=60.0): + if url.endswith("/updates"): + return INDEX_RESPONSE + raise MsrcApiError("MSRC API returned HTTP 503") + + monkeypatch.setattr(msrc_api, "_get_json", fake_get_json) + clear_cache() + + result = await msrc_search(kb=["5094123", "5094127"]) + assert "error" not in result + for entry in result["results"]: + assert entry["found"] is False + assert entry["error_kind"] == "upstream" + + +async def test_kb_list_invalid_entry_fails_whole_call(): + """A silently dropped KB would make a per-machine patch report wrong.""" + result = await msrc_search(kb=["5094123", "notakb"]) + assert result["error_kind"] == "invalid_input" + assert "notakb" in result["error"] + assert "results" not in result + + +async def test_kb_list_empty_rejected(): + result = await msrc_search(kb=[]) + assert result["error_kind"] == "invalid_input" + + +async def test_kb_list_over_cap_rejected(): + result = await msrc_search(kb=[str(5000000 + i) for i in range(31)]) + assert result["error_kind"] == "invalid_input" + assert "30" in result["error"] + + +async def test_kb_list_dedupes_preserving_order(): + result = await msrc_search(kb=["KB5094127", "5094123", "5094127"]) + assert result["total_kbs"] == 2 + assert [r["kb"] for r in result["results"]] == ["KB5094127", "KB5094123"] + + +async def test_kb_list_fetches_each_month_once(monkeypatch): + """The batch must not re-fetch a month per KB — one upstream fetch total.""" + with open(FIXTURE, encoding="utf-8") as f: + cvrf_doc = json.load(f) + cvrf_fetches = {"count": 0} + + async def fake_get_json(url, timeout=60.0): + if url.endswith("/updates"): + return INDEX_RESPONSE + if url.endswith("/cvrf/2026-Jun"): + cvrf_fetches["count"] += 1 + return cvrf_doc + raise MsrcApiError("not found") + + monkeypatch.setattr(msrc_api, "_get_json", fake_get_json) + clear_cache() + + result = await msrc_search(kb=["5094123", "5094127", "5094125"], month="2026-Jun") + assert all(e["found"] for e in result["results"]) + assert cvrf_fetches["count"] == 1 + + +async def test_kb_list_with_month_restriction(): + result = await msrc_search(kb=["5094123"], month="2026-May") + assert result["results"][0]["found"] is False + assert result["results"][0]["error_kind"] == "not_found" + assert result["filters_applied"]["month"] == "2026-May" + + +async def test_kb_list_include_chain(monkeypatch): + _patch_chain_months( + monkeypatch, + { + "2026-Jun": _synthetic_month("2026-Jun", "", [("5300003", "5300002")]), + "2026-May": _synthetic_month("2026-May", "", [("5300002", None)]), + }, + ) + + result = await msrc_search(kb=["5300003", "5300002"], include_chain=True) + first, second = result["results"] + assert [hop["kb"] for hop in first["supersedence_chain"]] == ["5300003", "5300002"] + assert first["chain_complete"] is True + assert [hop["kb"] for hop in second["supersedence_chain"]] == ["5300002"] + + async def test_filters_applied_keeps_false_values(): result = await msrc_search(exploited=False) assert result["filters_applied"]["exploited"] is False diff --git a/uv.lock b/uv.lock index 9b51d5b..3670b03 100644 --- a/uv.lock +++ b/uv.lock @@ -1327,7 +1327,7 @@ wheels = [ [[package]] name = "patch-tuesday-mcp" -version = "0.6.3" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "fastmcp" },