mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
Adds ProwlerMCP, the FastMCP subclass every sub-server is now built
from. Its tool() wraps whatever it registers -- the decorator forms and
the direct call BaseTool uses -- so a failure leaves any tool as a
ToolError, which the client reads as isError: true.
Applied at the base class rather than by hand because forgetting it is
silent: every server now sets mask_error_details=True, so an unwrapped
tool would answer "Error calling tool 'x'" and nothing else. ToolError
bypasses that masking, which is what lets the servers mask by default
and still say something useful.
No tool changes yet. Tools that still return {"error": ...} keep working
exactly as before; they are converted surface by surface in the PRs
above this one. What changes here is that a failure which used to escape
as a raw exception is now described by render_tool_error.
The rules this establishes are in AGENTS.md and the developer guide,
so the conversions have something to be checked against.
80 lines
3.1 KiB
Python
80 lines
3.1 KiB
Python
"""Tests for the mounted root MCP server.
|
|
|
|
Reference for later branches: open the client inline with
|
|
``async with Client(mcp_root_server)``. FastMCP warns against holding a client in
|
|
a fixture because it causes hard-to-diagnose event-loop problems.
|
|
"""
|
|
|
|
from fastmcp import Client
|
|
|
|
from tests.helpers.assertions import (
|
|
assert_namespaced,
|
|
assert_tool_contract,
|
|
tools_in_namespace,
|
|
)
|
|
|
|
|
|
async def test_every_sub_server_contributes_tools(mcp_root_server):
|
|
"""Each of the three mounts must expose tools under its own namespace.
|
|
|
|
This is the guard against a silent startup failure. ``setup_main_server()``
|
|
wraps each mount in try/except and ``load_all_tools`` swallows per-tool
|
|
construction errors, so a sub-server that registers nothing is still logged as
|
|
"successfully mounted". The `prowler_*` namespace in particular collapses to
|
|
zero tools whenever the API key is missing when the module is first imported.
|
|
"""
|
|
async with Client(mcp_root_server) as client:
|
|
tools = await client.list_tools()
|
|
|
|
assert tools_in_namespace(tools, "prowler_hub_"), "Prowler Hub registered no tools"
|
|
assert tools_in_namespace(tools, "prowler_docs_"), (
|
|
"Prowler Docs registered no tools"
|
|
)
|
|
assert tools_in_namespace(tools, "prowler_"), "Prowler App registered no tools"
|
|
|
|
|
|
async def test_no_tool_disappears_between_registration_and_the_client(mcp_root_server):
|
|
"""Every tool registered on a sub-server must still be reachable through the mount.
|
|
|
|
`ProwlerMCP.tool` wraps every tool before handing it to FastMCP, whether it arrived
|
|
by decorator or by the direct call `BaseTool.register_tools` makes. A wrapper that
|
|
loses the signature, the name or the coroutine-ness of what it wraps drops the tool
|
|
silently: the mount still succeeds and the count is the only thing that moves.
|
|
"""
|
|
from prowler_mcp_server.prowler_app.server import app_mcp_server
|
|
from prowler_mcp_server.prowler_documentation.server import docs_mcp_server
|
|
from prowler_mcp_server.prowler_hub.server import hub_mcp_server
|
|
|
|
async with Client(mcp_root_server) as client:
|
|
tools = await client.list_tools()
|
|
|
|
for namespace, sub_server in (
|
|
("prowler_hub_", hub_mcp_server),
|
|
("prowler_docs_", docs_mcp_server),
|
|
("prowler_", app_mcp_server),
|
|
):
|
|
expected = len(await sub_server.list_tools())
|
|
published = len(tools_in_namespace(tools, namespace))
|
|
assert published == expected, (
|
|
f"'{namespace}' publishes {published} tools but its sub-server registered "
|
|
f"{expected}"
|
|
)
|
|
|
|
|
|
async def test_every_tool_is_namespaced(mcp_root_server):
|
|
"""Tool names are a published interface; nothing may escape the namespaces."""
|
|
async with Client(mcp_root_server) as client:
|
|
tools = await client.list_tools()
|
|
|
|
for tool in tools:
|
|
assert_namespaced(tool)
|
|
|
|
|
|
async def test_every_tool_and_parameter_is_described(mcp_root_server):
|
|
"""Descriptions are the contract a model reads before calling a tool."""
|
|
async with Client(mcp_root_server) as client:
|
|
tools = await client.list_tools()
|
|
|
|
for tool in tools:
|
|
assert_tool_contract(tool)
|