rubydex 0.2.8 → 0.2.9
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 +3 -3
- data/THIRD_PARTY_LICENSES.html +132 -1378
- data/exe/rdx +28 -2
- data/ext/rubydex/extconf.rb +0 -8
- data/ext/rubydex/graph.c +15 -15
- data/lib/rubydex/mcp_server/protocol.rb +156 -0
- data/lib/rubydex/mcp_server/tools/base_tool.rb +109 -0
- data/lib/rubydex/mcp_server/tools/codebase_stats_tool.rb +30 -0
- data/lib/rubydex/mcp_server/tools/find_constant_references_tool.rb +46 -0
- data/lib/rubydex/mcp_server/tools/get_declaration_tool.rb +68 -0
- data/lib/rubydex/mcp_server/tools/get_descendants_tool.rb +46 -0
- data/lib/rubydex/mcp_server/tools/get_file_declarations_tool.rb +55 -0
- data/lib/rubydex/mcp_server/tools/search_declarations_tool.rb +55 -0
- data/lib/rubydex/mcp_server.rb +219 -0
- data/lib/rubydex/version.rb +1 -1
- data/rbi/rubydex.rbi +4 -4
- data/rust/Cargo.lock +1 -555
- data/rust/Cargo.toml +0 -1
- data/rust/rubydex/src/config.rs +54 -34
- data/rust/rubydex/src/listing.rs +159 -102
- data/rust/rubydex/src/main.rs +1 -1
- data/rust/rubydex/src/model/graph.rs +7 -7
- data/rust/rubydex/src/operation/applier.rs +0 -2
- data/rust/rubydex-sys/src/graph_api.rs +17 -13
- metadata +11 -10
- data/exe/rubydex_mcp +0 -17
- data/lib/rubydex/bin/rubydex_mcp.exe +0 -0
- data/rust/rubydex-mcp/Cargo.toml +0 -34
- data/rust/rubydex-mcp/src/main.rs +0 -48
- data/rust/rubydex-mcp/src/server.rs +0 -1148
- data/rust/rubydex-mcp/src/tools.rs +0 -49
- data/rust/rubydex-mcp/tests/mcp.rs +0 -302
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubydex
|
|
4
|
+
module MCPServer
|
|
5
|
+
class SearchDeclarationsTool < BaseTool
|
|
6
|
+
tool_name "search_declarations"
|
|
7
|
+
description 'Search for Ruby classes, modules, methods, or constants by name. Use this INSTEAD OF Grep when you know part of a Ruby identifier name and want to find its definition. Returns fully qualified names, kinds, and file locations. Use the `kind` filter ("Class", "Module", "Method", "Constant") to narrow results. Set `match_mode` to "exact" for precise substring matching or "fuzzy" for LSP-style workspace symbol search (default). Results are paginated: the response includes `total` (the full count of matches). If `total` exceeds the number of returned results, use `offset` to fetch subsequent pages.'
|
|
8
|
+
input_schema(
|
|
9
|
+
properties: {
|
|
10
|
+
query: { type: "string", description: "Search query to match against declaration names" },
|
|
11
|
+
kind: { type: "string", description: "Filter by declaration kind: Class, Module, Method, Constant, etc." },
|
|
12
|
+
match_mode: { type: "string", description: 'Matching mode: "fuzzy" (default) for LSP-style workspace symbol search, or "exact" for precise substring matching' },
|
|
13
|
+
limit: { type: "integer", description: "Maximum number of results to return (default 50, max 100)" },
|
|
14
|
+
offset: { type: "integer", description: "Number of results to skip for pagination (default 0)" },
|
|
15
|
+
},
|
|
16
|
+
required: ["query"],
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
#: (query: String, ?kind: String, ?match_mode: String, ?limit: Integer, ?offset: Integer) -> Tool::Response
|
|
20
|
+
def call(query:, kind: nil, match_mode: nil, limit: nil, offset: nil)
|
|
21
|
+
declarations = case match_mode
|
|
22
|
+
when nil, "fuzzy"
|
|
23
|
+
@graph.fuzzy_search(query)
|
|
24
|
+
when "exact"
|
|
25
|
+
@graph.search(query)
|
|
26
|
+
else
|
|
27
|
+
return response(
|
|
28
|
+
Error.new(
|
|
29
|
+
"invalid_match_mode",
|
|
30
|
+
"Invalid match_mode '#{match_mode}'",
|
|
31
|
+
'Use "fuzzy" or "exact"',
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if kind
|
|
37
|
+
declarations = declarations.lazy.select { |declaration| declaration_kind(declaration).casecmp?(kind) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
page, total = paginate(declarations, offset, limit, 100)
|
|
41
|
+
results = page.map do |declaration|
|
|
42
|
+
{
|
|
43
|
+
name: declaration.name,
|
|
44
|
+
kind: declaration_kind(declaration),
|
|
45
|
+
locations: declaration.definitions.map do |definition|
|
|
46
|
+
display_location(definition.location)
|
|
47
|
+
end,
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
response(results: results, total: total)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require "rubydex"
|
|
6
|
+
require "rubydex/mcp_server/protocol"
|
|
7
|
+
|
|
8
|
+
Dir[File.join(__dir__, "mcp_server", "tools", "*_tool.rb")].sort.each { |file| require file }
|
|
9
|
+
|
|
10
|
+
module Rubydex
|
|
11
|
+
module MCPServer
|
|
12
|
+
SERVER_INSTRUCTIONS = <<~TEXT
|
|
13
|
+
Rubydex provides semantic Ruby code intelligence.
|
|
14
|
+
|
|
15
|
+
Use these tools for Ruby source files (.rb, .rbi, .rbs) when you need structural information about declarations, locations, hierarchy, references, or codebase composition.
|
|
16
|
+
|
|
17
|
+
Use text search instead for literal strings, comments, log messages, non-Ruby files, or content search rather than structural queries.
|
|
18
|
+
|
|
19
|
+
Fully qualified name format: "Foo::Bar" for classes/modules/constants, "Foo::Bar#method_name" for instance methods.
|
|
20
|
+
|
|
21
|
+
Pagination: tools that may return a high number of results include `total` for pagination. When `total` exceeds the number of returned items, use `offset` to fetch the next page.
|
|
22
|
+
TEXT
|
|
23
|
+
|
|
24
|
+
class Server
|
|
25
|
+
WORKER_COUNT = 4
|
|
26
|
+
|
|
27
|
+
#: (root_path: String, ?transport: StdioTransport) -> void
|
|
28
|
+
def initialize(root_path:, transport: nil)
|
|
29
|
+
@root_path = root_path
|
|
30
|
+
@transport = transport
|
|
31
|
+
@graph = Graph.new(workspace_path: @root_path)
|
|
32
|
+
@graph.load_config
|
|
33
|
+
@index_finished = false
|
|
34
|
+
@incoming_queue = Thread::Queue.new
|
|
35
|
+
@outgoing_queue = Thread::Queue.new
|
|
36
|
+
@workers = []
|
|
37
|
+
@outgoing_dispatcher = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :root_path
|
|
41
|
+
|
|
42
|
+
#: -> Thread
|
|
43
|
+
def spawn_indexer
|
|
44
|
+
Thread.new do
|
|
45
|
+
@graph.index_workspace
|
|
46
|
+
@graph.resolve
|
|
47
|
+
@index_finished = true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#: -> void
|
|
52
|
+
def main_loop
|
|
53
|
+
@workers = Array.new(WORKER_COUNT) { new_worker }
|
|
54
|
+
@outgoing_dispatcher = Thread.new do
|
|
55
|
+
while (response = @outgoing_queue.pop)
|
|
56
|
+
@transport.write(response)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@transport.open do |request, parse_error|
|
|
61
|
+
if parse_error
|
|
62
|
+
send_message(parse_error)
|
|
63
|
+
else
|
|
64
|
+
@incoming_queue << request
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
ensure
|
|
68
|
+
run_shutdown
|
|
69
|
+
@transport.close
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#: (Hash | Array | untyped) -> Hash | Array[Hash]?
|
|
73
|
+
def handle(request)
|
|
74
|
+
if request.is_a?(Array)
|
|
75
|
+
return JSONRPC.error_response(nil, JSONRPC::INVALID_REQUEST, "Invalid Request", data: "Request is an empty array") if request.empty?
|
|
76
|
+
|
|
77
|
+
responses = request.filter_map { |entry| handle(entry) }
|
|
78
|
+
return responses if responses.any?
|
|
79
|
+
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
unless request.is_a?(Hash)
|
|
84
|
+
return JSONRPC.error_response(nil, JSONRPC::INVALID_REQUEST, "Invalid Request", data: "Request must be a hash")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
has_id = request.key?(:id)
|
|
88
|
+
id = request[:id]
|
|
89
|
+
method = request[:method]
|
|
90
|
+
params = request[:params]
|
|
91
|
+
|
|
92
|
+
unless request[:jsonrpc] == "2.0"
|
|
93
|
+
return JSONRPC.error_response(nil, JSONRPC::INVALID_REQUEST, "Invalid Request", data: "JSON-RPC version must be 2.0")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
unless !has_id || id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A[a-zA-Z0-9_-]+\z/))
|
|
97
|
+
return JSONRPC.error_response(nil, JSONRPC::INVALID_REQUEST, "Invalid Request", data: "Request ID must be a string or integer")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
unless method.is_a?(String) && !method.start_with?("rpc.")
|
|
101
|
+
return JSONRPC.error_response(nil, JSONRPC::INVALID_REQUEST, "Invalid Request", data: 'Method name must be a string and not start with "rpc."')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
unless params.nil? || params.is_a?(Hash)
|
|
105
|
+
return JSONRPC.error_response(id, JSONRPC::INVALID_PARAMS, "Invalid params", data: "Method parameters must be an object or null")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
result = case method
|
|
109
|
+
when "initialize"
|
|
110
|
+
{
|
|
111
|
+
protocolVersion: "2025-03-26",
|
|
112
|
+
capabilities: { tools: {} },
|
|
113
|
+
serverInfo: {
|
|
114
|
+
name: "rubydex_mcp",
|
|
115
|
+
version: Rubydex::VERSION,
|
|
116
|
+
},
|
|
117
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
118
|
+
}
|
|
119
|
+
when "tools/list"
|
|
120
|
+
{ tools: Tool.tools.map(&:to_h) }
|
|
121
|
+
when "tools/call"
|
|
122
|
+
call_tool(params || {})
|
|
123
|
+
when "ping"
|
|
124
|
+
{}
|
|
125
|
+
when "notifications/initialized"
|
|
126
|
+
return
|
|
127
|
+
else
|
|
128
|
+
return has_id ? JSONRPC.error_response(id, JSONRPC::METHOD_NOT_FOUND, "Method not found", data: method) : nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
has_id ? { jsonrpc: "2.0", id: id, result: result } : nil
|
|
132
|
+
rescue KeyError => e
|
|
133
|
+
has_id ? JSONRPC.error_response(id, JSONRPC::INVALID_PARAMS, "Invalid params", data: e.message) : nil
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
has_id ? JSONRPC.error_response(id, JSONRPC::INTERNAL_ERROR, "Internal error", data: e.message) : nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
#: -> Graph | Error
|
|
139
|
+
def graph_or_error
|
|
140
|
+
return @graph if @index_finished
|
|
141
|
+
|
|
142
|
+
Error.new(
|
|
143
|
+
"indexing",
|
|
144
|
+
"Rubydex is still indexing the codebase",
|
|
145
|
+
"The server is starting up. Please retry in a few seconds.",
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
#: -> void
|
|
152
|
+
def run_shutdown
|
|
153
|
+
@incoming_queue.close unless @incoming_queue.closed?
|
|
154
|
+
@workers.each(&:join)
|
|
155
|
+
@outgoing_queue.close unless @outgoing_queue.closed?
|
|
156
|
+
@outgoing_dispatcher&.join
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#: -> Thread
|
|
160
|
+
def new_worker
|
|
161
|
+
Thread.new do
|
|
162
|
+
while (request = @incoming_queue.pop)
|
|
163
|
+
send_message(handle(request))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
#: (Hash | Array[Hash]?) -> void
|
|
169
|
+
def send_message(response)
|
|
170
|
+
return unless response
|
|
171
|
+
return if @outgoing_queue.closed?
|
|
172
|
+
|
|
173
|
+
@outgoing_queue << response
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
#: (Hash) -> Hash
|
|
177
|
+
def call_tool(params)
|
|
178
|
+
tool_name = params.fetch(:name)
|
|
179
|
+
tool = Tool.tools_by_name.fetch(tool_name, nil)
|
|
180
|
+
raise KeyError, "Tool not found: #{tool_name}" unless tool
|
|
181
|
+
|
|
182
|
+
arguments = params[:arguments] || {}
|
|
183
|
+
known_arguments = tool.input_schema.fetch(:properties).keys.map(&:to_s)
|
|
184
|
+
unknown_arguments = arguments.keys.map(&:to_s) - known_arguments
|
|
185
|
+
unless unknown_arguments.empty?
|
|
186
|
+
return Tool::Response.new(
|
|
187
|
+
[{ type: "text", text: "Unknown arguments: #{unknown_arguments.join(", ")}" }],
|
|
188
|
+
error: true,
|
|
189
|
+
).to_h
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
missing_arguments = Array(tool.input_schema[:required]) - arguments.keys.map(&:to_s)
|
|
193
|
+
unless missing_arguments.empty?
|
|
194
|
+
return Tool::Response.new(
|
|
195
|
+
[{ type: "text", text: "Missing required arguments: #{missing_arguments.join(", ")}" }],
|
|
196
|
+
error: true,
|
|
197
|
+
).to_h
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
graph = graph_or_error
|
|
201
|
+
return Tool::Response.new([{ type: "text", text: JSON.generate(graph) }]).to_h if graph.is_a?(Error)
|
|
202
|
+
|
|
203
|
+
response = tool.new(graph).call(**arguments.transform_keys(&:to_sym))
|
|
204
|
+
response.to_h
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
class << self
|
|
209
|
+
#: (?String) -> void
|
|
210
|
+
def run(path = ".")
|
|
211
|
+
root = File.realpath(path)
|
|
212
|
+
server = Server.new(root_path: root, transport: StdioTransport.new)
|
|
213
|
+
server.spawn_indexer
|
|
214
|
+
|
|
215
|
+
server.main_loop
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
data/lib/rubydex/version.rb
CHANGED
data/rbi/rubydex.rbi
CHANGED
|
@@ -313,7 +313,7 @@ class Rubydex::Graph
|
|
|
313
313
|
sig { returns(T::Array[String]) }
|
|
314
314
|
def index_workspace; end
|
|
315
315
|
|
|
316
|
-
# Loads configuration, merging its
|
|
316
|
+
# Loads configuration, merging its exclusion patterns into the graph's configuration (the workspace path is never
|
|
317
317
|
# overridden). With `config_path` (resolved relative to the workspace path), an explicitly named file that does not
|
|
318
318
|
# exist raises `Rubydex::ConfigError`. With no argument, the default `.rubydex` is loaded if present and ignored if
|
|
319
319
|
# missing. Raises `Rubydex::ConfigError` if a file cannot be read or is malformed.
|
|
@@ -418,11 +418,11 @@ class Rubydex::Graph
|
|
|
418
418
|
sig { params(paths: T::Array[String]).void }
|
|
419
419
|
def add_workspace_dependency_paths(paths); end
|
|
420
420
|
|
|
421
|
-
sig { params(
|
|
422
|
-
def
|
|
421
|
+
sig { params(patterns: T::Array[String]).void }
|
|
422
|
+
def exclude_patterns(patterns); end
|
|
423
423
|
|
|
424
424
|
sig { returns(T::Array[String]) }
|
|
425
|
-
def
|
|
425
|
+
def excluded_patterns; end
|
|
426
426
|
end
|
|
427
427
|
|
|
428
428
|
class Rubydex::DisplayLocation < Rubydex::Location
|