scout_apm_mcp 0.1.2 → 0.1.4

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: ce6703433e4f11c4cf2769a636135ed7a429707a84a09e196e7151b43cb368b2
4
+ data.tar.gz: 2ddf028b1023a364f4eb5d6afc8f1693549ce4ace041f7d12487719f4513ef3d
5
5
  SHA512:
6
- metadata.gz: e591d27a417285348f7257f150aeb11a9fa4390227893fc996fadf65d08dd856fd4121f3780e7d5403ecb3e985d5c3f2389dce5a99c3d4f57ad9f47bb4123876
7
- data.tar.gz: 6112164513df8a51d4e0c039f9245621869ea4533a54ad493df3ec3e4aa67b738fd4952a0507c740a35a1c8568705ed72f8c2cf9d927603bd85fb9080b671f46
6
+ metadata.gz: 1f1ff860d8e236a1338d4f1ceb62a0a6620ff667dc266cc528a7ffd8eceaaf5f4a874612fcc7e8fe45b64a83c446d42af6eca639d13b4ee5cf71b76ce7e7b6b6
7
+ data.tar.gz: 3cadb0d9852c1e661630c69148cfdb2d9c85076094e4672f5b0358448015ade8d13bc800c04a67c1b105ddf66b1ca842ac056c8e38b12b158c3c6290a289daa7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.1.4 (2025-12-08)
4
+
5
+ - Enhanced API client and documentation for improved endpoint management
6
+ - Refactored RSpec test suite with improved organization and clarity (split into focused spec files by component)
7
+ - Added RuboCop configuration (replacing Standard) with custom rules for RSpec and strict linting
8
+ - Updated trunk configuration to use RuboCop instead of Standard
9
+ - Added Code of Conduct, Governance, and Security Guidelines documents
10
+ - Updated MCP server configuration to use `gem exec` command with Ruby version specification
11
+ - Updated GitHub Actions workflow dependencies
12
+ - Updated bundler dependencies for improved compatibility
13
+
14
+ ## 0.1.3 (2025-11-21)
15
+
16
+ - Custom exception classes (`ScoutApmMcp::Error`, `ScoutApmMcp::AuthError`, `ScoutApmMcp::APIError`) for better error handling
17
+ - Input validation for metric types (`VALID_METRICS` constant) and insight types (`VALID_INSIGHTS` constant)
18
+ - Time range validation (ensures from_time < to_time and range doesn't exceed 2 weeks)
19
+ - Trace age validation (ensures trace queries aren't older than 7 days)
20
+ - Client methods now return extracted data instead of full API response structure
21
+ - Time/Duration helpers (`Helpers.format_time`, `Helpers.parse_time`, `Helpers.make_duration`)
22
+ - Endpoint ID extraction helper (`Helpers.get_endpoint_id`)
23
+ - User-Agent header (`scout-apm-mcp-rb/VERSION`) on all API requests
24
+ - `active_since` parameter to `list_apps` method for filtering apps by last reported time
25
+ - API-level error parsing - checks for `header.status.code` in response body
26
+ - Error handling now uses custom exception classes instead of generic `RuntimeError`
27
+ - MCP `ListAppsTool` now supports optional `active_since` parameter
28
+ - Error responses now properly parse API-level error codes from response body
29
+ - Invalid metric types, insight types, and time ranges are now validated before API calls
30
+
3
31
  ## 0.1.2 (2025-11-21)
4
32
 
5
33
  - Enhanced SSL certificate handling with support for `SSL_CERT_FILE` environment variable and automatic fallback to system certificates
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # scout_apm_mcp
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/scout_apm_mcp.svg?v=0.1.0)](https://badge.fury.io/rb/scout_apm_mcp) [![Test Status](https://github.com/amkisko/scout_apm_mcp.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/scout_apm_mcp.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/scout_apm_mcp.rb/graph/badge.svg?token=HVWDDNLEO5)](https://codecov.io/gh/amkisko/scout_apm_mcp.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/scout_apm_mcp.svg?v=0.1.3)](https://badge.fury.io/rb/scout_apm_mcp) [![Test Status](https://github.com/amkisko/scout_apm_mcp.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/scout_apm_mcp.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/scout_apm_mcp.rb/graph/badge.svg?token=HVWDDNLEO5)](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
 
@@ -20,6 +20,12 @@ Sponsored by [Kisko Labs](https://www.kiskolabs.com).
20
20
  2. In 1Password create an item with the name "Scout APM API" and store the API key in a new field named API_KEY
21
21
  3. Configure your favorite service to use local MCP server, ensure OP_ENV_ENTRY_PATH has correct vault and item names (both are visible in 1Password UI)
22
22
 
23
+ ### Installation
24
+
25
+ ```bash
26
+ gem install scout_apm_mcp
27
+ ```
28
+
23
29
  ### Cursor IDE Configuration
24
30
 
25
31
  For Cursor IDE, create or update `.cursor/mcp.json` in your project:
@@ -28,15 +34,19 @@ For Cursor IDE, create or update `.cursor/mcp.json` in your project:
28
34
  {
29
35
  "mcpServers": {
30
36
  "scout-apm": {
31
- "command": "scout_apm_mcp",
37
+ "command": "gem",
38
+ "args": ["exec", "scout_apm_mcp"],
32
39
  "env": {
33
- "OP_ENV_ENTRY_PATH": "op://Vault Name/Item Name"
40
+ "OP_ENV_ENTRY_PATH": "op://Vault Name/Item Name",
41
+ "RUBY_VERSION": "3.4.7"
34
42
  }
35
43
  }
36
44
  }
37
45
  }
38
46
  ```
39
47
 
48
+ **Note**: Using `gem exec` ensures the correct Ruby version is used. If you're using a Ruby version manager like [mise](https://mise.jdx.dev/) or [rbenv](https://github.com/rbenv/rbenv), set the `RUBY_VERSION` environment variable to match your desired Ruby version. The `gem exec` command will automatically use the correct Ruby version based on your version manager configuration.
49
+
40
50
  ### Claude Desktop Configuration
41
51
 
42
52
  For Claude Desktop, edit the MCP configuration file:
@@ -48,16 +58,18 @@ For Claude Desktop, edit the MCP configuration file:
48
58
  {
49
59
  "mcpServers": {
50
60
  "scout-apm": {
51
- "command": "scout_apm_mcp",
61
+ "command": "gem",
62
+ "args": ["exec", "scout_apm_mcp"],
52
63
  "env": {
53
- "OP_ENV_ENTRY_PATH": "op://Vault Name/Item Name"
64
+ "OP_ENV_ENTRY_PATH": "op://Vault Name/Item Name",
65
+ "RUBY_VERSION": "3.4.7"
54
66
  }
55
67
  }
56
68
  }
57
69
  }
58
70
  ```
59
71
 
60
- **Note**: After updating the configuration, restart Claude Desktop for changes to take effect.
72
+ **Note**: After updating the configuration, restart Claude Desktop for changes to take effect. Using `gem exec` ensures the correct Ruby version is used. If you're using a Ruby version manager like [mise](https://mise.jdx.dev/) or [rbenv](https://github.com/rbenv/rbenv), set the `RUBY_VERSION` environment variable to match your desired Ruby version. The `gem exec` command will automatically use the correct Ruby version based on your version manager configuration.
61
73
 
62
74
  ### Security Best Practice
63
75
 
@@ -107,18 +119,40 @@ scout_apm_mcp
107
119
 
108
120
  The server will start and communicate via STDIN/STDOUT using the MCP protocol. Make sure you have your ScoutAPM API key configured (see API Key Management section below).
109
121
 
122
+ ## Upgrading
123
+
124
+ To upgrade to the latest version of the gem:
125
+
126
+ ```bash
127
+ gem update scout_apm_mcp
128
+ ```
129
+
130
+ If you're using Bundler, update your `Gemfile.lock`:
131
+
132
+ ```bash
133
+ bundle update scout_apm_mcp
134
+ ```
135
+
136
+ **Note**: As of version 0.1.3, client methods return extracted data (arrays, hashes) instead of full API response structures. This is a breaking change from previous versions. See the [CHANGELOG.md](CHANGELOG.md) for details on breaking changes and new features.
137
+
110
138
  ## Features
111
139
 
112
140
  - **ScoutAPM API Client**: Full-featured client for ScoutAPM REST API
113
141
  - **MCP Server Integration**: Ready-to-use MCP server compatible with Cursor IDE, Claude Desktop, and other MCP-enabled tools
114
142
  - **API Key Management**: Supports environment variables and 1Password integration (via optional `opdotenv` gem)
115
143
  - **URL Parsing**: Helper methods to parse ScoutAPM URLs and extract IDs
144
+ - **Time Utilities**: Helper methods for formatting, parsing, and working with ISO 8601 time strings
145
+ - **Input Validation**: Validates metric types, insight types, and time ranges before API calls
146
+ - **Custom Error Handling**: Dedicated exception classes for better error handling and debugging
116
147
  - **Comprehensive API Coverage**: Supports all ScoutAPM API endpoints (apps, metrics, endpoints, traces, errors, insights)
148
+ - **Data Extraction**: Client methods return extracted data instead of full API response structure for easier usage
117
149
 
118
150
  ## Basic Usage
119
151
 
120
152
  ### API Client
121
153
 
154
+ **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.
155
+
122
156
  ```ruby
123
157
  require "scout_apm_mcp"
124
158
 
@@ -128,10 +162,13 @@ api_key = ScoutApmMcp::Helpers.get_api_key
128
162
  # Create client
129
163
  client = ScoutApmMcp::Client.new(api_key: api_key)
130
164
 
131
- # List applications
165
+ # List applications (returns Array<Hash>)
132
166
  apps = client.list_apps
133
167
 
134
- # Get application details
168
+ # List applications filtered by active_since
169
+ apps = client.list_apps(active_since: "2025-11-01T00:00:00Z")
170
+
171
+ # Get application details (returns Hash)
135
172
  app = client.get_app(123)
136
173
 
137
174
  # List endpoints
@@ -140,7 +177,7 @@ endpoints = client.list_endpoints(123)
140
177
  # Fetch trace
141
178
  trace = client.fetch_trace(123, 456)
142
179
 
143
- # Get metrics
180
+ # Get metrics (returns Hash with series data)
144
181
  metrics = client.get_metric(123, "response_time", from: "2025-01-01T00:00:00Z", to: "2025-01-02T00:00:00Z")
145
182
 
146
183
  # List error groups
@@ -159,6 +196,26 @@ parsed = ScoutApmMcp::Helpers.parse_scout_url(url)
159
196
  # => { app_id: 123, endpoint_id: "...", trace_id: 456, decoded_endpoint: "...", query_params: {...} }
160
197
  ```
161
198
 
199
+ ### Time and Duration Helpers
200
+
201
+ ```ruby
202
+ # Format Time object to ISO 8601 string
203
+ time_str = ScoutApmMcp::Helpers.format_time(Time.now)
204
+ # => "2025-11-21T12:00:00Z"
205
+
206
+ # Parse ISO 8601 string to Time object
207
+ time = ScoutApmMcp::Helpers.parse_time("2025-11-21T12:00:00Z")
208
+ # => #<Time: 2025-11-21 12:00:00 UTC>
209
+
210
+ # Create duration hash from two ISO 8601 strings
211
+ duration = ScoutApmMcp::Helpers.make_duration("2025-11-21T00:00:00Z", "2025-11-21T12:00:00Z")
212
+ # => { start: #<Time: ...>, end: #<Time: ...> }
213
+
214
+ # Extract endpoint ID from endpoint hash
215
+ endpoint_id = ScoutApmMcp::Helpers.get_endpoint_id(endpoint_hash)
216
+ # => "base64-encoded-endpoint-id"
217
+ ```
218
+
162
219
  ### API Key Management
163
220
 
164
221
  The gem supports multiple methods for API key retrieval (checked in order):
@@ -193,7 +250,7 @@ api_key = ScoutApmMcp::Helpers.get_api_key(
193
250
 
194
251
  ### Applications
195
252
 
196
- - `list_apps` - List all applications
253
+ - `list_apps(active_since:)` - List all applications (optionally filtered by last reported time)
197
254
  - `get_app(app_id)` - Get application details
198
255
 
199
256
  ### Metrics
@@ -204,10 +261,11 @@ api_key = ScoutApmMcp::Helpers.get_api_key(
204
261
  ### Endpoints
205
262
 
206
263
  - `list_endpoints(app_id, from:, to:)` - List all endpoints
207
- - `get_endpoint(app_id, endpoint_id)` - Get endpoint details
208
264
  - `get_endpoint_metrics(app_id, endpoint_id, metric_type, from:, to:)` - Get endpoint metrics
209
265
  - `list_endpoint_traces(app_id, endpoint_id, from:, to:)` - List endpoint traces
210
266
 
267
+ Note: The API doesn't provide a direct endpoint detail endpoint. Use `list_endpoints` and filter by endpoint_id to get specific endpoint information.
268
+
211
269
  ### Traces
212
270
 
213
271
  - `fetch_trace(app_id, trace_id)` - Fetch detailed trace information
@@ -249,11 +307,32 @@ The server will communicate via STDIN/STDOUT using the MCP protocol. Configure i
249
307
 
250
308
  ## Error Handling
251
309
 
252
- The client raises exceptions for API errors:
310
+ The client uses custom exception classes for better error handling:
311
+
312
+ - `ScoutApmMcp::Error` - Base exception class for all ScoutAPM SDK errors
313
+ - `ScoutApmMcp::AuthError` - Raised when authentication fails (401 Unauthorized)
314
+ - `ScoutApmMcp::APIError` - Raised for API errors (includes `status_code` and `response_data` attributes)
253
315
 
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
316
+ The client also validates input parameters and raises `ArgumentError` for:
317
+ - Invalid metric types (must be one of: `apdex`, `response_time`, `response_time_95th`, `errors`, `throughput`, `queue_time`)
318
+ - Invalid insight types (must be one of: `n_plus_one`, `memory_bloat`, `slow_query`)
319
+ - Invalid time ranges (from_time must be before to_time, and range cannot exceed 2 weeks)
320
+ - Trace queries older than 7 days (for `list_endpoint_traces`)
321
+
322
+ ```ruby
323
+ begin
324
+ client.get_metric(123, "invalid_metric", from: "2025-01-01T00:00:00Z", to: "2025-01-02T00:00:00Z")
325
+ rescue ScoutApmMcp::AuthError => e
326
+ puts "Authentication failed: #{e.message}"
327
+ rescue ScoutApmMcp::APIError => e
328
+ puts "API error (#{e.status_code}): #{e.message}"
329
+ puts "Response data: #{e.response_data}"
330
+ rescue ArgumentError => e
331
+ puts "Invalid parameter: #{e.message}"
332
+ rescue ScoutApmMcp::Error => e
333
+ puts "Error: #{e.message}"
334
+ end
335
+ ```
257
336
 
258
337
  ## Development
259
338
 
@@ -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,66 @@ 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)
30
+ # @raise [ArgumentError] if api_key is nil or empty
20
31
  def initialize(api_key:, api_base: API_BASE)
21
- @api_key = api_key
32
+ if api_key.nil? || api_key.to_s.strip.empty?
33
+ raise ArgumentError, "API key is required and cannot be nil or empty"
34
+ end
35
+ @api_key = api_key.to_s
22
36
  @api_base = api_base
37
+ @user_agent = "scout-apm-mcp-rb/#{VERSION}"
23
38
  end
24
39
 
25
40
  # List all applications accessible with the provided API key
26
41
  #
27
- # @return [Hash] API response containing applications list
28
- def list_apps
42
+ # @param active_since [String, nil] ISO 8601 datetime string to filter apps active since that time (default: 30 days ago)
43
+ # @return [Array<Hash>] Array of application hashes
44
+ def list_apps(active_since: nil)
29
45
  uri = URI("#{@api_base}/apps")
30
- make_request(uri)
46
+ response = make_request(uri)
47
+ apps = response.dig("results", "apps") || []
48
+
49
+ if active_since
50
+ active_time = Helpers.parse_time(active_since)
51
+ apps = apps.select do |app|
52
+ reported_at = app["last_reported_at"]
53
+ if reported_at && !reported_at.empty?
54
+ Helpers.parse_time(reported_at) >= active_time
55
+ else
56
+ false
57
+ end
58
+ end
59
+ end
60
+
61
+ apps
31
62
  end
32
63
 
33
64
  # Get application details for a specific application
34
65
  #
35
66
  # @param app_id [Integer] ScoutAPM application ID
36
- # @return [Hash] API response containing application details
67
+ # @return [Hash] Application details hash
37
68
  def get_app(app_id)
38
69
  uri = URI("#{@api_base}/apps/#{app_id}")
39
- make_request(uri)
70
+ response = make_request(uri)
71
+ response.dig("results", "app") || {}
40
72
  end
41
73
 
42
74
  # List available metric types for an application
43
75
  #
44
76
  # @param app_id [Integer] ScoutAPM application ID
45
- # @return [Hash] API response containing available metrics
77
+ # @return [Array<String>] Array of available metric type names
46
78
  def list_metrics(app_id)
47
79
  uri = URI("#{@api_base}/apps/#{app_id}/metrics")
48
- make_request(uri)
80
+ response = make_request(uri)
81
+ response.dig("results", "availableMetrics") || []
49
82
  end
50
83
 
51
84
  # Get time-series data for a specific metric type
@@ -54,11 +87,20 @@ module ScoutApmMcp
54
87
  # @param metric_type [String] Metric type (apdex, response_time, response_time_95th, errors, throughput, queue_time)
55
88
  # @param from [String, nil] Start time in ISO 8601 format
56
89
  # @param to [String, nil] End time in ISO 8601 format
57
- # @return [Hash] API response containing metric data
58
- def get_metric(app_id, metric_type, from: nil, to: nil)
90
+ # @param range [String, nil] Quick time range (e.g., "30min", "1day", "3days", "7days"). If provided, calculates from/to automatically.
91
+ # @return [Hash] Hash containing metric series data
92
+ def get_metric(app_id, metric_type, from: nil, to: nil, range: nil)
93
+ if range
94
+ calculated = Helpers.calculate_range(range: range, to: to)
95
+ from = calculated[:from]
96
+ to = calculated[:to]
97
+ end
98
+
99
+ validate_metric_params(metric_type, from, to)
59
100
  uri = URI("#{@api_base}/apps/#{app_id}/metrics/#{metric_type}")
60
101
  uri.query = build_query_string(from: from, to: to)
61
- make_request(uri)
102
+ response = make_request(uri)
103
+ response.dig("results", "series") || {}
62
104
  end
63
105
 
64
106
  # List all endpoints for an application
@@ -66,22 +108,32 @@ module ScoutApmMcp
66
108
  # @param app_id [Integer] ScoutAPM application ID
67
109
  # @param from [String, nil] Start time in ISO 8601 format
68
110
  # @param to [String, nil] End time in ISO 8601 format
69
- # @return [Hash] API response containing endpoints list
70
- def list_endpoints(app_id, from: nil, to: nil)
111
+ # @param range [String, nil] Quick time range (e.g., "30min", "1day", "3days", "7days"). If provided, calculates from/to automatically.
112
+ # @return [Array<Hash>] Array of endpoint hashes
113
+ def list_endpoints(app_id, from: nil, to: nil, range: nil)
114
+ if from.nil? && to.nil? && range.nil?
115
+ range = "7days"
116
+ end
117
+
118
+ if range
119
+ calculated = Helpers.calculate_range(range: range, to: to)
120
+ from = calculated[:from]
121
+ to = calculated[:to]
122
+ end
123
+
124
+ now = Time.now.utc
125
+ if from.nil? && to
126
+ calculated = Helpers.calculate_range(range: "7days", to: to)
127
+ from = calculated[:from]
128
+ elsif from && to.nil?
129
+ to = Helpers.format_time(now)
130
+ end
131
+
132
+ validate_time_range(from, to) if from && to
71
133
  uri = URI("#{@api_base}/apps/#{app_id}/endpoints")
72
134
  uri.query = build_query_string(from: from, to: to)
73
- make_request(uri)
74
- end
75
-
76
- # Get endpoint details
77
- #
78
- # @param app_id [Integer] ScoutAPM application ID
79
- # @param endpoint_id [String] Endpoint ID (base64 URL-encoded)
80
- # @return [Hash] API response containing endpoint details
81
- def get_endpoint(app_id, endpoint_id)
82
- encoded_endpoint_id = URI.encode_www_form_component(endpoint_id)
83
- uri = URI("#{@api_base}/apps/#{app_id}/endpoints/#{encoded_endpoint_id}")
84
- make_request(uri)
135
+ response = make_request(uri)
136
+ response["results"] || []
85
137
  end
86
138
 
87
139
  # Get metric data for a specific endpoint
@@ -91,12 +143,22 @@ module ScoutApmMcp
91
143
  # @param metric_type [String] Metric type (apdex, response_time, response_time_95th, errors, throughput, queue_time)
92
144
  # @param from [String, nil] Start time in ISO 8601 format
93
145
  # @param to [String, nil] End time in ISO 8601 format
94
- # @return [Hash] API response containing endpoint metrics
95
- def get_endpoint_metrics(app_id, endpoint_id, metric_type, from: nil, to: nil)
96
- encoded_endpoint_id = URI.encode_www_form_component(endpoint_id)
97
- uri = URI("#{@api_base}/apps/#{app_id}/endpoints/#{encoded_endpoint_id}/metrics/#{metric_type}")
146
+ # @param range [String, nil] Quick time range (e.g., "30min", "1day", "3days", "7days"). If provided, calculates from/to automatically.
147
+ # @return [Array] Array of metric data points for the specified metric type
148
+ def get_endpoint_metrics(app_id, endpoint_id, metric_type, from: nil, to: nil, range: nil)
149
+ if range
150
+ calculated = Helpers.calculate_range(range: range, to: to)
151
+ from = calculated[:from]
152
+ to = calculated[:to]
153
+ end
154
+
155
+ validate_metric_params(metric_type, from, to)
156
+ uri = URI(@api_base)
157
+ uri.path = File.join(uri.path, "apps", app_id.to_s, "endpoints", endpoint_id, "metrics", metric_type)
98
158
  uri.query = build_query_string(from: from, to: to)
99
- make_request(uri)
159
+ response = make_request(uri)
160
+ series = response.dig("results", "series") || {}
161
+ series[metric_type] || []
100
162
  end
101
163
 
102
164
  # List traces for a specific endpoint (max 100, within 7 days)
@@ -105,22 +167,39 @@ module ScoutApmMcp
105
167
  # @param endpoint_id [String] Endpoint ID (base64 URL-encoded)
106
168
  # @param from [String, nil] Start time in ISO 8601 format
107
169
  # @param to [String, nil] End time in ISO 8601 format
108
- # @return [Hash] API response containing traces list
109
- def list_endpoint_traces(app_id, endpoint_id, from: nil, to: nil)
110
- encoded_endpoint_id = URI.encode_www_form_component(endpoint_id)
111
- uri = URI("#{@api_base}/apps/#{app_id}/endpoints/#{encoded_endpoint_id}/traces")
170
+ # @param range [String, nil] Quick time range (e.g., "30min", "1day", "3days", "7days"). If provided, calculates from/to automatically.
171
+ # @return [Array<Hash>] Array of trace hashes
172
+ def list_endpoint_traces(app_id, endpoint_id, from: nil, to: nil, range: nil)
173
+ if range
174
+ calculated = Helpers.calculate_range(range: range, to: to)
175
+ from = calculated[:from]
176
+ to = calculated[:to]
177
+ end
178
+
179
+ validate_time_range(from, to) if from && to
180
+ if from && to
181
+ from_time = Helpers.parse_time(from)
182
+ seven_days_ago = Time.now.utc - (7 * 24 * 60 * 60)
183
+ if from_time < seven_days_ago
184
+ raise ArgumentError, "from_time cannot be older than 7 days"
185
+ end
186
+ end
187
+ uri = URI(@api_base)
188
+ uri.path = File.join(uri.path, "apps", app_id.to_s, "endpoints", endpoint_id, "traces")
112
189
  uri.query = build_query_string(from: from, to: to)
113
- make_request(uri)
190
+ response = make_request(uri)
191
+ response.dig("results", "traces") || []
114
192
  end
115
193
 
116
194
  # Fetch detailed trace information
117
195
  #
118
196
  # @param app_id [Integer] ScoutAPM application ID
119
197
  # @param trace_id [Integer] Trace identifier
120
- # @return [Hash] API response containing trace details
198
+ # @return [Hash] Trace details hash
121
199
  def fetch_trace(app_id, trace_id)
122
200
  uri = URI("#{@api_base}/apps/#{app_id}/traces/#{trace_id}")
123
- make_request(uri)
201
+ response = make_request(uri)
202
+ response.dig("results", "trace") || {}
124
203
  end
125
204
 
126
205
  # List error groups for an application (max 100, within 30 days)
@@ -129,46 +208,51 @@ module ScoutApmMcp
129
208
  # @param from [String, nil] Start time in ISO 8601 format
130
209
  # @param to [String, nil] End time in ISO 8601 format
131
210
  # @param endpoint [String, nil] Base64 URL-encoded endpoint filter (optional)
132
- # @return [Hash] API response containing error groups list
211
+ # @return [Array<Hash>] Array of error group hashes
133
212
  def list_error_groups(app_id, from: nil, to: nil, endpoint: nil)
213
+ validate_time_range(from, to) if from && to
134
214
  uri = URI("#{@api_base}/apps/#{app_id}/error_groups")
135
215
  params = {}
136
216
  params["from"] = from if from
137
217
  params["to"] = to if to
138
218
  params["endpoint"] = endpoint if endpoint
139
219
  uri.query = URI.encode_www_form(params) unless params.empty?
140
- make_request(uri)
220
+ response = make_request(uri)
221
+ response.dig("results", "error_groups") || []
141
222
  end
142
223
 
143
224
  # Get details for a specific error group
144
225
  #
145
226
  # @param app_id [Integer] ScoutAPM application ID
146
227
  # @param error_id [Integer] Error group identifier
147
- # @return [Hash] API response containing error group details
228
+ # @return [Hash] Error group details hash
148
229
  def get_error_group(app_id, error_id)
149
230
  uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}")
150
- make_request(uri)
231
+ response = make_request(uri)
232
+ response.dig("results", "error_group") || {}
151
233
  end
152
234
 
153
235
  # Get individual errors within an error group (max 100)
154
236
  #
155
237
  # @param app_id [Integer] ScoutAPM application ID
156
238
  # @param error_id [Integer] Error group identifier
157
- # @return [Hash] API response containing errors list
239
+ # @return [Array<Hash>] Array of error hashes
158
240
  def get_error_group_errors(app_id, error_id)
159
241
  uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}/errors")
160
- make_request(uri)
242
+ response = make_request(uri)
243
+ response.dig("results", "errors") || []
161
244
  end
162
245
 
163
246
  # Get all insight types for an application (cached for 5 minutes)
164
247
  #
165
248
  # @param app_id [Integer] ScoutAPM application ID
166
249
  # @param limit [Integer, nil] Maximum number of items per insight type (default: 20)
167
- # @return [Hash] API response containing all insights
250
+ # @return [Hash] Hash containing all insight types
168
251
  def get_all_insights(app_id, limit: nil)
169
252
  uri = URI("#{@api_base}/apps/#{app_id}/insights")
170
253
  uri.query = "limit=#{limit}" if limit
171
- make_request(uri)
254
+ response = make_request(uri)
255
+ response["results"] || {}
172
256
  end
173
257
 
174
258
  # Get data for a specific insight type
@@ -176,11 +260,15 @@ module ScoutApmMcp
176
260
  # @param app_id [Integer] ScoutAPM application ID
177
261
  # @param insight_type [String] Insight type (n_plus_one, memory_bloat, slow_query)
178
262
  # @param limit [Integer, nil] Maximum number of items (default: 20)
179
- # @return [Hash] API response containing insights
263
+ # @return [Hash] Hash containing insight data
180
264
  def get_insight_by_type(app_id, insight_type, limit: nil)
265
+ unless VALID_INSIGHTS.include?(insight_type)
266
+ raise ArgumentError, "Invalid insight_type. Must be one of: #{VALID_INSIGHTS.join(", ")}"
267
+ end
181
268
  uri = URI("#{@api_base}/apps/#{app_id}/insights/#{insight_type}")
182
269
  uri.query = "limit=#{limit}" if limit
183
- make_request(uri)
270
+ response = make_request(uri)
271
+ response["results"] || {}
184
272
  end
185
273
 
186
274
  # Get historical insights data with cursor-based pagination
@@ -239,6 +327,7 @@ module ScoutApmMcp
239
327
 
240
328
  request = Net::HTTP::Get.new(uri)
241
329
  request["X-SCOUT-API"] = @api_key
330
+ request["User-Agent"] = @user_agent
242
331
  request["Accept"] = "application/x-yaml, application/yaml, text/yaml, */*"
243
332
 
244
333
  response = http.request(request)
@@ -251,14 +340,16 @@ module ScoutApmMcp
251
340
  status: response.code.to_i
252
341
  }
253
342
  when Net::HTTPUnauthorized
254
- raise "Authentication failed. Check your API key."
343
+ raise AuthError, "Authentication failed. Check your API key."
255
344
  else
256
- raise "API request failed: #{response.code} #{response.message}"
345
+ raise APIError.new("API request failed: #{response.code} #{response.message}", status_code: response.code.to_i)
257
346
  end
258
347
  rescue OpenSSL::SSL::SSLError => e
259
- raise "SSL verification failed: #{e.message}. This may be due to system certificate configuration issues."
348
+ raise Error, "SSL verification failed: #{e.message}. This may be due to system certificate configuration issues."
349
+ rescue Error
350
+ raise
260
351
  rescue => e
261
- raise "Request failed: #{e.class} - #{e.message}"
352
+ raise Error, "Request failed: #{e.class} - #{e.message}"
262
353
  end
263
354
 
264
355
  private
@@ -303,24 +394,92 @@ module ScoutApmMcp
303
394
 
304
395
  request = Net::HTTP::Get.new(uri)
305
396
  request["X-SCOUT-API"] = @api_key
397
+ request["User-Agent"] = @user_agent
306
398
  request["Accept"] = "application/json"
307
399
 
308
400
  response = http.request(request)
401
+ response_data = handle_response_errors(response)
402
+
403
+ if response_data.is_a?(Hash)
404
+ header = response_data["header"]
405
+ if header && header["status"]
406
+ status_code = header["status"]["code"]
407
+ if status_code && status_code >= 400
408
+ error_msg = header["status"]["message"] || "Unknown API error"
409
+ raise APIError.new(error_msg, status_code: status_code, response_data: response_data)
410
+ end
411
+ end
412
+ end
413
+
414
+ response_data
415
+ rescue OpenSSL::SSL::SSLError => e
416
+ raise Error, "SSL verification failed: #{e.message}. This may be due to system certificate configuration issues."
417
+ rescue Error
418
+ raise
419
+ rescue => e
420
+ raise Error, "Request failed: #{e.class} - #{e.message}"
421
+ end
422
+
423
+ # @param response [Net::HTTPResponse] HTTP response object
424
+ # @return [Hash, Array] Parsed JSON response
425
+ # @raise [AuthError] When authentication fails
426
+ # @raise [APIError] When the API returns an error response
427
+ def handle_response_errors(response)
428
+ begin
429
+ data = JSON.parse(response.body)
430
+ rescue JSON::ParserError
431
+ raise APIError.new("Invalid JSON response: #{response.body}", status_code: response.code.to_i)
432
+ end
309
433
 
310
434
  case response
311
435
  when Net::HTTPSuccess
312
- JSON.parse(response.body)
436
+ data
313
437
  when Net::HTTPUnauthorized
314
- raise "Authentication failed. Check your API key. Response: #{response.body}"
438
+ raise AuthError, "Authentication failed - check your API key"
315
439
  when Net::HTTPNotFound
316
- raise "Resource not found. Response: #{response.body}"
440
+ raise APIError.new("Resource not found", status_code: 404, response_data: data)
317
441
  else
318
- raise "API request failed: #{response.code} #{response.message}\n#{response.body}"
442
+ error_msg = "API request failed"
443
+ if data.is_a?(Hash) && data.dig("header", "status", "message")
444
+ error_msg = data.dig("header", "status", "message")
445
+ end
446
+ raise APIError.new(error_msg, status_code: response.code.to_i, response_data: data)
447
+ end
448
+ end
449
+
450
+ # Validate metric parameters
451
+ #
452
+ # @param metric_type [String] Metric type to validate
453
+ # @param from [String, nil] Start time in ISO 8601 format
454
+ # @param to [String, nil] End time in ISO 8601 format
455
+ # @raise [ArgumentError] If validation fails
456
+ def validate_metric_params(metric_type, from, to)
457
+ unless VALID_METRICS.include?(metric_type)
458
+ raise ArgumentError, "Invalid metric_type. Must be one of: #{VALID_METRICS.join(", ")}"
459
+ end
460
+ validate_time_range(from, to) if from && to
461
+ end
462
+
463
+ # Validate time ranges
464
+ #
465
+ # @param from [String, nil] Start time in ISO 8601 format
466
+ # @param to [String, nil] End time in ISO 8601 format
467
+ # @raise [ArgumentError] If validation fails
468
+ def validate_time_range(from, to)
469
+ return unless from && to
470
+
471
+ from_time = Helpers.parse_time(from)
472
+ to_time = Helpers.parse_time(to)
473
+
474
+ if from_time >= to_time
475
+ raise ArgumentError, "from_time must be before to_time"
476
+ end
477
+
478
+ # Validate time range (2 week maximum)
479
+ max_duration = 14 * 24 * 60 * 60 # 14 days in seconds
480
+ if (to_time - from_time) > max_duration
481
+ raise ArgumentError, "Time range cannot exceed 2 weeks"
319
482
  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
483
  end
325
484
  end
326
485
  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
  #
@@ -13,14 +14,10 @@ module ScoutApmMcp
13
14
  # @return [String] API key
14
15
  # @raise [RuntimeError] if API key cannot be found
15
16
  def self.get_api_key(api_key: nil, op_vault: nil, op_item: nil, op_field: "API_KEY")
16
- # Use provided API key if available
17
17
  return api_key if api_key && !api_key.empty?
18
18
 
19
- # Check environment variable (may have been set by opdotenv loaded early in server startup)
20
19
  api_key = ENV["API_KEY"] || ENV["SCOUT_APM_API_KEY"]
21
20
  return api_key if api_key && !api_key.empty?
22
-
23
- # Try direct 1Password CLI as fallback (opdotenv was already tried in server startup)
24
21
  op_env_entry_path = ENV["OP_ENV_ENTRY_PATH"]
25
22
  if op_env_entry_path && !op_env_entry_path.empty?
26
23
  begin
@@ -54,7 +51,6 @@ module ScoutApmMcp
54
51
  api_key = `op read "op://#{op_vault}/#{op_item}/#{op_field}" 2>/dev/null`.strip
55
52
  return api_key if api_key && !api_key.empty?
56
53
  rescue
57
- # Silently fail
58
54
  end
59
55
  end
60
56
 
@@ -134,7 +130,6 @@ module ScoutApmMcp
134
130
  # @return [String] Decoded endpoint ID
135
131
  def self.decode_endpoint_id(endpoint_id)
136
132
  decoded = Base64.urlsafe_decode64(endpoint_id)
137
- # Check if decoded result is valid UTF-8
138
133
  if decoded.force_encoding(Encoding::UTF_8).valid_encoding?
139
134
  decoded.force_encoding(Encoding::UTF_8)
140
135
  else
@@ -151,5 +146,107 @@ module ScoutApmMcp
151
146
  # If decoding raises an exception, return original string
152
147
  endpoint_id.dup.force_encoding(Encoding::UTF_8)
153
148
  end
149
+
150
+ # Get a unique identifier for an endpoint from an endpoint dictionary
151
+ #
152
+ # This is provided by the API implicitly in the 'link' field.
153
+ #
154
+ # @param endpoint [Hash] Endpoint dictionary from the API
155
+ # @return [String] Endpoint ID extracted from the link field, or empty string if not found
156
+ def self.get_endpoint_id(endpoint)
157
+ link = endpoint["link"] || endpoint[:link] || ""
158
+ return "" if link.empty?
159
+
160
+ # Extract the endpoint ID from the link (last path segment)
161
+ link.split("/").last || ""
162
+ end
163
+
164
+ # Format datetime to ISO 8601 string for API
165
+ #
166
+ # Relies on UTC timezone. Converts the time to UTC if it's not already.
167
+ #
168
+ # @param time [Time] Time object to format
169
+ # @return [String] ISO 8601 formatted time string (e.g., "2025-01-01T00:00:00Z")
170
+ def self.format_time(time)
171
+ time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
172
+ end
173
+
174
+ # Parse ISO 8601 time string to Time object
175
+ #
176
+ # Handles both 'Z' suffix and timezone offsets.
177
+ #
178
+ # @param time_str [String] ISO 8601 time string (e.g., "2025-01-01T00:00:00Z")
179
+ # @return [Time] Time object in UTC
180
+ def self.parse_time(time_str)
181
+ # Replace Z with +00:00 for Ruby's Time parser
182
+ normalized = time_str.sub(/Z\z/i, "+00:00")
183
+ Time.parse(normalized).utc
184
+ end
185
+
186
+ # Create a Duration object from ISO 8601 strings
187
+ #
188
+ # @param from_str [String] Start time in ISO 8601 format
189
+ # @param to_str [String] End time in ISO 8601 format
190
+ # @return [Hash] Hash with :start and :end Time objects
191
+ def self.make_duration(from_str, to_str)
192
+ {
193
+ start: parse_time(from_str),
194
+ end: parse_time(to_str)
195
+ }
196
+ end
197
+
198
+ # Parse a time range string into seconds
199
+ #
200
+ # Supports formats like: "30min", "60min", "3hrs", "6hrs", "12hrs", "1day", "3days", "7days"
201
+ # Case-insensitive, supports singular and plural forms
202
+ #
203
+ # @param range_str [String] Time range string (e.g., "30min", "1day", "7days")
204
+ # @return [Integer] Duration in seconds
205
+ # @raise [ArgumentError] If the range string format is invalid
206
+ def self.parse_range(range_str)
207
+ return nil if range_str.nil? || range_str.empty?
208
+
209
+ # Normalize: lowercase, remove spaces, handle singular/plural
210
+ normalized = range_str.downcase.strip.gsub(/\s+/, "")
211
+
212
+ # Match pattern: number followed by unit
213
+ match = normalized.match(/\A(\d+)(min|mins?|hr|hrs?|hour|hours|day|days)\z/)
214
+ unless match
215
+ valid_ranges = %w[30min 60min 3hrs 6hrs 12hrs 1day 3days 7days]
216
+ raise ArgumentError, "Invalid range format: #{range_str}. Valid formats: #{valid_ranges.join(", ")}"
217
+ end
218
+
219
+ value = match[1].to_i
220
+ unit = match[2]
221
+
222
+ case unit
223
+ when /^min/
224
+ value * 60
225
+ when /^hr/, /^hour/
226
+ value * 60 * 60
227
+ when /^day/
228
+ value * 24 * 60 * 60
229
+ else
230
+ raise ArgumentError, "Unknown time unit: #{unit}"
231
+ end
232
+ end
233
+
234
+ # Calculate from/to times based on a range string
235
+ #
236
+ # @param range [String, nil] Time range string (e.g., "30min", "1day", "3days")
237
+ # @param to [String, nil] End time in ISO 8601 format (defaults to now if not provided)
238
+ # @return [Hash] Hash with :from and :to as ISO 8601 strings
239
+ def self.calculate_range(range:, to: nil)
240
+ return {from: nil, to: to} if range.nil? || range.empty?
241
+
242
+ end_time = to ? parse_time(to) : Time.now.utc
243
+ duration_seconds = parse_range(range)
244
+ start_time = end_time - duration_seconds
245
+
246
+ {
247
+ from: format_time(start_time),
248
+ to: format_time(end_time)
249
+ }
250
+ end
154
251
  end
155
252
  end
@@ -133,7 +133,6 @@ module ScoutApmMcp
133
133
  server.register_tool(ListMetricsTool)
134
134
  server.register_tool(GetMetricTool)
135
135
  server.register_tool(ListEndpointsTool)
136
- server.register_tool(FetchEndpointTool)
137
136
  server.register_tool(GetEndpointMetricsTool)
138
137
  server.register_tool(ListEndpointTracesTool)
139
138
  server.register_tool(FetchTraceTool)
@@ -166,14 +165,27 @@ module ScoutApmMcp
166
165
 
167
166
  # Applications Tools
168
167
  class ListAppsTool < BaseTool
169
- description "List all applications accessible with the provided API key"
168
+ description <<~DESC
169
+ List all applications accessible with the provided API key.
170
+
171
+ Returns an array of applications with details like name, ID, and last reported time.
172
+ Use the app_id from the results to make subsequent API calls.
173
+
174
+ Optional filtering:
175
+ - active_since: Only return apps that have reported data since this time (ISO 8601 format)
176
+ - Default behavior: Returns all apps (no filtering by default, but API may filter to last 30 days)
177
+
178
+ Example:
179
+ - List all apps: call without parameters
180
+ - List apps active in last 7 days: provide active_since="2025-01-08T00:00:00Z"
181
+ DESC
170
182
 
171
183
  arguments do
172
- # No arguments required
184
+ optional(:active_since).maybe(:string).description("ISO 8601 datetime string to filter apps active since that time (e.g., 2025-01-08T00:00:00Z)")
173
185
  end
174
186
 
175
- def call
176
- get_client.list_apps
187
+ def call(active_since: nil)
188
+ get_client.list_apps(active_since: active_since)
177
189
  end
178
190
  end
179
191
 
@@ -203,80 +215,151 @@ module ScoutApmMcp
203
215
  end
204
216
 
205
217
  class GetMetricTool < BaseTool
206
- description "Get time-series data for a specific metric type"
218
+ description <<~DESC
219
+ Get time-series data for a specific metric type.
220
+
221
+ Available metric types:
222
+ - apdex: Application Performance Index (0-1, higher is better)
223
+ - response_time: Average response time in milliseconds
224
+ - response_time_95th: 95th percentile response time in milliseconds
225
+ - errors: Number of errors
226
+ - throughput: Requests per second
227
+ - queue_time: Time spent in queue in milliseconds
228
+
229
+ You can specify time ranges using:
230
+ 1. Quick range templates: range="30min", "1day", "3days", "7days", etc.
231
+ 2. Explicit times: from and to with ISO 8601 timestamps
232
+
233
+ Examples:
234
+ - Get response time for last hour: metric_type="response_time", range="1hr"
235
+ - Get error count for last day: metric_type="errors", range="1day"
236
+ - Get metrics for specific range: metric_type="apdex", from="2025-01-15T10:00:00Z", to="2025-01-15T12:00:00Z"
237
+ DESC
207
238
 
208
239
  arguments do
209
240
  required(:app_id).filled(:integer).description("ScoutAPM application ID")
210
241
  required(:metric_type).filled(:string).description("Metric type: apdex, response_time, response_time_95th, errors, throughput, queue_time")
211
- optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
212
- optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
242
+ optional(:range).maybe(:string).description("Quick time range template: 30min, 60min, 3hrs, 6hrs, 12hrs, 1day, 3days, 7days. If provided, calculates from/to automatically.")
243
+ optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z). Ignored if range is provided.")
244
+ optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z). Used as end point for range if range is provided.")
213
245
  end
214
246
 
215
- def call(app_id:, metric_type:, from: nil, to: nil)
216
- get_client.get_metric(app_id, metric_type, from: from, to: to)
247
+ def call(app_id:, metric_type:, range: nil, from: nil, to: nil)
248
+ get_client.get_metric(app_id, metric_type, from: from, to: to, range: range)
217
249
  end
218
250
  end
219
251
 
220
252
  # Endpoints Tools
221
253
  class ListEndpointsTool < BaseTool
222
- description "List all endpoints for an application"
254
+ description <<~DESC
255
+ List all endpoints for an application.
256
+
257
+ The API requires timeframe parameters. You can specify time ranges in two ways:
258
+ 1. Quick range templates: Use the 'range' parameter (e.g., "30min", "1day", "3days", "7days")
259
+ 2. Explicit times: Use 'from' and 'to' parameters with ISO 8601 timestamps
260
+
261
+ If neither from/to nor range are provided, defaults to the last 7 days.
262
+
263
+ Quick range templates (case-insensitive):
264
+ - "30min" or "30mins" - Last 30 minutes
265
+ - "60min" or "60mins" or "1hr" or "1hour" - Last 60 minutes
266
+ - "3hrs" or "3hours" - Last 3 hours
267
+ - "6hrs" or "6hours" - Last 6 hours
268
+ - "12hrs" or "12hours" - Last 12 hours
269
+ - "1day" or "1days" - Last 24 hours
270
+ - "3days" - Last 3 days
271
+ - "7days" - Last 7 days (default)
272
+
273
+ Examples:
274
+ - List endpoints for last 30 minutes: range="30min"
275
+ - List endpoints for last day: range="1day"
276
+ - List endpoints for a specific range: from="2025-01-15T10:00:00Z", to="2025-01-15T12:00:00Z"
277
+ - List endpoints from a specific time to now: from="2025-01-15T10:00:00Z"
278
+ - List endpoints for 1 day ending at specific time: range="1day", to="2025-01-15T12:00:00Z"
279
+ DESC
223
280
 
224
281
  arguments do
225
282
  required(:app_id).filled(:integer).description("ScoutAPM application ID")
226
- optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
227
- optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
283
+ optional(:range).maybe(:string).description("Quick time range template: 30min, 60min, 3hrs, 6hrs, 12hrs, 1day, 3days, 7days. If provided, calculates from/to automatically.")
284
+ optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z). Ignored if range is provided.")
285
+ optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z). Used as end point for range if range is provided, otherwise defaults to now.")
228
286
  end
229
287
 
230
- def call(app_id:, from: nil, to: nil)
231
- get_client.list_endpoints(app_id, from: from, to: to)
288
+ def call(app_id:, range: nil, from: nil, to: nil)
289
+ get_client.list_endpoints(app_id, from: from, to: to, range: range)
232
290
  end
233
291
  end
234
292
 
235
- class FetchEndpointTool < BaseTool
236
- description "Fetch endpoint details from ScoutAPM API"
293
+ class GetEndpointMetricsTool < BaseTool
294
+ description <<~DESC
295
+ Get metric data for a specific endpoint.
237
296
 
238
- arguments do
239
- required(:app_id).filled(:integer).description("ScoutAPM application ID")
240
- required(:endpoint_id).filled(:string).description("Endpoint ID (base64 URL-encoded)")
241
- end
297
+ Available metric types:
298
+ - apdex: Application Performance Index (0-1, higher is better)
299
+ - response_time: Average response time in milliseconds
300
+ - response_time_95th: 95th percentile response time in milliseconds
301
+ - errors: Number of errors
302
+ - throughput: Requests per second
303
+ - queue_time: Time spent in queue in milliseconds
242
304
 
243
- def call(app_id:, endpoint_id:)
244
- client = get_client
245
- {
246
- endpoint: client.get_endpoint(app_id, endpoint_id),
247
- decoded_endpoint: Helpers.decode_endpoint_id(endpoint_id)
248
- }
249
- end
250
- end
305
+ You can specify time ranges using:
306
+ 1. Quick range templates: range="30min", "1day", "3days", "7days", etc.
307
+ 2. Explicit times: from and to with ISO 8601 timestamps
251
308
 
252
- class GetEndpointMetricsTool < BaseTool
253
- description "Get metric data for a specific endpoint"
309
+ Returns time-series data points for the specified metric type.
310
+
311
+ Examples:
312
+ - Get response time for last hour: metric_type="response_time", range="1hr"
313
+ - Get error count for last day: metric_type="errors", range="1day"
314
+ - Get metrics for specific range: metric_type="apdex", from="2025-01-15T10:00:00Z", to="2025-01-15T12:00:00Z"
315
+ DESC
254
316
 
255
317
  arguments do
256
318
  required(:app_id).filled(:integer).description("ScoutAPM application ID")
257
- required(:endpoint_id).filled(:string).description("Endpoint ID (base64 URL-encoded)")
319
+ required(:endpoint_id).filled(:string).description("Endpoint ID (base64 URL-encoded). Extract from ScoutAPM URLs or use ParseScoutURLTool.")
258
320
  required(:metric_type).filled(:string).description("Metric type: apdex, response_time, response_time_95th, errors, throughput, queue_time")
259
- optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
260
- optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
321
+ optional(:range).maybe(:string).description("Quick time range template: 30min, 60min, 3hrs, 6hrs, 12hrs, 1day, 3days, 7days. If provided, calculates from/to automatically.")
322
+ optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z). Ignored if range is provided.")
323
+ optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z). Used as end point for range if range is provided.")
261
324
  end
262
325
 
263
- def call(app_id:, endpoint_id:, metric_type:, from: nil, to: nil)
264
- get_client.get_endpoint_metrics(app_id, endpoint_id, metric_type, from: from, to: to)
326
+ def call(app_id:, endpoint_id:, metric_type:, range: nil, from: nil, to: nil)
327
+ get_client.get_endpoint_metrics(app_id, endpoint_id, metric_type, from: from, to: to, range: range)
265
328
  end
266
329
  end
267
330
 
268
331
  class ListEndpointTracesTool < BaseTool
269
- description "List traces for a specific endpoint (max 100, within 7 days)"
332
+ description <<~DESC
333
+ List traces for a specific endpoint (max 100, within 7 days).
334
+
335
+ Traces are individual request executions that can be analyzed for performance issues.
336
+ Returns up to 100 traces for the specified endpoint within the last 7 days.
337
+
338
+ You can specify time ranges using:
339
+ 1. Quick range templates: range="30min", "1day", "3days", "7days", etc.
340
+ 2. Explicit times: from and to with ISO 8601 timestamps
341
+
342
+ Time range constraints:
343
+ - If from is provided, it must be within the last 7 days
344
+ - Maximum 100 traces returned per request
345
+ - Use the trace_id from results with FetchTraceTool for detailed analysis
346
+
347
+ Examples:
348
+ - List traces from last hour: range="1hr"
349
+ - List traces from last day: range="1day"
350
+ - List traces for specific range: from="2025-01-15T10:00:00Z", to="2025-01-15T12:00:00Z"
351
+ DESC
270
352
 
271
353
  arguments do
272
354
  required(:app_id).filled(:integer).description("ScoutAPM application ID")
273
- required(:endpoint_id).filled(:string).description("Endpoint ID (base64 URL-encoded)")
274
- optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
275
- optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
355
+ required(:endpoint_id).filled(:string).description("Endpoint ID (base64 URL-encoded). Extract from ScoutAPM URLs or use ParseScoutURLTool.")
356
+ optional(:range).maybe(:string).description("Quick time range template: 30min, 60min, 3hrs, 6hrs, 12hrs, 1day, 3days, 7days. If provided, calculates from/to automatically.")
357
+ optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z). Must be within last 7 days. Ignored if range is provided.")
358
+ optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z). Used as end point for range if range is provided.")
276
359
  end
277
360
 
278
- def call(app_id:, endpoint_id:, from: nil, to: nil)
279
- get_client.list_endpoint_traces(app_id, endpoint_id, from: from, to: to)
361
+ def call(app_id:, endpoint_id:, range: nil, from: nil, to: nil)
362
+ get_client.list_endpoint_traces(app_id, endpoint_id, from: from, to: to, range: range)
280
363
  end
281
364
  end
282
365
 
@@ -432,10 +515,28 @@ module ScoutApmMcp
432
515
 
433
516
  # Utility Tools
434
517
  class ParseScoutURLTool < BaseTool
435
- description "Parse a ScoutAPM URL and extract resource information (app_id, endpoint_id, trace_id, etc.)"
518
+ description <<~DESC
519
+ Parse a ScoutAPM URL and extract resource information (app_id, endpoint_id, trace_id, etc.).
520
+
521
+ This tool extracts structured information from ScoutAPM URLs without making API calls.
522
+ Useful for extracting IDs before making other API requests.
523
+
524
+ Returns a hash with:
525
+ - url_type: :endpoint, :trace, :error_group, :insight, :app, or :unknown
526
+ - app_id: Application ID (integer)
527
+ - endpoint_id: Base64 URL-encoded endpoint ID (if present)
528
+ - trace_id: Trace ID (if present)
529
+ - error_id: Error group ID (if present)
530
+ - insight_type: Insight type (if present)
531
+ - decoded_endpoint: Human-readable endpoint path (if endpoint_id present)
532
+
533
+ Example:
534
+ - Input: "https://scoutapm.com/apps/123/endpoints/ABC123.../trace/456"
535
+ - Output: {url_type: :trace, app_id: 123, endpoint_id: "ABC123...", trace_id: 456, decoded_endpoint: "Controller/Action"}
536
+ DESC
436
537
 
437
538
  arguments do
438
- required(:url).filled(:string).description("Full ScoutAPM URL (e.g., https://scoutapm.com/apps/123/endpoints/.../trace/456)")
539
+ required(:url).filled(:string).description("Full ScoutAPM URL")
439
540
  end
440
541
 
441
542
  def call(url:)
@@ -444,10 +545,27 @@ module ScoutApmMcp
444
545
  end
445
546
 
446
547
  class FetchScoutURLTool < BaseTool
447
- description "Fetch data from a ScoutAPM URL by automatically detecting the resource type and fetching the appropriate data"
548
+ description <<~DESC
549
+ Fetch data from a ScoutAPM URL by automatically detecting the resource type and fetching the appropriate data.
550
+
551
+ This tool automatically parses ScoutAPM URLs and fetches the corresponding data.
552
+ Supported URL types:
553
+ - Endpoint URLs: /apps/{app_id}/endpoints/{endpoint_id} (fetches from endpoint list)
554
+ - Trace URLs: /apps/{app_id}/endpoints/{endpoint_id}/trace/{trace_id}
555
+ - Error group URLs: /apps/{app_id}/error_groups/{error_id}
556
+ - Insight URLs: /apps/{app_id}/insights or /apps/{app_id}/insights/{insight_type}
557
+ - App URLs: /apps/{app_id}
558
+
559
+ Examples:
560
+ - https://scoutapm.com/apps/123/endpoints/ABC123... (endpoint)
561
+ - https://scoutapm.com/apps/123/endpoints/ABC123.../trace/456 (trace)
562
+ - https://scoutapm.com/apps/123/error_groups/789 (error group)
563
+
564
+ For trace URLs, set include_endpoint=true to also fetch endpoint context.
565
+ DESC
448
566
 
449
567
  arguments do
450
- required(:url).filled(:string).description("Full ScoutAPM URL (e.g., https://scoutapm.com/apps/123/endpoints/.../trace/456)")
568
+ required(:url).filled(:string).description("Full ScoutAPM URL")
451
569
  optional(:include_endpoint).filled(:bool).description("For trace URLs, also fetch endpoint details for context (default: false)")
452
570
  end
453
571
 
@@ -468,20 +586,37 @@ module ScoutApmMcp
468
586
  result[:data] = {trace: trace_data}
469
587
 
470
588
  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]
589
+ begin
590
+ endpoints = client.list_endpoints(parsed[:app_id], range: "7days")
591
+ endpoint_data = endpoints.find { |ep| Helpers.get_endpoint_id(ep) == parsed[:endpoint_id] }
592
+
593
+ if endpoint_data
594
+ result[:data][:endpoint] = endpoint_data
595
+ else
596
+ result[:data][:endpoint_error] = "Endpoint not found in the last 7 days"
597
+ end
598
+ result[:data][:decoded_endpoint] = parsed[:decoded_endpoint]
599
+ rescue => e
600
+ result[:data][:endpoint_error] = "Failed to fetch endpoint: #{e.message}"
601
+ result[:data][:decoded_endpoint] = parsed[:decoded_endpoint]
602
+ end
474
603
  end
475
604
  else
476
605
  raise "Invalid trace URL: missing app_id or trace_id"
477
606
  end
478
607
  when :endpoint
479
608
  if parsed[:app_id] && parsed[:endpoint_id]
480
- endpoint_data = client.get_endpoint(parsed[:app_id], parsed[:endpoint_id])
481
- result[:data] = {
482
- endpoint: endpoint_data,
483
- decoded_endpoint: parsed[:decoded_endpoint]
484
- }
609
+ endpoints = client.list_endpoints(parsed[:app_id], range: "7days")
610
+ endpoint_data = endpoints.find { |ep| Helpers.get_endpoint_id(ep) == parsed[:endpoint_id] }
611
+
612
+ if endpoint_data
613
+ result[:data] = {
614
+ endpoint: endpoint_data,
615
+ decoded_endpoint: parsed[:decoded_endpoint]
616
+ }
617
+ else
618
+ raise "Endpoint not found in the last 7 days. Try using ListEndpointsTool with a longer time range."
619
+ end
485
620
  else
486
621
  raise "Invalid endpoint URL: missing app_id or endpoint_id"
487
622
  end
@@ -1,3 +1,3 @@
1
1
  module ScoutApmMcp
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.4"
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
@@ -16,7 +16,6 @@ module ScoutApmMcp
16
16
  def list_metrics: (Integer app_id) -> Hash[String, untyped]
17
17
  def get_metric: (Integer app_id, String metric_type, ?from: String?, ?to: String?) -> Hash[String, untyped]
18
18
  def list_endpoints: (Integer app_id, ?from: String?, ?to: String?) -> Hash[String, untyped]
19
- def get_endpoint: (Integer app_id, String endpoint_id) -> Hash[String, untyped]
20
19
  def get_endpoint_metrics: (Integer app_id, String endpoint_id, String metric_type, ?from: String?, ?to: String?) -> Hash[String, untyped]
21
20
  def list_endpoint_traces: (Integer app_id, String endpoint_id, ?from: String?, ?to: String?) -> Hash[String, untyped]
22
21
  def fetch_trace: (Integer app_id, Integer trace_id) -> Hash[String, untyped]
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.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -47,22 +47,22 @@ dependencies:
47
47
  name: rack
48
48
  requirement: !ruby/object:Gem::Requirement
49
49
  requirements:
50
- - - "~>"
50
+ - - ">="
51
51
  - !ruby/object:Gem::Version
52
52
  version: '2.2'
53
- - - ">="
53
+ - - "<"
54
54
  - !ruby/object:Gem::Version
55
- version: 2.2.0
55
+ version: '4.0'
56
56
  type: :runtime
57
57
  prerelease: false
58
58
  version_requirements: !ruby/object:Gem::Requirement
59
59
  requirements:
60
- - - "~>"
60
+ - - ">="
61
61
  - !ruby/object:Gem::Version
62
62
  version: '2.2'
63
- - - ">="
63
+ - - "<"
64
64
  - !ruby/object:Gem::Version
65
- version: 2.2.0
65
+ version: '4.0'
66
66
  - !ruby/object:Gem::Dependency
67
67
  name: opdotenv
68
68
  requirement: !ruby/object:Gem::Requirement
@@ -175,6 +175,48 @@ dependencies:
175
175
  - - "~>"
176
176
  - !ruby/object:Gem::Version
177
177
  version: '1.52'
178
+ - !ruby/object:Gem::Dependency
179
+ name: standard-performance
180
+ requirement: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - "~>"
183
+ - !ruby/object:Gem::Version
184
+ version: '1.8'
185
+ type: :development
186
+ prerelease: false
187
+ version_requirements: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - "~>"
190
+ - !ruby/object:Gem::Version
191
+ version: '1.8'
192
+ - !ruby/object:Gem::Dependency
193
+ name: standard-rspec
194
+ requirement: !ruby/object:Gem::Requirement
195
+ requirements:
196
+ - - "~>"
197
+ - !ruby/object:Gem::Version
198
+ version: '0.3'
199
+ type: :development
200
+ prerelease: false
201
+ version_requirements: !ruby/object:Gem::Requirement
202
+ requirements:
203
+ - - "~>"
204
+ - !ruby/object:Gem::Version
205
+ version: '0.3'
206
+ - !ruby/object:Gem::Dependency
207
+ name: rubocop-rspec
208
+ requirement: !ruby/object:Gem::Requirement
209
+ requirements:
210
+ - - "~>"
211
+ - !ruby/object:Gem::Version
212
+ version: '3.8'
213
+ type: :development
214
+ prerelease: false
215
+ version_requirements: !ruby/object:Gem::Requirement
216
+ requirements:
217
+ - - "~>"
218
+ - !ruby/object:Gem::Version
219
+ version: '3.8'
178
220
  - !ruby/object:Gem::Dependency
179
221
  name: appraisal
180
222
  requirement: !ruby/object:Gem::Requirement
@@ -233,6 +275,7 @@ files:
233
275
  - bin/scout_apm_mcp
234
276
  - lib/scout_apm_mcp.rb
235
277
  - lib/scout_apm_mcp/client.rb
278
+ - lib/scout_apm_mcp/errors.rb
236
279
  - lib/scout_apm_mcp/helpers.rb
237
280
  - lib/scout_apm_mcp/server.rb
238
281
  - lib/scout_apm_mcp/version.rb