scout_apm_mcp 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fe2bbad61c1acff6ae4c31b52a7e820acdc64449be4ae998a574c1937ac4775c
4
- data.tar.gz: a2c68cf35aa37975a99cf98b68fa29a91673c449f983bbb96547b6403df8401f
3
+ metadata.gz: 24de549e2d8e310ce563f8a9c380f642aabdd528259785ed17ae062c8b8e59aa
4
+ data.tar.gz: 5a9c147401b3136be74f5197ec757b4f1d357c1ad5483f12c0813315f6d6d9e8
5
5
  SHA512:
6
- metadata.gz: e591d27a417285348f7257f150aeb11a9fa4390227893fc996fadf65d08dd856fd4121f3780e7d5403ecb3e985d5c3f2389dce5a99c3d4f57ad9f47bb4123876
7
- data.tar.gz: 6112164513df8a51d4e0c039f9245621869ea4533a54ad493df3ec3e4aa67b738fd4952a0507c740a35a1c8568705ed72f8c2cf9d927603bd85fb9080b671f46
6
+ metadata.gz: 509eeae8882791a33706ceda9a71a4095ef6e99abda4e7401082435d0d6e055882ebced248e176c74154bfa28db09eb965e49c5df88e8fa3c581f0f1e9dac782
7
+ data.tar.gz: 33143a776cb940411007c14be1a34691a5569275fa8c3c507b2eca92846c8585bcfa438906b135dd31f986c6fbbd0ff7a98c7be74e27491242aa9f751b410202
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.1.3 (2025-11-21)
4
+
5
+ - Custom exception classes (`ScoutApmMcp::Error`, `ScoutApmMcp::AuthError`, `ScoutApmMcp::APIError`) for better error handling
6
+ - Input validation for metric types (`VALID_METRICS` constant) and insight types (`VALID_INSIGHTS` constant)
7
+ - Time range validation (ensures from_time < to_time and range doesn't exceed 2 weeks)
8
+ - Trace age validation (ensures trace queries aren't older than 7 days)
9
+ - Client methods now return extracted data instead of full API response structure
10
+ - Time/Duration helpers (`Helpers.format_time`, `Helpers.parse_time`, `Helpers.make_duration`)
11
+ - Endpoint ID extraction helper (`Helpers.get_endpoint_id`)
12
+ - User-Agent header (`scout-apm-mcp-rb/VERSION`) on all API requests
13
+ - `active_since` parameter to `list_apps` method for filtering apps by last reported time
14
+ - API-level error parsing - checks for `header.status.code` in response body
15
+ - Error handling now uses custom exception classes instead of generic `RuntimeError`
16
+ - MCP `ListAppsTool` now supports optional `active_since` parameter
17
+ - Error responses now properly parse API-level error codes from response body
18
+ - Invalid metric types, insight types, and time ranges are now validated before API calls
19
+
3
20
  ## 0.1.2 (2025-11-21)
4
21
 
5
22
  - Enhanced SSL certificate handling with support for `SSL_CERT_FILE` environment variable and automatic fallback to system certificates
data/README.md CHANGED
@@ -113,12 +113,18 @@ The server will start and communicate via STDIN/STDOUT using the MCP protocol. M
113
113
  - **MCP Server Integration**: Ready-to-use MCP server compatible with Cursor IDE, Claude Desktop, and other MCP-enabled tools
114
114
  - **API Key Management**: Supports environment variables and 1Password integration (via optional `opdotenv` gem)
115
115
  - **URL Parsing**: Helper methods to parse ScoutAPM URLs and extract IDs
116
+ - **Time Utilities**: Helper methods for formatting, parsing, and working with ISO 8601 time strings
117
+ - **Input Validation**: Validates metric types, insight types, and time ranges before API calls
118
+ - **Custom Error Handling**: Dedicated exception classes for better error handling and debugging
116
119
  - **Comprehensive API Coverage**: Supports all ScoutAPM API endpoints (apps, metrics, endpoints, traces, errors, insights)
120
+ - **Data Extraction**: Client methods return extracted data instead of full API response structure for easier usage
117
121
 
118
122
  ## Basic Usage
119
123
 
120
124
  ### API Client
121
125
 
126
+ **Note**: As of version 0.1.3, all client methods return extracted data (arrays, hashes) instead of the full API response structure. This is a breaking change from previous versions.
127
+
122
128
  ```ruby
123
129
  require "scout_apm_mcp"
124
130
 
@@ -128,10 +134,13 @@ api_key = ScoutApmMcp::Helpers.get_api_key
128
134
  # Create client
129
135
  client = ScoutApmMcp::Client.new(api_key: api_key)
130
136
 
131
- # List applications
137
+ # List applications (returns Array<Hash>)
132
138
  apps = client.list_apps
133
139
 
134
- # Get application details
140
+ # List applications filtered by active_since
141
+ apps = client.list_apps(active_since: "2025-11-01T00:00:00Z")
142
+
143
+ # Get application details (returns Hash)
135
144
  app = client.get_app(123)
136
145
 
137
146
  # List endpoints
@@ -140,7 +149,7 @@ endpoints = client.list_endpoints(123)
140
149
  # Fetch trace
141
150
  trace = client.fetch_trace(123, 456)
142
151
 
143
- # Get metrics
152
+ # Get metrics (returns Hash with series data)
144
153
  metrics = client.get_metric(123, "response_time", from: "2025-01-01T00:00:00Z", to: "2025-01-02T00:00:00Z")
145
154
 
146
155
  # List error groups
@@ -159,6 +168,26 @@ parsed = ScoutApmMcp::Helpers.parse_scout_url(url)
159
168
  # => { app_id: 123, endpoint_id: "...", trace_id: 456, decoded_endpoint: "...", query_params: {...} }
160
169
  ```
161
170
 
171
+ ### Time and Duration Helpers
172
+
173
+ ```ruby
174
+ # Format Time object to ISO 8601 string
175
+ time_str = ScoutApmMcp::Helpers.format_time(Time.now)
176
+ # => "2025-11-21T12:00:00Z"
177
+
178
+ # Parse ISO 8601 string to Time object
179
+ time = ScoutApmMcp::Helpers.parse_time("2025-11-21T12:00:00Z")
180
+ # => #<Time: 2025-11-21 12:00:00 UTC>
181
+
182
+ # Create duration hash from two ISO 8601 strings
183
+ duration = ScoutApmMcp::Helpers.make_duration("2025-11-21T00:00:00Z", "2025-11-21T12:00:00Z")
184
+ # => { start: #<Time: ...>, end: #<Time: ...> }
185
+
186
+ # Extract endpoint ID from endpoint hash
187
+ endpoint_id = ScoutApmMcp::Helpers.get_endpoint_id(endpoint_hash)
188
+ # => "base64-encoded-endpoint-id"
189
+ ```
190
+
162
191
  ### API Key Management
163
192
 
164
193
  The gem supports multiple methods for API key retrieval (checked in order):
@@ -193,7 +222,7 @@ api_key = ScoutApmMcp::Helpers.get_api_key(
193
222
 
194
223
  ### Applications
195
224
 
196
- - `list_apps` - List all applications
225
+ - `list_apps(active_since:)` - List all applications (optionally filtered by last reported time)
197
226
  - `get_app(app_id)` - Get application details
198
227
 
199
228
  ### Metrics
@@ -249,11 +278,32 @@ The server will communicate via STDIN/STDOUT using the MCP protocol. Configure i
249
278
 
250
279
  ## Error Handling
251
280
 
252
- The client raises exceptions for API errors:
281
+ The client uses custom exception classes for better error handling:
282
+
283
+ - `ScoutApmMcp::Error` - Base exception class for all ScoutAPM SDK errors
284
+ - `ScoutApmMcp::AuthError` - Raised when authentication fails (401 Unauthorized)
285
+ - `ScoutApmMcp::APIError` - Raised for API errors (includes `status_code` and `response_data` attributes)
253
286
 
254
- - `RuntimeError` with message containing "Authentication failed" for 401 Unauthorized
255
- - `RuntimeError` with message containing "Resource not found" for 404 Not Found
256
- - `RuntimeError` with message containing "API request failed" for other HTTP errors
287
+ The client also validates input parameters and raises `ArgumentError` for:
288
+ - Invalid metric types (must be one of: `apdex`, `response_time`, `response_time_95th`, `errors`, `throughput`, `queue_time`)
289
+ - Invalid insight types (must be one of: `n_plus_one`, `memory_bloat`, `slow_query`)
290
+ - Invalid time ranges (from_time must be before to_time, and range cannot exceed 2 weeks)
291
+ - Trace queries older than 7 days (for `list_endpoint_traces`)
292
+
293
+ ```ruby
294
+ begin
295
+ client.get_metric(123, "invalid_metric", from: "2025-01-01T00:00:00Z", to: "2025-01-02T00:00:00Z")
296
+ rescue ScoutApmMcp::AuthError => e
297
+ puts "Authentication failed: #{e.message}"
298
+ rescue ScoutApmMcp::APIError => e
299
+ puts "API error (#{e.status_code}): #{e.message}"
300
+ puts "Response data: #{e.response_data}"
301
+ rescue ArgumentError => e
302
+ puts "Invalid parameter: #{e.message}"
303
+ rescue ScoutApmMcp::Error => e
304
+ puts "Error: #{e.message}"
305
+ end
306
+ ```
257
307
 
258
308
  ## Development
259
309
 
@@ -3,6 +3,10 @@ require "net/http"
3
3
  require "openssl"
4
4
  require "json"
5
5
  require "base64"
6
+ require "time"
7
+
8
+ require_relative "errors"
9
+ require_relative "version"
6
10
 
7
11
  module ScoutApmMcp
8
12
  # ScoutAPM API client for making authenticated requests to the ScoutAPM API
@@ -15,37 +19,63 @@ module ScoutApmMcp
15
19
  class Client
16
20
  API_BASE = "https://scoutapm.com/api/v0"
17
21
 
22
+ # Valid metric types
23
+ VALID_METRICS = %w[apdex response_time response_time_95th errors throughput queue_time].freeze
24
+
25
+ # Valid insight types
26
+ VALID_INSIGHTS = %w[n_plus_one memory_bloat slow_query].freeze
27
+
18
28
  # @param api_key [String] ScoutAPM API key
19
29
  # @param api_base [String] API base URL (default: https://scoutapm.com/api/v0)
20
30
  def initialize(api_key:, api_base: API_BASE)
21
31
  @api_key = api_key
22
32
  @api_base = api_base
33
+ @user_agent = "scout-apm-mcp-rb/#{VERSION}"
23
34
  end
24
35
 
25
36
  # List all applications accessible with the provided API key
26
37
  #
27
- # @return [Hash] API response containing applications list
28
- def list_apps
38
+ # @param active_since [String, nil] ISO 8601 datetime string to filter apps active since that time (default: 30 days ago)
39
+ # @return [Array<Hash>] Array of application hashes
40
+ def list_apps(active_since: nil)
29
41
  uri = URI("#{@api_base}/apps")
30
- make_request(uri)
42
+ response = make_request(uri)
43
+ apps = response.dig("results", "apps") || []
44
+
45
+ # Filter by active_since if provided
46
+ if active_since
47
+ active_time = Helpers.parse_time(active_since)
48
+ apps = apps.select do |app|
49
+ reported_at = app["last_reported_at"]
50
+ if reported_at && !reported_at.empty?
51
+ Helpers.parse_time(reported_at) >= active_time
52
+ else
53
+ false
54
+ end
55
+ end
56
+ end
57
+
58
+ apps
31
59
  end
32
60
 
33
61
  # Get application details for a specific application
34
62
  #
35
63
  # @param app_id [Integer] ScoutAPM application ID
36
- # @return [Hash] API response containing application details
64
+ # @return [Hash] Application details hash
37
65
  def get_app(app_id)
38
66
  uri = URI("#{@api_base}/apps/#{app_id}")
39
- make_request(uri)
67
+ response = make_request(uri)
68
+ response.dig("results", "app") || {}
40
69
  end
41
70
 
42
71
  # List available metric types for an application
43
72
  #
44
73
  # @param app_id [Integer] ScoutAPM application ID
45
- # @return [Hash] API response containing available metrics
74
+ # @return [Array<String>] Array of available metric type names
46
75
  def list_metrics(app_id)
47
76
  uri = URI("#{@api_base}/apps/#{app_id}/metrics")
48
- make_request(uri)
77
+ response = make_request(uri)
78
+ response.dig("results", "availableMetrics") || []
49
79
  end
50
80
 
51
81
  # Get time-series data for a specific metric type
@@ -54,11 +84,13 @@ module ScoutApmMcp
54
84
  # @param metric_type [String] Metric type (apdex, response_time, response_time_95th, errors, throughput, queue_time)
55
85
  # @param from [String, nil] Start time in ISO 8601 format
56
86
  # @param to [String, nil] End time in ISO 8601 format
57
- # @return [Hash] API response containing metric data
87
+ # @return [Hash] Hash containing metric series data
58
88
  def get_metric(app_id, metric_type, from: nil, to: nil)
89
+ validate_metric_params(metric_type, from, to)
59
90
  uri = URI("#{@api_base}/apps/#{app_id}/metrics/#{metric_type}")
60
91
  uri.query = build_query_string(from: from, to: to)
61
- make_request(uri)
92
+ response = make_request(uri)
93
+ response.dig("results", "series") || {}
62
94
  end
63
95
 
64
96
  # List all endpoints for an application
@@ -66,22 +98,25 @@ module ScoutApmMcp
66
98
  # @param app_id [Integer] ScoutAPM application ID
67
99
  # @param from [String, nil] Start time in ISO 8601 format
68
100
  # @param to [String, nil] End time in ISO 8601 format
69
- # @return [Hash] API response containing endpoints list
101
+ # @return [Array<Hash>] Array of endpoint hashes
70
102
  def list_endpoints(app_id, from: nil, to: nil)
103
+ validate_time_range(from, to) if from && to
71
104
  uri = URI("#{@api_base}/apps/#{app_id}/endpoints")
72
105
  uri.query = build_query_string(from: from, to: to)
73
- make_request(uri)
106
+ response = make_request(uri)
107
+ response["results"] || []
74
108
  end
75
109
 
76
110
  # Get endpoint details
77
111
  #
78
112
  # @param app_id [Integer] ScoutAPM application ID
79
113
  # @param endpoint_id [String] Endpoint ID (base64 URL-encoded)
80
- # @return [Hash] API response containing endpoint details
114
+ # @return [Hash] Endpoint details hash
81
115
  def get_endpoint(app_id, endpoint_id)
82
116
  encoded_endpoint_id = URI.encode_www_form_component(endpoint_id)
83
117
  uri = URI("#{@api_base}/apps/#{app_id}/endpoints/#{encoded_endpoint_id}")
84
- make_request(uri)
118
+ response = make_request(uri)
119
+ response.dig("results", "endpoint") || response["results"] || {}
85
120
  end
86
121
 
87
122
  # Get metric data for a specific endpoint
@@ -91,12 +126,15 @@ module ScoutApmMcp
91
126
  # @param metric_type [String] Metric type (apdex, response_time, response_time_95th, errors, throughput, queue_time)
92
127
  # @param from [String, nil] Start time in ISO 8601 format
93
128
  # @param to [String, nil] End time in ISO 8601 format
94
- # @return [Hash] API response containing endpoint metrics
129
+ # @return [Array] Array of metric data points for the specified metric type
95
130
  def get_endpoint_metrics(app_id, endpoint_id, metric_type, from: nil, to: nil)
131
+ validate_metric_params(metric_type, from, to)
96
132
  encoded_endpoint_id = URI.encode_www_form_component(endpoint_id)
97
133
  uri = URI("#{@api_base}/apps/#{app_id}/endpoints/#{encoded_endpoint_id}/metrics/#{metric_type}")
98
134
  uri.query = build_query_string(from: from, to: to)
99
- make_request(uri)
135
+ response = make_request(uri)
136
+ series = response.dig("results", "series") || {}
137
+ series[metric_type] || []
100
138
  end
101
139
 
102
140
  # List traces for a specific endpoint (max 100, within 7 days)
@@ -105,22 +143,33 @@ module ScoutApmMcp
105
143
  # @param endpoint_id [String] Endpoint ID (base64 URL-encoded)
106
144
  # @param from [String, nil] Start time in ISO 8601 format
107
145
  # @param to [String, nil] End time in ISO 8601 format
108
- # @return [Hash] API response containing traces list
146
+ # @return [Array<Hash>] Array of trace hashes
109
147
  def list_endpoint_traces(app_id, endpoint_id, from: nil, to: nil)
148
+ validate_time_range(from, to) if from && to
149
+ if from && to
150
+ # Validate that from_time is not older than 7 days
151
+ from_time = Helpers.parse_time(from)
152
+ seven_days_ago = Time.now.utc - (7 * 24 * 60 * 60)
153
+ if from_time < seven_days_ago
154
+ raise ArgumentError, "from_time cannot be older than 7 days"
155
+ end
156
+ end
110
157
  encoded_endpoint_id = URI.encode_www_form_component(endpoint_id)
111
158
  uri = URI("#{@api_base}/apps/#{app_id}/endpoints/#{encoded_endpoint_id}/traces")
112
159
  uri.query = build_query_string(from: from, to: to)
113
- make_request(uri)
160
+ response = make_request(uri)
161
+ response.dig("results", "traces") || []
114
162
  end
115
163
 
116
164
  # Fetch detailed trace information
117
165
  #
118
166
  # @param app_id [Integer] ScoutAPM application ID
119
167
  # @param trace_id [Integer] Trace identifier
120
- # @return [Hash] API response containing trace details
168
+ # @return [Hash] Trace details hash
121
169
  def fetch_trace(app_id, trace_id)
122
170
  uri = URI("#{@api_base}/apps/#{app_id}/traces/#{trace_id}")
123
- make_request(uri)
171
+ response = make_request(uri)
172
+ response.dig("results", "trace") || {}
124
173
  end
125
174
 
126
175
  # List error groups for an application (max 100, within 30 days)
@@ -129,46 +178,51 @@ module ScoutApmMcp
129
178
  # @param from [String, nil] Start time in ISO 8601 format
130
179
  # @param to [String, nil] End time in ISO 8601 format
131
180
  # @param endpoint [String, nil] Base64 URL-encoded endpoint filter (optional)
132
- # @return [Hash] API response containing error groups list
181
+ # @return [Array<Hash>] Array of error group hashes
133
182
  def list_error_groups(app_id, from: nil, to: nil, endpoint: nil)
183
+ validate_time_range(from, to) if from && to
134
184
  uri = URI("#{@api_base}/apps/#{app_id}/error_groups")
135
185
  params = {}
136
186
  params["from"] = from if from
137
187
  params["to"] = to if to
138
188
  params["endpoint"] = endpoint if endpoint
139
189
  uri.query = URI.encode_www_form(params) unless params.empty?
140
- make_request(uri)
190
+ response = make_request(uri)
191
+ response.dig("results", "error_groups") || []
141
192
  end
142
193
 
143
194
  # Get details for a specific error group
144
195
  #
145
196
  # @param app_id [Integer] ScoutAPM application ID
146
197
  # @param error_id [Integer] Error group identifier
147
- # @return [Hash] API response containing error group details
198
+ # @return [Hash] Error group details hash
148
199
  def get_error_group(app_id, error_id)
149
200
  uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}")
150
- make_request(uri)
201
+ response = make_request(uri)
202
+ response.dig("results", "error_group") || {}
151
203
  end
152
204
 
153
205
  # Get individual errors within an error group (max 100)
154
206
  #
155
207
  # @param app_id [Integer] ScoutAPM application ID
156
208
  # @param error_id [Integer] Error group identifier
157
- # @return [Hash] API response containing errors list
209
+ # @return [Array<Hash>] Array of error hashes
158
210
  def get_error_group_errors(app_id, error_id)
159
211
  uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}/errors")
160
- make_request(uri)
212
+ response = make_request(uri)
213
+ response.dig("results", "errors") || []
161
214
  end
162
215
 
163
216
  # Get all insight types for an application (cached for 5 minutes)
164
217
  #
165
218
  # @param app_id [Integer] ScoutAPM application ID
166
219
  # @param limit [Integer, nil] Maximum number of items per insight type (default: 20)
167
- # @return [Hash] API response containing all insights
220
+ # @return [Hash] Hash containing all insight types
168
221
  def get_all_insights(app_id, limit: nil)
169
222
  uri = URI("#{@api_base}/apps/#{app_id}/insights")
170
223
  uri.query = "limit=#{limit}" if limit
171
- make_request(uri)
224
+ response = make_request(uri)
225
+ response["results"] || {}
172
226
  end
173
227
 
174
228
  # Get data for a specific insight type
@@ -176,11 +230,15 @@ module ScoutApmMcp
176
230
  # @param app_id [Integer] ScoutAPM application ID
177
231
  # @param insight_type [String] Insight type (n_plus_one, memory_bloat, slow_query)
178
232
  # @param limit [Integer, nil] Maximum number of items (default: 20)
179
- # @return [Hash] API response containing insights
233
+ # @return [Hash] Hash containing insight data
180
234
  def get_insight_by_type(app_id, insight_type, limit: nil)
235
+ unless VALID_INSIGHTS.include?(insight_type)
236
+ raise ArgumentError, "Invalid insight_type. Must be one of: #{VALID_INSIGHTS.join(", ")}"
237
+ end
181
238
  uri = URI("#{@api_base}/apps/#{app_id}/insights/#{insight_type}")
182
239
  uri.query = "limit=#{limit}" if limit
183
- make_request(uri)
240
+ response = make_request(uri)
241
+ response["results"] || {}
184
242
  end
185
243
 
186
244
  # Get historical insights data with cursor-based pagination
@@ -239,6 +297,7 @@ module ScoutApmMcp
239
297
 
240
298
  request = Net::HTTP::Get.new(uri)
241
299
  request["X-SCOUT-API"] = @api_key
300
+ request["User-Agent"] = @user_agent
242
301
  request["Accept"] = "application/x-yaml, application/yaml, text/yaml, */*"
243
302
 
244
303
  response = http.request(request)
@@ -251,14 +310,16 @@ module ScoutApmMcp
251
310
  status: response.code.to_i
252
311
  }
253
312
  when Net::HTTPUnauthorized
254
- raise "Authentication failed. Check your API key."
313
+ raise AuthError, "Authentication failed. Check your API key."
255
314
  else
256
- raise "API request failed: #{response.code} #{response.message}"
315
+ raise APIError.new("API request failed: #{response.code} #{response.message}", status_code: response.code.to_i)
257
316
  end
258
317
  rescue OpenSSL::SSL::SSLError => e
259
- raise "SSL verification failed: #{e.message}. This may be due to system certificate configuration issues."
318
+ raise Error, "SSL verification failed: #{e.message}. This may be due to system certificate configuration issues."
319
+ rescue Error
320
+ raise
260
321
  rescue => e
261
- raise "Request failed: #{e.class} - #{e.message}"
322
+ raise Error, "Request failed: #{e.class} - #{e.message}"
262
323
  end
263
324
 
264
325
  private
@@ -303,24 +364,97 @@ module ScoutApmMcp
303
364
 
304
365
  request = Net::HTTP::Get.new(uri)
305
366
  request["X-SCOUT-API"] = @api_key
367
+ request["User-Agent"] = @user_agent
306
368
  request["Accept"] = "application/json"
307
369
 
308
370
  response = http.request(request)
371
+ response_data = handle_response_errors(response)
372
+
373
+ # Check for API-level errors in response body
374
+ if response_data.is_a?(Hash)
375
+ header = response_data["header"]
376
+ if header && header["status"]
377
+ status_code = header["status"]["code"]
378
+ if status_code && status_code >= 400
379
+ error_msg = header["status"]["message"] || "Unknown API error"
380
+ raise APIError.new(error_msg, status_code: status_code, response_data: response_data)
381
+ end
382
+ end
383
+ end
309
384
 
385
+ response_data
386
+ rescue OpenSSL::SSL::SSLError => e
387
+ raise Error, "SSL verification failed: #{e.message}. This may be due to system certificate configuration issues."
388
+ rescue Error
389
+ raise
390
+ rescue => e
391
+ raise Error, "Request failed: #{e.class} - #{e.message}"
392
+ end
393
+
394
+ # Handle common response errors and parse JSON
395
+ #
396
+ # @param response [Net::HTTPResponse] HTTP response object
397
+ # @return [Hash, Array] Parsed JSON response
398
+ # @raise [AuthError] When authentication fails
399
+ # @raise [APIError] When the API returns an error response
400
+ def handle_response_errors(response)
401
+ # Try to parse JSON response
402
+ begin
403
+ data = JSON.parse(response.body)
404
+ rescue JSON::ParserError
405
+ raise APIError.new("Invalid JSON response: #{response.body}", status_code: response.code.to_i)
406
+ end
407
+
408
+ # Check for HTTP-level errors
310
409
  case response
311
410
  when Net::HTTPSuccess
312
- JSON.parse(response.body)
411
+ data
313
412
  when Net::HTTPUnauthorized
314
- raise "Authentication failed. Check your API key. Response: #{response.body}"
413
+ raise AuthError, "Authentication failed - check your API key"
315
414
  when Net::HTTPNotFound
316
- raise "Resource not found. Response: #{response.body}"
415
+ raise APIError.new("Resource not found", status_code: 404, response_data: data)
317
416
  else
318
- raise "API request failed: #{response.code} #{response.message}\n#{response.body}"
417
+ error_msg = "API request failed"
418
+ if data.is_a?(Hash) && data.dig("header", "status", "message")
419
+ error_msg = data.dig("header", "status", "message")
420
+ end
421
+ raise APIError.new(error_msg, status_code: response.code.to_i, response_data: data)
422
+ end
423
+ end
424
+
425
+ # Validate metric parameters
426
+ #
427
+ # @param metric_type [String] Metric type to validate
428
+ # @param from [String, nil] Start time in ISO 8601 format
429
+ # @param to [String, nil] End time in ISO 8601 format
430
+ # @raise [ArgumentError] If validation fails
431
+ def validate_metric_params(metric_type, from, to)
432
+ unless VALID_METRICS.include?(metric_type)
433
+ raise ArgumentError, "Invalid metric_type. Must be one of: #{VALID_METRICS.join(", ")}"
434
+ end
435
+ validate_time_range(from, to) if from && to
436
+ end
437
+
438
+ # Validate time ranges
439
+ #
440
+ # @param from [String, nil] Start time in ISO 8601 format
441
+ # @param to [String, nil] End time in ISO 8601 format
442
+ # @raise [ArgumentError] If validation fails
443
+ def validate_time_range(from, to)
444
+ return unless from && to
445
+
446
+ from_time = Helpers.parse_time(from)
447
+ to_time = Helpers.parse_time(to)
448
+
449
+ if from_time >= to_time
450
+ raise ArgumentError, "from_time must be before to_time"
451
+ end
452
+
453
+ # Validate time range (2 week maximum)
454
+ max_duration = 14 * 24 * 60 * 60 # 14 days in seconds
455
+ if (to_time - from_time) > max_duration
456
+ raise ArgumentError, "Time range cannot exceed 2 weeks"
319
457
  end
320
- rescue OpenSSL::SSL::SSLError => e
321
- raise "SSL verification failed: #{e.message}. This may be due to system certificate configuration issues."
322
- rescue => e
323
- raise "Request failed: #{e.class} - #{e.message}"
324
458
  end
325
459
  end
326
460
  end
@@ -0,0 +1,18 @@
1
+ module ScoutApmMcp
2
+ # Base exception for Scout APM SDK errors
3
+ class Error < StandardError; end
4
+
5
+ # Raised when authentication fails
6
+ class AuthError < Error; end
7
+
8
+ # Raised when the API returns an error response
9
+ class APIError < Error
10
+ attr_reader :status_code, :response_data
11
+
12
+ def initialize(message, status_code: nil, response_data: nil)
13
+ super(message)
14
+ @status_code = status_code
15
+ @response_data = response_data
16
+ end
17
+ end
18
+ end
@@ -1,8 +1,9 @@
1
1
  require "uri"
2
2
  require "base64"
3
+ require "time"
3
4
 
4
5
  module ScoutApmMcp
5
- # Helper module for API key management and URL parsing
6
+ # Helper module for API key management, URL parsing, and time utilities
6
7
  module Helpers
7
8
  # Get API key from environment or 1Password
8
9
  #
@@ -151,5 +152,53 @@ module ScoutApmMcp
151
152
  # If decoding raises an exception, return original string
152
153
  endpoint_id.dup.force_encoding(Encoding::UTF_8)
153
154
  end
155
+
156
+ # Get a unique identifier for an endpoint from an endpoint dictionary
157
+ #
158
+ # This is provided by the API implicitly in the 'link' field.
159
+ #
160
+ # @param endpoint [Hash] Endpoint dictionary from the API
161
+ # @return [String] Endpoint ID extracted from the link field, or empty string if not found
162
+ def self.get_endpoint_id(endpoint)
163
+ link = endpoint["link"] || endpoint[:link] || ""
164
+ return "" if link.empty?
165
+
166
+ # Extract the endpoint ID from the link (last path segment)
167
+ link.split("/").last || ""
168
+ end
169
+
170
+ # Format datetime to ISO 8601 string for API
171
+ #
172
+ # Relies on UTC timezone. Converts the time to UTC if it's not already.
173
+ #
174
+ # @param time [Time] Time object to format
175
+ # @return [String] ISO 8601 formatted time string (e.g., "2025-01-01T00:00:00Z")
176
+ def self.format_time(time)
177
+ time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
178
+ end
179
+
180
+ # Parse ISO 8601 time string to Time object
181
+ #
182
+ # Handles both 'Z' suffix and timezone offsets.
183
+ #
184
+ # @param time_str [String] ISO 8601 time string (e.g., "2025-01-01T00:00:00Z")
185
+ # @return [Time] Time object in UTC
186
+ def self.parse_time(time_str)
187
+ # Replace Z with +00:00 for Ruby's Time parser
188
+ normalized = time_str.sub(/Z\z/i, "+00:00")
189
+ Time.parse(normalized).utc
190
+ end
191
+
192
+ # Create a Duration object from ISO 8601 strings
193
+ #
194
+ # @param from_str [String] Start time in ISO 8601 format
195
+ # @param to_str [String] End time in ISO 8601 format
196
+ # @return [Hash] Hash with :start and :end Time objects
197
+ def self.make_duration(from_str, to_str)
198
+ {
199
+ start: parse_time(from_str),
200
+ end: parse_time(to_str)
201
+ }
202
+ end
154
203
  end
155
204
  end
@@ -166,14 +166,14 @@ module ScoutApmMcp
166
166
 
167
167
  # Applications Tools
168
168
  class ListAppsTool < BaseTool
169
- description "List all applications accessible with the provided API key"
169
+ description "List all applications accessible with the provided API key. Provide an optional active_since ISO 8601 to filter to only apps that have reported data since that time. Defaults to the metric retention period of thirty days."
170
170
 
171
171
  arguments do
172
- # No arguments required
172
+ optional(:active_since).maybe(:string).description("ISO 8601 datetime string to filter apps active since that time")
173
173
  end
174
174
 
175
- def call
176
- get_client.list_apps
175
+ def call(active_since: nil)
176
+ get_client.list_apps(active_since: active_since)
177
177
  end
178
178
  end
179
179
 
@@ -468,9 +468,15 @@ module ScoutApmMcp
468
468
  result[:data] = {trace: trace_data}
469
469
 
470
470
  if include_endpoint && parsed[:endpoint_id]
471
- endpoint_data = client.get_endpoint(parsed[:app_id], parsed[:endpoint_id])
472
- result[:data][:endpoint] = endpoint_data
473
- result[:data][:decoded_endpoint] = parsed[:decoded_endpoint]
471
+ begin
472
+ endpoint_data = client.get_endpoint(parsed[:app_id], parsed[:endpoint_id])
473
+ result[:data][:endpoint] = endpoint_data
474
+ result[:data][:decoded_endpoint] = parsed[:decoded_endpoint]
475
+ rescue => e
476
+ # Endpoint fetch failed, but we still have trace data
477
+ result[:data][:endpoint_error] = "Failed to fetch endpoint: #{e.message}"
478
+ result[:data][:decoded_endpoint] = parsed[:decoded_endpoint]
479
+ end
474
480
  end
475
481
  else
476
482
  raise "Invalid trace URL: missing app_id or trace_id"
@@ -1,3 +1,3 @@
1
1
  module ScoutApmMcp
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
data/lib/scout_apm_mcp.rb CHANGED
@@ -2,6 +2,7 @@ require "uri"
2
2
  require "base64"
3
3
 
4
4
  require_relative "scout_apm_mcp/version"
5
+ require_relative "scout_apm_mcp/errors"
5
6
  require_relative "scout_apm_mcp/client"
6
7
  require_relative "scout_apm_mcp/helpers"
7
8
  # Server is loaded on-demand when running the executable
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scout_apm_mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -233,6 +233,7 @@ files:
233
233
  - bin/scout_apm_mcp
234
234
  - lib/scout_apm_mcp.rb
235
235
  - lib/scout_apm_mcp/client.rb
236
+ - lib/scout_apm_mcp/errors.rb
236
237
  - lib/scout_apm_mcp/helpers.rb
237
238
  - lib/scout_apm_mcp/server.rb
238
239
  - lib/scout_apm_mcp/version.rb