Skip to content

Commit 8a9dae2

Browse files
committed
Index MCP dependency paths from wrapper
1 parent a2c10d7 commit 8a9dae2

6 files changed

Lines changed: 170 additions & 20 deletions

File tree

exe/rubydex_mcp

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
#!/usr/bin/env ruby
22
# frozen_string_literal: true
33

4-
require "rbconfig"
4+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
55

6-
host_os = RbConfig::CONFIG.fetch("host_os")
7-
executable = host_os.match?(/mswin|mingw|cygwin/) ? "rubydex_mcp.exe" : "rubydex_mcp"
8-
binary = File.expand_path("../lib/rubydex/bin/#{executable}", __dir__)
6+
require "rubydex/mcp_executable"
7+
8+
binary = Rubydex::MCPExecutable.binary_path
99

1010
unless File.executable?(binary)
1111
abort(<<~MESSAGE.chomp)
@@ -14,4 +14,13 @@ unless File.executable?(binary)
1414
MESSAGE
1515
end
1616

17-
exec(binary, *ARGV)
17+
args = if Rubydex::MCPExecutable.passthrough?(ARGV)
18+
ARGV
19+
elsif Rubydex::MCPExecutable.setup_bundler_context(ARGV.first || Dir.pwd)
20+
# The wrapper only discovers paths that require Ruby/Bundler context. Rust still performs the indexing pass.
21+
Rubydex::MCPExecutable.compute_index_paths(ARGV)
22+
else
23+
ARGV.empty? ? [Dir.pwd] : ARGV
24+
end
25+
26+
exec(binary, *args)

lib/rubydex/mcp_executable.rb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
require "rbconfig"
4+
5+
module Rubydex
6+
# Launcher-only helpers for `exe/rubydex_mcp`.
7+
#
8+
# Do not require this file from `lib/rubydex.rb`. The main Ruby API owns graph construction through
9+
# `Rubydex::Graph`; this file exists only so the executable wrapper can inspect Bundler before it execs the Rust MCP
10+
# server. The Rust server still performs the indexing pass.
11+
module MCPExecutable
12+
PASSTHROUGH_ARGUMENTS = ["-h", "--help", "-V", "--version"].freeze
13+
14+
extend self
15+
16+
def executable_name
17+
host_os = RbConfig::CONFIG.fetch("host_os")
18+
host_os.match?(/mswin|mingw|cygwin/) ? "rubydex_mcp.exe" : "rubydex_mcp"
19+
end
20+
21+
def binary_path
22+
File.expand_path("bin/#{executable_name}", __dir__)
23+
end
24+
25+
def passthrough?(argv)
26+
argv.any? { |arg| PASSTHROUGH_ARGUMENTS.include?(arg) }
27+
end
28+
29+
def setup_bundler_context(workspace_path)
30+
root = File.expand_path(workspace_path)
31+
root = File.dirname(root) if File.file?(root)
32+
33+
gemfile = File.join(root, "Gemfile")
34+
ENV["BUNDLE_GEMFILE"] ||= gemfile if File.file?(gemfile)
35+
36+
require "bundler/setup"
37+
require "bundler"
38+
39+
true
40+
rescue LoadError => e
41+
warn("Warning: failed to load Bundler context: #{e.message}")
42+
false
43+
rescue Bundler::BundlerError => e
44+
warn("Warning: failed to load Bundler context: #{e.message}")
45+
false
46+
end
47+
48+
def compute_index_paths(argv)
49+
paths = argv.empty? ? [Dir.pwd] : argv
50+
paths = paths.map { |path| File.expand_path(path) }
51+
52+
paths.concat(dependency_index_paths)
53+
paths.uniq
54+
end
55+
56+
def dependency_index_paths
57+
Bundler.load.specs.flat_map do |spec|
58+
spec.require_paths.filter_map do |path|
59+
next if File.absolute_path?(path)
60+
61+
full_path = File.join(spec.full_gem_path, path)
62+
full_path if File.directory?(full_path)
63+
end
64+
end.uniq
65+
rescue Bundler::BundlerError => e
66+
warn("Warning: failed to collect Bundler dependency paths: #{e.message}")
67+
[]
68+
end
69+
end
70+
end

rust/rubydex-mcp/src/main.rs

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,24 @@ mod tools;
1010
version
1111
)]
1212
struct Args {
13-
#[arg(value_name = "PATH", default_value = ".")]
14-
path: String,
13+
#[arg(value_name = "PATH")]
14+
paths: Vec<String>,
1515
}
1616

1717
fn main() {
1818
let args = Args::parse();
19-
20-
let root = match std::fs::canonicalize(&args.path) {
21-
Ok(p) => p
22-
.into_os_string()
23-
.into_string()
24-
.expect("Project path is not valid UTF-8"),
25-
Err(e) => {
26-
eprintln!("Warning: failed to canonicalize '{}': {e}", args.path);
27-
args.path
28-
}
19+
let paths = if args.paths.is_empty() {
20+
vec![".".to_string()]
21+
} else {
22+
args.paths
2923
};
3024

25+
let paths: Vec<String> = paths.into_iter().map(canonicalize_path).collect();
26+
let root = paths.first().expect("expected at least one path").clone();
27+
3128
// Create the server and start indexing in the background.
3229
let server = server::RubydexServer::new(root.clone());
33-
server.spawn_indexer(root);
30+
server.spawn_indexer(paths);
3431

3532
// Serve MCP over stdio immediately while indexing runs.
3633
// We need to do this because Claude Code's default MCP server timeout is 30 seconds,
@@ -46,3 +43,16 @@ fn main() {
4643
std::process::exit(1);
4744
}
4845
}
46+
47+
fn canonicalize_path(path: String) -> String {
48+
match std::fs::canonicalize(&path) {
49+
Ok(p) => p
50+
.into_os_string()
51+
.into_string()
52+
.expect("Project path is not valid UTF-8"),
53+
Err(e) => {
54+
eprintln!("Warning: failed to canonicalize '{path}': {e}");
55+
path
56+
}
57+
}
58+
}

rust/rubydex-mcp/src/server.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ impl RubydexServer {
4444
}
4545

4646
/// Spawns a background thread that indexes the codebase and marks the server as ready.
47-
pub fn spawn_indexer(&self, path: String) {
47+
pub fn spawn_indexer(&self, paths: Vec<String>) {
4848
let state = Arc::clone(&self.state);
4949
std::thread::spawn(move || {
5050
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
51-
let (file_paths, errors) = rubydex::listing::collect_file_paths(vec![path], &HashSet::new());
51+
let (file_paths, errors) = rubydex::listing::collect_file_paths(paths, &HashSet::new());
5252
for error in &errors {
5353
eprintln!("Listing error: {error}");
5454
}

rust/rubydex-mcp/tests/mcp.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,46 @@ fn mcp_server_e2e() {
300300
let _ = child.wait().unwrap();
301301
});
302302
}
303+
304+
#[test]
305+
fn mcp_server_indexes_multiple_roots() {
306+
with_context(|context| {
307+
context.write("workspace/app.rb", "class WorkspaceThing; end");
308+
context.write("dependency/lib/dependency_thing.rb", "class DependencyThing; end");
309+
310+
let mut child = Command::cargo_bin("rubydex_mcp")
311+
.unwrap()
312+
.args([
313+
context.absolute_path_to("workspace").to_str().unwrap(),
314+
context.absolute_path_to("dependency/lib").to_str().unwrap(),
315+
])
316+
.stdin(Stdio::piped())
317+
.stdout(Stdio::piped())
318+
.stderr(Stdio::piped())
319+
.spawn()
320+
.unwrap();
321+
322+
let mut stdin = child.stdin.take().unwrap();
323+
let stdout = child.stdout.take().unwrap();
324+
let mut reader = BufReader::new(stdout);
325+
326+
initialize_session(&mut stdin, &mut reader);
327+
328+
let mut request_id = 3;
329+
wait_for_indexing_to_complete(&mut stdin, &mut reader, &mut request_id);
330+
331+
let search_response = call_next_tool(
332+
&mut stdin,
333+
&mut reader,
334+
&mut request_id,
335+
"search_declarations",
336+
&json!({ "query": "DependencyThing" }),
337+
);
338+
let results = search_response["results"].as_array().unwrap();
339+
let result_names = names_from_entries(results);
340+
assert_has_name(&result_names, "DependencyThing", "search results");
341+
342+
drop(stdin);
343+
let _ = child.wait().unwrap();
344+
});
345+
}

test/mcp_executable_test.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "test_helper"
4+
5+
require "rubydex/mcp_executable"
6+
7+
class MCPExecutableTest < Minitest::Test
8+
def test_compute_index_paths_include_bundler_dependency_require_paths
9+
Rubydex::MCPExecutable.setup_bundler_context(Dir.pwd)
10+
11+
paths = Rubydex::MCPExecutable.compute_index_paths([Dir.pwd])
12+
spec = Bundler.load.specs.find { |loaded_spec| loaded_spec.name == "rake" }
13+
expected_path = File.join(spec.full_gem_path, "lib")
14+
15+
assert_includes(paths, Dir.pwd)
16+
assert_includes(paths, expected_path)
17+
end
18+
end

0 commit comments

Comments
 (0)