diff --git a/.github/workflows/helm_test.yaml b/.github/workflows/helm_test.yaml index 4427b67..d8ae1e3 100644 --- a/.github/workflows/helm_test.yaml +++ b/.github/workflows/helm_test.yaml @@ -4,25 +4,20 @@ name: Lint & Test helm chart on: pull_request: branches: - - main - - develop - - feature/** - - release/** - - hotfix/** + - beta paths: - 'charts/**' push: branches: - main - - develop - - feature/** - - release/** - - hotfix/** + - beta paths: - 'charts/**' + workflow_call: + workflow_dispatch: concurrency: - group: '${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + group: 'helm-test-${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true jobs: diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 9d18d6e..e0e65c8 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,6 +5,7 @@ on: push: branches: - main + - beta paths: - pyproject.toml - Dockerfile @@ -15,7 +16,7 @@ on: workflow_dispatch: concurrency: - group: '${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + group: 'publish-${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true jobs: @@ -44,7 +45,7 @@ jobs: - name: Extract version id: extract_version run: | - VERSION=$(grep 'version =' pyproject.toml | sed -e 's/version = "\(.*\)"/\1/')-$(echo $GITHUB_SHA | cut -c1-7) + VERSION=$(grep 'version =' pyproject.toml | sed -e 's/version = "\(.*\)"/\1/') echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" TAG=v$(grep 'version =' pyproject.toml | sed -e 's/version = "\(.*\)"/\1/') echo "TAG=$TAG" >> "$GITHUB_OUTPUT" @@ -84,23 +85,34 @@ jobs: runs-on: ubuntu-latest needs: push_to_registry steps: - - name: Check out the repo + - name: Check out repository uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} # required for better experience using pre-releases + fetch-depth: '0' # Required due to the way Git works, without it this action won't be able to find any or the correct tags - name: Get tag version id: semantic_release - uses: anothrNick/github-tag-action@1.73.0 + uses: anothrNick/github-tag-action@1.71.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEFAULT_BUMP: "patch" - TAG_CONTEXT: ${{ (github.base_ref != 'main') && 'branch' || 'repo' }} + TAG_CONTEXT: 'repo' + WITH_V: true PRERELEASE_SUFFIX: "beta" - PRERELEASE: ${{ (github.base_ref != 'main') && 'true' || 'false' }} - DRY_RUN: false - INITIAL_VERSION: ${{ needs.push_to_registry.outputs.tag }} + PRERELEASE: ${{ (github.base_ref == 'beta') && 'true' || (github.base_ref == 'main') && 'false' || (github.base_ref == 'integration') && 'false' || 'true' }} - name: Summary run: | echo "## Release Summary - Tag: ${{ steps.semantic_release.outputs.tag }} - Docker Image: ghcr.io/sysdiglabs/sysdig-mcp-server:v${{ needs.push_to_registry.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + test_helm_chart: + name: Test Helm Chart + needs: push_to_registry + permissions: + contents: read # required for actions/checkout + pull-requests: write # required for creating a PR with the chart changes + uses: ./.github/workflows/helm_test.yaml + secrets: inherit diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index abde233..1755d09 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -2,20 +2,6 @@ name: Test on: - push: - branches: - - main - - develop - - feature/** - - release/** - - hotfix/** - paths: - - pyproject.toml - - Dockerfile - - '*.py' - - tests/** - - tools/** - - utils/** pull_request: paths: - pyproject.toml @@ -25,9 +11,10 @@ on: - tools/** - utils/** workflow_call: + workflow_dispatch: concurrency: - group: '${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + group: 'tests-${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true jobs: @@ -59,18 +46,18 @@ jobs: - name: Run Unit Tests run: make test - pre_release: - name: Tag Release + check_version: + name: Check Version runs-on: ubuntu-latest needs: test permissions: contents: write # required for creating a tag steps: - - name: Check out the repo + - name: Check out repository uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} # checkout the correct branch name - fetch-depth: 0 + ref: ${{ github.sha }} # required for better experience using pre-releases + fetch-depth: '0' # Required due to the way Git works, without it this action won't be able to find any or the correct tags - name: Extract current version id: pyproject_version @@ -80,15 +67,15 @@ jobs: - name: Get tag version id: semantic_release - uses: anothrNick/github-tag-action@1.73.0 + uses: anothrNick/github-tag-action@1.71.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEFAULT_BUMP: "patch" - TAG_CONTEXT: ${{ (github.base_ref != 'main') && 'branch' || 'repo' }} + TAG_CONTEXT: 'repo' + WITH_V: true PRERELEASE_SUFFIX: "beta" - PRERELEASE: ${{ (github.base_ref != 'main') && 'true' || 'false' }} + PRERELEASE: ${{ (github.base_ref == 'beta') && 'true' || (github.base_ref == 'main') && 'false' || (github.base_ref == 'integration') && 'false' || 'true' }} DRY_RUN: true - INITIAL_VERSION: ${{ steps.pyproject_version.outputs.TAG }} - name: Compare versions run: | diff --git a/Dockerfile b/Dockerfile index 97a20c5..472a84f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,4 +33,6 @@ COPY --from=builder --chown=app:app /app/app_config.yaml /app RUN pip install /app/sysdig_mcp_server.tar.gz +USER 1001:1001 + ENTRYPOINT ["sysdig-mcp-server"] diff --git a/README.md b/README.md index aa049d0..2060249 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - [Description](#description) - [Quickstart Guide](#quickstart-guide) - [Available Tools](#available-tools) + - [Available Resources](#available-resources) - [Requirements](#requirements) - [UV Setup](#uv-setup) - [Configuration](#configuration) @@ -124,6 +125,13 @@ Get up and running with the Sysdig MCP Server quickly using our pre-built Docker +### Available Resources + +- Sysdig Secure Vulnerability Management Overview: + - VM documentation based on the following [url](https://docs.sysdig.com/en/sysdig-secure/vulnerability-management/) +- Sysdig Filter Query Language Instructions: + - Sysdig Filter Query Language for different API endpoint filters + ## Requirements ### UV Setup diff --git a/charts/sysdig-mcp/Chart.yaml b/charts/sysdig-mcp/Chart.yaml index 4fe758e..4dd3d2b 100644 --- a/charts/sysdig-mcp/Chart.yaml +++ b/charts/sysdig-mcp/Chart.yaml @@ -20,10 +20,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.1 +version: 0.1.2 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.1.1" +appVersion: "v0.1.2" diff --git a/charts/sysdig-mcp/values.yaml b/charts/sysdig-mcp/values.yaml index 4993f5d..d10247b 100644 --- a/charts/sysdig-mcp/values.yaml +++ b/charts/sysdig-mcp/values.yaml @@ -8,7 +8,7 @@ image: repository: ghcr.io/sysdiglabs/sysdig-mcp-server pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. - tag: "v0.1.1-e789d6e" + tag: "v0.1.2" imagePullSecrets: [] nameOverride: "" @@ -46,13 +46,11 @@ podLabels: {} podSecurityContext: {} # fsGroup: 2000 -securityContext: {} - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: true - # runAsNonRoot: true - # runAsUser: 1000 +securityContext: + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 service: type: ClusterIP diff --git a/main.py b/main.py index 0ff9ee6..1abc108 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,9 @@ """ import os -import asyncio +import signal +import sys +import logging from dotenv import load_dotenv # Application config loader @@ -12,16 +14,34 @@ # Register all tools so they attach to the MCP server from utils.mcp_server import run_stdio, run_http +# Set up logging +logging.basicConfig( + format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", + level=os.environ.get("LOGLEVEL", "ERROR"), +) +log = logging.getLogger(__name__) + # Load environment variables from .env load_dotenv() app_config = get_app_config() +def handle_signals(): + def signal_handler(sig, frame): + log.info(f"Received signal {sig}, shutting down...") + os._exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGHUP, signal_handler) + + def main(): # Choose transport: "stdio" or "sse" (HTTP/SSE) + handle_signals() transport = os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower() - print(""" + log.info(""" ▄▖ ▌▘ ▖ ▖▄▖▄▖ ▄▖ ▚ ▌▌▛▘▛▌▌▛▌ ▛▖▞▌▌ ▙▌ ▚ █▌▛▘▌▌█▌▛▘ ▄▌▙▌▄▌▙▌▌▙▌ ▌▝ ▌▙▖▌ ▄▌▙▖▌ ▚▘▙▖▌ @@ -29,11 +49,14 @@ def main(): """) if transport == "stdio": # Run MCP server over STDIO (local) - asyncio.run(run_stdio()) + run_stdio() else: # Run MCP server over streamable HTTP by default run_http() if __name__ == "__main__": - main() + try: + sys.exit(main()) + except KeyboardInterrupt: + os._exit(0) diff --git a/pyproject.toml b/pyproject.toml index d22daf5..0009b1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sysdig-mcp-server" -version = "0.1.1" +version = "0.1.2" description = "Sysdig MCP Server" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/conftest.py b/tests/conftest.py index aa73121..29f8dac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,14 @@ from fastmcp import FastMCP +class MockMCP(FastMCP): + """ + Mock class for FastMCP + """ + + pass + + def util_load_json(path): """ Utility function to load a JSON file from the given path. @@ -42,8 +50,8 @@ def mock_ctx(): Returns: Context: A mocked Context object with 'fastmcp' tags. """ - fastmcp: FastMCP = FastMCP( - name="Test", + + fastmcp: MockMCP = MockMCP( tags=["sysdig", "mcp", "stdio"], ) ctx = Context(fastmcp=fastmcp) diff --git a/tools/events_feed/tool.py b/tools/events_feed/tool.py index 9a2b8ad..18ada11 100644 --- a/tools/events_feed/tool.py +++ b/tools/events_feed/tool.py @@ -14,6 +14,7 @@ from fastmcp import Context from sysdig_client import ApiException from fastmcp.prompts.prompt import PromptMessage, TextContent +from fastmcp.exceptions import ToolError from starlette.requests import Request from sysdig_client.api import SecureEventsApi from utils.sysdig.old_sysdig_api import OldSysdigApi @@ -99,7 +100,7 @@ def tool_get_event_info(self, event_id: str, ctx: Context) -> dict: response = create_standard_response(results=raw, execution_time_ms=execution_time) return response - except ApiException as e: + except ToolError as e: logging.error("Exception when calling SecureEventsApi->get_event_v1: %s\n" % e) raise e @@ -181,7 +182,7 @@ def tool_list_runtime_events( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling SecureEventsApi->get_events_v1: {e}\n") raise e @@ -225,7 +226,7 @@ def tool_get_event_process_tree(self, ctx: Context, event_id: str) -> Dict[str, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling Sysdig Sage API to get process tree: {e}") raise e diff --git a/tools/inventory/tool.py b/tools/inventory/tool.py index 4037721..f426732 100644 --- a/tools/inventory/tool.py +++ b/tools/inventory/tool.py @@ -9,6 +9,7 @@ from pydantic import Field from fastmcp.server.dependencies import get_http_request from fastmcp import Context +from fastmcp.exceptions import ToolError from starlette.requests import Request from sysdig_client import ApiException from sysdig_client.api import InventoryApi @@ -18,8 +19,8 @@ from utils.query_helpers import create_standard_response # Configure logging -log = logging.getLogger(__name__) logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) +log = logging.getLogger(__name__) # Load app config (expects keys: mcp.host, mcp.port, mcp.transport) app_config = get_app_config() @@ -69,12 +70,78 @@ def tool_list_resources( Field( description=( """ - Sysdig Secure filter expression for inventory resources, - base filter: platform in ("GCP", "AWS", "Azure", "Kubernetes"), - Examples: - not isExposed exists; category in ("IAM") and isExposed exists; category in ("IAM","Audit & Monitoring") + Sysdig Secure query filter expression to filter inventory resources. + + Use the resource://filter-query-language to get the expected filter expression format. + + List of supported fields: + - accountName + - accountId + - cluster + - externalDNS + - distribution + - integrationName + - labels + - location + - name + - namespace + - nodeType + - osName + - osImage + - organization + - platform + - control.accepted + - policy + - control.severity + - control.failed + - policy.failed + - policy.passed + - projectName + - projectId + - region + - repository + - resourceOrigin + - type + - subscriptionName + - subscriptionId + - sourceType + - version + - zone + - category + - isExposed + - validatedExposure + - arn + - resourceId + - container.name + - architecture + - baseOS + - digest + - imageId + - os + - container.imageName + - image.registry + - image.tag + - package.inUse + - package.info + - package.path + - package.type + - vuln.cvssScore + - vuln.hasExploit + - vuln.hasFix + - vuln.name + - vuln.severity + - machineImage """ - ) + ), + examples=[ + 'zone in ("zone1") and machineImage = "ami-0b22b359fdfabe1b5"', + '(projectId = "1235495521" or projectId = "987654321") and vuln.severity in ("Critical")', + 'vuln.name in ("CVE-2023-0049")', + 'vuln.cvssScore >= "3"', + 'container.name in ("sysdig-container") and not labels exists', + 'imageId in ("sha256:3768ff6176e29a35ce1354622977a1e5c013045cbc4f30754ef3459218be8ac")', + 'platform in ("GCP", "AWS", "Azure", "Kubernetes") and isExposed exists', + ], ), ] = 'platform in ("GCP", "AWS", "Azure", "Kubernetes")', page_number: Annotated[int, Field(ge=1, description="Page number for pagination (1-based index)")] = 1, @@ -112,7 +179,7 @@ def tool_list_resources( response = create_standard_response(results=api_response, execution_time_ms=execution_time) return response - except ApiException as e: + except ToolError as e: logging.error("Exception when calling InventoryApi->get_resources: %s\n" % e) raise e @@ -141,6 +208,6 @@ def tool_get_resource( response = create_standard_response(results=api_response, execution_time_ms=execution_time) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling InventoryApi->get_resource: {e}") raise e diff --git a/tools/sysdig_sage/tool.py b/tools/sysdig_sage/tool.py index fc33153..516d926 100644 --- a/tools/sysdig_sage/tool.py +++ b/tools/sysdig_sage/tool.py @@ -9,6 +9,7 @@ import time from typing import Any, Dict from fastmcp import Context +from fastmcp.exceptions import ToolError from utils.sysdig.old_sysdig_api import OldSysdigApi from starlette.requests import Request from fastmcp.server.dependencies import get_http_request @@ -74,7 +75,7 @@ async def tool_sysdig_sage(self, ctx: Context, question: str) -> Dict[str, Any]: Dict: JSON-decoded response of the executed SysQL query, or an error object. Raises: - Exception: If the SysQL query generation or execution fails. + ToolError: If the SysQL query generation or execution fails. Examples: # tool_sysdig_sage(question="Match Cloud Resource affected by Critical Vulnerability") @@ -87,8 +88,8 @@ async def tool_sysdig_sage(self, ctx: Context, question: str) -> Dict[str, Any]: old_sysdig_api = self.init_client(config_tags=ctx.fastmcp.tags) sysql_response = await old_sysdig_api.generate_sysql_query(question) if sysql_response.status > 299: - raise Exception(f"Sysdig Sage returned an error: {sysql_response.status} - {sysql_response.data}") - except Exception as e: + raise ToolError(f"Sysdig Sage returned an error: {sysql_response.status} - {sysql_response.data}") + except ToolError as e: log.error(f"Failed to generate SysQL query: {e}") raise e json_resp = sysql_response.json() if sysql_response.data else {} @@ -107,6 +108,6 @@ async def tool_sysdig_sage(self, ctx: Context, question: str) -> Dict[str, Any]: ) return response - except Exception as e: + except ToolError as e: log.error(f"Failed to execute SysQL query: {e}") raise e diff --git a/tools/vulnerability_management/tool.py b/tools/vulnerability_management/tool.py index b70f6c4..7a0bed6 100644 --- a/tools/vulnerability_management/tool.py +++ b/tools/vulnerability_management/tool.py @@ -12,6 +12,7 @@ from sysdig_client.models.scan_result_response import ScanResultResponse from sysdig_client.models.get_policy_response import GetPolicyResponse from fastmcp.prompts.prompt import PromptMessage, TextContent +from fastmcp.exceptions import ToolError from starlette.requests import Request from sysdig_client.api import VulnerabilityManagementApi from fastmcp.server.dependencies import get_http_request @@ -73,14 +74,36 @@ def tool_list_runtime_vulnerabilities( Field( description=( """ - Logical filter expression to select runtime vulnerabilities. - Supports operators: =, !=, in, exists, contains, startsWith. Combine with and/or/not. - Key fields include: asset.type, aws.account.id, aws.host.name, aws.region, - cloudProvider, cloudProvider.account.id, cloudProvider.region, - gcp.instance.id, gcp.instance.zone, gcp.project.id, gcp.project.numericId, - host.hostName, kubernetes.cluster.name, kubernetes.namespace.name, kubernetes.node.name, - kubernetes.pod.container.name, kubernetes.workload.name, kubernetes.workload.type, - workload.name, workload.orchestrator + Sysdig Secure query filter expression to filter runtime vulnerability scan results. + + Use the resource://filter-query-language to get the expected filter expression format. + + Key fields include: + - asset.type + - aws.account.id + - aws.host.name + - aws.region + - cloudProvider + - cloudProvider.account.id + - cloudProvider.region + - gcp.instance.id + - gcp.instance.zone + - gcp.project.id + - gcp.project.numericId + - host.hostName + - kubernetes.cluster.name + - kubernetes.namespace.name + - kubernetes.node.name + - kubernetes.pod.container.name + - kubernetes.workload.name + - kubernetes.workload.type + - workload.name + - workload.orchestrator + + The supported fields are all the fields of the Scope above, plus:: + - freeText + - hasRunningVulns + - hasRunningVulns. """ ), examples=[ @@ -138,9 +161,9 @@ def tool_list_runtime_vulnerabilities( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->scanner_api_service_list_runtime_results: {e}") - return {"error": str(e), "cursor": None} + raise e def tool_list_accepted_risks( self, @@ -179,7 +202,7 @@ def tool_list_accepted_risks( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->get_accepted_risks_v1: {e}") raise e @@ -197,7 +220,7 @@ def tool_get_accepted_risk(self, ctx: Context, accepted_risk_id: str) -> dict: vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) response = vulnerability_api.get_accepted_risk_v1(accepted_risk_id) return response.model_dump_json() if hasattr(response, "dict") else response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->get_accepted_risk_v1: {e}") raise e @@ -208,20 +231,20 @@ def tool_list_registry_scan_results( Optional[str], Field( description=( - "Logical filter expression to select registry scan results. " - "Supports operators: =, !=, in, exists, contains, startsWith. " - "Combine with and/or/not. " - "Key selectors include: " - '- policyStatus (values "noPolicy", "failed", "passed", "accepted"), ' - "- registry.vendor, registry.name, freeText" + """ + Sysdig Secure query filter expression to filter vulnerability scan results on registries. + + Use the resource://filter-query-language to get the expected filter expression format. + + The supported fields are: + - freeText + - vendor + """ ), examples=[ - 'policyStatus in ("noPolicy") and registry.vendor = "harbor"', - 'registry.vendor = "dockerv2" and registry.name = "index.docker.io"', - 'registry.vendor = "harbor" and freeText in ("redis")', - 'policyStatus in ("failed") and registry.vendor = "harbor"' - 'policyStatus in ("passed", "accepted") and registry.vendor = "harbor"', - 'registry.vendor = "dockerv2" and registry.name = "registry.access.redhat.com"', + 'freeText = "alpine:latest" and vendor = "docker"', + 'vendor = "ecr"', + 'vendor = "harbor" and freeText in ("redis")', ], ), ] = None, @@ -235,13 +258,10 @@ def tool_list_registry_scan_results( filter (Optional[str]): Logical filter expression to select registry scan results. Supports operators: =, !=, in, exists, contains, startsWith. Combine with and/or/not. - Key selectors include: - - policyStatus (values "noPolicy", "failed", "passed", "accepted"), - - registry.vendor, registry.name, freeText + Key selectors include: freeText (string), vendor (e.g., "docker", "ecr", "harbor"). Examples: - - policyStatus in ("noPolicy") and registry.vendor = "harbor" - - registry.vendor = "dockerv2" and registry.name = "index.docker.io" - - registry.vendor = "harbor" and freeText in ("redis") + - freeText = "alpine:latest" and vendor = "docker" + - vendor = "ecr" limit (int): Maximum number of results to return. cursor (Optional[str]): Pagination cursor. If None, returns the first page. @@ -262,7 +282,7 @@ def tool_list_registry_scan_results( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->scanner_api_service_list_registry_results: {e}") raise e @@ -283,7 +303,7 @@ def tool_get_vulnerability_policy( vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) response: GetPolicyResponse = vulnerability_api.secure_vulnerability_v1_policies_policy_id_get(policy_id) return response.model_dump_json() if hasattr(response, "dict") else response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_policies_policy_id_get: {e}") raise e @@ -323,7 +343,7 @@ def tool_list_vulnerability_policies( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_policies_get: {e}") raise e @@ -335,17 +355,18 @@ def tool_list_pipeline_scan_results( Optional[str], Field( description=( - "Logical filter expression to select pipeline scan results. " - "Supports operators: =, !=, in, exists, contains, startsWith. " - "Combine with and/or/not. " - "Key selectors include: " - "- policyEvaluationsPassed (true/false), " - "- freeText (string)." + """ + Sysdig Secure query filter expression to filter vulnerability scan results on pipelines. + + Use the resource://filter-query-language to get the expected filter expression format. + + The supported fields are: + - freeText + """ ), examples=[ - "policyEvaluationsPassed = true", + 'freeText in ("nginx")', 'freeText in ("ubuntu")', - 'policyEvaluationsPassed = false and freeText in ("ubuntu")', ], ), ] = None, @@ -387,7 +408,7 @@ def tool_list_pipeline_scan_results( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_policies_get: {e}") raise e @@ -405,7 +426,7 @@ def tool_get_scan_result(self, ctx: Context, scan_id: str) -> dict: vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) resp: ScanResultResponse = vulnerability_api.secure_vulnerability_v1_results_result_id_get(scan_id) return resp.model_dump_json() if hasattr(resp, "dict") else resp - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_results_result_id_get: {e}") raise e diff --git a/utils/mcp_server.py b/utils/mcp_server.py index e951e91..0b49e21 100644 --- a/utils/mcp_server.py +++ b/utils/mcp_server.py @@ -5,12 +5,14 @@ import logging import os +import asyncio from typing import Optional import uvicorn from starlette.requests import Request from starlette.responses import JSONResponse, Response from fastapi import FastAPI from fastmcp import FastMCP +from fastmcp.resources import HttpResource, TextResource from utils.middleware.auth import CustomAuthMiddleware from starlette.middleware import Middleware from tools.events_feed.tool import EventsFeedTools @@ -22,11 +24,11 @@ from utils.app_config import get_app_config # Set up logging -log = logging.getLogger(__name__) logging.basicConfig( format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR"), ) +log = logging.getLogger(__name__) # Load app config (expects keys: mcp.host, mcp.port, mcp.transport) app_config = get_app_config() @@ -66,14 +68,23 @@ def get_mcp() -> FastMCP: return _mcp_instance -async def run_stdio(): +def run_stdio(): """ Run the MCP server using STDIO transport. """ mcp = get_mcp() # Add tools to the MCP server add_tools(mcp) - await mcp.run_stdio_async() + # Add resources to the MCP server + add_resources(mcp) + try: + asyncio.run(mcp.run_stdio_async()) + except KeyboardInterrupt: + log.info("Keyboard interrupt received, forcing immediate exit") + os._exit(0) + except Exception as e: + log.error(f"Exception received, forcing immediate exit: {str(e)}") + os._exit(1) def run_http(): @@ -81,6 +92,8 @@ def run_http(): mcp = get_mcp() # Add tools to the MCP server add_tools(mcp) + # Add resources to the MCP server + add_resources(mcp) # Mount the MCP HTTP/SSE app at '/sysdig-mcp-server' mcp_app = mcp.http_app( path="/mcp", transport=os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower(), middleware=middlewares @@ -100,13 +113,27 @@ async def health_check(request: Request) -> Response: """ return JSONResponse({"status": "ok"}) - print(f"Starting {mcp.name} at http://{app_config['app']['host']}:{app_config['app']['port']}/sysdig-mcp-server/mcp") - uvicorn.run( + log.info(f"Starting {mcp.name} at http://{app_config['app']['host']}:{app_config['app']['port']}/sysdig-mcp-server/mcp") + # Use Uvicorn's Config and Server classes for more control + config = uvicorn.Config( app, - port=app_config["app"]["port"], host=app_config["app"]["host"], + port=app_config["app"]["port"], + timeout_graceful_shutdown=1, log_level=os.environ.get("LOGLEVEL", app_config["app"]["log_level"]).lower(), ) + server = uvicorn.Server(config) + + # Override the default behavior + server.force_exit = True # This makes Ctrl+C force exit + try: + asyncio.run(server.serve()) + except KeyboardInterrupt: + log.info("Keyboard interrupt received, forcing immediate exit") + os._exit(0) + except Exception as e: + log.error(f"Exception received, forcing immediate exit: {str(e)}") + os._exit(1) def add_tools(mcp: FastMCP) -> None: @@ -152,10 +179,6 @@ def add_tools(mcp: FastMCP) -> None: description=( """ List inventory resources based on a Sysdig Secure query filter expression with optional pagination.' - Example filters: not isExposed exists; category in ("IAM") and isExposed exists; - category in ("IAM","Audit & Monitoring"); - vuln.hasFix exists and vuln.hasExploit exists and isExposed exists and package.inUse exists and - validatedExposure exists and control.failed in ("Contains AI Package"); """ ), ) @@ -233,3 +256,51 @@ def add_tools(mcp: FastMCP) -> None: """ ), ) + + +def add_resources(mcp: FastMCP) -> None: + """ + Add resources to the MCP server. + Args: + mcp (FastMCP): The FastMCP server instance. + """ + vm_docs = HttpResource( + name="Sysdig Secure Vulnerability Management Overview", + description="Sysdig Secure Vulnerability Management documentation.", + uri="resource://sysdig-secure-vulnerability-management", + url="https://docs.sysdig.com/en/sysdig-secure/vulnerability-management/", + tags=["documentation"], + ) + filter_query_language = TextResource( + name="Sysdig Filter Query Language", + description=( + "Sysdig Filter Query Language documentation. " + "Learn how to filter resources in Sysdig using the Filter Query Language for the API calls." + ), + uri="resource://filter-query-language", + text=( + """ + Query language expressions for filtering results. + The query language allows you to filter resources based on their attributes. + You can use the following operators and functions to build your queries: + + Operators: + - `and` and `not` logical operators + - `=`, `!=` + - `in` + - `contains` and `startsWith` to check partial values of attributes + - `exists` to check if a field exists and not empty + + Note: + The supported fields are going to depend on the API endpoint you are querying. + Check the description of each tool for the supported fields. + + Examples: + - in ("example") and = "example2" + - >= "3" + """ + ), + tags=["query-language", "documentation"], + ) + mcp.add_resource(vm_docs) + mcp.add_resource(filter_query_language) diff --git a/utils/middleware/auth.py b/utils/middleware/auth.py index bc6697d..67ae8b6 100644 --- a/utils/middleware/auth.py +++ b/utils/middleware/auth.py @@ -14,8 +14,8 @@ from utils.app_config import get_app_config # Set up logging -log = logging.getLogger(__name__) logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) +log = logging.getLogger(__name__) # Load app config (expects keys: mcp.host, mcp.port, mcp.transport) app_config = get_app_config() diff --git a/utils/reports/inventory_report.py b/utils/reports/inventory_report.py index d0e17bb..ab6a3fa 100644 --- a/utils/reports/inventory_report.py +++ b/utils/reports/inventory_report.py @@ -6,11 +6,30 @@ import os import dask.dataframe as dd import pandas as pd -from tools.inventory.tool import tool_list_resources +from tools.inventory.tool import InventoryTools +from fastmcp import Context, FastMCP # Configure logging -log = logging.getLogger(__name__) logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) +log = logging.getLogger(__name__) + + +inventory = InventoryTools() + + +class MockMCP(FastMCP): + """ + Mock class for FastMCP + """ + + pass + + +# Mocking MCP context for the inventory tool +fastmcp: MockMCP = MockMCP( + tags=["sysdig", "mcp", "stdio"], +) +ctx = Context(fastmcp=fastmcp) def list_all_resources(filter_exp: str = 'platform in ("GCP")') -> dd.DataFrame: @@ -26,14 +45,16 @@ def list_all_resources(filter_exp: str = 'platform in ("GCP")') -> dd.DataFrame: df: dd.DataFrame = None logging.debug(f"Listing all resources with filter: {filter_exp}") try: - resources = tool_list_resources(filter_exp=filter_exp, page_number=1, page_size=1000) - df = pd.DataFrame.from_records([r.to_dict() for r in resources.data]) - while resources.page.next: + resources = inventory.tool_list_resources(ctx=ctx, filter_exp=filter_exp, page_number=1, page_size=1000) + df = pd.DataFrame.from_records([r for r in resources.get("results", {}).get("data", [])]) + while resources.get("results", {}).get("page", {}).get("next"): # Get the next page of resources - logging.debug(f"Fetching next page: {resources.page.next}") - next_page = resources.page.next - resources = tool_list_resources(filter_exp=filter_exp, page_number=next_page, page_size=1000) - df = dd.concat([df, pd.DataFrame.from_records([r.to_dict() for r in resources.data])], ignore_index=True) + next_page = resources.get("results", {}).get("page", {}).get("next") + logging.debug(f"Fetching next page: {next_page}") + resources = inventory.tool_list_resources(ctx=ctx, filter_exp=filter_exp, page_number=next_page, page_size=1000) + df = dd.concat( + [df, pd.DataFrame.from_records([r for r in resources.get("results", {}).get("data", [])])], ignore_index=True + ) dd.from_pandas return df except Exception as e: diff --git a/uv.lock b/uv.lock index 0b85cab..8e27168 100644 --- a/uv.lock +++ b/uv.lock @@ -732,7 +732,7 @@ wheels = [ [[package]] name = "sysdig-mcp-server" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "dask" },