woods 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a3bf50a7ad421cc52a336375446c4e08c2583dbf2ce96e4a3038bd8dca7a0ae
4
- data.tar.gz: fe9a6512787372c3f3d0cbe4038720f123349b9961c548b1584d21b8b4cbd0fb
3
+ metadata.gz: 135a4869f2fdd230990dc723a96d62f19447e51295768392d25bd0125dd4e25a
4
+ data.tar.gz: bdce75a6b653bd5da5f3a7f4e7c9180020235f5a232daa5e58a4d439824e9a34
5
5
  SHA512:
6
- metadata.gz: 7070f985d4a9bab191593599e4f4f8c807d0378c8345a9aa8a73c2ac4de0200b7959ffd6bb6a823bb12fd67a4afe90ad6d3475eb16bbb60ac320ead1de114b77
7
- data.tar.gz: 3c0cd27167baa9cd7e0245894527760e12bf2d038e57819ffa78468e6f0e6e4122dd144129bcfd769bd89965caad7c997299dfc5de9c1c38dfddb88bab8f2f78
6
+ metadata.gz: 7dbe8d5460b1775c4f785954c88539fb29a76ad2878ee58e5e8ca11616787c87a058b9e9a774867f3cff4afe0c1b8ade137fd657f5249995d594e2ce2d7dfd8e
7
+ data.tar.gz: 99db15fe718972172398fcf1574b33e26e94869beeb0d90c95e7adcb6b41b4a53ad3b3c4fe34067ba01df7e485dec61e73cda5b739dda89dab665f9c6bfb3282
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.2] - 2026-09-18
11
+
12
+ ### Fixed
13
+
14
+ - Refresh encrypted Rails credentials from disk when rebuilding the Console credential index, update every live scanner, and preserve the last valid index if refresh fails. Each response uses one consistent index snapshot.
15
+ Keep credential scanner refresh snapshots limited to live weakly referenced
16
+ scanners on older Ruby versions, avoiding unsafe receiver access after garbage
17
+ collection while preserving updates to every live Console server.
18
+ Keep JSON on `>= 2.19.9, < 3` so supported Rails encoders can serialize metadata after a fresh bundle resolution; JSON 3 removes their `quirks_mode` option. Bound MessagePack below 2 and Railties below 9, retaining their existing minimum versions and the strict package-build gate.
19
+ - Preserve version-aware missing-tool guidance with MCP 0.23 by installing its dispatch override before the SDK captures request handlers.
20
+
21
+ ### Build
22
+
23
+ - Add a no-publish, one-off 1.6.2 maintenance preparation flow and installed-package CI. Disable the legacy automatic publisher; publication requires the trusted main maintenance profile and its reviewed candidate SHA.
24
+
25
+ ### Security
26
+
27
+ - Require MCP 0.23.0 or newer within the 0.x line for upstream transport security fixes; run `bundle update woods mcp` when upgrading. Ruby 3.0 remains supported. The newer SDK removes the old json-schema/addressable dependency chain. Existing Index and Console HTTP origin settings now reach the SDK Host/Origin guards; cross-origin browser access requires the actual configured port.
28
+
10
29
  ## [1.6.1] - 2026-07-22
11
30
 
12
31
  ### Fixed
data/CONTRIBUTING.md CHANGED
@@ -125,3 +125,52 @@ Woods uses runtime introspection, not static parsing. If your feature requires a
125
125
  ## License
126
126
 
127
127
  By contributing, you agree that your contributions will be licensed under the MIT License.
128
+
129
+ ## Maintenance release
130
+
131
+ This tree has a one-off, no-publish preparation adapter for the reviewed 1.6.2
132
+ security patch while `main` carries the newer 2.0 prerelease line. It does not
133
+ establish a permanent stable branch or authorize another maintenance version.
134
+ The approved target is `release/1.6.2`, based on immutable `v1.6.1`. Create that
135
+ remote target only after the trusted main maintenance policy is reviewed.
136
+
137
+ From a clean checkout, the only supported transitions are:
138
+
139
+ ```sh
140
+ bin/rake "release:reopen[1.6.2.alpha]"
141
+ # Review and commit the generated development-state diff.
142
+ bin/rake "release:prepare[1.6.2]"
143
+ ```
144
+
145
+ The tasks never commit, tag, push, dispatch or publish. Never edit VERSION or the
146
+ README maintenance `release-state` banner manually. The first reopen creates that
147
+ banner; prepare updates it and folds classified Unreleased notes and optional
148
+ `changelog/<type>_<slug>.md` entries into a dated release heading. Entry files are
149
+ nonempty Markdown without headings; supported types include `fixed`, `security`,
150
+ `build`, and `documentation`. Invalid transitions, dirty trees, malformed notes,
151
+ and missing/duplicate/unknown fences refuse before writes. This legacy profile
152
+ has no v2 migration guide or v2 surface inventory requirement.
153
+
154
+ Validate the full suite, lint, booted extraction, real Console credential rotation,
155
+ and installed maintenance package tests. CI builds one gem plus its SHA-256
156
+ sidecar into `woods-release-<commit SHA>` and tests that artifact on Ruby 3.0/Rails
157
+ 6.0 with exactly MCP 0.23.0 and Ruby 4.0/Rails 8.1 with the latest compatible 0.x SDK.
158
+ MCP >=0.23.0 is required for upstream transport security fixes; update Woods and
159
+ MCP together (`bundle update woods mcp`). Ruby 3.0 remains supported.
160
+ The optional package specs require `WOODS_GEM_PATH` and
161
+ run standalone, without the repository's `spec_helper` or implementation path:
162
+
163
+ ```sh
164
+ ruby -rrubygems -e 'load Gem.bin_path("rspec-core", "rspec")' -- \
165
+ --options /dev/null spec/integration/maintenance_packaged_gem_spec.rb
166
+ ```
167
+
168
+ Preparation is not publication; check RubyGems before describing 1.6.2 as released.
169
+ The legacy automatic tag publisher is disabled, and Bundler's `release`,
170
+ `release:rubygem_push`, and `release:source_control_push` tasks abort. Only a
171
+ maintainer may later tag the reviewed merge commit and dispatch the trusted
172
+ **main** workflow. That workflow must explicitly allow the exact tag, protected
173
+ maintenance branch, immutable 1.6.1 base, reviewed final candidate SHA, required
174
+ CI jobs, and immutable artifact. An unpinned candidate remains blocked. First merge a reviewed trusted-main profile update pinning `approved_sha` to that
175
+ exact maintenance merge SHA; only then may the maintainer tag it. The
176
+ workflow publishes the already tested gem bytes; never rebuild or publish locally.
data/README.md CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  # Woods
6
6
 
7
+ <!-- release-state:maintenance-banner -->
8
+ > **Woods 1.6.2: prepared maintenance candidate.** This tree remains on the 1.6 maintenance line.
9
+ > Preparation does not establish publication. Check [RubyGems versions](https://rubygems.org/gems/woods/versions)
10
+ > before selecting an install version. See [maintenance release policy](CONTRIBUTING.md#maintenance-release).
11
+ <!-- release-state:end -->
12
+
7
13
  **Your AI coding assistant is guessing about your Rails app. Woods gives it the real answers.**
8
14
 
9
15
  Rails hides enormous amounts of behavior behind conventions, concerns, and runtime magic. When you ask an AI assistant "what callbacks fire when a User saves?" or "what routes map to this controller?", it guesses from training data — and gets it wrong. Woods runs *inside* your Rails app, extracts what's actually happening at runtime, and serves that context directly to your AI tools via [MCP](https://modelcontextprotocol.io/).
data/exe/woods-mcp-http CHANGED
@@ -28,6 +28,7 @@ require_relative '../lib/woods/mcp/server'
28
28
  require_relative '../lib/woods/mcp/bootstrapper'
29
29
  require_relative '../lib/woods/mcp/bearer_auth'
30
30
  require_relative '../lib/woods/mcp/origin_guard'
31
+ require_relative '../lib/woods/mcp/http_transport_options'
31
32
  require_relative '../lib/woods/embedding/text_preparer'
32
33
  require_relative '../lib/woods/embedding/indexer'
33
34
 
@@ -63,10 +64,11 @@ server = Woods::MCP::Server.build(
63
64
  index_dir: index_dir, retriever: retriever, snapshot_store: snapshot_store,
64
65
  bootstrap_state: bootstrap_state, retriever_reloader: retriever_reloader
65
66
  )
66
- transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
67
- server.transport = transport
68
-
69
67
  allowed_origins = ENV.fetch('WOODS_MCP_HTTP_ALLOWED_ORIGINS', '').split(',').map(&:strip).reject(&:empty?)
68
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(
69
+ server, **Woods::MCP::HttpTransportOptions.for(allowed_origins)
70
+ )
71
+ server.transport = transport
70
72
 
71
73
  inner = proc { |env| transport.handle_request(Rack::Request.new(env)) }
72
74
  app = token ? Woods::MCP::BearerAuth.new(inner, token: token) : inner
@@ -68,6 +68,7 @@ module Woods
68
68
  MISSING_KEY_ERRORS = %w[
69
69
  ActiveSupport::EncryptedConfiguration::MissingKeyError
70
70
  ActiveSupport::EncryptedFile::MissingKeyError
71
+ ActiveSupport::EncryptedFile::MissingContentError
71
72
  ActiveSupport::MessageEncryptor::InvalidMessage
72
73
  ].freeze
73
74
 
@@ -90,13 +91,19 @@ module Woods
90
91
  # @return [CredentialIndex] populated index, or an empty index when
91
92
  # the credentials store can't be opened.
92
93
  def build(rails_app:)
93
- new(secrets: collect_secrets(rails_app))
94
+ refresh(rails_app: rails_app)
94
95
  rescue StandardError => e
95
96
  raise unless missing_key_error?(e)
96
97
 
97
98
  new(secrets: [])
98
99
  end
99
100
 
101
+ # Strict refresh: construct the replacement completely before a caller
102
+ # installs it. Unlike boot-time .build, errors must preserve the old index.
103
+ def refresh(rails_app:)
104
+ new(secrets: collect_secrets(rails_app))
105
+ end
106
+
100
107
  # Emit a structured log warning when any of the given credentials
101
108
  # files has a modification time newer than `process_start`. This
102
109
  # catches the common "rotated credentials but forgot to restart"
@@ -136,12 +143,23 @@ module Woods
136
143
  private
137
144
 
138
145
  def collect_secrets(rails_app)
139
- config = rails_app.credentials.config
146
+ config = fresh_credentials(rails_app.credentials).config
140
147
  collected = []
141
148
  walk(config, collected)
142
149
  collected
143
150
  end
144
151
 
152
+ def fresh_credentials(credentials)
153
+ return credentials unless defined?(ActiveSupport::EncryptedConfiguration) &&
154
+ credentials.is_a?(ActiveSupport::EncryptedConfiguration)
155
+
156
+ require_relative 'encrypted_credential_snapshot'
157
+ EncryptedCredentialSnapshot.new(
158
+ config_path: credentials.content_path, key_path: credentials.key_path,
159
+ env_key: credentials.env_key, raise_if_missing_key: true
160
+ )
161
+ end
162
+
145
163
  def walk(node, collected)
146
164
  case node
147
165
  when Hash then node.each_value { |v| walk(v, collected) }
@@ -155,17 +155,17 @@ module Woods
155
155
  # for patterns that fired — callers should treat a missing key as zero.
156
156
  def scan(value)
157
157
  counts = {}
158
- scanned = walk(value, counts)
158
+ scanned = walk(value, counts, @secret_index)
159
159
  [scanned, counts]
160
160
  end
161
161
 
162
162
  private
163
163
 
164
- def walk(value, counts)
164
+ def walk(value, counts, index)
165
165
  case value
166
- when String then scan_string(value, counts)
167
- when Hash then walk_hash(value, counts)
168
- when Array then value.map { |item| walk(item, counts) }
166
+ when String then scan_string(value, counts, index)
167
+ when Hash then walk_hash(value, counts, index)
168
+ when Array then value.map { |item| walk(item, counts, index) }
169
169
  else value
170
170
  end
171
171
  end
@@ -177,19 +177,19 @@ module Woods
177
177
  # credential-shaped string is emitted as a Symbol after redaction
178
178
  # (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
179
179
  # non-Symbol keys (Integer, etc.) pass through unchanged.
180
- def walk_hash(hash, counts)
180
+ def walk_hash(hash, counts, index)
181
181
  hash.each_with_object({}) do |(key, val), out|
182
182
  scanned_key = case key
183
- when String then scan_string(key, counts)
184
- when Symbol then scan_string(key.to_s, counts).to_sym
183
+ when String then scan_string(key, counts, index)
184
+ when Symbol then scan_string(key.to_s, counts, index).to_sym
185
185
  else key
186
186
  end
187
- out[scanned_key] = walk(val, counts)
187
+ out[scanned_key] = walk(val, counts, index)
188
188
  end
189
189
  end
190
190
 
191
- def scan_string(str, counts)
192
- result = redact_indexed_secrets(str, counts)
191
+ def scan_string(str, counts, index)
192
+ result = redact_indexed_secrets(str, counts, index)
193
193
  result = scan_encoded_forms(result, counts)
194
194
  @active_patterns.inject(result) do |acc, (name, pattern)|
195
195
  acc.gsub(pattern) do
@@ -290,10 +290,10 @@ module Woods
290
290
  printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
291
291
  end
292
292
 
293
- def redact_indexed_secrets(str, counts)
294
- return str unless @secret_index&.match?(str)
293
+ def redact_indexed_secrets(str, counts, index)
294
+ return str unless index&.match?(str)
295
295
 
296
- redacted = @secret_index.redact(str)
296
+ redacted = index.redact(str)
297
297
  counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
298
298
  redacted
299
299
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Console
5
+ # Coordinates scanner construction and rotation without retaining servers
6
+ # after their transports release them. Requests keep their own index snapshot.
7
+ class CredentialScannerRegistry
8
+ def initialize
9
+ @scanners = ObjectSpace::WeakMap.new
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def register
14
+ @mutex.synchronize do
15
+ scanner = yield
16
+ # Older Ruby WeakMap implementations validate the value's liveness
17
+ # when enumerating. Keep the scanner in both weak positions so an
18
+ # immortal sentinel cannot make an abandoned scanner look live.
19
+ @scanners[scanner] = scanner
20
+ scanner
21
+ end
22
+ end
23
+
24
+ def rebuild
25
+ @mutex.synchronize do
26
+ scanners = @scanners.values
27
+ return nil if scanners.empty?
28
+
29
+ index = yield
30
+ scanners.each { |scanner| scanner.replace_index!(index) }
31
+ index
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/encrypted_configuration'
4
+
5
+ module Woods
6
+ module Console
7
+ # A private, uncached Rails configuration for one credential-index build.
8
+ # Unlike Rails' permissive configuration reader, a missing encrypted file
9
+ # must raise during an explicit refresh rather than erase the last index.
10
+ class EncryptedCredentialSnapshot < ActiveSupport::EncryptedConfiguration
11
+ def read
12
+ ActiveSupport::EncryptedFile.instance_method(:read).bind(self).call
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'woods/mcp/http_transport_options'
4
5
  require 'woods/observability/structured_logger'
5
6
 
6
7
  module Woods
@@ -131,7 +132,9 @@ module Woods
131
132
  Rails.application.eager_load!
132
133
 
133
134
  server = build_embedded_server
134
- @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server)
135
+ @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
136
+ server, **Woods::MCP::HttpTransportOptions.for(Woods.configuration.console_mcp_allowed_origins)
137
+ )
135
138
  server.transport = @transport
136
139
  @transport
137
140
  end
@@ -13,6 +13,7 @@ require_relative 'audit_logger'
13
13
  require_relative 'confirmation'
14
14
  require_relative 'console_response_renderer'
15
15
  require_relative 'credential_scanner'
16
+ require_relative 'credential_scanner_registry'
16
17
  require_relative 'eval_guard'
17
18
  require_relative 'table_gate'
18
19
  require_relative 'redactor'
@@ -34,10 +35,14 @@ module Woods
34
35
  # transport.open
35
36
  #
36
37
  module Server # rubocop:disable Metrics/ModuleLength
38
+ SCANNERS = CredentialScannerRegistry.new
39
+ private_constant :SCANNERS
40
+
37
41
  class << self # rubocop:disable Metrics/ClassLength
38
42
  # Rebuild the boot-time credential index from fresh Rails credentials
39
- # and hot-swap it into the active scanner without restarting the process.
43
+ # and hot-swap it into every live scanner without restarting the process.
40
44
  #
45
+ # Refresh failures propagate without replacing any live index.
41
46
  # Host rotation jobs should call this immediately after `rails credentials:edit`
42
47
  # changes are deployed. The swap is atomic on MRI (GVL) — in-flight scans see
43
48
  # either the old or the new index, never a partial one.
@@ -57,14 +62,11 @@ module Woods
57
62
  # the rebuild was skipped.
58
63
  def rebuild_credential_index(rails_app: nil)
59
64
  return nil unless credential_defense_enabled?
60
- return nil unless @active_scanner
61
65
 
62
66
  target_app = rails_app || default_rails_app
63
67
  return nil unless target_app
64
68
 
65
- new_index = CredentialIndex.build(rails_app: target_app)
66
- @active_scanner.replace_index!(new_index)
67
- new_index
69
+ SCANNERS.rebuild { CredentialIndex.refresh(rails_app: target_app) }
68
70
  end
69
71
 
70
72
  # True when Woods is configured and credential defense is on.
@@ -399,21 +401,21 @@ module Woods
399
401
  model_reflections: model_reflections)
400
402
  end
401
403
 
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
404
+ scanner = build_scanner(config)
413
405
 
414
406
  ResponseContext.build(safe_ctx: safe_ctx, table_gate: table_gate, credential_scanner: scanner)
415
407
  end
416
408
 
409
+ def build_scanner(config)
410
+ disabled_patterns = Array(config&.console_disabled_scanner_patterns)
411
+ return nil if disabled_patterns.include?(:all)
412
+
413
+ SCANNERS.register do
414
+ CredentialScanner.new(disabled_patterns: disabled_patterns,
415
+ secret_index: build_credential_index(config))
416
+ end
417
+ end
418
+
417
419
  # Build the boot-time credential index from Rails.application's encrypted
418
420
  # credentials. Returns nil when credential defense is disabled or when no
419
421
  # Rails application is reachable (specs, non-Rails hosts) — the scanner
@@ -5,7 +5,7 @@
5
5
  # Metrics/ModuleLength is disabled here because the module body is almost
6
6
  # entirely declarative data, not imperative logic. Decomposition would just
7
7
  # scatter the tool catalogue across many files with no readability gain.
8
- # rubocop:disable Metrics/ModuleLength
8
+ # rubocop:disable-next Metrics/ModuleLength
9
9
  module Woods
10
10
  module Console
11
11
  module Server
@@ -549,4 +549,3 @@ module Woods
549
549
  end
550
550
  end
551
551
  end
552
- # rubocop:enable Metrics/ModuleLength
@@ -98,13 +98,12 @@ module Woods
98
98
  # @param scope [Hash, nil] Filter conditions
99
99
  # @param columns [Array<String>, nil] Columns to include
100
100
  # @return [Hash] Bridge request
101
- # rubocop:disable Metrics/ParameterLists
101
+ # rubocop:disable-next Metrics/ParameterLists
102
102
  def console_recent(model:, order_by: 'created_at', direction: 'desc', limit: 10, scope: nil, columns: nil)
103
103
  limit = [limit, 50].min
104
104
  { tool: 'recent', params: { model: model, order_by: order_by, direction: direction,
105
105
  limit: limit, scope: scope, columns: columns }.compact }
106
106
  end
107
- # rubocop:enable Metrics/ParameterLists
108
107
 
109
108
  # System health check.
110
109
  #
@@ -61,7 +61,7 @@ module Woods
61
61
  # @param scope [Hash, nil] Filter conditions
62
62
  # @param limit [Integer, nil] Row limit (max 10000)
63
63
  # @return [Hash] Bridge request
64
- # rubocop:disable Metrics/ParameterLists
64
+ # rubocop:disable-next Metrics/ParameterLists
65
65
  def console_query(model:, select:, joins: nil, group_by: nil, having: nil, order: nil, scope: nil, limit: nil)
66
66
  limit = [limit, MAX_QUERY_LIMIT].min if limit
67
67
  {
@@ -78,7 +78,6 @@ module Woods
78
78
  }.compact
79
79
  }
80
80
  end
81
- # rubocop:enable Metrics/ParameterLists
82
81
  end
83
82
  end
84
83
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Woods
6
+ module MCP
7
+ # Keep the SDK DNS-rebinding guard aligned with Woods' explicit allowlist.
8
+ # Empty configuration retains the SDK's strict loopback defaults.
9
+ module HttpTransportOptions
10
+ def self.for(origins)
11
+ normalized = Array(origins).map { |origin| origin.strip.downcase.delete_suffix('/') }.reject(&:empty?)
12
+ return {} if normalized.empty?
13
+
14
+ hosts = normalized.filter_map do |origin|
15
+ uri = URI.parse(origin)
16
+ uri.host if %w[http https].include?(uri.scheme)
17
+ rescue URI::InvalidURIError
18
+ nil
19
+ end
20
+ { allowed_origins: normalized, allowed_hosts: hosts.uniq }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -93,15 +93,12 @@ module Woods
93
93
  )
94
94
  end
95
95
 
96
- server = ::MCP::Server.new(
96
+ server = VersionAwareServer.new(
97
97
  name: 'woods',
98
98
  version: Woods::VERSION,
99
99
  resources: resources,
100
100
  resource_templates: resource_templates
101
101
  )
102
- # Rewrite "Tool not found" into version-aware update guidance for agents
103
- # running against an older gem than the skill they're following assumes.
104
- server.singleton_class.prepend(VersionAwareToolDispatch)
105
102
 
106
103
  define_lookup_tool(server, reader, respond, respond_err, renderer)
107
104
  define_search_tool(server, reader, respond, respond_err, renderer)
@@ -200,11 +197,7 @@ module Woods
200
197
  meta[:config_key] = config_key if config_key
201
198
  meta[:doc_link] = doc_link if doc_link
202
199
  meta.merge!(extra) unless extra.empty?
203
- ::MCP::Tool::Response.new(
204
- [{ type: 'text', text: message }],
205
- error: true,
206
- meta: meta
207
- )
200
+ ::MCP::Tool::Response.new([{ type: 'text', text: message }], error: true, meta: meta)
208
201
  end
209
202
 
210
203
  def truncate_section(array, limit)
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'mcp'
3
4
  require_relative '../update_check'
4
5
 
5
6
  module Woods
6
7
  module MCP
7
- # Prepended onto a built +MCP::Server+ instance so that a call to a tool the
8
+ # Prepended before +MCP::Server+ initialization so that a call to a tool the
8
9
  # installed gem does not define produces version-aware, self-healing
9
10
  # guidance instead of a bare "Tool not found".
10
11
  #
@@ -12,8 +13,7 @@ module Woods
12
13
  # to call a tool that only exists in a later Woods release. The upstream
13
14
  # +mcp+ gem raises +RequestHandlerError("Tool not found: X")+ with no seam to
14
15
  # customize the message, so we intercept the built server's +call_tool+
15
- # (dispatched directly by +handle_request+, so a prepend on the instance is
16
- # honored on both the stdio and HTTP transports) and re-raise with the
16
+ # before the SDK captures its bound handler method and re-raise with the
17
17
  # installed version plus an update hint the agent can relay to the user.
18
18
  #
19
19
  # Only the genuine tool-not-found case is rewritten; every other
@@ -43,5 +43,11 @@ module Woods
43
43
  error.error_type == :invalid_params && error.message.to_s.start_with?('Tool not found:')
44
44
  end
45
45
  end
46
+
47
+ # MCP 0.23 captures method(:call_tool) during initialization. The override
48
+ # must already exist then; a later singleton prepend misses that handler.
49
+ class VersionAwareServer < ::MCP::Server
50
+ prepend VersionAwareToolDispatch
51
+ end
46
52
  end
47
53
  end
@@ -30,9 +30,8 @@ module Woods
30
30
  debug: /\b(bug|error|fix|broken|failing|wrong|issue|problem|crash|exception)\b/i,
31
31
  implement: /\b(implement|add|create|build|write|make|generate)\b/i,
32
32
  compare: /\b(compare|difference|vs|versus|between|contrast)\b/i,
33
- # rubocop:disable Layout/LineLength
33
+ # rubocop:disable-next Layout/LineLength
34
34
  framework: /\b(how does rails|what does rails|rails .+ work|work.+\brails\b|in rails\b|activerecord|actioncontroller|activejob|actionmailer|actioncable|actiontext|activestorage|solid_queue|solid_cache|solid_cable|kamal|propshaft|importmap|hotwire|turbo|stimulus|zeitwerk)\b/i,
35
- # rubocop:enable Layout/LineLength
36
35
  reference: /\b(show me|what is|what are|list|options for|api|interface|signature)\b/i,
37
36
  understand: /\b(how|why|explain|understand|what happens|describe|overview)\b/i
38
37
  }.freeze
@@ -85,7 +85,7 @@ module Woods
85
85
  # @param env [Hash] Rack environment
86
86
  # @param status [Integer] HTTP response status
87
87
  # @param duration_ms [Integer] Request duration in milliseconds
88
- # rubocop:disable Metrics/MethodLength
88
+ # rubocop:disable-next Metrics/MethodLength
89
89
  def record_request(env, status, duration_ms)
90
90
  path = env['PATH_INFO'] || ''
91
91
  return if excluded?(path)
@@ -116,7 +116,6 @@ module Woods
116
116
 
117
117
  @store.record(session_id, request_data)
118
118
  end
119
- # rubocop:enable Metrics/MethodLength
120
119
 
121
120
  # Extract session ID: X-Trace-Session header first, then session cookie, then fallback.
122
121
  #
@@ -24,7 +24,7 @@ module Woods
24
24
  # doc = assembler.assemble("abc123", budget: 8000, depth: 1)
25
25
  # puts doc.to_context
26
26
  #
27
- # rubocop:disable Metrics/ClassLength
27
+ # rubocop:disable-next Metrics/ClassLength
28
28
  class SessionFlowAssembler
29
29
  ASYNC_TYPES = %w[job mailer].to_set.freeze
30
30
 
@@ -41,7 +41,7 @@ module Woods
41
41
  # @param budget [Integer] Maximum token budget (default: 8000)
42
42
  # @param depth [Integer] Expansion depth (0=metadata only, 1=direct deps, 2+=full flow)
43
43
  # @return [SessionFlowDocument] The assembled document
44
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
44
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
45
45
  def assemble(session_id, budget: 8000, depth: 1)
46
46
  requests = @store.read(session_id)
47
47
  return empty_document(session_id) if requests.empty?
@@ -89,7 +89,6 @@ module Woods
89
89
  token_count: token_count
90
90
  )
91
91
  end
92
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
93
92
 
94
93
  private
95
94
 
@@ -115,7 +114,7 @@ module Woods
115
114
  # Resolve dependencies for a unit, separating sync deps from async side effects.
116
115
  #
117
116
  # @return [Array<String>] Non-async dependency identifiers added
118
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
117
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
119
118
  def resolve_dependencies(unit_id, seen_units, context_pool,
120
119
  side_effects, step, dependency_map, depth)
121
120
  graph = @reader.dependency_graph
@@ -154,7 +153,6 @@ module Woods
154
153
 
155
154
  added
156
155
  end
157
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
158
156
 
159
157
  # Expand transitive dependencies (depth 2+).
160
158
  #
@@ -249,6 +247,5 @@ module Woods
249
247
  SessionFlowDocument.new(session_id: session_id)
250
248
  end
251
249
  end
252
- # rubocop:enable Metrics/ClassLength
253
250
  end
254
251
  end
@@ -23,7 +23,7 @@ module Woods
23
23
  # doc.to_markdown # => human-readable document
24
24
  # doc.to_context # => LLM XML format
25
25
  #
26
- # rubocop:disable Metrics/ClassLength
26
+ # rubocop:disable-next Metrics/ClassLength
27
27
  class SessionFlowDocument
28
28
  attr_reader :session_id, :steps, :context_pool, :side_effects,
29
29
  :dependency_map, :token_count, :generated_at
@@ -35,7 +35,7 @@ module Woods
35
35
  # @param dependency_map [Hash<String, Array<String>>] Unit -> dependency identifiers
36
36
  # @param token_count [Integer] Estimated total tokens
37
37
  # @param generated_at [String, nil] ISO8601 timestamp (defaults to now)
38
- # rubocop:disable Metrics/ParameterLists
38
+ # rubocop:disable-next Metrics/ParameterLists
39
39
  def initialize(session_id:, steps: [], context_pool: {}, side_effects: [],
40
40
  dependency_map: {}, token_count: 0, generated_at: nil)
41
41
  @session_id = session_id
@@ -46,7 +46,6 @@ module Woods
46
46
  @token_count = token_count
47
47
  @generated_at = generated_at || Time.now.utc.iso8601
48
48
  end
49
- # rubocop:enable Metrics/ParameterLists
50
49
 
51
50
  # Serialize to a JSON-compatible Hash.
52
51
  #
@@ -86,7 +85,7 @@ module Woods
86
85
  # Render as human-readable Markdown.
87
86
  #
88
87
  # @return [String]
89
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
88
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
90
89
  def to_markdown
91
90
  lines = []
92
91
  lines << "## Session: #{@session_id}"
@@ -146,14 +145,13 @@ module Woods
146
145
 
147
146
  lines.join("\n")
148
147
  end
149
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
150
148
 
151
149
  # Render as LLM-consumable XML context.
152
150
  #
153
151
  # Follows the format from docs/CONTEXT_AND_CHUNKING.md.
154
152
  #
155
153
  # @return [String]
156
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
154
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
157
155
  def to_context
158
156
  lines = []
159
157
  header = "<session_context session_id=\"#{@session_id}\" requests=\"#{@steps.size}\" " \
@@ -201,7 +199,6 @@ module Woods
201
199
  lines << '</session_context>'
202
200
  lines.join("\n")
203
201
  end
204
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
205
202
 
206
203
  # @api private
207
204
  def self.deep_symbolize_keys(obj)
@@ -218,6 +215,5 @@ module Woods
218
215
  end
219
216
  private_class_method :deep_symbolize_keys
220
217
  end
221
- # rubocop:enable Metrics/ClassLength
222
218
  end
223
219
  end
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '1.6.1'
4
+ VERSION = '1.6.2'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: woods
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.1
4
+ version: 1.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leah Armstrong
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-22 00:00:00.000000000 Z
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mcp
@@ -16,7 +16,7 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.9.2
19
+ version: 0.23.0
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
22
  version: '1.0'
@@ -26,10 +26,30 @@ dependencies:
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: 0.9.2
29
+ version: 0.23.0
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '1.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: json
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 2.19.9
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '3'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 2.19.9
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '3'
33
53
  - !ruby/object:Gem::Dependency
34
54
  name: msgpack
35
55
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +57,9 @@ dependencies:
37
57
  - - ">="
38
58
  - !ruby/object:Gem::Version
39
59
  version: '1.5'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '2'
40
63
  type: :runtime
41
64
  prerelease: false
42
65
  version_requirements: !ruby/object:Gem::Requirement
@@ -44,6 +67,9 @@ dependencies:
44
67
  - - ">="
45
68
  - !ruby/object:Gem::Version
46
69
  version: '1.5'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '2'
47
73
  - !ruby/object:Gem::Dependency
48
74
  name: prism
49
75
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +91,9 @@ dependencies:
65
91
  - - ">="
66
92
  - !ruby/object:Gem::Version
67
93
  version: '6.0'
94
+ - - "<"
95
+ - !ruby/object:Gem::Version
96
+ version: '9'
68
97
  type: :runtime
69
98
  prerelease: false
70
99
  version_requirements: !ruby/object:Gem::Requirement
@@ -72,6 +101,9 @@ dependencies:
72
101
  - - ">="
73
102
  - !ruby/object:Gem::Version
74
103
  version: '6.0'
104
+ - - "<"
105
+ - !ruby/object:Gem::Version
106
+ version: '9'
75
107
  description: |
76
108
  Woods extracts structured data from Rails applications for use in
77
109
  AI-assisted development tooling. It provides version-specific context by
@@ -133,8 +165,10 @@ files:
133
165
  - lib/woods/console/console_response_renderer.rb
134
166
  - lib/woods/console/credential_index.rb
135
167
  - lib/woods/console/credential_scanner.rb
168
+ - lib/woods/console/credential_scanner_registry.rb
136
169
  - lib/woods/console/dispatch_pipeline.rb
137
170
  - lib/woods/console/embedded_executor.rb
171
+ - lib/woods/console/encrypted_credential_snapshot.rb
138
172
  - lib/woods/console/eval_guard.rb
139
173
  - lib/woods/console/model_validator.rb
140
174
  - lib/woods/console/rack_middleware.rb
@@ -243,6 +277,7 @@ files:
243
277
  - lib/woods/mcp/bootstrapper.rb
244
278
  - lib/woods/mcp/config_resolver.rb
245
279
  - lib/woods/mcp/errors.rb
280
+ - lib/woods/mcp/http_transport_options.rb
246
281
  - lib/woods/mcp/index_reader.rb
247
282
  - lib/woods/mcp/origin_guard.rb
248
283
  - lib/woods/mcp/provider_probe.rb
@@ -324,10 +359,10 @@ licenses:
324
359
  - MIT
325
360
  metadata:
326
361
  homepage_uri: https://github.com/lost-in-the/woods
327
- source_code_uri: https://github.com/lost-in-the/woods/tree/main
328
- changelog_uri: https://github.com/lost-in-the/woods/blob/main/CHANGELOG.md
362
+ source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.2
363
+ changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.2/CHANGELOG.md
329
364
  bug_tracker_uri: https://github.com/lost-in-the/woods/issues
330
- documentation_uri: https://github.com/lost-in-the/woods/tree/main/docs
365
+ documentation_uri: https://github.com/lost-in-the/woods/tree/v1.6.2/docs
331
366
  rubygems_mfa_required: 'true'
332
367
  post_install_message:
333
368
  rdoc_options: []