feat(mcp): add HTTP transport support (#8784)

This commit is contained in:
Rubén De la Torre Vico
2025-10-08 11:32:39 +02:00
committed by GitHub
parent 5cfe140b7b
commit 4e143cf013
7 changed files with 312 additions and 96 deletions

View File

@@ -1,4 +1,5 @@
PROWLER_APP_EMAIL="your_registered@email.com"
PROWLER_APP_PASSWORD="your_user_pass"
PROWLER_APP_TENANT_ID="optional_tenant_to_login"
PROWLER_API_BASE_URL=https://api.prowler.com
PROWLER_API_BASE_URL="https://api.prowler.com"
PROWLER_MCP_MODE="stdio"

View File

@@ -51,4 +51,9 @@ COPY --from=builder --chown=prowler /app/pyproject.toml /app/pyproject.toml
ENV PATH="/app/.venv/bin:$PATH"
# Entry point for the MCP server
# Default to stdio mode, but allow overriding via command arguments
# Examples:
# docker run -p 8000:8000 prowler-mcp --transport http --host 0.0.0.0 --port 8000
# docker run prowler-mcp --transport stdio
ENTRYPOINT ["prowler-mcp"]
CMD ["--transport", "stdio"]

View File

@@ -43,6 +43,43 @@ docker run --rm --env-file ./.env -it prowler-mcp
## Running
The Prowler MCP server supports two transport modes:
- **STDIO mode** (default): For direct integration with MCP clients like Claude Desktop
- **HTTP mode**: For remote access over HTTP with Bearer token authentication
### Transport Modes
#### STDIO Mode (Default)
STDIO mode is the standard MCP transport for direct client integration:
```bash
cd prowler/mcp_server
uv run prowler-mcp
# or
uv run prowler-mcp --transport stdio
```
#### HTTP Mode (Remote Server)
HTTP mode allows the server to run as a remote service accessible over HTTP:
```bash
cd prowler/mcp_server
# Run on default host and port (127.0.0.1:8000)
uv run prowler-mcp --transport http
# Run on custom host and port
uv run prowler-mcp --transport http --host 0.0.0.0 --port 8080
```
For self-deployed MCP remote server, you can use also configure the server to use a custom API base URL with the environment variable `PROWLER_API_BASE_URL`; and the transport mode with the environment variable `PROWLER_MCP_MODE`.
```bash
export PROWLER_API_BASE_URL="https://api.prowler.com"
export PROWLER_MCP_MODE="http"
```
### Using uv directly
After installation, start the MCP server via the console script:
@@ -60,13 +97,61 @@ uvx /path/to/prowler/mcp_server/
### Using Docker
Run the pre-built Docker container:
#### STDIO Mode (Default)
Run the pre-built Docker container in STDIO mode:
```bash
cd prowler/mcp_server
docker run --rm --env-file ./.env -it prowler-mcp
```
#### HTTP Mode (Remote Server)
Run as a remote HTTP server:
```bash
cd prowler/mcp_server
# Run on port 8000 (accessible from host)
docker run --rm --env-file ./.env -p 8000:8000 -it prowler-mcp --transport http --host 0.0.0.0 --port 8000
# Run on custom port
docker run --rm --env-file ./.env -p 8080:8080 -it prowler-mcp --transport http --host 0.0.0.0 --port 8080
```
## Command Line Arguments
The Prowler MCP server supports the following command line arguments:
```
prowler-mcp [--transport {stdio,http}] [--host HOST] [--port PORT]
```
**Arguments:**
- `--transport {stdio,http}`: Transport method (default: stdio)
- `stdio`: Standard input/output transport for direct MCP client integration
- `http`: HTTP transport for remote server access
- `--host HOST`: Host to bind to for HTTP transport (default: 127.0.0.1)
- `--port PORT`: Port to bind to for HTTP transport (default: 8000)
**Examples:**
```bash
# Default STDIO mode
prowler-mcp
# Explicit STDIO mode
prowler-mcp --transport stdio
# HTTP mode with default host and port (127.0.0.1:8000)
prowler-mcp --transport http
# HTTP mode accessible from any network interface
prowler-mcp --transport http --host 0.0.0.0
# HTTP mode with custom port
prowler-mcp --transport http --host 0.0.0.0 --port 8080
```
## Available Tools
### Prowler Hub
@@ -130,9 +215,16 @@ All tools are exposed under the `prowler_app` prefix.
## Configuration
### Environment Variables
### Prowler Cloud and Prowler App (Self-Managed) Authentication
For Prowler Cloud and Prowler App (Self-Managed) features, you need to set the following environment variables:
> [!IMPORTANT]
> Authentication is not needed for using Prowler Hub features.
The Prowler MCP server supports different authentication in Prowler Cloud and Prowler App (Self-Managed) methods depending on the transport mode:
#### STDIO Mode Authentication
For STDIO mode, authentication is handled via environment variables:
```bash
# Required for Prowler Cloud and Prowler App (Self-Managed) authentication
@@ -146,11 +238,36 @@ export PROWLER_APP_TENANT_ID="your-tenant-id"
export PROWLER_API_BASE_URL="https://api.prowler.com"
```
#### HTTP Mode Authentication
For HTTP mode (remote server), authentication is handled via Bearer tokens. You need to obtain a JWT token from Prowler Cloud/App and include the generated token in the MCP client configuration. To get a valid token, you can use the following command (replace the email and password with your own credentials):
```bash
curl -X POST https://api.prowler.com/api/v1/tokens \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-d '{
"data": {
"type": "tokens",
"attributes": {
"email": "your-email@example.com",
"password": "your-password"
}
}
}'
```
The response will be a JWT token that you can use to [authenticate your MCP client](#http-mode-configuration-remote-server).
### MCP Client Configuration
Configure your MCP client, like Claude Desktop, Cursor, etc, to launch the server. Below are examples for both direct execution and Docker deployment; consult your client's documentation for exact locations.
Configure your MCP client, like Claude Desktop, Cursor, etc, to connect to the server. The configuration depends on whether you're running in STDIO mode (local) or HTTP mode (remote).
#### Using uvx (Direct Execution)
#### STDIO Mode Configuration
For local execution, configure your MCP client to launch the server directly. Below are examples for both direct execution and Docker deployment; consult your client's documentation for exact locations.
##### Using uvx (Direct Execution)
```json
{
@@ -169,7 +286,7 @@ Configure your MCP client, like Claude Desktop, Cursor, etc, to launch the serve
}
```
#### Using Docker
##### Using Docker
```json
{
@@ -189,6 +306,29 @@ Configure your MCP client, like Claude Desktop, Cursor, etc, to launch the serve
}
```
#### HTTP Mode Configuration (Remote Server)
For HTTP mode, you can configure your MCP client to connect to a remote Prowler MCP server.
**Important Limitations:**
- HTTP mode support varies by client - some clients may not support HTTP transport yet.
- Some MCP clients like Claude Desktop only support OAuth authentication for HTTP connections, which is not currently supported by our MCP server.
Example configuration for clients that support HTTP transport:
```json
{
"mcpServers": {
"prowler": {
"url": "http://mcp.prowler.com/mcp", // Replace with your own MCP server URL, by default when server is run in local it is http://localhost:8000/mcp
"headers": {
"Authorization": "Bearer <your-jwt-token-here>"
}
}
}
}
```
### Claude Desktop (macOS/Windows)
Add the example server to Claude Desktop's config file, then restart the app.

View File

@@ -1,15 +1,48 @@
import argparse
import asyncio
import os
import sys
from prowler_mcp_server.lib.logger import logger
from prowler_mcp_server.server import prowler_mcp_server, setup_main_server
from prowler_mcp_server.server import setup_main_server
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description="Prowler MCP Server")
parser.add_argument(
"--transport",
choices=["stdio", "http"],
default=os.getenv("PROWLER_MCP_MODE", "stdio"),
help="Transport method (default: stdio)",
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Host to bind to for HTTP transport (default: 127.0.0.1)",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port to bind to for HTTP transport (default: 8000)",
)
return parser.parse_args()
def main():
"""Main entry point for the MCP server."""
try:
asyncio.run(setup_main_server())
prowler_mcp_server.run()
args = parse_arguments()
# Set up server with configuration
prowler_mcp_server = asyncio.run(setup_main_server(transport=args.transport))
if args.transport == "stdio":
prowler_mcp_server.run(transport="stdio")
elif args.transport == "http":
prowler_mcp_server.run(transport="http", host=args.host, port=args.port)
except KeyboardInterrupt:
logger.info("Shutting down Prowler MCP server...")
sys.exit(0)

View File

@@ -1,5 +1,3 @@
"""Authentication manager for Prowler App API."""
import base64
import json
import os
@@ -7,6 +5,7 @@ from datetime import datetime
from typing import Dict, Optional
import httpx
from fastmcp.server.dependencies import get_http_headers
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.logger import logger
@@ -14,26 +13,32 @@ from prowler_mcp_server.lib.logger import logger
class ProwlerAppAuth:
"""Handles authentication and token management for Prowler App API."""
def __init__(self):
self.base_url = os.getenv(
"PROWLER_API_BASE_URL", "https://api.prowler.com"
).rstrip("/")
self.email = os.getenv("PROWLER_APP_EMAIL")
self.password = os.getenv("PROWLER_APP_PASSWORD")
self.tenant_id = os.getenv("PROWLER_APP_TENANT_ID", None)
def __init__(
self,
mode: str = os.getenv("PROWLER_MCP_MODE", "stdio"),
base_url: str = os.getenv("PROWLER_API_BASE_URL", "https://api.prowler.com"),
):
self.base_url = base_url.rstrip("/")
logger.info(f"Using Prowler App API base URL: {self.base_url}")
self.mode = mode
if mode == "stdio": # STDIO mode
self.email = os.getenv("PROWLER_APP_EMAIL")
self.password = os.getenv("PROWLER_APP_PASSWORD")
self.tenant_id = os.getenv("PROWLER_APP_TENANT_ID", None)
if not self.email or not self.password:
raise ValueError(
"PROWLER_APP_EMAIL and PROWLER_APP_PASSWORD environment variables are required"
)
else:
self.email = None
self.password = None
self.tenant_id = None
self.access_token: Optional[str] = None
self.refresh_token: Optional[str] = None
self._validate_credentials()
def _validate_credentials(self):
"""Validate that all required credentials are present."""
if not self.email:
raise ValueError("PROWLER_APP_EMAIL environment variable is required")
if not self.password:
raise ValueError("PROWLER_APP_PASSWORD environment variable is required")
def _parse_jwt(self, token: str) -> Optional[Dict]:
"""Parse JWT token and return payload, similar to JS parseJwt function."""
if not token:
@@ -63,56 +68,80 @@ class ProwlerAppAuth:
async def authenticate(self) -> str:
"""Authenticate with Prowler App API and return access token."""
logger.info("Starting authentication with Prowler App API")
async with httpx.AsyncClient() as client:
try:
# Prepare JSON:API formatted request body
auth_attributes = {"email": self.email, "password": self.password}
if self.tenant_id:
auth_attributes["tenant_id"] = self.tenant_id
if self.mode == "stdio":
async with httpx.AsyncClient() as client:
try:
# Prepare JSON:API formatted request body
auth_attributes = {"email": self.email, "password": self.password}
if self.tenant_id:
auth_attributes["tenant_id"] = self.tenant_id
request_body = {
"data": {
"type": "tokens",
"attributes": auth_attributes,
request_body = {
"data": {
"type": "tokens",
"attributes": auth_attributes,
}
}
}
response = await client.post(
f"{self.base_url}/api/v1/tokens",
json=request_body,
headers={
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
)
response.raise_for_status()
response = await client.post(
f"{self.base_url}/api/v1/tokens",
json=request_body,
headers={
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
)
response.raise_for_status()
data = response.json()
# Extract token from JSON:API response format
self.access_token = (
data.get("data", {}).get("attributes", {}).get("access")
)
self.refresh_token = (
data.get("data", {}).get("attributes", {}).get("refresh")
)
data = response.json()
# Extract token from JSON:API response format
self.access_token = (
data.get("data", {}).get("attributes", {}).get("access")
)
self.refresh_token = (
data.get("data", {}).get("attributes", {}).get("refresh")
)
logger.debug(f"Access token: {self.access_token}")
if not self.access_token:
raise ValueError("Token not found in response")
if not self.access_token:
raise ValueError("Token not found in response")
logger.info("Authentication successful")
logger.info("Authentication successful")
return self.access_token
return self.access_token
except httpx.HTTPStatusError as e:
logger.error(
f"Authentication failed with HTTP status {e.response.status_code}: {e.response.text}"
)
raise ValueError(f"Authentication failed: {e.response.text}")
except Exception as e:
logger.error(f"Authentication failed with error: {e}")
raise ValueError(f"Authentication failed: {e}")
elif self.mode == "http":
headers = get_http_headers()
access_token = headers.get("authorization", None)
except httpx.HTTPStatusError as e:
logger.error(
f"Authentication failed with HTTP status {e.response.status_code}: {e.response.text}"
)
raise ValueError(f"Authentication failed: {e.response.text}")
except Exception as e:
logger.error(f"Authentication failed with error: {e}")
raise ValueError(f"Authentication failed: {e}")
# Validate the token
if access_token:
if access_token.startswith("Bearer "):
access_token = access_token.replace("Bearer ", "")
payload = self._parse_jwt(access_token)
if not payload:
raise ValueError("Invalid JWT token format")
# Check if token is expired
now = int(datetime.now().timestamp())
exp = payload.get("exp", 0)
if exp <= now:
raise ValueError("Token has expired")
else:
raise ValueError("No authorization token provided")
# Don't cache tokens in HTTP mode to prevent sharing between clients
return access_token
else:
raise ValueError(f"Invalid mode: {self.mode}")
async def refresh_access_token(self) -> str:
"""Refresh the access token using the refresh token."""
@@ -161,28 +190,33 @@ class ProwlerAppAuth:
async def get_valid_token(self) -> str:
"""Get a valid access token, checking JWT expiry."""
current_token = self.access_token
need_new_token = True
if current_token:
payload = self._parse_jwt(current_token)
if payload:
now = int(datetime.now().timestamp())
time_left = payload.get("exp", 0) - now
if time_left > 120: # 2 minutes margin
need_new_token = False
if need_new_token:
token = await self.authenticate()
# Verify the new token
payload = self._parse_jwt(token)
return token
if self.mode == "http":
# In HTTP mode, always authenticate fresh to prevent token sharing between clients
return await self.authenticate()
else:
return current_token
# In STDIO mode, cache tokens for efficiency
current_token = self.access_token
need_new_token = True
if current_token:
payload = self._parse_jwt(current_token)
if payload:
now = int(datetime.now().timestamp())
time_left = payload.get("exp", 0) - now
if time_left > 120: # 2 minutes margin
need_new_token = False
if need_new_token:
token = await self.authenticate()
# Verify the new token
payload = self._parse_jwt(token)
return token
else:
return current_token
def get_headers(self, token: str) -> Dict[str, str]:
"""Get headers for API requests with authentication."""

View File

@@ -15,7 +15,6 @@ from typing import Optional
import requests
import yaml
from prowler_mcp_server.lib.logger import logger
class OpenAPIToMCPGenerator:
@@ -758,7 +757,6 @@ class OpenAPIToMCPGenerator:
# Check excluded tags
if any(tag in self.exclude_tags for tag in tags):
logger.debug(f"Excluding endpoint {path} due to tag {tags}")
return True
return False

View File

@@ -3,13 +3,13 @@ import os
from fastmcp import FastMCP
from prowler_mcp_server.lib.logger import logger
# Initialize main Prowler MCP server
prowler_mcp_server = FastMCP("prowler-mcp-server")
async def setup_main_server():
async def setup_main_server(transport: str) -> FastMCP:
"""Set up the main Prowler MCP server with all available integrations."""
# Initialize main Prowler MCP server
prowler_mcp_server = FastMCP("prowler-mcp-server")
# Import Prowler Hub tools with prowler_hub_ prefix
try:
logger.info("Importing Prowler Hub server...")
@@ -23,6 +23,9 @@ async def setup_main_server():
try:
logger.info("Importing Prowler App server...")
if os.getenv("PROWLER_MCP_MODE", None) is None:
os.environ["PROWLER_MCP_MODE"] = transport
if not os.path.exists(
os.path.join(os.path.dirname(__file__), "prowler_app", "server.py")
):
@@ -39,3 +42,5 @@ async def setup_main_server():
logger.info("Successfully imported Prowler App server")
except Exception as e:
logger.error(f"Failed to import Prowler App server: {e}")
return prowler_mcp_server