codebase_index 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b4a8714907098ba15578b80015b9d20bcf7b3d10da54f211321656c3583bcf9
4
- data.tar.gz: 3b9b8b03f66e7126b74199688fd89264c4abc9850ce104c9657faf41d3827db7
3
+ metadata.gz: 8ba2df9baa16005b8f3981639c0e1bca59bbff3382c1cd483e2a686d399054f4
4
+ data.tar.gz: '0983e4f7e63febbe63631bff76caab5d11cc88dec9e360eab2a3ea33f6adb025'
5
5
  SHA512:
6
- metadata.gz: d08c8bf05ba7d74ee664abb13340fedbf50cce318a6a83a0ef9d42dadce8d85600cda3c56b867feafc2f09949c9735786c28edd2647542cec336d4496a476711
7
- data.tar.gz: c366e25d8efdde76f11a8b8e4267cbdfe098b567041e28fbc49071ebcf7143e2a7e530a1033888dadf26965888f14f9a1a3a306d668e3e87c28d4cb441f29a36
6
+ metadata.gz: 25af1daf07cdbdbf810919db95f8dd9535b79c784e6fc74aad5885931221ca9c389507fd67eb1a1e5ec2cb3f5415fc111c4946db27a29df1882975a0741eb2f8
7
+ data.tar.gz: 5a9e35f69d822a8cc0e12a82a0d33fad0fdc6fac61d65c88e4c00ccfe8fbda5b7f7bcde0a82f7051a3238e43b60f1a8143dc35a28486fbc8e6818770b9d98693
data/README.md CHANGED
@@ -191,6 +191,55 @@ codebase-console-mcp
191
191
 
192
192
  See [docs/MCP_SERVERS.md](docs/MCP_SERVERS.md) for the full tool catalog and setup instructions.
193
193
 
194
+ ### Claude Code Setup
195
+
196
+ Add the servers to your project's `.mcp.json`:
197
+
198
+ ```json
199
+ {
200
+ "mcpServers": {
201
+ "codebase-index": {
202
+ "command": "codebase-index-mcp",
203
+ "args": ["/path/to/rails-app/tmp/codebase_index"]
204
+ },
205
+ "codebase-console": {
206
+ "command": "bundle",
207
+ "args": ["exec", "rake", "codebase_index:console"],
208
+ "cwd": "/path/to/rails-app"
209
+ }
210
+ }
211
+ }
212
+ ```
213
+
214
+ The **index server** reads from a pre-extracted directory — run `bundle exec rake codebase_index:extract` in your Rails app first.
215
+
216
+ The **console server** runs embedded inside your Rails app (no config file needed). For Docker:
217
+
218
+ ```json
219
+ {
220
+ "mcpServers": {
221
+ "codebase-console": {
222
+ "command": "docker",
223
+ "args": ["exec", "-i", "my_container", "bundle", "exec", "rake", "codebase_index:console"]
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ ### Validation
230
+
231
+ Verify each server starts and lists its tools:
232
+
233
+ ```bash
234
+ # Index server — should list 27 tools
235
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \
236
+ codebase-index-mcp /path/to/rails-app/tmp/codebase_index
237
+
238
+ # Console server — should list 31 tools (requires Rails app)
239
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \
240
+ bundle exec rake codebase_index:console
241
+ ```
242
+
194
243
  ## Subsystems
195
244
 
196
245
  ```
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Embedded console MCP server — runs inside a Rails environment.
5
+ #
6
+ # Usage (via rake, recommended):
7
+ # bundle exec rake codebase_index:console
8
+ #
9
+ # Usage (via rails runner):
10
+ # bundle exec rails runner "$(bundle show codebase_index)/exe/codebase-console"
11
+ #
12
+ # The rake task captures stdout before Rails boots and passes the fd via
13
+ # $codebase_index_protocol_out. When run via rails runner, this script
14
+ # captures stdout itself to keep MCP protocol clean.
15
+
16
+ # Check if the rake task already captured stdout for us.
17
+ protocol_out = $codebase_index_protocol_out # rubocop:disable Style/GlobalVars
18
+
19
+ unless protocol_out
20
+ # Running via rails runner — capture stdout ourselves.
21
+ protocol_out = $stdout.dup
22
+ $stdout.reopen($stderr)
23
+ end
24
+
25
+ require 'codebase_index/console/server'
26
+
27
+ # Ensure all application models are loaded for the registry.
28
+ Rails.application.eager_load!
29
+
30
+ registry = ActiveRecord::Base.descendants.each_with_object({}) do |model, hash|
31
+ next if model.abstract_class?
32
+ next unless model.table_exists?
33
+
34
+ hash[model.name] = model.column_names
35
+ rescue StandardError
36
+ next
37
+ end
38
+
39
+ validator = CodebaseIndex::Console::ModelValidator.new(registry: registry)
40
+ safe_context = CodebaseIndex::Console::SafeContext.new(connection: ActiveRecord::Base.connection)
41
+
42
+ redacted_columns = if CodebaseIndex.respond_to?(:configuration) && CodebaseIndex.configuration
43
+ Array(CodebaseIndex.configuration.console_redacted_columns)
44
+ else
45
+ []
46
+ end
47
+
48
+ server = CodebaseIndex::Console::Server.build_embedded(
49
+ model_validator: validator,
50
+ safe_context: safe_context,
51
+ redacted_columns: redacted_columns
52
+ )
53
+
54
+ # Restore the protocol output for MCP transport.
55
+ $stdout.reopen(protocol_out)
56
+ protocol_out.close unless protocol_out.closed?
57
+
58
+ transport = MCP::Server::Transports::StdioTransport.new(server)
59
+ transport.open
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'model_validator'
4
+ require_relative 'safe_context'
5
+
6
+ module CodebaseIndex
7
+ module Console
8
+ # Drop-in replacement for ConnectionManager + Bridge that executes
9
+ # queries directly via ActiveRecord instead of a separate bridge process.
10
+ #
11
+ # Implements the same `send_request(Hash) -> Hash` interface as
12
+ # ConnectionManager, so all existing tool definitions in Server work
13
+ # unchanged — just pass this where `conn_mgr` goes.
14
+ #
15
+ # @example
16
+ # executor = EmbeddedExecutor.new(model_validator: validator, safe_context: ctx)
17
+ # response = executor.send_request({ 'tool' => 'count', 'params' => { 'model' => 'User' } })
18
+ # # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
19
+ #
20
+ class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
21
+ AGGREGATE_FUNCTIONS = %w[sum average minimum maximum].freeze
22
+
23
+ TIER1_TOOLS = %w[count sample find pluck aggregate association_count schema recent status].freeze
24
+
25
+ # @param model_validator [ModelValidator] Validates model/column names
26
+ # @param safe_context [SafeContext] Wraps execution in rolled-back transaction
27
+ # @param connection [Object, nil] Database connection for adapter detection
28
+ def initialize(model_validator:, safe_context:, connection: nil)
29
+ @model_validator = model_validator
30
+ @safe_context = safe_context
31
+ @connection = connection
32
+ end
33
+
34
+ # Execute a tool request and return a response hash.
35
+ #
36
+ # Compatible with ConnectionManager#send_request — Server's `send_to_bridge`
37
+ # calls this method and expects `{ 'ok' => true/false, ... }`.
38
+ #
39
+ # @param request [Hash] Request with 'tool' and 'params' keys
40
+ # @return [Hash] Response with 'ok', 'result'/'error', and 'timing_ms'
41
+ def send_request(request)
42
+ # Deep-stringify keys — Tier1 tool builders use symbol keys, but the bridge
43
+ # path naturally stringifies via JSON round-trip. Replicate that here.
44
+ request = deep_stringify_keys(request)
45
+ tool = request['tool']
46
+ params = request['params'] || {}
47
+
48
+ unless TIER1_TOOLS.include?(tool)
49
+ return { 'ok' => false,
50
+ 'error' => 'Not yet implemented in embedded mode',
51
+ 'error_type' => 'unsupported' }
52
+ end
53
+
54
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
+ result = @safe_context.execute { dispatch(tool, params) }
56
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
57
+
58
+ { 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
59
+ rescue ValidationError => e
60
+ { 'ok' => false, 'error' => e.message, 'error_type' => 'validation' }
61
+ rescue StandardError => e
62
+ { 'ok' => false, 'error' => e.message, 'error_type' => 'execution' }
63
+ end
64
+
65
+ private
66
+
67
+ # Route a tool name to its handler.
68
+ #
69
+ # @param tool [String] Tool name
70
+ # @param params [Hash] Tool parameters
71
+ # @return [Hash] Tool result
72
+ def dispatch(tool, params)
73
+ case tool
74
+ when 'status' then handle_status
75
+ when 'schema' then handle_schema(params)
76
+ else
77
+ validate_model!(params)
78
+ send(:"handle_#{tool}", params)
79
+ end
80
+ end
81
+
82
+ # @param params [Hash] Must contain 'model' key
83
+ # @raise [ValidationError]
84
+ def validate_model!(params)
85
+ model = params['model']
86
+ raise ValidationError, 'Missing required parameter: model' unless model
87
+
88
+ @model_validator.validate_model!(model)
89
+ end
90
+
91
+ # Resolve a model name string to an ActiveRecord class.
92
+ #
93
+ # @param name [String] Model class name (e.g., 'User', 'Admin::Account')
94
+ # @return [Class] The ActiveRecord model class
95
+ def resolve_model(name)
96
+ name.constantize
97
+ end
98
+
99
+ # ── Tier 1 Handlers ──────────────────────────────────────────────────
100
+
101
+ def handle_count(params)
102
+ model = resolve_model(params['model'])
103
+ scope = apply_scope(model, params['scope'])
104
+ { 'count' => scope.count }
105
+ end
106
+
107
+ def handle_sample(params)
108
+ model = resolve_model(params['model'])
109
+ limit = [params.fetch('limit', 5).to_i, 25].min
110
+ scope = apply_scope(model, params['scope'])
111
+ scope = apply_columns(scope, params['columns'])
112
+ records = scope.order(random_function).limit(limit)
113
+ { 'records' => serialize_records(records, params['columns']) }
114
+ end
115
+
116
+ def handle_find(params)
117
+ model = resolve_model(params['model'])
118
+ record = if params['id']
119
+ model.find_by(id: params['id'])
120
+ elsif params['by']
121
+ model.find_by(params['by'])
122
+ end
123
+ { 'record' => record ? serialize_record(record, params['columns']) : nil }
124
+ end
125
+
126
+ def handle_pluck(params)
127
+ columns = params['columns']
128
+ @model_validator.validate_columns!(params['model'], columns) if columns
129
+ model = resolve_model(params['model'])
130
+ limit = [params.fetch('limit', 100).to_i, 1000].min
131
+ scope = apply_scope(model, params['scope'])
132
+ scope = scope.distinct if params['distinct']
133
+ values = scope.limit(limit).pluck(*columns.map(&:to_sym))
134
+ { 'values' => values }
135
+ end
136
+
137
+ def handle_aggregate(params)
138
+ column = params['column']
139
+ function = params['function']
140
+ @model_validator.validate_column!(params['model'], column) if column
141
+
142
+ unless AGGREGATE_FUNCTIONS.include?(function)
143
+ raise ValidationError, "Invalid aggregate function: #{function}. " \
144
+ "Allowed: #{AGGREGATE_FUNCTIONS.join(', ')}"
145
+ end
146
+
147
+ model = resolve_model(params['model'])
148
+ scope = apply_scope(model, params['scope'])
149
+ { 'value' => scope.send(function.to_sym, column.to_sym) }
150
+ end
151
+
152
+ def handle_association_count(params)
153
+ model = resolve_model(params['model'])
154
+ record = model.find(params['id'])
155
+ association_name = params['association']
156
+
157
+ unless model.reflect_on_association(association_name.to_sym)
158
+ raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}"
159
+ end
160
+
161
+ scope = record.public_send(association_name)
162
+ scope = apply_scope(scope, params['scope'])
163
+ { 'count' => scope.count }
164
+ end
165
+
166
+ def handle_schema(params)
167
+ model_name = params['model']
168
+ raise ValidationError, 'Missing required parameter: model' unless model_name
169
+
170
+ @model_validator.validate_model!(model_name)
171
+ model = resolve_model(model_name)
172
+
173
+ columns = model.columns_hash.transform_values do |col|
174
+ { 'type' => col.type.to_s, 'null' => col.null, 'default' => col.default&.to_s }
175
+ end
176
+
177
+ result = { 'columns' => columns }
178
+
179
+ if params['include_indexes']
180
+ indexes = model.connection.indexes(model.table_name).map do |idx|
181
+ { 'name' => idx.name, 'columns' => idx.columns, 'unique' => idx.unique }
182
+ end
183
+ result['indexes'] = indexes
184
+ end
185
+
186
+ result
187
+ end
188
+
189
+ def handle_recent(params)
190
+ model = resolve_model(params['model'])
191
+ order_by = params.fetch('order_by', 'created_at')
192
+ direction = params.fetch('direction', 'desc')
193
+ limit = [params.fetch('limit', 10).to_i, 50].min
194
+
195
+ @model_validator.validate_column!(params['model'], order_by)
196
+ direction = 'desc' unless %w[asc desc].include?(direction)
197
+
198
+ scope = apply_scope(model, params['scope'])
199
+ scope = apply_columns(scope, params['columns'])
200
+ records = scope.order(order_by => direction.to_sym).limit(limit)
201
+ { 'records' => serialize_records(records, params['columns']) }
202
+ end
203
+
204
+ def handle_status
205
+ adapter = begin
206
+ active_connection.adapter_name
207
+ rescue StandardError
208
+ 'unknown'
209
+ end
210
+ { 'status' => 'ok', 'models' => @model_validator.model_names, 'adapter' => adapter }
211
+ end
212
+
213
+ # ── Helpers ──────────────────────────────────────────────────────────
214
+
215
+ # Apply scope conditions (WHERE clauses) to a relation.
216
+ #
217
+ # @param relation [ActiveRecord::Relation, Class] Model or relation
218
+ # @param scope [Hash, nil] Filter conditions
219
+ # @return [ActiveRecord::Relation]
220
+ def apply_scope(relation, scope)
221
+ return relation unless scope.is_a?(Hash) && scope.any?
222
+
223
+ relation.where(scope)
224
+ end
225
+
226
+ # Apply column selection to a relation.
227
+ #
228
+ # @param relation [ActiveRecord::Relation] The relation
229
+ # @param columns [Array<String>, nil] Columns to select
230
+ # @return [ActiveRecord::Relation]
231
+ def apply_columns(relation, columns)
232
+ return relation unless columns.is_a?(Array) && columns.any?
233
+
234
+ relation.select(columns)
235
+ end
236
+
237
+ # Serialize a single record to a Hash.
238
+ #
239
+ # @param record [ActiveRecord::Base] The record
240
+ # @param columns [Array<String>, nil] Columns to include
241
+ # @return [Hash]
242
+ def serialize_record(record, columns = nil)
243
+ if columns.is_a?(Array) && columns.any?
244
+ record.attributes.slice(*columns)
245
+ else
246
+ record.attributes
247
+ end
248
+ end
249
+
250
+ # Serialize multiple records.
251
+ #
252
+ # @param records [ActiveRecord::Relation] The records
253
+ # @param columns [Array<String>, nil] Columns to include
254
+ # @return [Array<Hash>]
255
+ def serialize_records(records, columns = nil)
256
+ records.map { |r| serialize_record(r, columns) }
257
+ end
258
+
259
+ # DB-dialect-aware random ordering function.
260
+ #
261
+ # @return [Arel::Nodes::SqlLiteral]
262
+ def random_function
263
+ adapter = active_connection.adapter_name.downcase
264
+ func = adapter.include?('mysql') ? 'RAND' : 'RANDOM'
265
+ Arel.sql("#{func}()")
266
+ end
267
+
268
+ # Return the database connection (injected or from ActiveRecord).
269
+ #
270
+ # @return [Object] Database connection
271
+ def active_connection
272
+ @connection || ActiveRecord::Base.connection
273
+ end
274
+
275
+ # Recursively convert all Hash keys to strings.
276
+ #
277
+ # @param obj [Object] The object to stringify
278
+ # @return [Object] Object with string keys
279
+ def deep_stringify_keys(obj)
280
+ case obj
281
+ when Hash
282
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
283
+ when Array
284
+ obj.map { |item| deep_stringify_keys(item) }
285
+ else
286
+ obj
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module CodebaseIndex
6
+ module Console
7
+ # Rack middleware that serves the embedded console MCP server over HTTP.
8
+ #
9
+ # Lazy-builds the MCP server on first request so Rails has fully booted
10
+ # and all models are loaded. Uses ActiveRecord connection pool for thread
11
+ # safety under Puma.
12
+ #
13
+ # @example In config/application.rb or an initializer:
14
+ # config.middleware.use CodebaseIndex::Console::RackMiddleware, path: '/mcp/console'
15
+ #
16
+ class RackMiddleware
17
+ # @param app [#call] The next Rack app in the middleware stack
18
+ # @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
19
+ def initialize(app, path: '/mcp/console')
20
+ @app = app
21
+ @path = path
22
+ @mutex = Mutex.new
23
+ @transport = nil
24
+ end
25
+
26
+ # Rack interface — intercepts requests at the configured path.
27
+ #
28
+ # @param env [Hash] Rack environment
29
+ # @return [Array] Rack response triple
30
+ def call(env)
31
+ return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
32
+
33
+ transport = ensure_transport
34
+ request = Rack::Request.new(env)
35
+ transport.handle_request(request)
36
+ end
37
+
38
+ private
39
+
40
+ # Thread-safe lazy initialization of the MCP server and transport.
41
+ #
42
+ # @return [MCP::Server::Transports::StreamableHTTPTransport]
43
+ def ensure_transport # rubocop:disable Metrics/MethodLength
44
+ return @transport if @transport
45
+
46
+ @mutex.synchronize do
47
+ return @transport if @transport
48
+
49
+ require 'codebase_index/console/server'
50
+
51
+ Rails.application.eager_load!
52
+
53
+ registry = ActiveRecord::Base.descendants.each_with_object({}) do |model, hash|
54
+ next if model.abstract_class?
55
+ next unless model.table_exists?
56
+
57
+ hash[model.name] = model.column_names
58
+ rescue StandardError
59
+ next
60
+ end
61
+
62
+ validator = ModelValidator.new(registry: registry)
63
+
64
+ config = CodebaseIndex.configuration
65
+ redacted = Array(config.console_redacted_columns)
66
+
67
+ # Each HTTP request gets its own connection from the pool.
68
+ # SafeContext wraps that connection in a rolled-back transaction.
69
+ safe_context = SafeContext.new(connection: ActiveRecord::Base.connection)
70
+
71
+ server = Server.build_embedded(
72
+ model_validator: validator,
73
+ safe_context: safe_context,
74
+ redacted_columns: redacted
75
+ )
76
+
77
+ @transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
78
+ server.transport = @transport
79
+ @transport
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -34,7 +34,7 @@ module CodebaseIndex
34
34
  TIER4_TOOLS = %w[eval sql query].freeze
35
35
 
36
36
  class << self # rubocop:disable Metrics/ClassLength
37
- # Build a configured MCP::Server with console tools.
37
+ # Build a configured MCP::Server with console tools using the bridge protocol.
38
38
  #
39
39
  # @param config [Hash] Configuration hash (from YAML or env)
40
40
  # @return [MCP::Server] Configured server ready for transport
@@ -44,24 +44,37 @@ module CodebaseIndex
44
44
  redacted_columns = Array(config['redacted_columns'] || connection_config['redacted_columns'])
45
45
  safe_ctx = redacted_columns.any? ? SafeContext.new(connection: nil, redacted_columns: redacted_columns) : nil
46
46
 
47
- server = ::MCP::Server.new(
48
- name: 'codebase-console',
49
- version: defined?(CodebaseIndex::VERSION) ? CodebaseIndex::VERSION : '0.1.0'
50
- )
47
+ build_server(conn_mgr, safe_ctx)
48
+ end
51
49
 
52
- renderer = build_console_renderer
50
+ # Build a configured MCP::Server using embedded ActiveRecord execution.
51
+ #
52
+ # No bridge process needed — queries run directly via ActiveRecord.
53
+ # Pass the returned server to StdioTransport or StreamableHTTPTransport.
54
+ #
55
+ # @param model_validator [ModelValidator] Validates model/column names
56
+ # @param safe_context [SafeContext] Wraps queries in rolled-back transactions
57
+ # @param redacted_columns [Array<String>] Column names to redact from output
58
+ # @param connection [Object, nil] Database connection for adapter detection
59
+ # @return [MCP::Server] Configured server ready for transport
60
+ def build_embedded(model_validator:, safe_context:, redacted_columns: [], connection: nil)
61
+ require_relative 'embedded_executor'
53
62
 
54
- register_tier1_tools(server, conn_mgr, safe_ctx, renderer: renderer)
55
- register_tier2_tools(server, conn_mgr, safe_ctx, renderer: renderer)
56
- register_tier3_tools(server, conn_mgr, safe_ctx, renderer: renderer)
57
- register_tier4_tools(server, conn_mgr, safe_ctx, renderer: renderer)
58
- server
63
+ executor = EmbeddedExecutor.new(
64
+ model_validator: model_validator, safe_context: safe_context, connection: connection
65
+ )
66
+ redact_ctx = if redacted_columns.any?
67
+ SafeContext.new(connection: nil,
68
+ redacted_columns: redacted_columns)
69
+ end
70
+
71
+ build_server(executor, redact_ctx)
59
72
  end
60
73
 
61
74
  # Register Tier 1 read-only tools on the server.
62
75
  #
63
76
  # @param server [MCP::Server] The MCP server instance
64
- # @param conn_mgr [ConnectionManager] Bridge connection
77
+ # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
65
78
  # @param safe_ctx [SafeContext, nil] Optional context for column redaction
66
79
  # @return [void]
67
80
  def register_tier1_tools(server, conn_mgr, safe_ctx = nil, renderer: nil)
@@ -71,7 +84,7 @@ module CodebaseIndex
71
84
  # Register Tier 2 domain-aware tools on the server.
72
85
  #
73
86
  # @param server [MCP::Server] The MCP server instance
74
- # @param conn_mgr [ConnectionManager] Bridge connection
87
+ # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
75
88
  # @param safe_ctx [SafeContext, nil] Optional context for column redaction
76
89
  # @return [void]
77
90
  def register_tier2_tools(server, conn_mgr, safe_ctx = nil, renderer: nil)
@@ -81,7 +94,7 @@ module CodebaseIndex
81
94
  # Register Tier 3 analytics tools on the server.
82
95
  #
83
96
  # @param server [MCP::Server] The MCP server instance
84
- # @param conn_mgr [ConnectionManager] Bridge connection
97
+ # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
85
98
  # @param safe_ctx [SafeContext, nil] Optional context for column redaction
86
99
  # @return [void]
87
100
  def register_tier3_tools(server, conn_mgr, safe_ctx = nil, renderer: nil)
@@ -91,7 +104,7 @@ module CodebaseIndex
91
104
  # Register Tier 4 guarded tools on the server.
92
105
  #
93
106
  # @param server [MCP::Server] The MCP server instance
94
- # @param conn_mgr [ConnectionManager] Bridge connection
107
+ # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Request executor
95
108
  # @param safe_ctx [SafeContext, nil] Optional context for column redaction
96
109
  # @return [void]
97
110
  def register_tier4_tools(server, conn_mgr, safe_ctx = nil, renderer: nil)
@@ -100,6 +113,26 @@ module CodebaseIndex
100
113
 
101
114
  private
102
115
 
116
+ # Shared server construction used by both build() and build_embedded().
117
+ #
118
+ # @param conn_mgr [ConnectionManager, EmbeddedExecutor] Any object with send_request(Hash) -> Hash
119
+ # @param safe_ctx [SafeContext, nil] Optional context for column redaction
120
+ # @return [MCP::Server]
121
+ def build_server(conn_mgr, safe_ctx)
122
+ server = ::MCP::Server.new(
123
+ name: 'codebase-console',
124
+ version: defined?(CodebaseIndex::VERSION) ? CodebaseIndex::VERSION : '0.1.0'
125
+ )
126
+
127
+ renderer = build_console_renderer
128
+
129
+ register_tier1_tools(server, conn_mgr, safe_ctx, renderer: renderer)
130
+ register_tier2_tools(server, conn_mgr, safe_ctx, renderer: renderer)
131
+ register_tier3_tools(server, conn_mgr, safe_ctx, renderer: renderer)
132
+ register_tier4_tools(server, conn_mgr, safe_ctx, renderer: renderer)
133
+ server
134
+ end
135
+
103
136
  def respond(text)
104
137
  ::MCP::Tool::Response.new([{ type: 'text', text: text }])
105
138
  end
@@ -112,13 +145,14 @@ module CodebaseIndex
112
145
  text = renderer ? renderer.render_default(result) : JSON.pretty_generate(result)
113
146
  respond(text)
114
147
  else
148
+ error_text = "#{response['error_type']}: #{response['error']}"
115
149
  ::MCP::Tool::Response.new(
116
- [{ type: 'text', text: "#{response['error_type']}: #{response['error']}" }],
117
- is_error: true
150
+ [{ type: 'text', text: error_text }],
151
+ error: error_text
118
152
  )
119
153
  end
120
154
  rescue ConnectionError => e
121
- ::MCP::Tool::Response.new([{ type: 'text', text: "Connection error: #{e.message}" }], is_error: true)
155
+ ::MCP::Tool::Response.new([{ type: 'text', text: "Connection error: #{e.message}" }], error: e.message)
122
156
  end
123
157
 
124
158
  # Apply SafeContext column redaction to a result value.
@@ -536,11 +570,12 @@ module CodebaseIndex
536
570
  mgr = conn_mgr
537
571
  ctx = safe_ctx
538
572
  rdr = renderer
573
+ bridge_method = method(:send_to_bridge)
539
574
  schema = { properties: properties }
540
575
  schema[:required] = required if required&.any?
541
576
  server.define_tool(name: name, description: description, input_schema: schema) do |server_context:, **args|
542
577
  request = tool_block.call(args)
543
- send_to_bridge(mgr, request.transform_keys(&:to_s), ctx, renderer: rdr)
578
+ bridge_method.call(mgr, request.transform_keys(&:to_s), ctx, renderer: rdr)
544
579
  end
545
580
  end
546
581
  # rubocop:enable Metrics/ParameterLists
@@ -22,5 +22,17 @@ module CodebaseIndex
22
22
  )
23
23
  end
24
24
  end
25
+
26
+ initializer 'codebase_index.console_mcp' do |app|
27
+ config = CodebaseIndex.configuration
28
+ if config.console_mcp_enabled
29
+ require 'codebase_index/console/rack_middleware'
30
+
31
+ app.middleware.use(
32
+ CodebaseIndex::Console::RackMiddleware,
33
+ path: config.console_mcp_path
34
+ )
35
+ end
36
+ end
25
37
  end
26
38
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CodebaseIndex
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.1'
5
5
  end
@@ -41,10 +41,11 @@ module CodebaseIndex
41
41
  :vector_store_options, :metadata_store_options, :embedding_options,
42
42
  :concurrent_extraction, :precompute_flows, :enable_snapshots,
43
43
  :session_tracer_enabled, :session_store, :session_id_proc, :session_exclude_paths,
44
+ :console_mcp_enabled, :console_mcp_path, :console_redacted_columns,
44
45
  :notion_api_token, :notion_database_ids
45
46
  attr_reader :max_context_tokens, :similarity_threshold, :extractors, :pretty_json, :context_format
46
47
 
47
- def initialize
48
+ def initialize # rubocop:disable Metrics/MethodLength
48
49
  @output_dir = nil # Resolved lazily; Rails.root is nil at require time
49
50
  @embedding_model = 'text-embedding-3-small'
50
51
  @max_context_tokens = 8000
@@ -62,6 +63,9 @@ module CodebaseIndex
62
63
  @session_store = nil
63
64
  @session_id_proc = nil
64
65
  @session_exclude_paths = []
66
+ @console_mcp_enabled = false
67
+ @console_mcp_path = '/mcp/console'
68
+ @console_redacted_columns = []
65
69
  @notion_api_token = nil
66
70
  @notion_database_ids = {}
67
71
  end
@@ -538,6 +538,20 @@ namespace :codebase_index do
538
538
  end
539
539
  end
540
540
 
541
+ desc 'Start the embedded console MCP server (stdio transport)'
542
+ task :console do
543
+ # Capture stdout before Rails boot to keep MCP protocol clean.
544
+ # Rails boot emits OpenTelemetry, gem warnings, etc. to stdout —
545
+ # MCP client cannot parse these as JSON-RPC.
546
+ # Global variable passes the fd to exe/codebase-console via load.
547
+ $codebase_index_protocol_out = $stdout.dup # rubocop:disable Style/GlobalVars
548
+ $stdout.reopen($stderr)
549
+
550
+ Rake::Task[:environment].invoke
551
+
552
+ load File.expand_path('../../exe/codebase-console', __dir__)
553
+ end
554
+
541
555
  desc 'Sync extraction data to Notion databases (Data Models + Columns)'
542
556
  task notion_sync: :environment do
543
557
  require 'codebase_index/notion/exporter'
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: codebase_index
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leah Armstrong
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-02-28 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: mcp
@@ -46,10 +47,11 @@ description: |
46
47
  email:
47
48
  - info@leah.wtf
48
49
  executables:
49
- - codebase-console-mcp
50
50
  - codebase-index-mcp
51
- - codebase-index-mcp-http
52
51
  - codebase-index-mcp-start
52
+ - codebase-console-mcp
53
+ - codebase-console
54
+ - codebase-index-mcp-http
53
55
  extensions: []
54
56
  extra_rdoc_files: []
55
57
  files:
@@ -58,6 +60,7 @@ files:
58
60
  - CONTRIBUTING.md
59
61
  - LICENSE.txt
60
62
  - README.md
63
+ - exe/codebase-console
61
64
  - exe/codebase-console-mcp
62
65
  - exe/codebase-index-mcp
63
66
  - exe/codebase-index-mcp-http
@@ -80,7 +83,9 @@ files:
80
83
  - lib/codebase_index/console/confirmation.rb
81
84
  - lib/codebase_index/console/connection_manager.rb
82
85
  - lib/codebase_index/console/console_response_renderer.rb
86
+ - lib/codebase_index/console/embedded_executor.rb
83
87
  - lib/codebase_index/console/model_validator.rb
88
+ - lib/codebase_index/console/rack_middleware.rb
84
89
  - lib/codebase_index/console/safe_context.rb
85
90
  - lib/codebase_index/console/server.rb
86
91
  - lib/codebase_index/console/sql_validator.rb
@@ -227,11 +232,12 @@ licenses:
227
232
  - MIT
228
233
  metadata:
229
234
  homepage_uri: https://github.com/LeahArmstrong/codebase_index
230
- source_code_uri: https://github.com/LeahArmstrong/codebase_index
235
+ source_code_uri: https://github.com/LeahArmstrong/codebase_index/tree/main
231
236
  changelog_uri: https://github.com/LeahArmstrong/codebase_index/blob/main/CHANGELOG.md
232
237
  bug_tracker_uri: https://github.com/LeahArmstrong/codebase_index/issues
233
238
  documentation_uri: https://github.com/LeahArmstrong/codebase_index/tree/main/docs
234
239
  rubygems_mfa_required: 'true'
240
+ post_install_message:
235
241
  rdoc_options: []
236
242
  require_paths:
237
243
  - lib
@@ -246,7 +252,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
246
252
  - !ruby/object:Gem::Version
247
253
  version: '0'
248
254
  requirements: []
249
- rubygems_version: 4.0.3
255
+ rubygems_version: 3.5.22
256
+ signing_key:
250
257
  specification_version: 4
251
258
  summary: Rails codebase extraction and indexing for AI-assisted development
252
259
  test_files: []