woods 1.0.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.
Files changed (185) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +89 -0
  3. data/CODE_OF_CONDUCT.md +83 -0
  4. data/CONTRIBUTING.md +65 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +406 -0
  7. data/exe/woods-console +59 -0
  8. data/exe/woods-console-mcp +22 -0
  9. data/exe/woods-mcp +34 -0
  10. data/exe/woods-mcp-http +37 -0
  11. data/exe/woods-mcp-start +58 -0
  12. data/lib/generators/woods/install_generator.rb +32 -0
  13. data/lib/generators/woods/pgvector_generator.rb +37 -0
  14. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
  15. data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
  16. data/lib/tasks/woods.rake +621 -0
  17. data/lib/tasks/woods_evaluation.rake +115 -0
  18. data/lib/woods/ast/call_site_extractor.rb +106 -0
  19. data/lib/woods/ast/method_extractor.rb +71 -0
  20. data/lib/woods/ast/node.rb +116 -0
  21. data/lib/woods/ast/parser.rb +614 -0
  22. data/lib/woods/ast.rb +6 -0
  23. data/lib/woods/builder.rb +200 -0
  24. data/lib/woods/cache/cache_middleware.rb +199 -0
  25. data/lib/woods/cache/cache_store.rb +264 -0
  26. data/lib/woods/cache/redis_cache_store.rb +116 -0
  27. data/lib/woods/cache/solid_cache_store.rb +111 -0
  28. data/lib/woods/chunking/chunk.rb +84 -0
  29. data/lib/woods/chunking/semantic_chunker.rb +295 -0
  30. data/lib/woods/console/adapters/cache_adapter.rb +58 -0
  31. data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
  32. data/lib/woods/console/adapters/job_adapter.rb +68 -0
  33. data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
  34. data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
  35. data/lib/woods/console/audit_logger.rb +75 -0
  36. data/lib/woods/console/bridge.rb +177 -0
  37. data/lib/woods/console/confirmation.rb +90 -0
  38. data/lib/woods/console/connection_manager.rb +173 -0
  39. data/lib/woods/console/console_response_renderer.rb +74 -0
  40. data/lib/woods/console/embedded_executor.rb +373 -0
  41. data/lib/woods/console/model_validator.rb +81 -0
  42. data/lib/woods/console/rack_middleware.rb +87 -0
  43. data/lib/woods/console/safe_context.rb +82 -0
  44. data/lib/woods/console/server.rb +612 -0
  45. data/lib/woods/console/sql_validator.rb +172 -0
  46. data/lib/woods/console/tools/tier1.rb +118 -0
  47. data/lib/woods/console/tools/tier2.rb +117 -0
  48. data/lib/woods/console/tools/tier3.rb +110 -0
  49. data/lib/woods/console/tools/tier4.rb +79 -0
  50. data/lib/woods/coordination/pipeline_lock.rb +109 -0
  51. data/lib/woods/cost_model/embedding_cost.rb +88 -0
  52. data/lib/woods/cost_model/estimator.rb +128 -0
  53. data/lib/woods/cost_model/provider_pricing.rb +67 -0
  54. data/lib/woods/cost_model/storage_cost.rb +52 -0
  55. data/lib/woods/cost_model.rb +22 -0
  56. data/lib/woods/db/migrations/001_create_units.rb +38 -0
  57. data/lib/woods/db/migrations/002_create_edges.rb +35 -0
  58. data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
  59. data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
  60. data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
  61. data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
  62. data/lib/woods/db/migrator.rb +73 -0
  63. data/lib/woods/db/schema_version.rb +73 -0
  64. data/lib/woods/dependency_graph.rb +236 -0
  65. data/lib/woods/embedding/indexer.rb +140 -0
  66. data/lib/woods/embedding/openai.rb +126 -0
  67. data/lib/woods/embedding/provider.rb +162 -0
  68. data/lib/woods/embedding/text_preparer.rb +112 -0
  69. data/lib/woods/evaluation/baseline_runner.rb +115 -0
  70. data/lib/woods/evaluation/evaluator.rb +139 -0
  71. data/lib/woods/evaluation/metrics.rb +79 -0
  72. data/lib/woods/evaluation/query_set.rb +148 -0
  73. data/lib/woods/evaluation/report_generator.rb +90 -0
  74. data/lib/woods/extracted_unit.rb +145 -0
  75. data/lib/woods/extractor.rb +1028 -0
  76. data/lib/woods/extractors/action_cable_extractor.rb +201 -0
  77. data/lib/woods/extractors/ast_source_extraction.rb +46 -0
  78. data/lib/woods/extractors/behavioral_profile.rb +309 -0
  79. data/lib/woods/extractors/caching_extractor.rb +261 -0
  80. data/lib/woods/extractors/callback_analyzer.rb +246 -0
  81. data/lib/woods/extractors/concern_extractor.rb +292 -0
  82. data/lib/woods/extractors/configuration_extractor.rb +219 -0
  83. data/lib/woods/extractors/controller_extractor.rb +404 -0
  84. data/lib/woods/extractors/database_view_extractor.rb +278 -0
  85. data/lib/woods/extractors/decorator_extractor.rb +253 -0
  86. data/lib/woods/extractors/engine_extractor.rb +223 -0
  87. data/lib/woods/extractors/event_extractor.rb +211 -0
  88. data/lib/woods/extractors/factory_extractor.rb +289 -0
  89. data/lib/woods/extractors/graphql_extractor.rb +892 -0
  90. data/lib/woods/extractors/i18n_extractor.rb +117 -0
  91. data/lib/woods/extractors/job_extractor.rb +374 -0
  92. data/lib/woods/extractors/lib_extractor.rb +218 -0
  93. data/lib/woods/extractors/mailer_extractor.rb +269 -0
  94. data/lib/woods/extractors/manager_extractor.rb +188 -0
  95. data/lib/woods/extractors/middleware_extractor.rb +133 -0
  96. data/lib/woods/extractors/migration_extractor.rb +469 -0
  97. data/lib/woods/extractors/model_extractor.rb +988 -0
  98. data/lib/woods/extractors/phlex_extractor.rb +252 -0
  99. data/lib/woods/extractors/policy_extractor.rb +191 -0
  100. data/lib/woods/extractors/poro_extractor.rb +229 -0
  101. data/lib/woods/extractors/pundit_extractor.rb +223 -0
  102. data/lib/woods/extractors/rails_source_extractor.rb +473 -0
  103. data/lib/woods/extractors/rake_task_extractor.rb +343 -0
  104. data/lib/woods/extractors/route_extractor.rb +181 -0
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
  106. data/lib/woods/extractors/serializer_extractor.rb +339 -0
  107. data/lib/woods/extractors/service_extractor.rb +217 -0
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
  109. data/lib/woods/extractors/shared_utility_methods.rb +281 -0
  110. data/lib/woods/extractors/state_machine_extractor.rb +398 -0
  111. data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
  112. data/lib/woods/extractors/validator_extractor.rb +211 -0
  113. data/lib/woods/extractors/view_component_extractor.rb +311 -0
  114. data/lib/woods/extractors/view_template_extractor.rb +261 -0
  115. data/lib/woods/feedback/gap_detector.rb +89 -0
  116. data/lib/woods/feedback/store.rb +119 -0
  117. data/lib/woods/filename_utils.rb +32 -0
  118. data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
  119. data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
  120. data/lib/woods/flow_assembler.rb +290 -0
  121. data/lib/woods/flow_document.rb +191 -0
  122. data/lib/woods/flow_precomputer.rb +102 -0
  123. data/lib/woods/formatting/base.rb +30 -0
  124. data/lib/woods/formatting/claude_adapter.rb +98 -0
  125. data/lib/woods/formatting/generic_adapter.rb +56 -0
  126. data/lib/woods/formatting/gpt_adapter.rb +64 -0
  127. data/lib/woods/formatting/human_adapter.rb +78 -0
  128. data/lib/woods/graph_analyzer.rb +374 -0
  129. data/lib/woods/mcp/bootstrapper.rb +96 -0
  130. data/lib/woods/mcp/index_reader.rb +394 -0
  131. data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
  132. data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
  133. data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
  134. data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
  135. data/lib/woods/mcp/server.rb +962 -0
  136. data/lib/woods/mcp/tool_response_renderer.rb +85 -0
  137. data/lib/woods/model_name_cache.rb +51 -0
  138. data/lib/woods/notion/client.rb +217 -0
  139. data/lib/woods/notion/exporter.rb +219 -0
  140. data/lib/woods/notion/mapper.rb +40 -0
  141. data/lib/woods/notion/mappers/column_mapper.rb +57 -0
  142. data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
  143. data/lib/woods/notion/mappers/model_mapper.rb +161 -0
  144. data/lib/woods/notion/mappers/shared.rb +22 -0
  145. data/lib/woods/notion/rate_limiter.rb +68 -0
  146. data/lib/woods/observability/health_check.rb +79 -0
  147. data/lib/woods/observability/instrumentation.rb +34 -0
  148. data/lib/woods/observability/structured_logger.rb +57 -0
  149. data/lib/woods/operator/error_escalator.rb +81 -0
  150. data/lib/woods/operator/pipeline_guard.rb +92 -0
  151. data/lib/woods/operator/status_reporter.rb +80 -0
  152. data/lib/woods/railtie.rb +38 -0
  153. data/lib/woods/resilience/circuit_breaker.rb +99 -0
  154. data/lib/woods/resilience/index_validator.rb +167 -0
  155. data/lib/woods/resilience/retryable_provider.rb +108 -0
  156. data/lib/woods/retrieval/context_assembler.rb +261 -0
  157. data/lib/woods/retrieval/query_classifier.rb +133 -0
  158. data/lib/woods/retrieval/ranker.rb +277 -0
  159. data/lib/woods/retrieval/search_executor.rb +316 -0
  160. data/lib/woods/retriever.rb +152 -0
  161. data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
  162. data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
  163. data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
  164. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
  165. data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
  166. data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
  167. data/lib/woods/ruby_analyzer.rb +87 -0
  168. data/lib/woods/session_tracer/file_store.rb +104 -0
  169. data/lib/woods/session_tracer/middleware.rb +143 -0
  170. data/lib/woods/session_tracer/redis_store.rb +106 -0
  171. data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
  172. data/lib/woods/session_tracer/session_flow_document.rb +223 -0
  173. data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
  174. data/lib/woods/session_tracer/store.rb +81 -0
  175. data/lib/woods/storage/graph_store.rb +120 -0
  176. data/lib/woods/storage/metadata_store.rb +196 -0
  177. data/lib/woods/storage/pgvector.rb +195 -0
  178. data/lib/woods/storage/qdrant.rb +205 -0
  179. data/lib/woods/storage/vector_store.rb +167 -0
  180. data/lib/woods/temporal/json_snapshot_store.rb +245 -0
  181. data/lib/woods/temporal/snapshot_store.rb +345 -0
  182. data/lib/woods/token_utils.rb +19 -0
  183. data/lib/woods/version.rb +5 -0
  184. data/lib/woods.rb +246 -0
  185. metadata +270 -0
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../mcp/tool_response_renderer'
4
+ require_relative '../mcp/renderers/json_renderer'
5
+
6
+ module Woods
7
+ module Console
8
+ # Renders Console MCP tool responses with smart auto-detection of data shape.
9
+ #
10
+ # Auto-detects:
11
+ # - Array<Hash> → Markdown tables
12
+ # - Single Hash → Key-value bullet lists
13
+ # - Simple Array → Bullet list
14
+ # - Scalars → Plain text
15
+ #
16
+ class ConsoleResponseRenderer < MCP::ToolResponseRenderer
17
+ # Smart default: auto-detect data shape and render accordingly.
18
+ #
19
+ # @param data [Object] The bridge response result
20
+ # @return [String] Rendered text
21
+ def render_default(data)
22
+ case data
23
+ when Array
24
+ render_array(data)
25
+ when Hash
26
+ render_hash(data)
27
+ else
28
+ data.to_s
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def render_array(data)
35
+ return '_(empty)_' if data.empty?
36
+
37
+ if data.first.is_a?(Hash)
38
+ render_table(data)
39
+ else
40
+ data.map { |item| "- #{item}" }.join("\n")
41
+ end
42
+ end
43
+
44
+ def render_table(rows)
45
+ keys = rows.first.keys
46
+ lines = []
47
+ lines << "| #{keys.join(' | ')} |"
48
+ lines << "| #{keys.map { '---' }.join(' | ')} |"
49
+ rows.each do |row|
50
+ lines << "| #{keys.map { |k| row[k] }.join(' | ')} |"
51
+ end
52
+ lines.join("\n")
53
+ end
54
+
55
+ def render_hash(data)
56
+ data.map do |key, value|
57
+ case value
58
+ when Hash
59
+ "**#{key}:**\n" + value.map { |k, v| " - #{k}: #{v}" }.join("\n")
60
+ when Array
61
+ "**#{key}:** #{value.size} items"
62
+ else
63
+ "**#{key}:** #{value}"
64
+ end
65
+ end.join("\n")
66
+ end
67
+ end
68
+
69
+ # JSON passthrough renderer for backward compatibility.
70
+ # Delegates to MCP::Renderers::JsonRenderer for consistent JSON output.
71
+ class JsonConsoleRenderer < MCP::Renderers::JsonRenderer
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bridge'
4
+ require_relative 'model_validator'
5
+ require_relative 'safe_context'
6
+
7
+ module Woods
8
+ module Console
9
+ # Drop-in replacement for ConnectionManager + Bridge that executes
10
+ # queries directly via ActiveRecord instead of a separate bridge process.
11
+ #
12
+ # Implements the same `send_request(Hash) -> Hash` interface as
13
+ # ConnectionManager, so all existing tool definitions in Server work
14
+ # unchanged — just pass this where `conn_mgr` goes.
15
+ #
16
+ # @example
17
+ # executor = EmbeddedExecutor.new(model_validator: validator, safe_context: ctx)
18
+ # response = executor.send_request({ 'tool' => 'count', 'params' => { 'model' => 'User' } })
19
+ # # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
20
+ #
21
+ class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
22
+ AGGREGATE_FUNCTIONS = %w[sum average minimum maximum].freeze
23
+
24
+ TIER1_TOOLS = Bridge::TIER1_TOOLS
25
+
26
+ # Tools gated behind the read_tools_enabled flag.
27
+ # sql/query have existing safety gates (SqlValidator, SafeContext rollback)
28
+ # but require explicit opt-in for embedded mode.
29
+ EMBEDDED_READ_TOOLS = %w[sql query].freeze
30
+
31
+ MAX_SQL_LIMIT = 10_000
32
+ MAX_QUERY_LIMIT = 10_000
33
+
34
+ # @param model_validator [ModelValidator] Validates model/column names
35
+ # @param safe_context [SafeContext] Wraps execution in rolled-back transaction
36
+ # @param connection [Object, nil] Database connection for adapter detection
37
+ # @param read_tools_enabled [Boolean] Enable sql/query tools in embedded mode (default: false)
38
+ def initialize(model_validator:, safe_context:, connection: nil, read_tools_enabled: false)
39
+ @model_validator = model_validator
40
+ @safe_context = safe_context
41
+ @connection = connection
42
+ @read_tools_enabled = read_tools_enabled
43
+ end
44
+
45
+ # Execute a tool request and return a response hash.
46
+ #
47
+ # Compatible with ConnectionManager#send_request — Server's `send_to_bridge`
48
+ # calls this method and expects `{ 'ok' => true/false, ... }`.
49
+ #
50
+ # @param request [Hash] Request with 'tool' and 'params' keys
51
+ # @return [Hash] Response with 'ok', 'result'/'error', and 'timing_ms'
52
+ def send_request(request)
53
+ # Deep-stringify keys — Tier1 tool builders use symbol keys, but the bridge
54
+ # path naturally stringifies via JSON round-trip. Replicate that here.
55
+ request = deep_stringify_keys(request)
56
+ tool = request['tool']
57
+ params = request['params'] || {}
58
+
59
+ unless TIER1_TOOLS.include?(tool) || (@read_tools_enabled && EMBEDDED_READ_TOOLS.include?(tool))
60
+ return { 'ok' => false,
61
+ 'error' => 'Not yet implemented in embedded mode',
62
+ 'error_type' => 'unsupported' }
63
+ end
64
+
65
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ result = @safe_context.execute { dispatch(tool, params) }
67
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
68
+
69
+ { 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
70
+ rescue ValidationError => e
71
+ { 'ok' => false, 'error' => e.message, 'error_type' => 'validation' }
72
+ rescue StandardError => e
73
+ { 'ok' => false, 'error' => e.message, 'error_type' => 'execution' }
74
+ end
75
+
76
+ private
77
+
78
+ # Route a tool name to its handler.
79
+ #
80
+ # @param tool [String] Tool name
81
+ # @param params [Hash] Tool parameters
82
+ # @return [Hash] Tool result
83
+ def dispatch(tool, params)
84
+ case tool
85
+ when 'status' then handle_status
86
+ when 'schema' then handle_schema(params)
87
+ when 'sql' then handle_sql(params)
88
+ when 'query' then handle_query(params)
89
+ else
90
+ validate_model!(params)
91
+ send(:"handle_#{tool}", params)
92
+ end
93
+ end
94
+
95
+ # @param params [Hash] Must contain 'model' key
96
+ # @raise [ValidationError]
97
+ def validate_model!(params)
98
+ model = params['model']
99
+ raise ValidationError, 'Missing required parameter: model' unless model
100
+
101
+ @model_validator.validate_model!(model)
102
+ end
103
+
104
+ # Resolve a model name string to an ActiveRecord class.
105
+ #
106
+ # @param name [String] Model class name (e.g., 'User', 'Admin::Account')
107
+ # @return [Class] The ActiveRecord model class
108
+ def resolve_model(name)
109
+ name.constantize
110
+ end
111
+
112
+ # ── Tier 1 Handlers ──────────────────────────────────────────────────
113
+
114
+ def handle_count(params)
115
+ model = resolve_model(params['model'])
116
+ scope = apply_scope(model, params['scope'])
117
+ { 'count' => scope.count }
118
+ end
119
+
120
+ def handle_sample(params)
121
+ model = resolve_model(params['model'])
122
+ limit = [params.fetch('limit', 5).to_i, 25].min
123
+ scope = apply_scope(model, params['scope'])
124
+ scope = apply_columns(scope, params['columns'])
125
+ records = scope.order(random_function).limit(limit)
126
+ { 'records' => serialize_records(records, params['columns']) }
127
+ end
128
+
129
+ def handle_find(params)
130
+ model = resolve_model(params['model'])
131
+ record = if params['id']
132
+ model.find_by(id: params['id'])
133
+ elsif params['by']
134
+ model.find_by(params['by'])
135
+ end
136
+ { 'record' => record ? serialize_record(record, params['columns']) : nil }
137
+ end
138
+
139
+ def handle_pluck(params)
140
+ columns = params['columns']
141
+ @model_validator.validate_columns!(params['model'], columns) if columns
142
+ model = resolve_model(params['model'])
143
+ limit = [params.fetch('limit', 100).to_i, 1000].min
144
+ scope = apply_scope(model, params['scope'])
145
+ scope = scope.distinct if params['distinct']
146
+ values = scope.limit(limit).pluck(*columns.map(&:to_sym))
147
+ { 'values' => values }
148
+ end
149
+
150
+ def handle_aggregate(params)
151
+ column = params['column']
152
+ function = params['function']
153
+ @model_validator.validate_column!(params['model'], column) if column
154
+
155
+ unless AGGREGATE_FUNCTIONS.include?(function)
156
+ raise ValidationError, "Invalid aggregate function: #{function}. " \
157
+ "Allowed: #{AGGREGATE_FUNCTIONS.join(', ')}"
158
+ end
159
+
160
+ model = resolve_model(params['model'])
161
+ scope = apply_scope(model, params['scope'])
162
+ { 'value' => scope.send(function.to_sym, column.to_sym) }
163
+ end
164
+
165
+ def handle_association_count(params)
166
+ model = resolve_model(params['model'])
167
+ record = model.find(params['id'])
168
+ association_name = params['association']
169
+
170
+ unless model.reflect_on_association(association_name.to_sym)
171
+ raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}"
172
+ end
173
+
174
+ scope = record.public_send(association_name)
175
+ scope = apply_scope(scope, params['scope'])
176
+ { 'count' => scope.count }
177
+ end
178
+
179
+ def handle_schema(params)
180
+ model_name = params['model']
181
+ raise ValidationError, 'Missing required parameter: model' unless model_name
182
+
183
+ @model_validator.validate_model!(model_name)
184
+ model = resolve_model(model_name)
185
+
186
+ columns = model.columns_hash.transform_values do |col|
187
+ { 'type' => col.type.to_s, 'null' => col.null, 'default' => col.default&.to_s }
188
+ end
189
+
190
+ result = { 'columns' => columns }
191
+
192
+ if params['include_indexes']
193
+ indexes = model.connection.indexes(model.table_name).map do |idx|
194
+ { 'name' => idx.name, 'columns' => idx.columns, 'unique' => idx.unique }
195
+ end
196
+ result['indexes'] = indexes
197
+ end
198
+
199
+ result
200
+ end
201
+
202
+ def handle_recent(params)
203
+ model = resolve_model(params['model'])
204
+ order_by = params.fetch('order_by', 'created_at')
205
+ direction = params.fetch('direction', 'desc')
206
+ limit = [params.fetch('limit', 10).to_i, 50].min
207
+
208
+ @model_validator.validate_column!(params['model'], order_by)
209
+ direction = 'desc' unless %w[asc desc].include?(direction)
210
+
211
+ scope = apply_scope(model, params['scope'])
212
+ scope = apply_columns(scope, params['columns'])
213
+ records = scope.order(order_by => direction.to_sym).limit(limit)
214
+ { 'records' => serialize_records(records, params['columns']) }
215
+ end
216
+
217
+ def handle_status
218
+ adapter = begin
219
+ active_connection.adapter_name
220
+ rescue StandardError
221
+ 'unknown'
222
+ end
223
+ { 'status' => 'ok', 'models' => @model_validator.model_names, 'adapter' => adapter }
224
+ end
225
+
226
+ # ── Read tools (sql/query, gated by read_tools_enabled) ────────────
227
+
228
+ # Execute validated read-only SQL via ActiveRecord's select_all.
229
+ #
230
+ # @param params [Hash] Must contain 'sql'; optional 'limit'
231
+ # @return [Hash] Columns and rows
232
+ def handle_sql(params)
233
+ sql = params['sql']
234
+ raise ValidationError, 'Missing required parameter: sql' unless sql
235
+
236
+ require_relative 'sql_validator'
237
+ SqlValidator.new.validate!(sql)
238
+
239
+ limit = params['limit'] ? [params['limit'].to_i, MAX_SQL_LIMIT].min : nil
240
+ query_sql = limit ? "SELECT * FROM (#{sql}) AS _limited LIMIT #{limit}" : sql
241
+ result = active_connection.select_all(query_sql)
242
+
243
+ { 'columns' => result.columns, 'rows' => result.rows, 'count' => result.rows.size }
244
+ rescue SqlValidationError => e
245
+ raise ValidationError, e.message
246
+ end
247
+
248
+ # Build and execute a structured ActiveRecord query.
249
+ #
250
+ # @param params [Hash] Must contain 'model' and 'select'
251
+ # @return [Hash] Columns and rows
252
+ def handle_query(params)
253
+ validate_model!(params)
254
+ model = resolve_model(params['model'])
255
+ relation = build_query_relation(model, params)
256
+ result = active_connection.select_all(relation.to_sql)
257
+ { 'columns' => result.columns, 'rows' => result.rows, 'count' => result.rows.size }
258
+ end
259
+
260
+ # Build an ActiveRecord relation from structured query parameters.
261
+ #
262
+ # @param model [Class] ActiveRecord model class
263
+ # @param params [Hash] Query parameters (select, joins, scope, group_by, having, order, limit)
264
+ # @return [ActiveRecord::Relation]
265
+ def build_query_relation(model, params)
266
+ relation = apply_query_clauses(model.all, params)
267
+ limit = params['limit'] ? [params['limit'].to_i, MAX_QUERY_LIMIT].min : MAX_QUERY_LIMIT
268
+ relation.limit(limit)
269
+ end
270
+
271
+ # Apply select/joins/scope/group/having/order clauses to a relation.
272
+ #
273
+ # @param relation [ActiveRecord::Relation]
274
+ # @param params [Hash]
275
+ # @return [ActiveRecord::Relation]
276
+ def apply_query_clauses(relation, params) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
277
+ relation = relation.select(params['select']) if params['select']
278
+ relation = relation.joins(params['joins'].map(&:to_sym)) if params['joins']&.any?
279
+ relation = apply_scope(relation, params['scope'])
280
+ relation = relation.group(params['group_by']) if params['group_by']&.any?
281
+ relation = relation.having(params['having']) if params['having']
282
+ relation = relation.order(params['order']) if params['order']
283
+ relation
284
+ end
285
+
286
+ # ── Helpers ──────────────────────────────────────────────────────────
287
+
288
+ # Apply scope conditions (WHERE clauses) to a relation.
289
+ #
290
+ # Accepts Hash form for simple equality conditions, or Array form
291
+ # for parameterized SQL (e.g., JSON column queries like
292
+ # ["preferences->>'theme' = ?", "dark"]).
293
+ #
294
+ # @param relation [ActiveRecord::Relation, Class] Model or relation
295
+ # @param scope [Hash, Array, nil] Filter conditions
296
+ # @return [ActiveRecord::Relation]
297
+ def apply_scope(relation, scope)
298
+ case scope
299
+ when Hash
300
+ scope.any? ? relation.where(scope) : relation
301
+ when Array
302
+ scope.any? ? relation.where(*scope) : relation
303
+ else
304
+ relation
305
+ end
306
+ end
307
+
308
+ # Apply column selection to a relation.
309
+ #
310
+ # @param relation [ActiveRecord::Relation] The relation
311
+ # @param columns [Array<String>, nil] Columns to select
312
+ # @return [ActiveRecord::Relation]
313
+ def apply_columns(relation, columns)
314
+ return relation unless columns.is_a?(Array) && columns.any?
315
+
316
+ relation.select(columns)
317
+ end
318
+
319
+ # Serialize a single record to a Hash.
320
+ #
321
+ # @param record [ActiveRecord::Base] The record
322
+ # @param columns [Array<String>, nil] Columns to include
323
+ # @return [Hash]
324
+ def serialize_record(record, columns = nil)
325
+ if columns.is_a?(Array) && columns.any?
326
+ record.attributes.slice(*columns)
327
+ else
328
+ record.attributes
329
+ end
330
+ end
331
+
332
+ # Serialize multiple records.
333
+ #
334
+ # @param records [ActiveRecord::Relation] The records
335
+ # @param columns [Array<String>, nil] Columns to include
336
+ # @return [Array<Hash>]
337
+ def serialize_records(records, columns = nil)
338
+ records.map { |r| serialize_record(r, columns) }
339
+ end
340
+
341
+ # DB-dialect-aware random ordering function.
342
+ #
343
+ # @return [Arel::Nodes::SqlLiteral]
344
+ def random_function
345
+ adapter = active_connection.adapter_name.downcase
346
+ func = adapter.include?('mysql') ? 'RAND' : 'RANDOM'
347
+ Arel.sql("#{func}()")
348
+ end
349
+
350
+ # Return the database connection (injected or from ActiveRecord).
351
+ #
352
+ # @return [Object] Database connection
353
+ def active_connection
354
+ @connection || ActiveRecord::Base.connection
355
+ end
356
+
357
+ # Recursively convert all Hash keys to strings.
358
+ #
359
+ # @param obj [Object] The object to stringify
360
+ # @return [Object] Object with string keys
361
+ def deep_stringify_keys(obj)
362
+ case obj
363
+ when Hash
364
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
365
+ when Array
366
+ obj.map { |item| deep_stringify_keys(item) }
367
+ else
368
+ obj
369
+ end
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @see Woods
4
+ module Woods
5
+ class Error < StandardError; end unless defined?(Woods::Error)
6
+
7
+ module Console
8
+ class ValidationError < Woods::Error; end
9
+
10
+ # Validates model names and column names against the Rails schema.
11
+ #
12
+ # In production, validates against AR::Base.descendants and model.column_names.
13
+ # Accepts an injectable registry for testing without Rails.
14
+ #
15
+ # @example
16
+ # validator = ModelValidator.new(registry: { 'User' => %w[id email name] })
17
+ # validator.validate_model!('User') # => true
18
+ # validator.validate_model!('Hacker') # => raises ValidationError
19
+ # validator.validate_column!('User', 'email') # => true
20
+ #
21
+ class ModelValidator
22
+ # @param registry [Hash<String, Array<String>>] Model name => column names mapping
23
+ def initialize(registry:)
24
+ @registry = registry
25
+ end
26
+
27
+ # Validate that a model name is known.
28
+ #
29
+ # @param model_name [String]
30
+ # @return [true]
31
+ # @raise [ValidationError] if model is unknown
32
+ def validate_model!(model_name)
33
+ return true if @registry.key?(model_name)
34
+
35
+ raise ValidationError, "Unknown model: #{model_name}. Available: #{@registry.keys.sort.join(', ')}"
36
+ end
37
+
38
+ # Validate that a column exists on a model.
39
+ #
40
+ # @param model_name [String]
41
+ # @param column_name [String]
42
+ # @return [true]
43
+ # @raise [ValidationError] if column is unknown
44
+ def validate_column!(model_name, column_name)
45
+ validate_model!(model_name)
46
+ columns = @registry[model_name]
47
+ return true if columns.include?(column_name)
48
+
49
+ raise ValidationError,
50
+ "Unknown column '#{column_name}' on #{model_name}. Available: #{columns.sort.join(', ')}"
51
+ end
52
+
53
+ # Validate multiple columns at once.
54
+ #
55
+ # @param model_name [String]
56
+ # @param column_names [Array<String>]
57
+ # @return [true]
58
+ # @raise [ValidationError] if any column is unknown
59
+ def validate_columns!(model_name, column_names) # rubocop:disable Naming/PredicateMethod
60
+ column_names.each { |col| validate_column!(model_name, col) }
61
+ true
62
+ end
63
+
64
+ # List all known model names.
65
+ #
66
+ # @return [Array<String>]
67
+ def model_names
68
+ @registry.keys.sort
69
+ end
70
+
71
+ # List columns for a model.
72
+ #
73
+ # @param model_name [String]
74
+ # @return [Array<String>]
75
+ def columns_for(model_name)
76
+ validate_model!(model_name)
77
+ @registry[model_name].sort
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Console
7
+ # Rack middleware that serves the embedded console MCP server over HTTP.
8
+ #
9
+ # Lazy-builds the MCP server on first request so Rails has fully booted
10
+ # and all models are loaded. Uses ActiveRecord connection pool for thread
11
+ # safety under Puma.
12
+ #
13
+ # @example In config/application.rb or an initializer:
14
+ # config.middleware.use Woods::Console::RackMiddleware, path: '/mcp/console'
15
+ #
16
+ class RackMiddleware
17
+ # @param app [#call] The next Rack app in the middleware stack
18
+ # @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
19
+ # @param embedded_read_tools [Boolean] Enable sql/query tools in embedded mode (default: false)
20
+ def initialize(app, path: '/mcp/console', embedded_read_tools: false)
21
+ @app = app
22
+ @path = path
23
+ @embedded_read_tools = embedded_read_tools
24
+ @mutex = Mutex.new
25
+ @transport = nil
26
+ end
27
+
28
+ # Rack interface — intercepts requests at the configured path.
29
+ #
30
+ # @param env [Hash] Rack environment
31
+ # @return [Array] Rack response triple
32
+ def call(env)
33
+ return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
34
+
35
+ transport = ensure_transport
36
+ request = Rack::Request.new(env)
37
+ transport.handle_request(request)
38
+ end
39
+
40
+ private
41
+
42
+ # Thread-safe lazy initialization of the MCP server and transport.
43
+ #
44
+ # @return [MCP::Server::Transports::StreamableHTTPTransport]
45
+ def ensure_transport # rubocop:disable Metrics/MethodLength
46
+ return @transport if @transport
47
+
48
+ @mutex.synchronize do
49
+ return @transport if @transport
50
+
51
+ require 'woods/console/server'
52
+
53
+ Rails.application.eager_load!
54
+
55
+ registry = ActiveRecord::Base.descendants.each_with_object({}) do |model, hash|
56
+ next if model.abstract_class?
57
+ next unless model.table_exists?
58
+
59
+ hash[model.name] = model.column_names
60
+ rescue StandardError
61
+ next
62
+ end
63
+
64
+ validator = ModelValidator.new(registry: registry)
65
+
66
+ config = Woods.configuration
67
+ redacted = Array(config.console_redacted_columns)
68
+
69
+ # Each HTTP request gets its own connection from the pool.
70
+ # SafeContext wraps that connection in a rolled-back transaction.
71
+ safe_context = SafeContext.new(connection: ActiveRecord::Base.connection)
72
+
73
+ server = Server.build_embedded(
74
+ model_validator: validator,
75
+ safe_context: safe_context,
76
+ redacted_columns: redacted,
77
+ read_tools_enabled: @embedded_read_tools
78
+ )
79
+
80
+ @transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
81
+ server.transport = @transport
82
+ @transport
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stub for environments that don't load ActiveRecord
4
+ unless defined?(ActiveRecord::Rollback)
5
+ module ActiveRecord
6
+ class Rollback < StandardError; end
7
+ end
8
+ end
9
+
10
+ module Woods
11
+ module Console
12
+ # Wraps tool execution in a rolled-back transaction with statement timeout.
13
+ #
14
+ # Safety layers:
15
+ # - Every query runs inside a transaction that is always rolled back
16
+ # - Statement timeout prevents runaway queries
17
+ # - Column redaction replaces sensitive values with "[REDACTED]"
18
+ #
19
+ # @example
20
+ # ctx = SafeContext.new(connection: conn, timeout_ms: 5000, redacted_columns: %w[ssn])
21
+ # ctx.execute { |c| c.execute("SELECT count(*) FROM users") }
22
+ #
23
+ class SafeContext
24
+ # @param connection [Object] Database connection (or mock)
25
+ # @param timeout_ms [Integer] Statement timeout in milliseconds
26
+ # @param redacted_columns [Array<String>] Column names whose values should be redacted
27
+ def initialize(connection:, timeout_ms: 5000, redacted_columns: [])
28
+ @connection = connection
29
+ @timeout_ms = timeout_ms
30
+ @redacted_columns = redacted_columns.map(&:to_s)
31
+ end
32
+
33
+ # Execute a block within a rolled-back transaction with statement timeout.
34
+ #
35
+ # The transaction is always rolled back to ensure read-only behavior.
36
+ #
37
+ # @yield [connection] The database connection
38
+ # @return [Object] The block's return value
39
+ def execute
40
+ result = nil
41
+ @connection.transaction do
42
+ set_timeout
43
+ result = yield(@connection)
44
+ raise ActiveRecord::Rollback
45
+ end
46
+ result
47
+ end
48
+
49
+ # Replace values of redacted columns with "[REDACTED]".
50
+ #
51
+ # @param hash [Hash] Record attributes
52
+ # @param _model_name [String] Model name (reserved for per-model redaction rules)
53
+ # @return [Hash] Redacted copy of the hash
54
+ def redact(hash, _model_name = nil)
55
+ return hash if @redacted_columns.empty?
56
+
57
+ hash.transform_keys(&:to_s).each_with_object({}) do |(key, value), redacted|
58
+ redacted[key] = @redacted_columns.include?(key) ? '[REDACTED]' : value
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # Set statement timeout on the connection.
65
+ #
66
+ # PostgreSQL uses SET statement_timeout (applies to all statement types).
67
+ # MySQL uses SET max_execution_time (applies to SELECT only — MySQL limitation:
68
+ # DDL and DML statements cannot be time-limited via this variable).
69
+ def set_timeout(connection = @connection, timeout_ms = @timeout_ms)
70
+ adapter = connection.adapter_name.downcase
71
+ if adapter.include?('mysql')
72
+ connection.execute("SET max_execution_time = #{timeout_ms.to_i}")
73
+ else
74
+ connection.execute("SET statement_timeout = '#{timeout_ms.to_i}ms'")
75
+ end
76
+ rescue StandardError
77
+ # Unsupported adapter — timeout enforcement is best-effort
78
+ nil
79
+ end
80
+ end
81
+ end
82
+ end