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