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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubydex
4
- VERSION = "0.2.8"
4
+ VERSION = "0.2.9"
5
5
  end
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 excluded paths into the graph's configuration (the workspace path is never
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(paths: T::Array[String]).void }
422
- def exclude_paths(paths); end
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 excluded_paths; end
425
+ def excluded_patterns; end
426
426
  end
427
427
 
428
428
  class Rubydex::DisplayLocation < Rubydex::Location