feat(mcp): mark every tool as read-only or destructive (#13196)

This commit is contained in:
Rubén De la Torre Vico authored and GitHub committed 2026-10-29 11:48:22 +01:00
1 parent cbece30d7b
commit b036b8f42d
11 files changed
+89 -20

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "prowler-plugins",
"description": "Prowler Cloud Security for Claude Code",
"description": "Prowler Cloud security and compliance for Claude",
"owner": {
"name": "Prowler",
"email": "support@prowler.com"
@@ -9,7 +9,7 @@
{
"name": "prowler",
"source": "./claude_plugins/prowler",
"description": "Prowler Cloud security and compliance skills for Claude, powered by the Prowler MCP server. Triage compliance gaps and remediate findings on AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and many more providers listed at https://registry.prowler.com, plus custom providers, until your account meets the framework you choose.",
"description": "Connect Claude to Prowler Cloud through the Prowler MCP server. Reads providers, scans, findings, compliance results and resources for AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and the other providers listed at https://registry.prowler.com, and returns remediation guidance for failed checks. Writes are limited to Prowler Cloud itself: triggering scans, finding triage, mute rules and the mutelist, providers and integrations. Your cloud resources are not changed by the connector.",
"category": "security",
"homepage": "https://prowler.com"
}
@@ -3,13 +3,13 @@
"name": "prowler",
"displayName": "Prowler Cloud",
"version": "0.1.0",
"description": "Prowler Cloud security and compliance skills for Claude, powered by the Prowler MCP server. Triage compliance gaps and remediate findings on AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and many more providers listed at https://registry.prowler.com, plus custom providers, until your account meets the framework you choose.",
"description": "Connect Claude to Prowler Cloud through the Prowler MCP server. Reads providers, scans, findings, compliance results and resources for AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and the other providers listed at https://registry.prowler.com, and returns remediation guidance for failed checks. Writes are limited to Prowler Cloud itself: triggering scans, finding triage, mute rules and the mutelist, providers and integrations. Your cloud resources are not changed by the connector.",
"author": {
"name": "Prowler",
"email": "support@prowler.com",
"url": "https://prowler.com"
},
"homepage": "https://docs.prowler.com",
"homepage": "https://docs.prowler.com/user-guide/ai-agents",
"repository": "https://github.com/prowler-cloud/prowler",
"license": "Apache-2.0",
"keywords": [
+3 -3
View File
@@ -1,6 +1,6 @@
# Prowler for Claude Code
# Prowler Cloud
End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](https://docs.prowler.com/projects/prowler-mcp/). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
Cloud security and compliance in Claude, powered by the [Prowler MCP server](https://docs.prowler.com/projects/prowler-mcp/). Claude reads your Prowler Cloud providers, scans, findings and compliance results, returns Prowler's remediation guidance for each failed check, and manages triage and mute state. The connector never changes your cloud resources: any fix is applied with your own tools (Terraform, a cloud CLI or the console), and only after you approve it.
> **Preview**: this plugin is under active development. Report issues at <https://github.com/prowler-cloud/prowler/issues> or join the [Slack community](https://goto.prowler.com/slack).
@@ -70,7 +70,7 @@ Claude tracks progress in a markdown report under `.prowler/` at your project ro
## Data and privacy
The plugin stores nothing itself. It connects to the Prowler MCP server at `https://mcp.prowler.com/mcp`, authenticating with the API key you provide (kept in your system keychain and sent only as a bearer token to that server). Through it, Claude reads your Prowler Cloud providers, scans, findings, compliance results and resource metadata, and can trigger scans. It also queries the public Prowler Hub and Prowler documentation. The remediation report is written locally under `.prowler/` in your project. Nothing is sent to any other service. See the [Prowler privacy policy](https://prowler.com/privacy-policy) for how Prowler Cloud handles account data.
The plugin stores nothing itself. It connects to the Prowler MCP server at `https://mcp.prowler.com/mcp`, authenticating with the API key you provide (kept in your system keychain and sent only as a bearer token to that server). Through it, Claude reads your Prowler Cloud providers, scans, findings, compliance results and resource metadata. It can also write to Prowler Cloud: trigger scans, set finding triage, create mute rules, replace the mutelist, and add or remove providers and integrations. Mutes and deletions are marked as destructive, so your client can ask before running them. It also queries the public Prowler Hub and Prowler documentation. The remediation report is written locally under `.prowler/` in your project. Nothing is sent to any other service. See the [Prowler privacy policy](https://prowler.com/privacy-policy) for how Prowler Cloud handles account data.
## Uninstalling
@@ -0,0 +1 @@
`readOnlyHint` and `destructiveHint` annotations on every MCP tool, with mute rule and mutelist writes marked destructive
@@ -30,6 +30,8 @@ class AttackPathsTools(BaseTool):
- prowler_run_attack_paths_query: Execute query and analyze attack paths
"""
read_only_tools = frozenset({"run_attack_paths_query"})
async def list_attack_paths_scans(
self,
provider_id: list[str] = Field(
@@ -2,12 +2,16 @@ import inspect
from abc import ABC
from typing import TYPE_CHECKING
from mcp.types import ToolAnnotations
if TYPE_CHECKING:
from fastmcp import FastMCP
from prowler_mcp_server.lib.logger import logger
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIClient
READ_ONLY_PREFIXES = ("get_", "list_", "search_")
class BaseTool(ABC):
"""Abstract base class for all MCP tools.
@@ -24,6 +28,10 @@ class BaseTool(ABC):
Attributes:
_api_client: Singleton instance of ProwlerAPIClient for API requests
_logger: Logger instance for structured logging
read_only_tools: Tools that change nothing although their name does not
start with one of READ_ONLY_PREFIXES
destructive_tools: Tools that remove, replace or hide data although their
name does not start with "delete_"
Example:
class FindingsTools(BaseTool):
@@ -37,6 +45,9 @@ class BaseTool(ABC):
return response
"""
read_only_tools: frozenset[str] = frozenset()
destructive_tools: frozenset[str] = frozenset()
def __init__(self):
"""Initialize the tool.
@@ -93,10 +104,24 @@ class BaseTool(ABC):
# Check if the method is a coroutine function (async)
if inspect.iscoroutinefunction(method):
mcp.tool(method)
mcp.tool(method, annotations=self._annotations_for(name))
registered_count += 1
self.logger.debug(f"Auto-registered tool: {name}")
self.logger.info(
f"Auto-registered {registered_count} tools from {self.__class__.__name__}"
)
def _annotations_for(self, name: str) -> ToolAnnotations:
"""Return the MCP hints a client reads to decide whether to ask the user.
Every tool states both hints: left out, the MCP spec treats a tool as
destructive, so a client could not tell a listing from a deletion.
"""
if name.startswith(READ_ONLY_PREFIXES) or name in self.read_only_tools:
return ToolAnnotations(readOnlyHint=True, destructiveHint=False)
return ToolAnnotations(
readOnlyHint=False,
destructiveHint=name.startswith("delete_")
or name in self.destructive_tools,
)
@@ -29,6 +29,12 @@ class MutingTools(BaseTool):
- Managing mute rules (finding-specific muting)
"""
# A wrong mute hides findings silently and for good, so it is flagged like a deletion.
# update_mute_rule is included because re-enabling a rule mutes its findings again.
destructive_tools = frozenset(
{"set_mutelist", "create_mute_rule", "update_mute_rule"}
)
# ===== MUTELIST TOOLS =====
async def _get_mutelist_raw(self) -> dict[str, Any] | None:
@@ -34,6 +34,8 @@ class RolesTools(BaseTool):
- prowler_set_user_role: Set the role a user holds (idempotent)
"""
destructive_tools = frozenset({"set_user_role"})
async def list_roles(
self,
page_size: int = Field(
@@ -14,7 +14,7 @@ docs_mcp_server = FastMCP("prowler-docs", mask_error_details=True)
prowler_docs_search_engine = ProwlerDocsSearchEngine()
@docs_mcp_server.tool()
@docs_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
def search(
term: NonBlankStr = Field(
description="The term to search for in the documentation"
@@ -41,7 +41,7 @@ def search(
return prowler_docs_search_engine.search(term, page_size) # type: ignore In the hint we cannot put SearchResult type because JSON API MCP Generator cannot handle Pydantic models yet
@docs_mcp_server.tool()
@docs_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
def get_document(
doc_path: NonBlankStr = Field(
description="Path to the documentation file to retrieve. It is the same as the 'path' field of the search results. Use `prowler_docs_search` to find the path first."
@@ -167,7 +167,7 @@ def _explain_missing_check_file(
# Security Check Tools
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def list_checks(
providers: list[str] = Field(
default=[],
@@ -251,7 +251,7 @@ async def list_checks(
return {"count": len(checks), "checks": checks_list}
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def semantic_search_checks(
term: NonBlankStr = Field(
description="Search term. Examples: 'public access', 'encryption', 'MFA', 'logging'.",
@@ -303,7 +303,7 @@ async def semantic_search_checks(
return {"count": len(checks), "checks": checks_list}
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def get_check_details(
check_id: NonBlankStr = Field(
description="The check ID to retrieve details for. Example: 's3_bucket_level_public_access_block'"
@@ -441,7 +441,7 @@ async def get_check_details(
return result
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def get_check_code(
provider_id: NonBlankStr = Field(
description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
@@ -494,7 +494,7 @@ async def get_check_code(
}
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def get_check_fixer(
provider_id: NonBlankStr = Field(
description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
@@ -550,7 +550,7 @@ async def get_check_fixer(
# Compliance Framework Tools
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def list_compliances(
provider: list[str] = Field(
default=[],
@@ -610,7 +610,7 @@ async def list_compliances(
return {"count": len(compliances), "compliances": compliances_list}
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def semantic_search_compliances(
term: NonBlankStr = Field(
description="Search term. Examples: 'CIS', 'HIPAA', 'PCI', 'GDPR', 'SOC2', 'NIST'.",
@@ -654,7 +654,7 @@ async def semantic_search_compliances(
return {"count": len(compliances), "compliances": compliances_list}
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def get_compliance_details(
compliance_id: NonBlankStr = Field(
description="The compliance framework ID to retrieve details for. Example: 'cis_4.0_aws'. Use `prowler_hub_list_compliances` or `prowler_hub_semantic_search_compliances` to find available compliance IDs.",
@@ -743,7 +743,7 @@ async def get_compliance_details(
# Provider Tools
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def list_providers() -> dict:
"""List all providers supported by Prowler.
@@ -784,7 +784,7 @@ async def list_providers() -> dict:
return {"count": len(providers), "providers": providers_list}
@hub_mcp_server.tool()
@hub_mcp_server.tool(annotations={"readOnlyHint": True, "destructiveHint": False})
async def get_provider_services(
provider_id: NonBlankStr = Field(
description="The provider ID to get services for. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
+33
View File
@@ -49,3 +49,36 @@ async def test_every_tool_and_parameter_is_described(mcp_root_server):
for tool in tools:
assert_tool_contract(tool)
async def test_every_tool_says_whether_it_is_read_only_or_destructive(mcp_root_server):
"""Clients decide whether to ask the user from these hints.
Left unset, the MCP spec treats a tool as destructive, so a listing would look
exactly like a deletion.
"""
async with Client(mcp_root_server) as client:
tools = {tool.name: tool.annotations for tool in await client.list_tools()}
for name, annotations in tools.items():
assert annotations is not None, f"{name} has no annotations"
assert annotations.readOnlyHint is not None, f"{name} has no readOnlyHint"
assert annotations.destructiveHint is not None, f"{name} has no destructiveHint"
destructive = {name for name, a in tools.items() if a.destructiveHint}
assert destructive == {
"prowler_create_mute_rule",
"prowler_update_mute_rule",
"prowler_set_mutelist",
"prowler_delete_mutelist",
"prowler_delete_mute_rule",
"prowler_delete_provider",
"prowler_delete_integration",
"prowler_set_user_role",
}
assert tools["prowler_run_attack_paths_query"].readOnlyHint
assert tools["prowler_hub_list_checks"].readOnlyHint
assert tools["prowler_docs_search"].readOnlyHint
assert tools["prowler_get_mutelist"].readOnlyHint
assert tools["prowler_hub_get_check_code"].readOnlyHint
assert not tools["prowler_trigger_scan"].readOnlyHint