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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +169 -0
- data/README.md +20 -8
- data/exe/woods-console +51 -6
- data/exe/woods-console-mcp +24 -4
- data/exe/woods-mcp +30 -7
- data/exe/woods-mcp-http +47 -6
- data/lib/generators/woods/install_generator.rb +13 -4
- data/lib/generators/woods/templates/woods.rb.tt +155 -0
- data/lib/tasks/woods.rake +15 -50
- data/lib/woods/builder.rb +174 -9
- data/lib/woods/cache/cache_middleware.rb +360 -31
- data/lib/woods/chunking/semantic_chunker.rb +334 -7
- data/lib/woods/console/adapters/job_adapter.rb +10 -4
- data/lib/woods/console/audit_logger.rb +76 -4
- data/lib/woods/console/bridge.rb +48 -15
- data/lib/woods/console/bridge_protocol.rb +44 -0
- data/lib/woods/console/confirmation.rb +3 -4
- data/lib/woods/console/console_response_renderer.rb +56 -18
- data/lib/woods/console/credential_index.rb +201 -0
- data/lib/woods/console/credential_scanner.rb +302 -0
- data/lib/woods/console/dispatch_pipeline.rb +138 -0
- data/lib/woods/console/embedded_executor.rb +682 -35
- data/lib/woods/console/eval_guard.rb +319 -0
- data/lib/woods/console/model_validator.rb +1 -3
- data/lib/woods/console/rack_middleware.rb +185 -29
- data/lib/woods/console/redactor.rb +161 -0
- data/lib/woods/console/response_context.rb +127 -0
- data/lib/woods/console/safe_context.rb +220 -23
- data/lib/woods/console/scope_predicate_parser.rb +131 -0
- data/lib/woods/console/server.rb +417 -486
- data/lib/woods/console/sql_noise_stripper.rb +87 -0
- data/lib/woods/console/sql_table_scanner.rb +213 -0
- data/lib/woods/console/sql_validator.rb +81 -31
- data/lib/woods/console/table_gate.rb +93 -0
- data/lib/woods/console/tool_specs.rb +552 -0
- data/lib/woods/console/tools/tier1.rb +3 -3
- data/lib/woods/console/tools/tier4.rb +7 -1
- data/lib/woods/dependency_graph.rb +66 -7
- data/lib/woods/embedding/indexer.rb +190 -6
- data/lib/woods/embedding/openai.rb +40 -4
- data/lib/woods/embedding/provider.rb +104 -8
- data/lib/woods/embedding/text_preparer.rb +23 -3
- data/lib/woods/embedding/token_counter.rb +133 -0
- data/lib/woods/evaluation/baseline_runner.rb +20 -2
- data/lib/woods/evaluation/metrics.rb +4 -1
- data/lib/woods/extracted_unit.rb +1 -0
- data/lib/woods/extractor.rb +7 -1
- data/lib/woods/extractors/controller_extractor.rb +6 -0
- data/lib/woods/extractors/mailer_extractor.rb +16 -2
- data/lib/woods/extractors/model_extractor.rb +6 -1
- data/lib/woods/extractors/phlex_extractor.rb +13 -4
- data/lib/woods/extractors/rails_source_extractor.rb +2 -0
- data/lib/woods/extractors/route_helper_resolver.rb +130 -0
- data/lib/woods/extractors/shared_dependency_scanner.rb +130 -2
- data/lib/woods/extractors/view_component_extractor.rb +12 -1
- data/lib/woods/extractors/view_engines/base.rb +141 -0
- data/lib/woods/extractors/view_engines/erb.rb +145 -0
- data/lib/woods/extractors/view_template_extractor.rb +92 -133
- data/lib/woods/flow_assembler.rb +23 -15
- data/lib/woods/flow_precomputer.rb +21 -2
- data/lib/woods/graph_analyzer.rb +3 -4
- data/lib/woods/index_artifact.rb +173 -0
- data/lib/woods/mcp/bearer_auth.rb +45 -0
- data/lib/woods/mcp/bootstrap_state.rb +94 -0
- data/lib/woods/mcp/bootstrapper.rb +337 -16
- data/lib/woods/mcp/config_resolver.rb +288 -0
- data/lib/woods/mcp/errors.rb +134 -0
- data/lib/woods/mcp/index_reader.rb +265 -30
- data/lib/woods/mcp/origin_guard.rb +132 -0
- data/lib/woods/mcp/provider_probe.rb +166 -0
- data/lib/woods/mcp/renderers/claude_renderer.rb +6 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +39 -3
- data/lib/woods/mcp/renderers/plain_renderer.rb +16 -2
- data/lib/woods/mcp/server.rb +737 -137
- data/lib/woods/model_name_cache.rb +78 -2
- data/lib/woods/notion/client.rb +25 -2
- data/lib/woods/notion/mappers/model_mapper.rb +36 -2
- data/lib/woods/railtie.rb +55 -15
- data/lib/woods/resilience/circuit_breaker.rb +9 -2
- data/lib/woods/resilience/retryable_provider.rb +40 -3
- data/lib/woods/resolved_config.rb +299 -0
- data/lib/woods/retrieval/context_assembler.rb +112 -5
- data/lib/woods/retrieval/query_classifier.rb +1 -1
- data/lib/woods/retrieval/ranker.rb +55 -6
- data/lib/woods/retrieval/search_executor.rb +42 -13
- data/lib/woods/retriever.rb +330 -24
- data/lib/woods/session_tracer/middleware.rb +35 -1
- data/lib/woods/storage/graph_store.rb +39 -0
- data/lib/woods/storage/inapplicable_backend.rb +14 -0
- data/lib/woods/storage/metadata_store.rb +129 -1
- data/lib/woods/storage/pgvector.rb +70 -8
- data/lib/woods/storage/qdrant.rb +196 -5
- data/lib/woods/storage/snapshotter/metadata.rb +172 -0
- data/lib/woods/storage/snapshotter/vector.rb +238 -0
- data/lib/woods/storage/snapshotter.rb +24 -0
- data/lib/woods/storage/vector_store.rb +184 -35
- data/lib/woods/tasks.rb +85 -0
- data/lib/woods/temporal/snapshot_store.rb +49 -1
- data/lib/woods/token_utils.rb +44 -5
- data/lib/woods/unblocked/client.rb +1 -1
- data/lib/woods/unblocked/document_builder.rb +35 -10
- data/lib/woods/unblocked/exporter.rb +1 -1
- data/lib/woods/util/host_guard.rb +61 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +126 -6
- metadata +69 -4
data/lib/woods/console/server.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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: [],
|
|
62
|
-
|
|
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,
|
|
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
|
|
286
|
+
# @param ctx [ResponseContext, nil] Optional context for column redaction
|
|
82
287
|
# @return [void]
|
|
83
|
-
def register_tier1_tools(server, conn_mgr,
|
|
84
|
-
|
|
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
|
|
296
|
+
# @param ctx [ResponseContext, nil] Optional context for column redaction
|
|
92
297
|
# @return [void]
|
|
93
|
-
def register_tier2_tools(server, conn_mgr,
|
|
94
|
-
|
|
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
|
|
306
|
+
# @param ctx [ResponseContext, nil] Optional context for column redaction
|
|
102
307
|
# @return [void]
|
|
103
|
-
def register_tier3_tools(server, conn_mgr,
|
|
104
|
-
|
|
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
|
|
316
|
+
# @param ctx [ResponseContext, nil] Optional context for column redaction
|
|
112
317
|
# @return [void]
|
|
113
|
-
def register_tier4_tools(server, conn_mgr,
|
|
114
|
-
|
|
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
|
-
#
|
|
324
|
+
# Register a single ToolSpec on the MCP server.
|
|
120
325
|
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
#
|
|
587
|
-
#
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|