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