scout_apm_mcp 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +8 -7
- data/lib/scout_apm_mcp/client/api.rb +221 -0
- data/lib/scout_apm_mcp/client/http_transport.rb +174 -0
- data/lib/scout_apm_mcp/client/query_params.rb +31 -0
- data/lib/scout_apm_mcp/client/validation.rb +90 -0
- data/lib/scout_apm_mcp/client.rb +23 -557
- data/lib/scout_apm_mcp/helpers/api_key_resolver.rb +69 -0
- data/lib/scout_apm_mcp/helpers/identifiers.rb +32 -0
- data/lib/scout_apm_mcp/helpers/scout_url_parser.rb +104 -0
- data/lib/scout_apm_mcp/helpers/time_range.rb +65 -0
- data/lib/scout_apm_mcp/helpers.rb +11 -274
- data/lib/scout_apm_mcp/mcp_error_id_patch.rb +11 -17
- data/lib/scout_apm_mcp/server/base_tool.rb +11 -0
- data/lib/scout_apm_mcp/server/null_logger.rb +51 -0
- data/lib/scout_apm_mcp/server/tools/fetch_openapi_schema_tool.rb +83 -0
- data/lib/scout_apm_mcp/server/tools/fetch_scout_url_tool.rb +155 -0
- data/lib/scout_apm_mcp/server/tools/parse_scout_url_tool.rb +36 -0
- data/lib/scout_apm_mcp/server/tools/standard_tools.rb +488 -0
- data/lib/scout_apm_mcp/server.rb +38 -769
- data/lib/scout_apm_mcp/version.rb +1 -1
- data/sig/scout_apm_mcp.rbs +5 -3
- metadata +55 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe458cddc45b80969f47263d81bbe57f2050d3202b905a55afa60369aa9ed6d7
|
|
4
|
+
data.tar.gz: de2e96c9019de8727d7752134e84f6e19a2d66dc14247cd34b8d6ee7df125983
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 34116b86965f6e2f15dd857a1e271e40966ccd92bb4a6d782c3d8a87bf58098b41129ef4caff286f91fe4857127b0a24b2b354988735e0faab5afaeebb0d2cda
|
|
7
|
+
data.tar.gz: 0b53a613b27315899f3f416fd589327b57969758ed5c0db5923c644b3f50788765b6b4264146e4668ee50901c79923f8c20295f130fb2e7b5dab2c46130e8e74
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.0 (2026-07-05)
|
|
6
|
+
|
|
7
|
+
- Add `list_anomaly_events` and `get_anomaly_event` client methods and MCP tools.
|
|
8
|
+
- Add `sort_by`, `limit`, and `offset` to `list_endpoints` for paginated endpoint listings.
|
|
9
|
+
- Return extracted `results` from `get_insights_history` and `get_insights_history_by_type`; validate `insight_type` on history-by-type requests.
|
|
10
|
+
- Retry transient ScoutAPM GET failures (5xx and network timeouts) with bounded exponential backoff.
|
|
11
|
+
- Fix `FetchTraceTool` `include_endpoint` so `trace_metric_name` is read from the extracted trace payload.
|
|
12
|
+
- Fix MCP JSON-RPC error responses for strict MCP clients.
|
|
13
|
+
- Reuse one ScoutAPM API client per MCP server process instead of creating a client per tool call.
|
|
14
|
+
|
|
3
15
|
## 0.1.6 (2026-03-29)
|
|
4
16
|
|
|
5
17
|
- Background jobs API support: `list_jobs`, `list_job_metrics`, `get_job_metrics`, `list_job_traces` on the client, matching MCP tools, and URL parsing / `FetchScoutURLTool` handling for job and job-trace links.
|
data/README.md
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
# scout_apm_mcp
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/scout_apm_mcp) [](https://github.com/amkisko/scout_apm_mcp.rb/actions/workflows/test.yml) [](https://codecov.io/gh/amkisko/scout_apm_mcp.rb)
|
|
4
4
|
|
|
5
5
|
Ruby gem providing ScoutAPM API client and MCP (Model Context Protocol) server tools for fetching traces, endpoints, metrics, errors, and insights. Integrates with MCP-compatible clients like Cursor IDE, Claude Desktop, and other MCP-enabled tools.
|
|
6
6
|
|
|
7
|
-
Sponsored by [Kisko Labs](https://www.kiskolabs.com).
|
|
8
|
-
|
|
9
|
-
<a href="https://www.kiskolabs.com">
|
|
10
|
-
<img src="kisko.svg" width="200" alt="Sponsored by Kisko Labs" />
|
|
11
|
-
</a>
|
|
12
|
-
|
|
13
7
|
## Requirements
|
|
14
8
|
|
|
15
9
|
- **Ruby 3.1 or higher** (Ruby 3.0 and earlier are not supported)
|
|
@@ -378,3 +372,10 @@ If you discover a security vulnerability, please report it responsibly. See [SEC
|
|
|
378
372
|
|
|
379
373
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
380
374
|
|
|
375
|
+
## Sponsors
|
|
376
|
+
|
|
377
|
+
Sponsored by [Kisko Labs](https://www.kiskolabs.com).
|
|
378
|
+
|
|
379
|
+
<a href="https://www.kiskolabs.com">
|
|
380
|
+
<img src="kisko.svg" width="200" alt="Sponsored by Kisko Labs" />
|
|
381
|
+
</a>
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
module ScoutApmMcp
|
|
2
|
+
class Client
|
|
3
|
+
module Api
|
|
4
|
+
module Apps
|
|
5
|
+
def list_apps(active_since: nil)
|
|
6
|
+
uri = URI("#{@api_base}/apps")
|
|
7
|
+
response = make_request(uri)
|
|
8
|
+
apps = response.dig("results", "apps") || []
|
|
9
|
+
return apps unless active_since
|
|
10
|
+
|
|
11
|
+
active_time = Helpers.parse_time(active_since)
|
|
12
|
+
apps.select do |app|
|
|
13
|
+
reported_at = app["last_reported_at"]
|
|
14
|
+
reported_at && !reported_at.empty? && Helpers.parse_time(reported_at) >= active_time
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get_app(app_id)
|
|
19
|
+
uri = URI("#{@api_base}/apps/#{app_id}")
|
|
20
|
+
response = make_request(uri)
|
|
21
|
+
response.dig("results", "app") || {}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module Metrics
|
|
26
|
+
def list_metrics(app_id)
|
|
27
|
+
uri = URI("#{@api_base}/apps/#{app_id}/metrics")
|
|
28
|
+
response = make_request(uri)
|
|
29
|
+
response.dig("results", "availableMetrics") || []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def get_metric(app_id, metric_type, from: nil, to: nil, range: nil)
|
|
33
|
+
times = resolve_range_times(from: from, to: to, range: range)
|
|
34
|
+
validate_metric_params(metric_type, times[:from], times[:to])
|
|
35
|
+
uri = URI("#{@api_base}/apps/#{app_id}/metrics/#{metric_type}")
|
|
36
|
+
assign_query(uri, from: times[:from], to: times[:to])
|
|
37
|
+
response = make_request(uri)
|
|
38
|
+
response.dig("results", "series") || {}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
module Endpoints
|
|
43
|
+
def list_endpoints(app_id, from: nil, to: nil, range: nil, sort_by: nil, limit: nil, offset: nil)
|
|
44
|
+
times = resolve_listing_time_range(from: from, to: to, range: range)
|
|
45
|
+
validate_sort_by!(sort_by)
|
|
46
|
+
|
|
47
|
+
uri = URI("#{@api_base}/apps/#{app_id}/endpoints")
|
|
48
|
+
assign_query(uri, from: times[:from], to: times[:to], sort_by: sort_by, limit: limit, offset: offset)
|
|
49
|
+
response = make_request(uri)
|
|
50
|
+
endpoint_listing_results(response, sort_by: sort_by, limit: limit, offset: offset)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_endpoint_metrics(app_id, endpoint_id, metric_type, from: nil, to: nil, range: nil)
|
|
54
|
+
times = resolve_range_times(from: from, to: to, range: range)
|
|
55
|
+
validate_metric_params(metric_type, times[:from], times[:to])
|
|
56
|
+
uri = api_uri("apps", app_id, "endpoints", endpoint_id, "metrics", metric_type)
|
|
57
|
+
assign_query(uri, from: times[:from], to: times[:to])
|
|
58
|
+
response = make_request(uri)
|
|
59
|
+
series = response.dig("results", "series") || {}
|
|
60
|
+
series[metric_type] || []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def list_endpoint_traces(app_id, endpoint_id, from: nil, to: nil, range: nil)
|
|
64
|
+
times = resolve_range_times(from: from, to: to, range: range)
|
|
65
|
+
validate_trace_time_range(times[:from], times[:to])
|
|
66
|
+
uri = api_uri("apps", app_id, "endpoints", endpoint_id, "traces")
|
|
67
|
+
assign_query(uri, from: times[:from], to: times[:to])
|
|
68
|
+
response = make_request(uri)
|
|
69
|
+
response.dig("results", "traces") || []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def endpoint_listing_results(response, sort_by:, limit:, offset:)
|
|
75
|
+
results = response["results"]
|
|
76
|
+
paginated = !sort_by.nil? || !limit.nil? || !offset.nil?
|
|
77
|
+
paginated ? (results || {}) : (results || [])
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
module Jobs
|
|
82
|
+
def list_jobs(app_id, from: nil, to: nil, range: nil)
|
|
83
|
+
times = resolve_listing_time_range(from: from, to: to, range: range)
|
|
84
|
+
uri = URI("#{@api_base}/apps/#{app_id}/jobs")
|
|
85
|
+
assign_query(uri, from: times[:from], to: times[:to])
|
|
86
|
+
response = make_request(uri)
|
|
87
|
+
response["results"] || []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def list_job_metrics(app_id, job_id)
|
|
91
|
+
uri = api_uri("apps", app_id, "jobs", job_id, "metrics")
|
|
92
|
+
response = make_request(uri)
|
|
93
|
+
response.dig("results", "availableMetrics") || []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def get_job_metrics(app_id, job_id, metric_type, from: nil, to: nil, range: nil)
|
|
97
|
+
times = resolve_range_times(from: from, to: to, range: range)
|
|
98
|
+
validate_job_metric_params(metric_type, times[:from], times[:to])
|
|
99
|
+
uri = api_uri("apps", app_id, "jobs", job_id, "metrics", metric_type)
|
|
100
|
+
assign_query(uri, from: times[:from], to: times[:to])
|
|
101
|
+
response = make_request(uri)
|
|
102
|
+
series = response.dig("results", "series") || {}
|
|
103
|
+
series[metric_type] || []
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def list_job_traces(app_id, job_id, from: nil, to: nil, range: nil)
|
|
107
|
+
times = resolve_range_times(from: from, to: to, range: range)
|
|
108
|
+
validate_trace_time_range(times[:from], times[:to])
|
|
109
|
+
uri = api_uri("apps", app_id, "jobs", job_id, "traces")
|
|
110
|
+
assign_query(uri, from: times[:from], to: times[:to])
|
|
111
|
+
response = make_request(uri)
|
|
112
|
+
response.dig("results", "traces") || []
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
module Traces
|
|
117
|
+
def fetch_trace(app_id, trace_id)
|
|
118
|
+
uri = URI("#{@api_base}/apps/#{app_id}/traces/#{trace_id}")
|
|
119
|
+
response = make_request(uri)
|
|
120
|
+
response.dig("results", "trace") || {}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
module ErrorGroups
|
|
125
|
+
def list_error_groups(app_id, from: nil, to: nil, endpoint: nil)
|
|
126
|
+
validate_time_range(from, to) if from && to
|
|
127
|
+
uri = URI("#{@api_base}/apps/#{app_id}/error_groups")
|
|
128
|
+
assign_query(uri, from: from, to: to, endpoint: endpoint)
|
|
129
|
+
response = make_request(uri)
|
|
130
|
+
response.dig("results", "error_groups") || []
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def get_error_group(app_id, error_id)
|
|
134
|
+
uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}")
|
|
135
|
+
response = make_request(uri)
|
|
136
|
+
response.dig("results", "error_group") || {}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def get_error_group_errors(app_id, error_id)
|
|
140
|
+
uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}/errors")
|
|
141
|
+
response = make_request(uri)
|
|
142
|
+
response.dig("results", "errors") || []
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
module AnomalyEvents
|
|
147
|
+
def list_anomaly_events(app_id, from: nil, to: nil, range: nil, state: nil, metric: nil, endpoint: nil)
|
|
148
|
+
times = resolve_range_times(from: from, to: to, range: range)
|
|
149
|
+
validate_time_range(times[:from], times[:to]) if times[:from] && times[:to]
|
|
150
|
+
validate_anomaly_state!(state)
|
|
151
|
+
|
|
152
|
+
uri = URI("#{@api_base}/apps/#{app_id}/anomaly_events")
|
|
153
|
+
assign_query(uri, from: times[:from], to: times[:to], state: state, metric: metric, endpoint: endpoint)
|
|
154
|
+
response = make_request(uri)
|
|
155
|
+
response.dig("results", "anomaly_events") || []
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def get_anomaly_event(app_id, anomaly_event_id)
|
|
159
|
+
uri = URI("#{@api_base}/apps/#{app_id}/anomaly_events/#{anomaly_event_id}")
|
|
160
|
+
response = make_request(uri)
|
|
161
|
+
response.dig("results", "anomaly_event") || {}
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
module Insights
|
|
166
|
+
def get_all_insights(app_id, limit: nil)
|
|
167
|
+
uri = URI("#{@api_base}/apps/#{app_id}/insights")
|
|
168
|
+
assign_query(uri, limit: limit)
|
|
169
|
+
response = make_request(uri)
|
|
170
|
+
response["results"] || {}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def get_insight_by_type(app_id, insight_type, limit: nil)
|
|
174
|
+
validate_insight_type(insight_type)
|
|
175
|
+
uri = URI("#{@api_base}/apps/#{app_id}/insights/#{insight_type}")
|
|
176
|
+
assign_query(uri, limit: limit)
|
|
177
|
+
response = make_request(uri)
|
|
178
|
+
response["results"] || {}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def get_insights_history(app_id, from: nil, to: nil, limit: nil, pagination_cursor: nil, pagination_direction: nil, pagination_page: nil)
|
|
182
|
+
uri = URI("#{@api_base}/apps/#{app_id}/insights/history")
|
|
183
|
+
assign_query(
|
|
184
|
+
uri,
|
|
185
|
+
from: from,
|
|
186
|
+
to: to,
|
|
187
|
+
limit: limit,
|
|
188
|
+
pagination_cursor: pagination_cursor,
|
|
189
|
+
pagination_direction: pagination_direction,
|
|
190
|
+
pagination_page: pagination_page
|
|
191
|
+
)
|
|
192
|
+
response = make_request(uri)
|
|
193
|
+
response["results"] || {}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def get_insights_history_by_type(app_id, insight_type, from: nil, to: nil, limit: nil, pagination_cursor: nil, pagination_direction: nil, pagination_page: nil)
|
|
197
|
+
validate_insight_type(insight_type)
|
|
198
|
+
uri = URI("#{@api_base}/apps/#{app_id}/insights/history/#{insight_type}")
|
|
199
|
+
assign_query(
|
|
200
|
+
uri,
|
|
201
|
+
from: from,
|
|
202
|
+
to: to,
|
|
203
|
+
limit: limit,
|
|
204
|
+
pagination_cursor: pagination_cursor,
|
|
205
|
+
pagination_direction: pagination_direction,
|
|
206
|
+
pagination_page: pagination_page
|
|
207
|
+
)
|
|
208
|
+
response = make_request(uri)
|
|
209
|
+
response["results"] || {}
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
module OpenApi
|
|
214
|
+
def fetch_openapi_schema
|
|
215
|
+
uri = URI("https://scoutapm.com/api/v0/openapi.yaml")
|
|
216
|
+
fetch_openapi_schema_response(uri)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
module ScoutApmMcp
|
|
2
|
+
class Client
|
|
3
|
+
module HttpTransport
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def build_http_client(uri)
|
|
7
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
8
|
+
http.read_timeout = 10
|
|
9
|
+
http.open_timeout = 10
|
|
10
|
+
configure_ssl(http, uri) if uri.scheme == "https"
|
|
11
|
+
http
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def make_request(uri)
|
|
15
|
+
attempt = 0
|
|
16
|
+
|
|
17
|
+
loop do
|
|
18
|
+
attempt += 1
|
|
19
|
+
return perform_request(uri)
|
|
20
|
+
rescue APIError => error
|
|
21
|
+
retry_api_error!(error, attempt)
|
|
22
|
+
rescue OpenSSL::SSL::SSLError => error
|
|
23
|
+
raise_ssl_error(error)
|
|
24
|
+
rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT => error
|
|
25
|
+
retry_connection_error!(error, attempt)
|
|
26
|
+
rescue Error
|
|
27
|
+
raise
|
|
28
|
+
rescue => error
|
|
29
|
+
raise Error, "Request failed: #{error.class} - #{error.message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def perform_request(uri)
|
|
34
|
+
http = build_http_client(uri)
|
|
35
|
+
request = build_json_request(uri)
|
|
36
|
+
response = http.request(request)
|
|
37
|
+
response_data = handle_response_errors(response)
|
|
38
|
+
validate_api_status_header(response_data)
|
|
39
|
+
response_data
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def handle_response_errors(response)
|
|
43
|
+
data = parse_response_body(response)
|
|
44
|
+
|
|
45
|
+
case response
|
|
46
|
+
when Net::HTTPSuccess
|
|
47
|
+
data
|
|
48
|
+
when Net::HTTPUnauthorized
|
|
49
|
+
raise AuthError, "Authentication failed - check your API key"
|
|
50
|
+
when Net::HTTPNotFound
|
|
51
|
+
raise APIError.new("Resource not found", status_code: 404, response_data: data)
|
|
52
|
+
else
|
|
53
|
+
raise_api_error(response, data)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def fetch_openapi_schema_response(uri)
|
|
58
|
+
http = build_http_client(uri)
|
|
59
|
+
request = build_openapi_request(uri)
|
|
60
|
+
response = http.request(request)
|
|
61
|
+
handle_openapi_response(response)
|
|
62
|
+
rescue OpenSSL::SSL::SSLError => error
|
|
63
|
+
raise_ssl_error(error)
|
|
64
|
+
rescue Error
|
|
65
|
+
raise
|
|
66
|
+
rescue => error
|
|
67
|
+
raise Error, "Request failed: #{error.class} - #{error.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def configure_ssl(http, uri)
|
|
71
|
+
return unless uri.scheme == "https"
|
|
72
|
+
|
|
73
|
+
http.use_ssl = true
|
|
74
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
75
|
+
ca_file = ssl_certificate_file
|
|
76
|
+
http.ca_file = ca_file if ca_file
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ssl_certificate_file
|
|
80
|
+
if ENV["SSL_CERT_FILE"] && File.file?(ENV["SSL_CERT_FILE"])
|
|
81
|
+
ENV["SSL_CERT_FILE"]
|
|
82
|
+
elsif File.exist?(OpenSSL::X509::DEFAULT_CERT_FILE)
|
|
83
|
+
OpenSSL::X509::DEFAULT_CERT_FILE
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_json_request(uri)
|
|
88
|
+
request = Net::HTTP::Get.new(uri)
|
|
89
|
+
request["X-SCOUT-API"] = @api_key
|
|
90
|
+
request["User-Agent"] = @user_agent
|
|
91
|
+
request["Accept"] = "application/json"
|
|
92
|
+
request
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_openapi_request(uri)
|
|
96
|
+
request = Net::HTTP::Get.new(uri)
|
|
97
|
+
request["X-SCOUT-API"] = @api_key
|
|
98
|
+
request["User-Agent"] = @user_agent
|
|
99
|
+
request["Accept"] = "application/x-yaml, application/yaml, text/yaml, */*"
|
|
100
|
+
request
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_response_body(response)
|
|
104
|
+
JSON.parse(response.body)
|
|
105
|
+
rescue JSON::ParserError
|
|
106
|
+
raise APIError.new("Invalid JSON response: #{response.body}", status_code: response.code.to_i)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_api_status_header(response_data)
|
|
110
|
+
return unless response_data.is_a?(Hash)
|
|
111
|
+
|
|
112
|
+
header = response_data["header"]
|
|
113
|
+
return unless header && header["status"]
|
|
114
|
+
|
|
115
|
+
status_code = header["status"]["code"]
|
|
116
|
+
return unless status_code && status_code >= 400
|
|
117
|
+
|
|
118
|
+
error_message = header["status"]["message"] || "Unknown API error"
|
|
119
|
+
raise APIError.new(error_message, status_code: status_code, response_data: response_data)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def raise_api_error(response, data)
|
|
123
|
+
error_message = "API request failed"
|
|
124
|
+
if data.is_a?(Hash) && data.dig("header", "status", "message")
|
|
125
|
+
error_message = data.dig("header", "status", "message")
|
|
126
|
+
end
|
|
127
|
+
raise APIError.new(error_message, status_code: response.code.to_i, response_data: data)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def handle_openapi_response(response)
|
|
131
|
+
case response
|
|
132
|
+
when Net::HTTPSuccess
|
|
133
|
+
{
|
|
134
|
+
content: response.body,
|
|
135
|
+
content_type: response.content_type,
|
|
136
|
+
status: response.code.to_i
|
|
137
|
+
}
|
|
138
|
+
when Net::HTTPUnauthorized
|
|
139
|
+
raise AuthError, "Authentication failed. Check your API key."
|
|
140
|
+
else
|
|
141
|
+
raise APIError.new("API request failed: #{response.code} #{response.message}", status_code: response.code.to_i)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def raise_ssl_error(error)
|
|
146
|
+
raise Error, "SSL verification failed: #{error.message}. This may be due to system certificate configuration issues."
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def raise_connection_error(error)
|
|
150
|
+
raise Error, "Request failed: #{error.class} - #{error.message}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def retryable_api_error?(error)
|
|
154
|
+
error.is_a?(APIError) && error.status_code && RETRYABLE_HTTP_STATUS_CODES.include?(error.status_code)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def retry_api_error!(error, attempt)
|
|
158
|
+
raise unless retryable_api_error?(error) && attempt < MAX_REQUEST_ATTEMPTS
|
|
159
|
+
|
|
160
|
+
sleep(retry_backoff_seconds(attempt))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def retry_connection_error!(error, attempt)
|
|
164
|
+
raise_connection_error(error) if attempt >= MAX_REQUEST_ATTEMPTS
|
|
165
|
+
|
|
166
|
+
sleep(retry_backoff_seconds(attempt))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def retry_backoff_seconds(attempt)
|
|
170
|
+
RETRY_BASE_DELAY_SECONDS * (2**(attempt - 1))
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module ScoutApmMcp
|
|
2
|
+
class Client
|
|
3
|
+
module QueryParams
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def build_query_string(from: nil, to: nil)
|
|
7
|
+
encode_query_params("from" => from, "to" => to)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def encode_query_params(params)
|
|
11
|
+
filtered = params.each_with_object({}) do |(key, value), hash|
|
|
12
|
+
hash[key] = value unless value.nil?
|
|
13
|
+
end
|
|
14
|
+
return nil if filtered.empty?
|
|
15
|
+
|
|
16
|
+
URI.encode_www_form(filtered)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def assign_query(uri, **params)
|
|
20
|
+
query = encode_query_params(params.transform_keys(&:to_s))
|
|
21
|
+
uri.query = query if query
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def api_uri(*path_segments)
|
|
25
|
+
uri = URI(@api_base)
|
|
26
|
+
uri.path = File.join(uri.path, *path_segments.map(&:to_s))
|
|
27
|
+
uri
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module ScoutApmMcp
|
|
2
|
+
class Client
|
|
3
|
+
module Validation
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def validate_insight_type(insight_type)
|
|
7
|
+
return if VALID_INSIGHTS.include?(insight_type)
|
|
8
|
+
|
|
9
|
+
raise ArgumentError, "Invalid insight_type. Must be one of: #{VALID_INSIGHTS.join(", ")}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def validate_metric_params(metric_type, from, to)
|
|
13
|
+
unless VALID_METRICS.include?(metric_type)
|
|
14
|
+
raise ArgumentError, "Invalid metric_type. Must be one of: #{VALID_METRICS.join(", ")}"
|
|
15
|
+
end
|
|
16
|
+
validate_time_range(from, to) if from && to
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate_job_metric_params(metric_type, from, to)
|
|
20
|
+
unless VALID_JOB_METRICS.include?(metric_type)
|
|
21
|
+
raise ArgumentError, "Invalid metric_type. Must be one of: #{VALID_JOB_METRICS.join(", ")}"
|
|
22
|
+
end
|
|
23
|
+
validate_time_range(from, to) if from && to
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_time_range(from, to)
|
|
27
|
+
return unless from && to
|
|
28
|
+
|
|
29
|
+
from_time = Helpers.parse_time(from)
|
|
30
|
+
to_time = Helpers.parse_time(to)
|
|
31
|
+
|
|
32
|
+
if from_time >= to_time
|
|
33
|
+
raise ArgumentError, "from_time must be before to_time"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
max_duration = 14 * 24 * 60 * 60
|
|
37
|
+
if (to_time - from_time) > max_duration
|
|
38
|
+
raise ArgumentError, "Time range cannot exceed 2 weeks"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve_range_times(from:, to:, range:)
|
|
43
|
+
return {from: from, to: to} unless range
|
|
44
|
+
|
|
45
|
+
Helpers.calculate_range(range: range, to: to)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resolve_listing_time_range(from:, to:, range:, default_range: "7days")
|
|
49
|
+
range = default_range if listing_range_unset?(from, to, range)
|
|
50
|
+
times = resolve_range_times(from: from, to: to, range: range)
|
|
51
|
+
from, to = normalize_listing_bounds(times[:from], times[:to])
|
|
52
|
+
validate_time_range(from, to) if from && to
|
|
53
|
+
{from: from, to: to}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def listing_range_unset?(from, to, range)
|
|
57
|
+
from.nil? && to.nil? && range.nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_listing_bounds(from, to)
|
|
61
|
+
from = Helpers.calculate_range(range: "7days", to: to)[:from] if from.nil? && to
|
|
62
|
+
to = Helpers.format_time(Time.now.utc) if from && to.nil?
|
|
63
|
+
[from, to]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_trace_time_range(from, to)
|
|
67
|
+
validate_time_range(from, to) if from && to
|
|
68
|
+
return unless from && to
|
|
69
|
+
|
|
70
|
+
from_time = Helpers.parse_time(from)
|
|
71
|
+
seven_days_ago = Time.now.utc - (7 * 24 * 60 * 60)
|
|
72
|
+
if from_time < seven_days_ago
|
|
73
|
+
raise ArgumentError, "from_time cannot be older than 7 days"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_sort_by!(sort_by)
|
|
78
|
+
return if sort_by.nil? || VALID_ENDPOINT_SORT_BY.include?(sort_by)
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "Invalid sort_by. Must be one of: #{VALID_ENDPOINT_SORT_BY.join(", ")}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_anomaly_state!(state)
|
|
84
|
+
return if state.nil? || VALID_ANOMALY_STATES.include?(state)
|
|
85
|
+
|
|
86
|
+
raise ArgumentError, "Invalid state. Must be one of: #{VALID_ANOMALY_STATES.join(", ")}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|