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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- 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
|
-
|
|
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
|
-
|
|
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,
|
data/exe/woods-console-mcp
CHANGED
|
@@ -1,46 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Console MCP server
|
|
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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
32
|
-
require_relative '../lib/woods/console/server'
|
|
13
|
+
require_relative '../lib/woods/console/connection_manager'
|
|
33
14
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
config = File.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
2
|
-
#
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
exec bundle exec ruby "${GEM_DIR}/exe/woods-mcp" "$INDEX_DIR"
|
|
63
|
+
exec(File.expand_path('woods-mcp', __dir__), index_dir)
|