scout_apm_mcp 0.1.1 → 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: ee786e2a50c8985e1423731c5acf85264c5dce178d366238a317685762170c57
4
- data.tar.gz: 8fda9d886c840af02bc5caeb0375b4d963bfef95c4c9c12492f415256a21d361
3
+ metadata.gz: 24de549e2d8e310ce563f8a9c380f642aabdd528259785ed17ae062c8b8e59aa
4
+ data.tar.gz: 5a9c147401b3136be74f5197ec757b4f1d357c1ad5483f12c0813315f6d6d9e8
5
5
  SHA512:
6
- metadata.gz: 3434d01dfa3783ba3a1364caadd17aae11deb0170557fba1277d3b9b72e3706cbb5eed30a984ca9b0222f85933caff79242a05b19d400693e27b1b0a8adc9a08
7
- data.tar.gz: 87540aa4f25083d54b2aee4c9080c3b3544f3c87fe39462198949814ce12a82aae9dae266f73bbbf940ad0f643c78cf5435c40520a221d642b3859d8ab1b6683
6
+ metadata.gz: 509eeae8882791a33706ceda9a71a4095ef6e99abda4e7401082435d0d6e055882ebced248e176c74154bfa28db09eb965e49c5df88e8fa3c581f0f1e9dac782
7
+ data.tar.gz: 33143a776cb940411007c14be1a34691a5569275fa8c3c507b2eca92846c8585bcfa438906b135dd31f986c6fbbd0ff7a98c7be74e27491242aa9f751b410202
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
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
+
20
+ ## 0.1.2 (2025-11-21)
21
+
22
+ - Enhanced SSL certificate handling with support for `SSL_CERT_FILE` environment variable and automatic fallback to system certificates
23
+ - Improved error handling for SSL verification failures with clearer error messages
24
+ - Extended `Helpers.parse_scout_url` to support parsing multiple URL types (endpoints, error_groups, insights, apps) beyond just traces
25
+ - Added `FetchScoutURLTool` MCP tool for automatically detecting and fetching data from any ScoutAPM URL
26
+ - Fixed MCP error handling to ensure error responses always have valid IDs for strict MCP client validation
27
+ - Improved URL parsing to return `url_type` field for better resource type detection
28
+
3
29
  ## 0.1.1 (2025-11-21)
4
30
 
5
31
  - Fixed `NullLogger` missing `set_client_initialized` method that caused MCP initialization errors
data/README.md CHANGED
@@ -24,22 +24,6 @@ Sponsored by [Kisko Labs](https://www.kiskolabs.com).
24
24
 
25
25
  For Cursor IDE, create or update `.cursor/mcp.json` in your project:
26
26
 
27
- ```json
28
- {
29
- "mcpServers": {
30
- "scout-apm": {
31
- "command": "bundle",
32
- "args": ["exec", "scout_apm_mcp"],
33
- "env": {
34
- "OP_ENV_ENTRY_PATH": "op://Vault Name/Item Name"
35
- }
36
- }
37
- }
38
- }
39
- ```
40
-
41
- Or if installed globally:
42
-
43
27
  ```json
44
28
  {
45
29
  "mcpServers": {
@@ -64,21 +48,10 @@ For Claude Desktop, edit the MCP configuration file:
64
48
  {
65
49
  "mcpServers": {
66
50
  "scout-apm": {
67
- "command": "bundle",
68
- "args": ["exec", "scout_apm_mcp"],
69
- "cwd": "/path/to/your/project"
70
- }
71
- }
72
- }
73
- ```
74
-
75
- Or if installed globally:
76
-
77
- ```json
78
- {
79
- "mcpServers": {
80
- "scout-apm": {
81
- "command": "scout_apm_mcp"
51
+ "command": "scout_apm_mcp",
52
+ "env": {
53
+ "OP_ENV_ENTRY_PATH": "op://Vault Name/Item Name"
54
+ }
82
55
  }
83
56
  }
84
57
  }
@@ -140,12 +113,18 @@ The server will start and communicate via STDIN/STDOUT using the MCP protocol. M
140
113
  - **MCP Server Integration**: Ready-to-use MCP server compatible with Cursor IDE, Claude Desktop, and other MCP-enabled tools
141
114
  - **API Key Management**: Supports environment variables and 1Password integration (via optional `opdotenv` gem)
142
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
143
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
144
121
 
145
122
  ## Basic Usage
146
123
 
147
124
  ### API Client
148
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
+
149
128
  ```ruby
150
129
  require "scout_apm_mcp"
151
130
 
@@ -155,10 +134,13 @@ api_key = ScoutApmMcp::Helpers.get_api_key
155
134
  # Create client
156
135
  client = ScoutApmMcp::Client.new(api_key: api_key)
157
136
 
158
- # List applications
137
+ # List applications (returns Array<Hash>)
159
138
  apps = client.list_apps
160
139
 
161
- # 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)
162
144
  app = client.get_app(123)
163
145
 
164
146
  # List endpoints
@@ -167,7 +149,7 @@ endpoints = client.list_endpoints(123)
167
149
  # Fetch trace
168
150
  trace = client.fetch_trace(123, 456)
169
151
 
170
- # Get metrics
152
+ # Get metrics (returns Hash with series data)
171
153
  metrics = client.get_metric(123, "response_time", from: "2025-01-01T00:00:00Z", to: "2025-01-02T00:00:00Z")
172
154
 
173
155
  # List error groups
@@ -186,6 +168,26 @@ parsed = ScoutApmMcp::Helpers.parse_scout_url(url)
186
168
  # => { app_id: 123, endpoint_id: "...", trace_id: 456, decoded_endpoint: "...", query_params: {...} }
187
169
  ```
188
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
+
189
191
  ### API Key Management
190
192
 
191
193
  The gem supports multiple methods for API key retrieval (checked in order):
@@ -220,7 +222,7 @@ api_key = ScoutApmMcp::Helpers.get_api_key(
220
222
 
221
223
  ### Applications
222
224
 
223
- - `list_apps` - List all applications
225
+ - `list_apps(active_since:)` - List all applications (optionally filtered by last reported time)
224
226
  - `get_app(app_id)` - Get application details
225
227
 
226
228
  ### Metrics
@@ -276,11 +278,32 @@ The server will communicate via STDIN/STDOUT using the MCP protocol. Configure i
276
278
 
277
279
  ## Error Handling
278
280
 
279
- 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)
286
+
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`)
280
292
 
281
- - `RuntimeError` with message containing "Authentication failed" for 401 Unauthorized
282
- - `RuntimeError` with message containing "Resource not found" for 404 Not Found
283
- - `RuntimeError` with message containing "API request failed" for other HTTP errors
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
+ ```
284
307
 
285
308
  ## Development
286
309
 
@@ -1,7 +1,12 @@
1
1
  require "uri"
2
2
  require "net/http"
3
+ require "openssl"
3
4
  require "json"
4
5
  require "base64"
6
+ require "time"
7
+
8
+ require_relative "errors"
9
+ require_relative "version"
5
10
 
6
11
  module ScoutApmMcp
7
12
  # ScoutAPM API client for making authenticated requests to the ScoutAPM API
@@ -14,37 +19,63 @@ module ScoutApmMcp
14
19
  class Client
15
20
  API_BASE = "https://scoutapm.com/api/v0"
16
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
+
17
28
  # @param api_key [String] ScoutAPM API key
18
29
  # @param api_base [String] API base URL (default: https://scoutapm.com/api/v0)
19
30
  def initialize(api_key:, api_base: API_BASE)
20
31
  @api_key = api_key
21
32
  @api_base = api_base
33
+ @user_agent = "scout-apm-mcp-rb/#{VERSION}"
22
34
  end
23
35
 
24
36
  # List all applications accessible with the provided API key
25
37
  #
26
- # @return [Hash] API response containing applications list
27
- 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)
28
41
  uri = URI("#{@api_base}/apps")
29
- 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
30
59
  end
31
60
 
32
61
  # Get application details for a specific application
33
62
  #
34
63
  # @param app_id [Integer] ScoutAPM application ID
35
- # @return [Hash] API response containing application details
64
+ # @return [Hash] Application details hash
36
65
  def get_app(app_id)
37
66
  uri = URI("#{@api_base}/apps/#{app_id}")
38
- make_request(uri)
67
+ response = make_request(uri)
68
+ response.dig("results", "app") || {}
39
69
  end
40
70
 
41
71
  # List available metric types for an application
42
72
  #
43
73
  # @param app_id [Integer] ScoutAPM application ID
44
- # @return [Hash] API response containing available metrics
74
+ # @return [Array<String>] Array of available metric type names
45
75
  def list_metrics(app_id)
46
76
  uri = URI("#{@api_base}/apps/#{app_id}/metrics")
47
- make_request(uri)
77
+ response = make_request(uri)
78
+ response.dig("results", "availableMetrics") || []
48
79
  end
49
80
 
50
81
  # Get time-series data for a specific metric type
@@ -53,11 +84,13 @@ module ScoutApmMcp
53
84
  # @param metric_type [String] Metric type (apdex, response_time, response_time_95th, errors, throughput, queue_time)
54
85
  # @param from [String, nil] Start time in ISO 8601 format
55
86
  # @param to [String, nil] End time in ISO 8601 format
56
- # @return [Hash] API response containing metric data
87
+ # @return [Hash] Hash containing metric series data
57
88
  def get_metric(app_id, metric_type, from: nil, to: nil)
89
+ validate_metric_params(metric_type, from, to)
58
90
  uri = URI("#{@api_base}/apps/#{app_id}/metrics/#{metric_type}")
59
91
  uri.query = build_query_string(from: from, to: to)
60
- make_request(uri)
92
+ response = make_request(uri)
93
+ response.dig("results", "series") || {}
61
94
  end
62
95
 
63
96
  # List all endpoints for an application
@@ -65,22 +98,25 @@ module ScoutApmMcp
65
98
  # @param app_id [Integer] ScoutAPM application ID
66
99
  # @param from [String, nil] Start time in ISO 8601 format
67
100
  # @param to [String, nil] End time in ISO 8601 format
68
- # @return [Hash] API response containing endpoints list
101
+ # @return [Array<Hash>] Array of endpoint hashes
69
102
  def list_endpoints(app_id, from: nil, to: nil)
103
+ validate_time_range(from, to) if from && to
70
104
  uri = URI("#{@api_base}/apps/#{app_id}/endpoints")
71
105
  uri.query = build_query_string(from: from, to: to)
72
- make_request(uri)
106
+ response = make_request(uri)
107
+ response["results"] || []
73
108
  end
74
109
 
75
110
  # Get endpoint details
76
111
  #
77
112
  # @param app_id [Integer] ScoutAPM application ID
78
113
  # @param endpoint_id [String] Endpoint ID (base64 URL-encoded)
79
- # @return [Hash] API response containing endpoint details
114
+ # @return [Hash] Endpoint details hash
80
115
  def get_endpoint(app_id, endpoint_id)
81
116
  encoded_endpoint_id = URI.encode_www_form_component(endpoint_id)
82
117
  uri = URI("#{@api_base}/apps/#{app_id}/endpoints/#{encoded_endpoint_id}")
83
- make_request(uri)
118
+ response = make_request(uri)
119
+ response.dig("results", "endpoint") || response["results"] || {}
84
120
  end
85
121
 
86
122
  # Get metric data for a specific endpoint
@@ -90,12 +126,15 @@ module ScoutApmMcp
90
126
  # @param metric_type [String] Metric type (apdex, response_time, response_time_95th, errors, throughput, queue_time)
91
127
  # @param from [String, nil] Start time in ISO 8601 format
92
128
  # @param to [String, nil] End time in ISO 8601 format
93
- # @return [Hash] API response containing endpoint metrics
129
+ # @return [Array] Array of metric data points for the specified metric type
94
130
  def get_endpoint_metrics(app_id, endpoint_id, metric_type, from: nil, to: nil)
131
+ validate_metric_params(metric_type, from, to)
95
132
  encoded_endpoint_id = URI.encode_www_form_component(endpoint_id)
96
133
  uri = URI("#{@api_base}/apps/#{app_id}/endpoints/#{encoded_endpoint_id}/metrics/#{metric_type}")
97
134
  uri.query = build_query_string(from: from, to: to)
98
- make_request(uri)
135
+ response = make_request(uri)
136
+ series = response.dig("results", "series") || {}
137
+ series[metric_type] || []
99
138
  end
100
139
 
101
140
  # List traces for a specific endpoint (max 100, within 7 days)
@@ -104,22 +143,33 @@ module ScoutApmMcp
104
143
  # @param endpoint_id [String] Endpoint ID (base64 URL-encoded)
105
144
  # @param from [String, nil] Start time in ISO 8601 format
106
145
  # @param to [String, nil] End time in ISO 8601 format
107
- # @return [Hash] API response containing traces list
146
+ # @return [Array<Hash>] Array of trace hashes
108
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
109
157
  encoded_endpoint_id = URI.encode_www_form_component(endpoint_id)
110
158
  uri = URI("#{@api_base}/apps/#{app_id}/endpoints/#{encoded_endpoint_id}/traces")
111
159
  uri.query = build_query_string(from: from, to: to)
112
- make_request(uri)
160
+ response = make_request(uri)
161
+ response.dig("results", "traces") || []
113
162
  end
114
163
 
115
164
  # Fetch detailed trace information
116
165
  #
117
166
  # @param app_id [Integer] ScoutAPM application ID
118
167
  # @param trace_id [Integer] Trace identifier
119
- # @return [Hash] API response containing trace details
168
+ # @return [Hash] Trace details hash
120
169
  def fetch_trace(app_id, trace_id)
121
170
  uri = URI("#{@api_base}/apps/#{app_id}/traces/#{trace_id}")
122
- make_request(uri)
171
+ response = make_request(uri)
172
+ response.dig("results", "trace") || {}
123
173
  end
124
174
 
125
175
  # List error groups for an application (max 100, within 30 days)
@@ -128,46 +178,51 @@ module ScoutApmMcp
128
178
  # @param from [String, nil] Start time in ISO 8601 format
129
179
  # @param to [String, nil] End time in ISO 8601 format
130
180
  # @param endpoint [String, nil] Base64 URL-encoded endpoint filter (optional)
131
- # @return [Hash] API response containing error groups list
181
+ # @return [Array<Hash>] Array of error group hashes
132
182
  def list_error_groups(app_id, from: nil, to: nil, endpoint: nil)
183
+ validate_time_range(from, to) if from && to
133
184
  uri = URI("#{@api_base}/apps/#{app_id}/error_groups")
134
185
  params = {}
135
186
  params["from"] = from if from
136
187
  params["to"] = to if to
137
188
  params["endpoint"] = endpoint if endpoint
138
189
  uri.query = URI.encode_www_form(params) unless params.empty?
139
- make_request(uri)
190
+ response = make_request(uri)
191
+ response.dig("results", "error_groups") || []
140
192
  end
141
193
 
142
194
  # Get details for a specific error group
143
195
  #
144
196
  # @param app_id [Integer] ScoutAPM application ID
145
197
  # @param error_id [Integer] Error group identifier
146
- # @return [Hash] API response containing error group details
198
+ # @return [Hash] Error group details hash
147
199
  def get_error_group(app_id, error_id)
148
200
  uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}")
149
- make_request(uri)
201
+ response = make_request(uri)
202
+ response.dig("results", "error_group") || {}
150
203
  end
151
204
 
152
205
  # Get individual errors within an error group (max 100)
153
206
  #
154
207
  # @param app_id [Integer] ScoutAPM application ID
155
208
  # @param error_id [Integer] Error group identifier
156
- # @return [Hash] API response containing errors list
209
+ # @return [Array<Hash>] Array of error hashes
157
210
  def get_error_group_errors(app_id, error_id)
158
211
  uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}/errors")
159
- make_request(uri)
212
+ response = make_request(uri)
213
+ response.dig("results", "errors") || []
160
214
  end
161
215
 
162
216
  # Get all insight types for an application (cached for 5 minutes)
163
217
  #
164
218
  # @param app_id [Integer] ScoutAPM application ID
165
219
  # @param limit [Integer, nil] Maximum number of items per insight type (default: 20)
166
- # @return [Hash] API response containing all insights
220
+ # @return [Hash] Hash containing all insight types
167
221
  def get_all_insights(app_id, limit: nil)
168
222
  uri = URI("#{@api_base}/apps/#{app_id}/insights")
169
223
  uri.query = "limit=#{limit}" if limit
170
- make_request(uri)
224
+ response = make_request(uri)
225
+ response["results"] || {}
171
226
  end
172
227
 
173
228
  # Get data for a specific insight type
@@ -175,11 +230,15 @@ module ScoutApmMcp
175
230
  # @param app_id [Integer] ScoutAPM application ID
176
231
  # @param insight_type [String] Insight type (n_plus_one, memory_bloat, slow_query)
177
232
  # @param limit [Integer, nil] Maximum number of items (default: 20)
178
- # @return [Hash] API response containing insights
233
+ # @return [Hash] Hash containing insight data
179
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
180
238
  uri = URI("#{@api_base}/apps/#{app_id}/insights/#{insight_type}")
181
239
  uri.query = "limit=#{limit}" if limit
182
- make_request(uri)
240
+ response = make_request(uri)
241
+ response["results"] || {}
183
242
  end
184
243
 
185
244
  # Get historical insights data with cursor-based pagination
@@ -238,6 +297,7 @@ module ScoutApmMcp
238
297
 
239
298
  request = Net::HTTP::Get.new(uri)
240
299
  request["X-SCOUT-API"] = @api_key
300
+ request["User-Agent"] = @user_agent
241
301
  request["Accept"] = "application/x-yaml, application/yaml, text/yaml, */*"
242
302
 
243
303
  response = http.request(request)
@@ -250,10 +310,16 @@ module ScoutApmMcp
250
310
  status: response.code.to_i
251
311
  }
252
312
  when Net::HTTPUnauthorized
253
- raise "Authentication failed. Check your API key."
313
+ raise AuthError, "Authentication failed. Check your API key."
254
314
  else
255
- 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)
256
316
  end
317
+ rescue OpenSSL::SSL::SSLError => e
318
+ raise Error, "SSL verification failed: #{e.message}. This may be due to system certificate configuration issues."
319
+ rescue Error
320
+ raise
321
+ rescue => e
322
+ raise Error, "Request failed: #{e.class} - #{e.message}"
257
323
  end
258
324
 
259
325
  private
@@ -264,9 +330,24 @@ module ScoutApmMcp
264
330
  # @return [Net::HTTP] Configured HTTP client
265
331
  def build_http_client(uri)
266
332
  http = Net::HTTP.new(uri.host, uri.port)
267
- http.use_ssl = uri.scheme == "https"
268
333
  http.read_timeout = 10
269
334
  http.open_timeout = 10
335
+
336
+ if uri.scheme == "https"
337
+ http.use_ssl = true
338
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
339
+
340
+ # Set ca_file directly - this is the simplest and most reliable approach
341
+ # Try SSL_CERT_FILE first, then default cert file
342
+ ca_file = if ENV["SSL_CERT_FILE"] && File.file?(ENV["SSL_CERT_FILE"])
343
+ ENV["SSL_CERT_FILE"]
344
+ elsif File.exist?(OpenSSL::X509::DEFAULT_CERT_FILE)
345
+ OpenSSL::X509::DEFAULT_CERT_FILE
346
+ end
347
+
348
+ http.ca_file = ca_file if ca_file
349
+ end
350
+
270
351
  http
271
352
  end
272
353
 
@@ -283,19 +364,96 @@ module ScoutApmMcp
283
364
 
284
365
  request = Net::HTTP::Get.new(uri)
285
366
  request["X-SCOUT-API"] = @api_key
367
+ request["User-Agent"] = @user_agent
286
368
  request["Accept"] = "application/json"
287
369
 
288
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
289
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
290
409
  case response
291
410
  when Net::HTTPSuccess
292
- JSON.parse(response.body)
411
+ data
293
412
  when Net::HTTPUnauthorized
294
- raise "Authentication failed. Check your API key. Response: #{response.body}"
413
+ raise AuthError, "Authentication failed - check your API key"
295
414
  when Net::HTTPNotFound
296
- raise "Resource not found. Response: #{response.body}"
415
+ raise APIError.new("Resource not found", status_code: 404, response_data: data)
297
416
  else
298
- 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"
299
457
  end
300
458
  end
301
459
  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
  #
@@ -63,30 +64,55 @@ module ScoutApmMcp
63
64
  "or provide OP_ENV_ENTRY_PATH, or op_vault and op_item parameters for 1Password integration"
64
65
  end
65
66
 
66
- # Parse a ScoutAPM trace URL and extract app_id, endpoint_id, and trace_id
67
+ # Parse a ScoutAPM URL and extract resource information
67
68
  #
68
- # @param url [String] Full ScoutAPM trace URL
69
- # @return [Hash] Hash containing :app_id, :endpoint_id, :trace_id, :query_params, and :decoded_endpoint
69
+ # @param url [String] Full ScoutAPM URL
70
+ # @return [Hash] Hash containing resource type and extracted IDs
71
+ # Possible keys: :url_type, :app_id, :endpoint_id, :trace_id, :error_id, :insight_type,
72
+ # :query_params, :decoded_endpoint
70
73
  def self.parse_scout_url(url)
71
74
  uri = URI.parse(url)
72
75
  path_parts = uri.path.split("/").reject(&:empty?)
73
76
 
74
- # Extract from URL: /apps/{app_id}/endpoints/{endpoint_id}/trace/{trace_id}
77
+ result = {}
75
78
  app_index = path_parts.index("apps")
76
- endpoints_index = path_parts.index("endpoints")
77
- trace_index = path_parts.index("trace")
78
79
 
79
- result = {}
80
+ return result unless app_index
81
+
82
+ result[:app_id] = path_parts[app_index + 1].to_i
80
83
 
81
- if app_index && endpoints_index && trace_index
82
- result[:app_id] = path_parts[app_index + 1].to_i
83
- result[:endpoint_id] = path_parts[endpoints_index + 1]
84
- result[:trace_id] = path_parts[trace_index + 1].to_i
84
+ # Detect URL type and extract IDs
85
+ # Pattern: /apps/{app_id}/endpoints/{endpoint_id}/trace/{trace_id}
86
+ if path_parts.include?("trace")
87
+ result[:url_type] = :trace
88
+ endpoints_index = path_parts.index("endpoints")
89
+ trace_index = path_parts.index("trace")
90
+ if endpoints_index && trace_index
91
+ result[:endpoint_id] = path_parts[endpoints_index + 1]
92
+ result[:trace_id] = path_parts[trace_index + 1].to_i
93
+ end
94
+ # Pattern: /apps/{app_id}/endpoints/{endpoint_id}
95
+ elsif path_parts.include?("endpoints")
96
+ result[:url_type] = :endpoint
97
+ endpoints_index = path_parts.index("endpoints")
98
+ result[:endpoint_id] = path_parts[endpoints_index + 1] if endpoints_index
99
+ # Pattern: /apps/{app_id}/error_groups/{error_id}
100
+ elsif path_parts.include?("error_groups")
101
+ result[:url_type] = :error_group
102
+ error_groups_index = path_parts.index("error_groups")
103
+ result[:error_id] = path_parts[error_groups_index + 1].to_i if error_groups_index
104
+ # Pattern: /apps/{app_id}/insights or /apps/{app_id}/insights/{insight_type}
105
+ elsif path_parts.include?("insights")
106
+ result[:url_type] = :insight
107
+ insights_index = path_parts.index("insights")
108
+ if insights_index && path_parts.length > insights_index + 1
109
+ result[:insight_type] = path_parts[insights_index + 1]
110
+ end
111
+ # Pattern: /apps/{app_id}
112
+ elsif path_parts.length == 2 && path_parts[0] == "apps"
113
+ result[:url_type] = :app
85
114
  else
86
- # Fallback: try to extract by position
87
- result[:app_id] = path_parts[1].to_i if path_parts[0] == "apps"
88
- result[:endpoint_id] = path_parts[3] if path_parts[2] == "endpoints"
89
- result[:trace_id] = path_parts[5].to_i if path_parts[4] == "trace"
115
+ result[:url_type] = :unknown
90
116
  end
91
117
 
92
118
  # Parse query parameters
@@ -126,5 +152,53 @@ module ScoutApmMcp
126
152
  # If decoding raises an exception, return original string
127
153
  endpoint_id.dup.force_encoding(Encoding::UTF_8)
128
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
129
203
  end
130
204
  end
@@ -16,6 +16,21 @@ FastMcp = MCP unless defined?(FastMcp)
16
16
  module MCP
17
17
  module Transports
18
18
  class StdioTransport
19
+ if method_defined?(:send_error)
20
+ alias_method :original_send_error, :send_error
21
+
22
+ def send_error(code, message, id = nil)
23
+ # Use placeholder id if nil to satisfy strict MCP client validation
24
+ # JSON-RPC 2.0 allows null for notifications, but MCP clients require valid id
25
+ id = "error_#{SecureRandom.hex(8)}" if id.nil?
26
+ original_send_error(code, message, id)
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ class Server
33
+ if method_defined?(:send_error)
19
34
  alias_method :original_send_error, :send_error
20
35
 
21
36
  def send_error(code, message, id = nil)
@@ -26,17 +41,6 @@ module MCP
26
41
  end
27
42
  end
28
43
  end
29
-
30
- class Server
31
- alias_method :original_send_error, :send_error
32
-
33
- def send_error(code, message, id = nil)
34
- # Use placeholder id if nil to satisfy strict MCP client validation
35
- # JSON-RPC 2.0 allows null for notifications, but MCP clients require valid id
36
- id = "error_#{SecureRandom.hex(8)}" if id.nil?
37
- original_send_error(code, message, id)
38
- end
39
- end
40
44
  end
41
45
 
42
46
  module ScoutApmMcp
@@ -141,6 +145,7 @@ module ScoutApmMcp
141
145
  server.register_tool(GetInsightsHistoryTool)
142
146
  server.register_tool(GetInsightsHistoryByTypeTool)
143
147
  server.register_tool(ParseScoutURLTool)
148
+ server.register_tool(FetchScoutURLTool)
144
149
  server.register_tool(FetchOpenAPISchemaTool)
145
150
  end
146
151
 
@@ -161,14 +166,14 @@ module ScoutApmMcp
161
166
 
162
167
  # Applications Tools
163
168
  class ListAppsTool < BaseTool
164
- 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."
165
170
 
166
171
  arguments do
167
- # No arguments required
172
+ optional(:active_since).maybe(:string).description("ISO 8601 datetime string to filter apps active since that time")
168
173
  end
169
174
 
170
- def call
171
- get_client.list_apps
175
+ def call(active_since: nil)
176
+ get_client.list_apps(active_since: active_since)
172
177
  end
173
178
  end
174
179
 
@@ -427,10 +432,10 @@ module ScoutApmMcp
427
432
 
428
433
  # Utility Tools
429
434
  class ParseScoutURLTool < BaseTool
430
- description "Parse a ScoutAPM trace URL and extract app_id, endpoint_id, and trace_id"
435
+ description "Parse a ScoutAPM URL and extract resource information (app_id, endpoint_id, trace_id, etc.)"
431
436
 
432
437
  arguments do
433
- required(:url).filled(:string).description("Full ScoutAPM trace URL (e.g., https://scoutapm.com/apps/123/endpoints/.../trace/456)")
438
+ required(:url).filled(:string).description("Full ScoutAPM URL (e.g., https://scoutapm.com/apps/123/endpoints/.../trace/456)")
434
439
  end
435
440
 
436
441
  def call(url:)
@@ -438,6 +443,90 @@ module ScoutApmMcp
438
443
  end
439
444
  end
440
445
 
446
+ class FetchScoutURLTool < BaseTool
447
+ description "Fetch data from a ScoutAPM URL by automatically detecting the resource type and fetching the appropriate data"
448
+
449
+ arguments do
450
+ required(:url).filled(:string).description("Full ScoutAPM URL (e.g., https://scoutapm.com/apps/123/endpoints/.../trace/456)")
451
+ optional(:include_endpoint).filled(:bool).description("For trace URLs, also fetch endpoint details for context (default: false)")
452
+ end
453
+
454
+ def call(url:, include_endpoint: false)
455
+ parsed = Helpers.parse_scout_url(url)
456
+ client = get_client
457
+
458
+ result = {
459
+ url: url,
460
+ parsed: parsed,
461
+ data: nil
462
+ }
463
+
464
+ case parsed[:url_type]
465
+ when :trace
466
+ if parsed[:app_id] && parsed[:trace_id]
467
+ trace_data = client.fetch_trace(parsed[:app_id], parsed[:trace_id])
468
+ result[:data] = {trace: trace_data}
469
+
470
+ if include_endpoint && parsed[:endpoint_id]
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
480
+ end
481
+ else
482
+ raise "Invalid trace URL: missing app_id or trace_id"
483
+ end
484
+ when :endpoint
485
+ if parsed[:app_id] && parsed[:endpoint_id]
486
+ endpoint_data = client.get_endpoint(parsed[:app_id], parsed[:endpoint_id])
487
+ result[:data] = {
488
+ endpoint: endpoint_data,
489
+ decoded_endpoint: parsed[:decoded_endpoint]
490
+ }
491
+ else
492
+ raise "Invalid endpoint URL: missing app_id or endpoint_id"
493
+ end
494
+ when :error_group
495
+ if parsed[:app_id] && parsed[:error_id]
496
+ error_data = client.get_error_group(parsed[:app_id], parsed[:error_id])
497
+ result[:data] = {error_group: error_data}
498
+ else
499
+ raise "Invalid error group URL: missing app_id or error_id"
500
+ end
501
+ when :insight
502
+ if parsed[:app_id]
503
+ if parsed[:insight_type]
504
+ insight_data = client.get_insight_by_type(parsed[:app_id], parsed[:insight_type])
505
+ result[:data] = {insight: insight_data, insight_type: parsed[:insight_type]}
506
+ else
507
+ insights_data = client.get_all_insights(parsed[:app_id])
508
+ result[:data] = {insights: insights_data}
509
+ end
510
+ else
511
+ raise "Invalid insight URL: missing app_id"
512
+ end
513
+ when :app
514
+ if parsed[:app_id]
515
+ app_data = client.get_app(parsed[:app_id])
516
+ result[:data] = {app: app_data}
517
+ else
518
+ raise "Invalid app URL: missing app_id"
519
+ end
520
+ when :unknown
521
+ raise "Unknown or unsupported ScoutAPM URL format: #{url}"
522
+ else
523
+ raise "Unable to determine URL type from: #{url}"
524
+ end
525
+
526
+ result
527
+ end
528
+ end
529
+
441
530
  class FetchOpenAPISchemaTool < BaseTool
442
531
  description "Fetch the ScoutAPM OpenAPI schema from the API and optionally validate it"
443
532
 
@@ -1,3 +1,3 @@
1
1
  module ScoutApmMcp
2
- VERSION = "0.1.1"
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.1
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