From 6f682c029912600869d86c1e28078843813bc923 Mon Sep 17 00:00:00 2001 From: jbutler Date: Sat, 11 Jul 2026 19:36:06 -0400 Subject: [PATCH 1/2] feat: opt-in known-issues data for KB lookups (include_known_issues) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kb= lookups (single or batched) can now return the issues Microsoft has publicly confirmed an update introduces — title, symptoms, workaround, and the resolving KB when stated — scraped best-effort from the KB's public support.microsoft.com page (no keyless API exists for this data). Honest three-way status per KB: published / none_published / unavailable; a fetch or parse failure is never reported as "no known issues". Landing pages are only trusted when their URL slug or title names the requested KB, because the /help/{kb} resolver fuzzily lands bogus ids on unrelated articles. One same-host redirect hop is followed manually after validation; the shared client still never follows redirects. TTL cache 6h (LRU-capped, failures uncached), semaphore- bounded fetches, 4 MiB body cap via MCP_KNOWN_ISSUES_MAX_RESPONSE_BYTES. Default JSON output is unchanged; the block only appears with include_known_issues=True, and also attaches to not_found results since preview-only updates publish known issues without being MSRC entries. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 +- README.md | 13 +- pyproject.toml | 2 +- server.json | 4 +- src/patch_tuesday_mcp/__init__.py | 2 +- src/patch_tuesday_mcp/feeds/http_client.py | 11 + src/patch_tuesday_mcp/feeds/known_issues.py | 377 +++++++++++++++++ src/patch_tuesday_mcp/server.py | 8 +- src/patch_tuesday_mcp/tools/search.py | 76 +++- tests/fixtures/kb_known_issues.html | 60 +++ tests/fixtures/kb_no_issues_section.html | 24 ++ tests/fixtures/kb_none_aware.html | 21 + tests/fixtures/kb_wrong_landing.html | 16 + tests/test_http_bounds.py | 16 + tests/test_known_issues.py | 430 ++++++++++++++++++++ tests/test_live_smoke.py | 31 ++ tests/test_tools.py | 118 +++++- uv.lock | 2 +- 18 files changed, 1200 insertions(+), 16 deletions(-) create mode 100644 src/patch_tuesday_mcp/feeds/known_issues.py create mode 100644 tests/fixtures/kb_known_issues.html create mode 100644 tests/fixtures/kb_no_issues_section.html create mode 100644 tests/fixtures/kb_none_aware.html create mode 100644 tests/fixtures/kb_wrong_landing.html create mode 100644 tests/test_known_issues.py diff --git a/CLAUDE.md b/CLAUDE.md index cd13927..1cbfd00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,13 +25,14 @@ 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 (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/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 and optional `include_known_issues=True` known-issues decoration — attached even to `not_found`/`upstream` KB results since preview-only updates aren't in MSRC data, but never to `invalid_input`), `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. -- `feeds/http_client.py` — shared httpx client: `follow_redirects=False` (hardcoded hosts; redirects are never followed) and `get_bounded()` which streams responses with a byte cap instead of buffering unbounded bodies. +- `feeds/http_client.py` — shared httpx client: `follow_redirects=False` (hardcoded hosts; redirects are never followed automatically), `get_bounded()` which streams responses with a byte cap instead of buffering unbounded bodies, and `get_location()` which returns a redirect's status+Location without reading the body so a caller can follow one hop manually after validating the target. - `feeds/msrc_api.py` — MSRC index + monthly CVRF fetch with in-process TTL caches (`MAX_FULL_MONTHS_CACHED = 12` — matches `MAX_TREND_MONTHS` so a max trend doesn't evict its own months; 40 slim), LRU eviction (hits refresh recency), per-month asyncio locks, `FETCH_CONCURRENCY = 3` semaphore, `force_refresh` bypass, freshness metadata. Response bodies capped via `MCP_MSRC_MAX_RESPONSE_BYTES` (64 MiB default). - `feeds/enrichment.py` — KEV catalog + EPSS fetches (batches of 100, fetched concurrently with `EPSS_FETCH_CONCURRENCY = 3`), cached; EPSS cache capped at `MAX_EPSS_CACHE_ENTRIES = 50_000`; failures return empty ({}) — enrichment must never break a search. Bodies capped via `MCP_ENRICHMENT_MAX_RESPONSE_BYTES` (32 MiB default). +- `feeds/known_issues.py` — Microsoft-confirmed known issues per KB (`include_known_issues=True`), scraped best-effort from the public support.microsoft.com KB page (no keyless API exists; the Graph windowsupdates API needs AAD). Honest three-way status: `published` / `none_published` / `unavailable` — a fetch/parse failure is never reported as "none". Coverage boundary: known-issues sections exist mainly for Windows OS cumulative/preview updates; Office/SharePoint/SQL/.NET pages usually have none → `none_published`. Source quirks handled: `/help/{kb}` answers one same-host redirect (followed manually via `get_location()` after validating the target host), and bogus KB numbers redirect to *unrelated* articles that still echo the requested id in an analytics meta tag, so a landing page is only trusted if its URL slug or title names the requested KB (pages that never self-reference, like some .NET update pages, conservatively report no per-KB page). TTL cache 6 h (`MAX_CACHE_ENTRIES = 500`, LRU; failures aren't cached), `FETCH_CONCURRENCY = 3` semaphore shared by batch prefetch, bodies capped via `MCP_KNOWN_ISSUES_MAX_RESPONSE_BYTES` (4 MiB default). Parser is stdlib-only (regex slicing + `html.parser`); on layout drift it degrades to `unavailable` with the source URL — the `--run-live` smoke test is the drift canary. - `models/vulnerability.py` — CVRF parsing into `Vulnerability`; numeric CVRF enums are documented constants (remediation types: 0=workaround, 1=mitigation, 2=vendor fix/KB, 4=will-not-fix). `to_summary_dict()` vs `to_detail_dict()` control output size; new fields are opt-in flags (`include_references`, `include_kb_details`, `include_kev_details`, `include_temporal`, filter-triggered `cwe`/`exploitation_assessment`). - `models/cvss.py` — lenient CVSS v3.x vector parser; fails open to `None`, never raises. - `middleware/` — per-IP token-bucket rate limit and request body cap, both with telemetry callbacks (`on_request`/`on_throttled`, `on_rejected`). X-Forwarded-For is honored only from private/loopback peers or `MCP_TRUSTED_PROXIES` members — a public direct peer can never forge it. diff --git a/README.md b/README.md index 8838566..3f84e91 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Patch Tuesday MCP Server bridges Microsoft's official CVRF security update API a - **Filter by Microsoft's own exploitation forecast** - "Which of this month's CVEs does Microsoft rate 'Exploitation More Likely'?" (`exploitation_likely=True`) - **Spot ransomware-weaponized CVEs** - "Which CVEs this month are used in known ransomware campaigns?" (`ransomware=True`; add `include_kev_details=True` for the full CISA entry with required actions) - **Plan the deployment, not just the priority** - "Does KB5094123 require a restart, and what build fixes it?" (`include_kb_details=True` adds per-KB URLs, fixed builds, supersedence, and restart requirements) +- **Know what Microsoft says an update breaks** - "Any known issues with KB5094126 before I roll it out?" (`include_known_issues=True` adds each KB's Microsoft-confirmed known issues — symptoms, workarounds, and the resolving update — best-effort from its public support page; published mainly for Windows updates, and the response says explicitly when Microsoft publishes none) - **Slice a month by weakness class** - "Show me this month's use-after-free CVEs" (`cwe="CWE-416"` or `cwe="use after free"`) - **Discover the release catalog** - "Which monthly releases are available, and when were they last revised?" (`list_months=True`) - **Export a triage briefing** - "Give me this month's Critical CVEs as a Markdown report" or "…as CSV" — a prioritized executive summary and table, or a spreadsheet-ready export (`format="markdown"` / `format="csv"`) @@ -141,7 +142,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). 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. +- **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_known_issues=True` to any KB lookup (single or batched) for the **Microsoft-confirmed known issues** of each update — issue titles, symptoms, workarounds, and the resolving KB when Microsoft names one — scraped best-effort from the KB's public support page with an honest per-KB status: `published`, `none_published` (Microsoft publishes no known-issues data for that KB — the norm outside Windows OS updates), or `unavailable` (retrieval/parse failure, explicitly distinct from "no issues"); it reports what Microsoft has confirmed breaks, not a prediction for your environment. 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) @@ -193,10 +194,11 @@ Once connected to an MCP client, you can ask questions like: 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"`) +20. **Known issues before rollout**: "What has Microsoft confirmed breaks in KB5094126, and is there a fix or workaround?" (`include_known_issues=True`) +21. **Weakness class**: "Show me this month's use-after-free vulnerabilities" (`cwe="CWE-416"`) +22. **Release catalog**: "Which Patch Tuesday months are available to query?" +23. **Product watchlist**: "Show me this month's Critical CVEs across my estate" (`product_profile="identity-core"`, `severity="Critical"`) +24. **Guided triage**: "Walk me through this month's triage for my identity estate" (selects the `monthly_triage` prompt with `product_profile="identity-core"`) ## Usage @@ -345,6 +347,7 @@ HTTP-mode environment variables: | `MCP_LOG_LEVEL` | `WARNING` | Root log level (`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`); logs go to stderr | | `MCP_MSRC_MAX_RESPONSE_BYTES` | `67108864` (64 MiB) | Cap on a single MSRC upstream response body (read while streaming, never buffered past the cap) | | `MCP_ENRICHMENT_MAX_RESPONSE_BYTES` | `33554432` (32 MiB) | Cap on a single EPSS/KEV upstream response body | +| `MCP_KNOWN_ISSUES_MAX_RESPONSE_BYTES` | `4194304` (4 MiB) | Cap on a single support.microsoft.com KB-page body (known-issues lookups) | | `APPLICATIONINSIGHTS_CONNECTION_STRING` | unset | Opt-in usage telemetry (requires `pip install patch-tuesday-mcp[telemetry]`) | HTTP mode also serves `GET /health` (liveness endpoint) and runs stateless, diff --git a/pyproject.toml b/pyproject.toml index 26e239a..06c7dfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "patch-tuesday-mcp" -version = "0.7.0" +version = "0.8.0" description = "MCP server for Microsoft Patch Tuesday security updates (MSRC Security Update Guide)" requires-python = ">=3.11" readme = "README.md" diff --git a/server.json b/server.json index a5847e1..b5ebbbb 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/jonnybottles/patch-tuesday-mcp", "source": "github" }, - "version": "0.7.0", + "version": "0.8.0", "packages": [ { "registryType": "pypi", "identifier": "patch-tuesday-mcp", - "version": "0.7.0", + "version": "0.8.0", "transport": { "type": "stdio" } diff --git a/src/patch_tuesday_mcp/__init__.py b/src/patch_tuesday_mcp/__init__.py index d4ddb7c..c2db333 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.7.0" +__version__ = "0.8.0" diff --git a/src/patch_tuesday_mcp/feeds/http_client.py b/src/patch_tuesday_mcp/feeds/http_client.py index bb443aa..05768ff 100644 --- a/src/patch_tuesday_mcp/feeds/http_client.py +++ b/src/patch_tuesday_mcp/feeds/http_client.py @@ -44,6 +44,17 @@ async def aclose() -> None: _client = None +async def get_location(url: str, *, timeout: float) -> tuple[int, str | None]: + """GET a URL without reading the body; return (status, Location header or None). + + Lets a caller resolve one redirect hop manually — validating the target + itself — while the shared client keeps follow_redirects=False. + """ + client = get_client() + async with client.stream("GET", url, timeout=timeout) as response: + return response.status_code, response.headers.get("location") + + async def get_bounded( url: str, *, diff --git a/src/patch_tuesday_mcp/feeds/known_issues.py b/src/patch_tuesday_mcp/feeds/known_issues.py new file mode 100644 index 0000000..8863e07 --- /dev/null +++ b/src/patch_tuesday_mcp/feeds/known_issues.py @@ -0,0 +1,377 @@ +"""Microsoft-confirmed known issues for a KB, scraped from its support page. + +Microsoft publishes no keyless API for known-issues data (the Graph +windowsupdates API requires an AAD tenant), so this feed reads the public +per-KB support page — https://support.microsoft.com/en-us/help/ — and +parses its "Known issues in this update" section. The source is unstructured +HTML, so retrieval is strictly best-effort and every result carries an honest +status: + +- ``published``: the section parsed into structured issue entries. +- ``none_published``: Microsoft publishes no known-issues data for this KB + (no per-KB page, no section on the page, or an explicit "not aware of any + issues" statement). Common for non-Windows products. +- ``unavailable``: the page could not be fetched or no longer parses (layout + drift). Never reported as ``none_published`` — an upstream failure must not + masquerade as "no known issues". + +Fetch failures never raise into a search; ``fetch_known_issues`` always +returns a dict. Two quirks of the source shape this module: the /help/ +URL answers with a single same-host redirect to the canonical article (we +follow exactly one validated hop; the shared client never follows redirects +on its own), and unknown KB numbers redirect to *unrelated* articles — which +still echo the requested id in an analytics meta tag — so a landing page is +only trusted when its URL slug or its title names the requested KB. +""" + +import asyncio +import logging +import os +import re +import time +from html.parser import HTMLParser +from urllib.parse import urlsplit + +import httpx + +from .. import telemetry +from . import http_client + +SUPPORT_HOST = "support.microsoft.com" +SUPPORT_KB_URL = "https://support.microsoft.com/en-us/help/{kb}" + +# KB pages measure ~150-360 KB; far beyond that means a misbehaving upstream. +MAX_RESPONSE_BYTES = int(os.getenv("MCP_KNOWN_ISSUES_MAX_RESPONSE_BYTES", str(4 * 1024 * 1024))) + +KNOWN_ISSUES_TTL_SECONDS = 6 * 3600 +MAX_CACHE_ENTRIES = 500 # parsed results are ~1-4 KB, so worst case ~2 MB +FETCH_CONCURRENCY = 3 + +# A known-issues section with no parseable issue entries is an explicit +# "none" statement when its prose is short; anything longer suggests issue +# content our parser no longer understands (layout drift). +_NONE_TEXT_MAX_CHARS = 400 + +_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) + +logger = logging.getLogger(__name__) + +# kb number -> (fetched_at, result); insertion order doubles as LRU recency. +# Only published/none_published results are cached: a transient failure must +# not pin "unavailable" for a whole TTL. +_cache: dict[str, tuple[float, dict]] = {} + +_fetch_semaphore: asyncio.Semaphore | None = None +_semaphore_loop: asyncio.AbstractEventLoop | None = None + + +class KnownIssuesError(Exception): + """Raised when the known-issues source returns an error or unexpected response.""" + + +def clear_cache() -> None: + """Reset the cache (used by tests).""" + global _fetch_semaphore, _semaphore_loop + _cache.clear() + _fetch_semaphore = None + _semaphore_loop = None + + +def _get_fetch_semaphore() -> asyncio.Semaphore: + """Semaphore bound to the running loop (recreated across test loops).""" + global _fetch_semaphore, _semaphore_loop + loop = asyncio.get_running_loop() + if _fetch_semaphore is None or _semaphore_loop is not loop: + _fetch_semaphore = asyncio.Semaphore(FETCH_CONCURRENCY) + _semaphore_loop = loop + return _fetch_semaphore + + +def _elapsed_ms(start: float) -> float: + return round((time.perf_counter() - start) * 1000, 1) + + +# --- HTML parsing (stdlib only; the source publishes no structured format) --- + +# The section heading as observed on Windows/.NET update pages; tolerate the +# small wording variants Microsoft has used over the years. +_HEADING_RE = re.compile( + r"]*>[^<]*known issues (?:in|with) this (?:security )?update", re.IGNORECASE +) +# The known-issues section ends where the next top-level section or heading +# starts; issue entries inside it are div-based, never
. +_REGION_END_RE = re.compile(r"]|', re.IGNORECASE) +_ISSUE_TITLE_RE = re.compile( + r'class="ocpExpandoHeadTitleContainer"[^>]*>(?P.*?)</div>', re.DOTALL +) +_ISSUE_BODY_RE = re.compile(r'class="ocpExpandoBody"[^>]*>', re.IGNORECASE) +# Bold paragraph labels segmenting an issue body. +_MARKER_RE = re.compile( + r'<b class="ocpLegacyBold">[^<]*?(?P<label>symptoms?|workaround|next steps?|resolution)' + r"[^<]*?</b>", + re.IGNORECASE, +) +_RESOLVED_BY_RE = re.compile( + r"(?:addressed|resolved)\s*(?:in|by)[^.]{0,80}?KB\s?(\d{6,8})", re.IGNORECASE +) + +_JUNK_TO_SPACE = dict.fromkeys([0x200B, 0x200C, 0x200D, 0x200E, 0x200F, 0xFEFF, 0xA0], " ") + + +class _TextExtractor(HTMLParser): + """Flatten an HTML fragment to plain text (entities resolved, tags dropped).""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + + def handle_data(self, data: str) -> None: + self.parts.append(data) + + def handle_starttag(self, tag: str, attrs: list) -> None: + # Block boundaries become spaces so joined paragraphs stay readable. + if tag in ("p", "br", "li", "div", "h3", "td", "tr"): + self.parts.append(" ") + + +def _text(fragment: str) -> str: + extractor = _TextExtractor() + extractor.feed(fragment) + text = "".join(extractor.parts).translate(_JUNK_TO_SPACE) + text = re.sub(r"\s+", " ", text).strip() + return re.sub(r"\s+([.,;:!?)])", r"\1", text) + + +def _segment_issue_body(body_html: str) -> dict[str, str]: + """Split an issue body on its bold Symptoms/Workaround/Resolution labels.""" + markers = list(_MARKER_RE.finditer(body_html)) + if not markers: + text = _text(body_html) + return {"symptoms": text} if text else {} + + label_field = { + "symptom": "symptoms", + "symptoms": "symptoms", + "workaround": "workaround", + "next step": "workaround", + "next steps": "workaround", + "resolution": "resolution", + } + segments: dict[str, list[str]] = {} + for i, marker in enumerate(markers): + field = label_field[marker.group("label").lower()] + end = markers[i + 1].start() if i + 1 < len(markers) else len(body_html) + text = _text(body_html[marker.end() : end]) + if text: + segments.setdefault(field, []).append(text) + return {field: " ".join(parts) for field, parts in segments.items()} + + +def _parse_issue_block(block_html: str) -> dict | None: + title_match = _ISSUE_TITLE_RE.search(block_html) + if not title_match: + return None + title = _text(title_match.group("title")) + if not title: + return None + + issue: dict = {"title": title} + body_match = _ISSUE_BODY_RE.search(block_html) + if body_match: + issue.update(_segment_issue_body(block_html[body_match.end() :])) + + resolved = _RESOLVED_BY_RE.search( + f"{issue.get('workaround', '')} {issue.get('resolution', '')}" + ) + if resolved: + issue["resolved_by"] = f"KB{resolved.group(1)}" + return issue + + +def _parse_known_issues(html: str, kb_number: str, source_url: str) -> dict: + """Parse a KB support page into the known-issues result contract. + + Pure and side-effect free; returns a ``published`` / ``none_published`` / + ``unavailable`` dict as documented in the module docstring. + """ + heading = _HEADING_RE.search(html) + if heading is None: + return { + "status": "none_published", + "note": ( + f"The Microsoft support page for KB{kb_number} does not publish a " + "known-issues section (common for non-Windows products)." + ), + "source_url": source_url, + } + + tail = html[heading.end() :] + region_end = _REGION_END_RE.search(tail) + region = tail[: region_end.start()] if region_end else tail + + issues = [] + for block in _ISSUE_BLOCK_RE.split(region)[1:]: + issue = _parse_issue_block(block) + if issue: + issues.append(issue) + + if issues: + return {"status": "published", "issues": issues, "source_url": source_url} + + # No structured entries: a short section is Microsoft's explicit "none" + # statement; a long one means content our parser no longer understands. + region_text = _text(region) + if len(region_text) <= _NONE_TEXT_MAX_CHARS: + return { + "status": "none_published", + "note": region_text + or f"The known-issues section on the KB{kb_number} support page is empty.", + "source_url": source_url, + } + return { + "status": "unavailable", + "note": ( + "A known-issues section exists on the Microsoft support page but could not " + "be parsed into structured entries (the page layout may have changed); " + "consult the source page directly." + ), + "source_url": source_url, + } + + +# --- Fetching --- + + +def _resolve_redirect(location: str | None) -> str: + """Validate a Location header, allowing only the same support host.""" + if not location: + raise KnownIssuesError("KB page redirect carried no Location header") + if location.startswith("/"): + return f"https://{SUPPORT_HOST}{location}" + parts = urlsplit(location) + if parts.scheme == "https" and parts.hostname == SUPPORT_HOST: + return location + raise KnownIssuesError(f"refusing KB page redirect off {SUPPORT_HOST}: {location!r}") + + +async def _fetch_kb_page(kb_number: str) -> tuple[str | None, str | None]: + """Fetch the support page for a KB, following one validated redirect hop. + + Returns (final_url, html), or (None, None) when Microsoft has no page for + this KB. Raises KnownIssuesError for anything that is a retrieval failure + rather than a definitive absence. + """ + url = SUPPORT_KB_URL.format(kb=kb_number) + try: + status, location = await http_client.get_location(url, timeout=30.0) + if status == 404: + return None, None + if status in _REDIRECT_STATUSES: + target = _resolve_redirect(location) + elif status == 200: + target = url + else: + raise KnownIssuesError(f"KB page lookup returned HTTP {status}") + + body_status, body = await http_client.get_bounded( + target, timeout=30.0, max_bytes=MAX_RESPONSE_BYTES + ) + except httpx.HTTPError as exc: + raise KnownIssuesError(f"KB page request failed: {exc}") from exc + if body_status != 200: + raise KnownIssuesError(f"KB page returned HTTP {body_status}") + return target, body.decode("utf-8", errors="replace") + + +def _page_matches_kb(final_url: str, html: str, kb_number: str) -> bool: + """Whether the landing page provably belongs to the requested KB. + + The /help/{kb} resolver is fuzzy: unknown ids land on unrelated articles, + and the page echoes the *requested* id in an analytics meta tag either + way, so in-body digit matches prove nothing. Only the canonical URL slug + (".../june-9-2026-kb5094126-...") or the page title count as proof. + Pages that never self-reference (some .NET update pages) fail this check + and are conservatively reported as having no per-KB page — never as a + source of issues that might belong to a different update. + """ + if f"kb{kb_number}" in final_url.lower(): + return True + title = re.search(r"<title[^>]*>(.*?)", html, re.IGNORECASE | re.DOTALL) + return bool(title and re.search(rf"(? dict | None: + cached = _cache.get(kb_number) + if cached is None or time.monotonic() - cached[0] >= KNOWN_ISSUES_TTL_SECONDS: + return None + _cache[kb_number] = _cache.pop(kb_number) # refresh LRU recency + return cached[1] + + +def _cache_put(kb_number: str, result: dict) -> None: + _cache.pop(kb_number, None) + _cache[kb_number] = (time.monotonic(), result) + while len(_cache) > MAX_CACHE_ENTRIES: + del _cache[next(iter(_cache))] + + +async def fetch_known_issues(kb_number: str) -> dict: + """Best-effort known-issues lookup for a numeric KB id ("5094123"). + + Never raises: failures degrade to {"status": "unavailable", ...}. Results + are cached in-process for KNOWN_ISSUES_TTL_SECONDS (failures are not). + """ + cached = _cache_get(kb_number) + if cached is not None: + return cached + + start = time.perf_counter() + try: + async with _get_fetch_semaphore(): + final_url, html = await _fetch_kb_page(kb_number) + if html is None or not _page_matches_kb(final_url, html, kb_number): + # No page, or a landing page that cannot be verified as this KB's: + # report "no per-KB page" rather than trust another article's data. + result = { + "status": "none_published", + "note": ( + f"Microsoft publishes no per-KB support page for KB{kb_number}, " + "so no known-issues data is available for this update." + ), + } + else: + result = _parse_known_issues(html, kb_number, final_url) + except Exception as exc: # fail open: enrichment must never break a lookup + logger.warning("known-issues fetch failed for KB%s: %s", kb_number, exc) + telemetry.track_event( + "enrichment_fetch", + {"source": "known_issues", "ok": False, "duration_ms": _elapsed_ms(start)}, + ) + return { + "status": "unavailable", + "note": ( + "The Microsoft support page for this KB could not be retrieved; retry " + "later or check the page directly. This does not mean no issues exist." + ), + "source_url": SUPPORT_KB_URL.format(kb=kb_number), + } + + telemetry.track_event( + "enrichment_fetch", + { + "source": "known_issues", + "ok": True, + "duration_ms": _elapsed_ms(start), + "status": result["status"], + }, + ) + if result["status"] != "unavailable": + _cache_put(kb_number, result) + return result + + +async def prefetch(kb_numbers: list[str]) -> None: + """Warm the cache for a KB batch; concurrency is semaphore-bounded and + failures are swallowed (each lookup already fails open).""" + await asyncio.gather(*(fetch_known_issues(kb) for kb in kb_numbers)) diff --git a/src/patch_tuesday_mcp/server.py b/src/patch_tuesday_mcp/server.py index 30429ff..ec02299 100644 --- a/src/patch_tuesday_mcp/server.py +++ b/src/patch_tuesday_mcp/server.py @@ -108,9 +108,13 @@ def _trusted_proxies() -> frozenset[str]: "(EPSS >= 50%), or exploitation_likely=True (Microsoft's own " "'Exploitation More Likely' assessment). Filter by weakness class " "with cwe='CWE-416'. Add include_chain=True to a kb= lookup to walk " - "Microsoft-stated supersedence links (which KBs it replaces), and " + "Microsoft-stated supersedence links (which KBs it replaces), " "include_kb_details=True for per-KB fixed builds and restart " - "requirements. Set include_stats=True with limit=0 for a month " + "requirements, and include_known_issues=True for the issues Microsoft " + "has publicly confirmed the update introduces (symptoms, workarounds, " + "resolving KB), best-effort from the KB's support page — published " + "mainly for Windows updates; other products usually report " + "none_published. Set include_stats=True with limit=0 for a month " "overview (counts by severity, impact, product family, exploited, " "KEV). Use list_months=True to discover available monthly releases. " "When no month is given, results default to the most recent release " diff --git a/src/patch_tuesday_mcp/tools/search.py b/src/patch_tuesday_mcp/tools/search.py index f64aed5..67328b3 100644 --- a/src/patch_tuesday_mcp/tools/search.py +++ b/src/patch_tuesday_mcp/tools/search.py @@ -9,7 +9,7 @@ from pydantic import Field from .. import telemetry -from ..feeds import enrichment, msrc_api +from ..feeds import enrichment, known_issues, msrc_api from ..feeds.msrc_api import MsrcApiError from ..models.vulnerability import ( EXPLOITATION_LIKELY_VALUES, @@ -82,6 +82,7 @@ async def msrc_search( include_references: bool = False, include_kb_details: bool = False, include_kev_details: bool = False, + include_known_issues: bool = False, include_temporal: bool = False, list_months: bool = False, format: str = "json", @@ -122,6 +123,9 @@ async def msrc_search( 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 + - See what Microsoft has confirmed an update breaks (kb=..., + include_known_issues=True) -- known issues from the KB's public support + page: symptoms, workarounds, and the resolving update when stated - Find KEV-listed CVEs this month (kev=True) -- confirmed exploited, with federal remediation due dates - High exploitation probability (min_epss=0.5) -- EPSS >= 50% @@ -226,6 +230,20 @@ async def msrc_search( entry (due_date, ransomware_use, required_action, vendor_project, product, vulnerability_name) instead of a boolean flag; on cve= lookups it extends the kev block with the extra catalog fields. + include_known_issues: When True together with kb=, adds a known_issues + block per KB with the issues Microsoft has publicly confirmed for + that update (title, symptoms, workaround, and the resolving KB + when stated), scraped best-effort from the KB's support page on + support.microsoft.com. This reports what Microsoft has confirmed + breaks -- it does not predict behavior in a specific environment. + The block's status field is honest about coverage: "published" + (issues listed), "none_published" (Microsoft publishes no + known-issues data for this KB -- the norm for most non-Windows + products; Windows cumulative/preview updates are the main + source), or "unavailable" (the page could not be fetched or + parsed; NOT the same as no issues). Attached even when the KB is + not found in MSRC security releases (e.g. preview-only updates). + Ignored without kb=. include_temporal: When True, cvss blocks gain the CVSS temporal score Microsoft publishes (exploit-code maturity adjusted). Applies to cve= detail and to list rows that carry a cvss block. @@ -277,6 +295,11 @@ 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 + - known_issues: (only for kb= lookups with include_known_issues=True) + per-KB block with status ("published" / "none_published" / + "unavailable"), an issues list (title, symptoms, workaround, + resolution, resolved_by) when published, a note otherwise, and the + source_url of the Microsoft support page - 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 @@ -331,6 +354,7 @@ async def msrc_search( include_references=include_references, include_kb_details=include_kb_details, include_kev_details=include_kev_details, + include_known_issues=include_known_issues, include_temporal=include_temporal, list_months=list_months, format=format, @@ -393,6 +417,7 @@ async def _search_impl( include_references: bool, include_kb_details: bool, include_kev_details: bool, + include_known_issues: bool, include_temporal: bool, list_months: bool, format: str, @@ -429,6 +454,7 @@ async def _search_impl( include_references=include_references, include_kb_details=include_kb_details, include_kev_details=include_kev_details, + include_known_issues=include_known_issues, ) if kb: return await _lookup_kb( @@ -441,6 +467,7 @@ async def _search_impl( include_references=include_references, include_kb_details=include_kb_details, include_kev_details=include_kev_details, + include_known_issues=include_known_issues, ) # --- Release-catalog discovery: which months exist --- @@ -962,6 +989,44 @@ async def _lookup_kb( include_references: bool = False, include_kb_details: bool = False, include_kev_details: bool = False, + include_known_issues: bool = False, +) -> dict: + """Single-KB lookup, optionally decorated with Microsoft's known issues. + + The known-issues block is best-effort and attaches even when the KB is + absent from MSRC security releases (preview-only updates publish known + issues too) — but never to an invalid_input error, where there is no + usable KB number to look up. + """ + response = await _lookup_kb_msrc( + kb, + include_chain, + month, + limit, + offset, + force_refresh, + include_references=include_references, + include_kb_details=include_kb_details, + include_kev_details=include_kev_details, + include_known_issues=include_known_issues, + ) + if include_known_issues and response.get("error_kind") != "invalid_input": + kb_number = kb.strip().upper().removeprefix("KB").strip() + response["known_issues"] = await known_issues.fetch_known_issues(kb_number) + return response + + +async def _lookup_kb_msrc( + kb: 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, + include_known_issues: bool = False, ) -> dict: kb_number = kb.strip().upper().removeprefix("KB").strip() filters_applied = {"kb": f"KB{kb_number}"} @@ -972,6 +1037,7 @@ async def _lookup_kb( ("include_references", include_references), ("include_kb_details", include_kb_details), ("include_kev_details", include_kev_details), + ("include_known_issues", include_known_issues), ): if flag: filters_applied[name] = True @@ -1069,6 +1135,7 @@ async def _lookup_kbs( include_references: bool = False, include_kb_details: bool = False, include_kev_details: bool = False, + include_known_issues: bool = False, ) -> dict: """Batched KB lookup: one grouped response with a per-KB result entry. @@ -1091,6 +1158,7 @@ async def _lookup_kbs( ("include_references", include_references), ("include_kb_details", include_kb_details), ("include_kev_details", include_kev_details), + ("include_known_issues", include_known_issues), ): if flag: filters_applied[name] = True @@ -1117,6 +1185,11 @@ async def _lookup_kbs( filters_applied, ) + if include_known_issues: + # Warm the known-issues cache for the whole batch concurrently; the + # per-KB lookups below are then served from it. + await known_issues.prefetch(kb_numbers) + results: list[dict] = [] total_found = 0 for number in kb_numbers: @@ -1130,6 +1203,7 @@ async def _lookup_kbs( include_references=include_references, include_kb_details=include_kb_details, include_kev_details=include_kev_details, + include_known_issues=include_known_issues, ) single.pop("filters_applied", None) entry = {"kb": f"KB{number}", "found": "error" not in single, **single} diff --git a/tests/fixtures/kb_known_issues.html b/tests/fixtures/kb_known_issues.html new file mode 100644 index 0000000..1878833 --- /dev/null +++ b/tests/fixtures/kb_known_issues.html @@ -0,0 +1,60 @@ + + + +June 9, 2026—KB5094126 (OS Builds 26200.8655 and 26100.8655) - Microsoft Support + + +
+

June 9, 2026—KB5094126 (OS Builds 26200.8655 and 26100.8655)

+
+

Highlights

+

This security update includes quality improvements. Below is a summary of the key issues that this update addresses when you install this KB.

+
    +
  • This update addresses security issues for your Windows operating system.

  • +
+
+

Known issues in this update

+ + +
+

+
+
+

+Symptoms +

+

Microsoft has received reports of an issue in which certain third-party applications might be unable to launch Microsoft Office applications or open documents after installing the Windows updates released on or after June 9, 2026. This issue affects certain third-party applications that use OLE automation​to interact with Microsoft Office applications. +Affected Microsoft Office applications might include Word, Excel, PowerPoint, Access, and other Microsoft Office applications when launched from within the affected third-party application.

+

+Workaround +

+

​​​​​​​A resolution is in progress and will be included in a future Windows update. More information will be shared when it becomes available.

+

To work around this issue, open the application or document directly instead of launching it from the affected third-party application.

+
+
+
+
+

+
+
+

+Symptoms +

+

After you install Windows updates released on June 9, 2026, the confirmation dialog might display the internal Recycle Bin file name (for example, $Rxxxxx.ext) instead of the original file name when you permanently delete a single item. In Recycle Bin, the item still appears with its original name.

+

​​​​​​​Workaround

+

This issue is addressed in​KB5095093.

+
+
+
+
+

​​​How to get this update

+

+Before you install this update +

+

Microsoft combines the latest servicing stack update (SSU) for your operating system with the latest cumulative update (LCU).

+
+
+ + diff --git a/tests/fixtures/kb_no_issues_section.html b/tests/fixtures/kb_no_issues_section.html new file mode 100644 index 0000000..7db6c50 --- /dev/null +++ b/tests/fixtures/kb_no_issues_section.html @@ -0,0 +1,24 @@ + + + +Description of the security update for SharePoint Server 2016: June 9, 2026 (KB5002880) - Microsoft Support + + +
+

Description of the security update for SharePoint Server 2016: June 9, 2026 (KB5002880)

+
+

Summary

+

This security update contains improvements and fixes for Microsoft SharePoint Server 2016. Learn more in KB5002880.

+
+

Improvements and fixes

+

This security update contains fixes and improvements for SharePoint Server 2016.

+
+

How to get and install the update

+

+Method 1: Microsoft Update +

+

This update is available from Microsoft Update. When you turn on automatic updating, this update will be downloaded and installed automatically.

+
+
+ + diff --git a/tests/fixtures/kb_none_aware.html b/tests/fixtures/kb_none_aware.html new file mode 100644 index 0000000..3ae60bc --- /dev/null +++ b/tests/fixtures/kb_none_aware.html @@ -0,0 +1,21 @@ + + + +May 12, 2026—KB5091234 (OS Build 26100.8501) - Microsoft Support + + +
+

May 12, 2026—KB5091234 (OS Build 26100.8501)

+
+

Highlights

+

This security update includes quality improvements.

+
+

Known issues in this update

+

​​​Microsoft is not currently aware of any issues with this update.

+
+

How to get this update

+

Microsoft combines the latest servicing stack update (SSU) for your operating system with the latest cumulative update (LCU).

+
+
+ + diff --git a/tests/fixtures/kb_wrong_landing.html b/tests/fixtures/kb_wrong_landing.html new file mode 100644 index 0000000..03fe69f --- /dev/null +++ b/tests/fixtures/kb_wrong_landing.html @@ -0,0 +1,16 @@ + + + +MS16-062: Security update for Windows kernel-mode drivers: May 10, 2016 - Microsoft Support + + + +
+

MS16-062: Security update for Windows kernel-mode drivers: May 10, 2016

+
+

Summary

+

This security update resolves vulnerabilities in Windows. The most severe of the vulnerabilities could allow elevation of privilege if an attacker logs on to an affected system and runs a specially crafted application.

+
+
+ + diff --git a/tests/test_http_bounds.py b/tests/test_http_bounds.py index 7edd809..810c098 100644 --- a/tests/test_http_bounds.py +++ b/tests/test_http_bounds.py @@ -106,3 +106,19 @@ async def test_shared_client_does_not_follow_redirects(): assert client.follow_redirects is False, ( "redirects could point off the hardcoded hosts; the client must not follow them" ) + + +async def test_get_location_returns_redirect_without_following(monkeypatch): + response = FakeStreamResponse( + status_code=301, body=b"ignored", headers={"location": "/en-us/topic/some-slug"} + ) + _use_fake_client(monkeypatch, response) + status, location = await http_client.get_location("https://support.microsoft.com/x", timeout=5) + assert (status, location) == (301, "/en-us/topic/some-slug") + assert response.body_consumed is False, "get_location must not read the body" + + +async def test_get_location_returns_none_on_non_redirect(monkeypatch): + _use_fake_client(monkeypatch, FakeStreamResponse(status_code=200, body=b"page")) + status, location = await http_client.get_location("https://support.microsoft.com/x", timeout=5) + assert (status, location) == (200, None) diff --git a/tests/test_known_issues.py b/tests/test_known_issues.py new file mode 100644 index 0000000..34750e3 --- /dev/null +++ b/tests/test_known_issues.py @@ -0,0 +1,430 @@ +"""Tests for the Microsoft support-page known-issues feed (mocked HTTP). + +The feed scrapes the public per-KB support page (an unstructured source), so +these tests pin the honest-status contract: `published` vs `none_published` +vs `unavailable`, with upstream failures never masquerading as "none". +""" + +import asyncio +from pathlib import Path + +import httpx +import pytest + +from patch_tuesday_mcp.feeds import http_client, known_issues +from patch_tuesday_mcp.feeds.known_issues import ( + KnownIssuesError, + clear_cache, + fetch_known_issues, + prefetch, +) + +FIXTURES = Path(__file__).parent / "fixtures" +SOURCE_URL = "https://support.microsoft.com/en-us/topic/test-kb-page" + + +def _load(name: str) -> str: + return (FIXTURES / name).read_text(encoding="utf-8") + + +@pytest.fixture(autouse=True) +def reset_cache(): + clear_cache() + yield + clear_cache() + + +def _set_page(monkeypatch, html: str, url: str = SOURCE_URL) -> list: + """Patch the network seam to serve one canned page; return the call log.""" + calls = [] + + async def fake_fetch(kb_number): + calls.append(kb_number) + return (url, html) + + monkeypatch.setattr(known_issues, "_fetch_kb_page", fake_fetch) + return calls + + +# --- Parser: published issues --- + + +def test_parse_published_issue_fields(): + result = known_issues._parse_known_issues( + _load("kb_known_issues.html"), "5094126", SOURCE_URL + ) + assert result["status"] == "published" + assert result["source_url"] == SOURCE_URL + issues = result["issues"] + assert len(issues) == 2 + + office = issues[0] + assert office["title"] == ( + "Microsoft Office applications might fail to open from certain third-party apps" + ) + assert "OLE automation" in office["symptoms"] + assert "open the application or document directly" in office["workaround"] + assert "resolved_by" not in office + + recycle = issues[1] + assert recycle["title"] == "Deleting from Recycle Bin displays an internal file name" + assert "$Rxxxxx.ext" in recycle["symptoms"] + assert "addressed in" in recycle["workaround"] + assert recycle["resolved_by"] == "KB5095093" + + +def test_parse_strips_zero_width_junk(): + result = known_issues._parse_known_issues( + _load("kb_known_issues.html"), "5094126", SOURCE_URL + ) + for issue in result["issues"]: + for value in issue.values(): + assert chr(0x200B) not in value + assert "\xa0" not in value + + +def test_parse_heading_variant_still_matches(): + html = _load("kb_known_issues.html").replace( + "Known issues in this update", "Known issues with this security update" + ) + result = known_issues._parse_known_issues(html, "5094126", SOURCE_URL) + assert result["status"] == "published" + assert len(result["issues"]) == 2 + + +def test_parse_slicing_excludes_neighbor_sections(): + result = known_issues._parse_known_issues( + _load("kb_known_issues.html"), "5094126", SOURCE_URL + ) + titles = " ".join(issue["title"] for issue in result["issues"]) + assert "How to get this update" not in titles + assert "Highlights" not in titles + + +def test_parse_unsegmented_body_lands_in_symptoms(): + # Some issue bodies carry prose without bold Symptoms/Workaround labels; + # the whole text describes the problem, so it maps to symptoms. + html = _load("kb_known_issues.html") + html = html.replace('Symptoms', "").replace( + 'Workaround', "" + ) + result = known_issues._parse_known_issues(html, "5094126", SOURCE_URL) + assert result["status"] == "published" + office = result["issues"][0] + assert "OLE automation" in office["symptoms"] + assert "workaround" not in office + + +def test_parse_block_without_title_is_skipped(): + html = _load("kb_known_issues.html").replace( + ">Deleting from Recycle Bin displays an internal file name<", "><" + ) + result = known_issues._parse_known_issues(html, "5094126", SOURCE_URL) + assert result["status"] == "published" + assert len(result["issues"]) == 1, "a titleless block must be skipped, not invented" + + +# --- Parser: none published --- + + +def test_parse_none_aware_section_is_none_published(): + result = known_issues._parse_known_issues( + _load("kb_none_aware.html"), "5091234", SOURCE_URL + ) + assert result["status"] == "none_published" + assert "not currently aware of any issues" in result["note"] + assert result["source_url"] == SOURCE_URL + assert "issues" not in result + + +def test_parse_missing_section_is_none_published(): + result = known_issues._parse_known_issues( + _load("kb_no_issues_section.html"), "5002880", SOURCE_URL + ) + assert result["status"] == "none_published" + assert "known-issues section" in result["note"] + assert result["source_url"] == SOURCE_URL + + +# --- Parser: markup drift degrades to unavailable, never a silent none --- + + +def test_parse_drifted_markup_is_unavailable_with_pointer(): + html = _load("kb_known_issues.html").replace("ocpExpandoHeadTitleContainer", "ocpRenamed") + result = known_issues._parse_known_issues(html, "5094126", SOURCE_URL) + assert result["status"] == "unavailable" + assert "could not be parsed" in result["note"] + assert result["source_url"] == SOURCE_URL + + +# --- _fetch_kb_page: redirect policy (manual, single hop, same host only) --- + + +def _patch_transport(monkeypatch, *, location_status=301, location=None, + body_status=200, body=b"ok"): + fetched = [] + + async def fake_get_location(url, *, timeout): + fetched.append(("head", url)) + return (location_status, location) + + async def fake_get_bounded(url, *, headers=None, timeout, max_bytes): + fetched.append(("get", url)) + return (body_status, body) + + monkeypatch.setattr(http_client, "get_location", fake_get_location) + monkeypatch.setattr(http_client, "get_bounded", fake_get_bounded) + return fetched + + +async def test_fetch_follows_single_same_host_relative_redirect(monkeypatch): + fetched = _patch_transport(monkeypatch, location="/en-us/topic/some-kb-slug") + url, html = await known_issues._fetch_kb_page("5094126") + assert url == "https://support.microsoft.com/en-us/topic/some-kb-slug" + assert html == "ok" + assert ("get", url) in fetched + + +async def test_fetch_accepts_absolute_same_host_https_redirect(monkeypatch): + _patch_transport( + monkeypatch, location="https://support.microsoft.com/en-us/topic/some-kb-slug" + ) + url, _ = await known_issues._fetch_kb_page("5094126") + assert url == "https://support.microsoft.com/en-us/topic/some-kb-slug" + + +async def test_fetch_rejects_cross_host_redirect(monkeypatch): + _patch_transport(monkeypatch, location="https://evil.example.com/en-us/topic/x") + with pytest.raises(KnownIssuesError): + await known_issues._fetch_kb_page("5094126") + + +async def test_fetch_rejects_redirect_without_location(monkeypatch): + _patch_transport(monkeypatch, location=None) + with pytest.raises(KnownIssuesError): + await known_issues._fetch_kb_page("5094126") + + +async def test_fetch_404_means_no_page(monkeypatch): + _patch_transport(monkeypatch, location_status=404) + assert await known_issues._fetch_kb_page("5094126") == (None, None) + + +async def test_fetch_direct_200_reads_original_url(monkeypatch): + fetched = _patch_transport(monkeypatch, location_status=200) + url, html = await known_issues._fetch_kb_page("5094126") + assert url == known_issues.SUPPORT_KB_URL.format(kb="5094126") + assert html == "ok" + assert ("get", url) in fetched + + +async def test_fetch_landing_non_200_raises(monkeypatch): + _patch_transport(monkeypatch, location="/en-us/topic/x", body_status=500) + with pytest.raises(KnownIssuesError): + await known_issues._fetch_kb_page("5094126") + + +async def test_fetch_unexpected_first_hop_status_raises(monkeypatch): + _patch_transport(monkeypatch, location_status=503) + with pytest.raises(KnownIssuesError): + await known_issues._fetch_kb_page("5094126") + + +async def test_fetch_wraps_httpx_errors(monkeypatch): + async def broken_get_location(url, *, timeout): + raise httpx.ConnectError("boom") + + monkeypatch.setattr(http_client, "get_location", broken_get_location) + with pytest.raises(KnownIssuesError): + await known_issues._fetch_kb_page("5094126") + + +async def test_fetch_oversized_body_raises_known_issues_error(monkeypatch): + async def fake_get_location(url, *, timeout): + return (301, "/en-us/topic/x") + + async def oversized_get_bounded(url, *, headers=None, timeout, max_bytes): + raise http_client.ResponseTooLarge("too big") + + monkeypatch.setattr(http_client, "get_location", fake_get_location) + monkeypatch.setattr(http_client, "get_bounded", oversized_get_bounded) + with pytest.raises(KnownIssuesError): + await known_issues._fetch_kb_page("5094126") + + +# --- fetch_known_issues: orchestration, honest statuses, caching --- + + +async def test_lookup_published_and_cached(monkeypatch): + calls = _set_page(monkeypatch, _load("kb_known_issues.html")) + first = await fetch_known_issues("5094126") + assert first["status"] == "published" + assert len(first["issues"]) == 2 + + second = await fetch_known_issues("5094126") + assert second == first + assert len(calls) == 1, "second lookup must be served from the cache" + + +async def test_lookup_no_page_is_none_published_and_cached(monkeypatch): + calls = [] + + async def fake_fetch(kb_number): + calls.append(kb_number) + return (None, None) + + monkeypatch.setattr(known_issues, "_fetch_kb_page", fake_fetch) + result = await fetch_known_issues("1111111") + assert result["status"] == "none_published" + assert "no per-KB support page" in result["note"] + assert "source_url" not in result + + await fetch_known_issues("1111111") + assert len(calls) == 1 + + +async def test_lookup_wrong_landing_page_is_none_published(monkeypatch): + # A bogus KB redirects to an unrelated article; the page must not be + # trusted when neither the URL slug nor the title names the requested KB. + # (The fixture carries the awa-kb_id analytics meta echoing the request — + # like the real site does even on wrong landings — so in-body digit + # matches must not count as verification.) + _set_page(monkeypatch, _load("kb_wrong_landing.html")) + result = await fetch_known_issues("9999999") + assert result["status"] == "none_published" + assert "no per-KB support page" in result["note"] + + +async def test_lookup_never_attributes_another_pages_issues(monkeypatch): + # The dangerous fuzzy-redirect case: the landing page HAS known issues, + # but belongs to a different KB. Its issues must not be attributed to the + # requested KB, even though the analytics meta echoes the requested id. + html = _load("kb_known_issues.html").replace( + "", '\n' + ) + _set_page( + monkeypatch, html, url="https://support.microsoft.com/en-us/topic/some-other-article" + ) + result = await fetch_known_issues("1234567") + assert result["status"] == "none_published" + assert "no per-KB support page" in result["note"] + assert "issues" not in result + + +async def test_lookup_url_slug_match_trusts_page(monkeypatch): + # Some pages never state their KB in the title; the canonical URL slug + # (e.g. .../june-9-2026-kb5094126-os-builds...) is verification enough. + html = _load("kb_known_issues.html").replace("KB5094126", "").replace("5094126", "") + _set_page( + monkeypatch, + html, + url="https://support.microsoft.com/en-us/topic/june-9-2026-kb5094126-os-builds-x", + ) + result = await fetch_known_issues("5094126") + assert result["status"] == "published" + + +async def test_lookup_fetch_failure_is_unavailable_and_not_cached(monkeypatch): + async def failing_fetch(kb_number): + raise KnownIssuesError("boom") + + monkeypatch.setattr(known_issues, "_fetch_kb_page", failing_fetch) + result = await fetch_known_issues("5094126") + assert result["status"] == "unavailable" + assert result["note"] + + # A later successful fetch must not be blocked by a cached failure. + _set_page(monkeypatch, _load("kb_known_issues.html")) + recovered = await fetch_known_issues("5094126") + assert recovered["status"] == "published" + + +async def test_lookup_ttl_expiry_refetches(monkeypatch): + calls = _set_page(monkeypatch, _load("kb_known_issues.html")) + await fetch_known_issues("5094126") + monkeypatch.setattr(known_issues, "KNOWN_ISSUES_TTL_SECONDS", 0) + await fetch_known_issues("5094126") + assert len(calls) == 2, "an expired entry must be re-fetched" + + +async def test_cache_is_bounded(monkeypatch): + monkeypatch.setattr(known_issues, "MAX_CACHE_ENTRIES", 2) + template = _load("kb_known_issues.html") + + async def fake_fetch(kb_number): + return (SOURCE_URL, template.replace("5094126", kb_number)) + + monkeypatch.setattr(known_issues, "_fetch_kb_page", fake_fetch) + for kb in ("5000001", "5000002", "5000003"): + await fetch_known_issues(kb) + assert len(known_issues._cache) <= 2, "cache must not grow unboundedly" + + +async def test_lookup_never_raises(monkeypatch): + async def exploding_fetch(kb_number): + raise RuntimeError("unexpected") + + monkeypatch.setattr(known_issues, "_fetch_kb_page", exploding_fetch) + result = await fetch_known_issues("5094126") + assert result["status"] == "unavailable" + + +# --- prefetch: batch warm-up with bounded concurrency --- + + +async def test_prefetch_warms_cache_with_bounded_concurrency(monkeypatch): + template = _load("kb_known_issues.html") + active = 0 + peak = 0 + calls = [] + + async def slow_fetch(kb_number): + nonlocal active, peak + active += 1 + peak = max(peak, active) + await asyncio.sleep(0.01) + active -= 1 + calls.append(kb_number) + return (SOURCE_URL, template.replace("5094126", kb_number)) + + monkeypatch.setattr(known_issues, "_fetch_kb_page", slow_fetch) + kbs = [f"500000{i}" for i in range(6)] + await prefetch(kbs) + assert peak >= 2, "prefetch must fetch concurrently" + assert peak <= known_issues.FETCH_CONCURRENCY, "prefetch concurrency must stay bounded" + + for kb in kbs: + await fetch_known_issues(kb) + assert len(calls) == 6, "individual lookups must be served from the warmed cache" + + +async def test_prefetch_swallows_failures(monkeypatch): + async def failing_fetch(kb_number): + raise KnownIssuesError("boom") + + monkeypatch.setattr(known_issues, "_fetch_kb_page", failing_fetch) + await prefetch(["5094126", "5094127"]) # must not raise + result = await fetch_known_issues("5094126") + assert result["status"] == "unavailable" + + +# --- Telemetry --- + + +async def test_lookup_emits_telemetry(monkeypatch): + events = [] + monkeypatch.setattr( + known_issues.telemetry, "track_event", lambda name, props: events.append((name, props)) + ) + _set_page(monkeypatch, _load("kb_known_issues.html")) + await fetch_known_issues("5094126") + + async def failing_fetch(kb_number): + raise KnownIssuesError("boom") + + monkeypatch.setattr(known_issues, "_fetch_kb_page", failing_fetch) + await fetch_known_issues("5094127") + + outcomes = [p["ok"] for n, p in events if p.get("source") == "known_issues"] + assert True in outcomes + assert False in outcomes diff --git a/tests/test_live_smoke.py b/tests/test_live_smoke.py index 3d9dde0..0bd3dd4 100644 --- a/tests/test_live_smoke.py +++ b/tests/test_live_smoke.py @@ -134,6 +134,37 @@ async def test_live_force_refresh_and_freshness_metadata(): assert "freshness" not in default +async def test_live_kb_known_issues_statuses(): + """Drift canary for the support.microsoft.com known-issues scrape. + + KB5094126 (June 2026 Windows 11 cumulative) is an archived page with + published known issues; if Microsoft changes the page layout this fails + (status becomes "unavailable") and the parser needs updating. + """ + from patch_tuesday_mcp.feeds import known_issues + + result = await known_issues.fetch_known_issues("5094126") + assert result["status"] == "published", result + assert result["source_url"].startswith("https://support.microsoft.com/") + assert result["issues"] + for issue in result["issues"]: + assert issue["title"] + + # A page without a known-issues section (SharePoint) is an explicit + # none_published, and a bogus KB's fuzzy redirect must not be trusted. + none = await known_issues.fetch_known_issues("5002880") + assert none["status"] == "none_published", none + bogus = await known_issues.fetch_known_issues("9999999") + assert bogus["status"] == "none_published", bogus + + +async def test_live_kb_known_issues_through_tool(): + result = await msrc_search(kb="5094126", include_known_issues=True) + block = result["known_issues"] + assert block["status"] in {"published", "none_published"}, block + assert result["filters_applied"]["include_known_issues"] is True + + async def test_live_trend_search_across_recent_months(): result = await msrc_search(months_back=3, limit=50) assert "error" not in result, result.get("error") diff --git a/tests/test_tools.py b/tests/test_tools.py index 57ebb4f..7c2eadf 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -6,7 +6,7 @@ import pytest -from patch_tuesday_mcp.feeds import enrichment, msrc_api +from patch_tuesday_mcp.feeds import enrichment, known_issues, msrc_api from patch_tuesday_mcp.feeds.enrichment import EnrichmentError from patch_tuesday_mcp.feeds.msrc_api import MsrcApiError, clear_cache from patch_tuesday_mcp.tools import search as search_module @@ -36,6 +36,7 @@ def mock_api(monkeypatch): clear_cache() enrichment.clear_cache() + known_issues.clear_cache() with open(FIXTURE, encoding="utf-8") as f: cvrf_doc = json.load(f) @@ -61,11 +62,18 @@ async def fake_enrichment_get_json(url, timeout=30.0): return {"vulnerabilities": []} raise EnrichmentError(f"unexpected URL in test: {url}") + async def fake_fetch_kb_page(kb_number): + # Default: the known-issues source is unreachable, so any accidental + # opt-in degrades to "unavailable" and no test ever touches the network. + raise known_issues.KnownIssuesError("no known-issues page in test") + monkeypatch.setattr(msrc_api, "_get_json", fake_get_json) monkeypatch.setattr(enrichment, "_get_json", fake_enrichment_get_json) + monkeypatch.setattr(known_issues, "_fetch_kb_page", fake_fetch_kb_page) yield clear_cache() enrichment.clear_cache() + known_issues.clear_cache() def _set_enrichment( @@ -1467,3 +1475,111 @@ async def test_profile_contents_not_echoed_in_filters(monkeypatch, tmp_path): filters = result["filters_applied"] assert filters.get("product_profile") == "secretwatch" assert "Contoso Internal App" not in json.dumps(filters) + + +# --- kb= known issues (include_known_issues, opt-in) --- + + +KI_FIXTURE = Path(__file__).parent / "fixtures" / "kb_known_issues.html" +KI_NO_SECTION_FIXTURE = Path(__file__).parent / "fixtures" / "kb_no_issues_section.html" + + +def _set_known_issues_page(monkeypatch, html_by_kb: dict[str, str]) -> list: + """Serve canned support pages per KB number; unknown KBs have no page.""" + calls = [] + + async def fake_fetch_kb_page(kb_number): + calls.append(kb_number) + if kb_number in html_by_kb: + url = f"https://support.microsoft.com/en-us/topic/kb{kb_number}-test" + return (url, html_by_kb[kb_number]) + return (None, None) + + monkeypatch.setattr(known_issues, "_fetch_kb_page", fake_fetch_kb_page) + known_issues.clear_cache() + return calls + + +def _ki_html_for(kb_number: str) -> str: + # The real fixture page is about KB5094126; re-target its KB token so the + # wrong-landing-page validation accepts it for the requested KB. + return KI_FIXTURE.read_text(encoding="utf-8").replace("5094126", kb_number) + + +async def test_kb_default_output_has_no_known_issues_key(): + single = await msrc_search(kb="5094123") + assert "error" not in single + assert "known_issues" not in single + assert "include_known_issues" not in single["filters_applied"] + + batch = await msrc_search(kb=["5094123"]) + assert "known_issues" not in batch["results"][0] + + +async def test_kb_include_known_issues_attaches_published_block(monkeypatch): + _set_known_issues_page(monkeypatch, {"5094123": _ki_html_for("5094123")}) + result = await msrc_search(kb="5094123", include_known_issues=True) + + assert "error" not in result + assert result["total_found"] > 0, "the MSRC lookup itself must be unchanged" + block = result["known_issues"] + assert block["status"] == "published" + assert len(block["issues"]) == 2 + assert block["issues"][1]["resolved_by"] == "KB5095093" + assert block["source_url"].startswith("https://support.microsoft.com/") + assert result["filters_applied"]["include_known_issues"] is True + + +async def test_kb_include_known_issues_failure_leaves_lookup_intact(): + # mock_api's default known-issues fetch raises: the KB lookup must still + # succeed, with the block explicitly marked unavailable (never missing, + # never masquerading as "none published"). + result = await msrc_search(kb="5094123", include_known_issues=True) + assert "error" not in result + assert result["total_found"] > 0 + assert result["vulnerabilities"] + assert result["known_issues"]["status"] == "unavailable" + + +async def test_kb_include_known_issues_none_published(monkeypatch): + html = KI_NO_SECTION_FIXTURE.read_text(encoding="utf-8").replace("5002880", "5094123") + _set_known_issues_page(monkeypatch, {"5094123": html}) + result = await msrc_search(kb="5094123", include_known_issues=True) + block = result["known_issues"] + assert block["status"] == "none_published" + assert "issues" not in block + + +async def test_kb_not_found_still_carries_known_issues(monkeypatch): + # Preview/optional updates publish known-issues pages but are absent from + # MSRC security releases; the block still attaches to the not_found error. + _set_known_issues_page(monkeypatch, {"1111111": _ki_html_for("1111111")}) + result = await msrc_search(kb="1111111", include_known_issues=True) + assert result["error_kind"] == "not_found" + assert result["known_issues"]["status"] == "published" + + +async def test_kb_list_include_known_issues_per_entry(monkeypatch): + _set_known_issues_page(monkeypatch, {"5094123": _ki_html_for("5094123")}) + result = await msrc_search(kb=["5094123", "1111111"], include_known_issues=True) + + assert result["filters_applied"]["include_known_issues"] is True + found, missing = result["results"] + assert found["kb"] == "KB5094123" + assert found["found"] is True + assert found["known_issues"]["status"] == "published" + assert missing["kb"] == "KB1111111" + assert missing["found"] is False + assert missing["known_issues"]["status"] == "none_published" + + +async def test_kb_list_default_shape_unchanged_by_known_issues_feature(): + result = await msrc_search(kb=["5094123", "1111111"]) + for entry in result["results"]: + assert "known_issues" not in entry + + +async def test_kb_invalid_input_skips_known_issues(): + result = await msrc_search(kb="Release Notes", include_known_issues=True) + assert result["error_kind"] == "invalid_input" + assert "known_issues" not in result diff --git a/uv.lock b/uv.lock index 3670b03..a27befc 100644 --- a/uv.lock +++ b/uv.lock @@ -1327,7 +1327,7 @@ wheels = [ [[package]] name = "patch-tuesday-mcp" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "fastmcp" }, From 13ef1cd7b17ef594d31fb83b5c9a6dfa6ea72b18 Mon Sep 17 00:00:00 2001 From: jbutler Date: Sat, 11 Jul 2026 19:36:34 -0400 Subject: [PATCH 2/2] docs: drop Glama hosted connector from the session-start checklist Confirmed live 2026-07-11: the ACA endpoint is listed as a hosted connector at glama.ai alongside the GitHub-sourced entry. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1cbfd00..587202c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,10 +44,9 @@ On Windows in this repo use `.venv/Scripts/python -m pytest` etc. — the venv w 1. **awesome-mcp-servers PR** — merged yet? `gh pr view 9879 --repo punkpeye/awesome-mcp-servers --json state,mergedAt` (all bot requirements met: Glama listing, badge, score A 4.8/5.0). 2. **Docker MCP Catalog PR** — CI green / review status? `gh pr view 4400 --repo docker/mcp-registry --json state,statusCheckRollup` (their CI builds the image itself; `server.yaml` pins `MCP_TRANSPORT=stdio` via config.env — if CI fails, fix in the fork branch `jonnybottles/mcp-registry:add-patch-tuesday`). -3. **Glama hosted connector** — approved yet? Check https://glama.ai/mcp/connectors?query=patch-tuesday for the ACA endpoint entry (the open-source *server* listing is already live and scored). -4. **PulseMCP auto-listing** — expected ~1 week after the 2026-07-11 registry publish: check https://www.pulsemcp.com/servers?q=patch-tuesday (they ingest the official MCP Registry; if absent after 2026-07-20, email hello@pulsemcp.com). -5. **Smithery deployment** — healthy and listed? https://smithery.ai/servers/xxbutler86xx/patch-tuesday (first deployment was still processing at submission time). -6. **Monthly draft routine** (only in the week after a Patch Tuesday) — did the Wednesday run open a `briefing/YYYY-MM` PR and email drafts to the user? Routine: https://claude.ai/code/routines/trig_01X24fvnRGhC6Lop3NRjVaJh +3. **PulseMCP auto-listing** — expected ~1 week after the 2026-07-11 registry publish: check https://www.pulsemcp.com/servers?q=patch-tuesday (they ingest the official MCP Registry; if absent after 2026-07-20, email hello@pulsemcp.com). +4. **Smithery deployment** — healthy and listed? https://smithery.ai/servers/xxbutler86xx/patch-tuesday (first deployment was still processing at submission time). +5. **Monthly draft routine** (only in the week after a Patch Tuesday) — did the Wednesday run open a `briefing/YYYY-MM` PR and email drafts to the user? Routine: https://claude.ai/code/routines/trig_01X24fvnRGhC6Lop3NRjVaJh Standing follow-ups (no check needed, do when convenient): upload a social-preview image (repo Settings → Social preview); bump the PyPI classifier to `Development Status :: 4 - Beta` with the next release (requires version bump + `uv lock` + full deploy verification).