woods 1.6.1 → 2.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Woods
6
+ module MCP
7
+ # Pins reader-backed MCP handlers to one cached generation per request.
8
+ # Installed on each built server's singleton class so the captured reader
9
+ # never leaks to another Woods server or a foreign MCP::Server instance.
10
+ #
11
+ # Coverage is derived from every tool the server actually has registered
12
+ # at install time, not a hand-maintained allowlist. A static list of
13
+ # "reader tools" silently stopped covering new tools the moment one was
14
+ # added and nobody remembered to update the list — the pin wrapper once
15
+ # covered 14 of 28 non-reload tools this way, leaving retrieval,
16
+ # pipeline, snapshot, and feedback calls free to run concurrently with an
17
+ # exclusive reload. Pinning a handler that turns out not to touch the
18
+ # reader costs an uncontended mutex + refcount bump; leaving one
19
+ # unpinned that does touch it is the correctness bug. Default to pinned.
20
+ module IndexReaderPinning
21
+ # Tools deliberately left outside the pin gate, each with its own
22
+ # justification — anything not listed here is pinned by construction.
23
+ #
24
+ # `reload` — the exclusive writer `with_pinned_generation` exists to
25
+ # hold readers back from. Pinning its own handler would acquire a pin
26
+ # first and then, inside the same call, ask `with_exclusive_reload` to
27
+ # wait for every outstanding pin to drain — including the one the
28
+ # handler itself is still holding. Self-deadlock, not missed coverage.
29
+ #
30
+ # No other registered tool qualifies as of this audit round. The
31
+ # operator/feedback/snapshot handlers read their own collaborators
32
+ # (StatusReporter, the feedback store, the snapshot store) rather than
33
+ # the shared `reader`, so pinning them is a no-op today — but it is a
34
+ # free no-op, and the alternative is a hand-maintained allowlist that
35
+ # silently stops covering a handler the moment it starts reading
36
+ # `reader` too. Default to pinned; name the exception, don't guess it.
37
+ NON_READER_TOOL_NAMES = Set.new(
38
+ %w[
39
+ reload
40
+ ]
41
+ ).freeze
42
+
43
+ class << self
44
+ # @param server [MCP::Server]
45
+ # @param reader [Woods::MCP::IndexReader]
46
+ # @return [MCP::Server]
47
+ def install(server, reader:)
48
+ server.singleton_class.prepend(Dispatch)
49
+ server.instance_variable_set(:@woods_index_reader, reader)
50
+ server.instance_variable_set(
51
+ :@woods_index_reader_tool_names,
52
+ (server.tools.keys.to_set - NON_READER_TOOL_NAMES).freeze
53
+ )
54
+ server
55
+ end
56
+ end
57
+
58
+ module Dispatch
59
+ private
60
+
61
+ def call_tool(request, **kwargs)
62
+ reader = @woods_index_reader
63
+ tool_names = @woods_index_reader_tool_names
64
+ return super unless reader && tool_names&.include?(request[:name])
65
+
66
+ reader.with_pinned_generation { super }
67
+ end
68
+
69
+ def read_resource_contents(request, **kwargs)
70
+ reader = @woods_index_reader
71
+ return super unless reader
72
+
73
+ reader.with_pinned_generation { super }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -39,7 +39,7 @@ module Woods
39
39
  LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
40
40
 
41
41
  ALLOWED_METHODS = 'GET, POST, DELETE, OPTIONS'
42
- ALLOWED_HEADERS = 'Authorization, Content-Type, Mcp-Session-Id'
42
+ ALLOWED_HEADERS = 'Authorization, Content-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id'
43
43
 
44
44
  # Response bodies are emitted as constants so the rejected Origin /
45
45
  # Host value is NEVER echoed back to the caller — preventing a
@@ -48,14 +48,39 @@ module Woods
48
48
  FORBIDDEN_BODY = { jsonrpc: '2.0', error: { code: -32_002, message: 'Origin not allowed' }, id: nil }.to_json.freeze
49
49
  FORBIDDEN_HOST_BODY = { jsonrpc: '2.0', error: { code: -32_002, message: 'Host not allowed' }, id: nil }.to_json.freeze
50
50
 
51
- def initialize(app, allowed_origins: nil)
51
+ # @param app [#call] The next Rack app in the middleware stack
52
+ # @param allowed_origins [Array<String>, #call, nil] Origin allow-list,
53
+ # or a callable returning one. A callable is resolved (and memoized)
54
+ # on the first guarded request, so an allow-list configured after the
55
+ # middleware was inserted — e.g. in `config/initializers/woods.rb`,
56
+ # which runs after Rails railtie initializers captured the middleware
57
+ # arguments — still takes effect (#183). Empty/nil falls back to
58
+ # {DEFAULT_ALLOWED}.
59
+ # @param path [String, nil] When set, only requests whose PATH_INFO
60
+ # starts with this prefix are guarded — everything else passes
61
+ # straight through to the app. Nil (the default) guards every request.
62
+ # @param enabled [#call, nil] Optional request-time predicate. When it
63
+ # returns falsy the request passes through unguarded. Nil (the
64
+ # default) means always guard.
65
+ def initialize(app, allowed_origins: nil, path: nil, enabled: nil)
52
66
  @app = app
53
- @allowed = Array(allowed_origins).compact.reject { |o| o.to_s.strip.empty? }.map { |o| normalize(o) }
54
- @allowed = DEFAULT_ALLOWED.dup if @allowed.empty?
55
- @allowed_hosts = @allowed.map { |o| extract_host(o) }.compact.uniq
67
+ @path = path
68
+ @enabled = enabled
69
+ if allowed_origins.respond_to?(:call)
70
+ @allowed_source = allowed_origins
71
+ else
72
+ build_allow_list(allowed_origins)
73
+ end
56
74
  end
57
75
 
76
+ # Rack entry point. Out-of-scope requests (non-matching `path:` prefix
77
+ # or a falsy `enabled:` predicate) pass through untouched.
78
+ #
79
+ # @param env [Hash] Rack environment
80
+ # @return [Array] Rack response triple
58
81
  def call(env)
82
+ return @app.call(env) unless guard?(env)
83
+
59
84
  origin = env['HTTP_ORIGIN']
60
85
  method = env['REQUEST_METHOD']
61
86
  host = env['HTTP_HOST']
@@ -72,6 +97,40 @@ module Woods
72
97
 
73
98
  private
74
99
 
100
+ # @param env [Hash] Rack environment
101
+ # @return [Boolean] whether this request falls under the guard
102
+ def guard?(env)
103
+ return false if @path && !env['PATH_INFO'].to_s.start_with?(@path)
104
+ return false if @enabled && !@enabled.call
105
+
106
+ true
107
+ end
108
+
109
+ # Normalize a raw allow-list into `@allowed` + `@allowed_hosts`.
110
+ #
111
+ # @param origins [Array<String>, nil]
112
+ # @return [Array<String>] the normalized allow-list
113
+ def build_allow_list(origins)
114
+ allowed = Array(origins).compact.reject { |o| o.to_s.strip.empty? }.map { |o| normalize(o) }
115
+ allowed = DEFAULT_ALLOWED.dup if allowed.empty?
116
+ @allowed_hosts = allowed.map { |o| extract_host(o) }.compact.uniq
117
+ @allowed = allowed
118
+ end
119
+
120
+ # The allow-list, resolving a deferred source on first use. Memoized —
121
+ # configuration is settled by the time the first request arrives.
122
+ #
123
+ # @return [Array<String>]
124
+ def allowed
125
+ @allowed || build_allow_list(@allowed_source.call)
126
+ end
127
+
128
+ # @return [Array<String>] hosts extracted from the allow-list
129
+ def allowed_hosts
130
+ allowed
131
+ @allowed_hosts
132
+ end
133
+
75
134
  def normalize(origin)
76
135
  origin.to_s.sub(%r{/\z}, '').downcase
77
136
  end
@@ -96,13 +155,13 @@ module Woods
96
155
 
97
156
  return true if LOOPBACK_HOSTS.include?(bare)
98
157
 
99
- @allowed_hosts.include?(normalized) || @allowed_hosts.include?(bare)
158
+ allowed_hosts.include?(normalized) || allowed_hosts.include?(bare)
100
159
  end
101
160
 
102
161
  def origin_allowed?(origin)
103
162
  return false if origin.match?(/[[:cntrl:]]/)
104
163
 
105
- @allowed.include?(normalize(origin)) || @allowed.include?(normalize(origin).sub(/:\d+\z/, ''))
164
+ allowed.include?(normalize(origin)) || allowed.include?(normalize(origin).sub(/:\d+\z/, ''))
106
165
  end
107
166
 
108
167
  def preflight(origin)
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module MCP
5
+ # Protocol-level policy shared by both MCP servers (index and console).
6
+ #
7
+ # Everything here is a decision the MCP 2026-07-28 revision asks a server to
8
+ # make but the SDK cannot make for us: how long a list/read result stays
9
+ # fresh, who is allowed to cache it, and what order tools are advertised in.
10
+ #
11
+ # @see docs/design/MCP_2026_STRATEGY.md
12
+ module ProtocolPolicy
13
+ # SEP-2549 `cacheScope`. **Always `"private"`, deliberately not configurable.**
14
+ #
15
+ # Every result Woods can return describes the user's own codebase — tool
16
+ # descriptions naming their models, resource reads carrying their source.
17
+ # `"public"` authorises shared intermediaries (a corporate proxy, a gateway,
18
+ # a CDN in front of `woods-mcp-http`) to cache and re-serve that to other
19
+ # callers. The SDK's fallback when only `ttl_ms` is set is `"public"`, so
20
+ # setting the ttl and leaving the scope alone is the one combination that
21
+ # silently opts into the bad outcome — which is exactly why this is pinned
22
+ # rather than defaulted.
23
+ CACHE_SCOPE = 'private'
24
+
25
+ # SEP-2549 `ttlMs`, a freshness hint with max-age semantics (0 = do not cache).
26
+ #
27
+ # The SDK applies one server-level value to `tools/list`, `prompts/list`,
28
+ # `resources/list`, `resources/read` and `resources/templates/list` alike,
29
+ # so this has to be safe for the most volatile of them — a `resources/read`
30
+ # against an index a watch daemon may rewrite seconds from now.
31
+ #
32
+ # Ten seconds collapses the burst of list calls an agent makes when it
33
+ # opens a session without letting a daemon-driven index change go unseen
34
+ # for meaningfully longer than the poll interval that produced it. Callers
35
+ # that never run a daemon can raise it via WOODS_MCP_CACHE_TTL_MS.
36
+ DEFAULT_TTL_MS = 10_000
37
+
38
+ # Environment override for {DEFAULT_TTL_MS}. `0` disables caching.
39
+ TTL_ENV_KEY = 'WOODS_MCP_CACHE_TTL_MS'
40
+
41
+ class << self
42
+ # Resolved `ttlMs` for this process.
43
+ #
44
+ # A malformed or negative value falls back to the default rather than
45
+ # raising: this is a cache hint on a server an agent is mid-conversation
46
+ # with, and refusing to boot over a typo'd env var trades a mild
47
+ # performance regression for a total outage.
48
+ #
49
+ # @return [Integer] non-negative milliseconds
50
+ def ttl_ms
51
+ raw = ENV.fetch(TTL_ENV_KEY, nil)
52
+ return DEFAULT_TTL_MS if raw.nil? || raw.strip.empty?
53
+
54
+ value = Integer(raw, exception: false)
55
+ return DEFAULT_TTL_MS if value.nil? || value.negative?
56
+
57
+ value
58
+ end
59
+
60
+ # Keyword arguments for `MCP::Server.new` carrying the cache hints.
61
+ #
62
+ # @return [Hash{Symbol => Object}]
63
+ def cache_hints
64
+ { ttl_ms: ttl_ms, cache_scope: CACHE_SCOPE }
65
+ end
66
+
67
+ # Sort a built server's tools by name, in place.
68
+ #
69
+ # MCP 2026-07-28 asks servers to return `tools/list` in a deterministic
70
+ # order, explicitly so clients can cache the list and so the tool block
71
+ # lands identically in an LLM's prompt cache across turns. The SDK lists
72
+ # `@tools.values` — Hash insertion order — and Woods registers 14
73
+ # always-on tools followed by up to 15 more gated on which collaborators
74
+ # happen to be wired. Two hosts with different integrations therefore
75
+ # advertise the same tools in different orders, and enabling an
76
+ # integration reorders the block for everyone on that host.
77
+ #
78
+ # Sorting also fixes cursor pagination, which the SDK implements by
79
+ # offset over that same unordered collection.
80
+ #
81
+ # This reaches into an SDK ivar because `define_tool` is the only
82
+ # registration API and it appends. The reach is contained here and
83
+ # no-ops if the internal shape ever changes, so a future SDK refactor
84
+ # degrades to "unsorted, as before" rather than breaking the server.
85
+ #
86
+ # @param server [MCP::Server]
87
+ # @return [MCP::Server] the same server, for chaining
88
+ def sort_tools!(server)
89
+ tools = server.instance_variable_get(:@tools)
90
+ return server unless tools.is_a?(Hash)
91
+
92
+ server.instance_variable_set(:@tools, tools.sort_by { |name, _| name.to_s }.to_h)
93
+ server
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -3,6 +3,11 @@
3
3
  require 'net/http'
4
4
  require 'uri'
5
5
 
6
+ require_relative 'errors'
7
+ require_relative '../embedding/provider'
8
+ require_relative '../embedding/openai'
9
+ require_relative '../embedding/fake'
10
+
6
11
  module Woods
7
12
  module MCP
8
13
  # Probes an embedding provider's HTTP endpoint to confirm it is reachable
@@ -39,28 +44,62 @@ module Woods
39
44
  # with +reason: "unauthorized"+ because an invalid key means the
40
45
  # provider cannot be used; network failures raise with the appropriate
41
46
  # reason string.
42
- # - Any other class → raises +ArgumentError+.
47
+ # - {Woods::Embedding::Provider::Fake} → trivially reachable (#178).
48
+ # The provider is deterministic and in-process; there is no endpoint
49
+ # to probe, so the probe succeeds without any network I/O.
50
+ # - Any other object responding to +#embed+ and +#embed_batch+ — an
51
+ # injected provider object, the third shape
52
+ # {Woods::Builder#build_embedding_provider} accepts (#178) — is
53
+ # presumed reachable. An arbitrary host-supplied implementation has
54
+ # no endpoint contract this probe could exercise, so the probe
55
+ # succeeds without any network I/O and the provider's first real
56
+ # call surfaces connectivity or credential errors.
57
+ # - Anything else → raises +ArgumentError+.
43
58
  #
44
59
  # @param provider [Woods::Embedding::Provider::Ollama,
45
- # Woods::Embedding::Provider::OpenAI] a concrete embedding provider
60
+ # Woods::Embedding::Provider::OpenAI,
61
+ # Woods::Embedding::Provider::Fake, Object] a concrete embedding
62
+ # provider, or an injected object implementing +#embed+/+#embed_batch+
46
63
  # @return [Object] the same +provider+ if reachable
47
64
  # @raise [Woods::MCP::ProviderUnreachable] if the endpoint is unreachable,
48
65
  # times out, returns 5xx, or (for OpenAI) returns 401
49
- # @raise [ArgumentError] if +provider+ is not a recognised provider class
66
+ # @raise [ArgumentError] if +provider+ is neither a recognised provider
67
+ # class nor an object implementing +#embed+ and +#embed_batch+
50
68
  def self.reachable!(provider)
51
69
  case provider
52
70
  when Woods::Embedding::Provider::Ollama
53
71
  probe_ollama!(provider)
54
72
  when Woods::Embedding::Provider::OpenAI
55
73
  probe_openai!(provider)
74
+ when Woods::Embedding::Provider::Fake
75
+ # In-process, no endpoint: nothing to probe (#178). The case arm
76
+ # is deliberately empty — the method returns +provider+ below.
77
+ nil
56
78
  else
57
- raise ArgumentError,
58
- "#{self}.reachable! does not know how to probe #{provider.class} — " \
59
- 'add a provider-specific probe method or implement #probe_url'
79
+ unless provider_object?(provider)
80
+ raise ArgumentError,
81
+ "#{self}.reachable! does not know how to probe #{provider.class} — " \
82
+ 'the object must implement #embed and #embed_batch, or be a known ' \
83
+ 'provider class (OpenAI, Ollama, Fake)'
84
+ end
85
+ # Injected provider object: presumed reachable (see above). The
86
+ # arm deliberately performs no network I/O — the method returns
87
+ # +provider+ below, like the Fake arm.
88
+ nil
60
89
  end
61
90
  provider
62
91
  end
63
92
 
93
+ # Duck-type check for an injected provider object — the same two-method
94
+ # contract {Woods::Builder#provider_object?} accepts (#178).
95
+ #
96
+ # @param provider [Object]
97
+ # @return [Boolean]
98
+ def self.provider_object?(provider)
99
+ provider.respond_to?(:embed) && provider.respond_to?(:embed_batch)
100
+ end
101
+ private_class_method :provider_object?
102
+
64
103
  # Probe the Ollama instance backing +provider+.
65
104
  #
66
105
  # @param provider [Woods::Embedding::Provider::Ollama]
@@ -72,6 +72,14 @@ module Woods
72
72
  lines << line
73
73
  end
74
74
 
75
+ note = fetch_key(data, :note)
76
+ partial = fetch_key(data, :partial, false)
77
+ if partial || note
78
+ lines << ''
79
+ lines << '**partial:** true' if partial
80
+ lines << "**note:** #{note}" if note
81
+ end
82
+
75
83
  lines.join("\n").rstrip
76
84
  end
77
85
 
@@ -165,14 +173,16 @@ module Woods
165
173
  lines << ''
166
174
  end
167
175
 
168
- %w[orphans dead_ends hubs cycles bridges].each do |section|
176
+ GRAPH_ANALYSIS_SECTIONS.each do |section|
169
177
  items = fetch_key(data, section)
170
178
  next unless items.is_a?(Array) && items.any?
171
179
 
172
180
  lines << "### #{section.tr('_', ' ').capitalize}"
173
181
  lines << ''
174
182
  items.each do |item|
175
- lines << if item.is_a?(Hash) && item.key?('score')
183
+ lines << if item.is_a?(Hash) && item.key?('from')
184
+ graph_edge_line(item, bold: true)
185
+ elsif item.is_a?(Hash) && item.key?('score')
176
186
  "- **#{item['identifier']}** (#{item['type']}) — score: #{item['score']}"
177
187
  elsif item.is_a?(Hash)
178
188
  "- **#{item['identifier']}** (#{item['type']}) — #{item['dependent_count']} dependents"
@@ -184,7 +194,9 @@ module Woods
184
194
  total_key = "#{section}_total"
185
195
  if data[total_key]
186
196
  lines << ''
187
- lines << "_Showing #{items.size} of #{data[total_key]} (truncated)_"
197
+ offset = fetch_key(data, "#{section}_offset", 0)
198
+ position = offset.positive? ? " from offset #{offset}" : ''
199
+ lines << "_Showing #{items.size} of #{data[total_key]}#{position} (truncated)_"
188
200
  end
189
201
  lines << ''
190
202
  end
@@ -354,6 +366,40 @@ module Woods
354
366
 
355
367
  private
356
368
 
369
+ # One line for an edge-shaped report item (from, to, via, then the
370
+ # remaining keys in the order the report emits them).
371
+ #
372
+ # @param item [Hash] string-keyed
373
+ # @param bold [Boolean] wrap the endpoints in ** for markdown
374
+ # @return [String]
375
+ def graph_edge_line(item, bold:)
376
+ detail = item.except('from', 'to', 'via')
377
+ .map { |key, value| "#{key}: #{edge_value(value)}" }.join(', ')
378
+ line = "#{edge_endpoint(item['from'], bold: bold)} -> #{edge_endpoint(item['to'], bold: bold)} " \
379
+ "(#{item['via']})"
380
+ detail.empty? ? "- #{line}" : "- #{line}: #{detail}"
381
+ end
382
+
383
+ # A resolved endpoint is bolded (when markdown calls for it); a nil
384
+ # endpoint (an ambiguous foreign-key owner, for example) is a plain
385
+ # placeholder, never an empty bold pair (`****`).
386
+ #
387
+ # @param value [String, nil]
388
+ # @param bold [Boolean]
389
+ # @return [String]
390
+ def edge_endpoint(value, bold:)
391
+ return '(unresolved)' if value.nil?
392
+
393
+ bold ? "**#{value}**" : value
394
+ end
395
+
396
+ # @param value [Object] a detail hash value; arrays (e.g. `ambiguous_owners`) join
397
+ # as plain text instead of printing Ruby's `Array#inspect` syntax.
398
+ # @return [Object]
399
+ def edge_value(value)
400
+ value.is_a?(Array) ? value.join(', ') : value
401
+ end
402
+
357
403
  def render_traversal(label, data)
358
404
  root = fetch_key(data, :root)
359
405
  found = data[:found] || data['found']
@@ -372,14 +418,36 @@ module Woods
372
418
  nodes.each do |id, info|
373
419
  depth = fetch_key(info, :depth) || 0
374
420
  deps = fetch_key(info, :deps, [])
421
+ # Present only where one identifier names units of several types,
422
+ # so the reader is told rather than shown one of them silently.
423
+ types = fetch_key(info, :types)
424
+ # Likewise for the database: the reader sets it only in a graph
425
+ # spanning more than one.
426
+ database = fetch_key(info, :database)
375
427
  indent = ' ' * depth
376
- lines << "#{indent}- **#{id}**"
428
+ suffix = types ? " (#{Array(types).join(', ')})" : ''
429
+ suffix += " [#{database}]" if database
430
+ lines << "#{indent}- **#{id}**#{suffix}"
377
431
  deps.each { |d| lines << "#{indent} - #{d}" }
378
432
  end
379
433
 
434
+ lines << '' << truncation_note(data, nodes.size) if fetch_key(data, :nodes_total)
435
+
380
436
  lines.join("\n").rstrip
381
437
  end
382
438
 
439
+ # The line `graph_analysis` prints over a paged section, reused
440
+ # verbatim for a paged traversal (B-183).
441
+ #
442
+ # @param data [Hash] the paged payload
443
+ # @param shown [Integer] entries in the page
444
+ # @return [String]
445
+ def truncation_note(data, shown)
446
+ offset = fetch_key(data, :nodes_offset, 0)
447
+ position = offset.positive? ? " from offset #{offset}" : ''
448
+ "_Showing #{shown} of #{fetch_key(data, :nodes_total)}#{position} (truncated)_"
449
+ end
450
+
383
451
  def render_metadata_section(metadata)
384
452
  lines = []
385
453
  lines << '### Metadata'
@@ -69,9 +69,17 @@ module Woods
69
69
  results.each do |r|
70
70
  ident = fetch_key(r, :identifier)
71
71
  type = fetch_key(r, :type)
72
- lines << " #{ident} (#{type})"
72
+ match = fetch_key(r, :match_field)
73
+ line = " #{ident} (#{type})"
74
+ line += " - matched in #{match}" if match
75
+ lines << line
73
76
  end
74
77
 
78
+ note = fetch_key(data, :note)
79
+ partial = fetch_key(data, :partial, false)
80
+ lines << 'partial: true' if partial
81
+ lines << "note: #{note}" if note
82
+
75
83
  lines.join("\n").rstrip
76
84
  end
77
85
 
@@ -135,13 +143,15 @@ module Woods
135
143
  lines << ''
136
144
  end
137
145
 
138
- %w[orphans dead_ends hubs cycles bridges].each do |section|
146
+ GRAPH_ANALYSIS_SECTIONS.each do |section|
139
147
  items = fetch_key(data, section)
140
148
  next unless items.is_a?(Array) && items.any?
141
149
 
142
150
  lines << "#{section.tr('_', ' ').upcase}:"
143
151
  items.each do |item|
144
- lines << if item.is_a?(Hash)
152
+ lines << if item.is_a?(Hash) && item.key?('from')
153
+ " #{plain_edge_line(item)}"
154
+ elsif item.is_a?(Hash)
145
155
  " #{item['identifier']} (#{item['type']}) - #{item['dependent_count']} dependents"
146
156
  else
147
157
  " #{item}"
@@ -149,7 +159,11 @@ module Woods
149
159
  end
150
160
 
151
161
  total_key = "#{section}_total"
152
- lines << " (showing #{items.size} of #{data[total_key]})" if data[total_key]
162
+ offset = fetch_key(data, "#{section}_offset", 0)
163
+ if data[total_key]
164
+ position = offset.positive? ? " from offset #{offset}" : ''
165
+ lines << " (showing #{items.size} of #{data[total_key]}#{position}; truncated)"
166
+ end
153
167
  lines << ''
154
168
  end
155
169
 
@@ -202,7 +216,8 @@ module Woods
202
216
  ident = fetch_key(r, :identifier)
203
217
  type = fetch_key(r, :type)
204
218
  modified = fetch_key(r, :last_modified) || '-'
205
- lines << " #{ident} (#{type}) - #{modified}"
219
+ author = fetch_key(r, :author) || '-'
220
+ lines << " #{ident} (#{type}) - #{modified} - #{author}"
206
221
  end
207
222
 
208
223
  lines.join("\n").rstrip
@@ -223,6 +238,31 @@ module Woods
223
238
 
224
239
  private
225
240
 
241
+ # @param item [Hash] string-keyed edge-shaped report item
242
+ # @return [String]
243
+ def plain_edge_line(item)
244
+ detail = item.except('from', 'to', 'via')
245
+ .map { |key, value| "#{key}: #{edge_value(value)}" }.join(', ')
246
+ line = "#{edge_endpoint(item['from'])} -> #{edge_endpoint(item['to'])} (#{item['via']})"
247
+ detail.empty? ? line : "#{line}: #{detail}"
248
+ end
249
+
250
+ # A nil endpoint (an ambiguous foreign-key owner, for example) prints
251
+ # as a placeholder rather than an empty string.
252
+ #
253
+ # @param value [String, nil]
254
+ # @return [String]
255
+ def edge_endpoint(value)
256
+ value.nil? ? '(unresolved)' : value
257
+ end
258
+
259
+ # @param value [Object] a detail hash value; arrays (e.g. `ambiguous_owners`) join
260
+ # as plain text instead of printing Ruby's `Array#inspect` syntax.
261
+ # @return [Object]
262
+ def edge_value(value)
263
+ value.is_a?(Array) ? value.join(', ') : value
264
+ end
265
+
226
266
  def render_plain_traversal(label, data)
227
267
  root = fetch_key(data, :root)
228
268
  found = data[:found] || data['found']
@@ -241,11 +281,19 @@ module Woods
241
281
  nodes.each do |id, info|
242
282
  depth = fetch_key(info, :depth) || 0
243
283
  deps = fetch_key(info, :deps, [])
284
+ # Set by the reader only in a graph spanning more than one database.
285
+ database = fetch_key(info, :database)
244
286
  indent = ' ' * (depth + 1)
245
- lines << "#{indent}#{id}"
287
+ lines << "#{indent}#{id}#{" [#{database}]" if database}"
246
288
  deps.each { |d| lines << "#{indent} -> #{d}" }
247
289
  end
248
290
 
291
+ if fetch_key(data, :nodes_total)
292
+ offset = fetch_key(data, :nodes_offset, 0)
293
+ position = offset.positive? ? " from offset #{offset}" : ''
294
+ lines << " (showing #{nodes.size} of #{fetch_key(data, :nodes_total)}#{position}; truncated)"
295
+ end
296
+
249
297
  lines.join("\n").rstrip
250
298
  end
251
299
  end