woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'mcp'
4
+
5
+ require_relative 'input_contract'
6
+ require_relative 'scope_predicate_parser'
7
+
3
8
  # This file is a pure data table — 31 ToolSpec entries across 4 tiers
4
9
  # (9 read-only / 9 domain-aware / 10 analytics / 3 guarded).
5
10
  # Metrics/ModuleLength is disabled here because the module body is almost
6
11
  # entirely declarative data, not imperative logic. Decomposition would just
7
12
  # scatter the tool catalogue across many files with no readability gain.
8
- # rubocop:disable Metrics/ModuleLength
13
+ # rubocop:disable-next Metrics/ModuleLength
9
14
  module Woods
10
15
  module Console
11
16
  module Server
@@ -16,6 +21,67 @@ module Woods
16
21
  job_schedule redis_info cache_stats channel_status].freeze
17
22
  TIER4_TOOLS = %w[eval sql query].freeze
18
23
 
24
+ MIN_INTEGER_INPUT = 1
25
+ MAX_RECORD_ID = 9_223_372_036_854_775_807
26
+ AGGREGATE_FUNCTIONS = %w[sum average minimum maximum count].freeze
27
+
28
+ SAFE_IDENTIFIER_GRAMMAR = '[A-Za-z_][A-Za-z0-9_]*'
29
+ NON_WHITESPACE_SCHEMA_PATTERN = '\\S'
30
+ COLUMN_REFERENCE_GRAMMAR =
31
+ "#{SAFE_IDENTIFIER_GRAMMAR}(?:\\.#{SAFE_IDENTIFIER_GRAMMAR})?".freeze
32
+ SAFE_IDENTIFIER_SCHEMA_PATTERN = "^(?:#{SAFE_IDENTIFIER_GRAMMAR})$(?![\\s\\S])".freeze
33
+ COLUMN_REFERENCE_SCHEMA_PATTERN = "^(?:#{COLUMN_REFERENCE_GRAMMAR})$(?![\\s\\S])".freeze
34
+ SAFE_IDENTIFIER_REGEXP = Regexp.new("\\A(?:#{SAFE_IDENTIFIER_GRAMMAR})\\z").freeze
35
+ SCOPE_PREDICATE_SUFFIX_GRAMMAR =
36
+ "(?:#{ScopePredicateParser::SUPPORTED_SUFFIXES.map { |suffix| Regexp.escape(suffix) }.join('|')})".freeze
37
+ SCOPE_KEY_GRAMMAR = "#{SAFE_IDENTIFIER_GRAMMAR}(?:#{SCOPE_PREDICATE_SUFFIX_GRAMMAR})?".freeze
38
+ SCOPE_KEY_SCHEMA_PATTERN = "^(?:#{SCOPE_KEY_GRAMMAR})$(?![\\s\\S])".freeze
39
+
40
+ # Suffix-dependent value types for scope predicates, shared by every
41
+ # `scope:` property below. Without this, a JSON boolean-looking string
42
+ # (e.g. `"false"`) reaches the executor for an existence suffix
43
+ # (`_null`/`_present`/`_blank`) and inverts on Ruby truthiness — any
44
+ # non-empty String is truthy, so `"false"` behaves as `true`.
45
+ SCOPE_VALUE_PATTERN_PROPERTIES = {
46
+ "(#{Regexp.union(ScopePredicateParser::EXISTENCE_SUFFIXES).source})$" => { type: 'boolean' },
47
+ "(#{Regexp.union(ScopePredicateParser::COMPARISON_SUFFIXES).source})$" => { type: %w[string number] },
48
+ "(#{Regexp.union(ScopePredicateParser::SET_SUFFIXES).source})$" => { type: 'array' }
49
+ }.freeze
50
+
51
+ CASE_INSENSITIVE_AGGREGATE_GRAMMAR = [
52
+ '[Ss][Uu][Mm]', '[Aa][Vv][Gg]', '[Mm][Ii][Nn]', '[Mm][Aa][Xx]', '[Cc][Oo][Uu][Nn][Tt]'
53
+ ].join('|').freeze
54
+ SELECT_EXPRESSION_GRAMMAR = [
55
+ "\\s*(?:((?:#{CASE_INSENSITIVE_AGGREGATE_GRAMMAR}))\\s*\\(\\s*",
56
+ "(\\*|#{COLUMN_REFERENCE_GRAMMAR})\\s*\\)|",
57
+ "(#{COLUMN_REFERENCE_GRAMMAR}))(?:\\s+[Aa][Ss]\\s+(#{SAFE_IDENTIFIER_GRAMMAR}))?\\s*"
58
+ ].join.freeze
59
+ SELECT_EXPRESSION_SCHEMA_PATTERN = "^(?:#{SELECT_EXPRESSION_GRAMMAR})$(?![\\s\\S])".freeze
60
+ SELECT_EXPRESSION_REGEXP = Regexp.new("\\A(?:#{SELECT_EXPRESSION_GRAMMAR})\\z").freeze
61
+
62
+ ORDER_DIRECTION_GRAMMAR = '(?:[Aa][Ss][Cc]|[Dd][Ee][Ss][Cc])'
63
+ ORDER_DIRECTION_SCHEMA_PATTERN = "^(?:#{ORDER_DIRECTION_GRAMMAR})$(?![\\s\\S])".freeze
64
+ ORDER_DIRECTION_REGEXP = Regexp.new("\\A(?:#{ORDER_DIRECTION_GRAMMAR})\\z").freeze
65
+
66
+ # Authoritative grammar shared by the public JSON Schema and executor.
67
+ # Schema and Ruby use different end-anchor syntax, but the executable
68
+ # template body is defined once so accepted SQL cannot drift.
69
+ HAVING_TEMPLATE_GRAMMAR = [
70
+ "\\s*(?:(#{COLUMN_REFERENCE_GRAMMAR})|",
71
+ '(SUM|AVG|MIN|MAX|COUNT)\\s*\\(\\s*',
72
+ "(\\*|#{COLUMN_REFERENCE_GRAMMAR})\\s*\\))",
73
+ '\\s*(=|!=|<>|<=|>=|<|>)\\s*\\?\\s*'
74
+ ].join.freeze
75
+ HAVING_TEMPLATE_SCHEMA_PATTERN = "^(?:#{HAVING_TEMPLATE_GRAMMAR})$(?![\\s\\S])".freeze
76
+ HAVING_TEMPLATE_REGEXP = Regexp.new("\\A(?:#{HAVING_TEMPLATE_GRAMMAR})\\z").freeze
77
+
78
+ QUERY_SCOPE_TEMPLATE_GRAMMAR = [
79
+ "\\s*(#{COLUMN_REFERENCE_GRAMMAR})\\s*",
80
+ '(?:=|!=|<>|<=|>=|<|>)\\s*\\?\\s*'
81
+ ].join.freeze
82
+ QUERY_SCOPE_TEMPLATE_SCHEMA_PATTERN = "^(?:#{QUERY_SCOPE_TEMPLATE_GRAMMAR})$(?![\\s\\S])".freeze
83
+ QUERY_SCOPE_TEMPLATE_REGEXP = Regexp.new("\\A(?:#{QUERY_SCOPE_TEMPLATE_GRAMMAR})\\z").freeze
84
+
19
85
  # Value object that holds a single MCP tool's declarative specification.
20
86
  #
21
87
  # @!attribute [r] name
@@ -32,34 +98,67 @@ module Woods
32
98
  # @return [Proc] Lambda called with symbolised args hash; returns the
33
99
  # request Hash forwarded to the bridge/executor. Any tier-specific
34
100
  # objects (validators, guards) are captured in the lambda's closure.
35
- ToolSpec = Struct.new(:name, :description, :properties, :required, :tier, :handler, keyword_init: true)
36
-
37
- # All 31 console tool specifications, grouped by tier:
38
- # Tier 1 (read-only, 9 tools) — no guard required, bridge-level
39
- # table_gate already constrains reach.
40
- # Tier 2 (domain-aware, 9 tools) — no guard required, validators run
41
- # inside the app under SafeContext.
42
- # Tier 3 (analytics, 10 tools) no guard required, adapters wrap
43
- # external services (Redis, job queues, cache).
44
- # Tier 4 (guarded, 3 tools) — `eval`, `sql`, `query`. Guards ARE
45
- # MANDATORY for these. The handler lambda for each Tier-4 tool
46
- # captures the relevant validator/guard closure; the Server's
47
- # {DispatchPipeline} and {EmbeddedExecutor} refuse to execute a
48
- # Tier-4 tool whose `guard` is missing or nil. Never call `eval`,
49
- # `sql`, or `query` without wiring EvalGuard / SqlValidator first.
50
- # Each spec is a ToolSpec; the handler lambda captures any objects that
51
- # must be built once at spec-definition time (validators, guards).
101
+ ToolSpec = Struct.new(
102
+ :name, :description, :properties, :required, :tier, :handler, :schema_constraints,
103
+ keyword_init: true
104
+ ) do
105
+ def input_schema
106
+ schema = { properties: properties }
107
+ schema[:required] = required if required&.any?
108
+ schema.merge!(schema_constraints) if schema_constraints
109
+ schema
110
+ end
111
+
112
+ def validate_arguments!(arguments)
113
+ schema = input_schema_value
114
+ if arguments.is_a?(Hash)
115
+ missing = schema.missing_required_arguments(arguments)
116
+ unless missing.empty?
117
+ raise InputContract::ValidationError, "Missing required arguments: #{missing.join(', ')}"
118
+ end
119
+ end
120
+
121
+ schema.validate_arguments(arguments)
122
+ arguments
123
+ rescue ::MCP::Tool::InputSchema::ValidationError => e
124
+ raise InputContract::ValidationError, e.message
125
+ end
126
+
127
+ def wrap_handler!
128
+ raw_handler = handler
129
+ self.handler = lambda do |arguments|
130
+ validate_arguments!(arguments)
131
+ raw_handler.call(arguments)
132
+ end
133
+ self
134
+ end
135
+
136
+ private
137
+
138
+ def input_schema_value
139
+ @input_schema_value ||= ::MCP::Tool::InputSchema.new(input_schema)
140
+ end
141
+ end
142
+
143
+ # All 31 possible Console tool specifications, grouped by tier. Runtime
144
+ # registration is derived separately in EXECUTABLE_MODES: Tier 1 is
145
+ # executable in both embedded modes, and sql/query are executable only
146
+ # in explicit embedded-read mode. Tier 2, Tier 3, and eval remain an
147
+ # unregistered inventory until real executors and enforceable controls
148
+ # exist for them.
52
149
  TOOL_SPECS = [
53
150
  # ── Tier 1: read-only ─────────────────────────────────────────────────
54
151
  ToolSpec.new(
55
152
  name: 'console_count',
56
153
  description: 'Count records matching scope conditions.',
57
154
  properties: {
58
- model: { type: 'string', description: 'Model name' },
59
- scope: { type: 'object', description: 'Filter: {status: "paid", total_refund_gt: 0, ' \
60
- 'transaction_id_not_null: true}. ' \
61
- 'Suffixes: _eq _gt _lt _in _null _present. ' \
62
- 'Complex queries: use console_query.' }
155
+ model: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN, description: 'Model name' },
156
+ scope: { type: 'object', propertyNames: { pattern: SCOPE_KEY_SCHEMA_PATTERN },
157
+ patternProperties: SCOPE_VALUE_PATTERN_PROPERTIES,
158
+ description: 'Filter: {status: "paid", total_refund_gt: 0, ' \
159
+ 'transaction_id_not_null: true}. ' \
160
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
161
+ 'Complex queries: use console_query.' }
63
162
  },
64
163
  required: ['model'],
65
164
  tier: 1,
@@ -69,12 +168,16 @@ module Woods
69
168
  name: 'console_sample',
70
169
  description: 'Random sample of records.',
71
170
  properties: {
72
- model: { type: 'string', description: 'Model name' },
73
- limit: { type: 'integer', description: 'Max records (default 5, max 25)' },
74
- columns: { type: 'array', items: { type: 'string' }, description: 'Columns to include' },
75
- scope: { type: 'object', description: 'Filter: {status: "paid", amount_gt: 100}. ' \
76
- 'Suffixes: _eq _gt _lt _in _null _present. ' \
77
- 'Complex queries: use console_query.' }
171
+ model: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN, description: 'Model name' },
172
+ limit: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 25,
173
+ description: 'Max records (default 5, max 25)' },
174
+ columns: { type: 'array', items: { type: 'string', pattern: SAFE_IDENTIFIER_SCHEMA_PATTERN },
175
+ description: 'Columns to include' },
176
+ scope: { type: 'object', propertyNames: { pattern: SCOPE_KEY_SCHEMA_PATTERN },
177
+ patternProperties: SCOPE_VALUE_PATTERN_PROPERTIES,
178
+ description: 'Filter: {status: "paid", amount_gt: 100}. ' \
179
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
180
+ 'Complex queries: use console_query.' }
78
181
  },
79
182
  required: ['model'],
80
183
  tier: 1,
@@ -88,13 +191,27 @@ module Woods
88
191
  name: 'console_find',
89
192
  description: 'Find a single record by primary key or unique column',
90
193
  properties: {
91
- model: { type: 'string', description: 'Model name' },
92
- id: { type: 'integer', description: 'Primary key value' },
93
- by: { type: 'object', description: 'Unique column lookup' },
94
- columns: { type: 'array', items: { type: 'string' }, description: 'Columns to include' }
194
+ model: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN, description: 'Model name' },
195
+ id: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: MAX_RECORD_ID,
196
+ description: 'Primary key value' },
197
+ by: { type: 'object', minProperties: 1, propertyNames: { pattern: SAFE_IDENTIFIER_SCHEMA_PATTERN },
198
+ description: 'Unique column lookup (must be non-empty)' },
199
+ columns: { type: 'array', items: { type: 'string', pattern: SAFE_IDENTIFIER_SCHEMA_PATTERN },
200
+ description: 'Columns to include' }
95
201
  },
96
202
  required: ['model'],
97
203
  tier: 1,
204
+ # Exactly one non-empty locator form: `id` XOR a non-empty `by`.
205
+ # `find_by({})` (no locator at all) returns an arbitrary row:
206
+ # this constraint is the schema half of closing that gap; the
207
+ # executor enforces it again as defense-in-depth (see
208
+ # EmbeddedExecutor#validate_find_locator!).
209
+ schema_constraints: {
210
+ oneOf: [
211
+ { required: ['id'] },
212
+ { required: ['by'] }
213
+ ]
214
+ },
98
215
  handler: lambda { |args|
99
216
  Tools::Tier1.console_find(
100
217
  model: args[:model], id: args[:id], by: args[:by], columns: args[:columns]
@@ -105,12 +222,17 @@ module Woods
105
222
  name: 'console_pluck',
106
223
  description: 'Extract column values from records.',
107
224
  properties: {
108
- model: { type: 'string', description: 'Model name' },
109
- columns: { type: 'array', items: { type: 'string' }, description: 'Column names to pluck' },
110
- scope: { type: 'object', description: 'Filter: {status_in: ["paid","refunded"], amount_gt: 0}. ' \
111
- 'Suffixes: _eq _gt _lt _in _null _present. ' \
112
- 'Complex queries: use console_query.' },
113
- limit: { type: 'integer', description: 'Max records (default 100, max 1000)' },
225
+ model: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN, description: 'Model name' },
226
+ columns: { type: 'array', minItems: 1,
227
+ items: { type: 'string', pattern: SAFE_IDENTIFIER_SCHEMA_PATTERN },
228
+ description: 'Column names to pluck' },
229
+ scope: { type: 'object', propertyNames: { pattern: SCOPE_KEY_SCHEMA_PATTERN },
230
+ patternProperties: SCOPE_VALUE_PATTERN_PROPERTIES,
231
+ description: 'Filter: {status_in: ["paid","refunded"], amount_gt: 0}. ' \
232
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
233
+ 'Complex queries: use console_query.' },
234
+ limit: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 1000,
235
+ description: 'Max records (default 100, max 1000)' },
114
236
  distinct: { type: 'boolean', description: 'Return unique values only' }
115
237
  },
116
238
  required: %w[model columns],
@@ -129,14 +251,29 @@ module Woods
129
251
  'Supports scope predicates: {status: "paid", total_gt: 0}. ' \
130
252
  'For complex queries use console_query.',
131
253
  properties: {
132
- model: { type: 'string', description: 'Model name' },
133
- function: { type: 'string', description: 'Aggregate function: sum, average, minimum, maximum, count' },
134
- column: { type: 'string', description: 'Column to aggregate (optional for count)' },
135
- scope: { type: 'object', description: 'Filter conditions: {col: val} or predicate suffixes ' \
136
- '(_gt, _lt, _in, _null, etc.)' }
254
+ model: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN, description: 'Model name' },
255
+ function: { type: 'string', enum: AGGREGATE_FUNCTIONS,
256
+ description: 'Aggregate function: sum, average, minimum, maximum, count' },
257
+ column: { type: 'string', pattern: SAFE_IDENTIFIER_SCHEMA_PATTERN,
258
+ description: 'Column to aggregate (optional for count)' },
259
+ scope: { type: 'object', propertyNames: { pattern: SCOPE_KEY_SCHEMA_PATTERN },
260
+ patternProperties: SCOPE_VALUE_PATTERN_PROPERTIES,
261
+ description: 'Filter conditions: {col: val} or predicate suffixes ' \
262
+ '(_gt, _lt, _in, _null, etc.)' }
137
263
  },
138
264
  required: %w[model function],
139
265
  tier: 1,
266
+ schema_constraints: {
267
+ allOf: [
268
+ {
269
+ if: {
270
+ properties: { function: { enum: AGGREGATE_FUNCTIONS.reject { |name| name == 'count' } } },
271
+ required: ['function']
272
+ },
273
+ then: { required: ['column'] }
274
+ }
275
+ ]
276
+ },
140
277
  handler: lambda { |args|
141
278
  Tools::Tier1.console_aggregate(
142
279
  model: args[:model], function: args[:function], column: args[:column], scope: args[:scope]
@@ -147,12 +284,16 @@ module Woods
147
284
  name: 'console_association_count',
148
285
  description: 'Count associated records for a specific record.',
149
286
  properties: {
150
- model: { type: 'string', description: 'Model name' },
151
- id: { type: 'integer', description: 'Record primary key' },
152
- association: { type: 'string', description: 'Association name' },
153
- scope: { type: 'object', description: 'Filter on association: {status: "paid", amount_gt: 0}. ' \
154
- 'Suffixes: _eq _gt _lt _in _null _present. ' \
155
- 'Complex queries: use console_query.' }
287
+ model: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN, description: 'Model name' },
288
+ id: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: MAX_RECORD_ID,
289
+ description: 'Record primary key' },
290
+ association: { type: 'string', pattern: SAFE_IDENTIFIER_SCHEMA_PATTERN,
291
+ description: 'Association name' },
292
+ scope: { type: 'object', propertyNames: { pattern: SCOPE_KEY_SCHEMA_PATTERN },
293
+ patternProperties: SCOPE_VALUE_PATTERN_PROPERTIES,
294
+ description: 'Filter on association: {status: "paid", amount_gt: 0}. ' \
295
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
296
+ 'Complex queries: use console_query.' }
156
297
  },
157
298
  required: %w[model id association],
158
299
  tier: 1,
@@ -166,7 +307,7 @@ module Woods
166
307
  name: 'console_schema',
167
308
  description: 'Get database schema for a model',
168
309
  properties: {
169
- model: { type: 'string', description: 'Model name' },
310
+ model: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN, description: 'Model name' },
170
311
  include_indexes: { type: 'boolean', description: 'Include index information' }
171
312
  },
172
313
  required: ['model'],
@@ -179,14 +320,20 @@ module Woods
179
320
  name: 'console_recent',
180
321
  description: 'Recently created/updated records.',
181
322
  properties: {
182
- model: { type: 'string', description: 'Model name' },
183
- order_by: { type: 'string', description: 'Column to sort by (default: created_at)' },
184
- direction: { type: 'string', description: 'Sort direction: asc or desc (default: desc)' },
185
- limit: { type: 'integer', description: 'Max records (default 10, max 50)' },
186
- scope: { type: 'object', description: 'Filter: {status: "paid", total_gt: 0}. ' \
187
- 'Suffixes: _eq _gt _lt _in _null _present. ' \
188
- 'Complex queries: use console_query.' },
189
- columns: { type: 'array', items: { type: 'string' }, description: 'Columns to include' }
323
+ model: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN, description: 'Model name' },
324
+ order_by: { type: 'string', pattern: SAFE_IDENTIFIER_SCHEMA_PATTERN,
325
+ description: 'Column to sort by (default: created_at)' },
326
+ direction: { type: 'string', enum: %w[asc desc],
327
+ description: 'Sort direction: asc or desc (default: desc)' },
328
+ limit: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 50,
329
+ description: 'Max records (default 10, max 50)' },
330
+ scope: { type: 'object', propertyNames: { pattern: SCOPE_KEY_SCHEMA_PATTERN },
331
+ patternProperties: SCOPE_VALUE_PATTERN_PROPERTIES,
332
+ description: 'Filter: {status: "paid", total_gt: 0}. ' \
333
+ 'Suffixes: _eq _gt _lt _in _null _present. ' \
334
+ 'Complex queries: use console_query.' },
335
+ columns: { type: 'array', items: { type: 'string', pattern: SAFE_IDENTIFIER_SCHEMA_PATTERN },
336
+ description: 'Columns to include' }
190
337
  },
191
338
  required: ['model'],
192
339
  tier: 1,
@@ -214,7 +361,8 @@ module Woods
214
361
  properties: {
215
362
  model: { type: 'string', description: 'Model name' },
216
363
  scope: { type: 'object', description: 'Filter conditions' },
217
- sample_size: { type: 'integer', description: 'Sample records (default 5, max 25)' }
364
+ sample_size: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 25,
365
+ description: 'Sample records (default 5, max 25)' }
218
366
  },
219
367
  required: ['model'],
220
368
  tier: 2,
@@ -229,9 +377,11 @@ module Woods
229
377
  description: 'Snapshot a record with associations for debugging',
230
378
  properties: {
231
379
  model: { type: 'string', description: 'Model name' },
232
- id: { type: 'integer', description: 'Record primary key' },
380
+ id: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: MAX_RECORD_ID,
381
+ description: 'Record primary key' },
233
382
  associations: { type: 'array', items: { type: 'string' }, description: 'Association names to include' },
234
- depth: { type: 'integer', description: 'Association depth (default 1, max 3)' }
383
+ depth: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 3,
384
+ description: 'Association depth (default 1, max 3)' }
235
385
  },
236
386
  required: %w[model id],
237
387
  tier: 2,
@@ -247,7 +397,8 @@ module Woods
247
397
  description: 'Run validations on an existing record',
248
398
  properties: {
249
399
  model: { type: 'string', description: 'Model name' },
250
- id: { type: 'integer', description: 'Record primary key' },
400
+ id: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: MAX_RECORD_ID,
401
+ description: 'Record primary key' },
251
402
  attributes: { type: 'object', description: 'Attributes to set before validating' }
252
403
  },
253
404
  required: %w[model id],
@@ -290,8 +441,10 @@ module Woods
290
441
  description: 'Check authorization policy for a record and user',
291
442
  properties: {
292
443
  model: { type: 'string', description: 'Model name' },
293
- id: { type: 'integer', description: 'Record primary key' },
294
- user_id: { type: 'integer', description: 'User to check' },
444
+ id: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: MAX_RECORD_ID,
445
+ description: 'Record primary key' },
446
+ user_id: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: MAX_RECORD_ID,
447
+ description: 'User to check' },
295
448
  action: { type: 'string', description: 'Policy action' }
296
449
  },
297
450
  required: %w[model id user_id action],
@@ -323,7 +476,8 @@ module Woods
323
476
  description: 'Check feature eligibility for a record',
324
477
  properties: {
325
478
  model: { type: 'string', description: 'Model name' },
326
- id: { type: 'integer', description: 'Record primary key' },
479
+ id: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: MAX_RECORD_ID,
480
+ description: 'Record primary key' },
327
481
  feature: { type: 'string', description: 'Feature name' }
328
482
  },
329
483
  required: %w[model id feature],
@@ -339,7 +493,8 @@ module Woods
339
493
  description: 'Invoke a decorator on a record and return computed attributes',
340
494
  properties: {
341
495
  model: { type: 'string', description: 'Model name' },
342
- id: { type: 'integer', description: 'Record primary key' },
496
+ id: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: MAX_RECORD_ID,
497
+ description: 'Record primary key' },
343
498
  methods: { type: 'array', items: { type: 'string' }, description: 'Decorator methods to call' }
344
499
  },
345
500
  required: %w[model id],
@@ -354,7 +509,8 @@ module Woods
354
509
  name: 'console_slow_endpoints',
355
510
  description: 'List slowest endpoints by response time',
356
511
  properties: {
357
- limit: { type: 'integer', description: 'Max endpoints (default 10, max 100)' },
512
+ limit: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 100,
513
+ description: 'Max endpoints (default 10, max 100)' },
358
514
  period: { type: 'string', description: 'Time period (default: 1h)' }
359
515
  },
360
516
  required: nil,
@@ -405,7 +561,8 @@ module Woods
405
561
  name: 'console_job_failures',
406
562
  description: 'List recent job failures',
407
563
  properties: {
408
- limit: { type: 'integer', description: 'Max failures (default 10, max 100)' },
564
+ limit: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 100,
565
+ description: 'Max failures (default 10, max 100)' },
409
566
  queue: { type: 'string', description: 'Filter by queue name' }
410
567
  },
411
568
  required: nil,
@@ -429,7 +586,8 @@ module Woods
429
586
  name: 'console_job_schedule',
430
587
  description: 'List scheduled/upcoming jobs',
431
588
  properties: {
432
- limit: { type: 'integer', description: 'Max jobs (default 20, max 100)' }
589
+ limit: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 100,
590
+ description: 'Max jobs (default 20, max 100)' }
433
591
  },
434
592
  required: nil,
435
593
  tier: 3,
@@ -480,7 +638,8 @@ module Woods
480
638
  properties: {
481
639
  code: { type: 'string',
482
640
  description: 'Ruby code you propose to run (will be surfaced to the user first)' },
483
- timeout: { type: 'integer', description: 'Timeout in seconds (default 10, max 30)' }
641
+ timeout: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 30,
642
+ description: 'Timeout in seconds (default 10, max 30)' }
484
643
  },
485
644
  required: ['code'],
486
645
  tier: 4,
@@ -495,19 +654,28 @@ module Woods
495
654
  description: [
496
655
  'Execute read-only SQL against the live database (SELECT/WITH...SELECT only).',
497
656
  'SqlValidator blocks all DML/DDL. Every query runs inside a rolled-back transaction — no writes persist.',
498
- 'Requires embedded_read_tools: true in the rack middleware (see docs/CONSOLE_MCP_SETUP.md).',
657
+ 'Requires embedded_read_tools: true on the rack middleware, or ' \
658
+ 'config.console_embedded_read_tools = true for the stdio server ' \
659
+ '(see docs/CONSOLE_MCP_SETUP.md).',
499
660
  'Use console_query instead when you want ActiveRecord query builder rather than raw SQL.'
500
661
  ].join(' '),
501
662
  properties: {
502
- sql: { type: 'string', description: 'SQL query (SELECT or WITH...SELECT only)' },
503
- limit: { type: 'integer', description: 'Max rows returned (default unlimited, max 10000)' }
663
+ sql: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN,
664
+ description: 'SQL query (SELECT or WITH...SELECT only)' },
665
+ limit: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 10_000,
666
+ description: 'Max rows returned (default unlimited, max 10000)' }
504
667
  },
505
668
  required: ['sql'],
506
669
  tier: 4,
507
- handler: begin
508
- validator = SqlValidator.new
509
- ->(args) { Tools::Tier4.console_sql(sql: args[:sql], validator: validator, limit: args[:limit]) }
510
- end
670
+ # No handler-stage validator: SQL validation is the executor's, which
671
+ # alone knows the host adapter's dialect (CON-2). A `SqlValidator.new`
672
+ # here is the conservative postgres+mysql union, and it ran *first* —
673
+ # so on a MySQL host it rejected statements whose `\'`/backtick
674
+ # grammar produces a spuriously forbidden PostgreSQL view, and the
675
+ # dialect-aware validator at EmbeddedExecutor#handle_sql never got to
676
+ # accept them. No gate is lost: the executor re-validates and raises
677
+ # SqlValidationError, which DispatchPipeline renders as a tool error.
678
+ handler: ->(args) { Tools::Tier4.console_sql(sql: args[:sql], limit: args[:limit]) }
511
679
  ),
512
680
  ToolSpec.new(
513
681
  name: 'console_query',
@@ -516,24 +684,60 @@ module Woods
516
684
  'Example: {model: "Order", select: ["status", "COUNT(*) AS n"], group_by: ["status"]}.',
517
685
  'Use console_count or console_aggregate for simple aggregates without a custom SELECT.',
518
686
  'Use console_sql when you need raw SQL that the query builder cannot express.',
519
- 'Requires embedded_read_tools: true in the rack middleware (see docs/CONSOLE_MCP_SETUP.md).',
687
+ 'Requires embedded_read_tools: true on the rack middleware, or ' \
688
+ 'config.console_embedded_read_tools = true for the stdio server ' \
689
+ '(see docs/CONSOLE_MCP_SETUP.md).',
520
690
  'Max 10,000 rows returned. Returns columns + rows arrays like a SQL result set.'
521
691
  ].join(' '),
522
692
  properties: {
523
- model: { type: 'string', description: 'ActiveRecord model name (e.g. "Order")' },
524
- select: { type: 'array', items: { type: 'string' },
693
+ model: { type: 'string', pattern: NON_WHITESPACE_SCHEMA_PATTERN,
694
+ description: 'ActiveRecord model name (e.g. "Order")' },
695
+ select: { type: 'array', minItems: 1,
696
+ items: { type: 'string', pattern: SELECT_EXPRESSION_SCHEMA_PATTERN },
525
697
  description: 'Columns or expressions to select (e.g. ["status", "COUNT(*) AS n"])' },
526
- joins: { type: 'array', items: { type: 'string' },
698
+ joins: { type: 'array', items: { type: 'string', pattern: SAFE_IDENTIFIER_SCHEMA_PATTERN },
527
699
  description: 'Association names to JOIN (e.g. ["line_items", "user"])' },
528
- group_by: { type: 'array', items: { type: 'string' },
700
+ group_by: { type: 'array', items: { type: 'string', pattern: COLUMN_REFERENCE_SCHEMA_PATTERN },
529
701
  description: 'Columns to GROUP BY (e.g. ["status", "user_id"])' },
530
- having: { type: 'string',
531
- description: 'HAVING filter applied after GROUP BY (e.g. "COUNT(*) > 5")' },
702
+ having: { type: %w[object array],
703
+ oneOf: [
704
+ {
705
+ type: 'object', minProperties: 1,
706
+ propertyNames: { pattern: COLUMN_REFERENCE_SCHEMA_PATTERN }
707
+ },
708
+ {
709
+ type: 'array', minItems: 2, maxItems: 2,
710
+ # The bind is restricted to scalar JSON types the executor can
711
+ # actually pass to AR's `?` binding: a Hash/Array bind reaches
712
+ # the adapter and fails as a generic execution error instead of
713
+ # a typed validation error (mirrors the `scope:` bind constraint).
714
+ prefixItems: [
715
+ { type: 'string', pattern: HAVING_TEMPLATE_SCHEMA_PATTERN },
716
+ { type: %w[string number boolean null] }
717
+ ]
718
+ }
719
+ ],
720
+ description: 'HAVING condition object or parameterized [template, bind] array' },
532
721
  order: { type: 'object',
722
+ propertyNames: { pattern: COLUMN_REFERENCE_SCHEMA_PATTERN },
723
+ additionalProperties: { type: 'string', pattern: ORDER_DIRECTION_SCHEMA_PATTERN },
533
724
  description: 'Order specification as {column => direction} (e.g. {"created_at" => "desc"})' },
534
- scope: { type: 'object',
535
- description: 'WHERE conditions as {column => value} or [sql, bind] array' },
536
- limit: { type: 'integer', description: 'Maximum rows to return (default 10000, hard max 10000)' }
725
+ scope: { type: %w[object array],
726
+ oneOf: [
727
+ { type: 'object', propertyNames: { pattern: SCOPE_KEY_SCHEMA_PATTERN },
728
+ patternProperties: SCOPE_VALUE_PATTERN_PROPERTIES },
729
+ {
730
+ type: 'array', minItems: 2, maxItems: 2,
731
+ prefixItems: [
732
+ { type: 'string', pattern: QUERY_SCOPE_TEMPLATE_SCHEMA_PATTERN },
733
+ { type: %w[string number boolean null] }
734
+ ]
735
+ }
736
+ ],
737
+ description: 'WHERE conditions as {column => value} or exact ' \
738
+ '["column OP ?", bind] array' },
739
+ limit: { type: 'integer', minimum: MIN_INTEGER_INPUT, maximum: 10_000,
740
+ description: 'Maximum rows to return (default 10000, hard max 10000)' }
537
741
  },
538
742
  required: %w[model select],
539
743
  tier: 4,
@@ -545,8 +749,177 @@ module Woods
545
749
  )
546
750
  }
547
751
  )
548
- ].freeze
752
+ ].each do |spec|
753
+ spec.wrap_handler! if spec.tier == 1 || %w[console_sql console_query].include?(spec.name)
754
+ end.freeze
755
+
756
+ EXECUTABLE_MODES = {
757
+ embedded: TIER1_TOOLS.map { |name| "console_#{name}" }.freeze,
758
+ embedded_read: (TIER1_TOOLS + %w[sql query]).map { |name| "console_#{name}" }.freeze
759
+ }.freeze
760
+
761
+ REPRESENTATIVE_ARGUMENT_VALUES = {
762
+ model: 'Post', columns: ['id'], id: 1, function: 'count', association: 'comments',
763
+ key: 'feature_flag', value: 'enabled', user_id: 1, action: 'show',
764
+ attributes: { 'title' => 'Example' }, feature: 'preview', job_id: 'job-1',
765
+ code: '1 + 1', sql: 'SELECT 1', select: ['id']
766
+ }.freeze
767
+
768
+ REPRESENTATIVE_VALID_INPUT_OVERRIDES = {
769
+ 'console_sample' => { limit: 25, columns: %w[id title] },
770
+ 'console_find' => { id: 1, columns: %w[id title] },
771
+ 'console_pluck' => { columns: %w[id title] },
772
+ 'console_aggregate' => { function: 'sum', column: 'status' },
773
+ 'console_schema' => { include_indexes: true },
774
+ 'console_recent' => { direction: 'desc', limit: 3, columns: %w[id title] },
775
+ 'console_sql' => { sql: 'SELECT id, title FROM posts ORDER BY id' },
776
+ 'console_query' => { select: %w[id title], order: { 'id' => 'ASC' } }
777
+ }.transform_values(&:freeze).freeze
778
+
779
+ INVALID_ARGUMENT_VALUES = {
780
+ 'string' => [], 'integer' => 'not-an-integer', 'boolean' => 'not-a-boolean',
781
+ 'object' => [], 'array' => {}
782
+ }.freeze
783
+
784
+ SEMANTIC_OUTPUT_SHAPES = {
785
+ 'console_count' => { count: :integer },
786
+ 'console_sample' => { records: :array_of_records },
787
+ 'console_find' => { record: :record_or_null },
788
+ 'console_pluck' => { columns: :array_of_strings, values: :array },
789
+ 'console_aggregate' => { value: :number_or_null },
790
+ 'console_association_count' => { count: :integer },
791
+ 'console_schema' => { columns: :column_metadata_by_name, indexes: :optional_index_metadata },
792
+ 'console_recent' => { records: :array_of_records },
793
+ 'console_status' => { status: :string, models: :array_of_strings, adapter: :string },
794
+ 'console_diagnose_model' => { diagnostic: :count_recent_and_aggregates },
795
+ 'console_data_snapshot' => { record: :record, associations: :records_by_association },
796
+ 'console_validate_record' => { valid: :boolean, errors: :validation_errors },
797
+ 'console_check_setting' => { setting: :key_value },
798
+ 'console_update_setting' => { updated: :boolean, setting: :key_value },
799
+ 'console_check_policy' => { allowed: :boolean },
800
+ 'console_validate_with' => { valid: :boolean, errors: :validation_errors },
801
+ 'console_check_eligibility' => { eligible: :boolean },
802
+ 'console_decorate' => { record: :record, computed: :object },
803
+ 'console_slow_endpoints' => { endpoints: :performance_rows },
804
+ 'console_error_rates' => { error_rates: :rate_rows },
805
+ 'console_throughput' => { throughput: :time_series },
806
+ 'console_job_queues' => { queues: :queue_statistics },
807
+ 'console_job_failures' => { failures: :job_failure_rows },
808
+ 'console_job_find' => { job: :job_or_null, retried: :optional_boolean },
809
+ 'console_job_schedule' => { jobs: :scheduled_job_rows },
810
+ 'console_redis_info' => { info: :redis_section_map },
811
+ 'console_cache_stats' => { stats: :cache_statistics },
812
+ 'console_channel_status' => { channels: :channel_status_rows },
813
+ 'console_eval' => { value: :ruby_value },
814
+ 'console_sql' => { columns: :array_of_strings, rows: :array_of_arrays, count: :integer },
815
+ 'console_query' => { columns: :array_of_strings, rows: :array_of_arrays, count: :integer }
816
+ }.transform_values(&:freeze).freeze
817
+
818
+ TABLE_GATE_CONTRACTS = {
819
+ 'console_count' => :model_before_execution,
820
+ 'console_sample' => :model_before_execution,
821
+ 'console_find' => :model_before_execution,
822
+ 'console_pluck' => :model_before_execution,
823
+ 'console_aggregate' => :model_before_execution,
824
+ 'console_association_count' => :model_and_association_before_execution,
825
+ 'console_schema' => :model_before_execution,
826
+ 'console_recent' => :model_before_execution,
827
+ 'console_status' => :not_applicable_status_metadata,
828
+ 'console_sql' => :sql_before_execution,
829
+ 'console_query' => :model_joins_and_rendered_sql_before_execution
830
+ }.freeze
831
+
832
+ REDACTION_CONTRACTS = {
833
+ 'console_count' => :not_applicable_scalar_count,
834
+ 'console_sample' => :record_columns_and_eav,
835
+ 'console_find' => :record_columns_and_eav,
836
+ 'console_pluck' => :positional_columns_and_eav,
837
+ 'console_aggregate' => :not_applicable_scalar_aggregate,
838
+ 'console_association_count' => :not_applicable_scalar_count,
839
+ 'console_schema' => :not_applicable_schema_metadata,
840
+ 'console_recent' => :record_columns_and_eav,
841
+ 'console_status' => :not_applicable_status_metadata,
842
+ 'console_sql' => :positional_columns_and_eav,
843
+ 'console_query' => :positional_columns_and_eav
844
+ }.freeze
845
+
846
+ TRANSPORT_AUTHORIZATION = {
847
+ stdio: :host_process_access,
848
+ railtie_http: :bearer_token_required
849
+ }.freeze
850
+
851
+ CONFIRMATION_REQUIREMENTS = {
852
+ 'console_update_setting' => :required_before_registration,
853
+ 'console_job_find' => :required_for_retry_before_registration,
854
+ 'console_eval' => :required_before_registration
855
+ }.freeze
856
+
857
+ AUDIT_REQUIREMENTS = {
858
+ 'console_eval' => :required_before_registration
859
+ }.freeze
860
+
861
+ # Code-derived inventory of all 31 possible Console tools. Argument
862
+ # names, constraints, and representative schema cases come directly
863
+ # from TOOL_SPECS. Only output semantics and control behavior that cannot
864
+ # be inferred from JSON Schema are declared above.
865
+ # rubocop:disable-next Metrics/BlockLength
866
+ CONTRACT_MATRIX = TOOL_SPECS.map do |spec|
867
+ modes = EXECUTABLE_MODES.filter_map { |mode, names| mode if names.include?(spec.name) }.freeze
868
+ executable = modes.any?
869
+ required = Array(spec.required).freeze
870
+ optional = (spec.properties.keys.map(&:to_s) - required).freeze
871
+ required_input = required.to_h do |name|
872
+ [name.to_sym, REPRESENTATIVE_ARGUMENT_VALUES.fetch(name.to_sym)]
873
+ end
874
+ valid_input = required_input.merge(REPRESENTATIVE_VALID_INPUT_OVERRIDES.fetch(spec.name, {})).freeze
875
+
876
+ invalid_arguments = if required.any?
877
+ valid_input.reject { |name, _value| name == required.first.to_sym }.freeze
878
+ elsif spec.properties.any?
879
+ name, definition = spec.properties.first
880
+ { name => INVALID_ARGUMENT_VALUES.fetch(Array(definition.fetch(:type)).first) }.freeze
881
+ else
882
+ [].freeze
883
+ end
884
+ error_contract = if required.any?
885
+ {
886
+ stage: :sdk_required_arguments,
887
+ is_error: true,
888
+ text_prefix: "Missing required arguments: #{required.first}"
889
+ }.freeze
890
+ else
891
+ {
892
+ stage: :sdk_schema_validation,
893
+ is_error: true,
894
+ text_prefix: 'Invalid arguments:'
895
+ }.freeze
896
+ end
897
+
898
+ {
899
+ name: spec.name,
900
+ executable_modes: modes,
901
+ arguments: {
902
+ required: required,
903
+ optional: optional,
904
+ constraints: spec.input_schema
905
+ }.freeze,
906
+ representative_valid_input: valid_input,
907
+ representative_invalid_input: {
908
+ arguments: invalid_arguments,
909
+ error_contract: error_contract
910
+ }.freeze,
911
+ semantic_output: {
912
+ availability: executable ? :executable : :inventory_only,
913
+ shape: SEMANTIC_OUTPUT_SHAPES.fetch(spec.name)
914
+ }.freeze,
915
+ authorization: executable ? TRANSPORT_AUTHORIZATION : :not_applicable_unregistered,
916
+ table_gate: executable ? TABLE_GATE_CONTRACTS.fetch(spec.name) : :not_active_unregistered,
917
+ redaction: executable ? REDACTION_CONTRACTS.fetch(spec.name) : :not_active_unregistered,
918
+ credential_scan: executable ? :response_and_error_text_when_enabled : :not_active_unregistered,
919
+ confirmation: CONFIRMATION_REQUIREMENTS.fetch(spec.name, :not_required),
920
+ audit: AUDIT_REQUIREMENTS.fetch(spec.name, executable ? :not_recorded : :not_active_unregistered)
921
+ }.freeze
922
+ end.freeze
549
923
  end
550
924
  end
551
925
  end
552
- # rubocop:enable Metrics/ModuleLength