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
data/docs/INTERNALS.md ADDED
@@ -0,0 +1,418 @@
1
+ # Woods internals
2
+
3
+ This doc explains how Woods works from the inside, how extraction, storage, retrieval, and the two MCP servers fit together.
4
+
5
+ ---
6
+
7
+ ## How does Woods work?
8
+
9
+ Woods runs in three phases across two environments:
10
+
11
+ ```
12
+ Inside Rails app (rake task):
13
+ 1. Extract, 35 extractors introspect the live Rails environment
14
+ 2. Resolve, dependency graph is built and enriched with git data
15
+ 3. Write, one JSON file per code unit to tmp/woods/
16
+
17
+ On the host / in CI:
18
+ 4. Embed, units are chunked and embedded into a vector store
19
+ 5. Query, MCP server reads the JSON index and answers questions
20
+ ```
21
+
22
+ The key insight: **extraction requires a booted Rails application** (`ActiveRecord::Base.descendants`, `Rails.application.routes`, etc.), but *querying* does not. The Index MCP server reads static JSON, no Rails, no database.
23
+
24
+ ---
25
+
26
+ ## Pipeline overview
27
+
28
+ ```
29
+ ┌──────────────────────────────────────────────────────────┐
30
+ │ Rails Application │
31
+ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
32
+ │ │ Extract │──▶│ Resolve │──▶│ Enrich │ │
33
+ │ │ 35 types │ │ graph │ │ git │ │
34
+ │ └──────────┘ └──────────┘ └──────────┘ │
35
+ │ │ │
36
+ │ ▼ │
37
+ │ ┌──────────────┐ │
38
+ │ │ Write JSON │ │
39
+ │ │ tmp/woods/ │ │
40
+ │ └──────────────┘ │
41
+ └──────────────────────────────────────────────────────────┘
42
+ │
43
+ ▼
44
+ ┌──────────────────────────────────────────────────────────┐
45
+ │ Host / CI Environment │
46
+ │ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
47
+ │ │ Embed │──▶│ Index │ │ MCP Index Server │ │
48
+ │ │ OpenAI │ │ pgvector│ │ 14 default, no Rails │ │
49
+ │ │ Ollama │ │ Qdrant │ └──────────────────────┘ │
50
+ │ └──────────┘ └──────────┘ │
51
+ └──────────────────────────────────────────────────────────┘
52
+ ▲
53
+ ┌────────────────────────┘
54
+ │ Console MCP Server
55
+ │ 9 tools default, live Rails
56
+ │ (runs inside the app)
57
+ ```
58
+
59
+ ---
60
+
61
+ ## What is an ExtractedUnit?
62
+
63
+ `ExtractedUnit` is the universal currency of Woods. Extractors produce them, the dependency graph connects them, the embedding pipeline consumes them, and the retrieval pipeline returns them.
64
+
65
+ Every unit carries:
66
+
67
+ - **`identifier`**: unique key, usually the class name (`"User"`, `"OrdersController"`) or a descriptive string for non-class units (`"POST /orders"`)
68
+ - **`type`**: what kind of thing this is (`:model`, `:controller`, `:service`, `:route`, etc.)
69
+ - **`file_path`**: relative path from `Rails.root` (e.g., `"app/models/user.rb"`). Gem-owned units (engine models such as `ActiveStorage::Blob`, framework sources) keep their absolute gem path, since nothing under `Rails.root` defines them
70
+ - **`source_code`**: the annotated source: for models this includes concerns inlined and schema prepended; for controllers this includes a route context header
71
+ - **`metadata`**: type-specific structured data (associations, callbacks, actions, fields, etc.)
72
+ - **`dependencies`**: forward edges: `[{ type:, target:, via: }]`
73
+ - **`dependents`**: reverse edges, populated in a second pass after all units are registered
74
+ - **`chunks`**: semantic sub-sections for large units (populated by `SemanticChunker`)
75
+ - **`estimated_tokens`**: approximate token count using 4.0 chars/token (benchmarked conservative floor)
76
+
77
+ Units are serialized to JSON with two additional fields: `extracted_at` (timestamp) and `source_hash` (SHA-256 of source_code for change detection).
78
+
79
+ See [EXTRACTOR_REFERENCE.md](EXTRACTOR_REFERENCE.md) for the full field table and a complete example JSON.
80
+
81
+ ### Internal static self-map
82
+
83
+ `woods:self_map[output_dir]` is an internal Woods developer tool. It snapshots
84
+ and statically maps Woods Ruby sources, rake tasks, and executables, then
85
+ publishes them through the ordinary atomic index layout. It exposes typed
86
+ `ruby_class`, `ruby_module`, `ruby_method`, and `ruby_file` units to the Index
87
+ MCP reader. It does not boot Rails or include tests and documentation, and
88
+ must not be presented as equivalent to runtime Rails extraction.
89
+
90
+ ---
91
+
92
+ ## How does extraction work?
93
+
94
+ ### Eager loading
95
+
96
+ Before any extractor runs, `Rails.application.eager_load!` is called once to load all application classes into memory. If `eager_load!` fails with a `NameError` (common when `app/graphql/` references an uninstalled gem. Zeitwerk processes directories alphabetically, so a failure in `graphql/` can prevent `models/` from loading), the orchestrator falls back to per-directory loading across the 19 directories in `EXTRACTION_DIRECTORIES`.
97
+
98
+ ### Five phases
99
+
100
+ ```ruby
101
+ # Phase 1: Extract
102
+ EXTRACTORS.each { |type, klass| @results[type] = klass.new.extract_all }
103
+
104
+ # Phase 1.5: Deduplicate
105
+ # Duplicate identifiers (e.g., engine routes duplicating app routes) are dropped
106
+
107
+ # Phase 2: Resolve dependents
108
+ # Second pass: if A.dependencies includes B, B.dependents gets a back-reference to A
109
+
110
+ # Phase 3: Enrich with git
111
+ # batch git log for all file paths → last_modified, contributors, change_frequency
112
+
113
+ # Phase 4: Graph analysis
114
+ # PageRank, orphans, dead ends, hubs, cycles, bridges
115
+
116
+ # Phase 5: Write
117
+ # One JSON file per unit + _index.json per type + dependency_graph.json + SUMMARY.md + manifest.json
118
+ ```
119
+
120
+ ### Concurrent mode
121
+
122
+ Set `config.concurrent_extraction = true` to run extractors in parallel threads. Thread safety is ensured by:
123
+ - Pre-computing `ModelNameCache` before threads start (avoids a `||=` race)
124
+ - Each thread gets its own extractor instance (no shared mutable state)
125
+ - Results are collected via `Mutex`-protected hash
126
+ - Dependency graph registration happens sequentially after all threads join
127
+
128
+ ### Incremental extraction
129
+
130
+ `extract_changed(changed_files)` re-extracts only the units affected by a set of changed files. It:
131
+ 1. Loads the existing `dependency_graph.json`
132
+ 2. Finds directly-changed units via the file map
133
+ 3. BFS-traverses reverse edges to find transitively affected units
134
+ 4. Re-extracts each affected unit using the appropriate extractor method
135
+ 5. Updates only the affected JSON files and the type-level `_index.json`
136
+
137
+ **Incremental extraction re-runs wholesale**, rather than skipping, the ten unit types that don't map to individual files: `route`, `middleware`, `engine`, `scheduled_job`, `state_machine`, `factory`, `event`, `database_view`, `rails_source` (gated by `include_framework_sources`), and `package`. Each has its own trigger path (e.g. `config/routes.rb` for routes, `Gemfile.lock` for middleware/engines), when it changes, `Extractor::WHOLE_APP_EXTRACTORS` re-runs that extractor in full instead of diffing files. `gem_source` works the same way, also triggered by `Gemfile.lock`.
138
+
139
+ ---
140
+
141
+ ## How does the dependency graph work?
142
+
143
+ The `DependencyGraph` is a directed graph where nodes are `ExtractedUnit` identifiers and edges are dependency relationships. It tracks:
144
+
145
+ - **Forward edges** (`@edges`): what each unit depends on, populated when units are registered
146
+ - **Reverse edges** (`@reverse`): what depends on each unit, built during registration and in the resolve phase
147
+
148
+ ```ruby
149
+ graph = DependencyGraph.new
150
+ graph.register(user_unit) # adds User to nodes, adds User→Order edge (from belongs_to)
151
+ graph.register(order_unit) # adds Order to nodes
152
+
153
+ graph.dependencies_of("User") # => ["Order", "UserService"]
154
+ graph.dependents_of("Order") # => ["User", "OrdersController"]
155
+
156
+ # Blast radius: what needs re-indexing if user.rb changes?
157
+ graph.affected_by(["app/models/user.rb"]) # BFS over reverse edges
158
+ ```
159
+
160
+ ### PageRank scoring
161
+
162
+ `DependencyGraph#pagerank` computes importance scores using the reverse edge structure: units with many dependents score higher. This matches the intuition that "important" units are the ones many other units depend on, the same insight as Google's PageRank applied to code graphs. Package units (#280) add nodes and `package_dependency` edges to the graph, so scores shift slightly wherever they land.
163
+
164
+ Scores feed into the retrieval ranker as one signal in the final ranking formula.
165
+
166
+ ### GraphAnalyzer: Structural metrics
167
+
168
+ `GraphAnalyzer` computes read-only structural reports from the graph:
169
+
170
+ | Metric | What it means |
171
+ |--------|--------------|
172
+ | **Orphans** | Units with no dependents, potential dead code or public entry points. Framework sources are excluded (they're naturally unreferenced in the reverse index). |
173
+ | **Dead ends** | Units with no dependencies, self-contained leaf nodes (value objects, standalone utilities) |
174
+ | **Hubs** | Units with many dependents, architectural bottlenecks; changes here have high blast radius |
175
+ | **Cycles** | Circular dependencies, A→B→C→A. Detected via DFS, and capped: `graph_cycle_limit` (default 500) bounds how many are enumerated and `graph_cycle_max_length` (default 50) skips one longer than that. Either cap firing sets `stats.cycle_limit_reached`. Set both to `nil` for exhaustive enumeration. |
176
+ | **Bridges** | Edges whose removal would disconnect the graph, high-risk structural connections |
177
+ | **Cross-database edges** | Association or foreign-key edges whose two ends resolve to different databases. A `has_many :through` is reported as `join_through_across_databases` when `disable_joins` is false and `from_db`, `through_db` (the join model's database), or `to_db` disagree. A foreign key never resolves to an owner in the source database, even when another database also claims the table; when every owner sits elsewhere and they span more than one database, the entry comes back with `to: nil` and an `ambiguous_owners` list instead of guessing. Read from graph node and edge attributes, so full and incremental runs agree. Scoped to primary nodes (units registered in the graph), not variants. |
178
+ | **Volatile dependencies** | Edges that point at a unit changing at least `volatile_dependency_ratio` times more often than the dependent (POODR: depend on things that change less often than you do). Dependencies with fewer than 5 commits or a `new` change frequency are skipped. Ranked by the dependency's PageRank; the persisted list keeps the top 20, while `stats.volatile_dependency_count` reports the full qualifying count and `stats.volatile_dependencies_limit` reports the cap. |
179
+ | **Undeclared package edges** | Edges that cross a Packwerk package boundary the source package does not list in `dependencies`. Membership comes from each unit's `package` node attribute, declarations from the package unit's own `package_dependency` edges. Woods reports the boundary; enforcement stays with `packwerk check` / `pks check`. |
180
+
181
+ Analysis results are written to `graph_analysis.json` and surfaced in `SUMMARY.md`.
182
+
183
+ #### Sections, their stat keys, and which are capped
184
+
185
+ Section names are plural, stat keys are singular. Do not derive one from the
186
+ other: `bridges` has no stat key at all, and `hub_count` counts the array as
187
+ persisted rather than every hub in the graph.
188
+
189
+ | Section in `graph_analysis.json` | Stat key | Capped in the artifact | What the stat counts |
190
+ |---|---|---|---|
191
+ | `orphans` | `stats.orphan_count` | no | every orphan |
192
+ | `dead_ends` | `stats.dead_end_count` | no | every dead end |
193
+ | `hubs` | `stats.hub_count` | yes, top 20 by dependent count | the persisted array, so it stops at 20 |
194
+ | `cycles` | `stats.cycle_count`, `stats.cycle_limit_reached` | yes, `graph_cycle_limit` cycles of at most `graph_cycle_max_length` nodes | the count is the persisted array; the flag says whether either cap fired |
195
+ | `bridges` | none | yes, top 10 by score | not counted |
196
+ | `cross_database_edges` | `stats.cross_database_edge_count` | no | every crossing edge |
197
+ | `volatile_dependencies` | `stats.volatile_dependency_count`, `stats.volatile_dependencies_limit` | yes, top 20 by the dependency's PageRank | the count is every qualifying edge; the limit is the cap |
198
+ | `undeclared_package_edges` | `stats.undeclared_package_edge_count` | no | every undeclared crossing |
199
+
200
+ A capped section means the array on disk is a page, not the population.
201
+ `volatile_dependencies` publishes both numbers and `cycles` publishes a
202
+ truncation flag, so those two are the ones where a reader can tell truncation
203
+ from a short list without re-running the analysis. Cycle enumeration stops at
204
+ the cap rather than counting past it, which is why `cycles` gets a flag and not
205
+ a population count.
206
+ The `graph_analysis` MCP tool pages each section independently when given
207
+ `limit` or `offset`, and its `Showing N of M (truncated)` line counts the array
208
+ it read from disk, not the population the stat keys count.
209
+
210
+ ---
211
+
212
+ ## How does retrieval work?
213
+
214
+ Retrieval is a four-stage pipeline coordinated by `Retriever`:
215
+
216
+ ```
217
+ Query → [Classify] → [Execute] → [Rank] → [Assemble] → Context string
218
+ ```
219
+
220
+ ### Stage 1: Query classification (`QueryClassifier`)
221
+
222
+ Classifies the query to determine:
223
+ - **Intent**: lookup, explanation, tracing, search, framework
224
+ - **Scope**: specific identifier, type filter, or broad
225
+ - **Target type**: `:model`, `:controller`, `:service`, etc. (or nil for cross-type)
226
+
227
+ Classification determines which search strategy to use and whether framework source context is relevant.
228
+
229
+ ### Stage 2: Search execution (`SearchExecutor`)
230
+
231
+ Executes one or more search strategies based on classification:
232
+
233
+ | Strategy | When used | How |
234
+ |----------|-----------|-----|
235
+ | **Vector** | Semantic/conceptual queries | Embeds the query and finds nearest neighbors |
236
+ | **Keyword** | Identifier lookups by name | Exact or prefix match on `identifier` field |
237
+ | **Graph** | "What uses X?" / "What does X depend on?" | Traverses forward/reverse edges from a starting node |
238
+ | **Hybrid** | Default for ambiguous queries | Combines vector + keyword, re-ranked via RRF |
239
+
240
+ ### Stage 3: Ranking (`Ranker`)
241
+
242
+ Re-ranks candidates using multiple signals with weighted combination:
243
+
244
+ - Vector similarity score
245
+ - Keyword match quality
246
+ - PageRank importance score
247
+ - Recency (git `last_modified`)
248
+ - Type relevance to the query's target type
249
+
250
+ Uses **Reciprocal Rank Fusion (RRF)** to merge ranked lists from multiple search strategies without score normalization.
251
+
252
+ ### Stage 4: Context assembly (`ContextAssembler`)
253
+
254
+ Allocates token budget across layers:
255
+
256
+ ```
257
+ Token Budget Allocation:
258
+ ├── 10% Structural overview ("Codebase: 42 units, 10 models, 5 controllers, ...")
259
+ └── 90% Remaining budget, split by framework context:
260
+ ├── framework active: 55% primary / 25% supporting / 20% framework reference
261
+ └── framework inactive: 65% primary / 35% supporting (framework gets nothing)
262
+ ```
263
+
264
+ Units that exceed the budget are truncated to their first semantic chunk. The assembled context string is then optionally post-processed by a formatter (`context_format: :claude`, `:markdown`, `:plain`, `:json`).
265
+
266
+ ---
267
+
268
+ ## What storage backends are available?
269
+
270
+ Woods uses three independent store abstractions:
271
+
272
+ | Store | Purpose | Available Backends |
273
+ |-------|---------|-------------------|
274
+ | **VectorStore** | Embedding vectors for semantic search | In-memory (dev/test), pgvector (PostgreSQL), Qdrant |
275
+ | **MetadataStore** | Unit metadata for keyword search and type filtering | In-memory, SQLite |
276
+ | **GraphStore** | Dependency graph for graph-based traversal | In-memory, JSON file (via `dependency_graph.json`) |
277
+
278
+ The gem is backend-agnostic by design. MySQL and PostgreSQL have different JSON querying, indexing, and CTE syntax, no backend-specific SQL is written into the core.
279
+
280
+ ### Configuration presets
281
+
282
+ ```ruby
283
+ # Local development (SQLite + in-memory vector, single process)
284
+ Woods.configure_with_preset(:local)
285
+
286
+ # Shared filesystem (rake embed writes a dump; a separate MCP server reads it)
287
+ Woods.configure_with_preset(:shared_filesystem)
288
+
289
+ # PostgreSQL with pgvector (complete credentials/connection are required)
290
+ Woods.configure_with_preset(:postgresql) do |config|
291
+ config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
292
+ config.vector_store_options = { connection: ActiveRecord::Base.connection }
293
+ end
294
+
295
+ # Production (Qdrant for vectors, SQLite for metadata)
296
+ Woods.configure_with_preset(:production) do |config|
297
+ config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
298
+ config.vector_store_options = {
299
+ url: ENV.fetch('QDRANT_URL'),
300
+ collection: ENV.fetch('WOODS_QDRANT_COLLECTION', 'woods'),
301
+ allow_private_hosts: true # only when QDRANT_URL is deliberately private
302
+ }
303
+ end
304
+ ```
305
+
306
+ Or wire backends manually:
307
+
308
+ ```ruby
309
+ Woods.configure do |config|
310
+ config.vector_store = :qdrant
311
+ config.vector_store_options = {
312
+ url: "http://localhost:6333",
313
+ collection: "woods",
314
+ allow_private_hosts: true # explicit opt-in for trusted localhost
315
+ }
316
+ config.metadata_store = :sqlite
317
+ config.embedding_provider = :openai
318
+ config.embedding_options = { api_key: ENV.fetch("OPENAI_API_KEY") }
319
+ config.embedding_model = "text-embedding-3-small"
320
+ end
321
+ ```
322
+
323
+ ---
324
+
325
+ ## Why are there two MCP servers?
326
+
327
+ The two servers have fundamentally different runtime requirements:
328
+
329
+ ### Index server (`woods-mcp`)
330
+
331
+ **29 schemas; 14 register in the packaged default. Two resources and two templates. Reads pre-extracted JSON without booting Rails.**
332
+
333
+ Starts with a path to the extraction output directory and reads from it:
334
+
335
+ ```bash
336
+ woods-mcp-start /path/to/rails-app/tmp/woods
337
+ ```
338
+
339
+ Use the Index Server for:
340
+ - Looking up models, controllers, services by name
341
+ - Dependency graph traversal ("what depends on User?")
342
+ - Semantic search across the codebase
343
+ - Pipeline management (status, trigger re-extraction)
344
+ - Temporal snapshots (comparing codebase state over time)
345
+ - Feedback collection
346
+
347
+ Note: pipeline management and feedback collection require specialized builder collaborators. The packaged `woods-mcp` executable wires neither, so those tools are not registered in a standard launch. Snapshot tools register only when snapshots are enabled (`enable_snapshots: true`, `WOODS_SNAPSHOTS=true`, or an existing snapshot database in the index directory).
348
+
349
+ The Index Server is safe to run anywhere, it has no database connection and makes no writes to the Rails application.
350
+
351
+ ### Console server (`woods-console-mcp`)
352
+
353
+ **31 tool schemas across 4 tiers, but only 9 are registered by default (11 with read tools enabled). Runs embedded inside the Rails process, no separate bridge process.**
354
+
355
+ Starts via rake task inside the Rails app (or `docker compose exec`):
356
+
357
+ ```bash
358
+ bundle exec rake woods:console
359
+ ```
360
+
361
+ Use the Console Server for:
362
+ - Live database queries (`User.where(...)` with schema awareness). Tier 1, registered by default
363
+ - Model diagnostics, job queue monitoring, cache inspection. Tier 2/3 schemas exist but are **not registered** in any supported mode today
364
+ - SQL/query. Tier 4, opt-in via `console_embedded_read_tools`; `console_eval` is inventory-only and unavailable
365
+
366
+ All Console Server queries run inside a **rolled-back transaction** (`SafeContext`). SQL is validated by `SqlValidator` (rejects DML/DDL at the string level) before any database interaction. Writes are silently discarded by the rollback, this is intentional defense-in-depth. See [MCP_SERVERS.md](MCP_SERVERS.md#console-server) for the full tool inventory and tier breakdown.
367
+
368
+ ### Which should I use?
369
+
370
+ | Task | Server |
371
+ |------|--------|
372
+ | Find the User model source | Index |
373
+ | What jobs does CheckoutService enqueue? | Index |
374
+ | How many pending orders are in the database? | Console |
375
+ | What does our middleware stack look like? | Index |
376
+ | Run a query against the live database | Console |
377
+ | Trigger a re-extraction | Index |
378
+ | Check Sidekiq queue depth | Console |
379
+
380
+ ---
381
+
382
+ ## How does semantic chunking work?
383
+
384
+ Large units are split into semantic chunks before embedding. The `SemanticChunker` is type-aware, it doesn't split on arbitrary token counts.
385
+
386
+ ### Model chunking
387
+
388
+ Models are split into purpose-specific sections:
389
+
390
+ ```
391
+ summary, class declaration, table info, concerns list
392
+ associations, all has_many, belongs_to, has_one, HABTM
393
+ callbacks, all before/after/around hooks with side-effects
394
+ validations, all validates and validate calls
395
+ scopes, named scopes
396
+ methods, remaining public and private methods
397
+ ```
398
+
399
+ Each chunk includes a header with the unit's identifier, type, and file path so it's self-contained when retrieved without the parent.
400
+
401
+ ### Controller chunking
402
+
403
+ Controllers chunk per-action:
404
+
405
+ ```
406
+ summary, class declaration, before_action filters, layout
407
+ <action>, each public action method with its applicable filters and route context
408
+ ```
409
+
410
+ This matches how queries actually come in: "how does the create action work?" retrieves only the `create` chunk and the filter context, not the entire controller.
411
+
412
+ ### Threshold
413
+
414
+ Units below 200 estimated tokens stay as a single `:whole` chunk. Above that, the semantic chunker applies type-specific splitting, with a line-based fallback for units that are still too large. The fallback's per-chunk limits are not fixed constants, they derive from the embedding provider's input budget (`max_chars` from `max_input_tokens` scaled by the provider's chars-per-token ratio, or an exact `max_tokens` when a tokenizer is available), so a provider with a larger context yields larger chunk ceilings.
415
+
416
+ ### Why not just split by token count?
417
+
418
+ Token-count splits break semantic units arbitrarily, an `associations` section split mid-way loses context. Semantic splits align with how the code is actually understood: "tell me about the associations" maps to the associations chunk, not to arbitrary line ranges 150–300.
@@ -0,0 +1,144 @@
1
+ # MCP HTTP Transport
2
+
3
+ Reference for `exe/woods-mcp-http`, the Index Server's HTTP transport, for hosts where a stdio subprocess isn't practical (shared access, multiple clients, a remote agent).
4
+
5
+ ## What's shipped
6
+
7
+ - **`exe/woods-mcp-http`**: the only HTTP entry point. There is no `--http` flag on `woods-mcp` and no `Woods::MCP::RackMiddleware` for the Index Server (the Console Server has its own separate `RackMiddleware`, see [CONSOLE_MCP_SETUP.md](CONSOLE_MCP_SETUP.md)).
8
+ - Built on `MCP::Server::Transports::StreamableHTTPTransport` from the `mcp` gem (`>= 1.2, < 2.0`), which implements MCP protocol version 2026-07-28 (legacy `initialize` still served for older clients).
9
+ - **Stateless by default.** `WOODS_MCP_HTTP_STATELESS=0` restores legacy session mode.
10
+ - **Bearer auth** (`WOODS_MCP_HTTP_TOKEN`) and an **origin guard** (`WOODS_MCP_HTTP_ALLOWED_ORIGINS`), see [Security](#security).
11
+ - Needs a Rack-compatible server (e.g. `puma`) in the host bundle. Uses the `rackup` gem when present, falls back to Rack 2's handler registry otherwise.
12
+
13
+ ## Running it
14
+
15
+ ```bash
16
+ bundle exec woods-mcp-http ./tmp/woods # loopback, stateless, no auth
17
+ HOST=0.0.0.0 PORT=9292 WOODS_MCP_HTTP_TOKEN=$(bundle exec rake woods:generate_token 2>/dev/null) \
18
+ bundle exec woods-mcp-http ./tmp/woods # non-loopback requires a token
19
+ ```
20
+
21
+ | Env var | Default | Meaning |
22
+ |---|---|---|
23
+ | `PORT` | `9292` | Listen port |
24
+ | `HOST` | `localhost` | Listen host. Anything non-loopback requires `WOODS_MCP_HTTP_TOKEN` (server refuses to boot otherwise) |
25
+ | `WOODS_MCP_HTTP_TOKEN` | unset | Bearer token required on every request when set |
26
+ | `WOODS_MCP_HTTP_STATELESS` | `1` | `0` restores legacy session mode (`Mcp-Session-Id`, GET SSE stream, DELETE teardown) |
27
+ | `WOODS_MCP_HTTP_ALLOWED_ORIGINS` | unset (loopback origins only) | Comma-separated origin allow-list for the `Origin` header guard |
28
+ | `WOODS_DIR` / `ARGV[0]` | cwd | Index directory to serve |
29
+
30
+ ## Statelessness
31
+
32
+ MCP 2026-07-28 ([SEP-2567](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567)) removes protocol-level sessions, and `woods-mcp-http` runs stateless by default.
33
+
34
+ For this server the session was never carrying anything: the index lives on disk, `IndexReader` self-refreshes off the published generation, and no tool holds per-client state. What the session *did* do was tie every client to one server process, so restarting the server (gem upgrade, machine sleep, worktree rebuild) invalidated every session and forced each client through a re-initialize. Stateless makes a restart invisible, and lets several instances serve one volume-mounted index without sticky routing.
35
+
36
+ What changes in stateless mode:
37
+
38
+ | Behaviour | Stateless (default) | Session mode (`=0`) |
39
+ |---|---|---|
40
+ | `Mcp-Session-Id` | never issued or required | issued on `initialize`, required after |
41
+ | `GET` on the MCP endpoint | `405 Method Not Allowed` | opens a standalone SSE stream |
42
+ | `DELETE` on the MCP endpoint | `200` no-op (see note) | terminates the session |
43
+ | A request carrying a stale `Mcp-Session-Id` | ignored, served normally | `404`, client must re-initialize |
44
+ | Server-initiated notifications | **not delivered**: there is no stream to push on | delivered to the session's SSE stream |
45
+ | Server restart | invisible to clients | every client must re-initialize |
46
+ | Horizontal scaling | any POST may hit any instance | requires sticky routing |
47
+
48
+ > **`Mcp-Session-Id` and CORS.** In stateless mode the header is neither read nor emitted, so it is absent from `Access-Control-Expose-Headers`. A client that depends on it needs `WOODS_MCP_HTTP_STATELESS=0`, a transitional escape hatch, since the header is gone from the specification.
49
+
50
+ > **DELETE returns 200, not 405.** The specification says a server supporting only this revision *should* answer `405` to DELETE; the `mcp` gem instead answers `200 {"success": true}` in stateless mode. This is the SDK's call, not Woods', there is no session to terminate either way, so the request is a no-op whichever status it carries.
51
+
52
+ The last row is the one that matters in practice: a client holding a session id from *before* a restart is simply served, rather than getting a `404` and having to re-initialize.
53
+
54
+ > **Notifications and long-running tools.** Stateless mode has no channel to push server-initiated notifications on at all (no `Last-Event-ID` resumability either, per SEP-2567). Woods' packaged Index tools are short reads. Custom embedded servers that wire pipeline tools use durable task polling rather than depending on a push; those tools are not registered by the normal packaged executable.
55
+
56
+ ## Security
57
+
58
+ The `mcp` gem does not authenticate at the transport layer, so `exe/woods-mcp-http` enforces authentication itself:
59
+
60
+ | `HOST` | `WOODS_MCP_HTTP_TOKEN` set? | Result |
61
+ |-----------------------------------|------------------------------|---------------------------------------------------------------|
62
+ | `localhost` / `127.0.0.1` / `::1` | no | Boots with a warning; unauthenticated loopback access only |
63
+ | `localhost` / `127.0.0.1` / `::1` | yes | Boots; every request must present `Authorization: Bearer …` |
64
+ | anything else | no | **Refuses to boot**: aborts with a pointer to this section |
65
+ | anything else | yes | Boots; every request must present `Authorization: Bearer …` |
66
+
67
+ This matches the posture used by other unauthenticated local servers (Redis `protected-mode`, Postgres `listen_addresses`): loopback works freely, non-loopback requires an explicit credential.
68
+
69
+ ### Generating a token
70
+
71
+ ```bash
72
+ bundle exec rake woods:generate_token
73
+ # prints a 64-char hex token to stdout
74
+ ```
75
+
76
+ Any cryptographically random string works; `openssl rand -hex 32` is equivalent.
77
+
78
+ ### Running the server with a token
79
+
80
+ ```bash
81
+ export WOODS_MCP_HTTP_TOKEN=$(bundle exec rake woods:generate_token 2>/dev/null)
82
+ HOST=0.0.0.0 PORT=9292 bundle exec woods-mcp-http ./tmp/woods
83
+ ```
84
+
85
+ Clients must send `Authorization: Bearer $WOODS_MCP_HTTP_TOKEN` on every request. Missing or mismatched tokens get `HTTP 401` with a `WWW-Authenticate: Bearer` header; comparison is constant-time (`Rack::Utils.secure_compare`).
86
+
87
+ ### Browser origins (DNS rebinding defense)
88
+
89
+ A second middleware, `Woods::MCP::OriginGuard`, rejects requests whose `Origin` header is outside an allow-list. Requests without an `Origin` header (curl, MCP stdio clients, server-to-server) pass through, bearer auth still gates them.
90
+
91
+ | Scenario | `WOODS_MCP_HTTP_ALLOWED_ORIGINS` | Origins accepted |
92
+ |--------------------|-----------------------------------------|-------------------------------------------------------------------|
93
+ | default | unset | `http(s)://localhost`, `127.0.0.1`, `::1` (any port) |
94
+ | explicit list | `https://app.example.com` | exactly `https://app.example.com`, loopback no longer allowed |
95
+ | multiple origins | `https://a.example,https://b.example` | each listed origin |
96
+
97
+ `OPTIONS` preflights are answered with the matching `Access-Control-Allow-*` headers; successful responses carry `Access-Control-Allow-Origin` and `Vary: Origin`. `Access-Control-Expose-Headers: Mcp-Session-Id` appears only in legacy session mode (`WOODS_MCP_HTTP_STATELESS=0`).
98
+
99
+ ### TLS termination
100
+
101
+ The server speaks plain HTTP. Any deployment beyond a single trusted host should front it with a reverse proxy that handles TLS, HTTP/2, and connection limits.
102
+
103
+ **Caddy** (automatic HTTPS via Let's Encrypt):
104
+
105
+ ```caddyfile
106
+ mcp.example.com {
107
+ reverse_proxy 127.0.0.1:9292
108
+ }
109
+ ```
110
+
111
+ **nginx** (bring-your-own cert):
112
+
113
+ ```nginx
114
+ server {
115
+ listen 443 ssl http2;
116
+ server_name mcp.example.com;
117
+
118
+ ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
119
+ ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
120
+
121
+ location / {
122
+ proxy_pass http://127.0.0.1:9292;
123
+ proxy_set_header Host $host;
124
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
125
+ proxy_set_header X-Forwarded-Proto $scheme;
126
+ proxy_http_version 1.1;
127
+ proxy_set_header Connection "";
128
+
129
+ # SSE streaming: disable buffering, raise timeouts
130
+ proxy_buffering off;
131
+ proxy_read_timeout 3600s;
132
+ proxy_send_timeout 3600s;
133
+ }
134
+ }
135
+ ```
136
+
137
+ Bind `woods-mcp-http` to `HOST=127.0.0.1` when a proxy handles the public surface; keep `WOODS_MCP_HTTP_TOKEN` set so the proxy-to-app hop still requires a bearer.
138
+
139
+ ### Known limitations
140
+
141
+ - **Plaintext tokens on the wire.** Bearer auth over HTTP leaks the token to anything on the network path. Terminate TLS at a reverse proxy (nginx, Caddy, Cloudflare) for any deployment beyond a single trusted host.
142
+ - **No rotation primitive.** There is one static token. Rotating it requires restarting the server and updating clients.
143
+ - **No per-client identity.** Every valid request is equally trusted; there are no scopes or audit trails. Treat the token as a shared secret for a trust boundary you already control.
144
+ - **No in-process TLS.** TLS is a reverse-proxy concern. Caddy/nginx/Cloudflare handle certs, HSTS, and cipher policy better than a Rack-level implementation would.