Skip to content
72 changes: 49 additions & 23 deletions sumologic_mcp/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,17 @@ def __init__(self, config: SumoLogicConfig, auth: SumoLogicAuth):
)
)

# Only transient/infra-class failures should count toward opening the
# circuit. Bare `Exception` would mean user errors (e.g. invalid query
# syntax → 4xx, validation errors) also count, which can trip the
# breaker on benign batches of work and degrade tools unnecessarily.
circuit_breaker_config = CircuitBreakerConfig(
failure_threshold=max(5, self.config.max_retries * 2),
recovery_timeout=60.0,
expected_exception=Exception,
expected_exception=(
APIError, RateLimitError, TimeoutError,
ConnectionError, OSError, httpx.RequestError,
),
success_threshold=3
)

Expand Down Expand Up @@ -819,37 +826,56 @@ async def get_search_results(
search_state=job_state
)

# Get messages (log records)
# Try /records first (for aggregate queries), fall back to /messages.
# If both are empty, return empty results gracefully.
params = {
"offset": offset,
"limit": limit
}

response = await self._make_request(
method="GET",
endpoint=f"/api/v1/search/jobs/{job_id}/messages",
params=params,
operation_type="search_results"
)

results = await self._parse_json_response(response)


result_type = "records"
try:
response = await self._make_request(
method="GET",
endpoint=f"/api/v1/search/jobs/{job_id}/records",
params=params,
operation_type="search_results"
)
results = await self._parse_json_response(response)
except Exception:
results = {"records": [], "fields": []}

if not results.get("records"):
# No records — try messages (may also be empty for no-match queries)
result_type = "messages"
try:
response = await self._make_request(
method="GET",
endpoint=f"/api/v1/search/jobs/{job_id}/messages",
params=params,
operation_type="search_results"
)
results = await self._parse_json_response(response)
except Exception:
results = {"messages": [], "fields": []}

logger.info(
f"Retrieved search results",
extra={
"job_id": job_id,
"returned_count": len(results.get("messages", [])),
"result_type": result_type,
"returned_count": len(results.get(result_type, [])),
"offset": offset,
"limit": limit
}
)

# Combine with job status for complete result
return {
"job_id": job_id,
"status": status,
"results": results,
"messages": results.get("messages", []),
"messages": results.get(result_type, []),
"fields": results.get("fields", [])
}

Expand Down Expand Up @@ -1542,14 +1568,14 @@ async def get_metric_metadata(

async def list_collectors(self, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
"""List collectors with pagination.

Args:
limit: Maximum collectors to return (1-1000)
offset: Result offset for pagination

Returns:
Dictionary containing collectors list and metadata

Raises:
ValidationError: If parameters are invalid
APIError: If API request fails
Expand Down Expand Up @@ -1586,17 +1612,17 @@ async def list_collectors(self, limit: int = 100, offset: int = 0) -> Dict[str,
operation_type="collector"
)

collectors = await self._parse_json_response(response)
result = await self._parse_json_response(response)

logger.info(
f"Retrieved {len(collectors.get('collectors', []))} collectors",
f"Retrieved {len(result.get('collectors', []))} collectors",
extra={
"limit": limit,
"offset": offset
}
)
return collectors

return result

except APIError as e:
raise APIError(
Expand Down
13 changes: 10 additions & 3 deletions sumologic_mcp/api_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class SumoLogicAPIValidator:
r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?([+-]\d{2}:\d{2}|Z)?$'
)
RELATIVE_TIME_PATTERN = re.compile(r'^-?\d+[smhdw]$|^now$')
EPOCH_PATTERN = re.compile(r'^\d{10,13}$')

@classmethod
def validate_search_params(cls, params: Dict[str, Any]) -> Dict[str, Any]:
Expand Down Expand Up @@ -135,8 +136,10 @@ def validate_search_params(cls, params: Dict[str, Any]) -> Dict[str, Any]:
api_endpoint="search API"
)

# Validate each parameter
# Validate each parameter (skip None so defaults get applied below)
for param_name, param_value in params.items():
if param_value is None:
continue
if param_name not in schema:
# Allow unknown parameters but log warning
validated_params[param_name] = param_value
Expand Down Expand Up @@ -334,8 +337,12 @@ def _validate_time_format(cls, param_name: str, time_value: str) -> None:
# Check if it matches relative time format
if cls.RELATIVE_TIME_PATTERN.match(time_value):
return

# Neither format matched

# Check if it matches epoch time format (seconds or milliseconds)
if cls.EPOCH_PATTERN.match(time_value):
return

# No format matched
raise TimeValidationError(
f"Invalid time format for parameter '{param_name}'",
time_value,
Expand Down
6 changes: 2 additions & 4 deletions sumologic_mcp/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from urllib.parse import urlparse

import httpx
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field

from .config import SumoLogicConfig
from .exceptions import AuthenticationError, APIError, ConfigurationError, TimeoutError
Expand All @@ -25,9 +25,7 @@ class AuthSession(BaseModel):
expires_at: Optional[datetime] = None
session_id: Optional[str] = None

class Config:
"""Pydantic configuration."""
arbitrary_types_allowed = True
model_config = ConfigDict(arbitrary_types_allowed=True)


class SumoLogicAuth:
Expand Down
Loading