Skip to content

Replace rubydex_mcp server with Ruby implementation#863

Open
st0012 wants to merge 4 commits into
mainfrom
codex/ruby-mcp-server
Open

Replace rubydex_mcp server with Ruby implementation#863
st0012 wants to merge 4 commits into
mainfrom
codex/ruby-mcp-server

Conversation

@st0012

@st0012 st0012 commented Jun 16, 2026

Copy link
Copy Markdown
Member

Summary

Adds a Ruby implementation of rubydex_mcp with a small internal MCP/JSON-RPC protocol layer.

The existing Rust MCP crate and build plumbing are left in place. If this PR is accepted, I'll remove it in a follow up PR.

Parity with the Rust MCP server

Matches the existing Rust MCP server for:

  • tool names and schemas: search_declarations, get_declaration, get_descendants, find_constant_references, get_file_declarations, codebase_stats
  • startup indexing behavior and indexing-in-progress / indexing-failed responses
  • JSON payload shapes, pagination defaults and caps, kind filtering, exact/fuzzy search modes, relative path formatting, declaration details, descendants, file declarations, and stats
  • constant-reference behavior for classes/modules/constants, with methods returning an empty reference list

Manually checked against rust/target/debug/rubydex_mcp using the same fixture and tool calls for the six tools.

Known difference:

  • unresolved partial ancestors are not exposed by the current Ruby API, so the Ruby MCP server only reports resolved ancestors. The manual parity check confirmed this as the expected mismatch.

Follow-up work

  • Remove the Rust MCP implementation in a separate PR.
  • Open a follow-up PR for Bundler integration.

@st0012 st0012 force-pushed the codex/ruby-mcp-server branch 12 times, most recently from f13e11d to 6a9ca41 Compare June 18, 2026 18:41
@st0012 st0012 changed the title Add Ruby MCP server Replace rubydex_mcp server with Ruby implementation Jun 18, 2026
@st0012 st0012 self-assigned this Jun 18, 2026
@st0012 st0012 force-pushed the codex/ruby-mcp-server branch from 6a9ca41 to db9f6ba Compare June 18, 2026 18:57
Assisted-By: devx/0b82d122-3a3f-4089-9195-b78154de2f9a
@st0012 st0012 marked this pull request as ready for review June 18, 2026 21:21
@st0012 st0012 requested a review from a team as a code owner June 18, 2026 21:21
@st0012 st0012 added the enhancement New feature or request label Jun 22, 2026
@st0012 st0012 force-pushed the codex/ruby-mcp-server branch from 0f6b7c8 to 077eb83 Compare June 22, 2026 17:59
Assisted-By: devx/0b82d122-3a3f-4089-9195-b78154de2f9a
@st0012 st0012 force-pushed the codex/ruby-mcp-server branch from 077eb83 to d88a604 Compare June 22, 2026 19:03
Comment thread exe/rubydex_mcp
end

begin
option_parser.parse!(ARGV)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, one of the things I regret in the LSP was using parse! instead of the non-raising version parse. It makes it more difficult to make backwards incompatible changes to accepted flags, even for development related stuff.

Comment thread exe/rubydex_mcp
exit(2)
end

path = ARGV.fetch(0, ".")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to use absolute paths when possible.

Suggested change
path = ARGV.fetch(0, ".")
path = ARGV.fetch(0, Dir.pwd)

Comment thread lib/rubydex/mcp_server.rb
Comment on lines +9 to +14
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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we don't have to keep adding to the list.

Suggested change
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"
Dir.glob("rubydex/mcp_server/tools/**/*.rb").each { |f| require f }

Comment thread lib/rubydex/mcp_server.rb
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I'm wondering if the reference to other language names will make it better or worse at picking up the MCP 😂

Comment thread lib/rubydex/mcp_server.rb
Comment on lines +25 to +31
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this maybe a better fit for the individual tool descriptions?

end
end

class Server

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, it would be easier to follow the architecture if we merged Server and State into a single object and kept things like error codes and convenience objects inside protocol.

INVALID_PARAMS = -32_602
INTERNAL_ERROR = -32_603

#: (Hash | Array | untyped) -> Hash | Array[Hash]?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the MCP send asynchronous messages to the client like an LSP can? If so, we're making the same mistake we originally made in the LSP here: implementing a handle method that returns the response to the sent to the client.

That approach makes it really difficult to have a single operation (like a tool call) both send an asynchronous notification and return a response.

That's why in the LSP we have an outgoing queue with a dispatcher thread. Any request/notification can enqueue as many messages as desired to be sent to the client.

Comment on lines +93 to +96
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"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to check if it's string or symbol? If we control the JSON parsing, we should be able to use symbolize_names: true and standardize everything on symbols.

#: (Hash) -> Hash
def call_tool(params)
tool_name = params[:name] || params["name"]
tool = TOOLS.find { |candidate| candidate.to_h.fetch(:name) == tool_name }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of an array, we should probably use a hash and make this a simple lookup.


#: -> void
def open
@input.each_line do |line|

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this using JSONRPC? The separator should be \r\n\r\n and not a single line. So you need something like gets("\r\n\r\n"). And the first line you get is the content length, so after the gets you need to read the specified number of bytes.

For example, something like this:

headers = io.gets("\r\n\r\n")
raise unless headers

length = headers[/Content-Length: (\d+)/i, 1]
raise unless length

raw_message = io.read(length)
message = JSON.parse(raw_message)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants