diff --git a/Gemfile b/Gemfile index 1c31710fc..c7ced72ac 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ gemspec gem "rake", "~> 13.3" gem "rake-compiler" gem "minitest" +gem "mocha" gem "rubocop" gem "rubocop-shopify" gem "extconf_compile_commands_json" diff --git a/Gemfile.lock b/Gemfile.lock index 549a434f0..5744de028 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,6 +33,8 @@ GEM minitest (6.0.2) drb (~> 2.0) prism (~> 1.5) + mocha (3.1.0) + ruby2_keywords (>= 0.0.5) nokogiri (1.19.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) @@ -85,6 +87,7 @@ GEM rubocop-shopify (2.18.0) rubocop (~> 1.62) ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) ruby_memcheck (3.0.1) nokogiri stringio (3.2.0) @@ -102,6 +105,7 @@ DEPENDENCIES extconf_compile_commands_json irb minitest + mocha rake (~> 13.3) rake-compiler rbs diff --git a/dev.yml b/dev.yml index 76af05d5a..ea8c2a993 100644 --- a/dev.yml +++ b/dev.yml @@ -6,11 +6,6 @@ up: - rust - packages: - libyaml - # met? checks: 1) binary exists, 2) no file under rust/ (excluding rust/target/) is newer than the binary - - custom: - name: Install rubydex MCP server - met?: test -x ~/.cargo/bin/rubydex_mcp && ! find rust -path rust/target -prune -o -newer ~/.cargo/bin/rubydex_mcp -print -quit | grep -q . - meet: cargo install --force --path rust/rubydex-mcp commands: test: diff --git a/exe/rubydex_mcp b/exe/rubydex_mcp index bca33de47..e704ad26b 100755 --- a/exe/rubydex_mcp +++ b/exe/rubydex_mcp @@ -1,17 +1,52 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require "rbconfig" +require "rubydex/version" +require "optparse" -host_os = RbConfig::CONFIG.fetch("host_os") -executable = host_os.match?(/mswin|mingw|cygwin/) ? "rubydex_mcp.exe" : "rubydex_mcp" -binary = File.expand_path("../lib/rubydex/bin/#{executable}", __dir__) +option_parser = OptionParser.new do |parser| + parser.banner = <<~TEXT.chomp + Rubydex MCP server for AI assistants using Ruby code intelligence -unless File.executable?(binary) - abort(<<~MESSAGE.chomp) - rubydex_mcp is not available at #{binary}. - Install a precompiled rubydex gem, or reinstall rubydex with Cargo available so the MCP executable can be built locally. - MESSAGE + Usage: rubydex_mcp [PATH] + TEXT + + parser.separator("") + parser.separator("Arguments:") + parser.separator(" [PATH] [default: .]") + parser.separator("") + parser.separator("Options:") + + parser.on("-h", "--help", "Print help") do + puts parser + exit + end + + parser.on("-V", "--version", "Print version") do + puts "rubydex_mcp #{Rubydex::VERSION}" + exit + end +end + +begin + option_parser.parse!(ARGV) +rescue OptionParser::ParseError => e + warn("error: #{e.message}") + warn + warn(option_parser) + exit(2) end -exec(binary, *ARGV) +if ARGV.length > 1 + warn("error: unexpected argument '#{ARGV[1]}' found") + warn + warn(option_parser) + exit(2) +end + +path = ARGV.fetch(0, ".") + +require "rubydex" +require "rubydex/mcp_server" + +Rubydex::MCPServer.run(path) diff --git a/lib/rubydex/mcp_server.rb b/lib/rubydex/mcp_server.rb new file mode 100644 index 000000000..683b594c5 --- /dev/null +++ b/lib/rubydex/mcp_server.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "json" +require "pathname" +require "uri" + +require "rubydex" +require "rubydex/mcp_server/protocol" +require "rubydex/mcp_server/tools/codebase_stats_tool" +require "rubydex/mcp_server/tools/find_constant_references_tool" +require "rubydex/mcp_server/tools/get_declaration_tool" +require "rubydex/mcp_server/tools/get_descendants_tool" +require "rubydex/mcp_server/tools/get_file_declarations_tool" +require "rubydex/mcp_server/tools/search_declarations_tool" + +module Rubydex + module MCPServer + SERVER_INSTRUCTIONS = <<~TEXT + Rubydex provides semantic Ruby code intelligence. + + ONLY use these tools for Ruby files (.rb, .rbi, .rbs) -- never for Rust, JavaScript, or other languages. + + Use these tools INSTEAD OF Grep when working with Ruby code structure. + + Decision guide: + - Know a name? -> search_declarations (fuzzy search by name) + - Have an exact fully qualified name? -> get_declaration (full details with docs, ancestors, members) + - Need reverse hierarchy? -> get_descendants (what inherits from this class/module) + - Refactoring a class/module/constant? -> find_constant_references (all precise usages across codebase) + - Exploring a file? -> get_file_declarations (structural overview) + - Want general statistics? -> codebase_stats (size and composition) + + Typical workflow: search_declarations -> get_declaration -> find_constant_references. + + Fully qualified name format: "Foo::Bar" for classes/modules/constants, "Foo::Bar#method_name" for instance methods. + + 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. + + Use Grep instead for: literal string search, log messages, comments, non-Ruby files, or content search rather than structural queries. + TEXT + TOOLS = [ + SearchDeclarationsTool, + GetDeclarationTool, + GetDescendantsTool, + FindConstantReferencesTool, + GetFileDeclarationsTool, + CodebaseStatsTool, + ].freeze + + class Error + #: (String, ?String, ?String) -> void + def initialize(error, message = nil, suggestion = nil) + @error = error + @message = message + @suggestion = suggestion + end + + #: (*untyped) -> String + def to_json(*args) + payload = { error: @error } + payload[:message] = @message if @message + payload[:suggestion] = @suggestion if @suggestion + payload.to_json(*args) + end + end + + class State + #: (String) -> void + def initialize(root_path) + @root_path = root_path + @mutex = Mutex.new + @graph = nil + @error = nil + end + + attr_reader :root_path + + #: -> Thread + def spawn_indexer + Thread.new do + graph = Graph.new(workspace_path: @root_path) + errors = graph.index_workspace + errors.each { |error| warn("Indexing error: #{error}") } + graph.resolve + warn("Rubydex indexed #{graph.documents.count} files, #{graph.declarations.count} declarations") + + @mutex.synchronize do + @graph = graph + end + rescue Exception => e # rubocop:disable Lint/RescueException + warn("Rubydex indexing failed: #{e.message}") + @mutex.synchronize do + @error = e.message + end + end + end + + #: -> Graph | Error + def graph_or_error + @mutex.synchronize do + if @error + return Error.new( + "indexing_failed", + "Rubydex indexing failed: #{@error}", + "Check server logs for details. The MCP server needs to be restarted.", + ) + end + + return @graph if @graph + end + + Error.new( + "indexing", + "Rubydex is still indexing the codebase", + "The server is starting up. Please retry in a few seconds.", + ) + end + end + + class << self + #: (?String) -> void + def run(path = ".") + root = File.realpath(path) + state = State.new(root) + state.spawn_indexer + + server = Server.new(server_state: state) + + StdioTransport.new(server).open + end + + #: (Hash | Error) -> Tool::Response + def response(payload) + Tool::Response.new([{ type: "text", text: JSON.generate(payload) }]) + end + + #: (Declaration) -> String + def declaration_kind(declaration) + return "" if declaration.is_a?(Rubydex::Todo) + + declaration.class.name.delete_prefix("Rubydex::") + end + + #: (String, String) -> String + def format_path(uri, root_path) + path = file_path_for_uri(uri) + return uri unless path + + absolute_path = File.expand_path(path) + absolute_root = File.expand_path(root_path) + relative_path = begin + Pathname.new(absolute_path).relative_path_from(Pathname.new(absolute_root)).to_s + rescue ArgumentError + nil + end + return absolute_path unless relative_path + + relative_path.start_with?("..") ? absolute_path : relative_path + end + + #: (String) -> String + def path_for_uri(uri) + file_path_for_uri(uri) || uri + end + + #: (String) -> String? + def file_path_for_uri(uri) + parsed = URI.parse(uri) + return unless parsed.scheme == "file" + + path = URI.decode_uri_component(parsed.path) + path.delete_prefix!("/") if Gem.win_platform? + path + rescue URI::InvalidURIError, ArgumentError + nil + end + + #: (Graph, String, String) -> Document? + def document_for_path(graph, root_path, file_path) + absolute_target = if Pathname.new(file_path).absolute? + file_path + else + File.join(root_path, file_path) + end + canonical_target = File.realpath(absolute_target) + graph.documents.find do |document| + path = file_path_for_uri(document.uri) + path && File.expand_path(path) == canonical_target + end + rescue SystemCallError + absolute_target = File.expand_path(absolute_target) + graph.documents.find do |document| + path = file_path_for_uri(document.uri) + path && File.expand_path(path) == absolute_target + end + end + + #: (Location, String) -> Hash + def display_location(location, root_path) + display = location.to_display + { + path: format_path(display.uri, root_path), + line: display.start_line, + } + end + + #: (Enumerable, Integer?, Integer?, Integer) -> [Array, Integer] + def paginate(items, offset, limit, max_limit) + offset = offset.to_i if offset + offset = 0 unless offset&.positive? + limit = limit.to_i if limit + limit = 50 unless limit&.positive? + limit = [limit, max_limit].min + + page = [] + total = 0 + + items.each do |item| + page << item if total >= offset && page.length < limit + total += 1 + end + + [page, total] + end + + #: (Graph, String) -> Declaration | Error + def lookup_declaration(graph, name) + declaration = graph[name] + return declaration if declaration + + Error.new( + "not_found", + "Declaration '#{name}' not found", + "Try search_declarations with a partial name to find the correct FQN", + ) + end + end + end +end diff --git a/lib/rubydex/mcp_server/protocol.rb b/lib/rubydex/mcp_server/protocol.rb new file mode 100644 index 000000000..e810f8966 --- /dev/null +++ b/lib/rubydex/mcp_server/protocol.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "json" + +module Rubydex + module MCPServer + class Tool + class Response + #: (Array[Hash], ?bool) -> void + def initialize(content, error: false) + @content = content + @error = error + end + + attr_reader :content + + #: -> bool + def error? + @error + end + + #: -> Hash + def to_h + { + content: content, + isError: error?, + } + end + end + + class << self + #: (String) -> String + def tool_name(value) + @tool_name = value + end + + #: (String) -> String + def description(value) + @description = value + end + + #: (?Hash, ?Array[String]) -> Hash + def input_schema(properties: nil, required: nil) + if properties + @input_schema = { + type: "object", + properties: properties, + } + @input_schema[:required] = required if required + end + + @input_schema || { type: "object", properties: {} } + end + + #: -> Hash + def to_h + { + name: @tool_name, + description: @description, + inputSchema: input_schema, + } + end + end + end + + class Server + #: (server_state: State) -> void + def initialize(server_state:) + @server_state = server_state + end + + PARSE_ERROR = -32_700 + INVALID_REQUEST = -32_600 + METHOD_NOT_FOUND = -32_601 + INVALID_PARAMS = -32_602 + INTERNAL_ERROR = -32_603 + + #: (Hash | Array | untyped) -> Hash | Array[Hash]? + def handle(request) + if request.is_a?(Array) + return error_response(nil, INVALID_REQUEST, "Invalid Request", data: "Request is an empty array") if request.empty? + + responses = request.filter_map { |entry| handle(entry) } + return responses if responses.any? + + return + end + + unless request.is_a?(Hash) + return error_response(nil, INVALID_REQUEST, "Invalid Request", data: "Request must be a hash") + end + + has_id = request.key?(:id) || request.key?("id") + id = request.key?(:id) ? request[:id] : request["id"] + method = request[:method] || request["method"] + params = request.key?(:params) ? request[:params] : request["params"] + + unless request[:jsonrpc] == "2.0" || request["jsonrpc"] == "2.0" + return error_response(nil, INVALID_REQUEST, "Invalid Request", data: "JSON-RPC version must be 2.0") + end + + unless !has_id || id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A[a-zA-Z0-9_-]+\z/)) + return error_response(nil, INVALID_REQUEST, "Invalid Request", data: "Request ID must be a string or integer") + end + + unless method.is_a?(String) && !method.start_with?("rpc.") + return error_response(nil, INVALID_REQUEST, "Invalid Request", data: 'Method name must be a string and not start with "rpc."') + end + + unless params.nil? || params.is_a?(Hash) + return error_response(id, INVALID_PARAMS, "Invalid params", data: "Method parameters must be an object or null") + end + + result = case method + when "initialize" + { + protocolVersion: "2025-03-26", + capabilities: { tools: {} }, + serverInfo: { + name: "rubydex_mcp", + version: Rubydex::VERSION, + }, + instructions: SERVER_INSTRUCTIONS, + } + when "tools/list" + { tools: TOOLS.map(&:to_h) } + when "tools/call" + call_tool(params || {}) + when "ping" + {} + when "notifications/initialized" + return + else + return has_id ? error_response(id, METHOD_NOT_FOUND, "Method not found", data: method) : nil + end + + has_id ? { jsonrpc: "2.0", id: id, result: result } : nil + rescue KeyError => e + has_id ? error_response(id, INVALID_PARAMS, "Invalid params", data: e.message) : nil + rescue StandardError => e + has_id ? error_response(id, INTERNAL_ERROR, "Internal error", data: e.message) : nil + end + + private + + #: (Hash) -> Hash + def call_tool(params) + tool_name = params[:name] || params["name"] + tool = TOOLS.find { |candidate| candidate.to_h.fetch(:name) == tool_name } + raise KeyError, "Tool not found: #{tool_name}" unless tool + + arguments = params[:arguments] || params["arguments"] || {} + missing_arguments = Array(tool.input_schema[:required]) - arguments.keys.map(&:to_s) + unless missing_arguments.empty? + return Tool::Response.new( + [{ type: "text", text: "Missing required arguments: #{missing_arguments.join(", ")}" }], + error: true, + ).to_h + end + + response = tool.call(**arguments.transform_keys(&:to_sym), server_state: @server_state) + response.to_h + end + + #: (untyped, Integer, String, ?data: untyped) -> Hash + def error_response(id, code, message, data: nil) + { + jsonrpc: "2.0", + id: id, + error: { + code: code, + message: message, + data: data, + }.compact, + } + end + end + + class StdioTransport + #: (Server, ?IO, ?IO) -> void + def initialize(server, input: $stdin, output: $stdout) + @server = server + @input = input + @output = output + end + + #: -> void + def open + @input.each_line do |line| + response = @server.handle(JSON.parse(line)) + next unless response + + @output.puts(JSON.generate(response)) + @output.flush + rescue JSON::ParserError + @output.puts(JSON.generate(jsonrpc: "2.0", id: nil, error: { code: Server::PARSE_ERROR, message: "Parse error", data: "Invalid JSON" })) + @output.flush + end + end + end + end +end diff --git a/lib/rubydex/mcp_server/tools/codebase_stats_tool.rb b/lib/rubydex/mcp_server/tools/codebase_stats_tool.rb new file mode 100644 index 000000000..773b188c6 --- /dev/null +++ b/lib/rubydex/mcp_server/tools/codebase_stats_tool.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Rubydex + module MCPServer + class CodebaseStatsTool < Tool + tool_name "codebase_stats" + description "Get an overview of the indexed Ruby codebase: total file count, declaration counts, and breakdown by kind (classes, modules, methods, constants). Use this to understand codebase size and composition, or to verify that indexing completed successfully." + input_schema(properties: {}) + + class << self + #: (server_state: State) -> Tool::Response + def call(server_state:) + graph = server_state.graph_or_error + + case graph + when Error + MCPServer.response(graph) + else + declaration_count = 0 + breakdown = Hash.new(0) + graph.declarations.each do |declaration| + declaration_count += 1 + breakdown[MCPServer.declaration_kind(declaration)] += 1 + end + + MCPServer.response( + files: graph.documents.count, + declarations: declaration_count, + definitions: graph.documents.sum { |document| document.definitions.count }, + constant_references: graph.constant_references.count, + method_references: graph.method_references.count, + breakdown_by_kind: breakdown, + ) + end + end + end + end + end +end diff --git a/lib/rubydex/mcp_server/tools/find_constant_references_tool.rb b/lib/rubydex/mcp_server/tools/find_constant_references_tool.rb new file mode 100644 index 000000000..105e66ee3 --- /dev/null +++ b/lib/rubydex/mcp_server/tools/find_constant_references_tool.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Rubydex + module MCPServer + class FindConstantReferencesTool < Tool + tool_name "find_constant_references" + description "Find all resolved references to a Ruby class, module, or constant across the codebase. Returns file paths, line numbers, and columns for each usage. Results are paginated: the response includes `total`. If `total` exceeds the number of returned results, use `offset` to fetch subsequent pages." + input_schema( + properties: { + name: { type: "string", description: "Fully qualified name of the class, module, or constant to find references for" }, + limit: { type: "integer", description: "Maximum number of references to return (default 50, max 200)" }, + offset: { type: "integer", description: "Number of references to skip for pagination (default 0)" }, + }, + required: ["name"], + ) + + class << self + #: (name: String, ?limit: Integer, ?offset: Integer, server_state: State) -> Tool::Response + def call(name:, limit: nil, offset: nil, server_state:) + graph = server_state.graph_or_error + + case graph + when Error + MCPServer.response(graph) + else + declaration = MCPServer.lookup_declaration(graph, name) + + case declaration + when Error + MCPServer.response(declaration) + else + references = case declaration + when Rubydex::Namespace, Rubydex::Constant, Rubydex::ConstantAlias + declaration.references + else + [] + end + page, total = MCPServer.paginate(references, offset, limit, 200) + root_path = server_state.root_path + payload = page.map do |reference| + display = reference.location.to_display + { + path: MCPServer.format_path(display.uri, root_path), + line: display.start_line, + column: display.start_column, + } + end + + MCPServer.response(name: name, references: payload, total: total) + end + end + end + end + end + end +end diff --git a/lib/rubydex/mcp_server/tools/get_declaration_tool.rb b/lib/rubydex/mcp_server/tools/get_declaration_tool.rb new file mode 100644 index 000000000..bda3225f3 --- /dev/null +++ b/lib/rubydex/mcp_server/tools/get_declaration_tool.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Rubydex + module MCPServer + class GetDeclarationTool < Tool + tool_name "get_declaration" + description 'Get complete information about a Ruby class, module, method, or constant by its exact fully qualified name. Returns file locations, documentation comments, ancestor chain, and members with locations. FQN format: "Foo::Bar" for classes/modules/constants, "Foo::Bar#method_name" for instance methods.' + input_schema( + properties: { + name: { type: "string", description: "Fully qualified name of the declaration (e.g. 'Foo::Bar', 'Foo::Bar#baz')" }, + }, + required: ["name"], + ) + + class << self + #: (name: String, server_state: State) -> Tool::Response + def call(name:, server_state:) + graph = server_state.graph_or_error + + case graph + when Error + MCPServer.response(graph) + else + declaration = MCPServer.lookup_declaration(graph, name) + + case declaration + when Error + MCPServer.response(declaration) + else + root_path = server_state.root_path + definitions = declaration.definitions.map do |definition| + MCPServer.display_location(definition.location, root_path).merge( + comments: definition.comments.map do |comment| + comment.string.delete_prefix("# ") + end, + ) + end + + ancestors = if declaration.is_a?(Rubydex::Namespace) + declaration.ancestors.map do |ancestor| + { + name: ancestor.name, + kind: MCPServer.declaration_kind(ancestor), + } + end + else + [] + end + + members = if declaration.is_a?(Rubydex::Namespace) + declaration.members.map do |member| + payload = { + name: member.name, + kind: MCPServer.declaration_kind(member), + } + + definition = member.definitions.first + payload[:location] = MCPServer.display_location(definition.location, root_path) if definition + payload + end + else + [] + end + + MCPServer.response( + name: declaration.name, + kind: MCPServer.declaration_kind(declaration), + definitions: definitions, + ancestors: ancestors, + members: members, + ) + end + end + end + end + end + end +end diff --git a/lib/rubydex/mcp_server/tools/get_descendants_tool.rb b/lib/rubydex/mcp_server/tools/get_descendants_tool.rb new file mode 100644 index 000000000..60a6c9371 --- /dev/null +++ b/lib/rubydex/mcp_server/tools/get_descendants_tool.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Rubydex + module MCPServer + class GetDescendantsTool < Tool + tool_name "get_descendants" + description "Returns all known descendants for the given namespace including itself and all transitive descendants. Can be used to understand how a module/class is used across the codebase. Results are paginated: the response includes `total`. If `total` exceeds the number of returned results, use `offset` to fetch subsequent pages." + input_schema( + properties: { + name: { type: "string", description: "Fully qualified name of the class or module" }, + limit: { type: "integer", description: "Maximum number of descendants to return (default 50, max 500)" }, + offset: { type: "integer", description: "Number of descendants to skip for pagination (default 0)" }, + }, + required: ["name"], + ) + + class << self + #: (name: String, ?limit: Integer, ?offset: Integer, server_state: State) -> Tool::Response + def call(name:, limit: nil, offset: nil, server_state:) + graph = server_state.graph_or_error + + case graph + when Error + MCPServer.response(graph) + else + declaration = MCPServer.lookup_declaration(graph, name) + + case declaration + when Error + MCPServer.response(declaration) + when Rubydex::Namespace + page, total = MCPServer.paginate(declaration.descendants, offset, limit, 500) + descendants = page.map do |descendant| + { + name: descendant.name, + kind: MCPServer.declaration_kind(descendant), + } + end + + MCPServer.response(name: declaration.name, descendants: descendants, total: total) + else + MCPServer.response( + Error.new( + "invalid_kind", + "'#{name}' is not a class or module (it is a #{MCPServer.declaration_kind(declaration)})", + "get_descendants only works on classes and modules, not methods or constants", + ), + ) + end + end + end + end + end + end +end diff --git a/lib/rubydex/mcp_server/tools/get_file_declarations_tool.rb b/lib/rubydex/mcp_server/tools/get_file_declarations_tool.rb new file mode 100644 index 000000000..a837dffde --- /dev/null +++ b/lib/rubydex/mcp_server/tools/get_file_declarations_tool.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Rubydex + module MCPServer + class GetFileDeclarationsTool < Tool + tool_name "get_file_declarations" + description "List all Ruby classes, modules, methods, and constants defined in a specific file. Returns a structural overview with names, kinds, and line numbers. Use this to understand a file's structure before reading it, or to see what a file contributes to the codebase. Accepts relative or absolute paths." + input_schema( + properties: { + file_path: { type: "string", description: "File path (relative or absolute) to list declarations for" }, + }, + required: ["file_path"], + ) + + class << self + #: (file_path: String, server_state: State) -> Tool::Response + def call(file_path:, server_state:) + graph = server_state.graph_or_error + + case graph + when Error + MCPServer.response(graph) + else + root_path = server_state.root_path + document = MCPServer.document_for_path(graph, root_path, file_path) + unless document + return MCPServer.response( + Error.new( + "not_found", + "File '#{file_path}' not found in the index", + "Use a relative path like 'app/models/user.rb' or an absolute path matching the indexed project", + ), + ) + end + + declarations = document.definitions.filter_map do |definition| + declaration = definition.declaration + next unless declaration + + { + name: declaration.name, + kind: MCPServer.declaration_kind(declaration), + line: definition.location.to_display.start_line, + } + end + + MCPServer.response(file: MCPServer.format_path(document.uri, root_path), declarations: declarations) + end + end + end + end + end +end diff --git a/lib/rubydex/mcp_server/tools/search_declarations_tool.rb b/lib/rubydex/mcp_server/tools/search_declarations_tool.rb new file mode 100644 index 000000000..d49bbf6e5 --- /dev/null +++ b/lib/rubydex/mcp_server/tools/search_declarations_tool.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Rubydex + module MCPServer + class SearchDeclarationsTool < Tool + tool_name "search_declarations" + 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.' + input_schema( + properties: { + query: { type: "string", description: "Search query to match against declaration names" }, + kind: { type: "string", description: "Filter by declaration kind: Class, Module, Method, Constant, etc." }, + match_mode: { type: "string", description: 'Matching mode: "fuzzy" (default) for LSP-style workspace symbol search, or "exact" for precise substring matching' }, + limit: { type: "integer", description: "Maximum number of results to return (default 50, max 100)" }, + offset: { type: "integer", description: "Number of results to skip for pagination (default 0)" }, + }, + required: ["query"], + ) + + class << self + #: (query: String, ?kind: String, ?match_mode: String, ?limit: Integer, ?offset: Integer, server_state: State) -> Tool::Response + def call(query:, kind: nil, match_mode: nil, limit: nil, offset: nil, server_state:) + graph = server_state.graph_or_error + + case graph + when Error + MCPServer.response(graph) + else + declarations = case match_mode + when nil, "fuzzy" + graph.fuzzy_search(query) + when "exact" + graph.search(query) + else + return MCPServer.response( + Error.new( + "invalid_match_mode", + "Invalid match_mode '#{match_mode}'", + 'Use "fuzzy" or "exact"', + ), + ) + end + + if kind + declarations = declarations.lazy.select { |declaration| MCPServer.declaration_kind(declaration).casecmp?(kind) } + end + + page, total = MCPServer.paginate(declarations, offset, limit, 100) + root_path = server_state.root_path + results = page.map do |declaration| + { + name: declaration.name, + kind: MCPServer.declaration_kind(declaration), + locations: declaration.definitions.map do |definition| + MCPServer.display_location(definition.location, root_path) + end, + } + end + + MCPServer.response(results: results, total: total) + end + end + end + end + end +end diff --git a/test/mcp_server_test.rb b/test/mcp_server_test.rb new file mode 100644 index 000000000..8e55be6b7 --- /dev/null +++ b/test/mcp_server_test.rb @@ -0,0 +1,481 @@ +# frozen_string_literal: true + +require "test_helper" +require "helpers/context" +require "json" +require "mocha/minitest" +require "rubydex/mcp_server" +require "open3" +require "rbconfig" +require "timeout" +require "uri" + +class MCPServerTest < Minitest::Test + include Test::Helpers::WithContext + + def test_spawn_indexer_uses_workspace_indexing_entrypoint + with_context do |context| + context.write!("app.rb", "class LocalWorkspaceClass; end") + + state = Rubydex::MCPServer::State.new(context.absolute_path) + capture_io do + state.spawn_indexer.join + end + graph = state.graph_or_error + + assert_kind_of(Rubydex::Graph, graph) + assert_equal("LocalWorkspaceClass", graph["LocalWorkspaceClass"].name) + + rbs_kernel = graph["Kernel"]&.definitions&.find do |definition| + path = URI(definition.location.uri).path + path && File.extname(path) == ".rbs" + end + assert(rbs_kernel, "Expected MCP startup indexing to include core RBS definitions") + end + end + + def test_run_fails_when_root_path_cannot_be_canonicalized + error = assert_raises(Errno::ENOENT) do + Rubydex::MCPServer.run("missing-path") + end + + assert_includes(error.message, "missing-path") + end + + def test_codebase_stats_reports_indexing_until_graph_is_ready + state = Rubydex::MCPServer::State.new(Dir.pwd) + server = Rubydex::MCPServer::Server.new(server_state: state) + + send_request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "codebase_stats", + arguments: {}, + }, + } + + response = server.handle(send_request) + result = response.fetch(:result) + payload = JSON.parse(result.fetch(:content)[0].fetch(:text)) + + assert_equal(false, result.fetch(:isError)) + assert_equal("indexing", payload.fetch("error")) + assert_match(/still indexing/, payload.fetch("message")) + assert_match(/retry/, payload.fetch("suggestion")) + end + + def test_ping_returns_empty_result + state = Rubydex::MCPServer::State.new(Dir.pwd) + server = Rubydex::MCPServer::Server.new(server_state: state) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "ping", + }, + ) + + assert_equal({}, response.fetch(:result)) + end + + def test_unknown_method_returns_json_rpc_method_error + state = Rubydex::MCPServer::State.new(Dir.pwd) + server = Rubydex::MCPServer::Server.new(server_state: state) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "missing/method", + }, + ) + + error = response.fetch(:error) + assert_equal(-32_601, error.fetch(:code)) + assert_equal("Method not found", error.fetch(:message)) + assert_equal("missing/method", error.fetch(:data)) + end + + def test_invalid_json_rpc_request_returns_invalid_request + state = Rubydex::MCPServer::State.new(Dir.pwd) + server = Rubydex::MCPServer::Server.new(server_state: state) + + response = server.handle( + { + jsonrpc: "1.0", + id: 1, + method: "ping", + }, + ) + + error = response.fetch(:error) + assert_equal(-32_600, error.fetch(:code)) + assert_equal("Invalid Request", error.fetch(:message)) + assert_equal("JSON-RPC version must be 2.0", error.fetch(:data)) + end + + def test_explicit_null_id_returns_invalid_request + state = Rubydex::MCPServer::State.new(Dir.pwd) + server = Rubydex::MCPServer::Server.new(server_state: state) + + response = server.handle( + { + jsonrpc: "2.0", + id: nil, + method: "ping", + }, + ) + + error = response.fetch(:error) + assert_equal(-32_600, error.fetch(:code)) + assert_equal("Invalid Request", error.fetch(:message)) + assert_equal("Request ID must be a string or integer", error.fetch(:data)) + assert_nil(response.fetch(:id)) + end + + def test_batch_request_always_returns_array + state = Rubydex::MCPServer::State.new(Dir.pwd) + server = Rubydex::MCPServer::Server.new(server_state: state) + + response = server.handle( + [ + { + jsonrpc: "2.0", + id: 1, + method: "ping", + }, + { + jsonrpc: "2.0", + method: "notifications/initialized", + }, + ], + ) + + assert_equal([{ jsonrpc: "2.0", id: 1, result: {} }], response) + end + + def test_missing_required_tool_argument_returns_tool_error + state = Rubydex::MCPServer::State.new(Dir.pwd) + server = Rubydex::MCPServer::Server.new(server_state: state) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "search_declarations", + arguments: {}, + }, + }, + ) + + result = response.fetch(:result) + assert_equal(true, result.fetch(:isError)) + assert_equal("Missing required arguments: query", result.fetch(:content)[0].fetch(:text)) + end + + def test_path_for_uri_removes_windows_file_uri_leading_slash + Gem.stubs(:win_platform?).returns(true) + + assert_equal("D:/a/_temp/app.rb", Rubydex::MCPServer.path_for_uri("file:///D:/a/_temp/app.rb")) + end + + def test_path_for_uri_decodes_file_uri_paths + Gem.stubs(:win_platform?).returns(false) + + assert_equal("/tmp/my app.rb", Rubydex::MCPServer.path_for_uri("file:///tmp/my%20app.rb")) + end + + def test_format_path_preserves_non_file_uris + assert_equal("untitled:Untitled-1", Rubydex::MCPServer.format_path("untitled:Untitled-1", Dir.pwd)) + end +end + +class MCPServerIntegrationTest < Minitest::Test + include Test::Helpers::WithContext + + MAX_INDEXING_RETRIES = 200 + + def test_executable_prints_help + stdout, stderr, status = run_executable("--help") + + assert_predicate(status, :success?) + assert_empty(stderr) + assert_includes(stdout, "Rubydex MCP server for AI assistants using Ruby code intelligence") + assert_includes(stdout, "Usage: rubydex_mcp [PATH]") + assert_includes(stdout, "-V, --version") + assert_includes(stdout, "Print version") + end + + def test_executable_prints_version + stdout, stderr, status = run_executable("--version") + + assert_predicate(status, :success?) + assert_empty(stderr) + assert_equal("rubydex_mcp #{Rubydex::VERSION}\n", stdout) + end + + def test_executable_rejects_extra_arguments + stdout, stderr, status = run_executable("foo", "bar") + + assert_equal(2, status.exitstatus) + assert_empty(stdout) + assert_includes(stderr, "error: unexpected argument 'bar' found") + assert_includes(stderr, "Usage: rubydex_mcp [PATH]") + end + + def test_executable_rejects_unknown_options + stdout, stderr, status = run_executable("--unknown") + + assert_equal(2, status.exitstatus) + assert_empty(stdout) + assert_includes(stderr, "error: invalid option: --unknown") + assert_includes(stderr, "Usage: rubydex_mcp [PATH]") + end + + def test_mcp_server_can_be_required_directly + stdout, stderr, status = Open3.capture3( + RbConfig.ruby, + "-rbundler/setup", + "-Ilib", + "-e", + <<~RUBY, + require "rubydex/mcp_server" + + puts Rubydex::VERSION + puts Rubydex::Graph.name + + state = Object.new + state.define_singleton_method(:root_path) { Dir.pwd } + state.define_singleton_method(:graph_or_error) { Rubydex::MCPServer::Error.new("indexing") } + + response = Rubydex::MCPServer::Server.new(server_state: state).handle( + jsonrpc: "2.0", + id: 1, + method: "initialize", + ) + puts response.fetch(:result).fetch(:serverInfo).fetch(:version) + RUBY + ) + + assert_predicate(status, :success?, stderr) + assert_equal("#{Rubydex::VERSION}\nRubydex::Graph\n#{Rubydex::VERSION}\n", stdout) + end + + def test_mcp_server_e2e + with_context do |context| + context.write!("app.rb", <<~RUBY) + class Animal + def speak + "..." + end + end + + class Dog < Animal + def speak + "Woof!" + end + end + + module Greetable + def greet + "Hello" + end + end + + module UniqueMarker + end + + class Kennel + def build + Animal.new + end + end + + RUBY + + stderr_output = +"" + Open3.popen3(RbConfig.ruby, "-rbundler/setup", executable_path, context.absolute_path) do |stdin, stdout, stderr, wait_thr| + stderr_reader = Thread.new { stderr_output << stderr.read } + + initialize_session(stdin, stdout) + assert_tools_are_registered(stdin, stdout) + + request_id = 3 + stats, request_id = wait_for_indexing_to_complete(stdin, stdout, request_id) + assert_operator(stats.fetch("files"), :>=, 2) + assert_operator(stats.fetch("declarations"), :>, 0) + + request_id += 1 + search_response = call_tool(stdin, stdout, request_id, "search_declarations", { query: "Dog", match_mode: "exact", kind: "Class" }) + assert_has_name(search_response.fetch("results"), "Dog", "search results") + assert_operator(search_response.fetch("total"), :>, 0) + + request_id += 1 + negative_offset_response = call_tool(stdin, stdout, request_id, "search_declarations", { query: "UniqueMarker", match_mode: "exact", offset: -1, limit: 1 }) + assert_has_name(negative_offset_response.fetch("results"), "UniqueMarker", "negative offset search results") + + request_id += 1 + declaration = call_tool(stdin, stdout, request_id, "get_declaration", { name: "Dog" }) + assert_equal("Dog", declaration.fetch("name")) + assert_equal("Class", declaration.fetch("kind")) + refute_empty(declaration.fetch("definitions")) + assert_has_name(declaration.fetch("ancestors"), "Animal", "Dog ancestors") + + request_id += 1 + descendants = call_tool(stdin, stdout, request_id, "get_descendants", { name: "Animal" }) + assert_has_name(descendants.fetch("descendants"), "Dog", "Animal descendants") + assert_operator(descendants.fetch("total"), :>, 0) + + request_id += 1 + references = call_tool(stdin, stdout, request_id, "find_constant_references", { name: "Animal" }) + refute_empty(references.fetch("references")) + assert(references.fetch("references").all? { |entry| entry.key?("path") }) + assert_operator(references.fetch("total"), :>, 0) + + request_id += 1 + method_references = call_tool(stdin, stdout, request_id, "find_constant_references", { name: "Dog#speak()" }) + assert_equal("Dog#speak()", method_references.fetch("name")) + assert_empty(method_references.fetch("references")) + assert_equal(0, method_references.fetch("total")) + + request_id += 1 + file_declarations = call_tool(stdin, stdout, request_id, "get_file_declarations", { file_path: "app.rb" }) + assert(file_declarations.fetch("file").end_with?("app.rb")) + declaration_entries = file_declarations.fetch("declarations") + assert_has_name(declaration_entries, "Animal", "file declarations") + assert_has_name(declaration_entries, "Dog", "file declarations") + assert_has_name(declaration_entries, "Greetable", "file declarations") + + stdin.close + Timeout.timeout(5) { wait_thr.value } + stderr_reader.join + rescue Timeout::Error + Process.kill("TERM", wait_thr.pid) + flunk("rubydex_mcp did not exit after stdin closed. stderr:\n#{stderr_output}") + end + end + end + + private + + def run_executable(*arguments) + Open3.capture3( + RbConfig.ruby, + "-rbundler/setup", + executable_path, + *arguments, + ) + end + + def executable_path + File.expand_path("../exe/rubydex_mcp", __dir__) + end + + def send_message(stdin, message) + stdin.puts(JSON.generate(message)) + stdin.flush + end + + def send_request(stdin, id, method, params) + send_message( + stdin, + { + jsonrpc: "2.0", + id: id, + method: method, + params: params, + }, + ) + end + + def read_response(stdout) + Timeout.timeout(5) do + line = stdout.gets + flunk("Expected JSON-RPC response, got EOF") unless line + + JSON.parse(line) + end + end + + def read_response_for_id(stdout, expected_id) + response = read_response(stdout) + assert_equal(expected_id, response.fetch("id")) + response + end + + def initialize_session(stdin, stdout) + send_request( + stdin, + 1, + "initialize", + { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "0.1.0" }, + }, + ) + + response = read_response_for_id(stdout, 1) + assert_kind_of(Hash, response.fetch("result").fetch("capabilities").fetch("tools")) + + send_message( + stdin, + { + jsonrpc: "2.0", + method: "notifications/initialized", + }, + ) + end + + def assert_tools_are_registered(stdin, stdout) + send_request(stdin, 2, "tools/list", {}) + response = read_response_for_id(stdout, 2) + tool_names = response.fetch("result").fetch("tools").map { |tool| tool.fetch("name") } + + assert_includes(tool_names, "search_declarations") + assert_includes(tool_names, "get_declaration") + assert_includes(tool_names, "get_descendants") + assert_includes(tool_names, "find_constant_references") + assert_includes(tool_names, "get_file_declarations") + assert_includes(tool_names, "codebase_stats") + assert_equal(6, tool_names.length) + end + + def call_tool(stdin, stdout, request_id, tool_name, arguments) + send_request( + stdin, + request_id, + "tools/call", + { + name: tool_name, + arguments: arguments, + }, + ) + + response = read_response_for_id(stdout, request_id) + JSON.parse(response.fetch("result").fetch("content")[0].fetch("text")) + end + + def wait_for_indexing_to_complete(stdin, stdout, request_id) + MAX_INDEXING_RETRIES.times do + parsed = call_tool(stdin, stdout, request_id, "codebase_stats", {}) + return [parsed, request_id] unless parsed.key?("error") + + assert_equal("indexing", parsed.fetch("error")) + request_id += 1 + sleep(0.05) + end + + flunk("Timed out waiting for indexing to complete") + end + + def assert_has_name(entries, expected_name, context) + names = entries.filter_map { |entry| entry["name"] } + assert_includes(names, expected_name, "Expected #{context} to include #{expected_name}, got: #{names.inspect}") + end +end diff --git a/test/mcp_server_tools_test.rb b/test/mcp_server_tools_test.rb new file mode 100644 index 000000000..2b8bebfee --- /dev/null +++ b/test/mcp_server_tools_test.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require "test_helper" +require "helpers/context" +require "json" +require "rubydex/mcp_server" + +class MCPServerToolsTest < Minitest::Test + include Test::Helpers::WithContext + + def test_search_declarations_tool + with_mcp_server do |server| + exact = call_tool(server, "search_declarations", query: "Dog", match_mode: "exact", kind: "Class") + + assert_equal(1, exact.fetch("total")) + assert_equal(["Dog"], exact.fetch("results").map { |entry| entry.fetch("name") }) + + paginated = call_tool(server, "search_declarations", query: "Dog", match_mode: "exact", limit: 1) + + assert_equal(3, paginated.fetch("total")) + assert_equal(1, paginated.fetch("results").length) + + invalid = call_tool(server, "search_declarations", query: "Dog", match_mode: "contains") + + assert_equal("invalid_match_mode", invalid.fetch("error")) + assert_match(/Invalid match_mode/, invalid.fetch("message")) + end + end + + def test_get_declaration_tool + with_mcp_server do |server| + declaration = call_tool(server, "get_declaration", name: "Dog") + + assert_equal("Dog", declaration.fetch("name")) + assert_equal("Class", declaration.fetch("kind")) + assert_has_value(declaration.fetch("definitions"), "app.rb", "Dog definitions", key: "path") + assert_has_value(declaration.fetch("ancestors"), "Animal", "Dog ancestors") + assert_has_value(declaration.fetch("members"), "Dog#speak()", "Dog members") + assert_has_value(declaration.fetch("members"), "Dog::BREED", "Dog members") + + not_found = call_tool(server, "get_declaration", name: "Missing") + + assert_equal("not_found", not_found.fetch("error")) + assert_match(/search_declarations/, not_found.fetch("suggestion")) + end + end + + def test_get_descendants_tool + with_mcp_server do |server| + descendants = call_tool(server, "get_descendants", name: "Animal") + + assert_equal("Animal", descendants.fetch("name")) + assert_equal(3, descendants.fetch("total")) + assert_has_value(descendants.fetch("descendants"), "Animal", "Animal descendants") + assert_has_value(descendants.fetch("descendants"), "Dog", "Animal descendants") + assert_has_value(descendants.fetch("descendants"), "Cat", "Animal descendants") + + page = call_tool(server, "get_descendants", name: "Animal", limit: 1, offset: 1) + + assert_equal(3, page.fetch("total")) + assert_equal(1, page.fetch("descendants").length) + + invalid = call_tool(server, "get_descendants", name: "Dog::BREED") + + assert_equal("invalid_kind", invalid.fetch("error")) + end + end + + def test_find_constant_references_tool + with_mcp_server do |server| + references = call_tool(server, "find_constant_references", name: "Animal") + + assert_equal("Animal", references.fetch("name")) + assert_operator(references.fetch("total"), :>=, 3) + assert(references.fetch("references").all? { |entry| entry.key?("path") && entry.key?("line") && entry.key?("column") }) + assert(references.fetch("references").any? { |entry| entry.fetch("path") == "app.rb" }) + assert(references.fetch("references").any? { |entry| entry.fetch("path") == "cat.rb" }) + + page = call_tool(server, "find_constant_references", name: "Animal", limit: 1, offset: -10) + + assert_equal(references.fetch("total"), page.fetch("total")) + assert_equal(1, page.fetch("references").length) + + method_references = call_tool(server, "find_constant_references", name: "Dog#speak()") + + assert_equal("Dog#speak()", method_references.fetch("name")) + assert_equal(0, method_references.fetch("total")) + assert_empty(method_references.fetch("references")) + end + end + + def test_get_file_declarations_tool + with_mcp_server do |server| + file = call_tool(server, "get_file_declarations", file_path: "app.rb") + + assert_equal("app.rb", file.fetch("file")) + assert_has_value(file.fetch("declarations"), "Animal", "app.rb declarations") + assert_has_value(file.fetch("declarations"), "Animal::KIND", "app.rb declarations") + assert_has_value(file.fetch("declarations"), "Dog", "app.rb declarations") + assert_has_value(file.fetch("declarations"), "Dog#speak()", "app.rb declarations") + + missing = call_tool(server, "get_file_declarations", file_path: "missing.rb") + + assert_equal("not_found", missing.fetch("error")) + end + end + + def test_get_file_declarations_decodes_file_uri_paths + with_context do |context| + context.write!("my app.rb", "class SpacedFile; end") + graph = Rubydex::Graph.new(workspace_path: context.absolute_path) + errors = graph.index_all([context.absolute_path]) + graph.resolve + + assert_empty(errors) + + server_state = Object.new + server_state.define_singleton_method(:root_path) { context.absolute_path } + server_state.define_singleton_method(:graph_or_error) { graph } + server = Rubydex::MCPServer::Server.new(server_state: server_state) + + file = call_tool(server, "get_file_declarations", file_path: "my app.rb") + + assert_equal("my app.rb", file.fetch("file")) + assert_has_value(file.fetch("declarations"), "SpacedFile", "my app.rb declarations") + end + end + + def test_todo_declaration_uses_graph_kind_string + with_mcp_server do |server| + declaration = call_tool(server, "get_declaration", name: "MissingParent") + stats = call_tool(server, "codebase_stats") + + assert_equal("", declaration.fetch("kind")) + assert_operator(stats.fetch("breakdown_by_kind").fetch(""), :>=, 1) + end + end + + def test_codebase_stats_tool + with_mcp_server do |server| + stats = call_tool(server, "codebase_stats") + + assert_equal(3, stats.fetch("files")) + assert_operator(stats.fetch("declarations"), :>, 0) + assert_operator(stats.fetch("definitions"), :>, 0) + assert_operator(stats.fetch("constant_references"), :>, 0) + assert_operator(stats.fetch("method_references"), :>, 0) + assert_operator(stats.fetch("breakdown_by_kind").fetch("Class"), :>=, 3) + assert_operator(stats.fetch("breakdown_by_kind").fetch("Method"), :>=, 3) + assert_operator(stats.fetch("breakdown_by_kind").fetch("Constant"), :>=, 2) + end + end + + private + + def with_mcp_server + with_context do |context| + write_fixture(context) + graph = Rubydex::Graph.new(workspace_path: context.absolute_path) + errors = graph.index_all([context.absolute_path]) + graph.resolve + + assert_empty(errors) + + server_state = Object.new + server_state.define_singleton_method(:root_path) { context.absolute_path } + server_state.define_singleton_method(:graph_or_error) { graph } + server = Rubydex::MCPServer::Server.new(server_state: server_state) + + yield server + end + end + + def write_fixture(context) + context.write!("app.rb", <<~RUBY) + class Animal + KIND = "animal" + + def speak + "..." + end + end + + class Dog < Animal + BREED = "unknown" + + def speak + Animal::KIND + end + end + RUBY + + context.write!("cat.rb", <<~RUBY) + class Cat < Animal + end + + class Kennel + def build + Animal.new + end + end + + class MissingParent::Child + end + RUBY + end + + def call_tool(server, tool_name, arguments = {}) + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: tool_name, + arguments: arguments, + }, + }, + ) + + JSON.parse(response.fetch(:result).fetch(:content)[0].fetch(:text)) + end + + def assert_has_value(entries, expected_value, context, key: "name") + values = entries.map { |entry| entry.fetch(key) } + assert_includes(values, expected_value, "Expected #{context} to include #{expected_value}, got: #{values.inspect}") + end +end