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/WHY_WOODS.md ADDED
@@ -0,0 +1,219 @@
1
+ # Why Woods?
2
+
3
+ AI coding assistants are only as good as the context they receive. For Rails applications,
4
+ that context is almost always wrong, not because the AI is bad, but because Rails hides
5
+ most of its behavior behind conventions, concerns, and runtime magic that no static tool
6
+ can see. Woods fixes this.
7
+
8
+ ---
9
+
10
+ ## The Problem: LLMs Get Rails Wrong
11
+
12
+ Rails is a framework built on convention over configuration. That's great for developers,
13
+ but it means the "real" code, the callbacks, the scopes, the route bindings, the concern
14
+ behavior, isn't visible in source files. An LLM reading your files sees the skeleton.
15
+ Woods shows the whole body.
16
+
17
+ **Three concrete examples:**
18
+
19
+ ### "What callbacks fire when User saves?"
20
+
21
+ Without Woods, an LLM reads your 40-line `User` model and guesses:
22
+
23
+ ```
24
+ User has: before_validation :normalize_email, before_save :set_slug
25
+ ```
26
+
27
+ But `User` includes `Auditable`, `Searchable`, and `SoftDeletable`, each with their own
28
+ callback chains. The real answer is a chain of 11 callbacks across 4 files, including
29
+ `after_commit :reindex_search` and `after_destroy :purge_avatar`.
30
+
31
+ With Woods, the model unit has all concerns inlined and the full resolved callback
32
+ chain in structured metadata. The LLM sees exactly what Rails sees at runtime.
33
+
34
+ ### "What routes map to OrdersController?"
35
+
36
+ Without Woods, an LLM assumes standard REST and guesses:
37
+
38
+ ```
39
+ GET /orders orders#index
40
+ GET /orders/:id orders#show
41
+ POST /orders orders#create
42
+ ...
43
+ ```
44
+
45
+ Your app has custom routes: `POST /checkout` → `orders#create`, `PUT /orders/:id/cancel`
46
+ → `orders#cancel`, and a nested resource under `/shops/:shop_id`. The LLM's guess is wrong
47
+ on path, wrong on nesting, and missing the custom action entirely.
48
+
49
+ With Woods, `ControllerExtractor` calls `Rails.application.routes` at runtime and
50
+ prepends the real route table to the controller source. No guessing.
51
+
52
+ ### "What does the checkout flow do?"
53
+
54
+ Without Woods, an LLM reads `CheckoutService` and sees a 60-line service object.
55
+ It describes what the service does, but misses that `order.save!` triggers `after_commit
56
+ :send_confirmation_email` on `Order`, which itself enqueues `InventoryJob` via
57
+ `after_save :reserve_stock` on `LineItem`.
58
+
59
+ With Woods, the dependency graph links `CheckoutService` → `Order` → `LineItem` →
60
+ `InventoryJob`. A single retrieval call assembles the full execution picture: the service,
61
+ the models it touches, the callbacks those models fire, and the jobs those callbacks enqueue.
62
+
63
+ ---
64
+
65
+ ## What Does Woods Do?
66
+
67
+ Woods runs inside your Rails application and produces structured, runtime-accurate
68
+ representations of every layer: models, controllers, services, jobs, components, routes,
69
+ middleware, and more.
70
+
71
+ **The key outcomes:**
72
+
73
+ **Concern inlining.** Every `include`d concern is read from disk and embedded directly into
74
+ the model unit. When an AI asks about `User`, it gets `User` + `Auditable` + `Searchable`
75
+ in one context block, not three separate lookups.
76
+
77
+ ```ruby
78
+ # What an AI sees without Woods (app/models/user.rb):
79
+ class User < ApplicationRecord
80
+ include Auditable
81
+ include Searchable
82
+ end # 4 lines, the AI guesses what these concerns add
83
+
84
+ # What Woods produces (User.json source_code field):
85
+ # == Schema Information
86
+ # email :string not null
87
+ # name :string
88
+ #
89
+ # class User < ApplicationRecord
90
+ # include Auditable
91
+ # include Searchable
92
+ # validates :email, presence: true
93
+ # end
94
+ #
95
+ # ┌─────────────────────────────────────────────────────────────────────┐
96
+ # │ Included from: Auditable │
97
+ # └─────────────────────────────────────────────────────────────────────┘
98
+ # def audit_trail; AuditLog.create!(auditable: self); end
99
+ # after_save :audit_trail
100
+ # ─────────────────────────── End Auditable ───────────────────────────
101
+ #
102
+ # ┌─────────────────────────────────────────────────────────────────────┐
103
+ # │ Included from: Searchable │
104
+ # └─────────────────────────────────────────────────────────────────────┘
105
+ # scope :search, ->(q) { where("name ILIKE ?", "%#{q}%") }
106
+ # after_commit :reindex_search
107
+ # ─────────────────────────── End Searchable ───────────────────────────
108
+ ```
109
+
110
+ **Schema prepending.** Model source gets a schema header with column types, indexes, and
111
+ foreign keys pulled live from the database. No more confusing `string` vs `text` vs
112
+ `integer` guesses.
113
+
114
+ **Route-to-controller binding.** Controller source gets a route block prepended showing
115
+ exactly which HTTP verbs and paths map to which actions. URL → code is always explicit.
116
+
117
+ **Dependency graph.** 35 extractors build a bidirectional graph: what each unit depends on,
118
+ and what depends on it. Change `Auditable` and you can trace every model affected.
119
+
120
+ **Two MCP servers.** The Index Server defines 29 schemas and registers 14 in the normal
121
+ packaged launch; it reads pre-extracted JSON without booting Rails. The Console Server
122
+ has a 31-schema inventory but registers 9 tools by default, or 11 with explicit embedded
123
+ read tools, and bridges to a live Rails process for bounded database queries.
124
+
125
+ ```bash
126
+ # What you get after extraction
127
+ tmp/woods/
128
+ ├── generation.json # Atomic pointer to the current complete payload
129
+ └── payloads/gen-<N>/
130
+ ├── manifest.json # Extraction metadata and git SHA
131
+ ├── dependency_graph.json # Full graph with PageRank scores
132
+ └── models/User.json # Schema + inlined concerns + resolved callbacks
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Who Is Woods For?
138
+
139
+ **Teams using MCP-capable coding tools and agents.** Woods is model-independent; it
140
+ supplies Rails context through MCP to tools backed by OpenAI, Anthropic, Google, xAI,
141
+ or other model providers. If an agent helps with Rails code but lacks runtime context,
142
+ Woods fills that gap.
143
+
144
+ **Rails apps of any size.** Small apps benefit from accurate schema and route context.
145
+ Large monoliths benefit most, hundreds of models with deep callback chains and concern
146
+ hierarchies are exactly where static tools fail and Woods shines.
147
+
148
+ **Anyone who wants structured codebase context.** The extraction output is plain JSON, useful beyond AI tools for documentation, impact analysis, and onboarding.
149
+
150
+ Woods works with any database (MySQL, PostgreSQL, SQLite), any background job
151
+ system (Sidekiq, Solid Queue, GoodJob), and any view layer (ERB, Phlex, ViewComponent).
152
+ See [docs/BACKEND_MATRIX.md](BACKEND_MATRIX.md) for the full compatibility matrix.
153
+
154
+ ---
155
+
156
+ ## When NOT to Use Woods
157
+
158
+ Woods is not a universal fit. Skip it when:
159
+
160
+ - **You're not building in Rails.** Woods leans hard on `ActiveRecord::Base.descendants`, `Rails.application.routes`, and reflection APIs, the value dries up outside Rails. For Django, Phoenix, or non-framework Ruby, other tools are a better fit.
161
+ - **You need static analysis without booting.** Extraction requires a booted Rails environment because runtime introspection is the whole point. If your constraint is "can't boot the app" (locked-down CI, untrusted code review), static parsers are what you want.
162
+ - **Production-only environments.** Extraction should run in development or CI. The Console Server is explicitly unsafe for production even with all five defense layers, it is a dev/staging tool.
163
+ - **Row-level data is the goal.** Woods extracts schema and structure, not data. If you need to index row content for retrieval (customer records, documents, audit events), a different pipeline is appropriate.
164
+ - **Tiny apps that already fit in context.** A 20-model app may not benefit, the LLM can probably read every file. Woods' win scales with monolith size and concern depth.
165
+ - **You want a hosted service.** Woods is a gem, not a SaaS. Extraction output lives on your machines and the MCP servers run on your hardware. There is no cloud component.
166
+
167
+ ---
168
+
169
+ ## How Woods compares to other Rails code tools
170
+
171
+ Three tools answered "what is in this Rails app" for coding agents in 2026. Woods overlaps with each on structure and differs on runtime behavior.
172
+
173
+ | Tool | Version checked | How it reads the app | Strong at | Does not cover |
174
+ |---|---|---|---|---|
175
+ | Rubydex (Shopify) | 0.4.1, announced 2026-05-12 | Rust static index of declarations, references, ancestors; experimental `rdx mcp` | Symbol references across a large tree, fast re-index, reported 15 to 80 percent token reduction | Resolved callbacks, inlined concerns, routes as Rails builds them, database partition, churn |
176
+ | rails-mcp-server | 2.0.0 | Boots the app; `analyze_models`, `get_routes`, `get_schema` | Live model, route, and schema listings over MCP | Callback side effects, request flows, git churn, graph reports, persistent index with generations |
177
+ | ruby-lsp-rails | 0.5.0.beta1 | Runtime server over `rails runner` for the editor | Model columns, association targets, route info at the cursor | A persistent index other tools can read, graph analysis, multi-database facts |
178
+ | Woods | 2.0 | Boots the app once, publishes an atomic JSON generation, serves it without Rails | Resolved runtime behavior on top of structure: inlined concerns, callback side effects, flows, churn, PageRank; database partition and Packwerk boundaries are in progress on this branch | Symbol-level references inside method bodies (Rubydex is the better fit and is complementary) |
179
+
180
+ Woods and Rubydex are complementary. Rubydex answers "where is this symbol referenced". Woods answers "what happens when this runs, and what does it touch". An agent can use both: Rubydex for references, Woods for behavior, boundaries, and blast radius.
181
+
182
+ The database-partition layer, in progress on this branch, is the one place Woods will be alone. Rubydex is static, and the other two resolve associations without saying which database each side lives on. See [Extractor reference](EXTRACTOR_REFERENCE.md#modelextractor) for the fields.
183
+
184
+ ---
185
+
186
+ ## Quick Start
187
+
188
+ Install, extract, validate, and connect:
189
+
190
+ ```bash
191
+ # 1. Add to your Rails app's Gemfile
192
+ gem 'woods', '~> 2.0', group: :development
193
+
194
+ # 2. Install
195
+ bundle install
196
+ bin/rails generate woods:install
197
+
198
+ # The generator also emits a legacy application migration. Woods 2's shipped
199
+ # paths do not use those tables; remove it for a new default installation.
200
+
201
+ # 3. Extract (requires a booted Rails environment)
202
+ bin/rails woods:extract
203
+
204
+ # 4. Verify
205
+ bin/rails woods:validate
206
+ bin/rails woods:stats
207
+
208
+ # 5. Add to .mcp.json
209
+ # { "mcpServers": { "woods": { "command": "bundle",
210
+ # "args": ["exec", "woods-mcp-start", "./tmp/woods"],
211
+ # "cwd": "/absolute/path/to/your-rails-app" } } }
212
+
213
+ # 6. Ask your AI tool a question about your codebase
214
+ ```
215
+
216
+ For Docker, run extraction inside the application container. If Woods is installed
217
+ only there, launch the Index Server through that container too. See
218
+ [Getting started](GETTING_STARTED.md) for the complete walkthrough and
219
+ [Docker setup](DOCKER_SETUP.md) for executable container and host alternatives.
data/exe/woods-console CHANGED
@@ -27,6 +27,7 @@ unless protocol_out
27
27
  $stdout.reopen($stderr)
28
28
  end
29
29
 
30
+ require 'woods'
30
31
  require 'woods/console/server'
31
32
 
32
33
  unless Woods.configuration.console_mcp_enabled
@@ -36,8 +37,19 @@ unless Woods.configuration.console_mcp_enabled
36
37
  exit 1
37
38
  end
38
39
 
39
- # Ensure all application models are loaded for the registry.
40
- Rails.application.eager_load!
40
+ # Ensure all application models are loaded for the registry. A NameError
41
+ # here (e.g. app/graphql/ referencing an uninstalled gem) would otherwise
42
+ # crash the whole server before it can even report why — warn and continue
43
+ # with whatever eager_load! managed to load, mirroring the extractor's own
44
+ # eager_load! fallback (Extractor#safe_eager_load!). The registry below is
45
+ # then built from whatever's in ActiveRecord::Base.descendants, so a partial
46
+ # load just means a partial registry, not a dead server.
47
+ begin
48
+ Rails.application.eager_load!
49
+ rescue NameError => e
50
+ warn "[Woods Console] eager_load! hit NameError: #{e.message}. " \
51
+ 'Continuing with a possibly incomplete model registry.'
52
+ end
41
53
 
42
54
  registry = ActiveRecord::Base.descendants.each_with_object({}) do |model, hash|
43
55
  next if model.abstract_class?
@@ -78,14 +90,38 @@ rescue StandardError
78
90
  next
79
91
  end
80
92
 
81
- validator = Woods::Console::ModelValidator.new(registry: registry)
82
- safe_context = Woods::Console::SafeContext.new(connection: ActiveRecord::Base.connection)
93
+ validator = Woods::Console::ModelValidator.new(registry: registry, table_names: model_tables)
94
+
95
+ # SafeContext gets the writing connection *pool*, not a connection, so each
96
+ # request leases a fresh connection via `pool.with_connection { ... }` and
97
+ # returns it when the rolled-back transaction completes. Capturing one
98
+ # connection here pinned it for the process lifetime: after a database
99
+ # failover or a `wait_timeout` recycle every tool call failed on the stale
100
+ # connection until restart. The HTTP path has leased per request since
101
+ # `RackMiddleware#build_embedded_server`; stdio predated that fix.
102
+ safe_context = Woods::Console::SafeContext.new(pool: ActiveRecord::Base.connection_pool)
83
103
 
84
104
  config = Woods.configuration
85
105
  redacted_columns = Array(config.console_redacted_columns)
86
106
  redacted_key_values = Array(config.console_redacted_key_values)
87
107
  read_tools_enabled = config.console_embedded_read_tools
88
108
 
109
+ # Mirrors Woods::Console::RackMiddleware#check_blocked_tables_config! — the
110
+ # stdio server wires the same TableGate config, so it needs the same loud
111
+ # warning (or hard failure in production) when Layer 1 is inactive.
112
+ if Array(config.console_blocked_tables).empty?
113
+ blocked_tables_message =
114
+ '[Woods Console] console_blocked_tables is empty — Layer 1 (table gate) is INACTIVE. ' \
115
+ 'All tables are reachable via the Console MCP. ' \
116
+ 'Set console_blocked_tables in your Woods initializer to restrict access. ' \
117
+ 'Example: Woods.configure { |c| c.console_blocked_tables = ' \
118
+ 'Woods::DEFAULT_CONSOLE_BLOCKED_TABLES + %w[your_table] }'
119
+
120
+ raise Woods::ConfigurationError, blocked_tables_message if Rails.env.production?
121
+
122
+ warn blocked_tables_message
123
+ end
124
+
89
125
  server = Woods::Console::Server.build_embedded(
90
126
  model_validator: validator,
91
127
  safe_context: safe_context,
@@ -1,46 +1,32 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Console MCP server for querying live Rails application state.
4
+ # Launches the real embedded Console MCP server through direct, Docker, or SSH
5
+ # process execution. The child owns stdio and lifecycle directly.
5
6
  #
6
- # Usage:
7
- # woods-console-mcp
8
- # WOODS_CONSOLE_CONFIG=/path/to/console.yml woods-console-mcp
9
- #
10
- # Connects to a Rails application via a bridge process (Docker exec, direct,
11
- # or SSH) and exposes read-only query tools via the Model Context Protocol
12
- # (stdio transport).
13
- #
14
- # Disabled by default. Set Woods.configuration.console_mcp_enabled = true
15
- # in the host application's Woods initializer to enable. Layer 1 blocked
16
- # tables, Layer 2 credential scanning, and Layer 3 column/EAV redaction are
17
- # all configured through Woods::Configuration — see docs/CONSOLE_MCP_SETUP.md.
18
-
19
- # Suppress json-schema MultiJSON deprecation notice that would otherwise pollute
20
- # stderr during MCP stdio transport. The deprecation fires the first time
21
- # +json-schema+ is required — must happen before any Woods require that could
22
- # transitively pull it in. MCP clients parsing stderr can't tolerate banners.
23
- begin
24
- require 'json-schema'
25
- JSON::Validator.use_multi_json = false if defined?(JSON::Validator) && JSON::Validator.respond_to?(:use_multi_json=)
26
- rescue LoadError
27
- # json-schema isn't in every bundle.
28
- end
7
+ # Configuration precedence:
8
+ # 1. WOODS_CONSOLE_CONFIG, when set (the file must exist)
9
+ # 2. ~/.woods/console.yml, when present
10
+ # 3. direct mode with `bundle exec rake woods:console`
29
11
 
30
12
  require 'yaml'
31
- require 'woods'
32
- require_relative '../lib/woods/console/server'
13
+ require_relative '../lib/woods/console/connection_manager'
33
14
 
34
- unless Woods.configuration.console_mcp_enabled
35
- warn 'Woods Console MCP is disabled. Set ' \
36
- 'Woods.configuration.console_mcp_enabled = true to enable. ' \
37
- 'See docs/CONSOLE_MCP_SETUP.md for the full security posture.'
15
+ explicit_path = ENV.fetch('WOODS_CONSOLE_CONFIG', nil)
16
+ config_path = explicit_path || File.expand_path('~/.woods/console.yml')
17
+
18
+ if explicit_path && !File.file?(config_path)
19
+ warn "[woods-console-mcp] Config file does not exist: #{config_path}"
38
20
  exit 1
39
21
  end
40
22
 
41
- config_path = ENV.fetch('WOODS_CONSOLE_CONFIG', File.expand_path('~/.woods/console.yml'))
42
- config = File.exist?(config_path) ? YAML.safe_load_file(config_path) : {}
23
+ begin
24
+ config = File.file?(config_path) ? YAML.safe_load_file(config_path, aliases: false) : {}
25
+ config ||= {}
26
+ raise Woods::Console::ConnectionError, "#{config_path} must contain a YAML mapping" unless config.is_a?(Hash)
43
27
 
44
- server = Woods::Console::Server.build(config: config)
45
- transport = MCP::Server::Transports::StdioTransport.new(server)
46
- transport.open
28
+ Woods::Console::ConnectionManager.new(config: config).replace_process!
29
+ rescue Psych::Exception, Woods::Console::ConnectionError => e
30
+ warn "[woods-console-mcp] #{e.message}"
31
+ exit 1
32
+ end
data/exe/woods-mcp CHANGED
@@ -11,6 +11,13 @@
11
11
  # them via the Model Context Protocol (stdio transport).
12
12
  # Does NOT require Rails — only reads pre-extracted data.
13
13
 
14
+ # A wrapper can exec this file directly, bypassing RubyGems' generated launcher.
15
+ # Activate this installed gem before optional requires select dependency versions.
16
+ # Source checkouts continue to use their calling bundle.
17
+ Gem::Specification.find_all_by_name('woods').find do |spec|
18
+ File.realpath(spec.full_gem_path) == File.realpath(File.expand_path('..', __dir__))
19
+ end&.activate
20
+
14
21
  # Suppress json-schema MultiJSON deprecation notice that would otherwise pollute
15
22
  # stderr during MCP stdio transport. The deprecation fires the first time
16
23
  # +json-schema+ is required — earlier revisions set the flag *after* the
@@ -37,14 +44,18 @@ begin
37
44
  index_dir = Woods::MCP::Bootstrapper.resolve_index_dir(ARGV)
38
45
  retriever, bootstrap_state = Woods::MCP::Bootstrapper.build_retriever(index_dir: index_dir)
39
46
  snapshot_store = Woods::MCP::Bootstrapper.build_snapshot_store(index_dir)
40
- rescue Woods::MCP::BootstrapError => e
47
+ # ConfigurationError is caught alongside BootstrapError so an unusable
48
+ # embedding configuration (e.g. a provider wired without its required
49
+ # credential) surfaces as the one-line operator message instead of a raw
50
+ # backtrace (M9).
51
+ rescue Woods::MCP::BootstrapError, Woods::ConfigurationError => e
41
52
  warn "[woods-mcp] #{e.class.name.split('::').last}: #{e.message}"
42
53
  warn "[woods-mcp] details: #{e.details.inspect}" if e.respond_to?(:details) && !e.details.empty?
43
54
  exit 2
44
55
  end
45
56
 
46
- retriever_reloader = lambda do
47
- Woods::MCP::Bootstrapper.reload_stores!(retriever, index_dir: index_dir)
57
+ retriever_reloader = lambda do |reader|
58
+ Woods::MCP::Bootstrapper.reload_stores!(retriever, index_dir: index_dir, reader: reader, state: bootstrap_state)
48
59
  end
49
60
 
50
61
  server = Woods::MCP::Server.build(
@@ -52,10 +63,12 @@ server = Woods::MCP::Server.build(
52
63
  bootstrap_state: bootstrap_state, retriever_reloader: retriever_reloader
53
64
  )
54
65
 
55
- # Pin protocol version for broad client compatibility (Claude Code, Cursor, etc.)
56
- if ENV['MCP_PROTOCOL_VERSION']
57
- server.configuration = MCP::Configuration.new(protocol_version: ENV['MCP_PROTOCOL_VERSION'])
58
- end
66
+ # Never pin by default: the SDK server answers `initialize` for legacy
67
+ # clients AND serves `server/discover` for modern ones, so pinning collapses
68
+ # it to one era. MCP_PROTOCOL_VERSION exists only as an operator escape
69
+ # hatch for a client that can't negotiate — see the "never pin
70
+ # MCP_PROTOCOL_VERSION" gotcha in CLAUDE.md.
71
+ server.configuration.protocol_version = ENV['MCP_PROTOCOL_VERSION'] if ENV['MCP_PROTOCOL_VERSION']
59
72
 
60
73
  transport = MCP::Server::Transports::StdioTransport.new(server)
61
74
  transport.open
data/exe/woods-mcp-http CHANGED
@@ -9,7 +9,15 @@
9
9
  #
10
10
  # Reads JSON files from the extraction output directory and exposes
11
11
  # them via the Model Context Protocol (Streamable HTTP transport).
12
- # Requires the `rackup` gem and a Rack-compatible server (e.g., puma).
12
+ # Requires a Rack-compatible server (e.g., puma). Uses `rackup` when available
13
+ # and falls back to Rack 2's bundled handler registry for older Rails hosts.
14
+
15
+ # A wrapper can exec this file directly, bypassing RubyGems' generated launcher.
16
+ # Activate this installed gem before optional requires select dependency versions.
17
+ # Source checkouts continue to use their calling bundle.
18
+ Gem::Specification.find_all_by_name('woods').find do |spec|
19
+ File.realpath(spec.full_gem_path) == File.realpath(File.expand_path('..', __dir__))
20
+ end&.activate
13
21
 
14
22
  # Suppress json-schema MultiJSON deprecation before any require chain that
15
23
  # could transitively load json-schema. See exe/woods-mcp for the full rationale.
@@ -20,7 +28,13 @@ rescue LoadError
20
28
  # json-schema isn't in every bundle.
21
29
  end
22
30
 
23
- require 'rackup'
31
+ begin
32
+ require 'rackup'
33
+ rescue LoadError
34
+ require 'rack'
35
+ end
36
+ require 'bundler'
37
+ require 'uri'
24
38
  require_relative '../lib/woods'
25
39
  require_relative '../lib/woods/dependency_graph'
26
40
  require_relative '../lib/woods/graph_analyzer'
@@ -35,7 +49,11 @@ begin
35
49
  index_dir = Woods::MCP::Bootstrapper.resolve_index_dir(ARGV)
36
50
  retriever, bootstrap_state = Woods::MCP::Bootstrapper.build_retriever(index_dir: index_dir)
37
51
  snapshot_store = Woods::MCP::Bootstrapper.build_snapshot_store(index_dir)
38
- rescue Woods::MCP::BootstrapError => e
52
+ # ConfigurationError is caught alongside BootstrapError so an unusable
53
+ # embedding configuration (e.g. a provider wired without its required
54
+ # credential) surfaces as the one-line operator message instead of a raw
55
+ # backtrace (M9).
56
+ rescue Woods::MCP::BootstrapError, Woods::ConfigurationError => e
39
57
  warn "[woods-mcp-http] #{e.class.name.split('::').last}: #{e.message}"
40
58
  warn "[woods-mcp-http] details: #{e.details.inspect}" if e.respond_to?(:details) && !e.details.empty?
41
59
  exit 2
@@ -55,24 +73,75 @@ if loopback && token.nil?
55
73
  warn '[woods-mcp-http] WARNING: running on loopback without a token; local processes can reach this server.'
56
74
  end
57
75
 
58
- retriever_reloader = lambda do
59
- Woods::MCP::Bootstrapper.reload_stores!(retriever, index_dir: index_dir)
76
+ retriever_reloader = lambda do |reader|
77
+ Woods::MCP::Bootstrapper.reload_stores!(retriever, index_dir: index_dir, reader: reader, state: bootstrap_state)
60
78
  end
61
79
 
62
80
  server = Woods::MCP::Server.build(
63
81
  index_dir: index_dir, retriever: retriever, snapshot_store: snapshot_store,
64
82
  bootstrap_state: bootstrap_state, retriever_reloader: retriever_reloader
65
83
  )
66
- transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
67
- server.transport = transport
68
-
84
+ # Stateless Streamable HTTP (MCP 2026-07-28, SEP-2567) is the default.
85
+ #
86
+ # Protocol-level sessions are gone from the spec, and for this server they were
87
+ # never carrying anything: the index is on disk, `IndexReader` self-refreshes off
88
+ # the published generation, and no tool holds per-client state. What the session
89
+ # DID do was tie every client to one server process — so a restart (gem upgrade,
90
+ # machine sleep, worktree rebuild) invalidated every session and forced a
91
+ # re-initialize. Stateless makes a restart invisible, and lets several instances
92
+ # serve one volume-mounted index without sticky routing.
93
+ #
94
+ # Set WOODS_MCP_HTTP_STATELESS=0 to restore session mode for a client that still
95
+ # needs `Mcp-Session-Id`, the GET SSE stream, or DELETE teardown. That escape
96
+ # hatch is transitional — the spec has removed all three. See
97
+ # docs/MCP_HTTP_TRANSPORT.md#statelessness.
98
+ stateless = !%w[0 false no].include?(ENV.fetch('WOODS_MCP_HTTP_STATELESS', '1').strip.downcase)
69
99
  allowed_origins = ENV.fetch('WOODS_MCP_HTTP_ALLOWED_ORIGINS', '').split(',').map(&:strip).reject(&:empty?)
100
+ allowed_hosts = allowed_origins.filter_map do |origin|
101
+ URI.parse(origin).host
102
+ rescue URI::InvalidURIError
103
+ nil
104
+ end
105
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(
106
+ server,
107
+ stateless: stateless,
108
+ allowed_origins: allowed_origins,
109
+ allowed_hosts: allowed_hosts
110
+ )
111
+ server.transport = transport
70
112
 
71
- inner = proc { |env| transport.handle_request(Rack::Request.new(env)) }
113
+ inner = proc do |env|
114
+ env = env.dup
115
+ env.delete('HTTP_MCP_SESSION_ID') if stateless
116
+ transport.handle_request(Rack::Request.new(env))
117
+ end
72
118
  app = token ? Woods::MCP::BearerAuth.new(inner, token: token) : inner
73
119
  app = Woods::MCP::OriginGuard.new(app, allowed_origins: allowed_origins)
74
120
 
75
121
  origin_summary = allowed_origins.empty? ? 'loopback' : allowed_origins.join(',')
76
122
  auth_mode = token ? 'bearer' : 'none'
77
- warn "Woods MCP HTTP server starting on http://#{host}:#{port} (auth: #{auth_mode}, origins: #{origin_summary})"
78
- Rackup::Handler.default.run(app, Port: port, Host: host)
123
+ session_mode = stateless ? 'stateless' : 'sessions (deprecated)'
124
+ warn "Woods MCP HTTP server starting on http://#{host}:#{port} " \
125
+ "(auth: #{auth_mode}, origins: #{origin_summary}, mode: #{session_mode})"
126
+ begin
127
+ handler = if defined?(Rackup::Handler)
128
+ Rackup::Handler.default
129
+ else
130
+ Rack::Handler.default
131
+ end
132
+ rescue LoadError => e
133
+ abort "[woods-mcp-http] No Rack server handler is available (#{e.message}). " \
134
+ 'Add a Rack-compatible server such as puma to the host bundle, or run under an existing Rack server.'
135
+ end
136
+
137
+ unless handler.respond_to?(:run)
138
+ abort '[woods-mcp-http] No Rack server handler is available. ' \
139
+ 'Add a Rack-compatible server such as puma to the host bundle, or run under an existing Rack server.'
140
+ end
141
+
142
+ begin
143
+ handler.run(app, Port: port, Host: host)
144
+ rescue LoadError => e
145
+ abort "[woods-mcp-http] Rack server handler failed to load (#{e.message}). " \
146
+ 'Add a Rack-compatible server such as puma to the host bundle, or run under an existing Rack server.'
147
+ end
data/exe/woods-mcp-start CHANGED
@@ -1,58 +1,63 @@
1
- #!/usr/bin/env bash
2
- # Self-healing wrapper for the Woods MCP server.
3
- # Ensures Ruby dependencies are installed, validates the index directory,
4
- # and starts the stdio MCP server.
5
- #
6
- # Usage (direct):
7
- # woods-mcp-start /path/to/index_dir
8
- #
9
- # Usage (.mcp.json):
10
- # {
11
- # "command": "${HOME}/work/codebase_index/exe/woods-mcp-start",
12
- # "args": ["${HOME}/my-rails-app/tmp/woods"]
13
- # }
14
- #
15
- # All diagnostic output goes to stderr to keep stdio clean for MCP protocol.
16
-
17
- set -euo pipefail
18
-
19
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20
- GEM_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
21
- INDEX_DIR="${1:-${WOODS_DIR:-}}"
22
-
23
- # --- Validate index directory ---
24
- if [[ -z "$INDEX_DIR" ]]; then
25
- echo "Error: No index directory specified." >&2
26
- echo "Usage: woods-mcp-start /path/to/index_dir" >&2
27
- exit 1
28
- fi
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
29
3
 
30
- if [[ ! -d "$INDEX_DIR" ]]; then
31
- echo "Error: Index directory does not exist: $INDEX_DIR" >&2
32
- echo "Run extraction first: bundle exec rake woods:extract" >&2
33
- exit 1
34
- fi
4
+ # Preflight entry point for the Woods Index MCP server: it validates the index
5
+ # directory and its published manifest, then `exec`s woods-mcp in place. There
6
+ # is no supervision and no restart loop — a crashed woods-mcp stays down, the
7
+ # same as when launched directly. RubyGems loads gem executables as Ruby, so
8
+ # this wrapper must remain a Ruby program when packaged.
35
9
 
36
- if [[ ! -f "$INDEX_DIR/manifest.json" ]]; then
37
- echo "Error: No manifest.json in: $INDEX_DIR" >&2
38
- echo "Run extraction first: bundle exec rake woods:extract" >&2
39
- exit 1
40
- fi
10
+ index_dir = ARGV[0] || ENV.fetch('WOODS_DIR', nil)
41
11
 
42
- # --- Ensure Ruby dependencies are installed ---
43
- export BUNDLE_GEMFILE="${GEM_DIR}/Gemfile"
12
+ if index_dir.nil? || index_dir.empty?
13
+ warn 'Error: No index directory specified.'
14
+ warn 'Usage: woods-mcp-start /path/to/index_dir'
15
+ exit 1
16
+ end
44
17
 
45
- if ! bundle check > /dev/null 2>&1; then
46
- echo "Installing woods dependencies..." >&2
47
- if ! bundle install --quiet >&2 2>&1; then
48
- echo "Error: bundle install failed. Check Ruby version and network." >&2
49
- exit 1
50
- fi
51
- echo "Dependencies installed." >&2
52
- fi
18
+ unless File.directory?(index_dir)
19
+ warn "Error: Index directory does not exist: #{index_dir}"
20
+ warn 'Run extraction first: bundle exec rake woods:extract'
21
+ exit 1
22
+ end
23
+
24
+ # Mirrors Woods::Generation#payload_dir + Woods::MCP::Bootstrapper.manifest_present?
25
+ # (lib/woods/generation.rb, lib/woods/mcp/bootstrapper.rb) without requiring
26
+ # the gem: this pre-flight runs before the `exec` below, so pulling in the
27
+ # whole library just to check one file is wasted work on every boot. A
28
+ # payload-born index has no manifest.json at the root — it lives under the
29
+ # directory generation.json's `payload` pointer names — so the pointer is
30
+ # followed here too, with the same escape guard, before concluding the
31
+ # directory holds no index. woods-mcp re-checks this properly through
32
+ # Bootstrapper regardless; this is just an early, friendlier exit.
33
+ def manifest_present?(index_dir)
34
+ return true if File.file?(File.join(index_dir, 'manifest.json'))
35
+
36
+ generation_path = File.join(index_dir, 'generation.json')
37
+ return false unless File.file?(generation_path)
38
+
39
+ require 'json'
40
+ require_relative '../lib/woods/atomic_file'
41
+ payload_name = JSON.parse(Woods::AtomicFile.read(generation_path))['payload']
42
+ return false if payload_name.nil? || payload_name.empty?
43
+
44
+ root = File.expand_path(index_dir)
45
+ candidate = File.expand_path(File.join(root, payload_name))
46
+ return false unless candidate.start_with?("#{root}#{File::SEPARATOR}")
47
+
48
+ File.file?(File.join(candidate, 'manifest.json'))
49
+ rescue JSON::ParserError, SystemCallError
50
+ false
51
+ end
52
+
53
+ unless manifest_present?(index_dir)
54
+ warn "Error: No manifest.json in: #{index_dir}"
55
+ warn 'Run extraction first: bundle exec rake woods:extract'
56
+ exit 1
57
+ end
53
58
 
54
- # --- Pin MCP protocol version for Claude Code compatibility ---
55
- export MCP_PROTOCOL_VERSION="${MCP_PROTOCOL_VERSION:-2024-11-05}"
59
+ if ENV['MCP_PROTOCOL_VERSION'] && !ENV['MCP_PROTOCOL_VERSION'].empty?
60
+ warn "Pinning MCP protocol version to #{ENV.fetch('MCP_PROTOCOL_VERSION')} (unset it to negotiate)."
61
+ end
56
62
 
57
- # --- Start the MCP server ---
58
- exec bundle exec ruby "${GEM_DIR}/exe/woods-mcp" "$INDEX_DIR"
63
+ exec(File.expand_path('woods-mcp', __dir__), index_dir)