diff --git a/src/oci-monitoring-mcp-server/README.md b/src/oci-monitoring-mcp-server/README.md index 4208b8c8..3a27e23a 100644 --- a/src/oci-monitoring-mcp-server/README.md +++ b/src/oci-monitoring-mcp-server/README.md @@ -20,25 +20,29 @@ ORACLE_MCP_HOST= ORACLE_MCP_PORT= uvx oracle.o ## Tools -| Tool Name | Description | -| --- | --- | -| list_metrics | List metrics in the tenancy | -| get_metric | Get metric by name | +| Tool Name | Description | +|-----------------------|------------------------------------------------------------------| +| list_alarms | List Alarms in the tenancy | +| get_metrics_data | Gets aggregated metric data | +| get_available_metrics | Lists the available metrics a user can query on in their tenancy | - -⚠️ **NOTE**: All actions are performed with the permissions of the configured OCI CLI profile. We advise least-privilege IAM setup, secure credential management, safe network practices, secure logging, and warn against exposing secrets. +⚠️ **NOTE**: All actions are performed with the permissions of the configured OCI CLI profile. We advise least-privilege +IAM setup, secure credential management, safe network practices, secure logging, and warn against exposing secrets. ## Third-Party APIs -Developers choosing to distribute a binary implementation of this project are responsible for obtaining and providing all required licenses and copyright notices for the third-party code used in order to ensure compliance with their respective open source licenses. +Developers choosing to distribute a binary implementation of this project are responsible for obtaining and providing +all required licenses and copyright notices for the third-party code used in order to ensure compliance with their +respective open source licenses. ## Disclaimer -Users are responsible for their local environment and credential safety. Different language model selections may yield different results and performance. +Users are responsible for their local environment and credential safety. Different language model selections may yield +different results and performance. ## License Copyright (c) 2025 Oracle and/or its affiliates. - + Released under the Universal Permissive License v1.0 as shown at . diff --git a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/alarm_models.py b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/alarm_models.py new file mode 100644 index 00000000..8054d127 --- /dev/null +++ b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/alarm_models.py @@ -0,0 +1,218 @@ +""" +Copyright (c) 2025, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional + +import oci +from pydantic import BaseModel, Field + +SeverityType = Literal["CRITICAL", "ERROR", "WARNING", "INFO", "UNKNOWN_ENUM_VALUE"] + + +class Suppression(BaseModel): + """ + Pydantic model mirroring oci.monitoring.models.Suppression. + """ + + description: Optional[str] = Field( + None, description="Human-readable description of the suppression." + ) + time_suppress_from: Optional[datetime] = Field( + None, description="The start time for the suppression (RFC3339)." + ) + time_suppress_until: Optional[datetime] = Field( + None, description="The end time for the suppression (RFC3339)." + ) + + +def map_suppression(s: oci.monitoring.models.Suppression | None) -> Suppression | None: + if not s: + return None + return Suppression( + description=getattr(s, "description", None), + time_suppress_from=getattr(s, "time_suppress_from", None) + or getattr(s, "timeSuppressFrom", None), + time_suppress_until=getattr(s, "time_suppress_until", None) + or getattr(s, "timeSuppressUntil", None), + ) + + +class AlarmOverride(BaseModel): + """ + Pydantic model mirroring (a subset of) oci.monitoring.models.AlarmOverride. + Each override can specify values for query, severity, body, and pending duration. + """ + + rule_name: Optional[str] = Field( + None, + description="Identifier of the alarm's base/override values. Default is 'BASE'.", + ) + query: Optional[str] = Field( + None, description="MQL expression override for this rule." + ) + severity: Optional[SeverityType] = Field( + None, description="Severity override for this rule." + ) + body: Optional[str] = Field(None, description="Message body override (alarm body).") + pending_duration: Optional[str] = Field( + None, + description="Override for pending duration as ISO 8601 duration (e.g., 'PT5M').", + ) + + +def map_alarm_override( + o: oci.monitoring.models.AlarmOverride | None, +) -> AlarmOverride | None: + if not o: + return None + return AlarmOverride( + rule_name=getattr(o, "rule_name", None) or getattr(o, "ruleName", None), + query=getattr(o, "query", None), + severity=getattr(o, "severity", None), + body=getattr(o, "body", None), + pending_duration=getattr(o, "pending_duration", None) + or getattr(o, "pendingDuration", None), + ) + + +def map_alarm_overrides(items) -> list[AlarmOverride] | None: + if not items: + return None + result: list[AlarmOverride] = [] + for it in items: + mapped = map_alarm_override(it) + if mapped is not None: + result.append(mapped) + return result if result else None + + +class AlarmSummary(BaseModel): + """ + Pydantic model mirroring (a subset of) oci.monitoring.models.AlarmSummary. + """ + + id: Optional[str] = Field(None, description="The OCID of the alarm.") + display_name: Optional[str] = Field( + None, + description="A user-friendly name for the alarm; used as title in notifications.", + ) + compartment_id: Optional[str] = Field( + None, description="The OCID of the compartment containing the alarm." + ) + metric_compartment_id: Optional[str] = Field( + None, + description="The OCID of the compartment containing the metric evaluated by the alarm.", + ) + namespace: Optional[str] = Field( + None, description="The source service/application emitting the metric." + ) + query: Optional[str] = Field( + None, + description="The Monitoring Query Language (MQL) expression to evaluate for the alarm.", + ) + severity: Optional[SeverityType] = Field( + None, + description="The perceived type of response required when the alarm is FIRING.", + ) + destinations: Optional[List[str]] = Field( + None, + description="List of destination OCIDs for alarm notifications (e.g., NotificationTopic).", + ) + suppression: Optional[Suppression] = Field( + None, description="Configuration details for suppressing an alarm." + ) + is_enabled: Optional[bool] = Field( + None, description="Whether the alarm is enabled." + ) + is_notifications_per_metric_dimension_enabled: Optional[bool] = Field( + None, + description="Whether the alarm sends a separate message for each metric stream.", + ) + freeform_tags: Optional[Dict[str, str]] = Field( + None, description="Simple key/value pair tags applied without predefined names." + ) + defined_tags: Optional[Dict[str, Dict[str, Any]]] = Field( + None, description="Defined tags for this resource, scoped to namespaces." + ) + lifecycle_state: Optional[str] = Field( + None, description="The current lifecycle state of the alarm." + ) + overrides: Optional[List[AlarmOverride]] = Field( + None, + description="Overrides controlling alarm evaluations (query, severity, body, pending duration).", + ) + rule_name: Optional[str] = Field( + None, + description="Identifier of the alarm’s base values when overrides are present; default 'BASE'.", + ) + notification_version: Optional[str] = Field( + None, + description="Version of the alarm notification to be delivered (e.g., '1.X').", + ) + notification_title: Optional[str] = Field( + None, + description="Customizable notification title used as subject/title in messages.", + ) + evaluation_slack_duration: Optional[str] = Field( + None, + description="Slack period for metric ingestion before evaluating the alarm, ISO 8601 (e.g., 'PT3M').", + ) + alarm_summary: Optional[str] = Field( + None, + description="Customizable alarm summary (message body) with optional dynamic variables.", + ) + resource_group: Optional[str] = Field( + None, + description="Resource group to match for metrics used by this alarm.", + ) + + +def map_alarm_summary( + alarm: oci.monitoring.models.AlarmSummary, +) -> AlarmSummary: + """ + Convert an oci.monitoring.models.AlarmSummary to + oracle.oci_monitoring_mcp_server.alarms.models.AlarmSummary, including nested types. + """ + return AlarmSummary( + id=getattr(alarm, "id", None), + display_name=getattr(alarm, "display_name", None) + or getattr(alarm, "displayName", None), + compartment_id=getattr(alarm, "compartment_id", None) + or getattr(alarm, "compartmentId", None), + metric_compartment_id=getattr(alarm, "metric_compartment_id", None) + or getattr(alarm, "metricCompartmentId", None), + namespace=getattr(alarm, "namespace", None), + query=getattr(alarm, "query", None), + severity=getattr(alarm, "severity", None), + destinations=getattr(alarm, "destinations", None), + suppression=map_suppression(getattr(alarm, "suppression", None)), + is_enabled=getattr(alarm, "is_enabled", None) + or getattr(alarm, "isEnabled", None), + is_notifications_per_metric_dimension_enabled=getattr( + alarm, "is_notifications_per_metric_dimension_enabled", None + ) + or getattr(alarm, "isNotificationsPerMetricDimensionEnabled", None), + freeform_tags=getattr(alarm, "freeform_tags", None) + or getattr(alarm, "freeformTags", None), + defined_tags=getattr(alarm, "defined_tags", None) + or getattr(alarm, "definedTags", None), + lifecycle_state=getattr(alarm, "lifecycle_state", None) + or getattr(alarm, "lifecycleState", None), + overrides=map_alarm_overrides(getattr(alarm, "overrides", None)), + rule_name=getattr(alarm, "rule_name", None) or getattr(alarm, "ruleName", None), + notification_version=getattr(alarm, "notification_version", None) + or getattr(alarm, "notificationVersion", None), + notification_title=getattr(alarm, "notification_title", None) + or getattr(alarm, "notificationTitle", None), + evaluation_slack_duration=getattr(alarm, "evaluation_slack_duration", None) + or getattr(alarm, "evaluationSlackDuration", None), + alarm_summary=getattr(alarm, "alarm_summary", None) + or getattr(alarm, "alarmSummary", None), + resource_group=getattr(alarm, "resource_group", None) + or getattr(alarm, "resourceGroup", None), + ) diff --git a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/metric_models.py b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/metric_models.py new file mode 100644 index 00000000..09a48386 --- /dev/null +++ b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/metric_models.py @@ -0,0 +1,393 @@ +""" +Copyright (c) 2025, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from datetime import datetime +from typing import Dict, List, Literal, Optional + +import oci +from pydantic import BaseModel, Field + +# @TODO Percentile should be converted to either common or custom percentages +StatisticType = Literal[ + "absent", + "avg", + "count", + "first", + "increment", + "last", + "max", + "mean", + "min", + "percentile", + "rate", + "sum", +] +PredicateType = Literal[ + "greater_than", + "greater_than_or_equal", + "less_than", + "less_than_or_equal", + "not_equal_to", + "between", + "outside", + "absent", +] +ExampleNamespaces = [ + "oci_apigateway", + "oci_autonomous_database", + "oci_bastion", + "oci_blockstore", + "oci_certificates", + "oci_cloudevents", + "oci_compute", + "oci_compute_infrastructure_health", + "oci_compute_instance_health", + "oci_dns", + "oci_dynamic_routing_gateway", + "oci_fastconnect", + "oci_filestorage", + "oci_goldengate", + "oci_healthchecks", + "oci_instancepools", + "oci_internet_gateway", + "oci_lbaas", + "oci_logging", + "oci_logging_analytics", + "oci_nat_gateway", + "oci_network_firewall", + "oci_nlb", + "oci_nlb_extended", + "oci_notification", + "oci_objectstorage", + "oci_secrets", + "oci_vcn", + "oci_vcnip", + "oci_vmi_resource_utilization", + "oci_vpn", +] +# Reusable Fields across tools + +CompartmentField = Field( + ..., + description="The OCID of the compartment", +) + +CompartmentIdInSubtreeField = Field( + False, + description="Whether to include metrics from all subcompartments of the specified compartment", +) + +NamespaceField = Field( + "oci_compute", + description="The source service or application to use when searching for metric data points" + "to aggregate. If you do not know the name of the namespace, " + "use the list_metric_definitions tool with an empty namespace parameter.", + examples=[ExampleNamespaces], +) + + +class Metric(BaseModel): + """ + Pydantic model mirroring (a subset of) oci.monitoring.models.Metric. + The fields below represent the commonly used attributes found on the OCI SDK Metric model. + """ + + namespace: Optional[str] = Field( + None, description="The source service or application emitting the metric." + ) + resource_group: Optional[str] = Field( + None, + description="Resource group specified for the metric. A metric can be part of a resource group.", + ) + compartment_id: Optional[str] = Field( + None, + description="The OCID of the compartment containing the resource emitting the metric.", + ) + name: Optional[str] = Field( + None, description="The metric name (for example, CpuUtilization)." + ) + dimensions: Optional[Dict[str, str]] = Field( + None, + description="Dimensions (key/value pairs) that qualify the metric (for example, resourceId).", + ) + metadata: Optional[Dict[str, str]] = Field( + None, + description="Metric metadata (for example, unit). " + "Keys and values are defined by the emitting service.", + ) + resolution: Optional[str] = Field( + None, + description="The publication resolution of the metric, such as '1m'.", + ) + + +def map_metric(metric_data: oci.monitoring.models.Metric) -> Metric: + """ + Convert an oci.monitoring.models.Metric to oracle.oci_monitoring_mcp_server.models.Metric. + """ + return Metric( + namespace=getattr(metric_data, "namespace", None), + resource_group=getattr(metric_data, "resource_group", None), + compartment_id=getattr(metric_data, "compartment_id", None), + name=getattr(metric_data, "name", None), + dimensions=getattr(metric_data, "dimensions", None), + metadata=getattr(metric_data, "metadata", None), + resolution=getattr(metric_data, "resolution", None), + ) + + +class AggregatedDatapoint(BaseModel): + """ + Pydantic model mirroring oci.monitoring.models.AggregatedDatapoint. + """ + + timestamp: Optional[datetime] = Field( + None, + description="The date and time associated with the aggregated value (RFC3339).", + ) + value: Optional[float] = Field( + None, description="The aggregated metric value for the time window." + ) + + +class MetricData(BaseModel): + """ + Pydantic model mirroring (a subset of) oci.monitoring.models.MetricData. + """ + + namespace: Optional[str] = Field( + None, description="The source service or application emitting the metric." + ) + resource_group: Optional[str] = Field( + None, description="Resource group specified for the metric." + ) + compartment_id: Optional[str] = Field( + None, description="The OCID of the compartment containing the resource." + ) + name: Optional[str] = Field( + None, description="The metric name (for example, CpuUtilization)." + ) + dimensions: Optional[Dict[str, str]] = Field( + None, + description="Dimensions that qualify the metric (for example, resourceId).", + ) + metadata: Optional[Dict[str, str]] = Field( + None, description="Metric metadata such as unit." + ) + resolution: Optional[str] = Field( + None, + description="The publication resolution of the metric (for example, '1m').", + ) + aggregated_datapoints: Optional[List[AggregatedDatapoint]] = Field( + None, + description="Time series datapoints aggregated at the requested resolution.", + ) + + +def map_aggregated_datapoint( + p: oci.monitoring.models.AggregatedDatapoint | None, +) -> AggregatedDatapoint | None: + if not p: + return None + return AggregatedDatapoint( + timestamp=getattr(p, "timestamp", None), + value=getattr(p, "value", None), + ) + + +def map_aggregated_datapoints(items) -> list[AggregatedDatapoint] | None: + if not items: + return None + result: list[AggregatedDatapoint] = [] + for it in items: + mapped_datapoint = map_aggregated_datapoint(it) + if mapped_datapoint is not None: + result.append(mapped_datapoint) + return result if result else None + + +def map_metric_data(metric_data: oci.monitoring.models.MetricData) -> MetricData: + """ + Convert an oci.monitoring.models.MetricData to oracle.oci_monitoring_mcp_server.models.MetricData. + """ + return MetricData( + namespace=getattr(metric_data, "namespace", None), + resource_group=getattr(metric_data, "resource_group", None), + compartment_id=getattr(metric_data, "compartment_id", None), + name=getattr(metric_data, "name", None), + dimensions=getattr(metric_data, "dimensions", None), + metadata=getattr(metric_data, "metadata", None), + resolution=getattr(metric_data, "resolution", None), + aggregated_datapoints=map_aggregated_datapoints( + getattr(metric_data, "aggregated_datapoints", None) + ), + ) + + +class Datapoint(BaseModel): + """ + Pydantic model mirroring oci.monitoring.models.Datapoint + used when posting metric data (not aggregated/summarized). + """ + + timestamp: Optional[datetime] = Field( + None, description="The time the metric value was recorded (RFC3339)." + ) + value: Optional[float] = Field( + None, description="Metric value at the given timestamp." + ) + count: Optional[int] = Field( + None, + description="Optional number of samples represented by this value (if provided).", + ) + + +def map_datapoint(p: oci.monitoring.models.Datapoint) -> Datapoint | None: + if not p: + return None + return Datapoint( + timestamp=getattr(p, "timestamp", None), + value=getattr(p, "value", None), + count=getattr(p, "count", None), + ) + + +def map_datapoints(items) -> list[Datapoint] | None: + if not items: + return None + result: list[Datapoint] = [] + for it in items: + mapped_datapoint = map_datapoint(it) + if mapped_datapoint is not None: + result.append(mapped_datapoint) + return result if result else None + + +class MetricDataDetails(BaseModel): + """ + Pydantic model mirroring (a subset of) oci.monitoring.models.MetricDataDetails. + Represents a single time series being posted to the Monitoring service. + """ + + namespace: Optional[str] = Field( + None, description="The source service or application emitting the metric." + ) + resource_group: Optional[str] = Field( + None, description="Resource group specified for the metric." + ) + compartment_id: Optional[str] = Field( + None, description="The OCID of the compartment containing the resource." + ) + name: Optional[str] = Field( + None, description="The metric name (for example, CpuUtilization)." + ) + dimensions: Optional[Dict[str, str]] = Field( + None, + description="Dimensions that qualify the metric (for example, resourceId).", + ) + metadata: Optional[Dict[str, str]] = Field( + None, description="Metric metadata such as unit." + ) + datapoints: Optional[List[Datapoint]] = Field( + None, description="Raw datapoints to post for this metric." + ) + + +def map_metric_data_details( + mdd: oci.monitoring.models.MetricDataDetails, +) -> MetricDataDetails: + """ + Convert an oci.monitoring.models.MetricDataDetails to + oracle.oci_monitoring_mcp_server.models.MetricDataDetails. + """ + return MetricDataDetails( + namespace=getattr(mdd, "namespace", None), + resource_group=getattr(mdd, "resource_group", None), + compartment_id=getattr(mdd, "compartment_id", None), + name=getattr(mdd, "name", None), + dimensions=getattr(mdd, "dimensions", None), + metadata=getattr(mdd, "metadata", None), + datapoints=map_datapoints(getattr(mdd, "datapoints", None)), + ) + + +# region List Metrics + + +class ListMetricsDetails(BaseModel): + """ + Pydantic model mirroring (a subset of) oci.monitoring.models.ListMetricsDetails. + Used to filter and group results when listing available metrics. + """ + + namespace: Optional[str] = Field( + None, description="The source service or application emitting the metric." + ) + resource_group: Optional[str] = Field( + None, description="Resource group specified for the metric." + ) + name: Optional[str] = Field( + None, description="Optional metric name to filter by (e.g., CpuUtilization)." + ) + dimension_filters: Optional[Dict[str, str]] = Field( + None, + description="Filter to only include metrics that match these dimension key/value pairs.", + ) + group_by: Optional[List[str]] = Field( + None, + description="Optional list of fields to group by in the response (e.g., ['namespace', 'name']).", + ) + + +def map_list_metrics_details( + lmd: oci.monitoring.models.ListMetricsDetails, +) -> ListMetricsDetails | None: + """ + Convert an oci.monitoring.models.ListMetricsDetails to + oracle.oci_monitoring_mcp_server.models.ListMetricsDetails. + """ + if not lmd: + return None + return ListMetricsDetails( + namespace=getattr(lmd, "namespace", None), + resource_group=getattr(lmd, "resource_group", None), + name=getattr(lmd, "name", None), + # OCI SDK may expose snake_case or camelCase depending on version + dimension_filters=getattr(lmd, "dimension_filters", None) + or getattr(lmd, "dimensionFilters", None), + group_by=getattr(lmd, "group_by", None) or getattr(lmd, "groupBy", None), + ) + + +class SummarizeMetricsDataDetails(BaseModel): + """ + Pydantic model mirroring (a subset of) oci.monitoring.models.SummarizeMetricsDataDetails. + Used to request aggregated time series from the Monitoring service. + """ + + namespace: Optional[str] = Field( + None, description="The source service or application emitting the metric." + ) + resource_group: Optional[str] = Field( + None, description="Resource group specified for the metric." + ) + query: Optional[str] = Field( + None, + description=( + "The Monitoring Query Language (MQL) expression, e.g. " + "'CpuUtilization[1m]{resourceId=\"ocid1.instance...\"}.mean()'" + ), + ) + start_time: Optional[datetime] = Field( + None, description="The beginning of the time window for the metrics (RFC3339)." + ) + end_time: Optional[datetime] = Field( + None, description="The end of the time window for the metrics (RFC3339)." + ) + resolution: Optional[str] = Field( + None, + description="The time window used to aggregate metrics, e.g., '1m', '5m', '1h'.", + ) diff --git a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/scripts.py b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/scripts.py new file mode 100644 index 00000000..0dc54f4a --- /dev/null +++ b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/scripts.py @@ -0,0 +1,17 @@ +""" +Copyright (c) 2025, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from pathlib import Path + +SCRIPTS_DIRECTORY = Path(__file__).parent / "scripts" + +MQL_QUERY_DOC = "MQL_QUERY.md" + + +def get_script_content(script_name: str) -> str: + file_path = SCRIPTS_DIRECTORY / script_name + with open(file_path, "r") as f: + return f.read() diff --git a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/scripts/MQL_QUERY.md b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/scripts/MQL_QUERY.md new file mode 100644 index 00000000..7ddc6252 --- /dev/null +++ b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/scripts/MQL_QUERY.md @@ -0,0 +1,205 @@ +# Monitoring Query Language (MQL) Quick Guide + +Use MQL to query metrics in Oracle Cloud Infrastructure (OCI) Monitoring. MQL is supported in: +- Metrics Explorer (Advanced mode) +- Service Metrics (open in Metrics Explorer → Advanced mode) +- Alarm definitions (Advanced mode) + +Source: Monitoring Query Language (MQL) Reference +https://docs.oracle.com/en-us/iaas/Content/Monitoring/Reference/mql.htm + +## 1) Anatomy of an MQL Expression + +Basic pattern: +``` +[]{}.groupBy([, ...]).() +``` + +Component overview: +- Metric name: The metric to query, e.g., `CpuUtilization`, `ServiceConnectorHubErrors` +- Interval: Window for aggregating raw datapoints (e.g., `1m`, `5m`, `1h`, `1d`) +- Dimension filters (optional): Key/value pairs narrowing the series, e.g., `{resourceId = "", availabilityDomain = "phx-AD-1"}` +- groupBy (optional): Aggregate (combine) series by one or more dimensions +- Statistic: Aggregation over the interval (e.g., `mean()`, `sum()`, `percentile(0.9)`) +- Predicate (optional): A threshold or absence test (e.g., `> 80`, `in (60,80)`, `absent(20)`) + +Example: +``` +CpuUtilization[1m]{availabilityDomain = "VeBZ:PHX-AD-1"}.groupBy(poolId).percentile(0.9) > 85 +``` + +## 2) Valid Intervals + +Choose an interval appropriate to the metric emission rate and time range: +- Valid intervals: `1m`-`60m`, `1h`-`24h`, `1d` +- For metric queries, the default resolution equals the interval. Resolution governs the maximum time range returned +- For alarm queries, resolution is always `1m` (interval does not change resolution) + +Examples: +``` +CpuUtilization[1m].mean() +TotalRequestLatency[5m].mean() +``` + +Tip: Smaller time ranges support more granular intervals. For long time ranges (e.g., 90 days), coarser intervals (≥ 1h) are required. + +## 3) Statistics + +You MUST Apply one statistic per expression (you can nest or chain via multiple queries/join operators). + +Common statistics: +- mean(), avg() — average (avg is identical to mean) +- sum() — sum of values per interval +- count() — number of observations in the interval +- min(), max() — min/max in the interval +- first(), last() — earliest/latest value in the interval +- rate() — per-interval average rate of change (per-second) +- increment() — per-interval change +- percentile(p) — 0.0 < p < 1.0 (e.g., `percentile(0.9)`) + +Absence statistic (special): +- absent(x) — returns 1 if a metric is absent for the entire interval; 0 if present + - Optional absence detection period (minutes/hours/days): `absent(20)`, `absent(2h)`, `absent(1d)` + - Default absence detection period is 2 hours (alarms can customize) + +Examples: +``` +CpuUtilization[1m].mean() +ServiceConnectorHubErrors[1m].count() +FileSystemReadRequestsBySize[5m]{size = "0B_to_8KiB"}.percentile(.50) +CpuUtilization[1m]{resourceId = ""}.groupBy(resourceId).absent(20) +``` + +## 4) Predicates (Thresholds and Absence) + +Use a predicate to keep only values that meet a condition. + +Supported operators: +- >, >=, <, <=, ==, != +- in (a, b) — inclusive range +- not in (a, b) — inclusive outside range +- absent() — absence predicate (see above) + +Examples: +``` +CpuUtilization[1m].mean() > 80 +CpuUtilization[1m].mean() in (60, 80) # inclusive range +ServiceConnectorHubErrors[1m].count() > 1 +``` + +## 5) Dimension Filters and Fuzzy Matching + +Filter series with exact matches: +``` +CpuUtilization[1m]{resourceId = "", availabilityDomain = "phx-AD-1"}.mean() +``` + +Use fuzzy matching for multiple values or wildcard patterns with `=~`: +- `|` — OR between values +- `*` — wildcard for zero or more characters + +Examples: +``` +CpuUtilization[1m]{resourceDisplayName =~ "ol8|ol7"}.min() >= 20 +CpuUtilization[1m]{resourceDisplayName =~ "instance-2023-*"}.min() >= 30 +CpuUtilization[1m]{faultDomain =~ "FAULT-DOMAIN-1|FAULT-DOMAIN-2"}.mean() +``` + +## 6) Grouping + +Aggregate across multiple streams and then compute a statistic: +``` +CpuUtilization[1m]{availabilityDomain = "VeBZ:PHX-AD-1"}.groupBy(poolId).percentile(0.9) > 85 +``` + +Common groupBy fields: `resourceId`, `availabilityDomain`, `faultDomain`, `resourceDisplayName`, service-specific dimensions. + +## 7) Arithmetic + +You can do math with metrics and constants: +``` +100 - CpuUtilization[1m].mean() # available CPU % +TotalRequestLatency[1m].mean() / 1000 # ms → seconds +``` + +## 8) Joining Multiple Queries + +Combine queries with logical AND/OR. Joins are only valid between complete queries (not between dimension sets). + +Operators: +- `&&` — AND +- `||` — OR + +Examples: +``` +CpuUtilization[1m]{faultDomain =~ "FAULT-DOMAIN-1|FAULT-DOMAIN-2"}.mean() +|| +MemoryUtilization[1m]{faultDomain =~ "FAULT-DOMAIN-1|FAULT-DOMAIN-2"}.mean() + +ServiceConnectorHubErrors[1m].count() > 1 +&& +ServiceConnectorHubErrors[1m].mean() > 0.5 +``` + +Invalid (don’t join inside a dimension set): +``` +# INVALID +CpuUtilization[1m]{faultDomain =~ "FD-1" || resourceDisplayName = "test"}.mean() +``` + +## 9) Practical Examples + +- Mean CPU over 1 minute: +``` +CpuUtilization[1m].mean() +``` + +- Errors per minute > 1: +``` +ServiceConnectorHubErrors[1m].count() > 1 +``` + +- 90th percentile CPU by pool in an AD, alarm when > 85: +``` +CpuUtilization[1m]{availabilityDomain = "VeBZ:PHX-AD-1"}.groupBy(poolId).percentile(0.9) > 85 +``` + +- Minimum CPU ≥ 20 for instances named ol8 or ol7: +``` +CpuUtilization[1m]{resourceDisplayName =~ "ol8|ol7"}.min() >= 20 +``` + +- Absence alarm for a specific resource (20 hours absence detection): +``` +CpuUtilization[1m]{resourceId = ""}.groupBy(resourceId).absent(20h) +``` + +- Join: CPU or Memory in fault domains 1 or 2: +``` +CpuUtilization[1m]{faultDomain =~ "FAULT-DOMAIN-1|FAULT-DOMAIN-2"}.mean() +|| +MemoryUtilization[1m]{faultDomain =~ "FAULT-DOMAIN-1|FAULT-DOMAIN-2"}.mean() +``` + +- Compute available CPU: +``` +100 - CpuUtilization[1m].mean() +``` + +## 10) Notes and Tips + +- Pick an interval aligned with the metric emission frequency (most service metrics are emitted every minute) +- For metric queries, interval drives default resolution and thus maximum time range returned +- For alarm queries, resolution is fixed at `1m` +- Use `groupBy()` to combine multiple metric streams before applying statistics +- Use `=~` with `|` and `*` to match multiple values and wildcard patterns +- For absence alarms, customize the absence detection period to match your operational expectations + +## References + +- MQL Reference: https://docs.oracle.com/en-us/iaas/Content/Monitoring/Reference/mql.htm +- Building Metric Queries: https://docs.oracle.com/en-us/iaas/Content/Monitoring/Tasks/buildingqueries.htm +- Selecting Interval: https://docs.oracle.com/en-us/iaas/Content/Monitoring/Tasks/query-metric-interval.htm +- Selecting Statistic: https://docs.oracle.com/en-us/iaas/Content/Monitoring/Tasks/query-metric-statistic.htm +- Fuzzy Matching: https://docs.oracle.com/en-us/iaas/Content/Monitoring/Reference/mql.htm#fuzzy-mql +- Join Queries: https://docs.oracle.com/en-us/iaas/Content/Monitoring/Reference/mql.htm#join diff --git a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/server.py b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/server.py index 2e4aa635..a127c1f5 100644 --- a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/server.py +++ b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/server.py @@ -5,18 +5,43 @@ """ import os +from datetime import datetime, timezone from logging import Logger -from typing import Annotated +from typing import Annotated, List, Optional, Tuple import oci -from fastmcp import FastMCP -from oci.monitoring.models import SummarizeMetricsDataDetails +from fastmcp import Context, FastMCP +from oci import Response +from oci.monitoring.models import ( + ListMetricsDetails, + SummarizeMetricsDataDetails, +) +from oracle.oci_monitoring_mcp_server.alarm_models import ( + AlarmSummary, + map_alarm_summary, +) +from oracle.oci_monitoring_mcp_server.metric_models import ( + CompartmentField, + CompartmentIdInSubtreeField, + ExampleNamespaces, + Metric, + MetricData, + NamespaceField, + map_metric, + map_metric_data, +) +from oracle.oci_monitoring_mcp_server.scripts import MQL_QUERY_DOC, get_script_content +from pydantic import Field from . import __project__, __version__ logger = Logger(__name__, level="INFO") -mcp = FastMCP(name=__project__) +mcp = FastMCP( + name=__project__, + instructions="Use this MCP server to run read-only commands and analyze " + "Monitoring Logs, Metrics, and Alarms.", +) def get_monitoring_client(): @@ -36,101 +61,228 @@ def get_monitoring_client(): return oci.monitoring.MonitoringClient(config, signer=signer) -@mcp.tool -def get_compute_metrics( - compartment_id: str, - start_time: str, - end_time: str, - metricName: Annotated[ +@mcp.tool(name="list_alarms", description="Lists all alarms in a given compartment") +def list_alarms( + compartment_id: Annotated[ str, - "The metric that the user wants to fetch. Currently we only support:" - "CpuUtilization, MemoryUtilization, DiskIopsRead, DiskIopsWritten," - "DiskBytesRead, DiskBytesWritten, NetworksBytesIn," - "NetworksBytesOut, LoadAverage, MemoryAllocationStalls", + "The ID of the compartment containing the resources" + "monitored by the metric that you are searching for.", ], - resolution: Annotated[ - str, - "The granularity of the metric. Currently we only support: 1m, 5m, 1h, 1d. Default: 1m.", - ] = "1m", - aggregation: Annotated[ - str, - "The aggregation for the metric. Currently we only support: " - "mean, sum, max, min, count. Default: mean", - ] = "mean", - instance_id: Annotated[ - str, - "Optional compute instance OCID to filter by " "(maps to resourceId dimension)", - ] = None, - compartment_id_in_subtree: Annotated[ - bool, - "Whether to include metrics from all subcompartments of the specified compartment", - ] = False, -) -> list[dict]: +) -> list[AlarmSummary] | str: monitoring_client = get_monitoring_client() - namespace = "oci_computeagent" - filter_clause = f'{{resourceId="{instance_id}"}}' if instance_id else "" - query = f"{metricName}[{resolution}]{filter_clause}.{aggregation}()" + response: Response | None = monitoring_client.list_alarms( + compartment_id=compartment_id + ) + if response is None: + logger.error("Received None response from list_metrics") + return "There was no response returned from the Monitoring API" + + alarms: List[oci.monitoring.models.AlarmSummary] = response.data + return [map_alarm_summary(alarm) for alarm in alarms] + - series_list = monitoring_client.summarize_metrics_data( - compartment_id=compartment_id, - summarize_metrics_data_details=SummarizeMetricsDataDetails( +@mcp.tool( + name="list_metric_definitions", + description="This tool returns the available metric definitions. " + "Use this tool when you do not know the name of the metric " + "or want to see all the available metric namespaces in a compartment. " + "If there are no results found, remove the metric name or namespace fields. ", +) +async def list_metric_definitions( + context: Context, + compartment_id: str = CompartmentField, + group_by: Optional[List[str]] = Field( + None, + description="Group metrics by these fields in the response. " + "For example, to list all metric namespaces available in a compartment, " + 'groupBy the "namespace" field. ' + "Supported fields: namespace, name, resourceGroup. " + "If groupBy is used, then dimensionFilters is ignored.", + examples=[["namespace"]], + ), + metric_name: Optional[str] = Field( + None, + description="The metric name to use when searching for metric definitions.", + ), + namespace: Optional[str] = Field( + None, + description="The source service or application to use when searching for metric definitions. " + "If you do not know the name of the namespace, leave it blank.", + examples=[ExampleNamespaces], + ), + resource_group: Optional[str] = Field( + None, + description="Resource group that you want to match. " + "A null value returns only metric data that has no resource groups. " + "The specified resource group must exist in the definition of the posted metric. " + "Only one resource group can be applied per metric. " + "A valid resourceGroup value starts with an alphabetical character " + "and includes only alphanumeric characters," + " periods (.), underscores (_), hyphens (-), and dollar signs ($).", + examples=["frontend-fleet"], + ), + compartment_id_in_subtree: bool = CompartmentIdInSubtreeField, +) -> List[Metric] | str: + try: + # Create client + monitoring_client = get_monitoring_client() + + list_metrics_details = ListMetricsDetails( + name=metric_name, + namespace=namespace, + resource_group=resource_group, + group_by=group_by, + ) + response: Response | None = monitoring_client.list_metrics( + compartment_id, + list_metrics_details=list_metrics_details, + compartment_id_in_subtree=compartment_id_in_subtree, + ) + + if response is None: + logger.error("Received None response from list_metrics") + await context.error("Received None response from list_metrics") + return "There was no response returned from the Monitoring API" + + data: List[oci.monitoring.models.Metric] = response.data + return [map_metric(metric) for metric in data] + except Exception as e: + logger.error(f"Error in get_available_metrics: {str(e)}") + await context.error(f"Error getting metric data: {str(e)}") + raise + + +def _prepare_time_parameters(start_time, end_time) -> Tuple[datetime, datetime]: + """Process time parameters and calculate the period.""" + # Convert string times to datetime objects + if isinstance(start_time, str): + start_time = datetime.fromisoformat(start_time.replace("Z", "+00:00")) + + if end_time is None: + end_time = datetime.now(timezone.utc) + elif isinstance(end_time, str): + end_time = datetime.fromisoformat(end_time.replace("Z", "+00:00")) + + return start_time, end_time + + +@mcp.tool( + name="get_metrics_data", + description="This tool retrieves aggregated metric data points in the OCI monitoring service." + "Use the query and optional properties to filter the returned results. " + "If there are no aggregated data points returned, " + "suggest using another query or expanding the time range." + "Only use this tool if you already know the MQL query." + "If you do not know the query, you MUST use the MQL Syntax Guide resource.", +) +async def get_metrics_data( + context: Context, + compartment_id: str = CompartmentField, + query: str = Field( + description="The Monitoring Query Language (MQL) expression " + "to use when searching for metric data points to aggregate. " + "The query must specify a metric, statistic, and interval." + "Supported values for interval depend on the specified time range. " + "More interval values are supported for smaller time ranges. " + "You can optionally specify dimensions and grouping functions. " + "When specifying a dimension value, surround it with double quotes," + "and escape each double quote with a backslash (`) character.", + examples=[ + "CpuUtilization[1m].sum()", + "BytesReceived[1h].mean()", + ], + ), + start_time: Optional[str] = Field( + "2025-11-04T18:17:00.000Z", + description="The beginning of the time range to use when searching for metric data points. " + "Format is defined by RFC3339. " + "The response is inclusive of metric data points for the startTime. " + "If no value is provided, this value will be the timestamp 3 hours before the call was sent.", + examples=["2023-02-01T01:02:29.600Z", "2023-03-10T22:44:26.789Z"], + ), + end_time: Optional[str] = Field( + None, + description="The end of the time range to use when searching for metric data points. " + "Format is defined by RFC3339. " + "The response is exclusive metric data points for the endTime. " + "If no value is provided, this value will be the timestamp representing when the call was sent.", + examples=["2023-02-01T01:02:29.600Z", "2023-03-10T22:44:26.789Z"], + ), + namespace: str = NamespaceField, + resource_group: Optional[str] = Field( + None, + description="Resource group that you want to match. " + "A null value returns only metric data that has no resource groups. " + "The specified resource group must exist in the definition of the posted metric. " + "Only one resource group can be applied per metric. " + "A valid resourceGroup value starts with an alphabetical character " + "and includes only alphanumeric characters," + " periods (.), underscores (_), hyphens (-), and dollar signs ($).", + examples=["frontend-fleet"], + ), + resolution: Optional[str] = Field( + "1m", + description="The time between calculated aggregation windows." + "Use with the query interval to vary the frequency for returning aggregated data points. " + "For example, use a query interval of 5 minutes with a resolution of " + "1 minute to retrieve five-minute aggregations at a one-minute frequency. " + "The resolution must 'be equal or less than the interval in the query. " + "The default resolution is 1m (one minute).", + examples=["1m", "5m", "1h", "1d"], + ), + compartment_id_in_subtree: Optional[bool] = CompartmentIdInSubtreeField, +) -> List[MetricData] | str: + try: + # Process time parameters and calculate period + start_time_obj, end_time_obj = _prepare_time_parameters(start_time, end_time) + start_time = start_time_obj.isoformat().replace("+00:00", "Z") + end_time = end_time_obj.isoformat().replace("+00:00", "Z") + + logger.info(f"Calling get metrics data with these parameters: {query}") + + # Create client + monitoring_client = get_monitoring_client() + + # Call Summarize metrics data api and process the results + summarize_metrics_data_details = SummarizeMetricsDataDetails( namespace=namespace, query=query, start_time=start_time, end_time=end_time, + resource_group=resource_group, resolution=resolution, - ), - compartment_id_in_subtree=compartment_id_in_subtree, - ).data - - result: list[dict] = [] - for series in series_list: - dims = getattr(series, "dimensions", None) - points = [] - for p in getattr(series, "aggregated_datapoints", []): - points.append( - { - "timestamp": getattr(p, "timestamp", None), - "value": getattr(p, "value", None), - } - ) - result.append( - { - "dimensions": dims, - "datapoints": points, - } ) - return result + response: Response | None = monitoring_client.summarize_metrics_data( + compartment_id, + summarize_metrics_data_details=summarize_metrics_data_details, + compartment_id_in_subtree=compartment_id_in_subtree, + ) + if response is None: + logger.error("Received None response from summarize_metrics_data") + await context.error("Received None response from summarize_metrics_data") + return "There was no response returned from the Monitoring API" -@mcp.tool -def list_alarms( - compartment_id: Annotated[ - str, - "The ID of the compartment containing the resources" - "monitored by the metric that you are searching for.", - ], -) -> list[dict]: - monitoring_client = get_monitoring_client() - response = monitoring_client.list_alarms(compartment_id=compartment_id) - alarms = response.data - result = [] - for alarm in alarms: - result.append( - { - "id": alarm.id, - "display_name": alarm.display_name, - "severity": alarm.severity, - "lifecycle_state": alarm.lifecycle_state, - "namespace": alarm.namespace, - "query": alarm.query, - } - ) - return result + data: List[oci.monitoring.models.MetricData] = response.data + return [map_metric_data(metric) for metric in data] + except Exception as e: + logger.error(f"Error in get_metrics_data: {str(e)}") + await context.error(f"Error getting metrics data: {str(e)}") + raise -def main(): +@mcp.resource( + name="MQL Syntax Guide", + uri="resource://monitoring-query-syntax-guide", + description="A guide for OCI Monitoring Query Language (MQL), " + "including syntax, examples, and event types. " + "Use this for constructing mql queries in the get_metrics_data tool.", +) +def monitoring_query_syntax_guide() -> str: + return get_script_content(MQL_QUERY_DOC) + +def main(): host = os.getenv("ORACLE_MCP_HOST") port = os.getenv("ORACLE_MCP_PORT") diff --git a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/tests/test_monitoring_tools.py b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/tests/test_monitoring_tools.py index f87428c8..d4024d8d 100644 --- a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/tests/test_monitoring_tools.py +++ b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/tests/test_monitoring_tools.py @@ -4,64 +4,171 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import oci import pytest from fastmcp import Client +from oracle.oci_monitoring_mcp_server.alarm_models import ( + AlarmSummary, + map_alarm_summary, +) +from oracle.oci_monitoring_mcp_server.metric_models import ( + Metric, + map_metric, +) +from oracle.oci_monitoring_mcp_server.scripts import MQL_QUERY_DOC, get_script_content from oracle.oci_monitoring_mcp_server.server import mcp +@pytest.fixture +def mock_context(): + """Create mock MCP context.""" + context = Mock() + context.info = AsyncMock() + context.warning = AsyncMock() + context.error = AsyncMock() + return context + + class TestMonitoringTools: @pytest.mark.asyncio @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") - async def test_get_compute_metrics(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - - # Mock OCI summarize_metrics_data response with one series containing two points - mock_summarize_response = create_autospec(oci.response.Response) - series = MagicMock() - series.dimensions = {"resourceId": "instance1"} - series.aggregated_datapoints = [ - MagicMock(timestamp="2023-01-01T00:00:00Z", value=42.0), - MagicMock(timestamp="2023-01-01T00:01:00Z", value=43.5), - ] - mock_summarize_response.data = [series] - mock_client.summarize_metrics_data.return_value = mock_summarize_response - - # Call the MCP tool + async def test_get_metrics_data(self, mock_get_client): + metric = oci.monitoring.models.MetricData( + namespace="123", + resource_group=None, + dimensions={"resourceId": "instance1"}, + compartment_id="compartment1", + aggregated_datapoints=[ + MagicMock(timestamp="2023-01-01T00:00:00Z", value=42.0), + MagicMock(timestamp="2023-01-01T00:01:00Z", value=43.5), + ], + ) + + mock_get_client.return_value = Mock() + mock_list_response = Mock() + mock_list_response.data = [metric] + mock_get_client.return_value.summarize_metrics_data.return_value = ( + mock_list_response + ) + async with Client(mcp) as client: - result = ( - await client.call_tool( - "get_compute_metrics", - { - "compartment_id": "compartment1", - "start_time": "2023-01-01T00:00:00Z", - "end_time": "2023-01-01T01:00:00Z", - "metricName": "CpuUtilization", - "resolution": "1m", - "aggregation": "mean", - "instance_id": "instance1", - "compartment_id_in_subtree": False, - }, - ) - ).structured_content["result"] - - # Validate result structure and values - assert isinstance(result, list) - assert len(result) == 1 - assert result[0]["dimensions"] == {"resourceId": "instance1"} - assert "datapoints" in result[0] - assert len(result[0]["datapoints"]) == 2 - assert result[0]["datapoints"][0]["timestamp"] == "2023-01-01T00:00:00Z" - assert result[0]["datapoints"][0]["value"] == pytest.approx(42.0) + call_tool_result = await client.call_tool( + "get_metrics_data", + { + "query": "CpuUtilization[1m].sum()", + "compartment_id": "compartment1", + "start_time": "2023-01-01T00:00:00Z", + "end_time": "2023-01-01T00:00:00Z", + }, + ) + result = call_tool_result.structured_content["result"] + + assert result is not None + for metric in result: + assert metric["namespace"] == "123" + assert metric["compartment_id"] == "compartment1" + assert isinstance(metric["aggregated_datapoints"], list) + + @pytest.mark.asyncio + @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") + async def test_list_metric_definitions(self, mock_get_client): + metric1 = oci.monitoring.models.Metric( + namespace="123", + resource_group=None, + dimensions={"resourceId": "instance1"}, + compartment_id="compartment1", + ) + + metric2 = oci.monitoring.models.Metric( + namespace="123", + resource_group=None, + dimensions={"resourceId": "instance1"}, + compartment_id="compartment1", + ) + + mock_get_client.return_value = Mock() + mock_list_response = Mock() + mock_list_response.data = [metric1, metric2] + mock_get_client.return_value.list_metrics.return_value = mock_list_response + + async with Client(mcp) as client: + call_tool_result = await client.call_tool( + "list_metric_definitions", + { + "compartment_id": "compartment1", + }, + ) + result = call_tool_result.structured_content["result"] + + assert result is not None + for metric in result: + assert isinstance(map_metric(metric), Metric) + assert metric["compartment_id"] == "compartment1" + + @pytest.mark.asyncio + @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") + async def test_list_metric_definitions_empty(self, mock_get_client): + mock_get_client.return_value = Mock() + mock_list_response = None + mock_get_client.return_value.list_metrics.return_value = mock_list_response + + async with Client(mcp) as client: + call_tool_result = await client.call_tool( + "list_metric_definitions", + { + "compartment_id": "compartment1", + }, + ) + result = call_tool_result.structured_content["result"] + + assert result == "There was no response returned from the Monitoring API" @pytest.mark.asyncio @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") async def test_list_alarms(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_alarm1 = oci.monitoring.models.Alarm( + id="alarm1", + display_name="Test Alarm 1", + severity="CRITICAL", + lifecycle_state="ACTIVE", + namespace="oci_monitoring", + query="CpuUtilization[1m].mean() > 80", + ) + mock_alarm2 = oci.monitoring.models.Alarm( + id="alarm2", + display_name="Test Alarm 2", + severity="WARNING", + lifecycle_state="ACTIVE", + namespace="oci_monitoring", + query="MemoryUtilization[1m].mean() > 90", + ) + + mock_get_client.return_value = Mock() + mock_list_response = Mock() + mock_list_response.data = [mock_alarm1, mock_alarm2] + mock_get_client.return_value.list_alarms.return_value = mock_list_response + + async with Client(mcp) as client: + call_tool_result = await client.call_tool( + "list_alarms", {"compartment_id": "compartment1"} + ) + result = call_tool_result.structured_content["result"] + + for alarm in result: + assert alarm is not None + assert isinstance(map_alarm_summary(alarm), AlarmSummary) + + @pytest.mark.asyncio + @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") + async def test_list_alarms_with_overrides(self, mock_get_client): + alarm_override = oci.monitoring.models.AlarmOverride( + body="95% CPU utilization", + query="CPUUtilization[1m].mean()>95", + severity="CRITICAL", + ) + alarm_overrides = [alarm_override] mock_alarm1 = oci.monitoring.models.Alarm( id="alarm1", @@ -70,6 +177,7 @@ async def test_list_alarms(self, mock_get_client): lifecycle_state="ACTIVE", namespace="oci_monitoring", query="CpuUtilization[1m].mean() > 80", + overrides=alarm_overrides, ) mock_alarm2 = oci.monitoring.models.Alarm( id="alarm2", @@ -80,25 +188,34 @@ async def test_list_alarms(self, mock_get_client): query="MemoryUtilization[1m].mean() > 90", ) - mock_list_response = create_autospec(oci.response.Response) + mock_get_client.return_value = Mock() + mock_list_response = Mock() mock_list_response.data = [mock_alarm1, mock_alarm2] - mock_client.list_alarms.return_value = mock_list_response + mock_get_client.return_value.list_alarms.return_value = mock_list_response + + async with Client(mcp) as client: + call_tool_result = await client.call_tool( + "list_alarms", {"compartment_id": "compartment1"} + ) + result = call_tool_result.structured_content["result"] + + for alarm in result: + assert alarm is not None + assert isinstance(map_alarm_summary(alarm), AlarmSummary) + + @pytest.mark.asyncio + @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") + async def test_list_alarms_no_response(self, mock_get_client): + mock_get_client.return_value = Mock() + mock_list_response = None + mock_get_client.return_value.list_alarms.return_value = mock_list_response async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_alarms", - { - "compartment_id": "compartment1", - }, - ) - ).structured_content["result"] - - assert len(result) == 2 - assert result[0]["id"] == "alarm1" - assert result[0]["display_name"] == "Test Alarm 1" - assert result[1]["id"] == "alarm2" - assert result[1]["display_name"] == "Test Alarm 2" + call_tool_result = await client.call_tool( + "list_alarms", {"compartment_id": "compartment1"} + ) + result = call_tool_result.structured_content["result"] + assert result == "There was no response returned from the Monitoring API" class TestServer: @@ -152,3 +269,9 @@ def test_main_with_only_port(self, mock_getenv, mock_mcp_run): server.main() mock_mcp_run.assert_called_once_with() + + +class TestReadFile: + def test_read_file(self): + document = get_script_content(MQL_QUERY_DOC) + assert document is not None