diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d54682c..dc2c9aa 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,5 +1,5 @@ --- -name: Test +name: Image Tests on: pull_request: diff --git a/.github/workflows/test_image.yaml b/.github/workflows/test_image.yaml index 66a669c..1ef929a 100644 --- a/.github/workflows/test_image.yaml +++ b/.github/workflows/test_image.yaml @@ -1,5 +1,5 @@ --- -name: Test Image Build +name: Image Scan Vulns on: pull_request: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e719b6e..072185c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,10 +6,10 @@ repos: description: Format code with ruff. entry: make fmt language: system - stages: ["commit", "push"] + stages: ["pre-commit", "pre-push"] - id: ruff-check name: Ruff Check description: Check code style with ruff. entry: make lint language: system - stages: ["commit", "push"] + stages: ["pre-commit", "pre-push"] diff --git a/README.md b/README.md index 5d4bcd0..a434a00 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # MCP Server -| App Test | Helm Test | -|------|---------| -| [![App Test](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/publish.yaml/badge.svg?branch=main)](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/publish.yaml) | [![Helm Test](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/helm_test.yaml/badge.svg?branch=main)](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/helm_test.yaml) | +| Image Build | Image Scanning | Image Test | +|------|---------|-----------| +| [![Image Build](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/publish.yaml/badge.svg?branch=main)](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/publish.yaml) | [![Image Scanning](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/test_image.yaml/badge.svg?branch=main)](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/test_image.yaml) | [![Image Test](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/test.yaml) | --- @@ -13,16 +13,18 @@ - [Description](#description) - [Quickstart Guide](#quickstart-guide) - [Available Tools](#available-tools) + - [GA Tools](#ga-tools) + - [Beta Tools](#beta-tools) - [Available Resources](#available-resources) - [Requirements](#requirements) - [UV Setup](#uv-setup) - [Configuration](#configuration) - [Running the Server](#running-the-server) - [Docker](#docker) - - [K8s Deployment](#k8s-deployment) - [UV](#uv) - [Client Configuration](#client-configuration) - - [Authentication](#authentication) + - [Server Authentication](#server-authentication) + - [Sysdig API authentication](#sysdig-api-authentication) - [URL](#url) - [Claude Desktop App](#claude-desktop-app) - [MCP Inspector](#mcp-inspector) @@ -82,8 +84,12 @@ Get up and running with the Sysdig MCP Server quickly using our pre-built Docker } ``` +You can find a `mcp.json` file in the root of the project that you can tweak and add to your client. + ## Available Tools +### GA Tools +
Events Feed @@ -101,6 +107,36 @@ Get up and running with the Sysdig MCP Server quickly using our pre-built Docker | Tool Name | Description | Sample Prompt | |-----------|-------------|----------------| | `list_resources` | List inventory resources using filters (e.g., platform or category) | "List all exposed IAM resources in AWS" | + +
+ + +
+Sysdig Sysql + +| Tool Name | Description | Sample Prompt | +|-----------|-------------|----------------| +| `sysdig_sysql_execute_query` | Execute a Sysdig Sysql query against the Sysdig API and return the results | "MATCH CloudResource AFFECTED_BY Vulnerability WHERE Vulnerability.severity = 'Critical' RETURN DISTINCT CloudResource, Vulnerability LIMIT 50;" | + +
+ +
+Sysdig CLI scanner + +| Tool Name | Description | Sample Prompt | +|-----------|-------------|----------------| +| `run_sysdig_cli_scanner` | Run the Sysdig CLI Scanner to analyze a container image or IaC files for vulnerabilities and posture and misconfigurations. | "Scan this image ubuntu:latest for vulnerabilities" | + +Only in `stdio` transport mode. Make sure to have in your local $PATH the `sysdig-cli-scanner` binary; more info in the [docs](https://docs.sysdig.com/en/sysdig-secure/install-vulnerability-cli-scanner/) +
+ +### Beta Tools + +
+Inventory + +| Tool Name | Description | Sample Prompt | +|-----------|-------------|----------------| | `get_resource` | Get detailed information about an inventory resource by its hash | "Get inventory details for hash abc123" |
@@ -122,22 +158,18 @@ Get up and running with the Sysdig MCP Server quickly using our pre-built Docker
-Sysdig Sage +Sysdig Sysql | Tool Name | Description | Sample Prompt | |-----------|-------------|----------------| -| `sysdig_sysql_sage_query` | Generate and run a SysQL query using natural language | "List top 10 pods by memory usage in the last hour" | +| `sysdig_sysql_sage_query` | Get a Sysdig Sysql query through the help of Sysdig Sage based on a natural language question. | "List top 10 pods by memory usage in the last hour" |
-
-Sysdig CLI scanner - -| Tool Name | Description | Sample Prompt | -|-----------|-------------|----------------| -| `run_sysdig_cli_scanner` | Run the Sysdig CLI Scanner to analyze a container image or IaC files for vulnerabilities and posture and misconfigurations. | "Scan this image ubuntu:latest for vulnerabilities" | +--- -
+> [!IMPORTANT] +> In order to enable the Beta tools, set the `SYSDIG_MCP_ENABLE_BETA_TOOLS` to `true`. Use the beta tools under your own responability since some Sysdig APIs are still on beta. ### Available Resources @@ -174,7 +206,7 @@ You can also set the following variables to override the default configuration: - `SYSDIG_MCP_TRANSPORT`: The transport protocol for the MCP Server (`stdio`, `streamable-http`, `sse`). Defaults to: `stdio`. - `SYSDIG_MCP_MOUNT_PATH`: The URL prefix for the Streamable-http/sse deployment. Defaults to: `/sysdig-mcp-server` -- `SYSDIG_MCP_LOGLEVEL`: Log Level of the application (`DEBUG`, `INFO`, `WARNING`, `ERROR`). Defaults to: `INFO` +- `SYSDIG_MCP_LOGLEVEL`: Log Level of the application (`DEBUG`, `INFO`, `WARNING`, `ERROR`). Defaults to: `ERROR` - `SYSDIG_MCP_LISTENING_PORT`: The port for the server when it is deployed using remote protocols (`steamable-http`, `sse`). Defaults to: `8080` - `SYSDIG_MCP_LISTENING_HOST`: The host for the server when it is deployed using remote protocols (`steamable-http`, `sse`). Defaults to: `localhost` @@ -227,22 +259,67 @@ MCP_TRANSPORT=streamable-http uv run main.py To use the MCP server with a client like Claude or Cursor, you need to provide the server's URL and authentication details. -### Authentication +### Server Authentication -When using the `sse` or `streamable-http` transport, the server requires a Bearer token for authentication. The token is passed in the `X-Sysdig-Token` or default to `Authorization` header of the HTTP request (i.e `Bearer SYSDIG_SECURE_API_TOKEN`). +If you want to secure your MCP server with Oauth you can configure some environments variables to enable it. In this case we are going to use GitHub as the example provider but it also works for Google Oauth and the rest of the providers listed in the [authentication integrations](https://gofastmcp.com/integrations) list. + +In your `.env` file you will need to set the `FASTMCP_SERVER_AUTH_...` appropriate env vars depending on your Oauth provider. + +```bash +export SYSDIG_MCP_... +# Oauth config vars +export FASTMCP_SERVER_AUTH="fastmcp.server.auth.providers.github.GitHubProvider" +export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="bv2..." +export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="ase2..." +export FASTMCP_SERVER_AUTH_GITHUB_BASE_URL="http://localhost:8080" +export FASTMCP_SERVER_AUTH_GITHUB_REDIRECT_PATH="/auth/callback" +``` + +Then if your MCP client supports the Oauth authentication method you will be able to connect to it. Here is an example client you can use to test it: + +```python +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport +import asyncio +import os + +async def main(): + async with Client( + transport=StreamableHttpTransport( + url="http://localhost:8080/sysdig-mcp-server/mcp", + headers={"X-Sysdig-Token": f"Bearer {os.getenv("SYSDIG_MCP_API_SECURE_TOKEN")}"} + ), + auth="oauth" + ) as client: + print("✓ Authenticated with Oauth!") + tool_name = "list_runtime_events" + result = await client.call_tool(tool_name) + print(f"{tool_name} tool completed with status code: {result.structured_content.get('status_code')} with a total of: {result.data.get('results',{}).get('page', {}).get('total', 0)} runtime events.") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +You can find the above client code here `tests/mcp_test_client.py` + +More information as an example of the Github Oauth config [here](https://gofastmcp.com/integrations/github) + +### Sysdig API authentication + +When using the `sse` or `streamable-http` transport, the server requires the `X-Sysdig-Token` header in the HTTP request (i.e `SYSDIG_SECURE_API_TOKEN`) to authenticate to the Sysdig API. This is different than the MCP server own authentication methods. Additionally, you can specify the Sysdig Secure host by providing the `X-Sysdig-Host` header. If this header is not present, the server will use the value from the env variable `SYSDIG_MCP_API_HOST`. Example headers: ``` -Authorization: Bearer +X-Sysdig-Token: Bearer X-Sysdig-Host: ``` ### URL -If you are running the server with the `sse` or `streamable-http` transport, the URL will be `http://:/sysdig-mcp-server/mcp`. +If you are running the server with the `sse` or `streamable-http` transport, the URL will be `http://:/sysdig-mcp-server/mcp`. You can configure the `SYSDIG_MCP_MOUNT_PATH` env variable to configure the mountpoint path, default to `/sysdig-mcp-server` For example, if you are running the server locally on port 8080, the URL will be `http://localhost:8080/sysdig-mcp-server/mcp`. @@ -318,7 +395,7 @@ For the Claude Desktop app, you can manually configure the MCP server by editing 1. Run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) locally 2. Select the transport type and have the Sysdig MCP server running accordingly. -3. Pass the Authorization header if using "streamable-http" or the SYSDIG_SECURE_API_TOKEN env var if using "stdio" +3. Pass the `X-Sysdig-Token` authorization header if using "streamable-http" or the `SYSDIG_MCP_API_SECURE_TOKEN` env var if using "stdio" ![mcp-inspector](./docs/assets/mcp-inspector.png) @@ -350,5 +427,3 @@ For the Claude Desktop app, you can manually configure the MCP server by editing 3. Have fun ![goose_results](./docs/assets/goose_results.png) - - diff --git a/docs/assets/mcp-inspector.png b/docs/assets/mcp-inspector.png index 0b6c7ce..5186cc7 100644 Binary files a/docs/assets/mcp-inspector.png and b/docs/assets/mcp-inspector.png differ diff --git a/main.py b/main.py index e7b6b80..ce966cc 100644 --- a/main.py +++ b/main.py @@ -41,7 +41,7 @@ def main(): # Choose transport: "stdio" or "sse" (HTTP/SSE) handle_signals() transport = app_config.transport() - log.info(""" + print(""" ▄▖ ▌▘ ▖ ▖▄▖▄▖ ▄▖ ▚ ▌▌▛▘▛▌▌▛▌ ▛▖▞▌▌ ▙▌ ▚ █▌▛▘▌▌█▌▛▘ ▄▌▙▌▄▌▙▌▌▙▌ ▌▝ ▌▙▖▌ ▄▌▙▖▌ ▚▘▙▖▌ diff --git a/mcp.json b/mcp.json new file mode 100644 index 0000000..dc22ff8 --- /dev/null +++ b/mcp.json @@ -0,0 +1,24 @@ +{ + "mcpServers": { + "sysdig-mcp-server": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "SYSDIG_MCP_API_HOST", + "-e", + "SYSDIG_MCP_TRANSPORT", + "-e", + "SYSDIG_MCP_API_SECURE_TOKEN", + "ghcr.io/sysdiglabs/sysdig-mcp-server:latest" + ], + "env": { + "SYSDIG_MCP_API_HOST": "", + "SYSDIG_MCP_API_SECURE_TOKEN": "", + "SYSDIG_MCP_TRANSPORT": "stdio" + } + } + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2173422..ce1f7cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "sysdig-mcp-server" -version = "0.2.0" +version = "0.2.1" description = "Sysdig MCP Server" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "mcp[cli]~=1.12", + "mcp[cli]~=1.14.0", "python-dotenv~=1.1", "pyyaml~=6.0", "sqlalchemy~=2.0", @@ -14,7 +14,8 @@ dependencies = [ "dask~=2025.4", "oauthlib~=3.2", "fastapi~=0.116.1", - "fastmcp~=2.11", + "fastmcp @ git+https://github.com/jlowin/fastmcp@7e4aaa86969e04a05330c6d4e55f9b6c647c8756", + # "fastmcp~=2.12.0", will use this form once new releases are pushed quicker to PyPI "requests", ] @@ -26,6 +27,7 @@ dev-dependencies = [ "pytest-cov~=6.2", "pytest~=8.4", "ruff~=0.12.1", + "fastmcp @ git+https://github.com/jlowin/fastmcp@7e4aaa86969e04a05330c6d4e55f9b6c647c8756", ] [build-system] diff --git a/tests/mcp_test_client.py b/tests/mcp_test_client.py new file mode 100644 index 0000000..56912d3 --- /dev/null +++ b/tests/mcp_test_client.py @@ -0,0 +1,30 @@ +""" +This is a simple test client to verify the MCP server is running and responding to requests. +""" + +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport +import asyncio +import os + + +async def main(): + async with Client( + transport=StreamableHttpTransport( + url="http://localhost:8080/sysdig-mcp-server/mcp", + headers={"X-Sysdig-Token": f"Bearer {os.getenv('SYSDIG_MCP_API_SECURE_TOKEN')}"}, + ), + auth="oauth", + ) as client: + print("✓ Authenticated with Oauth!") + tool_name = "list_runtime_events" + result = await client.call_tool(tool_name) + print( + f"{tool_name} tool completed with status code: " + f"{result.structured_content.get('status_code')} with a total of: " + f"{result.data.get('results', {}).get('page', {}).get('total', 0)} runtime events." + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tools/sysdig_sage/__init__.py b/tools/sysdig_sysql/__init__.py similarity index 100% rename from tools/sysdig_sage/__init__.py rename to tools/sysdig_sysql/__init__.py diff --git a/tools/sysdig_sage/tool.py b/tools/sysdig_sysql/tool.py similarity index 59% rename from tools/sysdig_sage/tool.py rename to tools/sysdig_sysql/tool.py index c88771a..5c56cc4 100644 --- a/tools/sysdig_sage/tool.py +++ b/tools/sysdig_sysql/tool.py @@ -14,7 +14,7 @@ from utils.query_helpers import create_standard_response -class SageTools: +class SysqlTools: """ A class to encapsulate the tools for interacting with Sysdig Sage. This class provides methods to generate SysQL queries based on natural @@ -25,27 +25,25 @@ def __init__(self, app_config: AppConfig): self.app_config = app_config self.log = logging.getLogger(__name__) - async def tool_sage_to_sysql(self, ctx: Context, question: str) -> dict: + async def tool_sysql_sage_query(self, ctx: Context, question: str) -> dict: """ - Queries Sysdig Sage with a natural language question, retrieves a SysQL query, - executes it against the Sysdig API, and returns the results. + Queries Sysdig Sage with a natural language question to generate a SysQL query. Args: ctx (Context): A context object containing configuration information. question (str): A natural language question to send to Sage. Returns: - dict: A dictionary containing the results of the SysQL query execution and the query text. + dict: A dictionary containing the results of the SysQL query generation. Raises: ToolError: If the SysQL query generation or execution fails. Examples: - # tool_sage_to_sysql(question="Match Cloud Resource affected by Critical Vulnerability") - # tool_sage_to_sysql(question="Match Kubernetes Workload affected by Critical Vulnerability") - # tool_sage_to_sysql(question="Match AWS EC2 Instance that violates control 'EC2 - Instances should use IMDSv2'") + # tool_sysql_sage_query(question="Match Cloud Resource affected by Critical Vulnerability") + # tool_sysql_sage_query(question="Match Kubernetes Workload affected by Critical Vulnerability") + # tool_sysql_sage_query(question="Match AWS EC2 Instance that violates control 'EC2 - Instances should use IMDSv2'") """ - # 1) Generate SysQL query try: start_time = time.time() api_instances: dict = ctx.get_state("api_instances") @@ -64,18 +62,40 @@ async def tool_sage_to_sysql(self, ctx: Context, question: str) -> dict: sysql_query: str = json_resp.get("text", "") if not sysql_query: return {"error": "Sysdig Sage did not return a query"} + else: + execution_time = (time.time() - start_time) * 1000 + self.log.debug(f"SysQL query generated in {execution_time} ms: {sysql_query}") + response = create_standard_response( + results={"sysql": sysql_query}, + execution_time_ms=execution_time, + metadata_kwargs={"question": question}, + ) + return response - # 2) Execute generated SysQL query + async def tool_sysql_execute_query(self, ctx: Context, sysql_query: str) -> dict: + """ + Executes a SysQL query generated from a natural language question against the Sysdig API + and returns the results. + Args: + ctx (Context): A context object containing configuration information. + sysql_query (str): The SysQL query to execute. + Returns: + dict: A dictionary containing the results of the SysQL query execution. + Raises: + ToolError: If the SysQL query execution fails. + """ try: + start_time = time.time() + api_instances: dict = ctx.get_state("api_instances") + legacy_api_client: LegacySysdigApi = api_instances.get("legacy_sysdig_api") self.log.debug(f"Executing SysQL query: {sysql_query}") results = legacy_api_client.execute_sysql_query(sysql_query) execution_time = (time.time() - start_time) * 1000 self.log.debug(f"SysQL query executed in {execution_time} ms") response = create_standard_response( - results=results, execution_time_ms=execution_time, metadata_kwargs={"question": question, "sysql": sysql_query} + results=results, execution_time_ms=execution_time, metadata_kwargs={"sysql": sysql_query} ) - return response except ToolError as e: self.log.error(f"Failed to execute SysQL query: {e}") - raise e + raise ToolError(f"Failed to execute SysQL query: {e}") diff --git a/utils/app_config.py b/utils/app_config.py index 996e17e..f9dca51 100644 --- a/utils/app_config.py +++ b/utils/app_config.py @@ -18,6 +18,11 @@ class AppConfig: A class to encapsulate the application configuration. """ + def __init__(self): + # Automatically run validation on initialization + self.transport() + self.sysdig_endpoint() + def sysdig_endpoint(self) -> str: """ Get the Sysdig endpoint. @@ -106,66 +111,6 @@ def mcp_mount_path(self) -> str: """ return os.environ.get(f"{ENV_PREFIX}MOUNT_PATH", "/sysdig-mcp-server") - def oauth_jwks_uri(self) -> str: - """ - Get the string value for the remote OAuth JWKS URI. - Returns: - str: The OAuth JWKS URI. - """ - return os.environ.get(f"{ENV_PREFIX}OAUTH_JWKS_URI", "") - - def oauth_auth_endpoint(self) -> str: - """ - Get the string value for the remote OAuth Auth Endpoint. - Returns: - str: The OAuth Auth Endpoint. - """ - return os.environ.get(f"{ENV_PREFIX}OAUTH_AUTH_ENDPOINT", "") - - def oauth_token_endpoint(self) -> str: - """ - Get the string value for the remote OAuth Token Endpoint. - Returns: - str: The OAuth Token Endpoint. - """ - return os.environ.get(f"{ENV_PREFIX}OAUTH_TOKEN_ENDPOINT", "") - - def oauth_required_scopes(self) -> List[str]: - """ - Get the list of required scopes for the remote OAuth. - Returns: - List[str]: The list of scopes. - """ - raw = os.environ.get(f"{ENV_PREFIX}OAUTH_REQUIRED_SCOPES", "") - if not raw: - return [] - # Support comma-separated scopes in env var - return [s.strip() for s in raw.split(",") if s.strip()] - - def oauth_audience(self) -> str: - """ - Get the string value for the remote OAuth Audience. - Returns: - str: The OAuth Audience. - """ - return os.environ.get(f"{ENV_PREFIX}OAUTH_AUDIENCE", "") - - def oauth_client_id(self) -> str: - """ - Get the string value for the remote OAuth Client ID. - Returns: - str: The OAuth Client ID. - """ - return os.environ.get(f"{ENV_PREFIX}OAUTH_CLIENT_ID", "") - - def oauth_client_secret(self) -> str: - """ - Get the string value for the remote OAuth Client Secret. - Returns: - str: The OAuth Client Secret. - """ - return os.environ.get(f"{ENV_PREFIX}OAUTH_CLIENT_SECRET", "") - def mcp_base_url(self) -> str: """ Get the string value for the remote MCP Base URL. @@ -174,41 +119,15 @@ def mcp_base_url(self) -> str: """ return os.environ.get(f"{ENV_PREFIX}BASE_URL", "http://localhost:8080") - def oauth_redirect_path(self) -> str: - """ - Get the string value for the remote OAuth Redirect Path. - Returns: - str: The OAuth Redirect Path. - """ - return os.environ.get(f"{ENV_PREFIX}OAUTH_REDIRECT_PATH", "/auth/callback") - - def oauth_allowed_client_redirect_uris(self) -> List[str]: - """ - Get the list of allowed client redirect URIs for the remote OAuth. - Returns: - List[str]: The list of allowed client redirect URIs. - """ - raw = os.environ.get(f"{ENV_PREFIX}OAUTH_ALLOWED_CLIENT_REDIRECT_URIS", "http://localhost:8080") - if not raw: - return [] - # Support comma-separated URIs in env var - return [s.strip() for s in raw.split(",") if s.strip()] - - def oauth_enabled(self) -> bool: + def use_beta_tools(self) -> bool: """ - Check to enable the remote OAuth. - Returns: - bool: Whether the remote OAuth should be enabled or not. - """ - return os.environ.get(f"{ENV_PREFIX}OAUTH_ENABLED", "false").lower() == "true" + Check if beta tools should be enabled. + Defaults to False. - def oauth_resource_server_uri(self) -> str: - """ - Get the string value for the remote OAuth Server Resource URI. Returns: - str: The OAuth Resource URI. + bool: True if beta tools should be enabled, False otherwise. """ - return os.environ.get(f"{ENV_PREFIX}OAUTH_RESOURCE_SERVER_URI", "[]") + return os.environ.get(f"{ENV_PREFIX}ENABLE_BETA_TOOLS", "false").lower() == "true" def get_app_config() -> AppConfig: diff --git a/utils/auth/auth_config.py b/utils/auth/auth_config.py deleted file mode 100644 index 38bfcf9..0000000 --- a/utils/auth/auth_config.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Auth configuration for the MCP server. -""" - -from typing import Optional - -from fastmcp.server.auth import OAuthProvider -from fastmcp.server.auth.providers.jwt import JWTVerifier -from pydantic import AnyHttpUrl -from utils.app_config import AppConfig - - -# FIXME: Need to implement OAuthProxy in v 2.12.0 not yet available in Pip repo -# https://gofastmcp.com/servers/auth/oauth-proxy -def obtain_remote_auth_provider(app_config: AppConfig) -> OAuthProvider: - # Configure token validation for your identity provider - - # Create the remote auth provider - remote_auth_provider: Optional[OAuthProvider] = None - - if app_config.oauth_enabled(): - token_verifier = JWTVerifier( - jwks_uri=app_config.oauth_jwks_uri(), - issuer=app_config.oauth_auth_endpoint(), - audience=app_config.oauth_audience(), - required_scopes=app_config.oauth_required_scopes(), - ) - - remote_auth_provider = OAuthProvider( - token_verifier=token_verifier, - upstream_authorization_endpoint=AnyHttpUrl(app_config.oauth_auth_endpoint()), - upstream_token_endpoint=AnyHttpUrl(app_config.oauth_token_endpoint()), - upstream_client_id=app_config.oauth_client_id(), - upstream_client_secret=app_config.oauth_client_secret(), - base_url=app_config.mcp_base_url(), - redirect_path=app_config.oauth_redirect_path(), - allowed_client_redirect_uris=app_config.oauth_allowed_client_redirect_uris(), - ) - - return remote_auth_provider diff --git a/utils/auth/middleware/auth.py b/utils/auth/middleware/auth.py index fb80b9d..4ac3e29 100644 --- a/utils/auth/middleware/auth.py +++ b/utils/auth/middleware/auth.py @@ -4,6 +4,8 @@ import logging import os +from typing import Any +import mcp.types as mt from http import HTTPStatus from starlette.requests import Request from fastmcp.server.middleware import Middleware, MiddlewareContext, CallNext @@ -25,8 +27,7 @@ ) log = logging.getLogger(__name__) -# TODO: Define the correct message notifications -INIT_NOTIFICATIONS = ["notifications/initialized", "tools/list", "tools/call"] +TOOLS_CALLS_NOTIFICATIONS = ["tools/list", "tools/call", "notifications/tools/list_changed"] def _get_permissions(context: MiddlewareContext) -> None: @@ -43,7 +44,7 @@ def _get_permissions(context: MiddlewareContext) -> None: response = legacy_api_client.get_me_permissions() if response.status != HTTPStatus.OK: log.error(f"Error fetching permissions: Status {response.status}") - raise Exception("Failed to fetch user permissions. Check your current Token and permissions.") + raise Exception("Failed to fetch Sysdig user permissions. Check your current Token and permissions.") context.fastmcp_context.set_state("permissions", response.json().get("permissions", [])) except Exception as e: log.error(f"Error fetching permissions: {e}") @@ -86,10 +87,9 @@ async def _save_api_instances(context: MiddlewareContext, app_config: AppConfig) if context.fastmcp_context.get_state("transport_method") in ["streamable-http", "sse"]: request: Request = get_http_request() - # TODO: Check for the custom Authorization header or use the default. Will be relevant with the Oauth provider config. - auth_header = request.headers.get("X-Sysdig-Token", request.headers.get("Authorization")) + auth_header = request.headers.get("X-Sysdig-Token") if not auth_header or not auth_header.startswith("Bearer "): - err = "Missing or invalid Authorization header" + err = "Missing or invalid `X-Sysdig-Token` Authorization Bearer token header." log.error(err) raise Exception(err) @@ -125,7 +125,11 @@ def __init__(self, app_config: AppConfig): self.app_config = app_config # TODO: Evaluate if init the clients and perform auth only on the `notifications/initialized` event - async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> CallNext: + async def on_request( + self, + context: MiddlewareContext[mt.Request], + call_next: CallNext[mt.Request, Any], + ) -> Any: """ Handle incoming messages and initialize the Sysdig API client if needed. Returns: @@ -135,12 +139,13 @@ async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> C """ # Save transport method in context if not context.fastmcp_context.get_state("transport_method"): - transport_method = os.environ.get("MCP_TRANSPORT", self.app_config.transport()).lower() + transport_method = os.environ.get("SYSDIG_MCP_TRANSPORT", self.app_config.transport()).lower() context.fastmcp_context.set_state("transport_method", transport_method) try: - # TODO: Currently not able to get the notifications/initialized only that should be the method that initializes - # the API instances for the whole session, we need to check if its possible - if context.method in INIT_NOTIFICATIONS: + # TODO: Check if with the current protocol that persists a session between client and server + # there is an object where we can save the api_instances instead of initializing them on every request + # Currently we initialize the clients on every request that requires it and keep them in the context state + if context.method in TOOLS_CALLS_NOTIFICATIONS: await _save_api_instances(context, self.app_config) return await call_next(context) diff --git a/utils/mcp_server.py b/utils/mcp_server.py index 0461274..3cc07ca 100644 --- a/utils/mcp_server.py +++ b/utils/mcp_server.py @@ -15,12 +15,11 @@ from fastmcp import FastMCP, Settings from fastmcp.resources import HttpResource, TextResource -from utils.auth.auth_config import obtain_remote_auth_provider from utils.auth.middleware.auth import CustomMiddleware from tools.events_feed.tool import EventsFeedTools from tools.inventory.tool import InventoryTools from tools.vulnerability_management.tool import VulnerabilityManagementTools -from tools.sysdig_sage.tool import SageTools +from tools.sysdig_sysql.tool import SysqlTools from tools.cli_scanner.tool import CLIScannerTool # Application config loader @@ -37,10 +36,11 @@ def __init__(self, app_config: AppConfig): self.mcp_instance: Optional[FastMCP] = FastMCP( name="Sysdig MCP Server", - instructions="Provides Sysdig Secure tools and resources.", - include_tags={"sysdig_secure"}, + instructions="Provides Sysdig Secure tools and resources to your AI apps.", + include_tags={"sysdig_secure"}, # Tags are used to enable tools middleware=middlewares, - auth=obtain_remote_auth_provider(app_config), + # Auth is handled through environment variables and middleware + # refer to https://gofastmcp.com/integrations/github#provider-selection ) # Add tools to the MCP server self.add_tools() @@ -170,7 +170,9 @@ def add_tools(self) -> None: name_or_fn=inventory_tools.tool_get_resource, name="get_resource", description="Retrieve a single inventory resource by its unique hash identifier.", - tags={"inventory", "sysdig_secure"}, + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"inventory", "sysdig_secure", "CA"}, ) # Register the Sysdig Vulnerability Management tools @@ -185,49 +187,65 @@ def add_tools(self) -> None: (Supports pagination using cursor). """ ), - tags={"vulnerability", "sysdig_secure"}, + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"vulnerability", "sysdig_secure", "CA"}, ) self.mcp_instance.tool( name_or_fn=vulnerability_tools.tool_list_accepted_risks, name="list_accepted_risks", description="List all accepted risks. Supports filtering and pagination.", - tags={"vulnerability", "sysdig_secure"}, + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"vulnerability", "sysdig_secure", "CA"}, ) self.mcp_instance.tool( name_or_fn=vulnerability_tools.tool_get_accepted_risk, name="get_accepted_risk", description="Retrieve a specific accepted risk by its ID.", - tags={"vulnerability", "sysdig_secure"}, + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"vulnerability", "sysdig_secure", "CA"}, ) self.mcp_instance.tool( name_or_fn=vulnerability_tools.tool_list_registry_scan_results, name="list_registry_scan_results", description="List registry scan results. Supports filtering and pagination.", - tags={"vulnerability", "sysdig_secure"}, + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"vulnerability", "sysdig_secure", "CA"}, ) self.mcp_instance.tool( name_or_fn=vulnerability_tools.tool_get_vulnerability_policy, name="get_vulnerability_policy_by_id", description="Retrieve a specific vulnerability policy by its ID.", - tags={"vulnerability", "sysdig_secure"}, + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"vulnerability", "sysdig_secure", "CA"}, ) self.mcp_instance.tool( name_or_fn=vulnerability_tools.tool_list_vulnerability_policies, name="list_vulnerability_policies", description="List all vulnerability policies. Supports filtering, pagination, and sorting.", - tags={"vulnerability", "sysdig_secure"}, + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"vulnerability", "sysdig_secure", "CA"}, ) self.mcp_instance.tool( name_or_fn=vulnerability_tools.tool_list_pipeline_scan_results, name="list_pipeline_scan_results", description="List pipeline scan results (e.g., built images). Supports pagination and filtering.", - tags={"vulnerability", "sysdig_secure"}, + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"vulnerability", "sysdig_secure", "CA"}, ) self.mcp_instance.tool( name_or_fn=vulnerability_tools.tool_get_scan_result, name="get_scan_result", description="Retrieve a specific scan result (registry/runtime/pipeline).", - tags={"vulnerability", "sysdig_secure"}, + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"vulnerability", "sysdig_secure", "CA"}, ) self.mcp_instance.add_prompt( Prompt.from_function( @@ -238,19 +256,31 @@ def add_tools(self) -> None: ) ) - # Register the Sysdig Sage tools - self.log.info("Adding Sysdig Sage Tools...") - sysdig_sage_tools = SageTools(self.app_config) + # Register the Sysdig Sysql tools + self.log.info("Adding Sysdig Sysql Tools...") + sysdig_sysql_tools = SysqlTools(self.app_config) self.mcp_instance.tool( - name_or_fn=sysdig_sage_tools.tool_sage_to_sysql, + name_or_fn=sysdig_sysql_tools.tool_sysql_sage_query, name="sysdig_sysql_sage_query", description=( """ - Query Sysdig Sage to generate a SysQL query based on a natural language question, - execute it against the Sysdig API, and return the results. + Get a Sysdig Sysql query through the help of Sysdig Sage based on a natural language question. + """ + ), + enabled=self.app_config.use_beta_tools(), + meta={"status": "beta feature, use with caution"}, + tags={"sysql", "sysdig_secure", "CA"}, + ) + + self.mcp_instance.tool( + name_or_fn=sysdig_sysql_tools.tool_sysql_execute_query, + name="sysdig_sysql_execute_query", + description=( + """ + Execute a Sysdig Sysql query against the Sysdig API and return the results. """ ), - tags={"sage", "sysdig_secure"}, + tags={"sysql", "sysdig_secure"}, ) if self.app_config.transport() == "stdio": diff --git a/utils/sysdig/helpers.py b/utils/sysdig/helpers.py index db687db..f069c8b 100644 --- a/utils/sysdig/helpers.py +++ b/utils/sysdig/helpers.py @@ -6,7 +6,7 @@ TOOL_PERMISSIONS = { "inventory": ["explore.read"], "vulnerability": ["scanning.read", "secure.vm.scanresults.read"], - "sage": ["sage.exec", "sage.manage.exec"], + "sysql": ["sage.exec", "sage.manage.exec", "explore.read"], "cli-scanner": ["secure.vm.cli-scanner.exec"], "threat-detection": ["custom-events.read"], } diff --git a/uv.lock b/uv.lock index 35ff3cb..0736b2d 100644 --- a/uv.lock +++ b/uv.lock @@ -360,8 +360,8 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.11.3" -source = { registry = "https://pypi.org/simple" } +version = "2.12.4.dev24+7e4aaa8" +source = { git = "https://github.com/jlowin/fastmcp?rev=7e4aaa86969e04a05330c6d4e55f9b6c647c8756#7e4aaa86969e04a05330c6d4e55f9b6c647c8756" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, @@ -375,10 +375,6 @@ dependencies = [ { name = "python-dotenv" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/80/13aec687ec21727b0fe6d26c6fe2febb33ae24e24c980929a706db3a8bc2/fastmcp-2.11.3.tar.gz", hash = "sha256:e8e3834a3e0b513712b8e63a6f0d4cbe19093459a1da3f7fbf8ef2810cfd34e3", size = 2692092, upload-time = "2025-08-11T21:38:46.493Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/05/63f63ad5b6789a730d94b8cb3910679c5da1ed5b4e38c957140ac9edcf0e/fastmcp-2.11.3-py3-none-any.whl", hash = "sha256:28f22126c90fd36e5de9cc68b9c271b6d832dcf322256f23d220b68afb3352cc", size = 260231, upload-time = "2025-08-11T21:38:44.746Z" }, -] [[package]] name = "fsspec" @@ -630,7 +626,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.13.1" +version = "1.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -645,9 +641,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/3c/82c400c2d50afdac4fbefb5b4031fd327e2ad1f23ccef8eee13c5909aa48/mcp-1.13.1.tar.gz", hash = "sha256:165306a8fd7991dc80334edd2de07798175a56461043b7ae907b279794a834c5", size = 438198, upload-time = "2025-08-22T09:22:16.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/e9/242096400d702924b49f8d202c6ded7efb8841cacba826b5d2e6183aef7b/mcp-1.14.1.tar.gz", hash = "sha256:31c4406182ba15e8f30a513042719c3f0a38c615e76188ee5a736aaa89e20134", size = 454944, upload-time = "2025-09-18T13:37:19.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/3f/d085c7f49ade6d273b185d61ec9405e672b6433f710ea64a90135a8dd445/mcp-1.13.1-py3-none-any.whl", hash = "sha256:c314e7c8bd477a23ba3ef472ee5a32880316c42d03e06dcfa31a1cc7a73b65df", size = 161494, upload-time = "2025-08-22T09:22:14.705Z" }, + { url = "https://files.pythonhosted.org/packages/8e/11/d334fbb7c2aeddd2e762b86d7a619acffae012643a5738e698f975a2a9e2/mcp-1.14.1-py3-none-any.whl", hash = "sha256:3b7a479e8e5cbf5361bdc1da8bc6d500d795dc3aff44b44077a363a7f7e945a4", size = 163809, upload-time = "2025-09-18T13:37:18.165Z" }, ] [package.optional-dependencies] @@ -1265,7 +1261,7 @@ wheels = [ [[package]] name = "sysdig-mcp-server" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "dask" }, @@ -1283,6 +1279,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "fastmcp" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -1292,8 +1289,8 @@ dev = [ requires-dist = [ { name = "dask", specifier = "~=2025.4" }, { name = "fastapi", specifier = "~=0.116.1" }, - { name = "fastmcp", specifier = "~=2.11" }, - { name = "mcp", extras = ["cli"], specifier = "~=1.12" }, + { name = "fastmcp", git = "https://github.com/jlowin/fastmcp?rev=7e4aaa86969e04a05330c6d4e55f9b6c647c8756" }, + { name = "mcp", extras = ["cli"], specifier = "~=1.14.0" }, { name = "oauthlib", specifier = "~=3.2" }, { name = "python-dotenv", specifier = "~=1.1" }, { name = "pyyaml", specifier = "~=6.0" }, @@ -1305,6 +1302,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "fastmcp", git = "https://github.com/jlowin/fastmcp?rev=7e4aaa86969e04a05330c6d4e55f9b6c647c8756" }, { name = "pytest", specifier = "~=8.4" }, { name = "pytest-cov", specifier = "~=6.2" }, { name = "ruff", specifier = "~=0.12.1" },