test(mcp): add test foundation for the MCP server (#12291)

This commit is contained in:
Rubén De la Torre Vico
2026-08-04 16:19:10 +02:00
committed by GitHub
parent c74eac1369
commit 138d643119
32 changed files with 2332 additions and 22 deletions
+99
View File
@@ -0,0 +1,99 @@
name: 'MCP: Tests'
on:
push:
branches:
- 'master'
- 'v5.*'
pull_request:
branches:
- 'master'
- 'v5.*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MCP_WORKING_DIR: ./mcp_server
permissions: {}
jobs:
mcp-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
strategy:
matrix:
# requires-python is >=3.12 while the shipped image is 3.13; testing both
# is what keeps that floor honest.
python-version:
- '3.12'
- '3.13'
defaults:
run:
working-directory: ./mcp_server
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
# hub.prowler.com and raw.githubusercontent.com are deliberately absent:
# the suite mocks every outbound call, so a real one must fail the job.
# The sentry.io entry is not the test suite: the Codecov uploader sends
# its own telemetry there, so api-tests.yml and sdk-tests.yml allow it too.
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
cli.codecov.io:443
keybase.io:443
ingest.codecov.io:443
o26192.ingest.us.sentry.io:443
storage.googleapis.com:443
api.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Check for MCP server changes
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
mcp_server/**
.github/workflows/mcp-tests.yml
codecov.yml
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
mcp_server/changelog.d/**
mcp_server/AGENTS.md
mcp_server/Dockerfile
mcp_server/.dockerignore
mcp_server/entrypoint.sh
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
working-directory: ./mcp_server
- name: Run tests with pytest
if: steps.check-changes.outputs.any_changed == 'true'
run: uv run pytest --cov=./prowler_mcp_server --cov-report=xml tests
- name: Upload coverage reports to Codecov
if: steps.check-changes.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: mcp
+3
View File
@@ -34,6 +34,9 @@ test: ## Test with pytest
rm -rf .coverage && \
pytest -n auto -vvv -s --cov=./prowler --cov-report=xml tests
test-mcp: ## Test MCP server with pytest (mirrors CI)
cd mcp_server && uv run pytest --cov=./prowler_mcp_server --cov-report=term-missing tests
coverage: ## Show Test Coverage
coverage run --skip-covered -m pytest -v && \
coverage report -m && \
+7
View File
@@ -6,12 +6,19 @@ component_management:
- component_id: "api"
paths:
- "api/**"
- component_id: "mcp_server"
paths:
- "mcp_server/**"
flags:
api:
paths:
- "api/**"
carryforward: true
mcp:
paths:
- "mcp_server/**"
carryforward: true
comment:
layout: "header, diff, flags, components"
+144
View File
@@ -426,6 +426,150 @@ For complete installation and deployment options, see:
For development I recommend to use the [Model Context Protocol Inspector](https://github.com/modelcontextprotocol/inspector) as MCP client to test and debug your tools.
## Testing
Tests live in `mcp_server/tests/`, mirroring the source tree, and use the `test_*.py`
prefix (the same convention as the API, not the SDK's `*_test.py` suffix).
From `mcp_server/`:
```bash
cd mcp_server
uv run pytest # Whole suite
uv run pytest tests/prowler_app/models # One area
uv run pytest --cov=./prowler_mcp_server # With coverage
```
From the repository root:
```bash
make test-mcp # Runs the MCP suite exactly as CI does
```
Async tests need no marker — `asyncio_mode` is set to `auto`.
### Reading the Coverage Numbers
<Warning>
Coverage here has a high floor that means nothing. `coverage.py` measures
*statements*, and in a Pydantic model module nearly every statement is a class-body
field declaration that runs at **import** time. `prowler_app/server.py` imports
every tool module — and therefore every model module — when it is first imported,
so all of those declarations execute and count as covered before a single test runs.
Importing the package and executing no tests at all already reports **36% overall**,
with individual model modules between 54% and 84%. A model module sitting at ~68%
with no tests written for it has **none** of its behaviour covered: the covered lines
are its imports, `class` statements and `Field(...)` declarations, and the missing
ranges are its `from_api_response()` bodies.
Judge a module against that import-only floor, not against zero, and do not set a
Codecov target from the raw total.
</Warning>
### Shared Fixtures
All fixtures live in `mcp_server/tests/conftest.py`. Three are autouse and apply to
every test: the environment is pinned to deterministic values, real socket
connections are blocked, and the API client singleton registry is snapshotted and
restored.
| Fixture | What it gives you |
|---------|-------------------|
| `mock_api_client` | The API client singleton with its transport mocked. The workhorse. |
| `mock_router` | Route registry and request recorder |
| `mcp_root_server` | The mounted root server, for in-memory client tests |
| `health_client` | Starlette `TestClient` for the `/health` route |
| `http_request_headers` | Injects request headers for HTTP-transport auth tests |
| `hub_router` / `docs_router` | Mock the Hub and Docs sub-servers' sync HTTP clients |
| `api_client` / `isolated_api_client` | The live singleton / a freshly-constructed one |
Helpers live in `mcp_server/tests/helpers/`: JSON:API document builders
(`jsonapi.py`), the `MockRouter` (`http.py`), tool-contract assertions
(`assertions.py`) and fake credentials (`tokens.py`).
### Writing a Tool Test
Drive tools through an in-memory MCP client, and open the client inside the test —
FastMCP warns that holding a client in a fixture causes event-loop problems.
```python
from fastmcp import Client
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
FINDING_ATTRIBUTES = {
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
"status": "FAIL",
"severity": "high",
"status_extended": "S3 bucket my-bucket is publicly accessible.",
"delta": "new",
"muted": False,
"muted_reason": None,
"check_metadata": {"checkid": "s3_bucket_public_access"},
}
async def test_search_without_dates_queries_the_latest_scan_endpoint(
mcp_root_server, mock_api_client, mock_router
):
"""With no date range the tool targets the cheaper `/findings/latest`."""
mock_router.add(
"GET",
"/api/v1/findings/latest",
json=jsonapi_collection(
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)]
),
)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_search_security_findings", {})
assert result.data["findings"][0]["check_id"] == "s3_bucket_public_access"
assert mock_router.paths() == ["GET /api/v1/findings/latest"]
```
The exemplar suite covers `findings` end to end — `tests/prowler_app/models/test_findings.py`
and `tests/prowler_app/tools/test_findings.py`. It is deliberately one feature
across both layers rather than a scattering of unrelated samples, and `findings`
is the feature that exercises the whole foundation: two-tier models, nested
sub-models, both relationship shapes, endpoint switching on a date range,
list-to-CSV filter encoding, and a tool that returns prose instead of a model.
Note the two files share a name. That is why `__init__.py` is required in every
`tests/` subdirectory here — without it they would collide on import.
<Warning>
Tool parameters are declared with pydantic `Field(default=...)`, and only FastMCP's
tool wrapper resolves those defaults. Calling a tool method directly with an
argument omitted leaves it as a raw `FieldInfo` object, which is truthy — so a
filter such as `if email:` silently builds a query out of the `FieldInfo` repr.
Call tools through the client, or pass every argument explicitly.
</Warning>
### Why the API Key Is Pinned, Not Stripped
`prowler_app/server.py` builds every tool at import time. Constructing a tool
reaches `ProwlerAppAuth`, which raises when `PROWLER_API_KEY` is missing, and
`load_all_tools` swallows that error per tool class. The result is that the whole
`prowler_*` namespace registers **zero** tools while the server still logs
"Successfully mounted Prowler tools server".
The suite therefore pins a fake key in `[tool.pytest_env]`, which is applied before
any test module is imported, and `tests/test_server.py` asserts each namespace is
non-empty so this failure can never return silently.
<Note>
`ProwlerAppAuth` resolves `PROWLER_MCP_TRANSPORT_MODE` and `API_BASE_URL` in its
default arguments, which Python evaluates once at module import. `monkeypatch.setenv`
cannot change them — pass `mode=` and `base_url=` explicitly in auth tests.
</Note>
For the full set of rules and templates, see the
[`prowler-test-mcp` skill](https://github.com/prowler-cloud/prowler/blob/master/skills/prowler-test-mcp/SKILL.md)
and the [official FastMCP testing guide](https://gofastmcp.com/development/tests).
## Related Documentation
<CardGroup cols={2}>
+25 -6
View File
@@ -15,6 +15,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Review changelog format and conventions | `prowler-changelog` |
| Update CHANGELOG.md in any component | `prowler-changelog` |
| Working on MCP server tools | `prowler-mcp` |
| Writing tests for the MCP server | `prowler-test-mcp` |
## Project Overview
@@ -48,9 +49,9 @@ The Prowler MCP Server provides AI agents access to the Prowler ecosystem throug
### Three Sub-Servers
```python
await prowler_mcp_server.import_server(hub_mcp_server, prefix="prowler_hub")
await prowler_mcp_server.import_server(app_mcp_server, prefix="prowler_app")
await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
prowler_mcp_server.mount(hub_mcp_server, namespace="prowler_hub")
prowler_mcp_server.mount(app_mcp_server, namespace="prowler")
prowler_mcp_server.mount(docs_mcp_server, namespace="prowler_docs")
```
### Tool Naming
@@ -62,7 +63,7 @@ await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
## TECH STACK
Python 3.12+ | FastMCP 2.13.1 | httpx (async) | Pydantic | uv
Python 3.12+ | FastMCP 3.4.4 | httpx (async) | Pydantic | uv | pytest
---
@@ -85,9 +86,23 @@ mcp_server/prowler_mcp_server/
## COMMANDS
From `mcp_server/`:
```bash
cd mcp_server && uv run prowler-mcp # STDIO mode
cd mcp_server && uv run prowler-mcp --transport http --port 8000 # HTTP mode
cd mcp_server
uv run prowler-mcp # STDIO mode
uv run prowler-mcp --transport http --port 8000 # HTTP mode
uv run pytest # Run the test suite
uv run pytest tests/prowler_app/models # Run one area
uv run pytest --cov=./prowler_mcp_server # With coverage
```
From the repository root:
```bash
make test-mcp # Run the MCP test suite exactly as CI does
```
---
@@ -100,3 +115,7 @@ cd mcp_server && uv run prowler-mcp --transport http --port 8000 # HTTP mode
- [ ] No hardcoded secrets
- [ ] Error handling returns structured responses
- [ ] Parameter descriptions use Pydantic `Field()`
- [ ] Tests added under `mcp_server/tests/`, mirroring the source path below the
package root (`prowler_mcp_server/prowler_app/tools/` -> `tests/prowler_app/tools/`),
as the SDK does for `prowler/` -> `tests/`
- [ ] `uv run pytest` passes
@@ -0,0 +1 @@
Test foundation for the MCP server with shared fixtures, JSON:API builders, mocked HTTP transports and CI coverage reporting
+28
View File
@@ -5,7 +5,11 @@ requires = ["setuptools>=61.0", "wheel"]
[dependency-groups]
dev = [
"bandit==1.8.3",
"coverage==7.15.2",
"pytest==9.0.3",
"pytest-asyncio==1.4.0",
"pytest-cov==6.0.0",
"pytest-env==1.1.5",
"ruff==0.15.11",
"vulture==2.14"
]
@@ -27,8 +31,32 @@ prowler-mcp = "prowler_mcp_server.main:main"
[tool.pytest]
[tool.pytest.ini_options]
addopts = "--strict-markers --strict-config"
# `asyncio_mode = "auto"` lets `async def test_*` run without a per-test marker;
# the server is async end to end, so requiring one would be pure noise. Setting
# the fixture loop scope explicitly silences a pytest-asyncio deprecation warning.
asyncio_default_fixture_loop_scope = "function"
asyncio_mode = "auto"
filterwarnings = [
"error",
# Starlette's TestClient warns that it will require httpx2. The httpx pin is a
# deliberate project-wide choice, so this stays allowed until that pin moves.
"default::starlette.exceptions.StarletteDeprecationWarning"
]
pythonpath = ["."]
testpaths = ["tests"]
# Applied before any conftest or test module is imported, which is what makes it
# work: `prowler_app/server.py` builds every tool at import time, and a tool whose
# construction raises (as it does without an API key) is swallowed by
# `load_all_tools`, leaving the `prowler_*` namespace silently empty. Pinning a
# fake key here keeps the full tool surface loadable and stops a developer's
# `mcp_server/.env` from reaching the suite.
[tool.pytest_env]
API_BASE_URL = "https://api.testing.invalid/api/v1"
PROWLER_API_KEY = "pk_fake_api_key_for_unit_testing_only"
PROWLER_MCP_TRANSPORT_MODE = "stdio"
# Shared ruff baseline (kept in sync with api/pyproject.toml).
# target-version tracks this project's lowest supported Python.
[tool.ruff]
+264
View File
@@ -0,0 +1,264 @@
"""Shared fixtures for the Prowler MCP Server test suite.
This module deliberately does not import ``prowler_mcp_server.server`` at module
scope. That import builds every tool and reads the environment, so it must happen
only once the environment is settled. Environment pinning itself lives in
``[tool.pytest_env]`` in ``pyproject.toml``, which is applied before any conftest
or test module is imported; the fixtures here only keep it pinned per test.
Three properties of the runtime shape everything below and are easy to get wrong:
1. ``prowler_app/server.py`` builds every tool at import time. A tool whose
construction raises -- which is what happens with no API key -- is swallowed by
``load_all_tools``, leaving the ``prowler_*`` namespace silently empty. So the
suite pins a fake key rather than stripping the real one.
2. ``BaseTool.__init__`` captured the ``ProwlerAPIClient`` singleton by reference
at import time. Evicting it from the registry does not re-point the tools, so
the client must be patched in place.
3. ``ProwlerAppAuth`` resolves ``PROWLER_MCP_TRANSPORT_MODE`` and ``API_BASE_URL``
in its default arguments, which are evaluated once at module import.
``monkeypatch.setenv`` cannot change them -- pass ``mode=``/``base_url=``
explicitly instead.
"""
import socket
from collections.abc import Callable, Iterator
import httpx
import pytest
from starlette.requests import Request
from starlette.testclient import TestClient
from tests.helpers.http import MockRouter
from tests.helpers.tokens import FAKE_API_KEY
# Must match [tool.pytest_env] in pyproject.toml: the env var is what the code
# reads at import time, this constant is what tests assert against.
TEST_API_BASE_URL = "https://api.testing.invalid/api/v1"
# --------------------------------------------------------------- environment
@pytest.fixture(autouse=True)
def _pinned_environment(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin the runtime environment to deterministic test values.
Pinned rather than stripped: a missing ``PROWLER_API_KEY`` collapses the
``prowler_*`` namespace to zero tools instead of failing loudly.
``PROWLER_APP_API_KEY`` is the deprecated fallback and is removed so only a
test that sets it exercises that path.
This also stops a developer's gitignored ``mcp_server/.env`` or shell
environment from reaching the suite.
"""
monkeypatch.setenv("PROWLER_API_KEY", FAKE_API_KEY)
monkeypatch.setenv("API_BASE_URL", TEST_API_BASE_URL)
monkeypatch.setenv("PROWLER_MCP_TRANSPORT_MODE", "stdio")
monkeypatch.delenv("PROWLER_APP_API_KEY", raising=False)
@pytest.fixture(autouse=True)
def _no_real_network(monkeypatch: pytest.MonkeyPatch) -> None:
"""Fail loudly on any real outbound socket connection.
The subject under test is an HTTP client, so a route that was not mocked must
fail fast and obviously rather than quietly reaching hub.prowler.com and
making the suite slow, flaky and dependent on someone else's uptime.
In-process transports (Starlette's ``TestClient``, fastmcp's in-memory
client) do not open sockets, so this does not interfere with them.
"""
def _blocked(self: socket.socket, address: object, *_: object) -> None:
raise RuntimeError(
f"Blocked a real network connection to {address}. Drive HTTP through "
"the mock_api_client, hub_router or docs_router fixtures."
)
monkeypatch.setattr(socket.socket, "connect", _blocked)
monkeypatch.setattr(socket.socket, "connect_ex", _blocked)
# ----------------------------------------------------------------- API client
@pytest.fixture(autouse=True)
def _singleton_registry_guard() -> Iterator[None]:
"""Snapshot and restore the singleton registry around every test.
Deliberately a snapshot, not a clear. ``BaseTool.__init__`` captured the
``ProwlerAPIClient`` instance by reference at import time, so evicting it
would leave every registered tool pointing at an orphan that later fixtures
cannot patch -- one holding a real ``httpx.AsyncClient``. Restoring keeps a
test that resets on purpose from leaking into the next one.
"""
from prowler_mcp_server.prowler_app.utils.api_client import SingletonMeta
snapshot = dict(SingletonMeta._instances)
try:
yield
finally:
SingletonMeta._instances.clear()
SingletonMeta._instances.update(snapshot)
@pytest.fixture
def mock_router() -> MockRouter:
"""An empty route registry and request recorder for this test."""
return MockRouter()
@pytest.fixture
def api_client():
"""The live ``ProwlerAPIClient`` singleton that every registered tool holds."""
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIClient
return ProwlerAPIClient()
@pytest.fixture
def mock_api_client(api_client, mock_router: MockRouter) -> Iterator:
"""The API client singleton, with its transport driven by ``mock_router``.
Swaps ``.client`` in place rather than constructing a fresh client, so tools
reached through the MCP protocol -- which hold this exact instance -- are
mocked too. Everything else still runs for real: URL joining, query encoding,
auth headers, ``raise_for_status()`` and the JSON:API error unwrapping.
"""
original = api_client.client
api_client.client = httpx.AsyncClient(transport=mock_router.transport, timeout=30.0)
try:
yield api_client
finally:
api_client.client = original
@pytest.fixture
def isolated_api_client() -> Iterator[type]:
"""Evict the singleton so a test can exercise construction semantics.
Only for tests *about* ``ProwlerAPIClient`` itself -- its ``__init__`` or its
singleton identity. Anything reached through a tool must use
``mock_api_client``, because the tools still point at the original instance.
"""
from prowler_mcp_server.prowler_app.utils.api_client import (
ProwlerAPIClient,
SingletonMeta,
)
SingletonMeta._instances.pop(ProwlerAPIClient, None)
yield ProwlerAPIClient
# --------------------------------------------------------------- MCP surface
@pytest.fixture(scope="session")
def mcp_root_server():
"""The mounted root MCP server, imported lazily because importing has effects.
Tests open their own client over this (``async with Client(mcp_root_server)``)
rather than receiving a connected one, because FastMCP warns that holding a
client in a fixture causes hard-to-diagnose event-loop problems.
"""
from prowler_mcp_server.server import prowler_mcp_server
return prowler_mcp_server
@pytest.fixture
def health_client() -> Iterator[TestClient]:
"""An ASGI client over the stateless HTTP app, for the ``/health`` route."""
from prowler_mcp_server.server import app
with TestClient(app) as client:
yield client
@pytest.fixture
def http_request_headers() -> Iterator[Callable[..., None]]:
"""Return a callable that makes ``get_http_headers()`` observe given headers.
In HTTP transport mode ``ProwlerAppAuth`` reads the authorization header
through fastmcp's request context variable. Setting that variable directly is
what lets an auth test run without standing up a real HTTP server.
Underscores in keyword names become hyphens, so ``x_request_id=`` sets
``x-request-id``.
"""
from fastmcp.server.http import _current_http_request
def _set(**headers: str) -> None:
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"path": "/mcp",
"raw_path": b"/mcp",
"root_path": "",
"scheme": "http",
"query_string": b"",
"server": ("testserver", 80),
"client": ("testclient", 50000),
"headers": [
(name.lower().replace("_", "-").encode(), value.encode())
for name, value in headers.items()
],
}
_current_http_request.set(Request(scope))
try:
yield _set
finally:
# Not a token-based reset: an async test calls `_set` inside its task,
# and asyncio gives each task its own copy of the context, so the token
# cannot be reset from here and the task's value is discarded with the
# task anyway. Clearing the value covers the sync-test case, where the
# set would otherwise persist into the next test.
_current_http_request.set(None)
# ------------------------------------------------------- hub / docs sub-servers
def _clone_with_transport(
client: httpx.Client, transport: httpx.MockTransport
) -> httpx.Client:
"""Copy a sync client's base URL and headers onto a mock transport."""
return httpx.Client(
base_url=client.base_url,
headers=dict(client.headers),
transport=transport,
)
@pytest.fixture
def hub_router(monkeypatch: pytest.MonkeyPatch, mock_router: MockRouter) -> MockRouter:
"""Route the Prowler Hub sub-server's two module-level sync clients.
Hub tools are synchronous and reach for these clients by module global, so
they are replaced on the module rather than injected.
"""
from prowler_mcp_server.prowler_hub import server as hub
for name in ("prowler_hub_client", "github_raw_client"):
monkeypatch.setattr(
hub, name, _clone_with_transport(getattr(hub, name), mock_router.transport)
)
return mock_router
@pytest.fixture
def docs_router(monkeypatch: pytest.MonkeyPatch, mock_router: MockRouter) -> MockRouter:
"""Route the documentation search engine's two sync clients."""
from prowler_mcp_server.prowler_documentation import server as docs
engine = docs.prowler_docs_search_engine
for name in ("mintlify_client", "docs_client"):
monkeypatch.setattr(
engine,
name,
_clone_with_transport(getattr(engine, name), mock_router.transport),
)
return mock_router
+49
View File
@@ -0,0 +1,49 @@
"""Shared test helpers for the Prowler MCP Server suite.
Import from the submodules directly (``from tests.helpers.jsonapi import ...``);
this package only re-exports the surface so it is discoverable in one place.
Nothing here is collected by pytest -- ``python_files`` is ``test_*.py``.
"""
from tests.helpers.assertions import (
NAMESPACES,
assert_namespaced,
assert_tool_contract,
tools_in_namespace,
)
from tests.helpers.http import MockRouter
from tests.helpers.jsonapi import (
jsonapi_collection,
jsonapi_document,
jsonapi_error,
jsonapi_relationship_many,
jsonapi_relationship_one,
jsonapi_resource,
task_document,
)
from tests.helpers.tokens import (
FAKE_API_KEY,
FAKE_LEGACY_API_KEY,
MALFORMED_API_KEY,
fake_jwt,
)
__all__ = [
"FAKE_API_KEY",
"FAKE_LEGACY_API_KEY",
"MALFORMED_API_KEY",
"NAMESPACES",
"MockRouter",
"assert_namespaced",
"assert_tool_contract",
"fake_jwt",
"jsonapi_collection",
"jsonapi_document",
"jsonapi_error",
"jsonapi_relationship_many",
"jsonapi_relationship_one",
"jsonapi_resource",
"task_document",
"tools_in_namespace",
]
+66
View File
@@ -0,0 +1,66 @@
"""Assertions for the MCP tool contract every sub-server must honour.
A tool's description and its parameter descriptions are not documentation -- they
are the only thing a model sees when deciding whether and how to call it. A tool
that registers without them is invisible in practice, so these are correctness
assertions rather than style ones.
"""
from mcp.types import Tool
# Mounted namespaces, most specific first so prefix matching is unambiguous.
NAMESPACES = ("prowler_hub_", "prowler_docs_", "prowler_")
def assert_tool_contract(tool: Tool) -> None:
"""Assert the tool and all of its parameters carry a usable description.
Missing and blank are asserted separately because they are different
mistakes: a missing description was never written, a blank one exists but was
left empty. One truthiness check would report both the same way.
"""
assert tool.description is not None, (
f"Tool '{tool.name}' has no description. Its docstring is what the model reads."
)
assert tool.description.strip(), (
f"Tool '{tool.name}' has a blank description. "
"Its docstring is what the model reads."
)
# `inputSchema` is a required field of the MCP Tool type, so it is always a
# dict; a tool that takes no arguments simply has no `properties`.
for parameter, schema in tool.inputSchema.get("properties", {}).items():
description = schema.get("description")
assert description is not None, (
f"Parameter '{parameter}' of tool '{tool.name}' has no description. "
"Declare it with pydantic Field(description=...)."
)
assert description.strip(), (
f"Parameter '{parameter}' of tool '{tool.name}' has a blank description. "
"Declare it with pydantic Field(description=...)."
)
def assert_namespaced(tool: Tool) -> None:
"""Assert the tool is reachable under one of the published namespaces."""
assert tool.name.startswith(NAMESPACES), (
f"Tool '{tool.name}' is outside the published namespaces {NAMESPACES}"
)
def tools_in_namespace(tools: list[Tool], namespace: str) -> list[Tool]:
"""Return the tools in a namespace.
``prowler_`` is a prefix of the other two namespaces, so tools belonging to a
more specific one are excluded rather than counted twice.
"""
more_specific = tuple(
other
for other in NAMESPACES
if other != namespace and other.startswith(namespace)
)
return [
tool
for tool in tools
if tool.name.startswith(namespace) and not tool.name.startswith(more_specific)
]
+108
View File
@@ -0,0 +1,108 @@
"""Route registry and request recorder backed by ``httpx.MockTransport``.
Mocking at the transport boundary rather than stubbing ``client.request`` keeps
the parts of httpx the code under test actually relies on in play: base-URL
joining, query-parameter encoding, header assembly, ``raise_for_status()`` and
JSON decoding. A test that asserts on a recorded request is therefore asserting
on the bytes that would really have gone out.
"""
from collections.abc import Callable
from typing import Any
import httpx
_UNSET = object()
ResponseFactory = Callable[[httpx.Request], httpx.Response]
class MockRouter:
"""Declare ``(METHOD, path) -> response`` and inspect what was requested.
Responses registered for the same route are consumed in order and the last
one repeats forever. That is what makes polling testable: register
``executing``, ``executing``, ``completed`` and the loop sees each in turn.
An unregistered request raises instead of returning a default, so a test can
never silently exercise a different endpoint than the one it set up.
"""
def __init__(self) -> None:
self._routes: dict[tuple[str, str], list[ResponseFactory]] = {}
self.requests: list[httpx.Request] = []
# --- registration -----------------------------------------------------
def add(
self,
method: str,
path: str,
*,
status: int = 200,
json: Any = _UNSET,
text: str | None = None,
headers: dict[str, str] | None = None,
) -> "MockRouter":
"""Register a canned response for a route. Chainable."""
kwargs: dict[str, Any] = {"headers": headers}
if json is not _UNSET:
kwargs["json"] = json
if text is not None:
kwargs["text"] = text
return self.add_handler(
method, path, lambda _request: httpx.Response(status, **kwargs)
)
def add_handler(
self, method: str, path: str, handler: ResponseFactory
) -> "MockRouter":
"""Register a callable that builds the response from the request."""
self._routes.setdefault((method.upper(), path), []).append(handler)
return self
# --- transport --------------------------------------------------------
@property
def transport(self) -> httpx.MockTransport:
"""A transport that serves this router. Works for sync and async clients."""
return httpx.MockTransport(self._handle)
def _handle(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
queue = self._routes.get((request.method.upper(), request.url.path))
if not queue:
registered = (
", ".join(f"{method} {path}" for method, path in sorted(self._routes))
or "none"
)
raise AssertionError(
f"Unregistered request {request.method} {request.url}. "
f"Registered routes: {registered}"
)
# Keep the final response so a route can be polled repeatedly.
factory = queue.pop(0) if len(queue) > 1 else queue[0]
return factory(request)
# --- inspection -------------------------------------------------------
def request_for(self, method: str, path: str) -> httpx.Request:
"""Return the last recorded request for a route, failing if there is none."""
matches = [
request
for request in self.requests
if request.method.upper() == method.upper() and request.url.path == path
]
if not matches:
raise AssertionError(
f"No {method.upper()} {path} request was made. Made: {self.paths()}"
)
return matches[-1]
def query_params(self, method: str, path: str) -> dict[str, str]:
"""Return the decoded query parameters of the last request for a route."""
return dict(self.request_for(method, path).url.params)
def paths(self) -> list[str]:
"""Return every request made so far, as ``"METHOD /path"`` strings."""
return [f"{request.method} {request.url.path}" for request in self.requests]
+112
View File
@@ -0,0 +1,112 @@
"""Builders for the JSON:API documents the Prowler API returns.
Every model's ``from_api_response()`` and every tool's error path consumes one of
these shapes, so building them by hand in each test would duplicate the document
structure hundreds of times. The builders keep the *shape* in one place so tests
only express the part they actually care about.
"""
from typing import Any
def jsonapi_relationship_many(resource_type: str, *ids: str) -> dict[str, Any]:
"""Build a to-many relationship.
Passing no ids yields a present-but-empty relationship (``{"data": []}``),
which ``extract_relationship_ids`` reports as ``[]`` rather than ``None``.
"""
return {"data": [{"type": resource_type, "id": resource_id} for resource_id in ids]}
def jsonapi_relationship_one(resource_type: str, resource_id: str) -> dict[str, Any]:
"""Build a to-one relationship."""
return {"data": {"type": resource_type, "id": resource_id}}
def jsonapi_resource(
resource_type: str,
resource_id: str,
attributes: dict[str, Any] | None = None,
relationships: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a single JSON:API resource object.
``relationships`` is omitted from the result entirely when not supplied, so a
test can express "the document did not expose this relationship"
(``extract_relationship_ids`` -> ``None``) distinctly from "the relationship
is present and empty" (-> ``[]``). Conflating the two is exactly the bug the
models go out of their way to avoid.
"""
resource: dict[str, Any] = {
"type": resource_type,
"id": resource_id,
"attributes": attributes or {},
}
if relationships is not None:
resource["relationships"] = relationships
return resource
def jsonapi_document(
data: dict[str, Any] | list[dict[str, Any]],
included: list[dict[str, Any]] | None = None,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a top-level JSON:API document."""
document: dict[str, Any] = {"data": data}
if included is not None:
document["included"] = included
if meta is not None:
document["meta"] = meta
return document
def jsonapi_collection(
items: list[dict[str, Any]],
*,
page: int = 1,
pages: int = 1,
count: int | None = None,
included: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a paginated collection document.
The ``meta.pagination`` keys are exactly the ones every ``*ListResponse``
reads (``page``, ``pages``, ``count``). ``count`` defaults to the number of
items so the common single-page case needs no arguments.
"""
return jsonapi_document(
data=items,
included=included,
meta={
"pagination": {
"page": page,
"pages": pages,
"count": len(items) if count is None else count,
}
},
)
def jsonapi_error(status: int, detail: str, title: str | None = None) -> dict[str, Any]:
"""Build an error document.
``ProwlerAPIClient._make_request`` surfaces ``errors[0].detail`` in the
exception message it raises, and tools relay that straight to the model.
"""
error: dict[str, Any] = {"status": str(status), "detail": detail}
if title is not None:
error["title"] = title
return {"errors": [error]}
def task_document(task_id: str, state: str, error: str | None = None) -> dict[str, Any]:
"""Build a ``/tasks/{id}`` document for driving ``poll_task_until_complete``.
Register a sequence of these on a ``MockRouter`` route (for example
``executing``, ``executing``, ``completed``) to exercise the polling loop.
"""
attributes: dict[str, Any] = {"state": state}
if error is not None:
attributes["error"] = error
return jsonapi_document(jsonapi_resource("tasks", task_id, attributes))
+34
View File
@@ -0,0 +1,34 @@
"""Obviously-fake credentials for tests.
Deliberately unrealistic so repository secret scanning does not flag them. Never
put a value here that could be mistaken for a real key.
"""
import base64
import json
import time
# Prowler API keys are recognised by their `pk_` prefix; anything else is rejected.
FAKE_API_KEY = "pk_fake_api_key_for_unit_testing_only"
FAKE_LEGACY_API_KEY = "pk_fake_legacy_api_key_for_unit_testing_only"
MALFORMED_API_KEY = "not_a_prowler_api_key"
def fake_jwt(expires_in: int = 3600, **claims: object) -> str:
"""Mint an unsigned JWT whose ``exp`` is ``expires_in`` seconds from now.
Pass a negative ``expires_in`` for an already-expired token.
``ProwlerAppAuth._parse_jwt`` only base64url-decodes the payload and reads
``exp`` -- it never verifies the signature, because the Prowler API is what
validates the token. A placeholder signature is therefore enough, and avoids
adding a JWT library just for tests.
"""
def _segment(payload: dict[str, object]) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
header = _segment({"alg": "HS256", "typ": "JWT"})
body = _segment({"exp": int(time.time()) + expires_in, **claims})
return f"{header}.{body}.fake-signature-not-verified"
+1
View File
@@ -0,0 +1 @@
"""Tests for the Prowler App sub-server."""
@@ -0,0 +1 @@
"""Tests for the Prowler App Pydantic models."""
@@ -0,0 +1,205 @@
"""Tests for the security finding models.
Reference for later branches: build the API document with the ``jsonapi``
helpers, run it through ``from_api_response()``, then assert on both the model
and its ``model_dump()``. The dump is what the agent actually receives, and
``MinimalSerializerMixin`` makes the two differ.
"""
from prowler_mcp_server.prowler_app.models.findings import (
DetailedFinding,
FindingsListResponse,
FindingsOverview,
SimplifiedFinding,
)
from tests.helpers.jsonapi import (
jsonapi_collection,
jsonapi_relationship_many,
jsonapi_relationship_one,
jsonapi_resource,
)
CHECK_METADATA = {
"checkid": "s3_bucket_public_access",
"checktitle": "Ensure S3 buckets block public access",
"description": "Checks whether the bucket blocks public access.",
"provider": "aws",
"servicename": "s3",
"resourcetype": "AwsS3Bucket",
"risk": "Public buckets expose data to the internet.",
"additionalurls": ["https://docs.aws.amazon.com/s3/"],
"categories": ["encryption", "internet-exposed"],
}
FINDING_ATTRIBUTES = {
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
"status": "FAIL",
"severity": "high",
"status_extended": "S3 bucket my-bucket is publicly accessible.",
"delta": "new",
"muted": False,
"muted_reason": None,
"check_metadata": CHECK_METADATA,
}
DETAILED_ATTRIBUTES = {
**FINDING_ATTRIBUTES,
"inserted_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:00:00Z",
"first_seen_at": "2025-01-10T09:00:00Z",
}
def test_simplified_finding_lifts_the_check_id_out_of_the_check_metadata():
"""`check_id` is nested under `check_metadata.checkid` in the API document.
Flattening it is what lets an agent filter findings by check without being
handed the whole metadata blob for every row in a list.
"""
finding = SimplifiedFinding.from_api_response(
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
)
assert finding.check_id == "s3_bucket_public_access"
assert finding.severity == "high"
assert finding.status == "FAIL"
def test_empty_finding_fields_are_dropped_from_the_serialized_payload():
"""Empty values are removed to keep the payload small for the model.
`muted_reason` is None on an unmuted finding; emitting it would spend tokens
on every row of every list response to say nothing.
"""
finding = SimplifiedFinding.from_api_response(
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
)
dumped = finding.model_dump()
assert "muted_reason" not in dumped
assert dumped["uid"] == FINDING_ATTRIBUTES["uid"]
def test_detailed_finding_parses_both_relationship_shapes():
"""`scan` is a to-one relationship and `resources` is to-many.
They are read from the same `relationships` object but reduce to a single id
and a list of ids respectively.
"""
resource = jsonapi_resource(
"findings",
"f1",
attributes=DETAILED_ATTRIBUTES,
relationships={
"scan": jsonapi_relationship_one("scans", "s1"),
"resources": jsonapi_relationship_many("resources", "r1", "r2"),
},
)
finding = DetailedFinding.from_api_response(resource)
assert finding.scan_id == "s1"
assert finding.resource_ids == ["r1", "r2"]
def test_detailed_finding_tolerates_missing_relationships():
"""A document without relationships must not raise.
`get_finding_details` requests `include=scan,resources`, but a finding whose
scan has been pruned still has to render rather than fail the tool call.
"""
finding = DetailedFinding.from_api_response(
jsonapi_resource("findings", "f1", DETAILED_ATTRIBUTES)
)
assert finding.scan_id is None
assert finding.resource_ids == []
def test_detailed_finding_flattens_the_nested_remediation_guidance():
"""Remediation is the payload an agent needs to actually fix the finding.
The API nests it under `remediation.code.*` and `remediation.recommendation.text`;
the model flattens both into one object.
"""
attributes = {
**DETAILED_ATTRIBUTES,
"check_metadata": {
**CHECK_METADATA,
"remediation": {
"code": {
"cli": "aws s3api put-public-access-block ...",
"terraform": 'resource "aws_s3_bucket_public_access_block" ...',
"nativeiac": "",
"other": "",
},
"recommendation": {"text": "Block all public access on the bucket."},
},
},
}
finding = DetailedFinding.from_api_response(
jsonapi_resource("findings", "f1", attributes)
)
remediation = finding.check_metadata.remediation
assert remediation.cli.startswith("aws s3api")
assert remediation.recommendation == "Block all public access on the bucket."
# Empty code snippets are dropped rather than shown as blank fields.
assert "nativeiac" not in remediation.model_dump()
def test_check_metadata_without_remediation_is_left_unset():
"""Not every check ships remediation guidance; absence must not fabricate one."""
finding = DetailedFinding.from_api_response(
jsonapi_resource("findings", "f1", DETAILED_ATTRIBUTES)
)
assert finding.check_metadata.remediation is None
assert "remediation" not in finding.check_metadata.model_dump()
def test_list_response_carries_the_api_pagination_metadata():
"""Pagination tells an agent whether it has seen everything it asked for."""
response = jsonapi_collection(
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)],
page=2,
pages=7,
count=312,
)
result = FindingsListResponse.from_api_response(response)
assert result.current_page == 2
assert result.total_num_pages == 7
assert result.total_num_finding == 312
assert result.findings[0].check_id == "s3_bucket_public_access"
def test_overview_renames_the_pass_attribute_to_a_valid_identifier():
"""The API's `pass` count cannot keep its name -- `pass` is a Python keyword."""
response = jsonapi_resource(
"findings-overview",
"overview",
{
"total": 100,
"fail": 30,
"pass": 60,
"muted": 10,
"new": 5,
"changed": 3,
"fail_new": 2,
"fail_changed": 1,
"pass_new": 2,
"pass_changed": 1,
"muted_new": 1,
"muted_changed": 1,
},
)
overview = FindingsOverview.from_api_response({"data": response})
assert overview.passed == 60
assert overview.fail == 30
assert overview.total == 100
@@ -0,0 +1,57 @@
"""Tests for the shared JSON:API response-parsing helpers.
These back every model's ``from_api_response()``, so they are foundation-level
rather than tied to any one feature.
"""
from prowler_mcp_server.prowler_app.models.utils import extract_relationship_ids
from tests.helpers.jsonapi import jsonapi_relationship_many, jsonapi_relationship_one
def test_an_absent_relationship_is_unknown_rather_than_empty():
"""A relationship the document never mentioned yields None, not [].
Returning [] would tell an agent "this role is assigned to nobody" when the
serializer simply did not expose the relationship -- for example a role
included via `?include=roles`, which carries no `users`.
"""
assert extract_relationship_ids({}, "users") is None
def test_a_present_but_empty_relationship_is_explicitly_empty():
"""An empty relationship yields [], which genuinely means "none"."""
relationships = {"users": jsonapi_relationship_many("users")}
assert extract_relationship_ids(relationships, "users") == []
def test_a_to_many_relationship_is_flattened_to_its_ids():
"""Linkage objects are reduced to the plain ids the tools pass around."""
relationships = {"users": jsonapi_relationship_many("users", "u1", "u2")}
assert extract_relationship_ids(relationships, "users") == ["u1", "u2"]
def test_a_to_one_relationship_is_returned_as_a_single_element_list():
"""To-one and to-many both return a list so callers need no shape check."""
relationships = {"scan": jsonapi_relationship_one("scans", "s1")}
assert extract_relationship_ids(relationships, "scan") == ["s1"]
def test_a_null_to_one_relationship_is_empty():
"""An explicitly null to-one link means "not related", not "unknown"."""
relationships = {"scan": {"data": None}}
assert extract_relationship_ids(relationships, "scan") == []
def test_members_without_an_id_are_discarded():
"""Malformed linkage must not surface as a None entry in the id list.
A None id would flow into a tool's next request and produce a confusing
404 rather than a clean, short list.
"""
relationships = {"users": {"data": [{"type": "users", "id": "u1"}, {}]}}
assert extract_relationship_ids(relationships, "users") == ["u1"]
@@ -0,0 +1 @@
"""Tests for the Prowler App MCP tools."""
@@ -0,0 +1,347 @@
"""Tests for the security findings tools.
Reference for later branches. Drive tools through an in-memory MCP client by
default. Tool parameters are declared with pydantic ``Field(default=...)``, and
those defaults are only resolved by FastMCP's tool wrapper -- calling the method
directly leaves an omitted argument as a raw ``FieldInfo`` object, which is
truthy and silently produces nonsense filters. Call the method directly only when
passing every argument explicitly.
Everything here relies on ``mock_api_client`` patching the API client *in place*:
the tool instances captured that exact object when the package was imported, so a
freshly-constructed client would not reach them.
"""
import pytest
from fastmcp import Client
from tests.helpers.jsonapi import (
jsonapi_collection,
jsonapi_error,
jsonapi_relationship_one,
jsonapi_resource,
)
LATEST = "/api/v1/findings/latest"
HISTORICAL = "/api/v1/findings"
CHECK_METADATA = {
"checkid": "s3_bucket_public_access",
"checktitle": "Ensure S3 buckets block public access",
"description": "Checks whether the bucket blocks public access.",
"provider": "aws",
"servicename": "s3",
"resourcetype": "AwsS3Bucket",
"risk": "Public buckets expose data to the internet.",
"additionalurls": [],
"categories": ["internet-exposed"],
}
FINDING_ATTRIBUTES = {
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
"status": "FAIL",
"severity": "high",
"status_extended": "S3 bucket my-bucket is publicly accessible.",
"delta": "new",
"muted": False,
"muted_reason": None,
"check_metadata": CHECK_METADATA,
}
async def test_search_without_dates_queries_the_latest_scan_endpoint(
mcp_root_server, mock_api_client, mock_router
):
"""With no date range the tool targets `/findings/latest`.
That endpoint reads only the most recent completed scan, which is far cheaper
than a historical query -- so picking the wrong one is a performance
regression the response body alone would not reveal.
"""
mock_router.add(
"GET",
LATEST,
json=jsonapi_collection(
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)]
),
)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_search_security_findings", {})
assert result.data["findings"][0]["check_id"] == "s3_bucket_public_access"
assert mock_router.paths() == [f"GET {LATEST}"]
async def test_search_defaults_to_failed_findings_only(
mcp_root_server, mock_api_client, mock_router
):
"""The default filter is FAIL, so an unqualified search surfaces real issues.
Also pins the sort order and field selection, which together keep the
response small and severity-first.
"""
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
async with Client(mcp_root_server) as client:
await client.call_tool("prowler_search_security_findings", {})
params = mock_router.query_params("GET", LATEST)
assert params["filter[status__in]"] == "FAIL"
assert params["sort"] == "severity,-inserted_at"
assert params["page[size]"] == "50"
async def test_search_with_dates_switches_to_the_historical_endpoint(
mcp_root_server, mock_api_client, mock_router
):
"""A date range moves the query to `/findings` with an inserted_at window.
Supplying only `date_from` auto-completes the other boundary, so the caller
cannot accidentally request an unbounded historical scan.
"""
mock_router.add("GET", HISTORICAL, json=jsonapi_collection([]))
async with Client(mcp_root_server) as client:
await client.call_tool(
"prowler_search_security_findings", {"date_from": "2025-01-15"}
)
params = mock_router.query_params("GET", HISTORICAL)
assert params["filter[inserted_at__gte]"] == "2025-01-15"
assert params["filter[inserted_at__lte]"] == "2025-01-16"
async def test_search_rejects_a_date_range_wider_than_the_api_allows(
mcp_root_server, mock_api_client, mock_router
):
"""The API caps historical queries at two days; reject before the round trip."""
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="Date range cannot exceed 2 days"):
await client.call_tool(
"prowler_search_security_findings",
{"date_from": "2025-01-01", "date_to": "2025-01-10"},
)
assert mock_router.requests == []
async def test_search_encodes_list_filters_as_comma_separated_values(
mcp_root_server, mock_api_client, mock_router
):
"""Multi-value filters reach the API as CSV, not as repeated query keys."""
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
async with Client(mcp_root_server) as client:
await client.call_tool(
"prowler_search_security_findings",
{"severity": ["critical", "high"], "service": ["s3", "ec2"]},
)
params = mock_router.query_params("GET", LATEST)
assert params["filter[severity__in]"] == "critical,high"
assert params["filter[service__in]"] == "s3,ec2"
@pytest.mark.parametrize(
("argument", "value", "expected_key", "expected_value"),
[
("provider_type", ["aws", "gcp"], "filter[provider_type__in]", "aws,gcp"),
("provider_alias", "prod", "filter[provider_alias__icontains]", "prod"),
("region", ["us-east-1"], "filter[region__in]", "us-east-1"),
("resource_type", ["AwsS3Bucket"], "filter[resource_type__in]", "AwsS3Bucket"),
(
"check_id",
["s3_bucket_public_access"],
"filter[check_id__in]",
"s3_bucket_public_access",
),
("delta", ["new"], "filter[delta__in]", "new"),
("search", "bucket", "filter[search]", "bucket"),
],
)
async def test_search_maps_each_argument_onto_its_api_filter(
mcp_root_server,
mock_api_client,
mock_router,
argument,
value,
expected_key,
expected_value,
):
"""Every search argument maps to a specific API filter key.
A mistyped filter key is not an error the API reports -- it is simply ignored,
so the tool returns unfiltered results while appearing to work. Pinning the
exact key per argument is the only thing that catches that.
"""
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
async with Client(mcp_root_server) as client:
await client.call_tool("prowler_search_security_findings", {argument: value})
assert mock_router.query_params("GET", LATEST)[expected_key] == expected_value
async def test_overview_can_be_scoped_to_a_provider(
mcp_root_server, mock_api_client, mock_router
):
"""The aggregate report accepts the same provider filter as the search tool."""
mock_router.add(
"GET",
"/api/v1/overviews/findings",
json={
"data": jsonapi_resource(
"findings-overview",
"overview",
dict.fromkeys(
[
"total",
"fail",
"pass",
"muted",
"new",
"changed",
"fail_new",
"fail_changed",
"pass_new",
"pass_changed",
"muted_new",
"muted_changed",
],
0,
),
)
},
)
async with Client(mcp_root_server) as client:
await client.call_tool(
"prowler_get_findings_overview", {"provider_type": ["aws"]}
)
params = mock_router.query_params("GET", "/api/v1/overviews/findings")
assert params["filter[provider_type__in]"] == "aws"
async def test_search_normalises_a_string_muted_flag_to_a_boolean(
mcp_root_server, mock_api_client, mock_router
):
"""`muted` accepts a string because some MCP clients send booleans as text.
It still has to reach the API as a lowercase boolean, otherwise the filter is
silently ignored and the agent gets muted findings it asked to exclude.
"""
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
async with Client(mcp_root_server) as client:
await client.call_tool("prowler_search_security_findings", {"muted": "true"})
assert mock_router.query_params("GET", LATEST)["filter[muted]"] == "true"
async def test_search_rejects_an_out_of_range_page_size(
mcp_root_server, mock_api_client, mock_router
):
"""Page size is validated locally, saving a round trip on an obvious mistake."""
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="Must be between 1 and 1000"):
await client.call_tool(
"prowler_search_security_findings", {"page_size": 5000}
)
assert mock_router.requests == []
async def test_get_finding_details_requests_its_relationships(
mcp_root_server, mock_api_client, mock_router
):
"""Details are only useful with the scan and resources included.
Dropping the `include` would leave `scan_id` and `resource_ids` empty and the
agent unable to pivot from a finding to the resource it concerns.
"""
attributes = {
**FINDING_ATTRIBUTES,
"inserted_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:00:00Z",
}
mock_router.add(
"GET",
f"{HISTORICAL}/f1",
json={
"data": jsonapi_resource(
"findings",
"f1",
attributes,
relationships={"scan": jsonapi_relationship_one("scans", "s1")},
)
},
)
async with Client(mcp_root_server) as client:
result = await client.call_tool(
"prowler_get_finding_details", {"finding_id": "f1"}
)
assert result.data["scan_id"] == "s1"
assert mock_router.query_params("GET", f"{HISTORICAL}/f1")["include"] == (
"scan,resources"
)
async def test_get_finding_details_surfaces_the_api_error_detail(
mcp_root_server, mock_api_client, mock_router
):
"""A missing finding surfaces the API's message rather than an opaque failure."""
mock_router.add(
"GET", f"{HISTORICAL}/nope", status=404, json=jsonapi_error(404, "Not found.")
)
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="Not found."):
await client.call_tool(
"prowler_get_finding_details", {"finding_id": "nope"}
)
async def test_overview_renders_a_markdown_report_with_percentages(
mcp_root_server, mock_api_client, mock_router
):
"""The overview returns prose, not a model, so the arithmetic is the contract.
Percentages are derived here rather than by the API, which makes them the one
part of this tool that can silently go wrong.
"""
mock_router.add(
"GET",
"/api/v1/overviews/findings",
json={
"data": jsonapi_resource(
"findings-overview",
"overview",
{
"total": 200,
"fail": 50,
"pass": 130,
"muted": 20,
"new": 10,
"changed": 4,
"fail_new": 6,
"fail_changed": 2,
"pass_new": 3,
"pass_changed": 1,
"muted_new": 1,
"muted_changed": 1,
},
)
},
)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_get_findings_overview", {})
report = result.data["report"]
assert "**Total Findings**: 200" in report
assert "**Failed Checks**: 50 (25.0%)" in report
assert "**Unchanged**: 186" in report
@@ -0,0 +1 @@
"""Tests for the Prowler App shared utilities."""
@@ -0,0 +1,83 @@
"""Tests for the shared Prowler API client.
Reference for later branches: drive the client through ``mock_api_client`` +
``mock_router`` and assert on the recorded request, so the real URL joining,
query encoding and header assembly stay covered.
"""
import pytest
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_error, jsonapi_resource
from tests.helpers.tokens import FAKE_API_KEY
async def test_get_sends_an_authenticated_jsonapi_request(mock_api_client, mock_router):
"""A GET carries the API key and the JSON:API content negotiation headers."""
mock_router.add(
"GET",
"/api/v1/findings",
json=jsonapi_collection(
[jsonapi_resource("findings", "f1", {"severity": "high"})]
),
)
await mock_api_client.get("/findings")
request = mock_router.request_for("GET", "/api/v1/findings")
assert request.headers["authorization"] == f"Api-Key {FAKE_API_KEY}"
assert request.headers["accept"] == "application/vnd.api+json"
assert request.headers["user-agent"].startswith("prowler-mcp-server/")
async def test_get_forwards_query_parameters(mock_api_client, mock_router):
"""Filter parameters reach the wire with their JSON:API bracket syntax intact."""
mock_router.add("GET", "/api/v1/findings", json=jsonapi_collection([]))
await mock_api_client.get(
"/findings", params={"page[size]": 5, "filter[severity__in]": "critical"}
)
assert mock_router.query_params("GET", "/api/v1/findings") == {
"page[size]": "5",
"filter[severity__in]": "critical",
}
async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_router):
"""A failed request is raised with the API's own `errors[].detail` message.
Tools relay this text straight to the model, so losing it turns an actionable
error into an opaque one.
"""
mock_router.add(
"GET",
"/api/v1/findings/nope",
status=404,
json=jsonapi_error(404, "Not found."),
)
with pytest.raises(Exception, match=r"API request failed: 404 - Not found\."):
await mock_api_client.get("/findings/nope")
def test_build_filter_params_normalises_types_for_the_api(mock_api_client):
"""Booleans become lowercase strings, sequences become CSV, `None` is dropped."""
result = mock_api_client.build_filter_params(
{
"filter[muted]": True,
"filter[severity__in]": ["high", "critical"],
"filter[status]": None,
"page[size]": 50,
}
)
assert result == {
"filter[muted]": "true",
"filter[severity__in]": "high,critical",
"page[size]": 50,
}
def test_the_api_client_is_a_singleton(isolated_api_client):
"""Every tool must share one client so the HTTP connection pool is shared."""
assert isolated_api_client() is isolated_api_client()
@@ -0,0 +1,62 @@
"""Tests for Prowler API authentication.
Reference for later branches: ``ProwlerAppAuth`` resolves its ``mode`` and
``base_url`` in default arguments, which Python evaluates once at module import.
``monkeypatch.setenv`` therefore has no effect on them -- always pass ``mode=``
and ``base_url=`` explicitly, as these tests do.
"""
import pytest
from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
from tests.helpers.tokens import FAKE_API_KEY, MALFORMED_API_KEY, fake_jwt
async def test_stdio_mode_reads_the_api_key_from_the_environment():
"""In STDIO transport the key comes from the process environment."""
auth = ProwlerAppAuth(mode="stdio")
assert await auth.get_valid_token() == FAKE_API_KEY
def test_stdio_mode_rejects_a_key_without_the_prowler_prefix(
monkeypatch: pytest.MonkeyPatch,
):
"""A key that is not `pk_`-prefixed is refused at construction.
Failing here rather than on the first API call is what turns a
misconfiguration into an immediate, readable startup error.
"""
monkeypatch.setenv("PROWLER_API_KEY", MALFORMED_API_KEY)
with pytest.raises(ValueError, match="Prowler API key format is incorrect"):
ProwlerAppAuth(mode="stdio")
async def test_http_mode_accepts_a_bearer_api_key(http_request_headers):
"""In HTTP transport the token comes from the request's Authorization header."""
http_request_headers(authorization=f"Bearer {FAKE_API_KEY}")
auth = ProwlerAppAuth(mode="http")
assert await auth.get_valid_token() == FAKE_API_KEY
async def test_http_mode_rejects_an_expired_jwt(http_request_headers):
"""An expired JWT is refused locally instead of being forwarded to the API."""
http_request_headers(authorization=f"Bearer {fake_jwt(expires_in=-60)}")
auth = ProwlerAppAuth(mode="http")
with pytest.raises(ValueError, match="Token has expired"):
await auth.get_valid_token()
def test_api_keys_and_jwts_use_different_authorization_schemes():
"""Prowler API keys authenticate with `Api-Key`, JWTs with `Bearer`."""
auth = ProwlerAppAuth(mode="stdio")
assert auth.get_headers(FAKE_API_KEY)["Authorization"] == f"Api-Key {FAKE_API_KEY}"
jwt = fake_jwt()
assert auth.get_headers(jwt)["Authorization"] == f"Bearer {jwt}"
@@ -0,0 +1 @@
"""Tests for the Prowler Documentation sub-server."""
+1
View File
@@ -0,0 +1 @@
"""Tests for the Prowler Hub sub-server."""
+6 -15
View File
@@ -1,16 +1,11 @@
"""Tests for the Prowler MCP Server health endpoint."""
from starlette.testclient import TestClient
from prowler_mcp_server import __version__
from prowler_mcp_server.server import app
def test_health_returns_ietf_pass_response():
def test_health_returns_ietf_pass_response(health_client):
"""GET /health returns 200 with the IETF health-check body and headers."""
client = TestClient(app)
response = client.get("/health")
response = health_client.get("/health")
assert response.status_code == 200
assert response.headers["content-type"] == "application/health+json"
@@ -24,23 +19,19 @@ def test_health_returns_ietf_pass_response():
}
def test_health_release_id_matches_package_version():
def test_health_release_id_matches_package_version(health_client):
"""The endpoint must surface the current package __version__ as releaseId.
Drift between the response and the installed package would mislead any
monitoring tool that uses releaseId to identify the running build.
"""
client = TestClient(app)
response = client.get("/health")
response = health_client.get("/health")
assert response.json()["releaseId"] == __version__
def test_health_rejects_non_get_methods():
def test_health_rejects_non_get_methods(health_client):
"""The endpoint only exposes GET; other verbs return 405."""
client = TestClient(app)
response = client.post("/health")
response = health_client.post("/health")
assert response.status_code == 405
+51
View File
@@ -0,0 +1,51 @@
"""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_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)
+115
View File
@@ -213,6 +213,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.15.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" },
{ url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" },
{ url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" },
{ url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" },
{ url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" },
{ url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" },
{ url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" },
{ url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" },
{ url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" },
{ url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" },
{ url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" },
{ url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" },
{ url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" },
{ url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" },
{ url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" },
{ url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" },
{ url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" },
{ url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" },
{ url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" },
{ url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" },
{ url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
{ url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
{ url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
{ url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
{ url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
{ url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
{ url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
{ url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
{ url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
{ url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
{ url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
{ url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
{ url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
{ url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
{ url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
{ url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
{ url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
{ url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
]
[[package]]
name = "cryptography"
version = "48.0.1"
@@ -730,7 +799,11 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "bandit" },
{ name = "coverage" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-env" },
{ name = "ruff" },
{ name = "vulture" },
]
@@ -744,7 +817,11 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "bandit", specifier = "==1.8.3" },
{ name = "coverage", specifier = "==7.15.2" },
{ name = "pytest", specifier = "==9.0.3" },
{ name = "pytest-asyncio", specifier = "==1.4.0" },
{ name = "pytest-cov", specifier = "==6.0.0" },
{ name = "pytest-env", specifier = "==1.1.5" },
{ name = "ruff", specifier = "==0.15.11" },
{ name = "vulture", specifier = "==2.14" },
]
@@ -940,6 +1017,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "pytest-cov"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" },
]
[[package]]
name = "pytest-env"
version = "1.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
+3 -1
View File
@@ -72,10 +72,12 @@ Use `@mcp.tool()` decorator directly—no BaseTool or models required.
- [ ] Error handling returns `{"error": str, "status": "failed"}`
- [ ] Parameters use `Field()` with descriptions
- [ ] No hardcoded secrets
- [ ] Tests added under `mcp_server/tests/`
---
## Resources
- **Full Guide**: [docs/developer-guide/mcp-server.mdx](../../../docs/developer-guide/mcp-server.mdx)
- **Full Guide**: [docs/developer-guide/mcp-server.mdx](../../docs/developer-guide/mcp-server.mdx)
- **Templates**: See [assets/](assets/) for tool and model templates
- **Testing**: See [prowler-test-mcp](../prowler-test-mcp/SKILL.md) for fixtures and test patterns
+167
View File
@@ -0,0 +1,167 @@
---
name: prowler-test-mcp
description: >
Testing patterns for the Prowler MCP Server: in-memory FastMCP clients, the
ProwlerAPIClient singleton, JSON:API model builders and mocked httpx transports.
Trigger: When writing tests under mcp_server/tests/ (tools, models, api_client, auth, sub-servers).
license: Apache-2.0
metadata:
author: prowler-cloud
version: "1.0.0"
scope: [root, mcp_server]
auto_invoke:
- "Writing Prowler MCP server tests"
- "Testing MCP tools or models"
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
---
## Critical Rules
- ALWAYS drive tools through an in-memory client: `async with Client(mcp_root_server)`.
Tool parameters use pydantic `Field(default=...)`, and only FastMCP's wrapper
resolves those defaults. Calling a tool method directly with an argument omitted
leaves it as a raw `FieldInfo` — which is truthy, so `if email:` silently builds
a filter out of the `FieldInfo` repr. Direct calls MUST pass every argument.
- NEVER open a `fastmcp.Client` inside a fixture. FastMCP warns this causes
hard-to-diagnose event-loop issues; open it inline in the test.
- ALWAYS use the `mock_api_client` fixture; NEVER construct a `ProwlerAPIClient`.
Tool instances captured the singleton by reference at import time, so only an
in-place patch of `.client` reaches them.
- NEVER clear `SingletonMeta._instances`. It orphans every registered tool on an
instance holding a real `httpx.AsyncClient`. Use `isolated_api_client` if you
genuinely need a fresh instance.
- NEVER strip `PROWLER_API_KEY`. Tools are built at import time and a construction
failure is swallowed, so the whole `prowler_*` namespace silently drops to zero
tools. It is pinned in `[tool.pytest_env]`.
- For `ProwlerAppAuth`, pass `mode=` / `base_url=` explicitly. Those are resolved in
default arguments, evaluated once at module import, so `monkeypatch.setenv` has
no effect on them.
- NEVER assert an exact tool count — every future branch would have to bump it.
- Assert on `result.data` (structured output), not `result.content[0].text`.
- Tests are `test_*.py` (prefix), like the API — not the SDK's `*_test.py` suffix.
- `__init__.py` IS required in every `tests/` subdirectory here (unlike the SDK's
repo-root `tests/`), or same-named modules collide under pytest's import mode.
- Async tests need no marker (`asyncio_mode = "auto"`). Do not use `@pytest.mark.anyio`.
- Use only obviously-fake credentials from `tests.helpers.tokens` (TruffleHog).
- One behaviour per test; keep tests self-contained and order-independent.
---
## 1. Layout
Mirror the source tree *below the package root* — drop the `prowler_mcp_server/`
level, exactly as the SDK maps `prowler/providers/...` to `tests/providers/...`.
So `prowler_mcp_server/prowler_app/tools/` is tested in `tests/prowler_app/tools/`.
```text
mcp_server/tests/
├── conftest.py # all shared fixtures
├── helpers/ # jsonapi.py, http.py, assertions.py, tokens.py
├── test_server.py # mounted-server contract
├── test_health.py
├── prowler_app/{models,tools,utils}/
├── prowler_hub/
└── prowler_documentation/
```
---
## 2. Fixtures
| Fixture | Autouse | What it gives you |
|---------|---------|-------------------|
| `_pinned_environment` | yes | Deterministic env; blocks a developer's `.env` from leaking |
| `_no_real_network` | yes | Any real socket connect raises `RuntimeError` |
| `_singleton_registry_guard` | yes | Snapshots/restores `SingletonMeta._instances` |
| `mock_router` | no | Route registry + request recorder |
| `api_client` | no | The live `ProwlerAPIClient` singleton |
| `mock_api_client` | no | **The workhorse** — singleton with a mocked transport |
| `isolated_api_client` | no | Evicts the singleton, for construction/identity tests |
| `mcp_root_server` | no | The mounted root server (session-scoped) |
| `health_client` | no | Starlette `TestClient` for `/health` |
| `http_request_headers` | no | Injects headers for HTTP-mode auth |
| `hub_router` | no | Mocks the Hub sub-server's two sync clients |
| `docs_router` | no | Mocks the docs search engine's two sync clients |
### `MockRouter`
```python
mock_router.add("GET", "/api/v1/users", json=jsonapi_collection([...]))
mock_router.add("GET", "/api/v1/tasks/t1", json=task_document("t1", "completed"))
mock_router.request_for("GET", "/api/v1/users") # last request, for header asserts
mock_router.query_params("GET", "/api/v1/users") # decoded query string
mock_router.paths() # everything requested so far
```
Register a route more than once to return a sequence — the last response repeats.
That is how you drive `poll_task_until_complete` (`executing`, `executing`,
`completed`). An unregistered request raises, listing what *was* registered.
---
## 3. Patterns
**Tool test** — see `assets/mcp_tool_test.py`. Register routes, call through the
in-memory client, assert on `result.data` *and* on the recorded request. When a
tool chooses between endpoints, assert `mock_router.paths()` — a wrong choice is
invisible in the response body.
**Model test** — see `assets/mcp_model_test.py`. Build the document with the
`jsonapi` helpers, run `from_api_response()`, assert on both the model and
`model_dump()`. `MinimalSerializerMixin` makes those differ, and an absent
relationship (`None`) must never be conflated with an empty one (`[]`).
**Contract test** — see `assets/mcp_contract_test.py`. Namespacing and
description coverage across every registered tool.
The worked example in the repo is `findings`, covered across both layers in
`tests/prowler_app/{models,tools}/test_findings.py`. Read those first — they
exercise every foundation capability in one feature.
### Reading coverage
Coverage has a meaningless high floor. Model modules are almost entirely class-body
`Field(...)` declarations that execute at import, and `prowler_app/server.py` imports
every model module at import time. **Importing the package with zero tests already
reports 36% overall**, and individual model modules 5484%.
So a model module at ~68% with no tests has none of its logic covered — the missing
ranges are the `from_api_response()` bodies, which is the only part worth testing.
Compare against the import-only floor, never against zero, and do not set a Codecov
target from the raw total.
### Where fixture data lives
`tests/helpers/` is feature-agnostic and must stay that way: it holds the JSON:API
*shape*, not any feature's data. Per-feature attribute dictionaries
(`FINDING_ATTRIBUTES`, `CHECK_METADATA`, …) belong as module-level constants in
the test module that uses them. Do not add feature fixtures to `helpers/`.
---
## 4. Commands
From `mcp_server/`:
```bash
cd mcp_server
uv run pytest # whole suite
uv run pytest tests/prowler_app/models # one area
uv run pytest --cov=./prowler_mcp_server # with coverage
```
From the repository root:
```bash
make test-mcp # runs the MCP suite exactly as CI does
```
---
## 5. Reference
- Fixtures and the reasoning behind them: `mcp_server/tests/conftest.py`
- Testing section of `docs/developer-guide/mcp-server.mdx`
- Official FastMCP testing guide: <https://gofastmcp.com/development/tests>
@@ -0,0 +1,49 @@
# Example: Prowler MCP Server contract test patterns
# Source: mcp_server/tests/test_server.py
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):
"""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". Assert each namespace is non-empty
-- never assert an exact count, which every future branch would have to bump.
"""
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_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.
A tool or parameter with no description is registered but effectively
invisible, so this is a correctness check rather than a style one.
"""
async with Client(mcp_root_server) as client:
tools = await client.list_tools()
for tool in tools:
assert_tool_contract(tool)
@@ -0,0 +1,116 @@
# Example: Prowler MCP Server model test patterns
# Source: mcp_server/tests/prowler_app/models/test_findings.py
from prowler_mcp_server.prowler_app.models.findings import (
DetailedFinding,
FindingsListResponse,
SimplifiedFinding,
)
from tests.helpers.jsonapi import (
jsonapi_collection,
jsonapi_relationship_many,
jsonapi_relationship_one,
jsonapi_resource,
)
CHECK_METADATA = {
"checkid": "s3_bucket_public_access",
"checktitle": "Ensure S3 buckets block public access",
"description": "Checks whether the bucket blocks public access.",
"provider": "aws",
"servicename": "s3",
"resourcetype": "AwsS3Bucket",
"risk": "Public buckets expose data to the internet.",
"additionalurls": [],
"categories": ["internet-exposed"],
}
FINDING_ATTRIBUTES = {
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
"status": "FAIL",
"severity": "high",
"status_extended": "S3 bucket my-bucket is publicly accessible.",
"delta": "new",
"muted": False,
"muted_reason": None,
"check_metadata": CHECK_METADATA,
}
DETAILED_ATTRIBUTES = {
**FINDING_ATTRIBUTES,
"inserted_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:00:00Z",
}
def test_nested_attributes_are_flattened_onto_the_model():
"""Assert on the fields the model derives, not the ones it copies verbatim."""
finding = SimplifiedFinding.from_api_response(
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
)
assert finding.check_id == "s3_bucket_public_access"
def test_empty_fields_are_dropped_from_the_serialized_payload():
"""Assert on `model_dump()` too -- MinimalSerializerMixin drops empty values.
A model may override that for fields whose empty form carries meaning, and
that override is exactly the kind of thing a refactor breaks silently.
"""
finding = SimplifiedFinding.from_api_response(
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
)
assert "muted_reason" not in finding.model_dump()
def test_both_relationship_shapes_are_parsed():
"""To-one reduces to a single id, to-many to a list of ids."""
resource = jsonapi_resource(
"findings",
"f1",
attributes=DETAILED_ATTRIBUTES,
relationships={
"scan": jsonapi_relationship_one("scans", "s1"),
"resources": jsonapi_relationship_many("resources", "r1", "r2"),
},
)
finding = DetailedFinding.from_api_response(resource)
assert finding.scan_id == "s1"
assert finding.resource_ids == ["r1", "r2"]
def test_missing_relationships_are_tolerated():
"""Omit `relationships=` entirely to express absence.
Pass an empty `jsonapi_relationship_many(...)` instead to express "present and
empty" -- some models must distinguish the two.
"""
finding = DetailedFinding.from_api_response(
jsonapi_resource("findings", "f1", DETAILED_ATTRIBUTES)
)
assert finding.scan_id is None
assert finding.resource_ids == []
def test_list_response_carries_pagination_metadata():
"""`jsonapi_collection` emits meta.pagination exactly as *ListResponse reads it."""
response = jsonapi_collection(
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)],
page=2,
pages=7,
count=312,
)
result = FindingsListResponse.from_api_response(response)
assert (result.current_page, result.total_num_pages, result.total_num_finding) == (
2,
7,
312,
)
@@ -0,0 +1,125 @@
# Example: Prowler MCP Server tool test patterns
# Source: mcp_server/tests/prowler_app/tools/test_findings.py
import pytest
from fastmcp import Client
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_error, jsonapi_resource
LATEST = "/api/v1/findings/latest"
HISTORICAL = "/api/v1/findings"
FINDING_ATTRIBUTES = {
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
"status": "FAIL",
"severity": "high",
"status_extended": "S3 bucket my-bucket is publicly accessible.",
"delta": "new",
"muted": False,
"muted_reason": None,
"check_metadata": {"checkid": "s3_bucket_public_access"},
}
async def test_tool_returns_a_simplified_payload(
mcp_root_server, mock_api_client, mock_router
):
"""Drive the tool through the protocol; assert on the structured result.
This is the default pattern. Going through the in-memory client is what
resolves the pydantic `Field(default=...)` declarations on the tool's
parameters -- calling the method directly leaves omitted arguments as raw
`FieldInfo` objects, which are truthy and build nonsense filters.
"""
mock_router.add(
"GET",
LATEST,
json=jsonapi_collection(
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)]
),
)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_search_security_findings", {})
assert result.data["findings"][0]["check_id"] == "s3_bucket_public_access"
async def test_tool_arguments_become_api_query_parameters(
mcp_root_server, mock_api_client, mock_router
):
"""Assert on the recorded request, not only the returned payload.
The request is where filter translation, pagination and field selection live,
and it is what breaks silently when an API contract shifts.
"""
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
async with Client(mcp_root_server) as client:
await client.call_tool(
"prowler_search_security_findings", {"severity": ["critical", "high"]}
)
params = mock_router.query_params("GET", LATEST)
assert params["filter[severity__in]"] == "critical,high" # lists become CSV
assert params["filter[status__in]"] == "FAIL" # the tool's default
async def test_tool_picks_the_right_endpoint(
mcp_root_server, mock_api_client, mock_router
):
"""Assert which endpoint was called when the tool chooses between several.
A wrong choice here is a performance regression the response body alone would
never reveal, so `paths()` is the assertion that catches it.
"""
mock_router.add("GET", HISTORICAL, json=jsonapi_collection([]))
async with Client(mcp_root_server) as client:
await client.call_tool(
"prowler_search_security_findings", {"date_from": "2025-01-15"}
)
assert mock_router.paths() == [f"GET {HISTORICAL}"]
async def test_tool_validates_input_before_calling_the_api(
mcp_root_server, mock_api_client, mock_router
):
"""Local validation must reject before any request goes out."""
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="Must be between 1 and 1000"):
await client.call_tool(
"prowler_search_security_findings", {"page_size": 5000}
)
assert mock_router.requests == []
async def test_tool_surfaces_the_api_error_detail(
mcp_root_server, mock_api_client, mock_router
):
"""Error text reaches the model, so assert on it rather than on the type alone."""
mock_router.add(
"GET", f"{HISTORICAL}/nope", status=404, json=jsonapi_error(404, "Not found.")
)
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="Not found."):
await client.call_tool(
"prowler_get_finding_details", {"finding_id": "nope"}
)
async def test_polling_tool_waits_for_a_terminal_task_state(
mock_api_client, mock_router
):
"""Register a route repeatedly to return a sequence; the last entry repeats."""
from tests.helpers.jsonapi import task_document
mock_router.add("GET", "/api/v1/tasks/t1", json=task_document("t1", "executing"))
mock_router.add("GET", "/api/v1/tasks/t1", json=task_document("t1", "completed"))
result = await mock_api_client.poll_task_until_complete("t1", poll_interval=0)
assert result["data"]["attributes"]["state"] == "completed"