scout_apm_mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.md +22 -0
- data/README.md +322 -0
- data/bin/scout_apm_mcp +18 -0
- data/lib/scout_apm_mcp/client.rb +302 -0
- data/lib/scout_apm_mcp/helpers.rb +130 -0
- data/lib/scout_apm_mcp/server.rb +502 -0
- data/lib/scout_apm_mcp/version.rb +3 -0
- data/lib/scout_apm_mcp.rb +36 -0
- data/sig/scout_apm_mcp.rbs +33 -0
- metadata +264 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module ScoutApmMcp
|
|
5
|
+
# Helper module for API key management and URL parsing
|
|
6
|
+
module Helpers
|
|
7
|
+
# Get API key from environment or 1Password
|
|
8
|
+
#
|
|
9
|
+
# @param api_key [String, nil] Optional API key to use directly
|
|
10
|
+
# @param op_vault [String, nil] 1Password vault name (optional)
|
|
11
|
+
# @param op_item [String, nil] 1Password item name (optional)
|
|
12
|
+
# @param op_field [String] 1Password field name (default: "API_KEY")
|
|
13
|
+
# @return [String] API key
|
|
14
|
+
# @raise [RuntimeError] if API key cannot be found
|
|
15
|
+
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
|
+
return api_key if api_key && !api_key.empty?
|
|
18
|
+
|
|
19
|
+
# Check environment variable (may have been set by opdotenv loaded early in server startup)
|
|
20
|
+
api_key = ENV["API_KEY"] || ENV["SCOUT_APM_API_KEY"]
|
|
21
|
+
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
|
+
op_env_entry_path = ENV["OP_ENV_ENTRY_PATH"]
|
|
25
|
+
if op_env_entry_path && !op_env_entry_path.empty?
|
|
26
|
+
begin
|
|
27
|
+
# Extract vault and item from OP_ENV_ENTRY_PATH (format: op://Vault/Item)
|
|
28
|
+
if op_env_entry_path =~ %r{op://([^/]+)/(.+)}
|
|
29
|
+
vault = Regexp.last_match(1)
|
|
30
|
+
item = Regexp.last_match(2)
|
|
31
|
+
api_key = `op read "op://#{vault}/#{item}/#{op_field}" 2>/dev/null`.strip
|
|
32
|
+
return api_key if api_key && !api_key.empty?
|
|
33
|
+
end
|
|
34
|
+
rescue
|
|
35
|
+
# Silently fail and try other methods
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Try to load from 1Password via opdotenv (if vault and item are provided)
|
|
40
|
+
if op_vault && op_item
|
|
41
|
+
begin
|
|
42
|
+
require "opdotenv"
|
|
43
|
+
Opdotenv::Loader.load("op://#{op_vault}/#{op_item}")
|
|
44
|
+
api_key = ENV["API_KEY"] || ENV["SCOUT_APM_API_KEY"]
|
|
45
|
+
return api_key if api_key && !api_key.empty?
|
|
46
|
+
rescue LoadError
|
|
47
|
+
# opdotenv not available, try direct op CLI
|
|
48
|
+
rescue
|
|
49
|
+
# Silently fail and try other methods
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Try direct 1Password CLI
|
|
53
|
+
begin
|
|
54
|
+
api_key = `op read "op://#{op_vault}/#{op_item}/#{op_field}" 2>/dev/null`.strip
|
|
55
|
+
return api_key if api_key && !api_key.empty?
|
|
56
|
+
rescue
|
|
57
|
+
# Silently fail
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
raise "API_KEY not found. " \
|
|
62
|
+
"Set API_KEY or SCOUT_APM_API_KEY environment variable, " \
|
|
63
|
+
"or provide OP_ENV_ENTRY_PATH, or op_vault and op_item parameters for 1Password integration"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Parse a ScoutAPM trace URL and extract app_id, endpoint_id, and trace_id
|
|
67
|
+
#
|
|
68
|
+
# @param url [String] Full ScoutAPM trace URL
|
|
69
|
+
# @return [Hash] Hash containing :app_id, :endpoint_id, :trace_id, :query_params, and :decoded_endpoint
|
|
70
|
+
def self.parse_scout_url(url)
|
|
71
|
+
uri = URI.parse(url)
|
|
72
|
+
path_parts = uri.path.split("/").reject(&:empty?)
|
|
73
|
+
|
|
74
|
+
# Extract from URL: /apps/{app_id}/endpoints/{endpoint_id}/trace/{trace_id}
|
|
75
|
+
app_index = path_parts.index("apps")
|
|
76
|
+
endpoints_index = path_parts.index("endpoints")
|
|
77
|
+
trace_index = path_parts.index("trace")
|
|
78
|
+
|
|
79
|
+
result = {}
|
|
80
|
+
|
|
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
|
|
85
|
+
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"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Parse query parameters
|
|
93
|
+
if uri.query
|
|
94
|
+
query_params = URI.decode_www_form(uri.query).to_h
|
|
95
|
+
result[:query_params] = query_params
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Decode endpoint ID for readability
|
|
99
|
+
if result[:endpoint_id]
|
|
100
|
+
result[:decoded_endpoint] = decode_endpoint_id(result[:endpoint_id])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
result
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Decode endpoint ID from base64
|
|
107
|
+
#
|
|
108
|
+
# @param endpoint_id [String] Base64-encoded endpoint ID
|
|
109
|
+
# @return [String] Decoded endpoint ID
|
|
110
|
+
def self.decode_endpoint_id(endpoint_id)
|
|
111
|
+
decoded = Base64.urlsafe_decode64(endpoint_id)
|
|
112
|
+
# Check if decoded result is valid UTF-8
|
|
113
|
+
if decoded.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
114
|
+
decoded.force_encoding(Encoding::UTF_8)
|
|
115
|
+
else
|
|
116
|
+
# Try standard base64
|
|
117
|
+
decoded = Base64.decode64(endpoint_id)
|
|
118
|
+
if decoded.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
119
|
+
decoded.force_encoding(Encoding::UTF_8)
|
|
120
|
+
else
|
|
121
|
+
# Return original string with proper encoding
|
|
122
|
+
endpoint_id.dup.force_encoding(Encoding::UTF_8)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
rescue
|
|
126
|
+
# If decoding raises an exception, return original string
|
|
127
|
+
endpoint_id.dup.force_encoding(Encoding::UTF_8)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fast_mcp"
|
|
5
|
+
require "scout_apm_mcp"
|
|
6
|
+
require "logger"
|
|
7
|
+
require "stringio"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
|
|
10
|
+
# Alias MCP to FastMcp for compatibility
|
|
11
|
+
FastMcp = MCP unless defined?(FastMcp)
|
|
12
|
+
|
|
13
|
+
# Monkey-patch fast-mcp to ensure error responses always have a valid id
|
|
14
|
+
# JSON-RPC 2.0 allows id: null for notifications, but MCP clients (Cursor/Inspector)
|
|
15
|
+
# use strict Zod validation that requires id to be a string or number
|
|
16
|
+
module MCP
|
|
17
|
+
module Transports
|
|
18
|
+
class StdioTransport
|
|
19
|
+
alias_method :original_send_error, :send_error
|
|
20
|
+
|
|
21
|
+
def send_error(code, message, id = nil)
|
|
22
|
+
# Use placeholder id if nil to satisfy strict MCP client validation
|
|
23
|
+
# JSON-RPC 2.0 allows null for notifications, but MCP clients require valid id
|
|
24
|
+
id = "error_#{SecureRandom.hex(8)}" if id.nil?
|
|
25
|
+
original_send_error(code, message, id)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
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
|
+
end
|
|
41
|
+
|
|
42
|
+
module ScoutApmMcp
|
|
43
|
+
# MCP Server for ScoutAPM integration
|
|
44
|
+
#
|
|
45
|
+
# This server provides MCP tools for interacting with ScoutAPM API
|
|
46
|
+
# Usage: bundle exec scout_apm_mcp
|
|
47
|
+
class Server
|
|
48
|
+
# Simple null logger that suppresses all output
|
|
49
|
+
# Must implement the same interface as MCP::Logger
|
|
50
|
+
class NullLogger
|
|
51
|
+
attr_accessor :transport, :client_initialized
|
|
52
|
+
|
|
53
|
+
def initialize
|
|
54
|
+
@transport = nil
|
|
55
|
+
@client_initialized = false
|
|
56
|
+
@level = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
attr_writer :level
|
|
60
|
+
|
|
61
|
+
attr_reader :level
|
|
62
|
+
|
|
63
|
+
def debug(*)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def info(*)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def warn(*)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def error(*)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fatal(*)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def unknown(*)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def client_initialized?
|
|
82
|
+
@client_initialized
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stdio_transport?
|
|
86
|
+
@transport == :stdio
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def rack_transport?
|
|
90
|
+
@transport == :rack
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.start
|
|
95
|
+
# Load 1Password credentials early if OP_ENV_ENTRY_PATH is set
|
|
96
|
+
op_env_entry_path = ENV["OP_ENV_ENTRY_PATH"]
|
|
97
|
+
if op_env_entry_path && !op_env_entry_path.empty?
|
|
98
|
+
begin
|
|
99
|
+
require "opdotenv"
|
|
100
|
+
Opdotenv::Loader.load(op_env_entry_path)
|
|
101
|
+
rescue LoadError
|
|
102
|
+
# opdotenv not available, will fall back to op CLI in get_api_key
|
|
103
|
+
rescue
|
|
104
|
+
# Silently fail - will try other methods in get_api_key
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Create server with null logger to prevent any output
|
|
109
|
+
server = FastMcp::Server.new(
|
|
110
|
+
name: "scout-apm",
|
|
111
|
+
version: ScoutApmMcp::VERSION,
|
|
112
|
+
logger: NullLogger.new
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Register all tools
|
|
116
|
+
register_tools(server)
|
|
117
|
+
|
|
118
|
+
# Start the server (blocks and speaks MCP over STDIN/STDOUT)
|
|
119
|
+
server.start
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.register_tools(server)
|
|
123
|
+
server.register_tool(ListAppsTool)
|
|
124
|
+
server.register_tool(GetAppTool)
|
|
125
|
+
server.register_tool(ListMetricsTool)
|
|
126
|
+
server.register_tool(GetMetricTool)
|
|
127
|
+
server.register_tool(ListEndpointsTool)
|
|
128
|
+
server.register_tool(FetchEndpointTool)
|
|
129
|
+
server.register_tool(GetEndpointMetricsTool)
|
|
130
|
+
server.register_tool(ListEndpointTracesTool)
|
|
131
|
+
server.register_tool(FetchTraceTool)
|
|
132
|
+
server.register_tool(ListErrorGroupsTool)
|
|
133
|
+
server.register_tool(GetErrorGroupTool)
|
|
134
|
+
server.register_tool(GetErrorGroupErrorsTool)
|
|
135
|
+
server.register_tool(GetAllInsightsTool)
|
|
136
|
+
server.register_tool(GetInsightByTypeTool)
|
|
137
|
+
server.register_tool(GetInsightsHistoryTool)
|
|
138
|
+
server.register_tool(GetInsightsHistoryByTypeTool)
|
|
139
|
+
server.register_tool(ParseScoutURLTool)
|
|
140
|
+
server.register_tool(FetchOpenAPISchemaTool)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Base tool class with common error handling
|
|
144
|
+
#
|
|
145
|
+
# Exceptions raised in tool #call methods are automatically caught by fast-mcp
|
|
146
|
+
# and converted to MCP error results with the request ID preserved.
|
|
147
|
+
# fast-mcp uses send_error_result(message, id) which sends a result with
|
|
148
|
+
# isError: true, not a JSON-RPC error response.
|
|
149
|
+
class BaseTool < FastMcp::Tool
|
|
150
|
+
protected
|
|
151
|
+
|
|
152
|
+
def get_client
|
|
153
|
+
api_key = Helpers.get_api_key
|
|
154
|
+
Client.new(api_key: api_key)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Applications Tools
|
|
159
|
+
class ListAppsTool < BaseTool
|
|
160
|
+
description "List all applications accessible with the provided API key"
|
|
161
|
+
|
|
162
|
+
arguments do
|
|
163
|
+
# No arguments required
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def call
|
|
167
|
+
get_client.list_apps
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
class GetAppTool < BaseTool
|
|
172
|
+
description "Get application details for a specific application"
|
|
173
|
+
|
|
174
|
+
arguments do
|
|
175
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def call(app_id:)
|
|
179
|
+
get_client.get_app(app_id)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Metrics Tools
|
|
184
|
+
class ListMetricsTool < BaseTool
|
|
185
|
+
description "List available metric types for an application"
|
|
186
|
+
|
|
187
|
+
arguments do
|
|
188
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def call(app_id:)
|
|
192
|
+
get_client.list_metrics(app_id)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
class GetMetricTool < BaseTool
|
|
197
|
+
description "Get time-series data for a specific metric type"
|
|
198
|
+
|
|
199
|
+
arguments do
|
|
200
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
201
|
+
required(:metric_type).filled(:string).description("Metric type: apdex, response_time, response_time_95th, errors, throughput, queue_time")
|
|
202
|
+
optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
|
|
203
|
+
optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def call(app_id:, metric_type:, from: nil, to: nil)
|
|
207
|
+
get_client.get_metric(app_id, metric_type, from: from, to: to)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Endpoints Tools
|
|
212
|
+
class ListEndpointsTool < BaseTool
|
|
213
|
+
description "List all endpoints for an application"
|
|
214
|
+
|
|
215
|
+
arguments do
|
|
216
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
217
|
+
optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
|
|
218
|
+
optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def call(app_id:, from: nil, to: nil)
|
|
222
|
+
get_client.list_endpoints(app_id, from: from, to: to)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
class FetchEndpointTool < BaseTool
|
|
227
|
+
description "Fetch endpoint details from ScoutAPM API"
|
|
228
|
+
|
|
229
|
+
arguments do
|
|
230
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
231
|
+
required(:endpoint_id).filled(:string).description("Endpoint ID (base64 URL-encoded)")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def call(app_id:, endpoint_id:)
|
|
235
|
+
client = get_client
|
|
236
|
+
{
|
|
237
|
+
endpoint: client.get_endpoint(app_id, endpoint_id),
|
|
238
|
+
decoded_endpoint: Helpers.decode_endpoint_id(endpoint_id)
|
|
239
|
+
}
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class GetEndpointMetricsTool < BaseTool
|
|
244
|
+
description "Get metric data for a specific endpoint"
|
|
245
|
+
|
|
246
|
+
arguments do
|
|
247
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
248
|
+
required(:endpoint_id).filled(:string).description("Endpoint ID (base64 URL-encoded)")
|
|
249
|
+
required(:metric_type).filled(:string).description("Metric type: apdex, response_time, response_time_95th, errors, throughput, queue_time")
|
|
250
|
+
optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
|
|
251
|
+
optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def call(app_id:, endpoint_id:, metric_type:, from: nil, to: nil)
|
|
255
|
+
get_client.get_endpoint_metrics(app_id, endpoint_id, metric_type, from: from, to: to)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
class ListEndpointTracesTool < BaseTool
|
|
260
|
+
description "List traces for a specific endpoint (max 100, within 7 days)"
|
|
261
|
+
|
|
262
|
+
arguments do
|
|
263
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
264
|
+
required(:endpoint_id).filled(:string).description("Endpoint ID (base64 URL-encoded)")
|
|
265
|
+
optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
|
|
266
|
+
optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def call(app_id:, endpoint_id:, from: nil, to: nil)
|
|
270
|
+
get_client.list_endpoint_traces(app_id, endpoint_id, from: from, to: to)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
class FetchTraceTool < BaseTool
|
|
275
|
+
description "Fetch detailed trace information from ScoutAPM API"
|
|
276
|
+
|
|
277
|
+
arguments do
|
|
278
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
279
|
+
required(:trace_id).filled(:integer).description("Trace identifier")
|
|
280
|
+
optional(:include_endpoint).filled(:bool).description("Also fetch endpoint details for context (default: false)")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def call(app_id:, trace_id:, include_endpoint: false)
|
|
284
|
+
client = get_client
|
|
285
|
+
result = {
|
|
286
|
+
trace: client.fetch_trace(app_id, trace_id)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if include_endpoint
|
|
290
|
+
trace_data = result[:trace]
|
|
291
|
+
if trace_data.is_a?(Hash) && trace_data.dig("results", "trace", "metric_name")
|
|
292
|
+
result[:trace_metric_name] = trace_data.dig("results", "trace", "metric_name")
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
result
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Errors Tools
|
|
301
|
+
class ListErrorGroupsTool < BaseTool
|
|
302
|
+
description "List error groups for an application (max 100, within 30 days)"
|
|
303
|
+
|
|
304
|
+
arguments do
|
|
305
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
306
|
+
optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
|
|
307
|
+
optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
|
|
308
|
+
optional(:endpoint).maybe(:string).description("Base64 URL-encoded endpoint filter (optional)")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def call(app_id:, from: nil, to: nil, endpoint: nil)
|
|
312
|
+
get_client.list_error_groups(app_id, from: from, to: to, endpoint: endpoint)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
class GetErrorGroupTool < BaseTool
|
|
317
|
+
description "Get details for a specific error group"
|
|
318
|
+
|
|
319
|
+
arguments do
|
|
320
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
321
|
+
required(:error_id).filled(:integer).description("Error group identifier")
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def call(app_id:, error_id:)
|
|
325
|
+
get_client.get_error_group(app_id, error_id)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
class GetErrorGroupErrorsTool < BaseTool
|
|
330
|
+
description "Get individual errors within an error group (max 100)"
|
|
331
|
+
|
|
332
|
+
arguments do
|
|
333
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
334
|
+
required(:error_id).filled(:integer).description("Error group identifier")
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def call(app_id:, error_id:)
|
|
338
|
+
get_client.get_error_group_errors(app_id, error_id)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Insights Tools
|
|
343
|
+
class GetAllInsightsTool < BaseTool
|
|
344
|
+
description "Get all insight types for an application (cached for 5 minutes)"
|
|
345
|
+
|
|
346
|
+
arguments do
|
|
347
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
348
|
+
optional(:limit).maybe(:integer).description("Maximum number of items per insight type (default: 20)")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def call(app_id:, limit: nil)
|
|
352
|
+
get_client.get_all_insights(app_id, limit: limit)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
class GetInsightByTypeTool < BaseTool
|
|
357
|
+
description "Get data for a specific insight type"
|
|
358
|
+
|
|
359
|
+
arguments do
|
|
360
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
361
|
+
required(:insight_type).filled(:string).description("Insight type: n_plus_one, memory_bloat, slow_query")
|
|
362
|
+
optional(:limit).maybe(:integer).description("Maximum number of items (default: 20)")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def call(app_id:, insight_type:, limit: nil)
|
|
366
|
+
get_client.get_insight_by_type(app_id, insight_type, limit: limit)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
class GetInsightsHistoryTool < BaseTool
|
|
371
|
+
description "Get historical insights data with cursor-based pagination"
|
|
372
|
+
|
|
373
|
+
arguments do
|
|
374
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
375
|
+
optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
|
|
376
|
+
optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
|
|
377
|
+
optional(:limit).maybe(:integer).description("Maximum number of items per page (default: 10)")
|
|
378
|
+
optional(:pagination_cursor).maybe(:integer).description("Cursor for pagination (insight ID)")
|
|
379
|
+
optional(:pagination_direction).maybe(:string).description("Pagination direction: forward, backward (default: forward)")
|
|
380
|
+
optional(:pagination_page).maybe(:integer).description("Page number for pagination (default: 1)")
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def call(app_id:, from: nil, to: nil, limit: nil, pagination_cursor: nil, pagination_direction: nil, pagination_page: nil)
|
|
384
|
+
get_client.get_insights_history(
|
|
385
|
+
app_id,
|
|
386
|
+
from: from,
|
|
387
|
+
to: to,
|
|
388
|
+
limit: limit,
|
|
389
|
+
pagination_cursor: pagination_cursor,
|
|
390
|
+
pagination_direction: pagination_direction,
|
|
391
|
+
pagination_page: pagination_page
|
|
392
|
+
)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
class GetInsightsHistoryByTypeTool < BaseTool
|
|
397
|
+
description "Get historical insights data filtered by insight type with cursor-based pagination"
|
|
398
|
+
|
|
399
|
+
arguments do
|
|
400
|
+
required(:app_id).filled(:integer).description("ScoutAPM application ID")
|
|
401
|
+
required(:insight_type).filled(:string).description("Insight type: n_plus_one, memory_bloat, slow_query")
|
|
402
|
+
optional(:from).maybe(:string).description("Start time in ISO 8601 format (e.g., 2025-11-17T15:25:35Z)")
|
|
403
|
+
optional(:to).maybe(:string).description("End time in ISO 8601 format (e.g., 2025-11-18T15:25:35Z)")
|
|
404
|
+
optional(:limit).maybe(:integer).description("Maximum number of items per page (default: 10)")
|
|
405
|
+
optional(:pagination_cursor).maybe(:integer).description("Cursor for pagination (insight ID)")
|
|
406
|
+
optional(:pagination_direction).maybe(:string).description("Pagination direction: forward, backward (default: forward)")
|
|
407
|
+
optional(:pagination_page).maybe(:integer).description("Page number for pagination (default: 1)")
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def call(app_id:, insight_type:, from: nil, to: nil, limit: nil, pagination_cursor: nil, pagination_direction: nil, pagination_page: nil)
|
|
411
|
+
get_client.get_insights_history_by_type(
|
|
412
|
+
app_id,
|
|
413
|
+
insight_type,
|
|
414
|
+
from: from,
|
|
415
|
+
to: to,
|
|
416
|
+
limit: limit,
|
|
417
|
+
pagination_cursor: pagination_cursor,
|
|
418
|
+
pagination_direction: pagination_direction,
|
|
419
|
+
pagination_page: pagination_page
|
|
420
|
+
)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Utility Tools
|
|
425
|
+
class ParseScoutURLTool < BaseTool
|
|
426
|
+
description "Parse a ScoutAPM trace URL and extract app_id, endpoint_id, and trace_id"
|
|
427
|
+
|
|
428
|
+
arguments do
|
|
429
|
+
required(:url).filled(:string).description("Full ScoutAPM trace URL (e.g., https://scoutapm.com/apps/123/endpoints/.../trace/456)")
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def call(url:)
|
|
433
|
+
Helpers.parse_scout_url(url)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
class FetchOpenAPISchemaTool < BaseTool
|
|
438
|
+
description "Fetch the ScoutAPM OpenAPI schema from the API and optionally validate it"
|
|
439
|
+
|
|
440
|
+
arguments do
|
|
441
|
+
optional(:validate).filled(:bool).description("Validate the schema structure (default: false)")
|
|
442
|
+
optional(:compare_with_local).filled(:bool).description("Compare with local schema file (tmp/scoutapm_openapi.yaml) (default: false)")
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def call(validate: false, compare_with_local: false)
|
|
446
|
+
api_key = Helpers.get_api_key
|
|
447
|
+
client = Client.new(api_key: api_key)
|
|
448
|
+
schema_data = client.fetch_openapi_schema
|
|
449
|
+
|
|
450
|
+
result = {
|
|
451
|
+
fetched: true,
|
|
452
|
+
content_type: schema_data[:content_type],
|
|
453
|
+
status: schema_data[:status],
|
|
454
|
+
content_length: schema_data[:content].length
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if validate
|
|
458
|
+
begin
|
|
459
|
+
require "yaml"
|
|
460
|
+
parsed = YAML.safe_load(schema_data[:content])
|
|
461
|
+
result[:valid_yaml] = true
|
|
462
|
+
result[:openapi_version] = parsed["openapi"] if parsed.is_a?(Hash)
|
|
463
|
+
result[:info] = parsed["info"] if parsed.is_a?(Hash) && parsed["info"]
|
|
464
|
+
rescue => e
|
|
465
|
+
result[:valid_yaml] = false
|
|
466
|
+
result[:validation_error] = e.message
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
if compare_with_local
|
|
471
|
+
local_schema_path = File.expand_path("tmp/scoutapm_openapi.yaml")
|
|
472
|
+
if File.exist?(local_schema_path)
|
|
473
|
+
local_content = File.read(local_schema_path)
|
|
474
|
+
result[:local_file_exists] = true
|
|
475
|
+
result[:local_file_length] = local_content.length
|
|
476
|
+
result[:content_matches] = (schema_data[:content] == local_content)
|
|
477
|
+
|
|
478
|
+
unless result[:content_matches]
|
|
479
|
+
begin
|
|
480
|
+
require "yaml"
|
|
481
|
+
remote_parsed = YAML.safe_load(schema_data[:content])
|
|
482
|
+
local_parsed = YAML.safe_load(local_content)
|
|
483
|
+
result[:structure_matches] = (remote_parsed == local_parsed)
|
|
484
|
+
result[:remote_paths_count] = remote_parsed.dig("paths")&.keys&.length if remote_parsed.is_a?(Hash)
|
|
485
|
+
result[:local_paths_count] = local_parsed.dig("paths")&.keys&.length if local_parsed.is_a?(Hash)
|
|
486
|
+
rescue => e
|
|
487
|
+
result[:comparison_error] = e.message
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
else
|
|
491
|
+
result[:local_file_exists] = false
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Include a preview of the content (first 500 chars) for inspection
|
|
496
|
+
result[:content_preview] = schema_data[:content][0..500] if schema_data[:content]
|
|
497
|
+
|
|
498
|
+
result
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
require_relative "scout_apm_mcp/version"
|
|
5
|
+
require_relative "scout_apm_mcp/client"
|
|
6
|
+
require_relative "scout_apm_mcp/helpers"
|
|
7
|
+
# Server is loaded on-demand when running the executable
|
|
8
|
+
# require_relative "scout_apm_mcp/server"
|
|
9
|
+
|
|
10
|
+
module ScoutApmMcp
|
|
11
|
+
# Main module for ScoutAPM MCP integration
|
|
12
|
+
#
|
|
13
|
+
# This gem provides:
|
|
14
|
+
# - ScoutApmMcp::Client - API client for ScoutAPM
|
|
15
|
+
# - ScoutApmMcp::Helpers - Helper methods for API key management and URL parsing
|
|
16
|
+
#
|
|
17
|
+
# @example Basic usage
|
|
18
|
+
# require "scout_apm_mcp"
|
|
19
|
+
#
|
|
20
|
+
# # Get API key
|
|
21
|
+
# api_key = ScoutApmMcp::Helpers.get_api_key
|
|
22
|
+
#
|
|
23
|
+
# # Create client
|
|
24
|
+
# client = ScoutApmMcp::Client.new(api_key: api_key)
|
|
25
|
+
#
|
|
26
|
+
# # List applications
|
|
27
|
+
# apps = client.list_apps
|
|
28
|
+
#
|
|
29
|
+
# # Fetch trace
|
|
30
|
+
# trace = client.fetch_trace(123, 456)
|
|
31
|
+
#
|
|
32
|
+
# @example Parse ScoutAPM URL
|
|
33
|
+
# url = "https://scoutapm.com/apps/123/endpoints/.../trace/456"
|
|
34
|
+
# parsed = ScoutApmMcp::Helpers.parse_scout_url(url)
|
|
35
|
+
# # => { app_id: 123, endpoint_id: "...", trace_id: 456, ... }
|
|
36
|
+
end
|