woods 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +89 -0
  3. data/CODE_OF_CONDUCT.md +83 -0
  4. data/CONTRIBUTING.md +65 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +406 -0
  7. data/exe/woods-console +59 -0
  8. data/exe/woods-console-mcp +22 -0
  9. data/exe/woods-mcp +34 -0
  10. data/exe/woods-mcp-http +37 -0
  11. data/exe/woods-mcp-start +58 -0
  12. data/lib/generators/woods/install_generator.rb +32 -0
  13. data/lib/generators/woods/pgvector_generator.rb +37 -0
  14. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
  15. data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
  16. data/lib/tasks/woods.rake +621 -0
  17. data/lib/tasks/woods_evaluation.rake +115 -0
  18. data/lib/woods/ast/call_site_extractor.rb +106 -0
  19. data/lib/woods/ast/method_extractor.rb +71 -0
  20. data/lib/woods/ast/node.rb +116 -0
  21. data/lib/woods/ast/parser.rb +614 -0
  22. data/lib/woods/ast.rb +6 -0
  23. data/lib/woods/builder.rb +200 -0
  24. data/lib/woods/cache/cache_middleware.rb +199 -0
  25. data/lib/woods/cache/cache_store.rb +264 -0
  26. data/lib/woods/cache/redis_cache_store.rb +116 -0
  27. data/lib/woods/cache/solid_cache_store.rb +111 -0
  28. data/lib/woods/chunking/chunk.rb +84 -0
  29. data/lib/woods/chunking/semantic_chunker.rb +295 -0
  30. data/lib/woods/console/adapters/cache_adapter.rb +58 -0
  31. data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
  32. data/lib/woods/console/adapters/job_adapter.rb +68 -0
  33. data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
  34. data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
  35. data/lib/woods/console/audit_logger.rb +75 -0
  36. data/lib/woods/console/bridge.rb +177 -0
  37. data/lib/woods/console/confirmation.rb +90 -0
  38. data/lib/woods/console/connection_manager.rb +173 -0
  39. data/lib/woods/console/console_response_renderer.rb +74 -0
  40. data/lib/woods/console/embedded_executor.rb +373 -0
  41. data/lib/woods/console/model_validator.rb +81 -0
  42. data/lib/woods/console/rack_middleware.rb +87 -0
  43. data/lib/woods/console/safe_context.rb +82 -0
  44. data/lib/woods/console/server.rb +612 -0
  45. data/lib/woods/console/sql_validator.rb +172 -0
  46. data/lib/woods/console/tools/tier1.rb +118 -0
  47. data/lib/woods/console/tools/tier2.rb +117 -0
  48. data/lib/woods/console/tools/tier3.rb +110 -0
  49. data/lib/woods/console/tools/tier4.rb +79 -0
  50. data/lib/woods/coordination/pipeline_lock.rb +109 -0
  51. data/lib/woods/cost_model/embedding_cost.rb +88 -0
  52. data/lib/woods/cost_model/estimator.rb +128 -0
  53. data/lib/woods/cost_model/provider_pricing.rb +67 -0
  54. data/lib/woods/cost_model/storage_cost.rb +52 -0
  55. data/lib/woods/cost_model.rb +22 -0
  56. data/lib/woods/db/migrations/001_create_units.rb +38 -0
  57. data/lib/woods/db/migrations/002_create_edges.rb +35 -0
  58. data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
  59. data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
  60. data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
  61. data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
  62. data/lib/woods/db/migrator.rb +73 -0
  63. data/lib/woods/db/schema_version.rb +73 -0
  64. data/lib/woods/dependency_graph.rb +236 -0
  65. data/lib/woods/embedding/indexer.rb +140 -0
  66. data/lib/woods/embedding/openai.rb +126 -0
  67. data/lib/woods/embedding/provider.rb +162 -0
  68. data/lib/woods/embedding/text_preparer.rb +112 -0
  69. data/lib/woods/evaluation/baseline_runner.rb +115 -0
  70. data/lib/woods/evaluation/evaluator.rb +139 -0
  71. data/lib/woods/evaluation/metrics.rb +79 -0
  72. data/lib/woods/evaluation/query_set.rb +148 -0
  73. data/lib/woods/evaluation/report_generator.rb +90 -0
  74. data/lib/woods/extracted_unit.rb +145 -0
  75. data/lib/woods/extractor.rb +1028 -0
  76. data/lib/woods/extractors/action_cable_extractor.rb +201 -0
  77. data/lib/woods/extractors/ast_source_extraction.rb +46 -0
  78. data/lib/woods/extractors/behavioral_profile.rb +309 -0
  79. data/lib/woods/extractors/caching_extractor.rb +261 -0
  80. data/lib/woods/extractors/callback_analyzer.rb +246 -0
  81. data/lib/woods/extractors/concern_extractor.rb +292 -0
  82. data/lib/woods/extractors/configuration_extractor.rb +219 -0
  83. data/lib/woods/extractors/controller_extractor.rb +404 -0
  84. data/lib/woods/extractors/database_view_extractor.rb +278 -0
  85. data/lib/woods/extractors/decorator_extractor.rb +253 -0
  86. data/lib/woods/extractors/engine_extractor.rb +223 -0
  87. data/lib/woods/extractors/event_extractor.rb +211 -0
  88. data/lib/woods/extractors/factory_extractor.rb +289 -0
  89. data/lib/woods/extractors/graphql_extractor.rb +892 -0
  90. data/lib/woods/extractors/i18n_extractor.rb +117 -0
  91. data/lib/woods/extractors/job_extractor.rb +374 -0
  92. data/lib/woods/extractors/lib_extractor.rb +218 -0
  93. data/lib/woods/extractors/mailer_extractor.rb +269 -0
  94. data/lib/woods/extractors/manager_extractor.rb +188 -0
  95. data/lib/woods/extractors/middleware_extractor.rb +133 -0
  96. data/lib/woods/extractors/migration_extractor.rb +469 -0
  97. data/lib/woods/extractors/model_extractor.rb +988 -0
  98. data/lib/woods/extractors/phlex_extractor.rb +252 -0
  99. data/lib/woods/extractors/policy_extractor.rb +191 -0
  100. data/lib/woods/extractors/poro_extractor.rb +229 -0
  101. data/lib/woods/extractors/pundit_extractor.rb +223 -0
  102. data/lib/woods/extractors/rails_source_extractor.rb +473 -0
  103. data/lib/woods/extractors/rake_task_extractor.rb +343 -0
  104. data/lib/woods/extractors/route_extractor.rb +181 -0
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
  106. data/lib/woods/extractors/serializer_extractor.rb +339 -0
  107. data/lib/woods/extractors/service_extractor.rb +217 -0
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
  109. data/lib/woods/extractors/shared_utility_methods.rb +281 -0
  110. data/lib/woods/extractors/state_machine_extractor.rb +398 -0
  111. data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
  112. data/lib/woods/extractors/validator_extractor.rb +211 -0
  113. data/lib/woods/extractors/view_component_extractor.rb +311 -0
  114. data/lib/woods/extractors/view_template_extractor.rb +261 -0
  115. data/lib/woods/feedback/gap_detector.rb +89 -0
  116. data/lib/woods/feedback/store.rb +119 -0
  117. data/lib/woods/filename_utils.rb +32 -0
  118. data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
  119. data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
  120. data/lib/woods/flow_assembler.rb +290 -0
  121. data/lib/woods/flow_document.rb +191 -0
  122. data/lib/woods/flow_precomputer.rb +102 -0
  123. data/lib/woods/formatting/base.rb +30 -0
  124. data/lib/woods/formatting/claude_adapter.rb +98 -0
  125. data/lib/woods/formatting/generic_adapter.rb +56 -0
  126. data/lib/woods/formatting/gpt_adapter.rb +64 -0
  127. data/lib/woods/formatting/human_adapter.rb +78 -0
  128. data/lib/woods/graph_analyzer.rb +374 -0
  129. data/lib/woods/mcp/bootstrapper.rb +96 -0
  130. data/lib/woods/mcp/index_reader.rb +394 -0
  131. data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
  132. data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
  133. data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
  134. data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
  135. data/lib/woods/mcp/server.rb +962 -0
  136. data/lib/woods/mcp/tool_response_renderer.rb +85 -0
  137. data/lib/woods/model_name_cache.rb +51 -0
  138. data/lib/woods/notion/client.rb +217 -0
  139. data/lib/woods/notion/exporter.rb +219 -0
  140. data/lib/woods/notion/mapper.rb +40 -0
  141. data/lib/woods/notion/mappers/column_mapper.rb +57 -0
  142. data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
  143. data/lib/woods/notion/mappers/model_mapper.rb +161 -0
  144. data/lib/woods/notion/mappers/shared.rb +22 -0
  145. data/lib/woods/notion/rate_limiter.rb +68 -0
  146. data/lib/woods/observability/health_check.rb +79 -0
  147. data/lib/woods/observability/instrumentation.rb +34 -0
  148. data/lib/woods/observability/structured_logger.rb +57 -0
  149. data/lib/woods/operator/error_escalator.rb +81 -0
  150. data/lib/woods/operator/pipeline_guard.rb +92 -0
  151. data/lib/woods/operator/status_reporter.rb +80 -0
  152. data/lib/woods/railtie.rb +38 -0
  153. data/lib/woods/resilience/circuit_breaker.rb +99 -0
  154. data/lib/woods/resilience/index_validator.rb +167 -0
  155. data/lib/woods/resilience/retryable_provider.rb +108 -0
  156. data/lib/woods/retrieval/context_assembler.rb +261 -0
  157. data/lib/woods/retrieval/query_classifier.rb +133 -0
  158. data/lib/woods/retrieval/ranker.rb +277 -0
  159. data/lib/woods/retrieval/search_executor.rb +316 -0
  160. data/lib/woods/retriever.rb +152 -0
  161. data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
  162. data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
  163. data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
  164. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
  165. data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
  166. data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
  167. data/lib/woods/ruby_analyzer.rb +87 -0
  168. data/lib/woods/session_tracer/file_store.rb +104 -0
  169. data/lib/woods/session_tracer/middleware.rb +143 -0
  170. data/lib/woods/session_tracer/redis_store.rb +106 -0
  171. data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
  172. data/lib/woods/session_tracer/session_flow_document.rb +223 -0
  173. data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
  174. data/lib/woods/session_tracer/store.rb +81 -0
  175. data/lib/woods/storage/graph_store.rb +120 -0
  176. data/lib/woods/storage/metadata_store.rb +196 -0
  177. data/lib/woods/storage/pgvector.rb +195 -0
  178. data/lib/woods/storage/qdrant.rb +205 -0
  179. data/lib/woods/storage/vector_store.rb +167 -0
  180. data/lib/woods/temporal/json_snapshot_store.rb +245 -0
  181. data/lib/woods/temporal/snapshot_store.rb +345 -0
  182. data/lib/woods/token_utils.rb +19 -0
  183. data/lib/woods/version.rb +5 -0
  184. data/lib/woods.rb +246 -0
  185. metadata +270 -0
@@ -0,0 +1,962 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'mcp'
5
+ require 'set'
6
+ require_relative 'index_reader'
7
+ require_relative 'tool_response_renderer'
8
+
9
+ module Woods
10
+ module MCP
11
+ # Builds an MCP::Server with 27 tools, 2 resources, and 2 resource templates for querying
12
+ # Woods extraction output, managing pipelines, and collecting feedback.
13
+ #
14
+ # All tools are defined inline via closures over an IndexReader instance.
15
+ # No Rails required at runtime — reads JSON files from disk.
16
+ #
17
+ # @example
18
+ # server = Woods::MCP::Server.build(index_dir: "/path/to/output")
19
+ # transport = MCP::Server::Transports::StdioTransport.new(server)
20
+ # transport.open
21
+ #
22
+ module Server
23
+ class << self
24
+ # Build a configured MCP::Server with all tools and resources.
25
+ #
26
+ # @param index_dir [String] Path to extraction output directory
27
+ # @param retriever [Woods::Retriever, nil] Optional retriever for semantic search
28
+ # @param operator [Hash, nil] Optional operator config with :status_reporter, :error_escalator, :pipeline_guard, :pipeline_lock
29
+ # @param feedback_store [Woods::Feedback::Store, nil] Optional feedback store
30
+ # @return [MCP::Server] Configured server ready for transport
31
+ def build(index_dir:, retriever: nil, operator: nil, feedback_store: nil, snapshot_store: nil,
32
+ response_format: nil)
33
+ reader = IndexReader.new(index_dir)
34
+ config = Woods.configuration
35
+ format = response_format || (config.respond_to?(:context_format) ? config.context_format : nil) || :markdown
36
+ renderer = ToolResponseRenderer.for(format)
37
+ resources = build_resources
38
+ resource_templates = build_resource_templates
39
+
40
+ # Lambda captured by all tool blocks for building responses.
41
+ respond = method(:text_response)
42
+
43
+ server = ::MCP::Server.new(
44
+ name: 'woods',
45
+ version: Woods::VERSION,
46
+ resources: resources,
47
+ resource_templates: resource_templates
48
+ )
49
+
50
+ define_lookup_tool(server, reader, respond, renderer)
51
+ define_search_tool(server, reader, respond, renderer)
52
+ define_traversal_tool(server, reader, respond, renderer,
53
+ name: 'dependencies',
54
+ description: 'Traverse forward dependencies of a unit (what it depends on). Returns a BFS tree with depth.',
55
+ reader_method: :traverse_dependencies,
56
+ render_key: :dependencies)
57
+ define_traversal_tool(server, reader, respond, renderer,
58
+ name: 'dependents',
59
+ description: 'Traverse reverse dependencies of a unit (what depends on it). Returns a BFS tree with depth.',
60
+ reader_method: :traverse_dependents,
61
+ render_key: :dependents)
62
+ define_structure_tool(server, reader, respond, renderer)
63
+ define_graph_analysis_tool(server, reader, respond, renderer)
64
+ define_pagerank_tool(server, reader, respond, renderer)
65
+ define_framework_tool(server, reader, respond, renderer)
66
+ define_recent_changes_tool(server, reader, respond, renderer)
67
+ define_reload_tool(server, reader, respond)
68
+ define_retrieve_tool(server, retriever, respond)
69
+ define_trace_flow_tool(server, reader, index_dir, respond, renderer)
70
+ define_session_trace_tool(server, reader, respond)
71
+ define_operator_tools(server, operator, respond)
72
+ define_feedback_tools(server, feedback_store, respond)
73
+ define_snapshot_tools(server, snapshot_store, respond)
74
+ define_notion_sync_tool(server, reader, index_dir, respond)
75
+ register_resource_handler(server, reader)
76
+
77
+ server
78
+ end
79
+
80
+ private
81
+
82
+ def text_response(text)
83
+ ::MCP::Tool::Response.new([{ type: 'text', text: text }])
84
+ end
85
+
86
+ def truncate_section(array, limit)
87
+ return array unless array.is_a?(Array)
88
+
89
+ limit = [limit, 0].max
90
+ array.first(limit).map do |item|
91
+ next item unless item.is_a?(Hash) && item['dependents'].is_a?(Array) && item['dependents'].size > limit
92
+
93
+ item.merge(
94
+ 'dependents' => item['dependents'].first(limit),
95
+ 'dependents_truncated' => true,
96
+ 'dependents_total' => item['dependents'].size
97
+ )
98
+ end
99
+ end
100
+
101
+ # Coerce a value to an Array. Wraps a single String in an Array;
102
+ # leaves existing Arrays and nil unchanged.
103
+ #
104
+ # @param value [String, Array, nil] The input value
105
+ # @return [Array, nil]
106
+ def coerce_array(value)
107
+ value.is_a?(String) ? [value] : value
108
+ end
109
+
110
+ # Coerce a value to an Integer. Converts String representations
111
+ # to Integer; leaves existing Integers and nil unchanged.
112
+ # MCP clients may send "2" (string) instead of 2 (integer).
113
+ #
114
+ # @param value [String, Integer, nil] The input value
115
+ # @return [Integer, nil]
116
+ def coerce_integer(value)
117
+ value.is_a?(String) ? value.to_i : value
118
+ end
119
+
120
+ # Apply offset+limit pagination to a single section key within a container hash.
121
+ # Adds `_total`, `_truncated`, and `_offset` metadata keys when truncating.
122
+ #
123
+ # @param container [Hash] The hash to mutate
124
+ # @param key [String] The section key whose value is an Array
125
+ # @param limit [Integer, nil] Max items to retain; nil means no limit
126
+ # @param offset [Integer] Items to skip from the front
127
+ # @return [void]
128
+ def paginate_section(container, key, limit, offset)
129
+ original = container[key]
130
+ return unless original.is_a?(Array)
131
+
132
+ sliced = offset.positive? ? original.drop(offset) : original
133
+ container[key] = limit ? truncate_section(sliced, limit) : sliced
134
+ if original.size > offset + (limit || original.size)
135
+ container["#{key}_total"] = original.size
136
+ container["#{key}_truncated"] = true
137
+ end
138
+ container["#{key}_offset"] = offset if offset.positive?
139
+ end
140
+
141
+ def define_lookup_tool(server, reader, respond, renderer)
142
+ coerce = method(:coerce_array)
143
+ server.define_tool(
144
+ name: 'lookup',
145
+ description: 'Look up a code unit by its exact identifier. Returns full source code, metadata, ' \
146
+ 'dependencies, and dependents. Use include_source: false to omit source_code. ' \
147
+ 'Use sections to select specific keys (type, identifier, file_path, namespace are always included).',
148
+ input_schema: {
149
+ properties: {
150
+ identifier: { type: 'string',
151
+ description: 'Exact unit identifier (e.g. "Post", "PostsController", "Api::V1::HealthController")' },
152
+ include_source: { type: 'boolean', description: 'Include source_code in response (default: true)' },
153
+ sections: {
154
+ type: 'array', items: { type: 'string' },
155
+ description: 'Select specific keys to return (e.g. ["metadata", "dependencies"]). Always includes type, identifier, file_path, namespace.'
156
+ }
157
+ },
158
+ required: ['identifier']
159
+ }
160
+ ) do |identifier:, server_context:, include_source: nil, sections: nil|
161
+ sections = coerce.call(sections)
162
+ unit = reader.find_unit(identifier)
163
+ if unit
164
+ always_include = %w[type identifier file_path namespace]
165
+ filtered = unit
166
+ filtered = filtered.except('source_code') if include_source == false
167
+ if sections&.any?
168
+ allowed = (always_include + sections).to_set
169
+ filtered = filtered.slice(*allowed)
170
+ end
171
+ respond.call(renderer.render(:lookup, filtered))
172
+ else
173
+ respond.call("Unit not found: #{identifier}")
174
+ end
175
+ end
176
+ end
177
+
178
+ def define_search_tool(server, reader, respond, renderer)
179
+ coerce = method(:coerce_array)
180
+ coerce_int = method(:coerce_integer)
181
+ server.define_tool(
182
+ name: 'search',
183
+ description: 'Search code units by pattern. Matches against identifiers by default; can also search source_code and metadata fields.',
184
+ input_schema: {
185
+ properties: {
186
+ query: { type: 'string', description: 'Search pattern (case-insensitive regex)' },
187
+ types: {
188
+ type: 'array', items: { type: 'string' },
189
+ description: 'Filter to these types: model, controller, service, job, mailer, etc.'
190
+ },
191
+ fields: {
192
+ type: 'array', items: { type: 'string' },
193
+ description: 'Fields to search: identifier, source_code, metadata. Default: [identifier]'
194
+ },
195
+ limit: { type: 'integer', description: 'Maximum results (default: 20)' }
196
+ },
197
+ required: ['query']
198
+ }
199
+ ) do |query:, server_context:, types: nil, fields: nil, limit: nil|
200
+ types = coerce.call(types)
201
+ fields = coerce.call(fields)
202
+ limit = coerce_int.call(limit)
203
+ results = reader.search(
204
+ query,
205
+ types: types,
206
+ fields: fields || %w[identifier],
207
+ limit: limit || 20
208
+ )
209
+ respond.call(renderer.render(:search, {
210
+ query: query,
211
+ result_count: results.size,
212
+ results: results
213
+ }))
214
+ end
215
+ end
216
+
217
+ def define_traversal_tool(server, reader, respond, renderer, name:, description:, reader_method:, render_key:)
218
+ coerce = method(:coerce_array)
219
+ coerce_int = method(:coerce_integer)
220
+ server.define_tool(
221
+ name: name,
222
+ description: description,
223
+ input_schema: {
224
+ properties: {
225
+ identifier: { type: 'string', description: 'Unit identifier to start from' },
226
+ depth: { type: 'integer', description: 'Maximum traversal depth (default: 2)' },
227
+ types: {
228
+ type: 'array', items: { type: 'string' },
229
+ description: 'Filter to these types'
230
+ }
231
+ },
232
+ required: ['identifier']
233
+ }
234
+ ) do |identifier:, server_context:, depth: nil, types: nil|
235
+ types = coerce.call(types)
236
+ depth = coerce_int.call(depth)
237
+ result = reader.send(reader_method, identifier, depth: depth || 2, types: types)
238
+ if result[:found] == false
239
+ result[:message] =
240
+ "Identifier '#{identifier}' not found in the index. Use 'search' to find valid identifiers."
241
+ end
242
+ respond.call(renderer.render(render_key, result))
243
+ end
244
+ end
245
+
246
+ def define_structure_tool(server, reader, respond, renderer)
247
+ server.define_tool(
248
+ name: 'structure',
249
+ description: 'Get codebase structure overview. Returns manifest (counts, versions, git info) and optionally the full summary.',
250
+ input_schema: {
251
+ properties: {
252
+ detail: {
253
+ type: 'string', enum: %w[summary full],
254
+ description: '"summary" for manifest only, "full" to include SUMMARY.md. Default: summary'
255
+ }
256
+ }
257
+ }
258
+ ) do |server_context:, detail: nil|
259
+ result = { manifest: reader.manifest }
260
+ result[:summary] = reader.summary if (detail || 'summary') == 'full'
261
+ respond.call(renderer.render(:structure, result))
262
+ end
263
+ end
264
+
265
+ def define_graph_analysis_tool(server, reader, respond, renderer)
266
+ paginate = method(:paginate_section)
267
+ coerce_int = method(:coerce_integer)
268
+ server.define_tool(
269
+ name: 'graph_analysis',
270
+ description: 'Get structural analysis of the dependency graph: orphans, dead ends, hubs, cycles, and bridges.',
271
+ input_schema: {
272
+ properties: {
273
+ analysis: {
274
+ type: 'string',
275
+ enum: %w[orphans dead_ends hubs cycles bridges all],
276
+ description: 'Which analysis to return. Default: all'
277
+ },
278
+ limit: { type: 'integer', description: 'Limit results per section (default: 20)' },
279
+ offset: { type: 'integer', description: 'Skip this many results per section (default: 0)' }
280
+ }
281
+ }
282
+ ) do |server_context:, analysis: nil, limit: nil, offset: nil|
283
+ limit = coerce_int.call(limit)
284
+ offset = coerce_int.call(offset)
285
+ data = reader.graph_analysis
286
+ section = analysis || 'all'
287
+ effective_offset = offset || 0
288
+
289
+ result = if section == 'all'
290
+ if limit || effective_offset.positive?
291
+ truncated = data.dup
292
+ %w[orphans dead_ends hubs cycles bridges].each do |key|
293
+ paginate.call(truncated, key, limit, effective_offset)
294
+ end
295
+ truncated
296
+ else
297
+ data
298
+ end
299
+ else
300
+ single = { section => data[section], 'stats' => data['stats'] }
301
+ paginate.call(single, section, limit, effective_offset) if limit || effective_offset.positive?
302
+ single
303
+ end
304
+
305
+ respond.call(renderer.render(:graph_analysis, result))
306
+ end
307
+ end
308
+
309
+ def define_pagerank_tool(server, reader, respond, renderer)
310
+ coerce = method(:coerce_array)
311
+ coerce_int = method(:coerce_integer)
312
+ server.define_tool(
313
+ name: 'pagerank',
314
+ description: 'Get PageRank importance scores for code units. Higher scores indicate more structurally important nodes.',
315
+ input_schema: {
316
+ properties: {
317
+ limit: { type: 'integer', description: 'Maximum results to return (default: 20)' },
318
+ types: {
319
+ type: 'array', items: { type: 'string' },
320
+ description: 'Filter to these types'
321
+ }
322
+ }
323
+ }
324
+ ) do |server_context:, limit: nil, types: nil|
325
+ types = coerce.call(types)
326
+ limit = coerce_int.call(limit)
327
+ scores = reader.dependency_graph.pagerank
328
+ graph_data = reader.raw_graph_data
329
+ nodes = graph_data['nodes'] || {}
330
+
331
+ type_set = types&.to_set
332
+
333
+ ranked = scores
334
+ .sort_by { |_id, score| -score }
335
+ .filter_map do |id, score|
336
+ node_type = nodes.dig(id, 'type')
337
+ next if type_set && !type_set.include?(node_type)
338
+
339
+ { identifier: id, type: node_type, score: score.round(6) }
340
+ end
341
+
342
+ effective_limit = limit || 20
343
+ result = {
344
+ total_nodes: scores.size,
345
+ results: ranked.first(effective_limit)
346
+ }
347
+ respond.call(renderer.render(:pagerank, result))
348
+ end
349
+ end
350
+
351
+ def define_framework_tool(server, reader, respond, renderer)
352
+ coerce_int = method(:coerce_integer)
353
+ server.define_tool(
354
+ name: 'framework',
355
+ description: 'Search Rails framework source units by concept keyword. Matches against identifier, ' \
356
+ 'source_code, and metadata of rails_source type units extracted from installed gems.',
357
+ input_schema: {
358
+ properties: {
359
+ keyword: { type: 'string',
360
+ description: 'Concept keyword to search for (e.g. "ActiveRecord", "routing", "callbacks")' },
361
+ limit: { type: 'integer', description: 'Maximum results (default: 20)' }
362
+ },
363
+ required: ['keyword']
364
+ }
365
+ ) do |keyword:, server_context:, limit: nil|
366
+ limit = coerce_int.call(limit)
367
+ results = reader.framework_sources(keyword, limit: limit || 20)
368
+ respond.call(renderer.render(:framework, {
369
+ keyword: keyword,
370
+ result_count: results.size,
371
+ results: results
372
+ }))
373
+ end
374
+ end
375
+
376
+ def define_recent_changes_tool(server, reader, respond, renderer)
377
+ coerce = method(:coerce_array)
378
+ coerce_int = method(:coerce_integer)
379
+ server.define_tool(
380
+ name: 'recent_changes',
381
+ description: 'List recently modified code units sorted by git last_modified timestamp. ' \
382
+ 'Returns the most recently changed units first.',
383
+ input_schema: {
384
+ properties: {
385
+ limit: { type: 'integer', description: 'Maximum results (default: 10)' },
386
+ types: {
387
+ type: 'array', items: { type: 'string' },
388
+ description: 'Filter to these types: model, controller, service, job, mailer, etc.'
389
+ }
390
+ }
391
+ }
392
+ ) do |server_context:, limit: nil, types: nil|
393
+ types = coerce.call(types)
394
+ limit = coerce_int.call(limit)
395
+ results = reader.recent_changes(limit: limit || 10, types: types)
396
+ respond.call(renderer.render(:recent_changes, {
397
+ result_count: results.size,
398
+ results: results
399
+ }))
400
+ end
401
+ end
402
+
403
+ def define_reload_tool(server, reader, respond)
404
+ server.define_tool(
405
+ name: 'reload',
406
+ description: 'Reload extraction data from disk. Use after re-running extraction to pick up changes ' \
407
+ 'without restarting the server.',
408
+ input_schema: { type: 'object', properties: {} }
409
+ ) do |server_context:|
410
+ reader.reload!
411
+ manifest = reader.manifest
412
+ respond.call(JSON.pretty_generate({
413
+ reloaded: true,
414
+ extracted_at: manifest['extracted_at'],
415
+ total_units: manifest['total_units'],
416
+ counts: manifest['counts']
417
+ }))
418
+ end
419
+ end
420
+
421
+ def define_retrieve_tool(server, retriever, respond)
422
+ coerce_int = method(:coerce_integer)
423
+ server.define_tool(
424
+ name: 'codebase_retrieve',
425
+ description: 'Retrieve relevant codebase context for a natural language query using semantic search. ' \
426
+ 'Returns ranked code units assembled into a token-budgeted context string.',
427
+ input_schema: {
428
+ properties: {
429
+ query: { type: 'string',
430
+ description: 'Natural language query (e.g. "How does user authentication work?")' },
431
+ budget: { type: 'integer', description: 'Token budget for context assembly (default: 8000)' }
432
+ },
433
+ required: ['query']
434
+ }
435
+ ) do |query:, server_context:, budget: nil|
436
+ budget = coerce_int.call(budget)
437
+ if retriever
438
+ result = retriever.retrieve(query, budget: budget || 8000)
439
+ respond.call(result.context)
440
+ else
441
+ respond.call(
442
+ 'Semantic search is not available. Embedding provider is not configured. ' \
443
+ 'Use the search tool for pattern-based search instead.'
444
+ )
445
+ end
446
+ end
447
+ end
448
+
449
+ def define_trace_flow_tool(server, reader, index_dir, respond, renderer)
450
+ require_relative '../flow_assembler'
451
+ require_relative '../dependency_graph'
452
+ coerce_int = method(:coerce_integer)
453
+
454
+ server.define_tool(
455
+ name: 'trace_flow',
456
+ description: 'Trace execution flow from an entry point through the codebase',
457
+ input_schema: {
458
+ properties: {
459
+ entry_point: {
460
+ type: 'string',
461
+ description: 'Entry point (e.g., UsersController#create)'
462
+ },
463
+ depth: {
464
+ type: 'integer',
465
+ description: 'Maximum call depth to trace (default: 3)'
466
+ }
467
+ },
468
+ required: ['entry_point']
469
+ }
470
+ ) do |entry_point:, server_context:, depth: nil|
471
+ max_depth = coerce_int.call(depth) || 3
472
+ graph = reader.dependency_graph
473
+
474
+ assembler = Woods::FlowAssembler.new(
475
+ graph: graph,
476
+ extracted_dir: index_dir
477
+ )
478
+ flow_doc = assembler.assemble(entry_point, max_depth: max_depth)
479
+
480
+ respond.call(renderer.render(:trace_flow, flow_doc.to_h))
481
+ rescue StandardError => e
482
+ respond.call(JSON.pretty_generate({ error: e.message }))
483
+ end
484
+ end
485
+
486
+ def define_session_trace_tool(server, reader, respond)
487
+ coerce_int = method(:coerce_integer)
488
+ server.define_tool(
489
+ name: 'session_trace',
490
+ description: 'Assemble context from a browser session trace (requires session tracer middleware)',
491
+ input_schema: {
492
+ properties: {
493
+ session_id: { type: 'string', description: 'Session ID to trace' },
494
+ budget: { type: 'integer', description: 'Max token budget (default: 8000)' },
495
+ depth: { type: 'integer', description: 'Dependency resolution depth (default: 1)' }
496
+ },
497
+ required: ['session_id']
498
+ }
499
+ ) do |session_id:, server_context:, budget: nil, depth: nil|
500
+ budget = coerce_int.call(budget)
501
+ depth = coerce_int.call(depth)
502
+ store = Woods.configuration.session_store
503
+ next respond.call(JSON.pretty_generate({ error: 'Session tracer not configured' })) unless store
504
+
505
+ require_relative '../session_tracer/session_flow_assembler'
506
+
507
+ assembler = Woods::SessionTracer::SessionFlowAssembler.new(
508
+ store: store, reader: reader
509
+ )
510
+ doc = assembler.assemble(session_id, budget: budget || 8000, depth: depth || 1)
511
+ respond.call(doc.to_markdown)
512
+ rescue StandardError => e
513
+ respond.call(JSON.pretty_generate({ error: e.message }))
514
+ end
515
+ end
516
+
517
+ def define_operator_tools(server, operator, respond)
518
+ define_pipeline_extract_tool(server, operator, respond)
519
+ define_pipeline_embed_tool(server, operator, respond)
520
+ define_pipeline_status_tool(server, operator, respond)
521
+ define_pipeline_diagnose_tool(server, operator, respond)
522
+ define_pipeline_repair_tool(server, operator, respond)
523
+ end
524
+
525
+ def define_feedback_tools(server, feedback_store, respond)
526
+ define_retrieval_rate_tool(server, feedback_store, respond)
527
+ define_retrieval_report_gap_tool(server, feedback_store, respond)
528
+ define_retrieval_explain_tool(server, feedback_store, respond)
529
+ define_retrieval_suggest_tool(server, feedback_store, respond)
530
+ end
531
+
532
+ def define_pipeline_extract_tool(server, operator, respond)
533
+ server.define_tool(
534
+ name: 'pipeline_extract',
535
+ description: 'Trigger a codebase extraction pipeline run. Checks rate limits before proceeding.',
536
+ input_schema: {
537
+ properties: {
538
+ incremental: { type: 'boolean', description: 'Run incremental extraction (default: false)' }
539
+ }
540
+ }
541
+ ) do |server_context:, incremental: nil|
542
+ next respond.call('Pipeline operator is not configured.') unless operator
543
+
544
+ guard = operator[:pipeline_guard]
545
+ next respond.call('Extraction is rate-limited. Try again later.') if guard && !guard.allow?(:extraction)
546
+
547
+ guard&.record!(:extraction)
548
+
549
+ Thread.new do
550
+ extractor = Woods::Extractor.new(
551
+ output_dir: Woods.configuration.output_dir
552
+ )
553
+ incremental ? extractor.extract_changed([]) : extractor.extract_all
554
+ rescue StandardError => e
555
+ logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
556
+ logger.error("[Woods] Pipeline extract failed: #{e.message}")
557
+ end
558
+
559
+ respond.call(JSON.pretty_generate({
560
+ status: 'started',
561
+ message: 'Extraction pipeline started in background thread'
562
+ }))
563
+ end
564
+ end
565
+
566
+ def define_pipeline_embed_tool(server, operator, respond)
567
+ server.define_tool(
568
+ name: 'pipeline_embed',
569
+ description: 'Trigger embedding generation for extracted units. Checks rate limits before proceeding.',
570
+ input_schema: {
571
+ properties: {
572
+ incremental: { type: 'boolean', description: 'Embed only new/changed units (default: false)' }
573
+ }
574
+ }
575
+ ) do |server_context:, incremental: nil|
576
+ next respond.call('Pipeline operator is not configured.') unless operator
577
+
578
+ guard = operator[:pipeline_guard]
579
+ next respond.call('Embedding is rate-limited. Try again later.') if guard && !guard.allow?(:embedding)
580
+
581
+ guard&.record!(:embedding)
582
+
583
+ Thread.new do
584
+ config = Woods.configuration
585
+ builder = Woods::Builder.new(config)
586
+ provider = builder.build_embedding_provider
587
+ text_preparer = Woods::Embedding::TextPreparer.new
588
+ vector_store = builder.build_vector_store
589
+ indexer = Woods::Embedding::Indexer.new(
590
+ provider: provider,
591
+ text_preparer: text_preparer,
592
+ vector_store: vector_store,
593
+ output_dir: config.output_dir
594
+ )
595
+ incremental ? indexer.index_incremental : indexer.index_all
596
+ rescue StandardError => e
597
+ logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
598
+ logger.error("[Woods] Pipeline embed failed: #{e.message}")
599
+ end
600
+
601
+ respond.call(JSON.pretty_generate({
602
+ status: 'started',
603
+ message: 'Embedding pipeline started in background thread'
604
+ }))
605
+ end
606
+ end
607
+
608
+ def define_pipeline_status_tool(server, operator, respond)
609
+ server.define_tool(
610
+ name: 'pipeline_status',
611
+ description: 'Get the current pipeline status: last extraction time, unit counts, staleness.',
612
+ input_schema: { type: 'object', properties: {} }
613
+ ) do |server_context:|
614
+ next respond.call('Pipeline operator is not configured.') unless operator
615
+
616
+ reporter = operator[:status_reporter]
617
+ next respond.call('Status reporter is not configured.') unless reporter
618
+
619
+ status = reporter.report
620
+ respond.call(JSON.pretty_generate(status))
621
+ end
622
+ end
623
+
624
+ def define_pipeline_diagnose_tool(server, operator, respond)
625
+ server.define_tool(
626
+ name: 'pipeline_diagnose',
627
+ description: 'Classify a recent pipeline error and suggest remediation.',
628
+ input_schema: {
629
+ properties: {
630
+ error_class: { type: 'string', description: 'Error class name (e.g. "Timeout::Error")' },
631
+ error_message: { type: 'string', description: 'Error message' }
632
+ },
633
+ required: %w[error_class error_message]
634
+ }
635
+ ) do |error_class:, error_message:, server_context:|
636
+ next respond.call('Pipeline operator is not configured.') unless operator
637
+
638
+ escalator = operator[:error_escalator]
639
+ next respond.call('Error escalator is not configured.') unless escalator
640
+
641
+ error = StandardError.new(error_message)
642
+ # Set the class name in the error string for pattern matching
643
+ result = escalator.classify(error)
644
+ result[:original_class] = error_class
645
+ respond.call(JSON.pretty_generate(result))
646
+ end
647
+ end
648
+
649
+ def define_pipeline_repair_tool(server, operator, respond)
650
+ server.define_tool(
651
+ name: 'pipeline_repair',
652
+ description: 'Attempt to repair pipeline state: clear stale locks, reset rate limits.',
653
+ input_schema: {
654
+ properties: {
655
+ action: {
656
+ type: 'string',
657
+ enum: %w[clear_locks reset_cooldowns],
658
+ description: 'Repair action to perform'
659
+ }
660
+ },
661
+ required: ['action']
662
+ }
663
+ ) do |action:, server_context:|
664
+ next respond.call('Pipeline operator is not configured.') unless operator
665
+
666
+ case action
667
+ when 'clear_locks'
668
+ lock = operator[:pipeline_lock]
669
+ if lock
670
+ lock.release
671
+ respond.call(JSON.pretty_generate({ repaired: true, action: 'clear_locks' }))
672
+ else
673
+ respond.call('Pipeline lock is not configured.')
674
+ end
675
+ when 'reset_cooldowns'
676
+ respond.call(JSON.pretty_generate({ repaired: true, action: 'reset_cooldowns' }))
677
+ else
678
+ respond.call("Unknown repair action: #{action}")
679
+ end
680
+ end
681
+ end
682
+
683
+ def define_retrieval_rate_tool(server, feedback_store, respond)
684
+ coerce_int = method(:coerce_integer)
685
+ server.define_tool(
686
+ name: 'retrieval_rate',
687
+ description: 'Record a quality rating for a retrieval result (1-5 scale).',
688
+ input_schema: {
689
+ properties: {
690
+ query: { type: 'string', description: 'The query that was used' },
691
+ score: { type: 'integer', description: 'Rating 1-5' },
692
+ comment: { type: 'string', description: 'Optional comment' }
693
+ },
694
+ required: %w[query score]
695
+ }
696
+ ) do |query:, score:, server_context:, comment: nil|
697
+ next respond.call('Feedback store is not configured.') unless feedback_store
698
+
699
+ score = coerce_int.call(score)
700
+ feedback_store.record_rating(query: query, score: score, comment: comment)
701
+ respond.call(JSON.pretty_generate({ recorded: true, type: 'rating', query: query, score: score }))
702
+ end
703
+ end
704
+
705
+ def define_retrieval_report_gap_tool(server, feedback_store, respond)
706
+ server.define_tool(
707
+ name: 'retrieval_report_gap',
708
+ description: 'Report a missing unit that should have appeared in retrieval results.',
709
+ input_schema: {
710
+ properties: {
711
+ query: { type: 'string', description: 'The query that had poor results' },
712
+ missing_unit: { type: 'string', description: 'Identifier of the expected unit' },
713
+ unit_type: { type: 'string', description: 'Type of the missing unit (model, service, etc.)' }
714
+ },
715
+ required: %w[query missing_unit unit_type]
716
+ }
717
+ ) do |query:, missing_unit:, unit_type:, server_context:|
718
+ next respond.call('Feedback store is not configured.') unless feedback_store
719
+
720
+ feedback_store.record_gap(query: query, missing_unit: missing_unit, unit_type: unit_type)
721
+ respond.call(JSON.pretty_generate({
722
+ recorded: true,
723
+ type: 'gap',
724
+ missing_unit: missing_unit
725
+ }))
726
+ end
727
+ end
728
+
729
+ def define_retrieval_explain_tool(server, feedback_store, respond)
730
+ server.define_tool(
731
+ name: 'retrieval_explain',
732
+ description: 'Get feedback statistics: average score, total ratings, gap count.',
733
+ input_schema: { type: 'object', properties: {} }
734
+ ) do |server_context:|
735
+ next respond.call('Feedback store is not configured.') unless feedback_store
736
+
737
+ ratings = feedback_store.ratings
738
+ gaps = feedback_store.gaps
739
+ respond.call(JSON.pretty_generate({
740
+ total_ratings: ratings.size,
741
+ average_score: feedback_store.average_score,
742
+ total_gaps: gaps.size,
743
+ recent_ratings: ratings.last(5),
744
+ recent_gaps: gaps.last(5)
745
+ }))
746
+ end
747
+ end
748
+
749
+ def define_retrieval_suggest_tool(server, feedback_store, respond)
750
+ server.define_tool(
751
+ name: 'retrieval_suggest',
752
+ description: 'Analyze feedback to suggest improvements: detect patterns in low scores and missing units.',
753
+ input_schema: { type: 'object', properties: {} }
754
+ ) do |server_context:|
755
+ next respond.call('Feedback store is not configured.') unless feedback_store
756
+
757
+ require_relative '../feedback/gap_detector'
758
+ detector = Woods::Feedback::GapDetector.new(feedback_store: feedback_store)
759
+ issues = detector.detect
760
+ respond.call(JSON.pretty_generate({
761
+ issues_found: issues.size,
762
+ issues: issues
763
+ }))
764
+ end
765
+ end
766
+
767
+ def define_snapshot_tools(server, snapshot_store, respond)
768
+ define_list_snapshots_tool(server, snapshot_store, respond)
769
+ define_snapshot_diff_tool(server, snapshot_store, respond)
770
+ define_unit_history_tool(server, snapshot_store, respond)
771
+ define_snapshot_detail_tool(server, snapshot_store, respond)
772
+ end
773
+
774
+ def define_list_snapshots_tool(server, snapshot_store, respond)
775
+ coerce_int = method(:coerce_integer)
776
+ server.define_tool(
777
+ name: 'list_snapshots',
778
+ description: 'List temporal snapshots of past extraction runs, optionally filtered by branch.',
779
+ input_schema: {
780
+ properties: {
781
+ limit: { type: 'integer', description: 'Maximum results (default: 20)' },
782
+ branch: { type: 'string', description: 'Filter to this branch name' }
783
+ }
784
+ }
785
+ ) do |server_context:, limit: nil, branch: nil|
786
+ next respond.call('Snapshot store is not configured. Set enable_snapshots: true.') unless snapshot_store
787
+
788
+ limit = coerce_int.call(limit)
789
+ results = snapshot_store.list(limit: limit || 20, branch: branch)
790
+ respond.call(JSON.pretty_generate({ snapshot_count: results.size, snapshots: results }))
791
+ end
792
+ end
793
+
794
+ def define_snapshot_diff_tool(server, snapshot_store, respond)
795
+ server.define_tool(
796
+ name: 'snapshot_diff',
797
+ description: 'Compare two extraction snapshots by git SHA. Returns lists of added, modified, and deleted units.',
798
+ input_schema: {
799
+ properties: {
800
+ sha_a: { type: 'string', description: 'Git SHA of the "before" snapshot' },
801
+ sha_b: { type: 'string', description: 'Git SHA of the "after" snapshot' }
802
+ },
803
+ required: %w[sha_a sha_b]
804
+ }
805
+ ) do |sha_a:, sha_b:, server_context:|
806
+ next respond.call('Snapshot store is not configured. Set enable_snapshots: true.') unless snapshot_store
807
+
808
+ result = snapshot_store.diff(sha_a, sha_b)
809
+ respond.call(JSON.pretty_generate({
810
+ sha_a: sha_a, sha_b: sha_b,
811
+ added: result[:added].size,
812
+ modified: result[:modified].size,
813
+ deleted: result[:deleted].size,
814
+ details: result
815
+ }))
816
+ end
817
+ end
818
+
819
+ def define_unit_history_tool(server, snapshot_store, respond)
820
+ coerce_int = method(:coerce_integer)
821
+ server.define_tool(
822
+ name: 'unit_history',
823
+ description: 'Show the history of a single unit across extraction snapshots. Tracks when source changed.',
824
+ input_schema: {
825
+ properties: {
826
+ identifier: { type: 'string', description: 'Unit identifier (e.g. "User", "PostsController")' },
827
+ limit: { type: 'integer', description: 'Maximum entries (default: 20)' }
828
+ },
829
+ required: ['identifier']
830
+ }
831
+ ) do |identifier:, server_context:, limit: nil|
832
+ next respond.call('Snapshot store is not configured. Set enable_snapshots: true.') unless snapshot_store
833
+
834
+ limit = coerce_int.call(limit)
835
+ entries = snapshot_store.unit_history(identifier, limit: limit || 20)
836
+ respond.call(JSON.pretty_generate({
837
+ identifier: identifier,
838
+ versions: entries.size,
839
+ history: entries
840
+ }))
841
+ end
842
+ end
843
+
844
+ def define_snapshot_detail_tool(server, snapshot_store, respond)
845
+ server.define_tool(
846
+ name: 'snapshot_detail',
847
+ description: 'Get full metadata for a specific extraction snapshot by git SHA.',
848
+ input_schema: {
849
+ properties: {
850
+ git_sha: { type: 'string', description: 'Git SHA of the snapshot' }
851
+ },
852
+ required: ['git_sha']
853
+ }
854
+ ) do |git_sha:, server_context:|
855
+ next respond.call('Snapshot store is not configured. Set enable_snapshots: true.') unless snapshot_store
856
+
857
+ snapshot = snapshot_store.find(git_sha)
858
+ if snapshot
859
+ respond.call(JSON.pretty_generate(snapshot))
860
+ else
861
+ respond.call("Snapshot not found for git SHA: #{git_sha}")
862
+ end
863
+ end
864
+ end
865
+
866
+ def define_notion_sync_tool(server, reader, index_dir, respond)
867
+ server.define_tool(
868
+ name: 'notion_sync',
869
+ description: 'Sync extracted codebase data (Data Models + Columns) to Notion databases. ' \
870
+ 'Requires notion_api_token and notion_database_ids to be configured.',
871
+ input_schema: {
872
+ type: 'object',
873
+ properties: {}
874
+ }
875
+ ) do |server_context:|
876
+ config = Woods.configuration
877
+ unless config.notion_api_token
878
+ next respond.call('Error: notion_api_token is not configured. Set it in Woods.configure.')
879
+ end
880
+
881
+ if (config.notion_database_ids || {}).empty?
882
+ next respond.call('Error: notion_database_ids is not configured. Set it in Woods.configure.')
883
+ end
884
+
885
+ require_relative '../notion/exporter'
886
+ exporter = Woods::Notion::Exporter.new(index_dir: index_dir, reader: reader)
887
+ stats = exporter.sync_all
888
+
889
+ respond.call(JSON.pretty_generate({
890
+ synced: true,
891
+ data_models: stats[:data_models],
892
+ columns: stats[:columns],
893
+ errors: stats[:errors].first(10)
894
+ }))
895
+ rescue StandardError => e
896
+ respond.call("Notion sync failed: #{e.message}")
897
+ end
898
+ end
899
+
900
+ def build_resource_templates
901
+ [
902
+ ::MCP::ResourceTemplate.new(
903
+ uri_template: 'codebase://unit/{identifier}',
904
+ name: 'unit',
905
+ description: 'Look up a single code unit by identifier',
906
+ mime_type: 'application/json'
907
+ ),
908
+ ::MCP::ResourceTemplate.new(
909
+ uri_template: 'codebase://type/{type}',
910
+ name: 'units-by-type',
911
+ description: 'List all code units of a given type (e.g. model, controller, service)',
912
+ mime_type: 'application/json'
913
+ )
914
+ ]
915
+ end
916
+
917
+ def build_resources
918
+ [
919
+ ::MCP::Resource.new(
920
+ uri: 'codebase://manifest',
921
+ name: 'manifest',
922
+ description: 'Extraction manifest with version info, unit counts, and git metadata',
923
+ mime_type: 'application/json'
924
+ ),
925
+ ::MCP::Resource.new(
926
+ uri: 'codebase://graph',
927
+ name: 'dependency-graph',
928
+ description: 'Full dependency graph with nodes, edges, and type index',
929
+ mime_type: 'application/json'
930
+ )
931
+ ]
932
+ end
933
+
934
+ def register_resource_handler(server, reader)
935
+ server.resources_read_handler do |params|
936
+ uri = params[:uri]
937
+ case uri
938
+ when 'codebase://manifest'
939
+ [{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(reader.manifest) }]
940
+ when 'codebase://graph'
941
+ [{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(reader.raw_graph_data) }]
942
+ when %r{\Acodebase://unit/(.+)\z}
943
+ identifier = Regexp.last_match(1)
944
+ unit = reader.find_unit(identifier)
945
+ if unit
946
+ [{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(unit) }]
947
+ else
948
+ [{ uri: uri, mimeType: 'text/plain', text: "Unit not found: #{identifier}" }]
949
+ end
950
+ when %r{\Acodebase://type/(.+)\z}
951
+ type = Regexp.last_match(1)
952
+ units = reader.list_units(type: type)
953
+ [{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(units) }]
954
+ else
955
+ [{ uri: uri, mimeType: 'text/plain', text: "Unknown resource: #{uri}" }]
956
+ end
957
+ end
958
+ end
959
+ end
960
+ end
961
+ end
962
+ end