mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(mcp): raise instead of returning error objects in the Prowler Docs tools (#12534)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0715619435
commit
13a31d9225
@@ -0,0 +1 @@
|
||||
`prowler_docs_search` no longer reports a failed search as zero matches or an unreadable answer as a bad search term, and `prowler_docs_get_document` no longer reports a failed fetch as a missing page
|
||||
@@ -2,6 +2,7 @@ import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from prowler_mcp_server import __version__
|
||||
from prowler_mcp_server.lib.errors import parse_json_response
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
@@ -58,8 +59,7 @@ class ProwlerDocsSearchEngine:
|
||||
)
|
||||
|
||||
def search(self, query: str, page_size: int = 5) -> list[SearchResult]:
|
||||
"""
|
||||
Search documentation using Mintlify API.
|
||||
"""Search documentation using Mintlify API.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
@@ -69,15 +69,23 @@ class ProwlerDocsSearchEngine:
|
||||
|
||||
Returns:
|
||||
list of search results
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If the search request failed, which is not the same
|
||||
answer as no matches
|
||||
UpstreamInvalidResponse: If the answer is not JSON, which is the
|
||||
documentation site's fault and not the search term's
|
||||
"""
|
||||
try:
|
||||
# Make request to Mintlify API
|
||||
response = self.mintlify_client.post(
|
||||
self.api_base_url,
|
||||
json={"query": query, "filters": {}},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# Not `response.json()`: the decode error it raises is a ValueError, which
|
||||
# the shared classifier reads as a malformed argument and answers by
|
||||
# telling the caller to fix a search term that was never the problem.
|
||||
data = parse_json_response(response)
|
||||
|
||||
# Parse results
|
||||
results = []
|
||||
@@ -113,22 +121,19 @@ class ProwlerDocsSearchEngine:
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
# Return empty list on error
|
||||
print(f"Search error: {e}")
|
||||
return []
|
||||
|
||||
def get_document(self, doc_path: str) -> str | None:
|
||||
"""
|
||||
Get full document content from Mintlify documentation.
|
||||
"""Get full document content from Mintlify documentation.
|
||||
|
||||
Args:
|
||||
doc_path: Path to the documentation file (e.g., "getting-started/installation")
|
||||
|
||||
Returns:
|
||||
Full markdown content of the documentation, or None if not found
|
||||
Full markdown content of the documentation, or None if there is no
|
||||
page at that path
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If the fetch failed for any reason other than a 404
|
||||
"""
|
||||
try:
|
||||
# Clean up the path
|
||||
doc_path = doc_path.rstrip("/")
|
||||
|
||||
@@ -141,10 +146,8 @@ class ProwlerDocsSearchEngine:
|
||||
|
||||
# Fetch the documentation page
|
||||
response = self.docs_client.get(url)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
|
||||
return response.text
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching document: {e}")
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.types import NonBlankStr
|
||||
@@ -9,7 +10,7 @@ from prowler_mcp_server.prowler_documentation.search_engine import (
|
||||
)
|
||||
|
||||
# Initialize FastMCP server
|
||||
docs_mcp_server = FastMCP("prowler-docs")
|
||||
docs_mcp_server = FastMCP("prowler-docs", mask_error_details=True)
|
||||
prowler_docs_search_engine = ProwlerDocsSearchEngine()
|
||||
|
||||
|
||||
@@ -56,6 +57,10 @@ def get_document(
|
||||
"""
|
||||
content: str | None = prowler_docs_search_engine.get_document(doc_path)
|
||||
if content is None:
|
||||
return {"error": f"Document '{doc_path}' not found."}
|
||||
else:
|
||||
# No `from`: this names the path asked for and the tool that produces a
|
||||
# valid one, neither of which the shared classifier can know.
|
||||
raise ToolError(
|
||||
f"The Prowler documentation has no page at '{doc_path}'. Use "
|
||||
"prowler_docs_search and pass the 'path' field of a result verbatim."
|
||||
)
|
||||
return {"content": content}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Tests for the Prowler documentation search tool.
|
||||
"""Tests for the Prowler documentation tools.
|
||||
|
||||
Mintlify moved the docs search to a new endpoint that answers with page
|
||||
sections, so a result is a part of a page and has to read as one.
|
||||
sections, so a result is a part of a page and has to read as one. And a failed
|
||||
request must not reach an agent as "the documentation has nothing on this",
|
||||
which is an answer it would act on, confidently and wrongly.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -9,6 +11,7 @@ import json
|
||||
from fastmcp import Client
|
||||
|
||||
SEARCH = "/api/search/prowler"
|
||||
DOC = "/getting-started/installation.md"
|
||||
|
||||
|
||||
def search_match(
|
||||
@@ -107,3 +110,65 @@ async def test_page_size_caps_a_response_the_api_did_not_size(
|
||||
)
|
||||
|
||||
assert len(result.data) == 2
|
||||
|
||||
|
||||
async def test_a_search_that_failed_is_not_reported_as_no_matches(
|
||||
mcp_root_server, docs_router
|
||||
):
|
||||
"""An empty list is an answer. A failed request is not, and must not look like one."""
|
||||
docs_router.add("POST", SEARCH, status=500, text="upstream error")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_docs_search", {"term": "install"})
|
||||
|
||||
assert result.isError is True
|
||||
assert result.structuredContent is None
|
||||
|
||||
|
||||
async def test_a_missing_page_fails_and_names_the_tool_that_finds_a_valid_path(
|
||||
mcp_root_server, docs_router
|
||||
):
|
||||
"""A 404 answers the question, and still reaches the agent as an error."""
|
||||
docs_router.add("GET", DOC, status=404, text="Not Found")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_docs_get_document", {"doc_path": "getting-started/installation"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "prowler_docs_search" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_fetch_that_failed_is_not_reported_as_a_missing_page(
|
||||
mcp_root_server, docs_router
|
||||
):
|
||||
"""Only a 404 answers the question; every other status left it unanswered."""
|
||||
docs_router.add("GET", DOC, status=503, text="upstream error")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp(
|
||||
"prowler_docs_get_document", {"doc_path": "getting-started/installation"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
assert "no page at" not in result.content[0].text
|
||||
|
||||
|
||||
async def test_an_unreadable_body_is_not_reported_as_a_bad_search_term(
|
||||
mcp_root_server, docs_router
|
||||
):
|
||||
"""An edge serving HTML is the site's fault; the caller has no term to fix."""
|
||||
docs_router.add("POST", SEARCH, status=200, text="<html>edge error page</html>")
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool_mcp("prowler_docs_search", {"term": "install"})
|
||||
|
||||
assert result.isError is True
|
||||
message = result.content[0].text
|
||||
# Named as the upstream at fault, and explicitly not the caller's arguments,
|
||||
# which is the story the shared ValueError branch would otherwise tell.
|
||||
assert "leaves.mintlify.com" in message
|
||||
assert "changing them will not help" in message
|
||||
# The body it choked on is upstream text, which this server never relays.
|
||||
assert "edge error page" not in message
|
||||
|
||||
Reference in New Issue
Block a user