From b036b8f42da03ab61bc1fea43c791da30f577ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= <98956809+puchy22@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:48:22 +0100 Subject: [PATCH] feat(mcp): mark every tool as read-only or destructive (#13196) --- .claude-plugin/marketplace.json | 4 +-- .../prowler/.claude-plugin/plugin.json | 4 +-- claude_plugins/prowler/README.md | 6 ++-- .../changelog.d/tool-annotations.added.md | 1 + .../prowler_app/tools/attack_paths.py | 2 ++ .../prowler_app/tools/base.py | 27 ++++++++++++++- .../prowler_app/tools/muting.py | 6 ++++ .../prowler_app/tools/roles.py | 2 ++ .../prowler_documentation/server.py | 4 +-- .../prowler_mcp_server/prowler_hub/server.py | 20 +++++------ mcp_server/tests/test_server.py | 33 +++++++++++++++++++ 11 files changed, 89 insertions(+), 20 deletions(-) create mode 100644 mcp_server/changelog.d/tool-annotations.added.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4fa316271d..57a2d01bf2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" } diff --git a/claude_plugins/prowler/.claude-plugin/plugin.json b/claude_plugins/prowler/.claude-plugin/plugin.json index d96b044be5..0debbbd3ff 100644 --- a/claude_plugins/prowler/.claude-plugin/plugin.json +++ b/claude_plugins/prowler/.claude-plugin/plugin.json @@ -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": [ diff --git a/claude_plugins/prowler/README.md b/claude_plugins/prowler/README.md index 1bdf6f30e8..3b1e33f312 100644 --- a/claude_plugins/prowler/README.md +++ b/claude_plugins/prowler/README.md @@ -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 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 diff --git a/mcp_server/changelog.d/tool-annotations.added.md b/mcp_server/changelog.d/tool-annotations.added.md new file mode 100644 index 0000000000..8bad9f0521 --- /dev/null +++ b/mcp_server/changelog.d/tool-annotations.added.md @@ -0,0 +1 @@ +`readOnlyHint` and `destructiveHint` annotations on every MCP tool, with mute rule and mutelist writes marked destructive diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py index b1914dc795..fd1767d6fb 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py @@ -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( diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/base.py b/mcp_server/prowler_mcp_server/prowler_app/tools/base.py index ec54969ba2..47a80938d7 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/base.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/base.py @@ -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, + ) diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py b/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py index 69a5f8884c..391d6dc362 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py @@ -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: diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py index 8fc4daa20b..fb26b9de0a 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py @@ -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( diff --git a/mcp_server/prowler_mcp_server/prowler_documentation/server.py b/mcp_server/prowler_mcp_server/prowler_documentation/server.py index 1121d7c419..2fffa12ebf 100644 --- a/mcp_server/prowler_mcp_server/prowler_documentation/server.py +++ b/mcp_server/prowler_mcp_server/prowler_documentation/server.py @@ -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." diff --git a/mcp_server/prowler_mcp_server/prowler_hub/server.py b/mcp_server/prowler_mcp_server/prowler_hub/server.py index 2c78ac7c36..1ea754235f 100644 --- a/mcp_server/prowler_mcp_server/prowler_hub/server.py +++ b/mcp_server/prowler_mcp_server/prowler_hub/server.py @@ -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.", diff --git a/mcp_server/tests/test_server.py b/mcp_server/tests/test_server.py index 30ab1fc92c..50d7876549 100644 --- a/mcp_server/tests/test_server.py +++ b/mcp_server/tests/test_server.py @@ -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