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 +4 -4
- data/README.md +49 -0
- data/exe/codebase-console +59 -0
- data/lib/codebase_index/console/embedded_executor.rb +291 -0
- data/lib/codebase_index/console/rack_middleware.rb +84 -0
- data/lib/codebase_index/console/server.rb +54 -19
- data/lib/codebase_index/railtie.rb +12 -0
- data/lib/codebase_index/version.rb +1 -1
- data/lib/codebase_index.rb +5 -1
- data/lib/tasks/codebase_index.rake +14 -0
- metadata +13 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ba2df9baa16005b8f3981639c0e1bca59bbff3382c1cd483e2a686d399054f4
|
|
4
|
+
data.tar.gz: '0983e4f7e63febbe63631bff76caab5d11cc88dec9e360eab2a3ea33f6adb025'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
version: defined?(CodebaseIndex::VERSION) ? CodebaseIndex::VERSION : '0.1.0'
|
|
50
|
-
)
|
|
47
|
+
build_server(conn_mgr, safe_ctx)
|
|
48
|
+
end
|
|
51
49
|
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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:
|
|
117
|
-
|
|
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}" }],
|
|
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
|
-
|
|
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
|
data/lib/codebase_index.rb
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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: []
|