woods 1.2.0 → 1.3.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +169 -0
  3. data/README.md +20 -8
  4. data/exe/woods-console +51 -6
  5. data/exe/woods-console-mcp +24 -4
  6. data/exe/woods-mcp +30 -7
  7. data/exe/woods-mcp-http +47 -6
  8. data/lib/generators/woods/install_generator.rb +13 -4
  9. data/lib/generators/woods/templates/woods.rb.tt +155 -0
  10. data/lib/tasks/woods.rake +15 -50
  11. data/lib/woods/builder.rb +174 -9
  12. data/lib/woods/cache/cache_middleware.rb +360 -31
  13. data/lib/woods/chunking/semantic_chunker.rb +334 -7
  14. data/lib/woods/console/adapters/job_adapter.rb +10 -4
  15. data/lib/woods/console/audit_logger.rb +76 -4
  16. data/lib/woods/console/bridge.rb +48 -15
  17. data/lib/woods/console/bridge_protocol.rb +44 -0
  18. data/lib/woods/console/confirmation.rb +3 -4
  19. data/lib/woods/console/console_response_renderer.rb +56 -18
  20. data/lib/woods/console/credential_index.rb +201 -0
  21. data/lib/woods/console/credential_scanner.rb +302 -0
  22. data/lib/woods/console/dispatch_pipeline.rb +138 -0
  23. data/lib/woods/console/embedded_executor.rb +682 -35
  24. data/lib/woods/console/eval_guard.rb +319 -0
  25. data/lib/woods/console/model_validator.rb +1 -3
  26. data/lib/woods/console/rack_middleware.rb +185 -29
  27. data/lib/woods/console/redactor.rb +161 -0
  28. data/lib/woods/console/response_context.rb +127 -0
  29. data/lib/woods/console/safe_context.rb +220 -23
  30. data/lib/woods/console/scope_predicate_parser.rb +131 -0
  31. data/lib/woods/console/server.rb +417 -486
  32. data/lib/woods/console/sql_noise_stripper.rb +87 -0
  33. data/lib/woods/console/sql_table_scanner.rb +213 -0
  34. data/lib/woods/console/sql_validator.rb +81 -31
  35. data/lib/woods/console/table_gate.rb +93 -0
  36. data/lib/woods/console/tool_specs.rb +552 -0
  37. data/lib/woods/console/tools/tier1.rb +3 -3
  38. data/lib/woods/console/tools/tier4.rb +7 -1
  39. data/lib/woods/dependency_graph.rb +66 -7
  40. data/lib/woods/embedding/indexer.rb +190 -6
  41. data/lib/woods/embedding/openai.rb +40 -4
  42. data/lib/woods/embedding/provider.rb +104 -8
  43. data/lib/woods/embedding/text_preparer.rb +23 -3
  44. data/lib/woods/embedding/token_counter.rb +133 -0
  45. data/lib/woods/evaluation/baseline_runner.rb +20 -2
  46. data/lib/woods/evaluation/metrics.rb +4 -1
  47. data/lib/woods/extracted_unit.rb +1 -0
  48. data/lib/woods/extractor.rb +7 -1
  49. data/lib/woods/extractors/controller_extractor.rb +6 -0
  50. data/lib/woods/extractors/mailer_extractor.rb +16 -2
  51. data/lib/woods/extractors/model_extractor.rb +6 -1
  52. data/lib/woods/extractors/phlex_extractor.rb +13 -4
  53. data/lib/woods/extractors/rails_source_extractor.rb +2 -0
  54. data/lib/woods/extractors/route_helper_resolver.rb +130 -0
  55. data/lib/woods/extractors/shared_dependency_scanner.rb +130 -2
  56. data/lib/woods/extractors/view_component_extractor.rb +12 -1
  57. data/lib/woods/extractors/view_engines/base.rb +141 -0
  58. data/lib/woods/extractors/view_engines/erb.rb +145 -0
  59. data/lib/woods/extractors/view_template_extractor.rb +92 -133
  60. data/lib/woods/flow_assembler.rb +23 -15
  61. data/lib/woods/flow_precomputer.rb +21 -2
  62. data/lib/woods/graph_analyzer.rb +3 -4
  63. data/lib/woods/index_artifact.rb +173 -0
  64. data/lib/woods/mcp/bearer_auth.rb +45 -0
  65. data/lib/woods/mcp/bootstrap_state.rb +94 -0
  66. data/lib/woods/mcp/bootstrapper.rb +337 -16
  67. data/lib/woods/mcp/config_resolver.rb +288 -0
  68. data/lib/woods/mcp/errors.rb +134 -0
  69. data/lib/woods/mcp/index_reader.rb +265 -30
  70. data/lib/woods/mcp/origin_guard.rb +132 -0
  71. data/lib/woods/mcp/provider_probe.rb +166 -0
  72. data/lib/woods/mcp/renderers/claude_renderer.rb +6 -0
  73. data/lib/woods/mcp/renderers/markdown_renderer.rb +39 -3
  74. data/lib/woods/mcp/renderers/plain_renderer.rb +16 -2
  75. data/lib/woods/mcp/server.rb +737 -137
  76. data/lib/woods/model_name_cache.rb +78 -2
  77. data/lib/woods/notion/client.rb +25 -2
  78. data/lib/woods/notion/mappers/model_mapper.rb +36 -2
  79. data/lib/woods/railtie.rb +55 -15
  80. data/lib/woods/resilience/circuit_breaker.rb +9 -2
  81. data/lib/woods/resilience/retryable_provider.rb +40 -3
  82. data/lib/woods/resolved_config.rb +299 -0
  83. data/lib/woods/retrieval/context_assembler.rb +112 -5
  84. data/lib/woods/retrieval/query_classifier.rb +1 -1
  85. data/lib/woods/retrieval/ranker.rb +55 -6
  86. data/lib/woods/retrieval/search_executor.rb +42 -13
  87. data/lib/woods/retriever.rb +330 -24
  88. data/lib/woods/session_tracer/middleware.rb +35 -1
  89. data/lib/woods/storage/graph_store.rb +39 -0
  90. data/lib/woods/storage/inapplicable_backend.rb +14 -0
  91. data/lib/woods/storage/metadata_store.rb +129 -1
  92. data/lib/woods/storage/pgvector.rb +70 -8
  93. data/lib/woods/storage/qdrant.rb +196 -5
  94. data/lib/woods/storage/snapshotter/metadata.rb +172 -0
  95. data/lib/woods/storage/snapshotter/vector.rb +238 -0
  96. data/lib/woods/storage/snapshotter.rb +24 -0
  97. data/lib/woods/storage/vector_store.rb +184 -35
  98. data/lib/woods/tasks.rb +85 -0
  99. data/lib/woods/temporal/snapshot_store.rb +49 -1
  100. data/lib/woods/token_utils.rb +44 -5
  101. data/lib/woods/unblocked/client.rb +1 -1
  102. data/lib/woods/unblocked/document_builder.rb +35 -10
  103. data/lib/woods/unblocked/exporter.rb +1 -1
  104. data/lib/woods/util/host_guard.rb +61 -0
  105. data/lib/woods/version.rb +1 -1
  106. data/lib/woods.rb +126 -6
  107. metadata +69 -4
@@ -12,13 +12,21 @@ require_relative 'sql_validator'
12
12
  require_relative 'audit_logger'
13
13
  require_relative 'confirmation'
14
14
  require_relative 'console_response_renderer'
15
+ require_relative 'credential_scanner'
16
+ require_relative 'eval_guard'
17
+ require_relative 'table_gate'
18
+ require_relative 'redactor'
19
+ require_relative 'response_context'
20
+ require_relative 'dispatch_pipeline'
21
+ require_relative 'tool_specs'
15
22
 
16
23
  module Woods
17
24
  module Console
18
25
  # Console MCP Server — queries live Rails application state.
19
26
  #
20
27
  # Communicates with a bridge process running inside the Rails environment
21
- # via JSON-lines over stdio. Exposes Tier 1-4 tools (read-only, domain, analytics, guarded) through MCP.
28
+ # via JSON-lines over stdio. Exposes 31 tools across 4 tiers
29
+ # (9 read-only / 9 domain-aware / 10 analytics / 3 guarded) through MCP.
22
30
  #
23
31
  # @example
24
32
  # server = Woods::Console::Server.build(config: config)
@@ -26,25 +34,126 @@ module Woods
26
34
  # transport.open
27
35
  #
28
36
  module Server # rubocop:disable Metrics/ModuleLength
29
- TIER1_TOOLS = %w[count sample find pluck aggregate association_count schema recent status].freeze
30
- TIER2_TOOLS = %w[diagnose_model data_snapshot validate_record check_setting update_setting
31
- check_policy validate_with check_eligibility decorate].freeze
32
- TIER3_TOOLS = %w[slow_endpoints error_rates throughput job_queues job_failures job_find
33
- job_schedule redis_info cache_stats channel_status].freeze
34
- TIER4_TOOLS = %w[eval sql query].freeze
35
-
36
37
  class << self # rubocop:disable Metrics/ClassLength
38
+ # Rebuild the boot-time credential index from fresh Rails credentials
39
+ # and hot-swap it into the active scanner without restarting the process.
40
+ #
41
+ # Host rotation jobs should call this immediately after `rails credentials:edit`
42
+ # changes are deployed. The swap is atomic on MRI (GVL) — in-flight scans see
43
+ # either the old or the new index, never a partial one.
44
+ #
45
+ # Returns nil when:
46
+ # - `console_credential_defense_enabled` is false
47
+ # - No server has been built yet in this process (`build` / `build_embedded`
48
+ # have not been called)
49
+ #
50
+ # Existing callers of `build` / `build_embedded` are unaffected — this is an
51
+ # additive class method with no required arguments beyond `rails_app`.
52
+ #
53
+ # @param rails_app [#credentials] The Rails application to re-read.
54
+ # Defaults to `Rails.application` when `Rails` is defined, otherwise
55
+ # the caller must supply it explicitly.
56
+ # @return [CredentialIndex, nil] The newly built index, or nil when
57
+ # the rebuild was skipped.
58
+ def rebuild_credential_index(rails_app: nil)
59
+ return nil unless credential_defense_enabled?
60
+ return nil unless @active_scanner
61
+
62
+ target_app = rails_app || default_rails_app
63
+ return nil unless target_app
64
+
65
+ new_index = CredentialIndex.build(rails_app: target_app)
66
+ @active_scanner.replace_index!(new_index)
67
+ new_index
68
+ end
69
+
70
+ # True when Woods is configured and credential defense is on.
71
+ def credential_defense_enabled?
72
+ config = Woods.configuration if Woods.respond_to?(:configuration)
73
+ config&.console_credential_defense_enabled ? true : false
74
+ end
75
+
76
+ # True when the caller has opted into the unsafe `console_eval`
77
+ # scaffolding via `WOODS_CONSOLE_UNSAFE_EVAL=true` or an explicit
78
+ # `config.console_unsafe_eval_enabled = true`. Explicit config wins
79
+ # over the env var in both directions.
80
+ #
81
+ # NOTE: returning true here does NOT enable eval execution. The
82
+ # execution path is deliberately unimplemented (backlog
83
+ # unsafe-eval-opt-in). This predicate only governs the boot-time
84
+ # banner and the production-environment refusal below.
85
+ #
86
+ # @return [Boolean]
87
+ def unsafe_eval_enabled?
88
+ config = Woods.configuration if Woods.respond_to?(:configuration)
89
+ explicit = config&.console_unsafe_eval_enabled
90
+ return explicit if [true, false].include?(explicit)
91
+
92
+ ENV['WOODS_CONSOLE_UNSAFE_EVAL'] == 'true'
93
+ end
94
+
95
+ # Enforce the `console_eval` opt-in safety contract at boot.
96
+ #
97
+ # When `WOODS_CONSOLE_UNSAFE_EVAL` is on:
98
+ # - refuse outright in `Rails.env.production?` (non-negotiable),
99
+ # - otherwise emit a LOUD stderr banner so operators know the flag
100
+ # is live even though eval remains unimplemented.
101
+ #
102
+ # Safe when Rails is not loaded (specs, non-Rails hosts).
103
+ #
104
+ # @return [void]
105
+ # @raise [Woods::ConfigurationError] when the flag is on in production.
106
+ def enforce_unsafe_eval_contract!
107
+ return unless unsafe_eval_enabled?
108
+
109
+ if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:production?) &&
110
+ Rails.env.production?
111
+ raise Woods::ConfigurationError,
112
+ 'WOODS_CONSOLE_UNSAFE_EVAL is set but Rails.env.production? is true. ' \
113
+ 'console_eval cannot be opted into in production. Unset the flag or ' \
114
+ 'restart in a non-production environment.'
115
+ end
116
+
117
+ warn unsafe_eval_banner
118
+ end
119
+
120
+ # Resolves `Rails.application` when available, else nil.
121
+ def default_rails_app
122
+ return nil unless defined?(Rails) && Rails.respond_to?(:application)
123
+
124
+ Rails.application
125
+ end
126
+
37
127
  # Build a configured MCP::Server with console tools using the bridge protocol.
38
128
  #
129
+ # ⚠ Layer 1 limitation in bridge mode:
130
+ # The server side of the bridge has no access to the remote app's
131
+ # `ActiveRecord::Base.descendants`, so model_tables and model_reflections
132
+ # are empty. `TableGate#check_sql!` still fires against the raw SQL
133
+ # argument of `console_sql`, but `check_model!`, `check_joins!`, and
134
+ # `check_association!` are effectively no-ops for tools that receive a
135
+ # model name rather than SQL (find, sample, count, etc.). A bridge-mode
136
+ # deployment therefore relies on Layer 2 (credential scanning) + Layer 3
137
+ # (column/EAV redaction) + Layer 4 (SqlValidator + SafeContext rollback)
138
+ # for non-SQL tool calls. If you need full Layer 1 coverage, use
139
+ # `build_embedded` (the stdio entry point `exe/woods-console` does this).
140
+ #
141
+ # See docs/CONSOLE_MCP_SETUP.md "Bridge vs. embedded defense coverage"
142
+ # for the full matrix.
143
+ #
39
144
  # @param config [Hash] Configuration hash (from YAML or env)
40
145
  # @return [MCP::Server] Configured server ready for transport
41
146
  def build(config:)
42
147
  connection_config = config['console'] || config
43
148
  conn_mgr = ConnectionManager.new(config: connection_config)
44
149
  redacted_columns = Array(config['redacted_columns'] || connection_config['redacted_columns'])
45
- safe_ctx = redacted_columns.any? ? SafeContext.new(connection: nil, redacted_columns: redacted_columns) : nil
150
+ redacted_key_values = Array(
151
+ config['redacted_key_values'] || connection_config['redacted_key_values']
152
+ )
153
+ safe_ctx = build_safe_context(redacted_columns, redacted_key_values)
154
+ ctx = build_response_context(safe_ctx: safe_ctx, model_tables: {}, model_reflections: {})
46
155
 
47
- build_server(conn_mgr, safe_ctx)
156
+ build_server(conn_mgr, ctx)
48
157
  end
49
158
 
50
159
  # Build a configured MCP::Server using embedded ActiveRecord execution.
@@ -55,557 +164,379 @@ module Woods
55
164
  # @param model_validator [ModelValidator] Validates model/column names
56
165
  # @param safe_context [SafeContext] Wraps queries in rolled-back transactions
57
166
  # @param redacted_columns [Array<String>] Column names to redact from output
167
+ # @param redacted_key_values [Array<Hash>] EAV redaction patterns. Each pattern:
168
+ # {key_column:, value_column:, sensitive_keys: []}. See SafeContext for semantics.
58
169
  # @param connection [Object, nil] Database connection for adapter detection
59
170
  # @param read_tools_enabled [Boolean] Enable sql/query tools in embedded mode (default: false)
171
+ # @param unsafe_eval_confirmation [Confirmation, nil] Approval callback for
172
+ # `console_eval`. Required when the opt-in is on; the server refuses to
173
+ # boot without it. Ignored when the opt-in is off.
174
+ # @param unsafe_eval_audit_log_path [String, Pathname, nil] JSONL audit log
175
+ # path for `console_eval`. Required when the opt-in is on.
60
176
  # @return [MCP::Server] Configured server ready for transport
61
- def build_embedded(model_validator:, safe_context:, redacted_columns: [], connection: nil,
62
- read_tools_enabled: false)
177
+ def build_embedded(model_validator:, safe_context:, redacted_columns: [], # rubocop:disable Metrics/ParameterLists
178
+ redacted_key_values: [], connection: nil,
179
+ read_tools_enabled: false, model_tables: {},
180
+ model_reflections: {},
181
+ unsafe_eval_confirmation: nil,
182
+ unsafe_eval_audit_log_path: nil)
63
183
  require_relative 'embedded_executor'
184
+ enforce_unsafe_eval_contract!
185
+
186
+ safe_ctx = build_safe_context(redacted_columns, redacted_key_values)
187
+ ctx = build_response_context(safe_ctx: safe_ctx, model_tables: model_tables,
188
+ model_reflections: model_reflections)
64
189
 
190
+ eval_wiring = build_unsafe_eval_wiring(
191
+ confirmation: unsafe_eval_confirmation,
192
+ audit_log_path: unsafe_eval_audit_log_path
193
+ )
194
+
195
+ # Wire the same TableGate into the executor so sql/query are blocked
196
+ # PRE-execution against console_blocked_tables (previously TableGate
197
+ # was only consulted on the render path, leaving the defense inert
198
+ # for the sql and query tools).
199
+ table_gate = ctx&.table_gate
65
200
  executor = EmbeddedExecutor.new(
66
201
  model_validator: model_validator, safe_context: safe_context,
67
- connection: connection, read_tools_enabled: read_tools_enabled
202
+ connection: connection, read_tools_enabled: read_tools_enabled,
203
+ table_gate: table_gate,
204
+ eval_guard: eval_wiring[:eval_guard],
205
+ confirmation: eval_wiring[:confirmation],
206
+ audit_logger: eval_wiring[:audit_logger],
207
+ unsafe_eval_enabled: eval_wiring[:unsafe_eval_enabled]
68
208
  )
69
- redact_ctx = if redacted_columns.any?
70
- SafeContext.new(connection: nil,
71
- redacted_columns: redacted_columns)
72
- end
73
209
 
74
- build_server(executor, redact_ctx)
210
+ build_server(executor, ctx)
211
+ end
212
+
213
+ # Resolve the three-collaborator eval wiring for the current config.
214
+ #
215
+ # When `unsafe_eval_enabled?` is false (default), returns nil for all
216
+ # three collaborators — the executor keeps its hard refusal and
217
+ # EvalGuard is never reached.
218
+ #
219
+ # When it's true, BOTH a {Confirmation} and an audit-log path MUST be
220
+ # provided (via kwargs or config); otherwise we raise so a
221
+ # misconfigured host fails at boot instead of silently running Ruby
222
+ # without approval or audit. See backlog B-053.
223
+ #
224
+ # @param confirmation [Confirmation, nil] Explicit kwarg wins over
225
+ # `config.console_unsafe_eval_confirmation`.
226
+ # @param audit_log_path [String, Pathname, nil] Explicit kwarg wins
227
+ # over `config.console_unsafe_eval_audit_log_path`.
228
+ # @return [Hash] { eval_guard:, confirmation:, audit_logger:, unsafe_eval_enabled: }
229
+ # @raise [Woods::ConfigurationError] when opt-in is on but either
230
+ # collaborator is missing.
231
+ def build_unsafe_eval_wiring(confirmation:, audit_log_path:)
232
+ return empty_unsafe_eval_wiring unless unsafe_eval_enabled?
233
+
234
+ config = Woods.configuration if Woods.respond_to?(:configuration)
235
+ confirmation ||= config&.console_unsafe_eval_confirmation
236
+ audit_log_path ||= config&.console_unsafe_eval_audit_log_path
237
+ require_unsafe_eval_collaborators!(confirmation, audit_log_path)
238
+
239
+ {
240
+ eval_guard: EvalGuard.new,
241
+ confirmation: confirmation,
242
+ audit_logger: AuditLogger.new(path: audit_log_path.to_s),
243
+ unsafe_eval_enabled: true
244
+ }
245
+ end
246
+
247
+ def empty_unsafe_eval_wiring
248
+ { eval_guard: nil, confirmation: nil, audit_logger: nil, unsafe_eval_enabled: false }
249
+ end
250
+
251
+ def require_unsafe_eval_collaborators!(confirmation, audit_log_path)
252
+ if confirmation.nil?
253
+ raise Woods::ConfigurationError,
254
+ 'WOODS_CONSOLE_UNSAFE_EVAL is set but no Confirmation was provided. ' \
255
+ 'Pass `unsafe_eval_confirmation:` to Server.build_embedded / RackMiddleware ' \
256
+ 'or set `config.console_unsafe_eval_confirmation`. Fail-closed by design — ' \
257
+ 'see backlog B-053 / docs/CONSOLE_MCP_SETUP.md.'
258
+ end
259
+ return unless audit_log_path.nil? || audit_log_path.to_s.strip.empty?
260
+
261
+ raise Woods::ConfigurationError,
262
+ 'WOODS_CONSOLE_UNSAFE_EVAL is set but no audit-log path was provided. ' \
263
+ 'Pass `unsafe_eval_audit_log_path:` to Server.build_embedded / RackMiddleware ' \
264
+ 'or set `config.console_unsafe_eval_audit_log_path`. Every console_eval run ' \
265
+ 'must be audited.'
266
+ end
267
+
268
+ # Register all tool specs for a given tier on the server.
269
+ #
270
+ # @param server [MCP::Server] The MCP server instance
271
+ # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
272
+ # @param ctx [ResponseContext, nil] Optional context bundling response-safety layers
273
+ # @param tier [Integer] Tier number (1-4)
274
+ # @param renderer [ConsoleResponseRenderer, nil] Optional response renderer
275
+ # @return [void]
276
+ def register_tier_tools(server, conn_mgr, ctx, tier:, renderer: nil)
277
+ TOOL_SPECS.select { |spec| spec.tier == tier }.each do |spec|
278
+ register(spec, server, conn_mgr, ctx, renderer: renderer)
279
+ end
75
280
  end
76
281
 
77
282
  # Register Tier 1 read-only tools on the server.
78
283
  #
79
284
  # @param server [MCP::Server] The MCP server instance
80
285
  # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
81
- # @param safe_ctx [SafeContext, nil] Optional context for column redaction
286
+ # @param ctx [ResponseContext, nil] Optional context for column redaction
82
287
  # @return [void]
83
- def register_tier1_tools(server, conn_mgr, safe_ctx = nil, renderer: nil)
84
- TIER1_TOOLS.each { |tool| send(:"define_#{tool}", server, conn_mgr, safe_ctx, renderer: renderer) }
288
+ def register_tier1_tools(server, conn_mgr, ctx = nil, renderer: nil)
289
+ register_tier_tools(server, conn_mgr, ctx, tier: 1, renderer: renderer)
85
290
  end
86
291
 
87
292
  # Register Tier 2 domain-aware tools on the server.
88
293
  #
89
294
  # @param server [MCP::Server] The MCP server instance
90
295
  # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
91
- # @param safe_ctx [SafeContext, nil] Optional context for column redaction
296
+ # @param ctx [ResponseContext, nil] Optional context for column redaction
92
297
  # @return [void]
93
- def register_tier2_tools(server, conn_mgr, safe_ctx = nil, renderer: nil)
94
- TIER2_TOOLS.each { |tool| send(:"define_#{tool}", server, conn_mgr, safe_ctx, renderer: renderer) }
298
+ def register_tier2_tools(server, conn_mgr, ctx = nil, renderer: nil)
299
+ register_tier_tools(server, conn_mgr, ctx, tier: 2, renderer: renderer)
95
300
  end
96
301
 
97
302
  # Register Tier 3 analytics tools on the server.
98
303
  #
99
304
  # @param server [MCP::Server] The MCP server instance
100
305
  # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
101
- # @param safe_ctx [SafeContext, nil] Optional context for column redaction
306
+ # @param ctx [ResponseContext, nil] Optional context for column redaction
102
307
  # @return [void]
103
- def register_tier3_tools(server, conn_mgr, safe_ctx = nil, renderer: nil)
104
- TIER3_TOOLS.each { |tool| send(:"define_#{tool}", server, conn_mgr, safe_ctx, renderer: renderer) }
308
+ def register_tier3_tools(server, conn_mgr, ctx = nil, renderer: nil)
309
+ register_tier_tools(server, conn_mgr, ctx, tier: 3, renderer: renderer)
105
310
  end
106
311
 
107
312
  # Register Tier 4 guarded tools on the server.
108
313
  #
109
314
  # @param server [MCP::Server] The MCP server instance
110
315
  # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
111
- # @param safe_ctx [SafeContext, nil] Optional context for column redaction
316
+ # @param ctx [ResponseContext, nil] Optional context for column redaction
112
317
  # @return [void]
113
- def register_tier4_tools(server, conn_mgr, safe_ctx = nil, renderer: nil)
114
- TIER4_TOOLS.each { |tool| send(:"define_#{tool}", server, conn_mgr, safe_ctx, renderer: renderer) }
318
+ def register_tier4_tools(server, conn_mgr, ctx = nil, renderer: nil)
319
+ register_tier_tools(server, conn_mgr, ctx, tier: 4, renderer: renderer)
115
320
  end
116
321
 
117
322
  private
118
323
 
119
- # Shared server construction used by both build() and build_embedded().
324
+ # Register a single ToolSpec on the MCP server.
120
325
  #
121
- # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Any object with send_request(Hash) -> Hash
122
- # @param safe_ctx [SafeContext, nil] Optional context for column redaction
123
- # @return [MCP::Server]
124
- def build_server(conn_mgr, safe_ctx)
125
- server = ::MCP::Server.new(
126
- name: 'woods-console',
127
- version: defined?(Woods::VERSION) ? Woods::VERSION : '0.1.0'
326
+ # Hands every tool a dedicated {DispatchPipeline} that owns the full
327
+ # args gate bridge redact scan → respond flow. The
328
+ # `define_tool` block stays a one-liner that delegates to the pipeline.
329
+ #
330
+ # @param spec [ToolSpec] The tool specification
331
+ # @param server [MCP::Server] The MCP server instance
332
+ # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
333
+ # @param ctx [ResponseContext, nil] Response context (table gate, scanner, safe_ctx)
334
+ # @param renderer [ConsoleResponseRenderer, nil] Optional response renderer
335
+ # @return [void]
336
+ def register(spec, server, conn_mgr, ctx, renderer: nil)
337
+ pipeline = DispatchPipeline.new(
338
+ tool_name: spec.name,
339
+ handler: spec.handler,
340
+ integer_keys: integer_property_keys(spec.properties),
341
+ conn_mgr: conn_mgr,
342
+ ctx: ctx || NullResponseContext.instance,
343
+ renderer: renderer,
344
+ logger: structured_logger
128
345
  )
129
346
 
130
- renderer = build_console_renderer
131
-
132
- register_tier1_tools(server, conn_mgr, safe_ctx, renderer: renderer)
133
- register_tier2_tools(server, conn_mgr, safe_ctx, renderer: renderer)
134
- register_tier3_tools(server, conn_mgr, safe_ctx, renderer: renderer)
135
- register_tier4_tools(server, conn_mgr, safe_ctx, renderer: renderer)
136
- server
137
- end
138
-
139
- def respond(text)
140
- ::MCP::Tool::Response.new([{ type: 'text', text: text }])
141
- end
142
-
143
- def send_to_bridge(conn_mgr, request, safe_ctx = nil, renderer: nil)
144
- response = conn_mgr.send_request(request)
145
- if response['ok']
146
- result = response['result']
147
- result = apply_redaction(result, safe_ctx) if safe_ctx
148
- text = renderer ? renderer.render_default(result) : JSON.pretty_generate(result)
149
- respond(text)
150
- else
151
- error_text = "#{response['error_type']}: #{response['error']}"
152
- ::MCP::Tool::Response.new(
153
- [{ type: 'text', text: error_text }],
154
- error: error_text
155
- )
156
- end
157
- rescue ConnectionError => e
158
- ::MCP::Tool::Response.new([{ type: 'text', text: "Connection error: #{e.message}" }], error: e.message)
159
- end
160
-
161
- # Apply SafeContext column redaction to a result value.
162
- #
163
- # Handles Hash (single record) and Array<Hash> (multiple records).
164
- # Non-Hash values are returned unchanged.
165
- #
166
- # @param result [Object] The result from the bridge
167
- # @param safe_ctx [SafeContext] The context with redacted_columns configured
168
- # @return [Object] Redacted result
169
- def apply_redaction(result, safe_ctx)
170
- case result
171
- when Array
172
- result.map { |item| item.is_a?(Hash) ? safe_ctx.redact(item) : item }
173
- when Hash
174
- safe_ctx.redact(result)
175
- else
176
- result
177
- end
178
- end
179
-
180
- def build_console_renderer
181
- format = if Woods.respond_to?(:configuration)
182
- Woods.configuration&.context_format || :markdown
183
- else
184
- :markdown
185
- end
186
- format == :json ? JsonConsoleRenderer.new : ConsoleResponseRenderer.new
187
- end
188
-
189
- def define_count(server, conn_mgr, safe_ctx = nil, renderer: nil)
190
- define_console_tool(server, conn_mgr, 'console_count', 'Count records matching scope conditions',
191
- properties: { model: str_prop('Model name'), scope: obj_prop('Filter conditions') },
192
- required: ['model'], safe_ctx: safe_ctx, renderer: renderer) do |args|
193
- Tools::Tier1.console_count(model: args[:model], scope: args[:scope])
194
- end
195
- end
196
-
197
- def define_sample(server, conn_mgr, safe_ctx = nil, renderer: nil)
198
- define_console_tool(server, conn_mgr, 'console_sample', 'Random sample of records',
199
- properties: {
200
- model: str_prop('Model name'), limit: int_prop('Max records (default 5, max 25)'),
201
- columns: arr_prop('Columns to include'), scope: obj_prop('Filter conditions')
202
- }, required: ['model'], safe_ctx: safe_ctx, renderer: renderer) do |args|
203
- Tools::Tier1.console_sample(
204
- model: args[:model], scope: args[:scope], limit: args[:limit] || 5, columns: args[:columns]
205
- )
206
- end
207
- end
208
-
209
- def define_find(server, conn_mgr, safe_ctx = nil, renderer: nil)
210
- define_console_tool(server, conn_mgr, 'console_find',
211
- 'Find a single record by primary key or unique column',
212
- properties: {
213
- model: str_prop('Model name'), id: int_prop('Primary key value'),
214
- by: obj_prop('Unique column lookup'),
215
- columns: arr_prop('Columns to include')
216
- }, required: ['model'], safe_ctx: safe_ctx, renderer: renderer) do |args|
217
- Tools::Tier1.console_find(model: args[:model], id: args[:id], by: args[:by], columns: args[:columns])
218
- end
219
- end
220
-
221
- def define_pluck(server, conn_mgr, safe_ctx = nil, renderer: nil)
222
- define_console_tool(server, conn_mgr, 'console_pluck', 'Extract column values from records',
223
- properties: {
224
- model: str_prop('Model name'), columns: arr_prop('Column names to pluck'),
225
- scope: obj_prop('Filter conditions'),
226
- limit: int_prop('Max records (default 100, max 1000)'),
227
- distinct: bool_prop('Return unique values only')
228
- }, required: %w[model columns], safe_ctx: safe_ctx, renderer: renderer) do |args|
229
- Tools::Tier1.console_pluck(
230
- model: args[:model], columns: args[:columns], scope: args[:scope],
231
- limit: args[:limit] || 100, distinct: args[:distinct] || false
232
- )
233
- end
234
- end
235
-
236
- def define_aggregate(server, conn_mgr, safe_ctx = nil, renderer: nil)
237
- define_console_tool(server, conn_mgr, 'console_aggregate',
238
- 'Run aggregate function (sum/avg/min/max) on a column',
239
- properties: {
240
- model: str_prop('Model name'),
241
- function: str_prop('Aggregate function: sum, avg, minimum, maximum'),
242
- column: str_prop('Column to aggregate'), scope: obj_prop('Filter conditions')
243
- }, required: %w[model function column], safe_ctx: safe_ctx, renderer: renderer) do |args|
244
- Tools::Tier1.console_aggregate(
245
- model: args[:model], function: args[:function], column: args[:column], scope: args[:scope]
246
- )
247
- end
248
- end
249
-
250
- def define_association_count(server, conn_mgr, safe_ctx = nil, renderer: nil)
251
- define_console_tool(server, conn_mgr, 'console_association_count',
252
- 'Count associated records for a specific record',
253
- properties: {
254
- model: str_prop('Model name'), id: int_prop('Record primary key'),
255
- association: str_prop('Association name'),
256
- scope: obj_prop('Filter on association')
257
- }, required: %w[model id association], safe_ctx: safe_ctx, renderer: renderer) do |args|
258
- Tools::Tier1.console_association_count(
259
- model: args[:model], id: args[:id], association: args[:association], scope: args[:scope]
260
- )
347
+ server.define_tool(name: spec.name, description: spec.description,
348
+ input_schema: spec_schema(spec)) do |server_context:, **args|
349
+ pipeline.call(args)
261
350
  end
262
351
  end
263
352
 
264
- def define_schema(server, conn_mgr, safe_ctx = nil, renderer: nil)
265
- define_console_tool(server, conn_mgr, 'console_schema', 'Get database schema for a model',
266
- properties: {
267
- model: str_prop('Model name'),
268
- include_indexes: bool_prop('Include index information')
269
- }, required: ['model'], safe_ctx: safe_ctx, renderer: renderer) do |args|
270
- Tools::Tier1.console_schema(model: args[:model], include_indexes: args[:include_indexes] || false)
271
- end
272
- end
273
-
274
- def define_recent(server, conn_mgr, safe_ctx = nil, renderer: nil)
275
- define_console_tool(server, conn_mgr, 'console_recent', 'Recently created/updated records',
276
- properties: {
277
- model: str_prop('Model name'),
278
- order_by: str_prop('Column to sort by (default: created_at)'),
279
- direction: str_prop('Sort direction: asc or desc (default: desc)'),
280
- limit: int_prop('Max records (default 10, max 50)'),
281
- scope: obj_prop('Filter conditions'), columns: arr_prop('Columns to include')
282
- }, required: ['model'], safe_ctx: safe_ctx, renderer: renderer) do |args|
283
- Tools::Tier1.console_recent(
284
- model: args[:model], order_by: args[:order_by] || 'created_at',
285
- direction: args[:direction] || 'desc', limit: args[:limit] || 10,
286
- scope: args[:scope], columns: args[:columns]
287
- )
288
- end
289
- end
290
-
291
- def define_status(server, conn_mgr, safe_ctx = nil, renderer: nil)
292
- define_console_tool(server, conn_mgr, 'console_status',
293
- 'System health check - list models and connection status',
294
- properties: {}, safe_ctx: safe_ctx, renderer: renderer) do |_args|
295
- Tools::Tier1.console_status
296
- end
297
- end
298
-
299
- # ── Tier 2 tool definitions ──────────────────────────────────────────
300
-
301
- def define_diagnose_model(server, conn_mgr, safe_ctx = nil, renderer: nil)
302
- define_console_tool(server, conn_mgr, 'console_diagnose_model',
303
- 'Diagnose a model: count, recent records, aggregates',
304
- properties: {
305
- model: str_prop('Model name'), scope: obj_prop('Filter conditions'),
306
- sample_size: int_prop('Sample records (default 5, max 25)')
307
- }, required: ['model'], safe_ctx: safe_ctx, renderer: renderer) do |args|
308
- Tools::Tier2.console_diagnose_model(
309
- model: args[:model], scope: args[:scope], sample_size: args[:sample_size] || 5
310
- )
311
- end
312
- end
313
-
314
- def define_data_snapshot(server, conn_mgr, safe_ctx = nil, renderer: nil)
315
- define_console_tool(server, conn_mgr, 'console_data_snapshot',
316
- 'Snapshot a record with associations for debugging',
317
- properties: {
318
- model: str_prop('Model name'), id: int_prop('Record primary key'),
319
- associations: arr_prop('Association names to include'),
320
- depth: int_prop('Association depth (default 1, max 3)')
321
- }, required: %w[model id], safe_ctx: safe_ctx, renderer: renderer) do |args|
322
- Tools::Tier2.console_data_snapshot(
323
- model: args[:model], id: args[:id],
324
- associations: args[:associations], depth: args[:depth] || 1
325
- )
326
- end
327
- end
328
-
329
- def define_validate_record(server, conn_mgr, safe_ctx = nil, renderer: nil)
330
- define_console_tool(server, conn_mgr, 'console_validate_record',
331
- 'Run validations on an existing record',
332
- properties: {
333
- model: str_prop('Model name'), id: int_prop('Record primary key'),
334
- attributes: obj_prop('Attributes to set before validating')
335
- }, required: %w[model id], safe_ctx: safe_ctx, renderer: renderer) do |args|
336
- Tools::Tier2.console_validate_record(
337
- model: args[:model], id: args[:id], attributes: args[:attributes]
338
- )
339
- end
340
- end
341
-
342
- def define_check_setting(server, conn_mgr, safe_ctx = nil, renderer: nil)
343
- define_console_tool(server, conn_mgr, 'console_check_setting',
344
- 'Check a configuration setting value',
345
- properties: {
346
- key: str_prop('Setting key'), namespace: str_prop('Setting namespace')
347
- }, required: ['key'], safe_ctx: safe_ctx, renderer: renderer) do |args|
348
- Tools::Tier2.console_check_setting(key: args[:key], namespace: args[:namespace])
349
- end
350
- end
351
-
352
- def define_update_setting(server, conn_mgr, safe_ctx = nil, renderer: nil)
353
- define_console_tool(server, conn_mgr, 'console_update_setting',
354
- 'Update a configuration setting (requires confirmation)',
355
- properties: {
356
- key: str_prop('Setting key'), value: str_prop('New value'),
357
- namespace: str_prop('Setting namespace')
358
- }, required: %w[key value], safe_ctx: safe_ctx, renderer: renderer) do |args|
359
- Tools::Tier2.console_update_setting(
360
- key: args[:key], value: args[:value], namespace: args[:namespace]
361
- )
362
- end
363
- end
364
-
365
- def define_check_policy(server, conn_mgr, safe_ctx = nil, renderer: nil)
366
- define_console_tool(server, conn_mgr, 'console_check_policy',
367
- 'Check authorization policy for a record and user',
368
- properties: {
369
- model: str_prop('Model name'), id: int_prop('Record primary key'),
370
- user_id: int_prop('User to check'), action: str_prop('Policy action')
371
- }, required: %w[model id user_id action],
372
- safe_ctx: safe_ctx, renderer: renderer) do |args|
373
- Tools::Tier2.console_check_policy(
374
- model: args[:model], id: args[:id], user_id: args[:user_id], action: args[:action]
375
- )
376
- end
377
- end
378
-
379
- def define_validate_with(server, conn_mgr, safe_ctx = nil, renderer: nil)
380
- define_console_tool(server, conn_mgr, 'console_validate_with',
381
- 'Validate attributes against a model without persisting',
382
- properties: {
383
- model: str_prop('Model name'), attributes: obj_prop('Attributes to validate'),
384
- context: str_prop('Validation context')
385
- }, required: %w[model attributes], safe_ctx: safe_ctx, renderer: renderer) do |args|
386
- Tools::Tier2.console_validate_with(
387
- model: args[:model], attributes: args[:attributes], context: args[:context]
388
- )
389
- end
390
- end
391
-
392
- def define_check_eligibility(server, conn_mgr, safe_ctx = nil, renderer: nil)
393
- define_console_tool(server, conn_mgr, 'console_check_eligibility',
394
- 'Check feature eligibility for a record',
395
- properties: {
396
- model: str_prop('Model name'), id: int_prop('Record primary key'),
397
- feature: str_prop('Feature name')
398
- }, required: %w[model id feature], safe_ctx: safe_ctx, renderer: renderer) do |args|
399
- Tools::Tier2.console_check_eligibility(
400
- model: args[:model], id: args[:id], feature: args[:feature]
401
- )
402
- end
403
- end
404
-
405
- def define_decorate(server, conn_mgr, safe_ctx = nil, renderer: nil)
406
- define_console_tool(server, conn_mgr, 'console_decorate',
407
- 'Invoke a decorator on a record and return computed attributes',
408
- properties: {
409
- model: str_prop('Model name'), id: int_prop('Record primary key'),
410
- methods: arr_prop('Decorator methods to call')
411
- }, required: %w[model id], safe_ctx: safe_ctx, renderer: renderer) do |args|
412
- Tools::Tier2.console_decorate(model: args[:model], id: args[:id], methods: args[:methods])
413
- end
414
- end
415
-
416
- # ── Tier 3 tool definitions ──────────────────────────────────────────
417
-
418
- def define_slow_endpoints(server, conn_mgr, safe_ctx = nil, renderer: nil)
419
- define_console_tool(server, conn_mgr, 'console_slow_endpoints',
420
- 'List slowest endpoints by response time',
421
- properties: {
422
- limit: int_prop('Max endpoints (default 10, max 100)'),
423
- period: str_prop('Time period (default: 1h)')
424
- }, safe_ctx: safe_ctx, renderer: renderer) do |args|
425
- Tools::Tier3.console_slow_endpoints(limit: args[:limit] || 10, period: args[:period] || '1h')
426
- end
353
+ # Build the JSON Schema object for a ToolSpec.
354
+ #
355
+ # @param spec [ToolSpec]
356
+ # @return [Hash]
357
+ def spec_schema(spec)
358
+ schema = { properties: spec.properties }
359
+ schema[:required] = spec.required if spec.required&.any?
360
+ schema
427
361
  end
428
362
 
429
- def define_error_rates(server, conn_mgr, safe_ctx = nil, renderer: nil)
430
- define_console_tool(server, conn_mgr, 'console_error_rates',
431
- 'Get error rates by controller or overall',
432
- properties: {
433
- period: str_prop('Time period (default: 1h)'),
434
- controller: str_prop('Filter by controller')
435
- }, safe_ctx: safe_ctx, renderer: renderer) do |args|
436
- Tools::Tier3.console_error_rates(period: args[:period] || '1h', controller: args[:controller])
437
- end
363
+ # Pre-compute property keys declared as integer in a schema.
364
+ #
365
+ # @param properties [Hash] Tool schema properties
366
+ # @return [Array<Symbol>]
367
+ def integer_property_keys(properties)
368
+ properties.select { |_k, v| v[:type] == 'integer' }.keys.map(&:to_sym)
438
369
  end
439
370
 
440
- def define_throughput(server, conn_mgr, safe_ctx = nil, renderer: nil)
441
- define_console_tool(server, conn_mgr, 'console_throughput',
442
- 'Get request throughput over time',
443
- properties: {
444
- period: str_prop('Time period (default: 1h)'),
445
- interval: str_prop('Aggregation interval (default: 5m)')
446
- }, safe_ctx: safe_ctx, renderer: renderer) do |args|
447
- Tools::Tier3.console_throughput(
448
- period: args[:period] || '1h', interval: args[:interval] || '5m'
449
- )
450
- end
371
+ # Build a SafeContext (Layer 3) from redaction settings, or nil when nothing is configured.
372
+ #
373
+ # @param redacted_columns [Array<String>]
374
+ # @param redacted_key_values [Array<Hash>]
375
+ # @return [SafeContext, nil]
376
+ def build_safe_context(redacted_columns, redacted_key_values)
377
+ return nil unless redacted_columns.any? || redacted_key_values.any?
378
+
379
+ SafeContext.new(
380
+ connection: nil,
381
+ redacted_columns: redacted_columns,
382
+ redacted_key_values: redacted_key_values
383
+ )
451
384
  end
452
385
 
453
- def define_job_queues(server, conn_mgr, safe_ctx = nil, renderer: nil)
454
- define_console_tool(server, conn_mgr, 'console_job_queues',
455
- 'Get job queue statistics',
456
- properties: {
457
- queue: str_prop('Filter by queue name')
458
- }, safe_ctx: safe_ctx, renderer: renderer) do |args|
459
- Tools::Tier3.console_job_queues(queue: args[:queue])
460
- end
461
- end
386
+ # Bundle the three response-safety layers into a ResponseContext the
387
+ # server can thread through every tool. Returns nil when every layer is
388
+ # absent so callers can skip wiring.
389
+ #
390
+ # @param safe_ctx [SafeContext, nil] Layer 3 (column + EAV redaction)
391
+ # @param model_tables [Hash{String=>String}] Model => table registry for Layer 1
392
+ # @param model_reflections [Hash{String=>Hash{String=>String}}] Model => { association => table }
393
+ # @return [ResponseContext, nil]
394
+ def build_response_context(safe_ctx:, model_tables:, model_reflections: {})
395
+ config = Woods.configuration if Woods.respond_to?(:configuration)
396
+ blocked = Array(config&.console_blocked_tables)
397
+ table_gate = if blocked.any?
398
+ TableGate.new(blocked_tables: blocked, model_tables: model_tables,
399
+ model_reflections: model_reflections)
400
+ end
462
401
 
463
- def define_job_failures(server, conn_mgr, safe_ctx = nil, renderer: nil)
464
- define_console_tool(server, conn_mgr, 'console_job_failures',
465
- 'List recent job failures',
466
- properties: {
467
- limit: int_prop('Max failures (default 10, max 100)'),
468
- queue: str_prop('Filter by queue name')
469
- }, safe_ctx: safe_ctx, renderer: renderer) do |args|
470
- Tools::Tier3.console_job_failures(limit: args[:limit] || 10, queue: args[:queue])
471
- end
472
- end
402
+ secret_index = build_credential_index(config)
403
+ disabled_patterns = Array(config&.console_disabled_scanner_patterns)
404
+ scanner = if disabled_patterns.include?(:all)
405
+ nil
406
+ else
407
+ CredentialScanner.new(
408
+ disabled_patterns: disabled_patterns,
409
+ secret_index: secret_index
410
+ )
411
+ end
412
+ @active_scanner = scanner
413
+
414
+ ResponseContext.build(safe_ctx: safe_ctx, table_gate: table_gate, credential_scanner: scanner)
415
+ end
416
+
417
+ # Build the boot-time credential index from Rails.application's encrypted
418
+ # credentials. Returns nil when credential defense is disabled or when no
419
+ # Rails application is reachable (specs, non-Rails hosts) — the scanner
420
+ # then falls back to its pattern-only behavior.
421
+ #
422
+ # When `console_credential_rotation_warning` is enabled (default: true),
423
+ # also emits a structured log warning if any credentials file on disk was
424
+ # modified after this process started — a strong signal that credentials
425
+ # were rotated without restarting the MCP process. Disable with:
426
+ #
427
+ # config.console_credential_rotation_warning = false
428
+ #
429
+ # @param config [Woods::Configuration, nil]
430
+ # @return [CredentialIndex, nil]
431
+ def build_credential_index(config)
432
+ return nil unless config&.console_credential_defense_enabled
433
+ return nil unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
473
434
 
474
- def define_job_find(server, conn_mgr, safe_ctx = nil, renderer: nil)
475
- define_console_tool(server, conn_mgr, 'console_job_find',
476
- 'Find a job by ID, optionally retry it (requires confirmation)',
477
- properties: {
478
- job_id: str_prop('Job identifier'),
479
- retry: bool_prop('Retry the job (requires confirmation)')
480
- }, required: ['job_id'], safe_ctx: safe_ctx, renderer: renderer) do |args|
481
- Tools::Tier3.console_job_find(job_id: args[:job_id], retry_job: args[:retry])
482
- end
435
+ index = CredentialIndex.build(rails_app: Rails.application)
436
+ maybe_warn_rotation(config, Rails.application)
437
+ index
483
438
  end
484
439
 
485
- def define_job_schedule(server, conn_mgr, safe_ctx = nil, renderer: nil)
486
- define_console_tool(server, conn_mgr, 'console_job_schedule',
487
- 'List scheduled/upcoming jobs',
488
- properties: {
489
- limit: int_prop('Max jobs (default 20, max 100)')
490
- }, safe_ctx: safe_ctx, renderer: renderer) do |args|
491
- Tools::Tier3.console_job_schedule(limit: args[:limit] || 20)
492
- end
440
+ # Emit a boot-time rotation warning when the credentials file mtime is
441
+ # newer than the process start time, indicating a rotation that was not
442
+ # followed by a restart. Only fires when Rails.root is available and
443
+ # `console_credential_rotation_warning` is not false.
444
+ #
445
+ # @param config [Woods::Configuration, nil]
446
+ # @param rails_app [#root] The Rails application (used to locate credential files).
447
+ # @return [void]
448
+ def maybe_warn_rotation(config, rails_app)
449
+ return if config&.console_credential_rotation_warning == false
450
+ return unless rails_app.respond_to?(:root) && rails_app.root
451
+
452
+ root = rails_app.root
453
+ candidates = [
454
+ root.join('config/credentials.yml.enc').to_s,
455
+ root.join("config/credentials/#{ENV.fetch('RAILS_ENV', 'production')}.yml.enc").to_s
456
+ ]
457
+
458
+ CredentialIndex.warn_if_credentials_rotated(
459
+ credentials_files: candidates,
460
+ process_start: CredentialIndex::PROCESS_START,
461
+ logger: structured_logger
462
+ )
463
+ rescue StandardError => e
464
+ handle_observability_failure(e)
493
465
  end
494
466
 
495
- def define_redis_info(server, conn_mgr, safe_ctx = nil, renderer: nil)
496
- define_console_tool(server, conn_mgr, 'console_redis_info',
497
- 'Get Redis server information',
498
- properties: {
499
- section: str_prop('INFO section (e.g., memory, stats)')
500
- }, safe_ctx: safe_ctx, renderer: renderer) do |args|
501
- Tools::Tier3.console_redis_info(section: args[:section])
502
- end
503
- end
467
+ # Shared server construction used by both build() and build_embedded().
468
+ #
469
+ # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Any object with send_request(Hash) -> Hash
470
+ # @param ctx [ResponseContext, nil] Optional context bundling response-safety layers
471
+ # @return [MCP::Server]
472
+ def build_server(conn_mgr, ctx)
473
+ server = ::MCP::Server.new(
474
+ name: 'woods-console',
475
+ version: defined?(Woods::VERSION) ? Woods::VERSION : '0.1.0'
476
+ )
504
477
 
505
- def define_cache_stats(server, conn_mgr, safe_ctx = nil, renderer: nil)
506
- define_console_tool(server, conn_mgr, 'console_cache_stats',
507
- 'Get cache store statistics',
508
- properties: {
509
- namespace: str_prop('Cache namespace filter')
510
- }, safe_ctx: safe_ctx, renderer: renderer) do |args|
511
- Tools::Tier3.console_cache_stats(namespace: args[:namespace])
512
- end
513
- end
478
+ renderer = build_console_renderer
514
479
 
515
- def define_channel_status(server, conn_mgr, safe_ctx = nil, renderer: nil)
516
- define_console_tool(server, conn_mgr, 'console_channel_status',
517
- 'Get ActionCable channel status',
518
- properties: {
519
- channel: str_prop('Filter by channel name')
520
- }, safe_ctx: safe_ctx, renderer: renderer) do |args|
521
- Tools::Tier3.console_channel_status(channel: args[:channel])
522
- end
480
+ register_tier1_tools(server, conn_mgr, ctx, renderer: renderer)
481
+ register_tier2_tools(server, conn_mgr, ctx, renderer: renderer)
482
+ register_tier3_tools(server, conn_mgr, ctx, renderer: renderer)
483
+ register_tier4_tools(server, conn_mgr, ctx, renderer: renderer)
484
+ server
523
485
  end
524
486
 
525
- # ── Tier 4 tool definitions ──────────────────────────────────────────
487
+ # Loud multi-line banner surfaced to stderr when the opt-in flag is
488
+ # recognised outside of production. Operators should see this every
489
+ # boot so an accidentally-persistent env var cannot go unnoticed.
490
+ #
491
+ # @return [String]
492
+ def unsafe_eval_banner
493
+ <<~BANNER
526
494
 
527
- def define_eval(server, conn_mgr, safe_ctx = nil, renderer: nil)
528
- define_console_tool(server, conn_mgr, 'console_eval',
529
- 'Execute arbitrary Ruby code (requires confirmation)',
530
- properties: {
531
- code: str_prop('Ruby code to execute'),
532
- timeout: int_prop('Timeout in seconds (default 10, max 30)')
533
- }, required: ['code'], safe_ctx: safe_ctx, renderer: renderer) do |args|
534
- Tools::Tier4.console_eval(code: args[:code], timeout: args[:timeout] || 10)
535
- end
495
+ ================================================================================
496
+ WOODS_CONSOLE_UNSAFE_EVAL IS SET
497
+ console_eval is LIVE on this process. Every run goes through EvalGuard +
498
+ Confirmation + SafeContext rollback + a wall-clock timeout and is recorded
499
+ to the audit log. The flag refuses to boot in Rails.env.production?.
500
+ If you did not mean to set this, unset the env var.
501
+ ================================================================================
502
+ BANNER
536
503
  end
537
504
 
538
- def define_sql(server, conn_mgr, safe_ctx = nil, renderer: nil)
539
- validator = SqlValidator.new
540
- define_console_tool(server, conn_mgr, 'console_sql',
541
- 'Execute read-only SQL (SELECT/WITH...SELECT only)',
542
- properties: {
543
- sql: str_prop('SQL query (SELECT or WITH...SELECT only)'),
544
- limit: int_prop('Max rows returned (default unlimited, max 10000)')
545
- }, required: ['sql'], safe_ctx: safe_ctx, renderer: renderer) do |args|
546
- Tools::Tier4.console_sql(sql: args[:sql], validator: validator, limit: args[:limit])
505
+ def structured_logger
506
+ @structured_logger ||= begin
507
+ require 'woods/observability/structured_logger'
508
+ Woods::Observability::StructuredLogger.new
547
509
  end
548
510
  end
549
511
 
550
- def define_query(server, conn_mgr, safe_ctx = nil, renderer: nil)
551
- props = {
552
- model: str_prop('Model name'), select: arr_prop('Columns to select'),
553
- joins: arr_prop('Associations to join'), group_by: arr_prop('Columns to group by'),
554
- having: str_prop('HAVING clause'), order: obj_prop('Order specification'),
555
- scope: obj_prop('Filter conditions'), limit: int_prop('Max rows (max 10000)')
556
- }
557
- define_console_tool(server, conn_mgr, 'console_query',
558
- 'Enhanced query builder with joins and grouping',
559
- properties: props, required: %w[model select],
560
- safe_ctx: safe_ctx, renderer: renderer) do |args|
561
- Tools::Tier4.console_query(
562
- model: args[:model], select: args[:select], joins: args[:joins],
563
- group_by: args[:group_by], having: args[:having],
564
- order: args[:order], scope: args[:scope], limit: args[:limit]
565
- )
566
- end
567
- end
512
+ # Swallow observability failures so they never break a tool response,
513
+ # but emit a single warn so operators can see if the structured
514
+ # logging pipeline is broken. Subsequent failures stay silent to
515
+ # avoid flooding stderr.
516
+ def handle_observability_failure(error)
517
+ return if @observability_failure_reported
568
518
 
569
- # Shared tool definition helper that wires block -> bridge -> response.
570
- # rubocop:disable Metrics/ParameterLists
571
- def define_console_tool(server, conn_mgr, name, description, properties:, required: nil,
572
- safe_ctx: nil, renderer: nil, &tool_block)
573
- bridge_method = method(:send_to_bridge)
574
- coerce_method = method(:coerce_integer_args!)
575
- integer_keys = integer_property_keys(properties)
576
- schema = { properties: properties }
577
- schema[:required] = required if required&.any?
578
- server.define_tool(name: name, description: description, input_schema: schema) do |server_context:, **args|
579
- coerce_method.call(args, integer_keys)
580
- request = tool_block.call(args)
581
- bridge_method.call(conn_mgr, request.transform_keys(&:to_s), safe_ctx, renderer: renderer)
582
- end
519
+ @observability_failure_reported = true
520
+ warn '[woods-console] structured logger failed ' \
521
+ "(#{error.class}: #{error.message}); further failures will be silent."
522
+ rescue StandardError
523
+ nil
583
524
  end
584
- # rubocop:enable Metrics/ParameterLists
585
525
 
586
- # Pre-compute property keys declared as integer in a schema.
587
- #
588
- # @param properties [Hash] Tool schema properties
589
- # @return [Array<Symbol>]
590
- def integer_property_keys(properties)
591
- properties.select { |_k, v| v[:type] == 'integer' }.keys.map(&:to_sym)
526
+ # Legacy delegate to {Redactor.apply} kept so the server's class-level
527
+ # spec can drive redaction without constructing a ResponseContext.
528
+ def apply_redaction(result, ctx)
529
+ Redactor.apply(result, ctx)
592
530
  end
593
531
 
594
- # Coerce string values to integers for known integer keys.
595
- #
596
- # @param args [Hash] Tool arguments (mutated in place)
597
- # @param keys [Array<Symbol>] Keys that should be integers
598
- # @return [void]
599
- def coerce_integer_args!(args, keys)
600
- keys.each { |k| args[k] = args[k].to_i if args[k].is_a?(String) }
532
+ def build_console_renderer
533
+ format = if Woods.respond_to?(:configuration)
534
+ Woods.configuration&.context_format || :markdown
535
+ else
536
+ :markdown
537
+ end
538
+ format == :json ? JsonConsoleRenderer.new : ConsoleResponseRenderer.new
601
539
  end
602
-
603
- # Schema property helpers for concise tool definitions.
604
- def str_prop(desc) = { type: 'string', description: desc }
605
- def int_prop(desc) = { type: 'integer', description: desc }
606
- def obj_prop(desc) = { type: 'object', description: desc }
607
- def bool_prop(desc) = { type: 'boolean', description: desc }
608
- def arr_prop(desc) = { type: 'array', items: { type: 'string' }, description: desc }
609
540
  end
610
541
  end
611
542
  end