diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 39691d6..b985ef6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -48,10 +48,6 @@ services: USAGE_LOG_DIR: /app/var/logs USAGE_LOG_KEEP_DAYS: "90" - # Phase 12 — doc-bug submission gate. Off by default; on only - # in production after you've verified the endpoint contract. - DOC_BUG_SUBMIT_ENABLED: "false" - # DOC_BUG_API_URL: "https://docs-be.example.com/api/feedback" volumes: # Usage logs persist across container recreates. - ./morpheus-docs-mcp-logs:/app/var/logs diff --git a/docs_mcp/server.py b/docs_mcp/server.py index d86a0a3..df79356 100644 --- a/docs_mcp/server.py +++ b/docs_mcp/server.py @@ -9,7 +9,7 @@ PLAN.md add or extend pieces of this file: Phase 9 — diff_versions, list_cluster, bundle_changelog Phase 10 — TimedCall wiring (already imported below) Phase 11 — _api_lessons tool - Phase 12 — find_doc_inconsistencies, submit_doc_bug + Phase 12 — find_doc_inconsistencies Phase 13 — weekly_digest + _digest_history reader Every stub below has a docstring + `raise NotImplementedError`. Replace @@ -63,10 +63,6 @@ RERANK_TIMEOUT = float(os.environ.get("RERANK_TIMEOUT", "30")) HYBRID_SEARCH = os.environ.get("HYBRID_SEARCH", "").lower() in ("true", "1", "yes", "on") RRF_K = int(os.environ.get("RRF_K", "60")) -DOC_BUG_SUBMIT_ENABLED = os.environ.get("DOC_BUG_SUBMIT_ENABLED", "").lower() in ("true", "1", "yes", "on") -DOC_BUG_API_URL = os.environ.get("DOC_BUG_API_URL", "") # product-specific endpoint -DOC_BUG_TIMEOUT = float(os.environ.get("DOC_BUG_TIMEOUT", "15")) - # --------------------------------------------------------------------------- # FastMCP setup. @@ -927,7 +923,7 @@ def morpheus_api_lessons( # =========================================================================== -# Phase 12 — find_doc_inconsistencies + submit_doc_bug +# Phase 12 — find_doc_inconsistencies # =========================================================================== _REDIRECT_PHRASE_RE = re.compile( @@ -1038,17 +1034,13 @@ def find_doc_inconsistencies( ) -> str: """Scan a scoped set of HVM docs pages for likely documentation bugs. - Surfaces concrete candidates for human review — NOT a stream of - bugs to auto-submit. Workflow: + Surfaces concrete candidates for human review. Workflow: 1. Run this against a focused scope. 2. Review each finding; many will be false positives. 3. For real bugs, drill in with `get_page` / `diff_versions`. - 4. Draft a bug report; show the operator; ask explicitly. - 5. Only then call `submit_doc_bug`. One bug = one confirmation. - - **Do NOT loop submissions.** Even on "submit them all", confirm each - one individually. HPE's docs queue is a shared resource. + 4. Draft a bug report and file it through the docs portal's own + feedback channel — this MCP does not submit upstream. """ with TimedCall("find_doc_inconsistencies", { "scope_query": scope_query, "version": version, "platform": platform, @@ -1110,8 +1102,8 @@ def find_doc_inconsistencies( f"# Doc inconsistency scan — {len(candidates)} pages inspected", "", f"_Scope_: `{scope_query}` • _Filters_: version={version}, platform={platform}, bundle_id={bundle_id} • _Checks_: {sorted(requested)}", "", f"**{total} candidate finding{'' if total == 1 else 's'}.** Review each individually. " - "For real bugs, follow up with `get_page` / `diff_versions`, draft the report, " - "show the operator, and only call `submit_doc_bug` after explicit confirmation.", "", + "For real bugs, follow up with `get_page` / `diff_versions`, draft a report, " + "and file it via the docs portal's own feedback channel.", "", ] if not total: lines.append("_No findings in this scope._") @@ -1131,146 +1123,8 @@ def find_doc_inconsistencies( elif check == "redirect_chain": lines.append(f"- Body length: {f['body_chars']} chars • Phrase: *\"{f['redirect_phrase']}\"*") lines.append("") - lines += ["---", - "_Reminder: `submit_doc_bug` has a real side effect. Draft → show → confirm → submit, one at a time. Do not loop._"] return "\n".join(lines) - -# --- submit_doc_bug ---------------------------------------------------------- -# HPE Support DocPortal's "Was this helpful?" widget POSTs to an endpoint -# we haven't sniffed yet. Until DOC_BUG_API_URL is set AND -# DOC_BUG_SUBMIT_ENABLED=true, this tool refuses submission and tells the -# operator to paste manually. When you sniff the endpoint, set both env -# vars and verify the payload shape against the schema below. - -_DOC_BUG_ALLOWED_HOSTS = {"support.hpe.com"} -_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") - - -@mcp.tool() -def submit_doc_bug( - page_url: Annotated[str, Field(description="Full URL of the support.hpe.com page the bug is about. Must be a support.hpe.com URL.")], - content: Annotated[str, Field(description="Body of the bug report. Be specific: what the page says, what's wrong, what it should say. Cite exact passages. The docs team reads it verbatim.")], - email: Annotated[str | None, Field(description="OPTIONAL submitter email for follow-up. Omit if anonymous.")] = None, - rating: Annotated[int | None, Field(description="OPTIONAL star rating 1-5 (1-2 for serious bugs, 3 unclear, 4-5 only on explicit request).")] = None, - like: Annotated[bool | None, Field(description="OPTIONAL thumbs-up/down. False for bugs, True for positive feedback.")] = None, -) -> str: - """Submit a documentation bug to HPE's docs feedback channel. - - **⚠️ THIS TOOL HAS A REAL SIDE EFFECT (when enabled). It POSTs to - HPE's docs feedback endpoint and the submission lands in their queue.** - - **MANDATORY operator-confirmation workflow:** - - 1. Draft the bug content yourself. Show the operator the exact text - you intend to submit + the page URL + any rating/email fields. - 2. Ask explicitly: *"Submit this bug? (yes/no)"* - 3. Only call submit_doc_bug AFTER they answer yes. - 4. If they say *"submit them all"*, STILL confirm each one. This - tool MUST NOT be called in a loop without per-bug consent. - - **Do not call this autonomously.** Don't preemptively submit while - exploring inconsistencies. Don't call inside an agent loop without - a human in the loop. Misuse will get this MCP blocked at HPE's WAF. - - **What makes a good bug report:** - - Specific page URL. One bug = one page. - - Concrete quote of the problem text + version/platform context. - - Suggested correction when you have one. - - Avoid editorializing — factual bugs and broken links best. - """ - with TimedCall("submit_doc_bug", { - "page_url": page_url, "content_len": len(content or ""), - "email_present": bool(email), "rating": rating, "like": like, - }) as _call: - if not DOC_BUG_SUBMIT_ENABLED: - _call.set(error="disabled", outcome="refused_disabled") - return ( - "submit_doc_bug is disabled on this MCP deployment " - "(DOC_BUG_SUBMIT_ENABLED is not set). The operator's draft is good — " - f"they can paste it into the feedback widget on {page_url} themselves.\n\n" - "_(For maintainers: sniff HPE's feedback endpoint, set DOC_BUG_API_URL " - "to the POST target, and DOC_BUG_SUBMIT_ENABLED=true to activate.)_" - ) - if not DOC_BUG_API_URL: - _call.set(error="no_endpoint", outcome="refused_disabled") - return ("submit_doc_bug is enabled but DOC_BUG_API_URL is empty. " - f"Operator should paste manually at {page_url}.") - if not content or not content.strip(): - _call.set(error="empty_content", outcome="refused_invalid") - return "Refused: empty `content`." - if len(content) > 10000: - _call.set(error="content_too_long", outcome="refused_invalid") - return f"Refused: `content` is {len(content)} chars (cap 10000)." - try: - from urllib.parse import urlparse - parsed = urlparse(page_url) - except Exception as e: - _call.set(error=f"url_parse: {e}", outcome="refused_invalid") - return f"Refused: couldn't parse page_url ({e})." - if parsed.scheme not in ("http", "https"): - _call.set(error="bad_scheme", outcome="refused_invalid") - return f"Refused: scheme must be http(s), got {parsed.scheme!r}." - if parsed.hostname not in _DOC_BUG_ALLOWED_HOSTS: - _call.set(error=f"bad_host: {parsed.hostname}", outcome="refused_invalid") - return (f"Refused: page_url host {parsed.hostname!r} isn't a " - f"support.hpe.com URL. submit_doc_bug only accepts bugs against HPE Support pages.") - if email is not None and not _EMAIL_RE.match(email): - _call.set(error="bad_email", outcome="refused_invalid") - return f"Refused: email {email!r} doesn't look valid. Omit if anonymous." - if rating is not None and not (1 <= rating <= 5): - _call.set(error="bad_rating", outcome="refused_invalid") - return f"Refused: rating must be 1-5, got {rating}." - - href = f"{parsed.scheme}://{parsed.hostname}{parsed.path}{('?' + parsed.query) if parsed.query else ''}" - payload: dict = {"content": content, "href": href} - if email: - payload["email"] = email - if rating is not None: - payload["rating"] = rating - if like is not None: - payload["like"] = like - - try: - import httpx - except ImportError: - _call.set(error="httpx_missing", outcome="refused_runtime") - return "Refused: httpx not available." - - headers = { - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": "hvm-docs-mcp submit_doc_bug", - "Origin": "https://support.hpe.com", - "Referer": href, - } - try: - with httpx.Client(timeout=DOC_BUG_TIMEOUT) as c: - r = c.post(DOC_BUG_API_URL, json=payload, headers=headers) - except httpx.RequestError as e: - _call.set(error=f"transport: {e}", outcome="failed_transport") - return f"Submission failed (transport): {e}" - - comment_id: object = None - body_summary = "" - try: - resp_json = r.json() - comment_id = resp_json.get("commentId") or resp_json.get("id") - body_summary = json.dumps(resp_json)[:300] - except (ValueError, json.JSONDecodeError): - body_summary = (r.text or "")[:300] - _call.set(http_status=r.status_code, comment_id=comment_id, - outcome=("submitted" if r.is_success else "rejected_upstream")) - if r.is_success: - id_note = f" (commentId={comment_id})" if comment_id else "" - return f"Submitted. HTTP {r.status_code}{id_note}. HPE docs team will see this for {href}." - if r.status_code in (401, 403, 429): - return (f"Submission rejected upstream (HTTP {r.status_code}). " - "Likely captcha/auth/rate-limit on anonymous POSTs. " - f"Operator can paste manually at {href}.\n\nResponse (truncated): {body_summary}") - return f"Submission rejected upstream (HTTP {r.status_code}). Response (truncated): {body_summary}" - - # =========================================================================== # Entry point # ===========================================================================