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.
@@ -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,3 @@
1
+ module ScoutApmMcp
2
+ VERSION = "0.1.0"
3
+ 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