From 900096ca89eb959fa33d86b6e0b3b802c1b19aa9 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 25 Feb 2026 13:16:48 +0000 Subject: [PATCH 1/7] Fix timeZone default not applied when parameter value is None When search_logs is called without a time_zone, the SearchRequest model sets time_zone=None. This None value gets passed into search_params as timeZone=None. The validator then tries to type-check None as a string, which raises an APIParameterError. The fix skips None values during validation so they are treated as "not provided", allowing the default value (UTC) to be applied in the subsequent defaults loop. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_validator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sumologic_mcp/api_validator.py b/sumologic_mcp/api_validator.py index 85ae755..31c8f66 100644 --- a/sumologic_mcp/api_validator.py +++ b/sumologic_mcp/api_validator.py @@ -135,8 +135,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 From 5f66f3d501deca30e08c467c14dec4a6f4058b8a Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 25 Feb 2026 13:17:24 +0000 Subject: [PATCH 2/7] Fix time format: use epoch milliseconds for search jobs API The Sumo Logic search jobs API (/api/v1/search/jobs) requires time values as epoch milliseconds, not ISO 8601 strings. Sending ISO format results in: "The 'from' field contains an invalid time." This changes to_sumo_api_format and to_sumo_time_format to output epoch milliseconds, and adds epoch time pattern validation to the API validator so it accepts the format it now produces. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_validator.py | 9 +++++++-- sumologic_mcp/time_utils.py | 25 ++++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/sumologic_mcp/api_validator.py b/sumologic_mcp/api_validator.py index 31c8f66..454f0c5 100644 --- a/sumologic_mcp/api_validator.py +++ b/sumologic_mcp/api_validator.py @@ -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]: @@ -336,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, diff --git a/sumologic_mcp/time_utils.py b/sumologic_mcp/time_utils.py index 19cb59e..31de280 100644 --- a/sumologic_mcp/time_utils.py +++ b/sumologic_mcp/time_utils.py @@ -201,30 +201,29 @@ def _parse_epoch_time(cls, time_str: str) -> datetime: @classmethod def to_sumo_api_format(cls, dt: datetime) -> str: - """Convert datetime to exact Sumo Logic API format. - + """Convert datetime to Sumo Logic search jobs API format. + Args: dt: Datetime object to convert - + Returns: - Time string in exact Sumo Logic API format (ISO 8601 with milliseconds and Z suffix) + Time string as epoch milliseconds (required by Sumo Logic search jobs API) """ - # Sumo Logic API expects ISO 8601 format with milliseconds and Z suffix - # Format: YYYY-MM-DDTHH:MM:SS.sssZ - return dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' - + import calendar + epoch_seconds = calendar.timegm(dt.timetuple()) + return str(epoch_seconds * 1000 + dt.microsecond // 1000) + @classmethod def to_sumo_time_format(cls, dt: datetime) -> str: """Convert datetime to Sumo Logic API time format. - + Args: dt: Datetime object to convert - + Returns: - Time string in Sumo Logic API format (ISO 8601 with milliseconds) + Time string as epoch milliseconds (required by Sumo Logic search jobs API) """ - # Sumo Logic expects ISO format with milliseconds - return dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' + return cls.to_sumo_api_format(dt) @classmethod def validate_time_range(cls, from_time: str, to_time: str) -> tuple[datetime, datetime]: From fab2a72756dde87680e64dd1ceb5d5a7aba2df68 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 25 Feb 2026 13:17:48 +0000 Subject: [PATCH 3/7] Fix result retrieval for aggregate queries and empty results The search results endpoint always fetched /messages, which fails for aggregate queries (count by, sum by, etc.) with "requireRawMessages is false". Aggregate query results are served from /records instead. This changes get_search_results to try /records first, then fall back to /messages. Both endpoints are handled gracefully when empty, so queries that match nothing return an empty result set instead of raising an error. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_client.py | 47 ++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index ed10d6d..e43aa80 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -819,37 +819,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", []) } From 9a0890d89e4c4af8afe4ca254bf13f7942ea7984 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 4 Mar 2026 10:04:59 +0000 Subject: [PATCH 4/7] Fix list_collectors missing filter_type parameter The tool layer passes filter_type to the API client, but the client method didn't accept it. Add the parameter and filter client-side since the Sumo Logic API doesn't support it as a query param. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_client.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index e43aa80..d1f17ca 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -1559,16 +1559,17 @@ async def get_metric_metadata( # Collector and Source API Methods - async def list_collectors(self, limit: int = 100, offset: int = 0) -> Dict[str, Any]: + async def list_collectors(self, limit: int = 100, offset: int = 0, filter_type: Optional[str] = None) -> Dict[str, Any]: """List collectors with pagination. - + Args: limit: Maximum collectors to return (1-1000) offset: Result offset for pagination - + filter_type: Optional filter — "Installable", "Hosted", or "All" + Returns: Dictionary containing collectors list and metadata - + Raises: ValidationError: If parameters are invalid APIError: If API request fails @@ -1605,17 +1606,23 @@ 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) + + if filter_type and filter_type != "All": + result["collectors"] = [ + c for c in result.get("collectors", []) + if c.get("collectorType") == filter_type + ] + 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( From afe78fd331d5e3c98a2cd1113e38c39d2e64a437 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 4 Mar 2026 10:22:51 +0000 Subject: [PATCH 5/7] Remove unsupported filter_type from list_collectors The Sumo Logic Collector API does not support filter_type as a query parameter. Remove it from the tool schema, tool method, and API client rather than faking it with client-side filtering. Co-Authored-By: Claude Opus 4.6 --- sumologic_mcp/api_client.py | 9 +-------- sumologic_mcp/tools/collector_tools.py | 26 +++++++------------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index d1f17ca..7d5060a 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -1559,13 +1559,12 @@ async def get_metric_metadata( # Collector and Source API Methods - async def list_collectors(self, limit: int = 100, offset: int = 0, filter_type: Optional[str] = None) -> Dict[str, Any]: + 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 - filter_type: Optional filter — "Installable", "Hosted", or "All" Returns: Dictionary containing collectors list and metadata @@ -1608,12 +1607,6 @@ async def list_collectors(self, limit: int = 100, offset: int = 0, filter_type: result = await self._parse_json_response(response) - if filter_type and filter_type != "All": - result["collectors"] = [ - c for c in result.get("collectors", []) - if c.get("collectorType") == filter_type - ] - logger.info( f"Retrieved {len(result.get('collectors', []))} collectors", extra={ diff --git a/sumologic_mcp/tools/collector_tools.py b/sumologic_mcp/tools/collector_tools.py index 7cc4566..b9ae872 100644 --- a/sumologic_mcp/tools/collector_tools.py +++ b/sumologic_mcp/tools/collector_tools.py @@ -29,17 +29,15 @@ def __init__(self, api_client: SumoLogicAPIClient): async def list_collectors( self, - filter_type: Optional[str] = None, limit: int = 100, offset: int = 0 ) -> Dict[str, Any]: - """List all collectors with optional filtering. - + """List all collectors with pagination. + This tool retrieves a list of collectors from Sumo Logic with support - for type-based filtering and pagination. - + for pagination. + Args: - filter_type: Optional collector type filter (Installable, Hosted, etc.) limit: Maximum number of collectors to return (1-1000) offset: Starting position for pagination (0-based) @@ -66,15 +64,10 @@ async def list_collectors( if offset < 0: raise ValidationError("Offset must be non-negative") - valid_types = ["Installable", "Hosted", "All"] - if filter_type and filter_type not in valid_types: - raise ValidationError(f"filter_type must be one of: {', '.join(valid_types)}") - - logger.info(f"Listing collectors with type='{filter_type}', limit={limit}, offset={offset}") - + logger.info(f"Listing collectors with limit={limit}, offset={offset}") + # Get collectors from API collectors_response = await self.api_client.list_collectors( - filter_type=filter_type, limit=limit, offset=offset ) @@ -119,7 +112,7 @@ async def list_collectors( "limit": limit, "returned_count": len(formatted_collectors), "has_more": has_more, - "filter_applied": filter_type is not None, + "filter_applied": False, "collector_types": collector_types, "summary": { "online_collectors": sum(1 for c in formatted_collectors if c["status"]), @@ -776,11 +769,6 @@ def get_tool_definitions(self) -> List[Dict[str, Any]]: "inputSchema": { "type": "object", "properties": { - "filter_type": { - "type": "string", - "description": "Optional collector type filter", - "enum": ["Installable", "Hosted", "All"] - }, "limit": { "type": "integer", "description": "Maximum number of collectors to return (1-1000)", From b663d48c88a22f247464be03611d3a2e802621ed Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 4 Mar 2026 14:51:50 +0000 Subject: [PATCH 6/7] fix --- sumologic_mcp/tools/collector_tools.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sumologic_mcp/tools/collector_tools.py b/sumologic_mcp/tools/collector_tools.py index b9ae872..eba0e8d 100644 --- a/sumologic_mcp/tools/collector_tools.py +++ b/sumologic_mcp/tools/collector_tools.py @@ -168,8 +168,13 @@ async def get_collector(self, collector_id: str) -> Dict[str, Any]: # Format collector data collector = collector_response.get("collector", collector_response) - # Get sources for this collector - sources = collector.get("sources", []) + # Get sources for this collector (requires separate API call) + try: + sources_response = await self.api_client.list_sources(collector_id) + sources = sources_response.get("sources", []) + except Exception as e: + logger.warning(f"Failed to fetch sources for collector {collector_id}: {e}") + sources = [] formatted_sources = [] for source in sources: From 8945b35dc39ea4c063643fb262712e7857ba7d7b Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 13 May 2026 11:37:20 +0100 Subject: [PATCH 7/7] Stop circuit breaker tripping on benign batches of work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the sumologic-api circuit breaker: 1. resilience.py: reset failure_count to 0 on a success in CLOSED state. Previously failure_count was monotonic in CLOSED state, so any process accumulating ~5 transient errors over its lifetime was guaranteed to trip the breaker eventually — "5 ever" rather than "5 consecutive." That's the wrong semantics for an MCP server doing dozens of small queries per session. 2. api_client.py: narrow expected_exception from bare Exception to the same transient set the retry layer already uses (APIError, RateLimitError, TimeoutError, ConnectionError, OSError, httpx.RequestError). User errors like 4xx-from-bad-query no longer count toward opening the breaker. Updated CircuitBreakerConfig.expected_exception's type annotation to reflect that a tuple is supported (Python's except clause accepts either form at runtime). Validated with three async test cases: - failure_count resets on success in CLOSED state - non-transient exceptions are propagated but don't count - 3 consecutive transient errors still open the breaker (intended behavior preserved) Co-Authored-By: Claude Opus 4.7 (1M context) --- sumologic_mcp/api_client.py | 9 ++++++++- sumologic_mcp/resilience.py | 17 ++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/sumologic_mcp/api_client.py b/sumologic_mcp/api_client.py index 7d5060a..0ce36e3 100644 --- a/sumologic_mcp/api_client.py +++ b/sumologic_mcp/api_client.py @@ -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 ) diff --git a/sumologic_mcp/resilience.py b/sumologic_mcp/resilience.py index 83d6b7d..98fc8a5 100644 --- a/sumologic_mcp/resilience.py +++ b/sumologic_mcp/resilience.py @@ -9,7 +9,7 @@ import time from dataclasses import dataclass, field from enum import Enum -from typing import Any, Callable, Dict, Optional, Type, Union, List +from typing import Any, Callable, Dict, Optional, Tuple, Type, Union, List from datetime import datetime, timedelta from .exceptions import APIError, RateLimitError, TimeoutError, SumoLogicError @@ -54,7 +54,10 @@ class CircuitBreakerConfig: """Configuration for circuit breaker.""" failure_threshold: int = 5 recovery_timeout: float = 60.0 - expected_exception: Type[Exception] = Exception + # Pass a tuple of exception classes to count only transient/infra-class + # failures toward opening the circuit (the default Exception catches user + # errors too, which can trip the breaker on benign batches). + expected_exception: Union[Type[Exception], Tuple[Type[Exception], ...]] = Exception success_threshold: int = 3 # Successes needed in half-open to close def __post_init__(self): @@ -221,7 +224,15 @@ async def _check_state_transition(self): async def _record_success(self): """Record successful operation.""" self.stats.record_success() - + + # In CLOSED state, a success means the recent failure streak is broken. + # Without this reset, failure_count is monotonic and the breaker is + # guaranteed to trip eventually after enough cumulative failures over + # the lifetime of the process — "5 consecutive failures" is the + # intended semantics, not "5 failures ever". + if self.stats.state == CircuitState.CLOSED: + self.stats.failure_count = 0 + logger.debug( f"Circuit breaker '{self.name}' recorded success", extra={