rails_vitals 0.5.1 → 0.6.1

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: 76e3b231f12c366dba7fbd5cf3e54959922fd0b8e26d6ad865d924de95523887
4
- data.tar.gz: 56e6c2d230f2fd852f6aded8773e627bbcac09b155c34e49d6b34c6453c2116a
3
+ metadata.gz: ce21f0b9edbbef2e18452c51755bf980c067c92afb651a86ee99b78aac0cbe6d
4
+ data.tar.gz: 0ee7cddd129757762d01cdce2f049622bb6f449e44de05ff5d592bfd4933136d
5
5
  SHA512:
6
- metadata.gz: a398946361ad02e3c442c498119c952be21e4a379357f59ff674ae599961b5688cd6c0abdbdfe7b675194e94e3f21c67c2c3c276c709036975cf1ab48c52b429
7
- data.tar.gz: 60cf92ad429a1aa1d2e706cb2226b1b3637cf757bd66df3bc77ddad4f8e64a9e415d5f6709b542477f5cd0c78e58d7e49cad443729e42af325a2a8ee519b666d
6
+ metadata.gz: 201b26e862cafbde3dfe07f1011ceb3d8241f362ff5386cbae8edbe5e0a250aa99ac800fd4c3d57d9984beec6f54348c78bd7a13f5265cae6a390d137494348e
7
+ data.tar.gz: de6f0ff8c15da14e155850368884cd14830dce374505ad098f8c3a902865d16ad7d42bd17a38ba51561b3e2fb829e73b03abf2530f16873f6249b1782da4ccc5
data/README.md CHANGED
@@ -168,8 +168,29 @@ RailsVitals includes a built-in [MCP (Model Context Protocol)](https://modelcont
168
168
 
169
169
  Ask Claude things like:
170
170
 
171
+ **Health score** (`railsvitals_get_score`)
171
172
  > "What is my Rails app's health score right now?"
173
+ > "How much would my score improve if I fixed all N+1 patterns?"
174
+
175
+ **N+1 patterns** (`railsvitals_get_n1_queries`)
172
176
  > "Show me the top 3 N+1 patterns and how to fix them."
177
+ > "Which endpoint is responsible for the most N+1 occurrences?"
178
+
179
+ **Slow queries** (`railsvitals_get_slow_queries`)
180
+ > "Show me queries slower than 50ms."
181
+ > "What is the single slowest query across all recent requests and which endpoint fired it?"
182
+
183
+ **Request log** (`railsvitals_get_request_log`)
184
+ > "Which requests from FeedController had the worst score?"
185
+ > "Is PostsController#index consistently slow or only sometimes?"
186
+
187
+ **Schema context** (`railsvitals_get_schema_context`)
188
+ > "What models have foreign keys without an index?"
189
+ > "Show me the full schema for Post and Comment including their associations."
190
+
191
+ **EXPLAIN** (`railsvitals_explain_query`)
192
+ > "Run EXPLAIN on this slow query and tell me what's wrong."
193
+ > "Why is this query doing a sequential scan and how do I fix it?"
173
194
 
174
195
  ### Enabling the MCP server
175
196
 
@@ -218,7 +239,11 @@ Restart Claude Desktop and look for the RailsVitals tools in the tool picker.
218
239
  | Tool | Description |
219
240
  |------|-------------|
220
241
  | `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) |
242
+ | `railsvitals_get_n1_queries` | All detected N+1 patterns ranked by occurrences, with affected endpoints and concrete `includes()` fix suggestions. Accepts `limit` |
243
+ | `railsvitals_get_slow_queries` | Individual slow queries across all recent requests, ordered by duration. Accepts `threshold_ms` override and `limit` |
244
+ | `railsvitals_get_request_log` | Recent requests ordered most-recent-first with score, query count, DB time, and N+1 count. Accepts `controller` filter and `limit` |
245
+ | `railsvitals_get_schema_context` | Columns, indexes, associations, and missing FK indexes for ActiveRecord models. Accepts a `models` array to scope results |
246
+ | `railsvitals_explain_query` | Runs `EXPLAIN ANALYZE` on a SELECT query and returns warnings, fix suggestions with migration snippets, and a plain-English interpretation. Requires `sql` param |
222
247
 
223
248
  ### How it's structured
224
249
 
@@ -229,9 +254,13 @@ lib/rails_vitals/mcp/
229
254
  ├── response_builder.rb # MCP response formatting helpers
230
255
  ├── tool_registry.rb # Declarative class-based tool registry
231
256
  └── 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
257
+ ├── base.rb # Base class: call + definition interface
258
+ ├── get_score.rb # railsvitals_get_score
259
+ ├── get_n1_queries.rb # railsvitals_get_n1_queries
260
+ ├── get_slow_queries.rb # railsvitals_get_slow_queries
261
+ ├── get_request_log.rb # railsvitals_get_request_log
262
+ ├── get_schema_context.rb # railsvitals_get_schema_context
263
+ └── explain_query.rb # railsvitals_explain_query
235
264
  ```
236
265
 
237
266
  Each tool inherits from `Tools::Base`, implements `call(params)` returning a plain hash, and self-registers at load time via `ToolRegistry.register(ToolClass)`.
@@ -13,7 +13,7 @@ module RailsVitals
13
13
  access_associations = Array(params[:access_associations]).reject(&:blank?)
14
14
 
15
15
  result = Playground::Sandbox.run(
16
- expression,
16
+ clean_expr,
17
17
  access_associations: access_associations
18
18
  )
19
19
 
@@ -25,6 +25,10 @@ module RailsVitals
25
25
  require "rails_vitals/mcp/request_handler"
26
26
  require "rails_vitals/mcp/tools/get_score"
27
27
  require "rails_vitals/mcp/tools/get_n1_queries"
28
+ require "rails_vitals/mcp/tools/get_slow_queries"
29
+ require "rails_vitals/mcp/tools/get_request_log"
30
+ require "rails_vitals/mcp/tools/get_schema_context"
31
+ require "rails_vitals/mcp/tools/explain_query"
28
32
  end
29
33
  end
30
34
 
@@ -0,0 +1,104 @@
1
+ module RailsVitals
2
+ module MCP
3
+ module Tools
4
+ class ExplainQuery < Base
5
+ TOOL_NAME = "railsvitals_explain_query"
6
+
7
+ DESCRIPTION = <<~DESC.strip
8
+ Runs EXPLAIN ANALYZE on a SELECT query and returns the execution plan summary:
9
+ total cost, actual execution time, rows examined, detected warnings (Seq Scan,
10
+ Sort without index, large Nested Loop), and deterministic fix suggestions with
11
+ migration hints. Only SELECT statements are accepted — any SQL containing DML
12
+ keywords (INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER) is rejected, including
13
+ CTEs. Use this to investigate a specific slow query from railsvitals_get_slow_queries.
14
+
15
+ When presenting results, always lead with the interpretation field as a plain-English
16
+ verdict. Then highlight each warning by name and explain what it means for this specific
17
+ query (table name, rows scanned). For each suggestion, show the body explanation first,
18
+ then the migration snippet in a code block, then the generator command if present.
19
+ Avoid listing raw numbers without context — translate total_cost and rows_examined into
20
+ plain language (e.g. "scanned 50,000 rows to return 3" instead of "rows_examined: 50000").
21
+ DESC
22
+
23
+ INPUT_SCHEMA = {
24
+ type: "object",
25
+ required: [ "sql" ],
26
+ properties: {
27
+ sql: {
28
+ type: "string",
29
+ description: "The SELECT query to explain. Must not contain INSERT, UPDATE, DELETE, DROP, TRUNCATE, or ALTER — including inside CTEs."
30
+ }
31
+ }
32
+ }.freeze
33
+
34
+ DML_PATTERN = /\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE)\b/i.freeze
35
+
36
+ def call(params)
37
+ sql = params[:sql] || params["sql"]
38
+
39
+ return missing_sql_response if sql.nil? || sql.strip.empty?
40
+ return rejected_sql_response(sql) if dml_present?(sql)
41
+
42
+ result = Analyzers::ExplainAnalyzer.analyze(sql.strip)
43
+
44
+ return error_response(result.error) if result.error
45
+
46
+ {
47
+ sql: result.sql,
48
+ total_cost: result.total_cost,
49
+ actual_time_ms: result.actual_time_ms,
50
+ rows_examined: result.rows_examined,
51
+ warnings: serialize_warnings(result.warnings),
52
+ suggestions: serialize_suggestions(result.suggestions),
53
+ interpretation: result.interpretation
54
+ }
55
+ end
56
+
57
+ private
58
+
59
+ def dml_present?(sql)
60
+ sql.match?(DML_PATTERN)
61
+ end
62
+
63
+ def serialize_warnings(warnings)
64
+ warnings.map do |w|
65
+ entry = { type: w[:type].to_s, severity: w[:severity].to_s }
66
+ entry[:table] = w[:table] if w[:table]
67
+ entry[:rows_scanned] = w[:rows] if w[:rows]
68
+ entry
69
+ end
70
+ end
71
+
72
+ def serialize_suggestions(suggestions)
73
+ suggestions.map do |s|
74
+ entry = {
75
+ severity: s[:severity].to_s,
76
+ title: s[:title],
77
+ body: s[:body]
78
+ }
79
+ entry[:migration] = s[:migration] if s[:migration]
80
+ entry[:command] = s[:command] if s[:command]
81
+ entry
82
+ end
83
+ end
84
+
85
+ def missing_sql_response
86
+ { error: "Missing required parameter: sql. Provide a SELECT query to explain." }
87
+ end
88
+
89
+ def rejected_sql_response(sql)
90
+ keyword = sql.match(DML_PATTERN)&.captures&.first&.upcase
91
+ { error: "SQL rejected: contains #{keyword}. Only SELECT statements are allowed. " \
92
+ "DML inside CTEs (WITH ... DELETE/INSERT/UPDATE) is also rejected because " \
93
+ "EXPLAIN ANALYZE executes the query." }
94
+ end
95
+
96
+ def error_response(message)
97
+ { error: message }
98
+ end
99
+ end
100
+
101
+ ToolRegistry.register(ExplainQuery)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,96 @@
1
+ module RailsVitals
2
+ module MCP
3
+ module Tools
4
+ class GetRequestLog < Base
5
+ TOOL_NAME = "railsvitals_get_request_log"
6
+ DEFAULT_LIMIT = 20
7
+
8
+ DESCRIPTION = <<~DESC.strip
9
+ Returns recent requests recorded by RailsVitals, ordered most recent first.
10
+ Each entry includes endpoint, health score, query count, total DB time, N+1
11
+ pattern count, and request duration. Use the controller param to scope results
12
+ to a specific controller. Use this to spot whether problems are consistent or
13
+ intermittent and to identify which requests to investigate further.
14
+ DESC
15
+
16
+ INPUT_SCHEMA = {
17
+ type: "object",
18
+ properties: {
19
+ controller: {
20
+ type: "string",
21
+ description: "Filter by controller name (case-insensitive, partial match). 'Feed' matches FeedController."
22
+ },
23
+ limit: {
24
+ type: "integer",
25
+ description: "Maximum number of requests to return, most recent first. Defaults to 20."
26
+ }
27
+ }
28
+ }.freeze
29
+
30
+ def call(params)
31
+ records = RailsVitals.store.all
32
+ return no_data_response if records.empty?
33
+
34
+ controller_filter = params[:controller] || params["controller"]
35
+ limit = (params[:limit] || params["limit"] || DEFAULT_LIMIT).to_i
36
+
37
+ filtered = filter_records(records, controller_filter)
38
+ return no_match_response(controller_filter) if filtered.empty?
39
+
40
+ shown = filtered.last(limit).reverse
41
+
42
+ {
43
+ total_requests: filtered.size,
44
+ shown: shown.size,
45
+ controller_filter: controller_filter,
46
+ requests: shown.map { |r| serialize(r) }
47
+ }
48
+ end
49
+
50
+ private
51
+
52
+ def filter_records(records, controller_filter)
53
+ return records unless controller_filter
54
+
55
+ records.select { |r| r.controller.downcase.include?(controller_filter.downcase) }
56
+ end
57
+
58
+ def serialize(record)
59
+ {
60
+ request_id: record.id,
61
+ endpoint: record.endpoint,
62
+ score: record.score,
63
+ grade: record.label,
64
+ query_count: record.total_query_count,
65
+ db_time_ms: record.total_db_time_ms.round(1),
66
+ n1_patterns: record.n_plus_one_patterns.size,
67
+ duration_ms: record.duration_ms&.round(1),
68
+ recorded_at: record.recorded_at.strftime("%H:%M:%S")
69
+ }
70
+ end
71
+
72
+ def no_data_response
73
+ {
74
+ total_requests: 0,
75
+ shown: 0,
76
+ controller_filter: nil,
77
+ requests: [],
78
+ message: "No requests recorded yet. Make some requests to the app first."
79
+ }
80
+ end
81
+
82
+ def no_match_response(controller_filter)
83
+ {
84
+ total_requests: 0,
85
+ shown: 0,
86
+ controller_filter: controller_filter,
87
+ requests: [],
88
+ message: "No requests matched controller '#{controller_filter}'."
89
+ }
90
+ end
91
+ end
92
+
93
+ ToolRegistry.register(GetRequestLog)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,115 @@
1
+ module RailsVitals
2
+ module MCP
3
+ module Tools
4
+ class GetSchemaContext < Base
5
+ TOOL_NAME = "railsvitals_get_schema_context"
6
+
7
+ DESCRIPTION = <<~DESC.strip
8
+ Returns schema context for ActiveRecord models: columns with types, indexes,
9
+ associations, and foreign keys missing an index. Use the models param to scope
10
+ to specific models; omit it to get all models. Use this to understand your data
11
+ model structure and spot missing indexes that could be causing slow queries or
12
+ N+1 patterns.
13
+ DESC
14
+
15
+ INPUT_SCHEMA = {
16
+ type: "object",
17
+ properties: {
18
+ models: {
19
+ type: "array",
20
+ items: { type: "string" },
21
+ description: "Model names to include (e.g. ['Post', 'User']). Omit to return all models."
22
+ }
23
+ }
24
+ }.freeze
25
+
26
+ def call(params)
27
+ requested = Array(params[:models] || params["models"]).map(&:to_s)
28
+
29
+ all_models = Analyzers::AssociationMapper.discover_models
30
+ models = requested.empty? ? all_models : filter_models(all_models, requested)
31
+
32
+ return no_models_response(requested) if models.empty?
33
+
34
+ {
35
+ models_analyzed: models.size,
36
+ models: models.map { |m| serialize_model(m) }
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ def filter_models(all_models, requested)
43
+ all_models.select do |m|
44
+ requested.any? { |r| m.name.downcase == r.downcase }
45
+ end
46
+ end
47
+
48
+ def serialize_model(model)
49
+ table = model.table_name
50
+ columns = columns_for(table)
51
+ indexes = indexes_for(table)
52
+ assocs = serialize_associations(model, indexes)
53
+ missing = assocs.select { |a| a[:macro] == "belongs_to" && !a[:indexed] }
54
+
55
+ {
56
+ name: model.name,
57
+ table: table,
58
+ columns: columns.map { |c| serialize_column(c) },
59
+ indexes: indexes.map { |i| serialize_index(i) },
60
+ associations: assocs,
61
+ missing_indexes: missing.map { |a| { foreign_key: a[:foreign_key], association: a[:name] } }
62
+ }
63
+ end
64
+
65
+ def serialize_column(col)
66
+ { name: col.name, type: col.type.to_s, nullable: col.null }
67
+ end
68
+
69
+ def serialize_index(idx)
70
+ { name: idx.name, columns: idx.columns, unique: idx.unique }
71
+ end
72
+
73
+ def serialize_associations(model, own_indexes)
74
+ model.reflect_on_all_associations.filter_map do |assoc|
75
+ target = assoc.klass rescue next
76
+
77
+ fk = assoc.foreign_key.to_s
78
+ if assoc.macro == :belongs_to
79
+ indexed = own_indexes.any? { |i| i.columns.first == fk }
80
+ else
81
+ indexed = indexes_for(target.table_name).any? { |i| i.columns.first == fk }
82
+ end
83
+
84
+ {
85
+ macro: assoc.macro.to_s,
86
+ name: assoc.name.to_s,
87
+ foreign_key: fk,
88
+ to: target.name,
89
+ indexed: indexed
90
+ }
91
+ end
92
+ end
93
+
94
+ def columns_for(table)
95
+ ActiveRecord::Base.connection.columns(table)
96
+ rescue StandardError
97
+ []
98
+ end
99
+
100
+ def indexes_for(table)
101
+ ActiveRecord::Base.connection.indexes(table)
102
+ rescue StandardError
103
+ []
104
+ end
105
+
106
+ def no_models_response(requested)
107
+ msg = requested.empty? ? "No ActiveRecord models found." : "No models matched: #{requested.join(', ')}."
108
+ { models_analyzed: 0, models: [], message: msg }
109
+ end
110
+ end
111
+
112
+ ToolRegistry.register(GetSchemaContext)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,98 @@
1
+ module RailsVitals
2
+ module MCP
3
+ module Tools
4
+ class GetSlowQueries < Base
5
+ TOOL_NAME = "railsvitals_get_slow_queries"
6
+
7
+ DESCRIPTION = <<~DESC.strip
8
+ Returns individual slow queries detected across recent requests, ordered by
9
+ duration descending. Each result includes the SQL, execution time, and the
10
+ endpoint that fired it. Use threshold_ms to override the default slow query
11
+ threshold (configured via mcp_slow_query_threshold_ms, default 100ms).
12
+ Use this after railsvitals_get_score to find which specific queries are
13
+ dragging down database performance.
14
+ DESC
15
+
16
+ INPUT_SCHEMA = {
17
+ type: "object",
18
+ properties: {
19
+ threshold_ms: {
20
+ type: "integer",
21
+ description: "Minimum query duration in ms to include. Defaults to config.mcp_slow_query_threshold_ms (100ms)."
22
+ },
23
+ limit: {
24
+ type: "integer",
25
+ description: "Maximum number of queries to return, ordered by duration desc. Defaults to 10."
26
+ }
27
+ }
28
+ }.freeze
29
+
30
+ DEFAULT_LIMIT = 10
31
+
32
+ def call(params)
33
+ records = RailsVitals.store.all
34
+ return no_data_response if records.empty?
35
+
36
+ threshold = (params[:threshold_ms] || params["threshold_ms"] || RailsVitals.config.mcp_slow_query_threshold_ms).to_i
37
+ limit = (params[:limit] || params["limit"] || DEFAULT_LIMIT).to_i
38
+
39
+ slow = collect_slow_queries(records, threshold)
40
+ return no_slow_queries_response(threshold) if slow.empty?
41
+
42
+ {
43
+ total_slow_queries: slow.size,
44
+ shown: [ limit, slow.size ].min,
45
+ threshold_ms: threshold,
46
+ queries: slow.first(limit).map { |q| serialize(q) }
47
+ }
48
+ end
49
+
50
+ private
51
+
52
+ def collect_slow_queries(records, threshold)
53
+ queries = []
54
+
55
+ records.each do |record|
56
+ record.queries.each do |q|
57
+ next if q[:duration_ms] < threshold
58
+
59
+ queries << q.merge(endpoint: record.endpoint, request_id: record.id)
60
+ end
61
+ end
62
+
63
+ queries.sort_by { |q| -q[:duration_ms] }
64
+ end
65
+
66
+ def serialize(query)
67
+ {
68
+ sql: query[:sql],
69
+ duration_ms: query[:duration_ms].round(1),
70
+ endpoint: query[:endpoint],
71
+ request_id: query[:request_id]
72
+ }
73
+ end
74
+
75
+ def no_data_response
76
+ {
77
+ total_slow_queries: 0,
78
+ shown: 0,
79
+ queries: [],
80
+ message: "No requests recorded yet. Make some requests to the app first."
81
+ }
82
+ end
83
+
84
+ def no_slow_queries_response(threshold)
85
+ {
86
+ total_slow_queries: 0,
87
+ shown: 0,
88
+ threshold_ms: threshold,
89
+ queries: [],
90
+ message: "No queries exceeded #{threshold}ms in recent requests."
91
+ }
92
+ end
93
+ end
94
+
95
+ ToolRegistry.register(GetSlowQueries)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,292 @@
1
+ require "strscan"
2
+
3
+ module RailsVitals
4
+ module Playground
5
+ class SafeChainBuilder
6
+ ALLOWED_METHODS = %w[
7
+ all where select limit offset order group
8
+ includes preload eager_load joins left_joins
9
+ find find_by first last count sum average
10
+ pluck distinct having references unscoped not
11
+ ].freeze
12
+
13
+ DISALLOWED_CLASS_METHODS = %w[
14
+ connection execute exec system eval send public_send __send__
15
+ instance_eval class_eval module_eval define_method method_missing
16
+ delete destroy delete_all destroy_all update_all
17
+ ].freeze
18
+
19
+ ParseError = Class.new(StandardError)
20
+
21
+ def self.build(chain_str, model)
22
+ relation = model.all
23
+ parse_chain(chain_str).each do |method_name, args|
24
+ if DISALLOWED_CLASS_METHODS.include?(method_name)
25
+ raise ParseError, "Method '#{method_name}' is not allowed for security reasons"
26
+ end
27
+ unless ALLOWED_METHODS.include?(method_name)
28
+ raise ParseError, "Method '#{method_name}' is not allowed. Allowed: #{ALLOWED_METHODS.join(', ')}"
29
+ end
30
+ relation = relation.public_send(method_name, *args)
31
+ end
32
+
33
+ raise ParseError, "Expression must return an ActiveRecord::Relation" unless relation.is_a?(ActiveRecord::Relation)
34
+ relation
35
+ end
36
+
37
+ private
38
+
39
+ def self.parse_chain(str)
40
+ scanner = StringScanner.new(str)
41
+ calls = []
42
+ scanner.skip(/\s+/)
43
+
44
+ until scanner.eos?
45
+ scanner.skip(/\.\s*/)
46
+
47
+ name = scanner.scan(/[a-z_][a-zA-Z0-9_!?]*/)
48
+ raise ParseError, "Expected method name at position #{scanner.pos}" unless name
49
+
50
+ scanner.skip(/\s+/)
51
+ if scanner.scan(/\(/)
52
+ args = parse_args(scanner)
53
+ scanner.skip(/\s*\)/)
54
+ calls << [ name, args ]
55
+ else
56
+ calls << [ name, [] ]
57
+ end
58
+ scanner.skip(/\s+/)
59
+ end
60
+
61
+ calls
62
+ end
63
+
64
+ def self.parse_args(scanner)
65
+ args = []
66
+ scanner.skip(/\s+/)
67
+ return args if scanner.eos? || scanner.peek(1) == ")"
68
+
69
+ loop do
70
+ scanner.skip(/\s+/)
71
+ break if scanner.eos? || scanner.peek(1) == ")"
72
+
73
+ if keyword_hash_start?(scanner)
74
+ args << scan_keyword_hash(scanner)
75
+ else
76
+ args << scan_value(scanner)
77
+ end
78
+
79
+ scanner.skip(/\s+/)
80
+ break unless scanner.scan(/,/)
81
+ end
82
+
83
+ args
84
+ end
85
+
86
+ def self.keyword_hash_start?(scanner)
87
+ pos = scanner.pos
88
+ ident = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
89
+ return false unless ident
90
+
91
+ scanner.skip(/\s*/)
92
+
93
+ if scanner.scan(/:/) && !scanner.scan(/:/)
94
+ scanner.pos = pos
95
+ return true
96
+ end
97
+
98
+ scanner.pos = pos
99
+ false
100
+ end
101
+
102
+ def self.scan_keyword_hash(scanner)
103
+ hash = {}
104
+ loop do
105
+ scanner.skip(/\s+/)
106
+ break if scanner.eos? || scanner.peek(1) == ")" || scanner.peek(1) == "}"
107
+
108
+ key = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
109
+ raise ParseError, "Expected hash key" unless key
110
+ scanner.skip(/\s*:\s*/)
111
+ value = scan_value(scanner)
112
+ hash[key.to_sym] = value
113
+ scanner.skip(/\s+/)
114
+ break unless scanner.scan(/,/)
115
+ end
116
+ hash
117
+ end
118
+
119
+ def self.scan_value(scanner)
120
+ scanner.skip(/\s+/)
121
+ ch = scanner.peek(1)
122
+ raise ParseError, "Unexpected end of expression" unless ch
123
+
124
+ case ch
125
+ when "'" then scan_single_quoted_string(scanner)
126
+ when '"' then scan_double_quoted_string(scanner)
127
+ when ":" then scan_symbol(scanner)
128
+ when "t"
129
+ if scanner.scan(/true\b/)
130
+ true
131
+ else
132
+ raise ParseError, "Unexpected token at position #{scanner.pos}"
133
+ end
134
+ when "f"
135
+ if scanner.scan(/false\b/)
136
+ false
137
+ else
138
+ raise ParseError, "Unexpected token at position #{scanner.pos}"
139
+ end
140
+ when "n"
141
+ if scanner.scan(/nil\b/)
142
+ nil
143
+ else
144
+ raise ParseError, "Unexpected token at position #{scanner.pos}"
145
+ end
146
+ when "[" then scan_array(scanner)
147
+ when "{" then scan_hash_literal(scanner)
148
+ when "-", "+", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" then scan_number(scanner)
149
+ else
150
+ raise ParseError, "Unexpected token '#{ch}' at position #{scanner.pos}"
151
+ end
152
+ end
153
+
154
+ def self.scan_single_quoted_string(scanner)
155
+ scanner.pos += 1
156
+ result = +""
157
+ until scanner.eos?
158
+ case scanner.peek(1)
159
+ when "'"
160
+ scanner.pos += 1
161
+ return result
162
+ when "\\"
163
+ scanner.pos += 1
164
+ escaped = scanner.getch
165
+ case escaped
166
+ when "'" then result << "'"
167
+ when "\\" then result << "\\"
168
+ else result << "\\#{escaped}"
169
+ end
170
+ else
171
+ result << scanner.getch
172
+ end
173
+ end
174
+ raise ParseError, "Unterminated single-quoted string"
175
+ end
176
+
177
+ def self.scan_double_quoted_string(scanner)
178
+ scanner.pos += 1
179
+ result = +""
180
+ until scanner.eos?
181
+ case scanner.peek(1)
182
+ when '"'
183
+ scanner.pos += 1
184
+ return result
185
+ when "\\"
186
+ scanner.pos += 1
187
+ escaped = scanner.getch
188
+ case escaped
189
+ when '"' then result << '"'
190
+ when "\\" then result << "\\"
191
+ when "n" then result << "\n"
192
+ when "t" then result << "\t"
193
+ when "r" then result << "\r"
194
+ when "#" then result << "#"
195
+ else result << "\\#{escaped}"
196
+ end
197
+ else
198
+ result << scanner.getch
199
+ end
200
+ end
201
+ raise ParseError, "Unterminated double-quoted string"
202
+ end
203
+
204
+ def self.scan_symbol(scanner)
205
+ scanner.pos += 1
206
+ if scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
207
+ scanner.matched.to_sym
208
+ elsif (str = scanner.scan(/"[^"]*"/) || scanner.scan(/'[^']*'/))
209
+ str[1..-2].to_sym
210
+ else
211
+ raise ParseError, "Invalid symbol at position #{scanner.pos}"
212
+ end
213
+ end
214
+
215
+ def self.scan_number(scanner)
216
+ num_str = scanner.scan(/-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/)
217
+ raise ParseError, "Invalid number at position #{scanner.pos}" unless num_str
218
+ if num_str.include?(".") || num_str.match?(/[eE]/)
219
+ num_str.to_f
220
+ else
221
+ num_str.to_i
222
+ end
223
+ end
224
+
225
+ def self.scan_array(scanner)
226
+ scanner.pos += 1
227
+ arr = []
228
+ scanner.skip(/\s+/)
229
+ unless scanner.peek(1) == "]"
230
+ loop do
231
+ arr << scan_value(scanner)
232
+ scanner.skip(/\s+/)
233
+ break unless scanner.scan(/,/)
234
+ scanner.skip(/\s+/)
235
+ end
236
+ end
237
+ scanner.skip(/\s*\]/)
238
+ raise ParseError, "Unterminated array" unless scanner.matched
239
+ arr
240
+ end
241
+
242
+ def self.scan_hash_literal(scanner)
243
+ scanner.pos += 1
244
+ hash = {}
245
+ scanner.skip(/\s+/)
246
+ unless scanner.eos? || scanner.peek(1) == "}"
247
+ loop do
248
+ scanner.skip(/\s+/)
249
+ break if scanner.eos? || scanner.peek(1) == "}"
250
+
251
+ key = parse_hash_key(scanner)
252
+ scanner.skip(/\s+/)
253
+
254
+ if scanner.scan(/=>/)
255
+ scanner.skip(/\s+/)
256
+ hash[key] = scan_value(scanner)
257
+ elsif scanner.scan(/:\s*/)
258
+ hash[key.to_sym] = scan_value(scanner)
259
+ else
260
+ raise ParseError, "Expected '=>' or ':' after hash key"
261
+ end
262
+
263
+ scanner.skip(/\s+/)
264
+ break unless scanner.scan(/,/)
265
+ end
266
+ end
267
+ scanner.skip(/\s*}/)
268
+ raise ParseError, "Unterminated hash" unless scanner.matched
269
+ hash
270
+ end
271
+
272
+ def self.parse_hash_key(scanner)
273
+ case scanner.peek(1)
274
+ when '"', "'" then scan_string(scanner)
275
+ when ":" then scan_symbol(scanner)
276
+ else
277
+ ident = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
278
+ raise ParseError, "Expected hash key" unless ident
279
+ ident
280
+ end
281
+ end
282
+
283
+ def self.scan_string(scanner)
284
+ case scanner.peek(1)
285
+ when "'" then scan_single_quoted_string(scanner)
286
+ when '"' then scan_double_quoted_string(scanner)
287
+ else raise ParseError, "Expected string"
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
@@ -1,19 +1,16 @@
1
1
  module RailsVitals
2
2
  module Playground
3
3
  class Sandbox
4
- ALLOWED_METHODS = %w[
5
- all where select limit offset order group
6
- includes preload eager_load joins left_joins
7
- find find_by first last count sum average
8
- pluck distinct having references unscoped
9
- ].freeze
10
-
11
4
  BLOCKED_PATTERNS = [
12
5
  /\b(insert|update|delete|destroy|drop|truncate|create|alter)\b/i,
13
6
  /\.save/i, /\.save!/i, /\.update/i, /\.delete/i,
14
7
  /\.destroy/i, /`/
15
8
  ].freeze
16
9
 
10
+ SAFE_EXPRESSION_PATTERN = /\A[a-zA-Z0-9_\.\s\(\),:\[\]{}'"!?=<>|&*+\-\/\\%]+\z/
11
+
12
+ ASSOCIATION_NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
13
+
17
14
  DEFAULT_LIMIT = 100
18
15
 
19
16
  Result = Struct.new(
@@ -26,6 +23,13 @@ module RailsVitals
26
23
  def self.run(expression, access_associations: [])
27
24
  return blocked_result("No expression provided") if expression.blank?
28
25
 
26
+ expression = expression.gsub(/#[^\n]*/, "").strip
27
+ return blocked_result("No expression provided") if expression.blank?
28
+
29
+ return blocked_result(
30
+ "Expression contains invalid characters."
31
+ ) unless expression.match?(SAFE_EXPRESSION_PATTERN)
32
+
29
33
  BLOCKED_PATTERNS.each do |pattern|
30
34
  return blocked_result(
31
35
  "Expression contains blocked operation. " \
@@ -33,6 +37,10 @@ module RailsVitals
33
37
  ) if expression.match?(pattern)
34
38
  end
35
39
 
40
+ access_associations = access_associations.select do |name|
41
+ name.to_s.match?(ASSOCIATION_NAME_PATTERN)
42
+ end
43
+
36
44
  model_name = extract_model_name(expression)
37
45
  return blocked_result(
38
46
  "Could not detect model from expression. " \
@@ -111,10 +119,7 @@ module RailsVitals
111
119
  end
112
120
 
113
121
  def self.extract_model_name(expression)
114
- # Strip comments first
115
- clean = expression.gsub(/#[^\n]*/, "").strip
116
- # First word before a dot or whitespace — must look like a constant (CamelCase)
117
- match = clean.match(/\A([A-Z][A-Za-z0-9]*)/)
122
+ match = expression.match(/\A([A-Z][A-Za-z0-9]*)/)
118
123
  match ? match[1] : nil
119
124
  end
120
125
 
@@ -132,31 +137,15 @@ module RailsVitals
132
137
  end
133
138
 
134
139
  def self.build_relation(expression, model)
135
- # Parse "Post.includes(:likes).where(published: true).limit(10)"
136
- # Strip the model name prefix if present
137
140
  chain_str = expression
138
141
  .sub(/\A#{Regexp.escape(model.name)}\s*\.?\s*/, "")
139
142
  .strip
140
143
 
141
144
  return model.all if chain_str.blank?
142
145
 
143
- # Build the chain by safe eval within a controlled binding
144
- # Only the model constant is exposed, no access to app globals
145
- sandbox_binding = build_binding(model)
146
- relation = eval(chain_str, sandbox_binding) # rubocop:disable Security/Eval
147
-
148
- unless relation.is_a?(ActiveRecord::Relation)
149
- raise "Expression must return an ActiveRecord::Relation"
150
- end
151
-
152
- relation
153
- end
154
-
155
- def self.build_binding(model)
156
- # Create a minimal binding with only the model exposed
157
- ctx = Object.new
158
- ctx.define_singleton_method(:relation) { model.all }
159
- ctx.instance_eval { binding }
146
+ SafeChainBuilder.build(chain_str, model)
147
+ rescue SafeChainBuilder::ParseError => e
148
+ raise "Expression error: #{e.message}"
160
149
  end
161
150
 
162
151
  def self.apply_limit(relation)
@@ -1,3 +1,3 @@
1
1
  module RailsVitals
2
- VERSION = "0.5.1"
2
+ VERSION = "0.6.1"
3
3
  end
data/lib/rails_vitals.rb CHANGED
@@ -14,6 +14,7 @@ require "rails_vitals/scorers/base_scorer"
14
14
  require "rails_vitals/scorers/query_scorer"
15
15
  require "rails_vitals/scorers/n_plus_one_scorer"
16
16
  require "rails_vitals/scorers/composite_scorer"
17
+ require "rails_vitals/playground/safe_chain_builder"
17
18
  require "rails_vitals/playground/sandbox"
18
19
  require "rails_vitals/panel_renderer"
19
20
  require "rails_vitals/middleware/panel_injector"
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.5.1
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Sanchez
@@ -86,11 +86,16 @@ files:
86
86
  - lib/rails_vitals/mcp/response_builder.rb
87
87
  - lib/rails_vitals/mcp/tool_registry.rb
88
88
  - lib/rails_vitals/mcp/tools/base.rb
89
+ - lib/rails_vitals/mcp/tools/explain_query.rb
89
90
  - lib/rails_vitals/mcp/tools/get_n1_queries.rb
91
+ - lib/rails_vitals/mcp/tools/get_request_log.rb
92
+ - lib/rails_vitals/mcp/tools/get_schema_context.rb
90
93
  - lib/rails_vitals/mcp/tools/get_score.rb
94
+ - lib/rails_vitals/mcp/tools/get_slow_queries.rb
91
95
  - lib/rails_vitals/middleware/panel_injector.rb
92
96
  - lib/rails_vitals/notifications/subscriber.rb
93
97
  - lib/rails_vitals/panel_renderer.rb
98
+ - lib/rails_vitals/playground/safe_chain_builder.rb
94
99
  - lib/rails_vitals/playground/sandbox.rb
95
100
  - lib/rails_vitals/request_record.rb
96
101
  - lib/rails_vitals/scorers/base_scorer.rb