Skip to content

Commit ba91c7c

Browse files
committed
Make MCP executable recover on non-FHS Linux
1 parent 33e510e commit ba91c7c

3 files changed

Lines changed: 120 additions & 12 deletions

File tree

exe/rubydex_mcp

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
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_server_launcher"
97

10-
unless File.executable?(binary)
11-
abort(<<~MESSAGE.chomp)
12-
rubydex_mcp is not available at #{binary}.
13-
Install a precompiled rubydex gem, or reinstall rubydex with Cargo available so the MCP executable can be built locally.
14-
MESSAGE
15-
end
16-
17-
exec(binary, *ARGV)
8+
Rubydex::MCPServerLauncher.exec_server(ARGV)

lib/rubydex/mcp_server_launcher.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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`. It exists only so the Ruby executable wrapper can handle
9+
# host-specific process launch details before it execs the Rust MCP server.
10+
module MCPServerLauncher
11+
extend self
12+
13+
def windows?
14+
RbConfig::CONFIG.fetch("host_os").match?(/mswin|mingw|cygwin/)
15+
end
16+
17+
def executable_name
18+
windows? ? "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 host_loader(maps_path)
26+
File.foreach(maps_path) do |line|
27+
path = line.split.last
28+
next unless path&.start_with?("/")
29+
next unless File.basename(path).match?(/\Ald-.*\.so/)
30+
31+
return path if File.exist?(path)
32+
end
33+
34+
nil
35+
rescue Errno::ENOENT
36+
nil
37+
end
38+
39+
def exec_server(argv)
40+
binary = binary_path
41+
42+
unless File.executable?(binary)
43+
abort(<<~MESSAGE.chomp)
44+
rubydex_mcp is not available at #{binary}.
45+
Install a precompiled rubydex gem, or reinstall rubydex with Cargo available so the MCP executable can be built locally.
46+
MESSAGE
47+
end
48+
49+
exec(binary, *argv)
50+
rescue Errno::ENOENT
51+
loader = host_loader("/proc/self/maps")
52+
raise unless loader
53+
54+
exec(loader, binary, *argv)
55+
end
56+
end
57+
end

test/mcp_server_executable_test.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "test_helper"
4+
5+
require "fileutils"
6+
require "tmpdir"
7+
require "tempfile"
8+
require "rubydex/mcp_server_launcher"
9+
10+
class MCPServerExecutableTest < Minitest::Test
11+
def test_host_loader_reads_loader_from_maps
12+
dir = Dir.mktmpdir
13+
loader_path = File.join(dir, "ld-linux-x86-64.so.2")
14+
File.write(loader_path, "")
15+
16+
maps = Tempfile.new("maps")
17+
maps.write(<<~MAPS)
18+
7f3d10000000-7f3d10022000 r--p 00000000 00:00 0 [heap]
19+
7f3d20000000-7f3d20022000 r-xp 00000000 00:00 0 #{loader_path}
20+
MAPS
21+
maps.close
22+
23+
assert_equal(loader_path, Rubydex::MCPServerLauncher.host_loader(maps.path))
24+
ensure
25+
maps&.unlink
26+
FileUtils.remove_entry_secure(dir) if dir && File.directory?(dir)
27+
end
28+
29+
def test_host_loader_ignores_missing_loader_paths
30+
Dir.mktmpdir do |dir|
31+
loader_path = File.join(dir, "ld-linux-x86-64.so.2")
32+
File.write(loader_path, "")
33+
34+
maps = Tempfile.new("maps")
35+
maps.write(<<~MAPS)
36+
7f3d10000000-7f3d10022000 r-xp 00000000 00:00 0 /missing/ld-linux-x86-64.so.2
37+
7f3d20000000-7f3d20022000 r-xp 00000000 00:00 0 #{loader_path}
38+
MAPS
39+
maps.close
40+
41+
assert_equal(loader_path, Rubydex::MCPServerLauncher.host_loader(maps.path))
42+
ensure
43+
maps&.unlink
44+
end
45+
end
46+
47+
def test_host_loader_returns_nil_when_maps_file_is_missing
48+
assert_nil(Rubydex::MCPServerLauncher.host_loader("/missing/proc/self/maps"))
49+
end
50+
51+
def test_host_loader_returns_existing_linux_loader
52+
skip("Linux-only") unless File.file?("/proc/self/maps")
53+
54+
loader = Rubydex::MCPServerLauncher.host_loader("/proc/self/maps")
55+
56+
refute_nil(loader)
57+
assert_path_exists(loader)
58+
assert_match(/\Ald-.*\.so/, File.basename(loader))
59+
end
60+
end

0 commit comments

Comments
 (0)