Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions server/secops-soar/secops_soar_mcp/bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@
"""Bindings for the SOAR client."""

import os
from typing import Optional, Set

import dotenv
from logger_utils import get_logger
from secops_soar_mcp.exceptions import (
SoarAuthError,
SoarConnectionError,
SoarError,
SoarSSLError,
)
from secops_soar_mcp.http_client import HttpClient
from secops_soar_mcp.utils import consts

Expand All @@ -25,20 +32,47 @@
logger = get_logger(__name__)


http_client: HttpClient = None
valid_scopes = set()
http_client: Optional[HttpClient] = None
valid_scopes: Set[str] = set()


async def _get_valid_scopes():
valid_scopes_list = await http_client.get(consts.Endpoints.GET_SCOPES)
if valid_scopes_list is None:
async def _get_valid_scopes() -> Set[str]:
"""Fetches valid scopes from SOAR endpoint during startup."""
try:
valid_scopes_list = await http_client.get(consts.Endpoints.GET_SCOPES)
if valid_scopes_list is None:
raise RuntimeError(
"Failed to fetch valid scopes from SOAR, please make sure you have "
"configured the right SOAR credentials. Shutting down..."
)
return set(valid_scopes_list)
except SoarSSLError as e:
raise RuntimeError(
"Failed to fetch valid scopes from SOAR, please make sure you have configured the right SOAR credentials. Shutting down..."
)
return set(valid_scopes_list)
"Failed to fetch valid scopes from SOAR because TLS certificate "
"verification failed. If you are using macOS (python.org installer), "
"run the 'Install Certificates.command' for your Python version, for example: "
"`/Applications/Python 3.12/Install Certificates.command`. "
"You can also point Python at certifi's CA bundle with "
"`SSL_CERT_FILE=$(python -m certifi)`."
) from e
except SoarAuthError as e:
raise RuntimeError(
"Failed to fetch valid scopes from SOAR: authentication failed. "
"Please make sure you have configured the right SOAR credentials "
"(SOAR_URL, SOAR_APP_KEY). Shutting down..."
) from e
except SoarConnectionError as e:
raise RuntimeError(
f"Failed to fetch valid scopes from SOAR: connection failed to {http_client.base_url}. "
"Please check network connectivity or proxy settings. Shutting down..."
) from e
except SoarError as e:
raise RuntimeError(
f"Failed to fetch valid scopes from SOAR: {e}. Shutting down..."
) from e


async def bind():
async def bind() -> None:
"""Binds global variables."""
global http_client, valid_scopes
http_client = HttpClient(
Expand All @@ -47,7 +81,7 @@ async def bind():
valid_scopes = await _get_valid_scopes()


async def cleanup():
async def cleanup() -> None:
"""Cleans up global variables."""
if http_client is None:
return
Expand Down
48 changes: 48 additions & 0 deletions server/secops-soar/secops_soar_mcp/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Exceptions for Chronicle SecOps SOAR MCP."""

from typing import Optional


class SoarError(Exception):
"""Base exception for all SecOps SOAR errors."""

pass


class SoarConnectionError(SoarError):
"""Raised when connecting to the SOAR server fails (network/DNS/timeout)."""

pass


class SoarSSLError(SoarConnectionError):
"""Raised when TLS/SSL certificate verification fails."""

pass


class SoarAuthError(SoarError):
"""Raised when authentication with the SOAR server fails (e.g. 401 Unauthorized, 403 Forbidden)."""

pass


class SoarHttpError(SoarError):
"""Raised for unexpected HTTP error responses from SOAR."""

def __init__(self, message: str, status_code: Optional[int] = None):
super().__init__(message)
self.status_code = status_code
102 changes: 78 additions & 24 deletions server/secops-soar/secops_soar_mcp/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,45 @@
"""HTTP client for making requests to the SecOps SOAR API."""

import json
from typing import Any, Dict
import ssl
from typing import Any, Dict, Optional

import aiohttp
from logger_utils import get_logger
from secops_soar_mcp.exceptions import (
SoarAuthError,
SoarConnectionError,
SoarError,
SoarHttpError,
SoarSSLError,
)

logger = get_logger(__name__)


def is_ssl_cert_verification_error(exc: Optional[BaseException]) -> bool:
"""Checks whether an exception was caused by an SSL certificate verification failure."""
if exc is None:
return False
current = exc
visited = set()
while current is not None and id(current) not in visited:
visited.add(id(current))
if isinstance(current, (ssl.SSLCertVerificationError, aiohttp.ClientConnectorCertificateError)):
return True
msg = str(current).lower()
if "certificate verify failed" in msg or "certifi" in msg:
return True
for related in (current.__cause__, current.__context__):
if related is not None and is_ssl_cert_verification_error(related):
return True
if hasattr(current, "os_error") and current.os_error is not None:
if is_ssl_cert_verification_error(current.os_error):
return True
current = current.__cause__ or current.__context__
return False


class HttpClient:
"""HTTP client for making requests to the SecOps SOAR API."""

Expand All @@ -35,17 +66,48 @@ def _get_session(self) -> aiohttp.ClientSession:
self._session = aiohttp.ClientSession()
return self._session

async def _get_headers(self):
async def _get_headers(self) -> Dict[str, str]:
headers = {}
if self.app_key:
headers["AppKey"] = self.app_key
return headers

def _handle_error(self, exc: Exception) -> None:
"""Translates low-level aiohttp/network errors into structured SoarError subtypes."""
if is_ssl_cert_verification_error(exc):
logger.error("TLS certificate verification failed: %s", exc)
raise SoarSSLError(
"TLS certificate verification failed while connecting to SOAR. "
"If you are using macOS (python.org installer), run: "
"'/Applications/Python 3.X/Install Certificates.command' "
"or set SSL_CERT_FILE with certifi's CA bundle (e.g. SSL_CERT_FILE=$(python -m certifi))."
) from exc

if isinstance(exc, aiohttp.ClientResponseError):
logger.debug("HTTP response error occurred (%s): %s", exc.status, exc)
if exc.status in (401, 403):
raise SoarAuthError(
f"SOAR authentication failed ({exc.status}): {exc.message}"
) from exc
raise SoarHttpError(
f"SOAR API returned HTTP {exc.status}: {exc.message}",
status_code=exc.status,
) from exc

if isinstance(exc, (aiohttp.ClientConnectorError, aiohttp.ServerTimeoutError)):
logger.debug("Connection error occurred: %s", exc)
raise SoarConnectionError(
f"Failed to connect to SOAR endpoint ({self.base_url}): {exc}"
) from exc

logger.debug("Unexpected error occurred: %s", exc)
raise SoarError(f"SOAR request failed: {exc}") from exc

async def get(
self,
endpoint: str,
params: Dict[str, Any] = None,
):
params: Optional[Dict[str, Any]] = None,
) -> Any:
"""Makes a GET request to the specified endpoint.

Args:
Expand All @@ -60,20 +122,17 @@ async def get(
async with self._get_session().get(
self.base_url + endpoint, params=params, headers=headers
) as response:
response.raise_for_status() # Raise an exception for 4xx/5xx responses
response.raise_for_status()
return await response.json()
except aiohttp.ClientResponseError as e:
logger.debug("HTTP error occurred: %s", e)
except Exception as e:
logger.debug("An error occurred: %s", e)
return None
self._handle_error(e)

async def post(
self,
endpoint: str,
req: Dict[str, Any] = None,
params: Dict[str, Any] = None,
):
req: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Any:
"""Makes a POST request to the specified endpoint.

Args:
Expand All @@ -93,18 +152,15 @@ async def post(
data = await response.content.read()
decoded_data = data.decode("utf-8")
return json.loads(decoded_data)
except aiohttp.ClientResponseError as e:
logger.debug("HTTP error occurred: %s", e)
except Exception as e:
logger.debug("An error occurred: %s", e)
return None
self._handle_error(e)

async def patch(
self,
endpoint: str,
req: Dict[str, Any] = None,
params: Dict[str, Any] = None,
):
req: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Any:
"""Makes a PATCH request to the specified endpoint.

Args:
Expand All @@ -122,12 +178,10 @@ async def patch(
) as response:
response.raise_for_status()
return await response.json()
except aiohttp.ClientResponseError as e:
logger.debug("HTTP error occurred: %s", e)
except Exception as e:
logger.debug("An error occurred: %s", e)
return None
self._handle_error(e)

async def close(self):
async def close(self) -> None:
"""Closes the underlying aiohttp session if open."""
if self._session is not None:
await self._session.close()
10 changes: 6 additions & 4 deletions server/secops-soar/secops_soar_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,17 @@ def register_tools(integrations_arg: str):
module_stem = (
py_file.stem
) # The filename without .py (e.g., "csv")
if module_stem not in enabled_integrations_set:
continue
module_import_path = f"marketplace.{module_stem}" # The import path (e.g., "marketplace.csv")
module_import_path = f"secops_soar_mcp.marketplace.{module_stem}"
fallback_import_path = f"marketplace.{module_stem}"

try:
logger.debug(
" Attempting to import module: %s", module_import_path
)
module = importlib.import_module(module_import_path)
try:
module = importlib.import_module(module_import_path)
except ImportError:
module = importlib.import_module(fallback_import_path)

if hasattr(module, "register_tools") and callable(
getattr(module, "register_tools")
Expand Down
1 change: 1 addition & 0 deletions server/secops-soar/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
install_requires=[
"aiohttp>=3.11.15",
"mcp[cli]>=1.4.1,<2.0",
"python-dotenv>=1.0.0",
],
entry_points={
"console_scripts": [
Expand Down
2 changes: 1 addition & 1 deletion server/secops-soar/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def update_env_vars(soar_config: Dict[str, str]):
os.environ[key] = value


@pytest_asyncio.fixture(loop_scope="session", autouse=True)
@pytest_asyncio.fixture(loop_scope="session")
async def setup_bindings(soar_config: Dict[str, str]):
"""Ensures bindings are done once before tests in this module run."""
update_env_vars(soar_config)
Expand Down
Loading