rails_vitals 0.4.2 → 0.5.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 +4 -4
- data/README.md +96 -2
- data/app/controllers/rails_vitals/dashboard_controller.rb +2 -6
- data/app/controllers/rails_vitals/heatmap_controller.rb +2 -7
- data/app/controllers/rails_vitals/mcp_controller.rb +42 -0
- data/config/routes.rb +2 -0
- data/lib/rails_vitals/calculable.rb +15 -0
- data/lib/rails_vitals/configuration.rb +11 -1
- data/lib/rails_vitals/engine.rb +14 -0
- data/lib/rails_vitals/mcp/auth.rb +21 -0
- data/lib/rails_vitals/mcp/request_handler.rb +101 -0
- data/lib/rails_vitals/mcp/response_builder.rb +47 -0
- data/lib/rails_vitals/mcp/tool_registry.rb +29 -0
- data/lib/rails_vitals/mcp/tools/base.rb +31 -0
- data/lib/rails_vitals/mcp/tools/get_n1_queries.rb +93 -0
- data/lib/rails_vitals/mcp/tools/get_score.rb +143 -0
- data/lib/rails_vitals/playground/sandbox.rb +1 -1
- data/lib/rails_vitals/scorers/base_scorer.rb +18 -0
- data/lib/rails_vitals/scorers/composite_scorer.rb +2 -12
- data/lib/rails_vitals/scorers/n_plus_one_scorer.rb +5 -4
- data/lib/rails_vitals/version.rb +1 -1
- data/lib/rails_vitals.rb +1 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 899c0ed5c53864a4498cc68ea499e35aa04799e85b9b7aa0ecad37d049c9b8f1
|
|
4
|
+
data.tar.gz: f71dfb2d3e615bcaee3db50d80dd01c5169dd6fda71bb8e18f3fa99837e5b06e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a54d18fced19b25868b6db6cbc84c9808b9a308dd60ba5247bbc469bd34a937c48620784fd51d3e16000c027d21f53c0fc4691371e4d89fe91e43fa89aed683
|
|
7
|
+
data.tar.gz: cd69a0bacb284837f611c997220293c64b711573b18090167355307c39727367d4f657dcdc6a74a07e9fb0f341837199e9c7d59e368d611463dfce8c2e552bea
|
data/README.md
CHANGED
|
@@ -135,6 +135,10 @@ RailsVitals.configure do |config|
|
|
|
135
135
|
|
|
136
136
|
# Custom auth lambda (if auth: :lambda)
|
|
137
137
|
# config.auth = ->(request) { request.session[:admin] == true }
|
|
138
|
+
|
|
139
|
+
# MCP server (disabled by default, never runs in production)
|
|
140
|
+
config.mcp_enabled = false
|
|
141
|
+
config.mcp_auth_token = nil # set a token to require Authorization: Bearer <token>
|
|
138
142
|
end
|
|
139
143
|
```
|
|
140
144
|
|
|
@@ -158,6 +162,86 @@ Navigate to `/rails_vitals` to access the full admin interface.
|
|
|
158
162
|
|
|
159
163
|
---
|
|
160
164
|
|
|
165
|
+
## MCP Server — AI Tool Integration
|
|
166
|
+
|
|
167
|
+
RailsVitals includes a built-in [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes your app's performance data as tools that Claude Desktop and other MCP-compatible AI clients can call directly.
|
|
168
|
+
|
|
169
|
+
Ask Claude things like:
|
|
170
|
+
|
|
171
|
+
> "What is my Rails app's health score right now?"
|
|
172
|
+
> "Show me the top 3 N+1 patterns and how to fix them."
|
|
173
|
+
|
|
174
|
+
### Enabling the MCP server
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# config/initializers/rails_vitals.rb
|
|
178
|
+
RailsVitals.configure do |config|
|
|
179
|
+
config.mcp_enabled = true
|
|
180
|
+
config.mcp_auth_token = "your-secret-token" # optional but recommended
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The MCP server is disabled by default and raises an error if enabled in production.
|
|
185
|
+
|
|
186
|
+
### Connecting Claude Desktop
|
|
187
|
+
|
|
188
|
+
Install `mcp-remote`, which bridges Claude Desktop's stdio transport to the HTTP-based MCP server:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
npm install -g mcp-remote
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Add an entry to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"mcpServers": {
|
|
199
|
+
"rails-vitals": {
|
|
200
|
+
"command": "/absolute/path/to/npx",
|
|
201
|
+
"args": [
|
|
202
|
+
"mcp-remote",
|
|
203
|
+
"http://localhost:3000/rails_vitals/mcp",
|
|
204
|
+
"--header",
|
|
205
|
+
"Authorization: Bearer your-secret-token"
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Use an absolute path to `npx` (e.g. from `which npx`) — Claude Desktop does not inherit your shell `PATH`.
|
|
213
|
+
|
|
214
|
+
Restart Claude Desktop and look for the RailsVitals tools in the tool picker.
|
|
215
|
+
|
|
216
|
+
### Available tools
|
|
217
|
+
|
|
218
|
+
| Tool | Description |
|
|
219
|
+
|------|-------------|
|
|
220
|
+
| `railsvitals_get_score` | Overall health score, grade, score breakdown by dimension, N+1 penalties, and projected score if all N+1 patterns were fixed |
|
|
221
|
+
| `railsvitals_get_n1_queries` | All detected N+1 patterns ranked by occurrences, with affected endpoints and concrete `includes()` fix suggestions. Accepts a `limit` param (default: 10) |
|
|
222
|
+
|
|
223
|
+
### How it's structured
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
lib/rails_vitals/mcp/
|
|
227
|
+
├── auth.rb # Bearer token validation
|
|
228
|
+
├── request_handler.rb # JSON-RPC 2.0 dispatcher
|
|
229
|
+
├── response_builder.rb # MCP response formatting helpers
|
|
230
|
+
├── tool_registry.rb # Declarative class-based tool registry
|
|
231
|
+
└── tools/
|
|
232
|
+
├── base.rb # Base class: call + definition interface
|
|
233
|
+
├── get_score.rb # railsvitals_get_score
|
|
234
|
+
└── get_n1_queries.rb # railsvitals_get_n1_queries
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Each tool inherits from `Tools::Base`, implements `call(params)` returning a plain hash, and self-registers at load time via `ToolRegistry.register(ToolClass)`.
|
|
238
|
+
|
|
239
|
+
### Example
|
|
240
|
+
|
|
241
|
+

|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
161
245
|
## How Scoring Works
|
|
162
246
|
|
|
163
247
|
RailsVitals scores each request using a `CompositeScorer` with two weighted dimensions:
|
|
@@ -264,8 +348,18 @@ rails_vitals/
|
|
|
264
348
|
│ │ ├── query_scorer.rb # 40% weight
|
|
265
349
|
│ │ ├── n_plus_one_scorer.rb # 60% weight
|
|
266
350
|
│ │ └── composite_scorer.rb
|
|
267
|
-
│
|
|
268
|
-
│
|
|
351
|
+
│ ├── calculable.rb # Shared average/percentage helpers
|
|
352
|
+
│ ├── middleware/
|
|
353
|
+
│ │ └── panel_injector.rb # Rack middleware for panel injection
|
|
354
|
+
│ └── mcp/
|
|
355
|
+
│ ├── auth.rb # Bearer token validation
|
|
356
|
+
│ ├── request_handler.rb # JSON-RPC 2.0 dispatcher
|
|
357
|
+
│ ├── response_builder.rb # MCP response helpers
|
|
358
|
+
│ ├── tool_registry.rb # Declarative tool registry
|
|
359
|
+
│ └── tools/
|
|
360
|
+
│ ├── base.rb # Base class for all tools
|
|
361
|
+
│ ├── get_score.rb # railsvitals_get_score
|
|
362
|
+
│ └── get_n1_queries.rb # railsvitals_get_n1_queries
|
|
269
363
|
└── app/
|
|
270
364
|
├── controllers/rails_vitals/
|
|
271
365
|
│ ├── dashboard_controller.rb
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module RailsVitals
|
|
2
2
|
class DashboardController < ApplicationController
|
|
3
|
+
include Calculable
|
|
4
|
+
|
|
3
5
|
def index
|
|
4
6
|
@records = RailsVitals.store.all.reverse
|
|
5
7
|
@total = @records.size
|
|
@@ -14,12 +16,6 @@ module RailsVitals
|
|
|
14
16
|
|
|
15
17
|
private
|
|
16
18
|
|
|
17
|
-
def average(records, method)
|
|
18
|
-
return 0 if records.empty?
|
|
19
|
-
|
|
20
|
-
(records.sum(&method).to_f / records.size).round(1)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
19
|
def top_offenders(records)
|
|
24
20
|
records
|
|
25
21
|
.group_by(&:endpoint)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
module RailsVitals
|
|
2
2
|
class HeatmapController < ApplicationController
|
|
3
|
+
include Calculable
|
|
3
4
|
def index
|
|
4
5
|
records = RailsVitals.store.all
|
|
5
6
|
@heatmap = build_heatmap(records)
|
|
@@ -25,15 +26,9 @@ module RailsVitals
|
|
|
25
26
|
.sort_by { |row| row[:avg_score] }
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
def average(records, method)
|
|
29
|
-
return 0.0 if records.empty?
|
|
30
|
-
(records.sum { |r| r.public_send(method) }.to_f / records.size).round(1)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
29
|
def n_plus_one_frequency(reqs)
|
|
34
30
|
reqs_with_n1 = reqs.count { |r| r.n_plus_one_patterns.any? }
|
|
35
|
-
|
|
36
|
-
((reqs_with_n1.to_f / reqs.size) * 100).round(1)
|
|
31
|
+
percentage(reqs_with_n1, reqs.size)
|
|
37
32
|
end
|
|
38
33
|
end
|
|
39
34
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
class McpController < ActionController::API
|
|
3
|
+
before_action :verify_environment
|
|
4
|
+
before_action :verify_auth
|
|
5
|
+
|
|
6
|
+
def call
|
|
7
|
+
raw_body = request.body.read
|
|
8
|
+
result = handler.handle(raw_body)
|
|
9
|
+
|
|
10
|
+
render json: result
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def verify_environment
|
|
16
|
+
if Rails.env.production?
|
|
17
|
+
render json: ResponseBuilder.error(
|
|
18
|
+
nil,
|
|
19
|
+
ResponseBuilder::AUTH_ERROR,
|
|
20
|
+
"RailsVitals MCP is not available in production"
|
|
21
|
+
), status: :forbidden
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def verify_auth
|
|
26
|
+
auth = MCP::Auth.new(RailsVitals.config.mcp_auth_token)
|
|
27
|
+
|
|
28
|
+
unless auth.valid?(request)
|
|
29
|
+
render json: MCP::ResponseBuilder.error(
|
|
30
|
+
nil,
|
|
31
|
+
MCP::ResponseBuilder::AUTH_ERROR,
|
|
32
|
+
"Unauthorized",
|
|
33
|
+
{ detail: "Valid Bearer token required" }
|
|
34
|
+
), status: :unauthorized
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handler
|
|
39
|
+
@handler ||= MCP::RequestHandler.new
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -8,4 +8,6 @@ RailsVitals::Engine.routes.draw do
|
|
|
8
8
|
resources :playgrounds, only: [ :index, :create ]
|
|
9
9
|
get "heatmap", to: "heatmap#index", as: :heatmap
|
|
10
10
|
get "requests/:request_id/explain/:query_index", to: "explains#show", as: :explain
|
|
11
|
+
|
|
12
|
+
post "/mcp", to: "mcp#call" if RailsVitals.config.mcp_enabled
|
|
11
13
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module Calculable
|
|
3
|
+
def average(records, method)
|
|
4
|
+
return 0.0 if records.empty?
|
|
5
|
+
|
|
6
|
+
(records.sum(&method).to_f / records.size).round(1)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def percentage(count, total)
|
|
10
|
+
return 0.0 if total.zero?
|
|
11
|
+
|
|
12
|
+
(count.to_f / total * 100).round(1)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -9,7 +9,11 @@ module RailsVitals
|
|
|
9
9
|
:query_warn_threshold,
|
|
10
10
|
:query_critical_threshold,
|
|
11
11
|
:db_time_warn_ms,
|
|
12
|
-
:db_time_critical_ms
|
|
12
|
+
:db_time_critical_ms,
|
|
13
|
+
:mcp_enabled,
|
|
14
|
+
:mcp_auth_token,
|
|
15
|
+
:mcp_max_log_size,
|
|
16
|
+
:mcp_slow_query_threshold_ms
|
|
13
17
|
|
|
14
18
|
def initialize
|
|
15
19
|
@enabled = defined?(Rails) && !Rails.env.production?
|
|
@@ -22,6 +26,12 @@ module RailsVitals
|
|
|
22
26
|
@query_critical_threshold = 25
|
|
23
27
|
@db_time_warn_ms = 100
|
|
24
28
|
@db_time_critical_ms = 500
|
|
29
|
+
|
|
30
|
+
# MCP Server — disabled by default
|
|
31
|
+
@mcp_enabled = false
|
|
32
|
+
@mcp_auth_token = nil
|
|
33
|
+
@mcp_max_log_size = 100
|
|
34
|
+
@mcp_slow_query_threshold_ms = 100
|
|
25
35
|
end
|
|
26
36
|
end
|
|
27
37
|
end
|
data/lib/rails_vitals/engine.rb
CHANGED
|
@@ -14,6 +14,20 @@ module RailsVitals
|
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
initializer "rails_vitals.mcp" do
|
|
18
|
+
if RailsVitals.config.mcp_enabled
|
|
19
|
+
raise "RailsVitals MCP cannot run in production" if Rails.env.production?
|
|
20
|
+
|
|
21
|
+
require "rails_vitals/mcp/auth"
|
|
22
|
+
require "rails_vitals/mcp/response_builder"
|
|
23
|
+
require "rails_vitals/mcp/tool_registry"
|
|
24
|
+
require "rails_vitals/mcp/tools/base"
|
|
25
|
+
require "rails_vitals/mcp/request_handler"
|
|
26
|
+
require "rails_vitals/mcp/tools/get_score"
|
|
27
|
+
require "rails_vitals/mcp/tools/get_n1_queries"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
17
31
|
config.to_prepare do
|
|
18
32
|
if RailsVitals.config.enabled
|
|
19
33
|
ActiveRecord::Base.prepend(
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
class Auth
|
|
4
|
+
BEARER_PATTERN = /\ABearer (.+)\z/
|
|
5
|
+
|
|
6
|
+
def initialize(token)
|
|
7
|
+
@expected_token = token
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def valid?(request)
|
|
11
|
+
return false if @expected_token.blank?
|
|
12
|
+
|
|
13
|
+
auth_header = request.get_header("HTTP_AUTHORIZATION") || ""
|
|
14
|
+
match = BEARER_PATTERN.match(auth_header)
|
|
15
|
+
return false unless match
|
|
16
|
+
|
|
17
|
+
ActiveSupport::SecurityUtils.secure_compare(match[1], @expected_token)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
class RequestHandler
|
|
4
|
+
include ResponseBuilder
|
|
5
|
+
|
|
6
|
+
SERVER_INFO = {
|
|
7
|
+
name: "railsvitals",
|
|
8
|
+
version: RailsVitals::VERSION
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
PROTOCOL_VERSION = "2025-11-25"
|
|
12
|
+
JSON_RPC_VERSION = "2.0"
|
|
13
|
+
|
|
14
|
+
INSTRUCTIONS = <<~TEXT.strip
|
|
15
|
+
RailsVitals exposes Rails app performance diagnostics.
|
|
16
|
+
Recommended flow: call railsvitals_get_schema_context first to understand
|
|
17
|
+
the data model, then railsvitals_get_score for a high-level diagnosis,
|
|
18
|
+
then drill down with the specific query tools.
|
|
19
|
+
TEXT
|
|
20
|
+
|
|
21
|
+
def handle(raw_body)
|
|
22
|
+
payload = parse_json(raw_body)
|
|
23
|
+
return payload if payload.is_a?(Hash) && payload[:error]
|
|
24
|
+
|
|
25
|
+
id = payload[:id]
|
|
26
|
+
method = payload[:method]
|
|
27
|
+
|
|
28
|
+
unless method.present?
|
|
29
|
+
return ResponseBuilder.error(id, ResponseBuilder::INVALID_REQUEST, "Missing method")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
case method
|
|
33
|
+
when "initialize" then handle_initialize(id, payload[:params] || {})
|
|
34
|
+
when "tools/list" then handle_tools_list(id)
|
|
35
|
+
when "tools/call" then handle_tools_call(id, payload[:params] || {})
|
|
36
|
+
else
|
|
37
|
+
ResponseBuilder.error(id, ResponseBuilder::METHOD_NOT_FOUND, "Method not found: #{method}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parse_json(raw_body)
|
|
44
|
+
payload = JSON.parse(raw_body, symbolize_names: true)
|
|
45
|
+
|
|
46
|
+
unless payload[:jsonrpc] == JSON_RPC_VERSION
|
|
47
|
+
return ResponseBuilder.error(nil, ResponseBuilder::INVALID_REQUEST, "jsonrpc must be '#{JSON_RPC_VERSION}'")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
payload
|
|
51
|
+
rescue JSON::ParserError => e
|
|
52
|
+
ResponseBuilder.error(nil, ResponseBuilder::PARSE_ERROR, "Parse error: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_initialize(id, _params)
|
|
56
|
+
ResponseBuilder.success(id, {
|
|
57
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
58
|
+
serverInfo: SERVER_INFO,
|
|
59
|
+
capabilities: { tools: {} },
|
|
60
|
+
instructions: INSTRUCTIONS
|
|
61
|
+
})
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def handle_tools_list(id)
|
|
65
|
+
ResponseBuilder.success(id, {
|
|
66
|
+
tools: ToolRegistry.all_definitions
|
|
67
|
+
})
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handle_tools_call(id, params)
|
|
71
|
+
name = params[:name]
|
|
72
|
+
arguments = params[:arguments] || {}
|
|
73
|
+
|
|
74
|
+
unless name.present?
|
|
75
|
+
return ResponseBuilder.error(id, ResponseBuilder::INVALID_PARAMS, "Missing tool name")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
tool_class = ToolRegistry.find(name)
|
|
79
|
+
|
|
80
|
+
unless tool_class
|
|
81
|
+
return ResponseBuilder.error(
|
|
82
|
+
id,
|
|
83
|
+
ResponseBuilder::METHOD_NOT_FOUND,
|
|
84
|
+
"Tool not found",
|
|
85
|
+
{ tool: name }
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = tool_class.new.call(arguments)
|
|
90
|
+
ResponseBuilder.tool_success(id, result)
|
|
91
|
+
rescue => e
|
|
92
|
+
ResponseBuilder.error(
|
|
93
|
+
id,
|
|
94
|
+
ResponseBuilder::TOOL_EXEC_ERROR,
|
|
95
|
+
"Tool execution failed",
|
|
96
|
+
{ tool: name, detail: e.message }
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
module ResponseBuilder
|
|
4
|
+
JSON_RPC_VERSION = "2.0"
|
|
5
|
+
|
|
6
|
+
def self.success(id, result)
|
|
7
|
+
{
|
|
8
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
9
|
+
id: id,
|
|
10
|
+
result: result
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.tool_success(id, data)
|
|
15
|
+
success(id, {
|
|
16
|
+
content: [
|
|
17
|
+
{
|
|
18
|
+
type: "text",
|
|
19
|
+
text: data.to_json
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
})
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.error(id, code, message, data = nil)
|
|
26
|
+
err = { code: code, message: message }
|
|
27
|
+
err[:data] = data if data.present?
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
31
|
+
id: id,
|
|
32
|
+
error: err
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# JSON-RPC 2.0 standard error codes
|
|
37
|
+
PARSE_ERROR = -32_700
|
|
38
|
+
INVALID_REQUEST = -32_600
|
|
39
|
+
METHOD_NOT_FOUND = -32_601
|
|
40
|
+
INVALID_PARAMS = -32_602
|
|
41
|
+
|
|
42
|
+
# RailsVitals custom error codes
|
|
43
|
+
AUTH_ERROR = -32_000
|
|
44
|
+
TOOL_EXEC_ERROR = -32_001
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
class ToolRegistry
|
|
4
|
+
class << self
|
|
5
|
+
def register(tool_class)
|
|
6
|
+
registry[tool_class.tool_name] = tool_class
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def all_definitions
|
|
10
|
+
registry.values.map(&:definition)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def find(name)
|
|
14
|
+
registry[name]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def exists?(name)
|
|
18
|
+
registry.key?(name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def registry
|
|
24
|
+
@registry ||= {}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
module Tools
|
|
4
|
+
class Base
|
|
5
|
+
# Subclasses must define:
|
|
6
|
+
# TOOL_NAME = "railsvitals_example"
|
|
7
|
+
# DESCRIPTION = "What this tool does for the AI model"
|
|
8
|
+
# INPUT_SCHEMA = { type: "object", properties: {} }
|
|
9
|
+
#
|
|
10
|
+
# And implement:
|
|
11
|
+
# def call(params) → Hash
|
|
12
|
+
|
|
13
|
+
def self.tool_name
|
|
14
|
+
self::TOOL_NAME
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.definition
|
|
18
|
+
{
|
|
19
|
+
name: self::TOOL_NAME,
|
|
20
|
+
description: self::DESCRIPTION,
|
|
21
|
+
inputSchema: self::INPUT_SCHEMA
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(params)
|
|
26
|
+
raise NotImplementedError, "#{self.class}#call is not implemented"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
module Tools
|
|
4
|
+
class GetN1Queries < Base
|
|
5
|
+
TOOL_NAME = "railsvitals_get_n1_queries"
|
|
6
|
+
|
|
7
|
+
DESCRIPTION = <<~DESC.strip
|
|
8
|
+
Returns all N+1 query patterns detected across recent requests, grouped and
|
|
9
|
+
ranked by number of occurrences. Each pattern includes the normalized SQL
|
|
10
|
+
fingerprint, affected endpoints with hit counts, and a deterministic fix
|
|
11
|
+
suggestion (the correct includes() call) derived from ActiveRecord reflection.
|
|
12
|
+
Use this after railsvitals_get_score to identify which N+1 patterns to fix.
|
|
13
|
+
DESC
|
|
14
|
+
|
|
15
|
+
INPUT_SCHEMA = {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
limit: {
|
|
19
|
+
type: "integer",
|
|
20
|
+
description: "Maximum number of patterns to return, ordered by occurrences desc. Defaults to 10."
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
DEFAULT_LIMIT = 10
|
|
26
|
+
|
|
27
|
+
def call(params)
|
|
28
|
+
records = RailsVitals.store.all
|
|
29
|
+
return no_data_response if records.empty?
|
|
30
|
+
|
|
31
|
+
limit = (params[:limit] || params["limit"] || DEFAULT_LIMIT).to_i
|
|
32
|
+
patterns = Analyzers::NPlusOneAggregator.aggregate(records)
|
|
33
|
+
|
|
34
|
+
return no_n1_response if patterns.empty?
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
total_patterns: patterns.size,
|
|
38
|
+
shown: [ limit, patterns.size ].min,
|
|
39
|
+
patterns: patterns.first(limit).map { |p| serialize(p) }
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def no_data_response
|
|
46
|
+
{
|
|
47
|
+
total_patterns: 0,
|
|
48
|
+
shown: 0,
|
|
49
|
+
patterns: [],
|
|
50
|
+
message: "No requests recorded yet. Make some requests to the app first."
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def no_n1_response
|
|
55
|
+
{
|
|
56
|
+
total_patterns: 0,
|
|
57
|
+
shown: 0,
|
|
58
|
+
patterns: [],
|
|
59
|
+
message: "No N+1 patterns detected in recent requests."
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def serialize(pattern)
|
|
64
|
+
{
|
|
65
|
+
pattern: pattern[:pattern],
|
|
66
|
+
occurrences: pattern[:occurrences],
|
|
67
|
+
table: pattern[:table],
|
|
68
|
+
foreign_key: pattern[:foreign_key],
|
|
69
|
+
affected_endpoints: serialize_endpoints(pattern[:endpoints]),
|
|
70
|
+
fix: serialize_fix(pattern[:fix_suggestion])
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def serialize_endpoints(endpoints)
|
|
75
|
+
endpoints
|
|
76
|
+
.sort_by { |_, count| -count }
|
|
77
|
+
.map { |endpoint, count| { endpoint: endpoint, request_count: count } }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def serialize_fix(suggestion)
|
|
81
|
+
{
|
|
82
|
+
code: suggestion[:code],
|
|
83
|
+
description: suggestion[:description],
|
|
84
|
+
owner: suggestion[:owner],
|
|
85
|
+
association: suggestion[:association]
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
ToolRegistry.register(GetN1Queries)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
module Tools
|
|
4
|
+
class GetScore < Base
|
|
5
|
+
include Calculable
|
|
6
|
+
TOOL_NAME = "railsvitals_get_score"
|
|
7
|
+
|
|
8
|
+
DESCRIPTION = <<~DESC.strip
|
|
9
|
+
Returns the composite health score for the Rails app based on recent requests
|
|
10
|
+
recorded by RailsVitals. Includes a grade, per-component score breakdown
|
|
11
|
+
(query count and N+1 patterns), penalty details, and a projected score that
|
|
12
|
+
shows how much improvement fixing all N+1 patterns would yield.
|
|
13
|
+
Call this first to get a high-level diagnosis before drilling into specifics.
|
|
14
|
+
DESC
|
|
15
|
+
|
|
16
|
+
INPUT_SCHEMA = {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {}
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
WEIGHTS = Scorers::CompositeScorer::WEIGHTS
|
|
22
|
+
|
|
23
|
+
def call(_params)
|
|
24
|
+
records = RailsVitals.store.all
|
|
25
|
+
|
|
26
|
+
return no_data_response if records.empty?
|
|
27
|
+
|
|
28
|
+
overall_score = average(records, :score)
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
overall_score: overall_score,
|
|
32
|
+
grade: grade_for(overall_score),
|
|
33
|
+
color: color_for(overall_score),
|
|
34
|
+
requests_analyzed: records.size,
|
|
35
|
+
score_breakdown: build_score_breakdown(records),
|
|
36
|
+
penalties: build_penalties(records),
|
|
37
|
+
projected_score_if_n1_fixed: projected_score(records)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def no_data_response
|
|
44
|
+
{
|
|
45
|
+
overall_score: nil,
|
|
46
|
+
grade: "No data",
|
|
47
|
+
color: "grey",
|
|
48
|
+
requests_analyzed: 0,
|
|
49
|
+
message: "No requests recorded yet. Make some requests to the app first."
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_score_breakdown(records)
|
|
54
|
+
avg_n1 = avg_n1_score(records)
|
|
55
|
+
avg_query = avg_query_score(records)
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
query_component: {
|
|
59
|
+
weight: WEIGHTS[:query],
|
|
60
|
+
avg_score: avg_query,
|
|
61
|
+
avg_contribution: (avg_query * WEIGHTS[:query]).round(1)
|
|
62
|
+
},
|
|
63
|
+
n1_component: {
|
|
64
|
+
weight: WEIGHTS[:n_plus_one],
|
|
65
|
+
avg_score: avg_n1,
|
|
66
|
+
avg_contribution: (avg_n1 * WEIGHTS[:n_plus_one]).round(1)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_penalties(records)
|
|
72
|
+
warn_threshold = RailsVitals.config.query_warn_threshold
|
|
73
|
+
db_warn_ms = RailsVitals.config.db_time_warn_ms
|
|
74
|
+
|
|
75
|
+
n1_affected = records.select { |r| r.n_plus_one_patterns.any? }
|
|
76
|
+
total_patterns = records.sum { |r| r.n_plus_one_patterns.size }
|
|
77
|
+
unique_patterns = records.flat_map { |r| r.n_plus_one_patterns.keys }.uniq.size
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
n1_patterns: {
|
|
81
|
+
requests_affected: n1_affected.size,
|
|
82
|
+
percentage_requests: percentage(n1_affected.size, records.size),
|
|
83
|
+
unique_patterns: unique_patterns,
|
|
84
|
+
total_occurrences: total_patterns
|
|
85
|
+
},
|
|
86
|
+
high_query_count: {
|
|
87
|
+
requests_above_threshold: records.count { |r| r.total_query_count > warn_threshold },
|
|
88
|
+
threshold: warn_threshold
|
|
89
|
+
},
|
|
90
|
+
slow_db_time: {
|
|
91
|
+
requests_above_threshold: records.count { |r| r.total_db_time_ms > db_warn_ms },
|
|
92
|
+
threshold_ms: db_warn_ms
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# What the average composite score would be if every request had zero N+1 patterns.
|
|
98
|
+
# Formula: composite_now = query_contribution + n1_contribution
|
|
99
|
+
# projected = composite_now - n1_contribution + (100 * WEIGHTS[:n_plus_one])
|
|
100
|
+
def projected_score(records)
|
|
101
|
+
gain_per_record = records.map do |r|
|
|
102
|
+
n1_now = n1_score_for(r)
|
|
103
|
+
((100 - n1_now) * WEIGHTS[:n_plus_one]).round(1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
avg_gain = gain_per_record.sum.to_f / gain_per_record.size
|
|
107
|
+
(average(records, :score) + avg_gain).round.clamp(0, 100)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def n1_score_for(record)
|
|
111
|
+
Scorers::NPlusOneScorer.score_for(record.n_plus_one_patterns.size)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def avg_n1_score(records)
|
|
115
|
+
(records.sum { |r| n1_score_for(r) }.to_f / records.size).round(1)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Back-calculates query score from composite and N+1 scores.
|
|
119
|
+
# composite = (query_score * W_query).round + (n1_score * W_n1).round
|
|
120
|
+
# Approximation is sufficient for AI context.
|
|
121
|
+
def query_score_for(record)
|
|
122
|
+
n1_contribution = (n1_score_for(record) * WEIGHTS[:n_plus_one]).round
|
|
123
|
+
query_contribution = record.score - n1_contribution
|
|
124
|
+
(query_contribution.to_f / WEIGHTS[:query]).round(1).clamp(0, 100)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def avg_query_score(records)
|
|
128
|
+
(records.sum { |r| query_score_for(r) }.to_f / records.size).round(1)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def grade_for(score)
|
|
132
|
+
Scorers::BaseScorer.label_for(score)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def color_for(score)
|
|
136
|
+
Scorers::BaseScorer.color_for(score)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
ToolRegistry.register(GetScore)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -6,6 +6,24 @@ module RailsVitals
|
|
|
6
6
|
WARNING = (50..69)
|
|
7
7
|
CRITICAL = (0..49)
|
|
8
8
|
|
|
9
|
+
def self.label_for(score)
|
|
10
|
+
case score
|
|
11
|
+
when HEALTHY then "Healthy"
|
|
12
|
+
when ACCEPTABLE then "Acceptable"
|
|
13
|
+
when WARNING then "Warning"
|
|
14
|
+
else "Critical"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.color_for(score)
|
|
19
|
+
case score
|
|
20
|
+
when HEALTHY then "green"
|
|
21
|
+
when ACCEPTABLE then "blue"
|
|
22
|
+
when WARNING then "amber"
|
|
23
|
+
else "red"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
9
27
|
def initialize(collector)
|
|
10
28
|
@collector = collector
|
|
11
29
|
end
|
|
@@ -15,21 +15,11 @@ module RailsVitals
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def label
|
|
18
|
-
|
|
19
|
-
when BaseScorer::HEALTHY then "Healthy"
|
|
20
|
-
when BaseScorer::ACCEPTABLE then "Acceptable"
|
|
21
|
-
when BaseScorer::WARNING then "Warning"
|
|
22
|
-
else "Critical"
|
|
23
|
-
end
|
|
18
|
+
BaseScorer.label_for(score)
|
|
24
19
|
end
|
|
25
20
|
|
|
26
21
|
def color
|
|
27
|
-
|
|
28
|
-
when BaseScorer::HEALTHY then "green"
|
|
29
|
-
when BaseScorer::ACCEPTABLE then "blue"
|
|
30
|
-
when BaseScorer::WARNING then "amber"
|
|
31
|
-
else "red"
|
|
32
|
-
end
|
|
22
|
+
BaseScorer.color_for(score)
|
|
33
23
|
end
|
|
34
24
|
end
|
|
35
25
|
end
|
|
@@ -4,11 +4,12 @@ module RailsVitals
|
|
|
4
4
|
# Minimum times the same query must repeat to be flagged
|
|
5
5
|
REPEAT_THRESHOLD = 3
|
|
6
6
|
|
|
7
|
-
def
|
|
8
|
-
|
|
7
|
+
def self.score_for(pattern_count)
|
|
8
|
+
[ 100 - (pattern_count * 25), 0 ].max
|
|
9
|
+
end
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
def score
|
|
12
|
+
self.class.score_for(n_plus_one_count)
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def n_plus_one_patterns
|
data/lib/rails_vitals/version.rb
CHANGED
data/lib/rails_vitals.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_vitals
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Sanchez
|
|
@@ -45,6 +45,7 @@ files:
|
|
|
45
45
|
- app/controllers/rails_vitals/dashboard_controller.rb
|
|
46
46
|
- app/controllers/rails_vitals/explains_controller.rb
|
|
47
47
|
- app/controllers/rails_vitals/heatmap_controller.rb
|
|
48
|
+
- app/controllers/rails_vitals/mcp_controller.rb
|
|
48
49
|
- app/controllers/rails_vitals/models_controller.rb
|
|
49
50
|
- app/controllers/rails_vitals/n_plus_ones_controller.rb
|
|
50
51
|
- app/controllers/rails_vitals/playgrounds_controller.rb
|
|
@@ -75,10 +76,18 @@ files:
|
|
|
75
76
|
- lib/rails_vitals/analyzers/explain_analyzer.rb
|
|
76
77
|
- lib/rails_vitals/analyzers/n_plus_one_aggregator.rb
|
|
77
78
|
- lib/rails_vitals/analyzers/sql_tokenizer.rb
|
|
79
|
+
- lib/rails_vitals/calculable.rb
|
|
78
80
|
- lib/rails_vitals/collector.rb
|
|
79
81
|
- lib/rails_vitals/configuration.rb
|
|
80
82
|
- lib/rails_vitals/engine.rb
|
|
81
83
|
- lib/rails_vitals/instrumentation/callback_instrumentation.rb
|
|
84
|
+
- lib/rails_vitals/mcp/auth.rb
|
|
85
|
+
- lib/rails_vitals/mcp/request_handler.rb
|
|
86
|
+
- lib/rails_vitals/mcp/response_builder.rb
|
|
87
|
+
- lib/rails_vitals/mcp/tool_registry.rb
|
|
88
|
+
- lib/rails_vitals/mcp/tools/base.rb
|
|
89
|
+
- lib/rails_vitals/mcp/tools/get_n1_queries.rb
|
|
90
|
+
- lib/rails_vitals/mcp/tools/get_score.rb
|
|
82
91
|
- lib/rails_vitals/middleware/panel_injector.rb
|
|
83
92
|
- lib/rails_vitals/notifications/subscriber.rb
|
|
84
93
|
- lib/rails_vitals/panel_renderer.rb
|