diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 000000000..5b61d19fa --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "rubyMcp": { + "command": "./.ruby-lsp/ruby-mcp-bridge" + } + } +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..cc7bedca6 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "inputs": [], + "servers": { + "rubyMcp": { + "type": "stdio", + "command": "${workspaceFolder}/.ruby-lsp/ruby-mcp-bridge" + } + } +} diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index 023d642b2..8ad5f9ada 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -33,6 +33,9 @@ class GlobalState #: String? attr_reader :telemetry_machine_id + #: bool + attr_reader :uses_ruby_mcp + #: -> void def initialize @workspace_uri = URI::Generic.from_path(path: Dir.pwd) #: URI::Generic @@ -56,6 +59,7 @@ def initialize @enabled_feature_flags = {} #: Hash[Symbol, bool] @mutex = Mutex.new #: Mutex @telemetry_machine_id = nil #: String? + @uses_ruby_mcp = false #: bool end #: [T] { -> T } -> T @@ -151,6 +155,9 @@ def apply_options(options) ) end + @uses_ruby_mcp = detects_ruby_mcp + notifications << Notification.window_log_message("Uses Ruby MCP: #{@uses_ruby_mcp}") + encodings = options.dig(:capabilities, :general, :positionEncodings) @encoding = if !encodings || encodings.empty? Encoding::UTF_16LE @@ -205,8 +212,30 @@ def supports_watching_files @client_capabilities.supports_watching_files end + #: -> bool + def detects_ruby_mcp + check_mcp_file(".vscode/mcp.json", ["servers", "rubyMcp"]) || + check_mcp_file(".cursor/mcp.json", ["mcpServers", "rubyMcp"]) + end + private + # Helper method to check for rubyMcp configuration in a specific file + #: (String relative_path, Array[String] keys_to_check) -> bool + def check_mcp_file(relative_path, keys_to_check) + file_path = File.join(workspace_path, relative_path) + return false unless File.exist?(file_path) + + begin + config = JSON.parse(File.read(file_path)) + # Check if the nested keys exist + !!config.dig(*keys_to_check) + rescue JSON::ParserError + # If JSON parsing fails, consider it not configured + false + end + end + #: (Array[String] direct_dependencies, Array[String] all_dependencies) -> String def detect_formatter(direct_dependencies, all_dependencies) # NOTE: Intentionally no $ at end, since we want to match rubocop-shopify, etc. diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 420ca665e..4c3294171 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -30,7 +30,6 @@ require "open3" require "securerandom" require "shellwords" -require "set" require "ruby-lsp" require "ruby_lsp/base_server" @@ -41,6 +40,7 @@ require "ruby_lsp/client_capabilities" require "ruby_lsp/global_state" require "ruby_lsp/server" +require "ruby_lsp/mcp_server" require "ruby_lsp/type_inferrer" require "ruby_lsp/node_context" require "ruby_lsp/document" diff --git a/lib/ruby_lsp/mcp/tool.rb b/lib/ruby_lsp/mcp/tool.rb new file mode 100644 index 000000000..75fb6a9fe --- /dev/null +++ b/lib/ruby_lsp/mcp/tool.rb @@ -0,0 +1,251 @@ +# typed: strict +# frozen_string_literal: true + +require "ruby_lsp/requests/support/common" + +module RubyLsp + module MCP + # @abstract + class Tool + include RubyLsp::Requests::Support::Common + + MAX_CLASSES_TO_RETURN = 5000 + + @tools = {} #: Hash[String, singleton(Tool)] + + #: (RubyIndexer::Index, Hash[Symbol, untyped]) -> void + def initialize(index, arguments) + @index = index #: RubyIndexer::Index + @arguments = arguments #: Hash[Symbol, untyped] + end + + # @abstract + #: -> Array[Hash[Symbol, untyped]] + def perform; end + + class << self + #: Hash[String, singleton(Tool)] + attr_reader :tools + + #: (singleton(Tool)) -> void + def register(tool_class) + tools[tool_class.name] = tool_class + end + + #: (String) -> singleton(Tool)? + def get(name) + tools[name] + end + + # @abstract + #: -> String + def name; end + + # @abstract + #: -> String + def description; end + + # @abstract + #: -> Hash[Symbol, untyped] + def input_schema; end + end + end + + class GetClassModuleDetails < Tool + class << self + # @override + #: -> String + def name + "get_class_module_details" + end + + # @override + #: -> String + def description + "Show details of classes/modules including comments, definition location, methods, and ancestors." + + "Use get_methods_details for specific method details." + end + + # @override + #: -> Hash[Symbol, untyped] + def input_schema + { + type: "object", + properties: { + fully_qualified_names: { type: "array", items: { type: "string" } }, + }, + } + end + end + + # @override + #: -> Array[Hash[Symbol, untyped]] + def perform + fully_qualified_names = @arguments[:fully_qualified_names] + fully_qualified_names.map do |fully_qualified_name| + *nestings, name = fully_qualified_name.delete_prefix("::").split("::") + entries = @index.resolve(name, nestings) || [] + + begin + ancestors = @index.linearized_ancestors_of(fully_qualified_name) + methods = @index.method_completion_candidates(nil, fully_qualified_name) + rescue RubyIndexer::Index::NonExistingNamespaceError + # If the namespace doesn't exist, we can't find ancestors or methods + ancestors = [] + methods = [] + end + + type = case entries.first + when RubyIndexer::Entry::Class + "class" + when RubyIndexer::Entry::Module + "module" + else + "unknown" + end + + { + type: "text", + text: { + name: fully_qualified_name, + nestings: nestings, + type: type, + ancestors: ancestors, + methods: methods.map(&:name), + uris: entries.map { |entry| entry.uri.to_s }, + }.to_s, + } + end + end + end + + class GetMethodsDetails < Tool + class << self + # @override + #: -> String + def name + "get_methods_details" + end + + # @override + #: -> String + def description + "Show method details including comments, location, visibility, parameters, and owner." + + "Use Class#method, Module#method, Class.singleton_method, or Module.singleton_method format." + end + + # @override + #: -> Hash[Symbol, untyped] + def input_schema + { + type: "object", + properties: { + signatures: { type: "array", items: { type: "string" } }, + }, + } + end + end + + # @override + #: -> Array[Hash[Symbol, untyped]] + def perform + signatures = @arguments[:signatures] + signatures.map do |signature| + entries = nil + receiver = nil + method = nil + + if signature.include?("#") + receiver, method = signature.split("#") + entries = @index.resolve_method(method, receiver) + elsif signature.include?(".") + receiver, method = signature.split(".") + singleton_class = @index.existing_or_new_singleton_class(receiver) + entries = @index.resolve_method(method, singleton_class.name) + end + + next if entries.nil? + + entry_details = entries.map do |entry| + "uri: #{entry.uri}, visibility: #{entry.visibility}, parameters: #{entry.decorated_parameters}," + + "owner: #{entry.owner&.name}" + end + + { + type: "text", + text: "{ receiver: #{receiver}, method: #{method}, entry_details: #{entry_details} }", + } + end.compact + end + end + + class GetClassesAndModules < Tool + class << self + # @override + #: -> String + def name + "get_classes_and_modules" + end + + # @override + #: -> String + def description + "Show all indexed classes and modules in the project and dependencies. When query provided, returns filtered matches. Stops after #{Tool::MAX_CLASSES_TO_RETURN} results." + + "Use get_class_module_details to get the details of a specific class or module." + end + + # @override + #: -> Hash[Symbol, untyped] + def input_schema + { + type: "object", + properties: { + query: { + type: "string", + description: "A query to filter the classes and modules", + }, + }, + } + end + end + + # @override + #: -> Array[Hash[Symbol, untyped]] + def perform + query = @arguments[:query] + class_names = @index.fuzzy_search(query).map do |entry| + case entry + when RubyIndexer::Entry::Class + "{name: #{entry.name}, type: class}" + when RubyIndexer::Entry::Module + "{name: #{entry.name}, type: module}" + end + end.compact.uniq + + if class_names.size > MAX_CLASSES_TO_RETURN + [ + { + type: "text", + text: "Too many classes and modules to return, please narrow down your request with a query.", + }, + { + type: "text", + text: class_names.first(MAX_CLASSES_TO_RETURN).join(", "), + }, + ] + else + [ + { + type: "text", + text: class_names.join(", "), + }, + ] + end + end + end + + Tool.register(GetClassesAndModules) + Tool.register(GetMethodsDetails) + Tool.register(GetClassModuleDetails) + end +end diff --git a/lib/ruby_lsp/mcp_server.rb b/lib/ruby_lsp/mcp_server.rb new file mode 100644 index 000000000..4a6b50d42 --- /dev/null +++ b/lib/ruby_lsp/mcp_server.rb @@ -0,0 +1,230 @@ +# typed: strict +# frozen_string_literal: true + +require "ruby_lsp/mcp/tool" +require "socket" + +module RubyLsp + class MCPServer + # JSON-RPC 2.0 Error Codes + module ErrorCode + PARSE_ERROR = -32700 + INVALID_REQUEST = -32600 + METHOD_NOT_FOUND = -32601 + INVALID_PARAMS = -32602 + INTERNAL_ERROR = -32603 + end + + class << self + # Find an available TCP port + #: -> Integer + def find_available_port + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + server.close + port + end + end + + #: (GlobalState) -> void + def initialize(global_state) + @workspace_path = global_state.workspace_path #: String + @port = self.class.find_available_port #: Integer + + # Create .ruby-lsp directory if it doesn't exist + lsp_dir = File.join(@workspace_path, ".ruby-lsp") + FileUtils.mkdir_p(lsp_dir) + + # Write port to file + @port_file = File.join(lsp_dir, "mcp-port") #: String + File.write(@port_file, @port.to_s) + + # Create TCP server + @server = TCPServer.new("127.0.0.1", @port) #: TCPServer + @server_thread = nil #: Thread? + + @running = false #: T::Boolean + @global_state = global_state #: GlobalState + @index = global_state.index #: RubyIndexer::Index + end + + #: -> void + def start + puts "[MCP] Server started on port #{@port}" + @running = true + + @server_thread = Thread.new do + while @running + begin + # Accept incoming connections + client = @server.accept + + # Handle each client in a separate thread + Thread.new(client) do |client_socket| + handle_client(client_socket) + end + rescue => e + puts "[MCP] Error accepting connection: #{e.message}" if @running + end + end + end + end + + #: -> void + def stop + puts "[MCP] Server stopping" + @running = false + @server.close + @server_thread&.join + ensure + File.delete(@port_file) if File.exist?(@port_file) + end + + private + + #: (TCPSocket) -> void + def handle_client(client_socket) + # Read JSON-RPC request from client + request_line = client_socket.gets + return unless request_line + + request_line = request_line.strip + + # Process the JSON-RPC request + response = process_jsonrpc_request(request_line) + + if response + client_socket.puts(response) + end + rescue => e + puts "[MCP] Client error: #{e.message}" + + # Send error response + error_response = generate_error_response(nil, ErrorCode::INTERNAL_ERROR, "Internal error", e.message) + client_socket.puts(error_response) + ensure + client_socket.close + end + + #: (String) -> String? + def process_jsonrpc_request(json) + # Parse JSON + begin + request = JSON.parse(json, symbolize_names: true) + rescue JSON::ParserError + return generate_error_response(nil, ErrorCode::PARSE_ERROR, "Parse error", "Invalid JSON") + end + + # Validate JSON-RPC 2.0 format + unless request.is_a?(Hash) && request[:jsonrpc] == "2.0" + return generate_error_response( + request[:id], + ErrorCode::INVALID_REQUEST, + "Invalid Request", + "Not a valid JSON-RPC 2.0 request", + ) + end + + method_name = request[:method] + params = request[:params] || {} + request_id = request[:id] + + begin + result = process_request(method_name, params) + + if result + generate_success_response(request_id, result) + else + generate_error_response( + request_id, + ErrorCode::METHOD_NOT_FOUND, + "Method not found", + "Method '#{method_name}' not found", + ) + end + rescue => e + generate_error_response(request_id, ErrorCode::INTERNAL_ERROR, "Internal error", e.message) + end + end + + #: (String, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]? + def process_request(method_name, params) + case method_name + when "initialize" + { + protocolVersion: "2024-11-05", + capabilities: { + tools: { list_changed: false }, + }, + serverInfo: { + name: "ruby-lsp-mcp-server", + version: "0.1.0", + }, + } + when "initialized", "notifications/initialized" + {} + when "tools/list" + { + tools: RubyLsp::MCP::Tool.tools.map do |tool_name, tool_class| + { + name: tool_name, + description: tool_class.description, + inputSchema: tool_class.input_schema, + } + end, + } + when "tools/call" + tool_name = params[:name] + tool_class = RubyLsp::MCP::Tool.get(tool_name) + + if tool_class + arguments = params[:arguments] || {} + contents = tool_class.new(@index, arguments).perform + generate_response(contents) + else + generate_response([]) + end + end + end + + #: (Integer?, untyped) -> String + def generate_success_response(id, result) + { + jsonrpc: "2.0", + id: id, + result: result, + }.to_json + end + + #: (Integer?, Integer, String, String) -> String + def generate_error_response(id, code, message, data) + { + jsonrpc: "2.0", + id: id, + error: { + code: code, + message: message, + data: data, + }, + }.to_json + end + + #: (Array[Hash[Symbol, untyped]]) -> Hash[Symbol, untyped] + def generate_response(contents) + if contents.empty? + { + content: [ + { + type: "text", + text: "No results found", + }, + ], + } + else + { + content: contents, + } + end + end + end +end diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 6f1838713..f03eb9dac 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -372,6 +372,17 @@ def run_initialized perform_initial_indexing check_formatter_is_available + + start_mcp_server + end + + #: -> void + def start_mcp_server + return if ENV["CI"] || !@global_state.uses_ruby_mcp + + @mcp_server = MCPServer.new(@global_state) #: MCPServer? + @mcp_server #: as !nil + .start end #: (Hash[Symbol, untyped] message) -> void @@ -1245,6 +1256,7 @@ def workspace_dependencies(message) #: -> void def shutdown Addon.unload_addons + @mcp_server&.stop end #: -> void diff --git a/test/global_state_test.rb b/test/global_state_test.rb index 61214caa5..a08f835e6 100644 --- a/test/global_state_test.rb +++ b/test/global_state_test.rb @@ -314,6 +314,75 @@ def test_saves_telemetry_machine_id assert_equal("test_machine_id", state.telemetry_machine_id) end + def test_detects_vscode_ruby_mcp + state = GlobalState.new + + # Stub the bin_rails_present method + state.stubs(:bin_rails_present).returns(false) + + stub_workspace_file_does_not_exist(".cursor/mcp.json") + stub_workspace_file_exists(".vscode/mcp.json") + File.stubs(:read).with("#{Dir.pwd}/.vscode/mcp.json").returns('{"servers":{"rubyMcp":{"command":"path"}}}') + + state.apply_options({}) + assert(state.uses_ruby_mcp) + end + + def test_detects_cursor_ruby_mcp + state = GlobalState.new + + # Stub the bin_rails_present method + state.stubs(:bin_rails_present).returns(false) + + stub_workspace_file_does_not_exist(".vscode/mcp.json") + stub_workspace_file_exists(".cursor/mcp.json") + File.stubs(:read).with("#{Dir.pwd}/.cursor/mcp.json").returns('{"mcpServers":{"rubyMcp":{"command":"path"}}}') + + state.apply_options({}) + assert(state.uses_ruby_mcp) + end + + def test_does_not_detect_ruby_mcp_when_no_files_exist + state = GlobalState.new + + # Stub the bin_rails_present method + state.stubs(:bin_rails_present).returns(false) + + stub_workspace_file_does_not_exist(".vscode/mcp.json") + stub_workspace_file_does_not_exist(".cursor/mcp.json") + + state.apply_options({}) + refute(state.uses_ruby_mcp) + end + + def test_does_not_detect_ruby_mcp_when_vscode_has_no_config + state = GlobalState.new + + # Stub the bin_rails_present method + state.stubs(:bin_rails_present).returns(false) + + stub_workspace_file_exists(".vscode/mcp.json") + stub_workspace_file_does_not_exist(".cursor/mcp.json") + File.stubs(:read).with("#{Dir.pwd}/.vscode/mcp.json").returns('{"servers":{"otherServer":{"command":"path"}}}') + + state.apply_options({}) + refute(state.uses_ruby_mcp) + end + + def test_does_not_detect_ruby_mcp_when_cursor_has_no_config + state = GlobalState.new + + # Stub the bin_rails_present method + state.stubs(:bin_rails_present).returns(false) + + stub_workspace_file_does_not_exist(".vscode/mcp.json") + stub_workspace_file_exists(".cursor/mcp.json") + File.stubs(:read).with("#{Dir.pwd}/.cursor/mcp.json").returns('{"mcpServers":{"otherServer":{"command":"path"}}}') + + state.apply_options({}) + refute(state.uses_ruby_mcp) + end + private def stub_direct_dependencies(dependencies) @@ -326,7 +395,11 @@ def stub_all_dependencies(*dependencies) end def stub_workspace_file_exists(path) - File.expects(:exist?).with("#{Dir.pwd}/#{path}").returns(true) + File.stubs(:exist?).with("#{Dir.pwd}/#{path}").returns(true) + end + + def stub_workspace_file_does_not_exist(path) + File.stubs(:exist?).with("#{Dir.pwd}/#{path}").returns(false) end end end diff --git a/test/mcp_server_test.rb b/test/mcp_server_test.rb new file mode 100644 index 000000000..a4e89d1a4 --- /dev/null +++ b/test/mcp_server_test.rb @@ -0,0 +1,255 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" +require "socket" + +module RubyLsp + class MCPServerTest < Minitest::Test + def setup + @global_state = GlobalState.new + @index = @global_state.index + + # Initialize the index with Ruby core - this is essential for method resolution! + RubyIndexer::RBSIndexer.new(@index).index_ruby_core + @mcp_server = MCPServer.new(@global_state) + capture_io do + @mcp_server.start + end + + @mcp_port = @mcp_server.instance_variable_get(:@port) + + sleep(0.1) + end + + def teardown + capture_io do + @mcp_server.stop + end + end + + def test_mcp_server_initialization + response = send_mcp_request("initialize", {}) + + assert_equal("2024-11-05", response.dig("protocolVersion")) + assert_equal("ruby-lsp-mcp-server", response.dig("serverInfo", "name")) + assert_equal("0.1.0", response.dig("serverInfo", "version")) + assert(response.dig("capabilities", "tools")) + end + + def test_tools_list + response = send_mcp_request("tools/list", {}) + tools = response["tools"] + + assert_instance_of(Array, tools) + tool_names = tools.map { |tool| tool["name"] } + + assert_includes(tool_names, "get_classes_and_modules") + assert_includes(tool_names, "get_methods_details") + assert_includes(tool_names, "get_class_module_details") + end + + def test_get_classes_and_modules_no_query + @index.index_single(URI("file:///fake.rb"), <<~RUBY) + class Foo; end + module Bar; end + RUBY + + response = send_mcp_request("tools/call", { + name: "get_classes_and_modules", + arguments: {}, + }) + + assert(response["content"]) + content_text = response.dig("content", 0, "text") + + # The format is: "{name: Foo, type: class}, {name: Bar, type: module}" + # Extract class/module names using regex + class_names = content_text.scan(/\{name: (\w+), type: (?:class|module)\}/).flatten + + # Now we get Ruby core classes too, so just verify our classes are included + assert_includes(class_names, "Foo") + assert_includes(class_names, "Bar") + end + + def test_get_classes_and_modules_with_query + @index.index_single(URI("file:///fake.rb"), <<~RUBY) + class FooClass; end + module FooModule; end + class AnotherClass; end + RUBY + + response = send_mcp_request("tools/call", { + name: "get_classes_and_modules", + arguments: { "query" => "Foo" }, + }) + + content_text = response.dig("content", 0, "text") + + # Extract class/module names using regex + class_names = content_text.scan(/\{name: (\w+), type: (?:class|module)\}/).flatten + + assert_includes(class_names, "FooClass") + assert_includes(class_names, "FooModule") + end + + def test_get_methods_details_instance_method + uri = URI("file:///fake_instance.rb") + @index.index_single(uri, <<~RUBY) + class MyClass + # Method comment + def my_method(param1) + end + end + RUBY + + response = send_mcp_request("tools/call", { + name: "get_methods_details", + arguments: { "signatures" => ["MyClass#my_method"] }, + }) + + content_text = response.dig("content", 0, "text") + + assert_match(/receiver: MyClass/, content_text) + assert_match(/method: my_method/, content_text) + assert_match(/entry_details: \[/, content_text) + end + + def test_get_methods_details_singleton_method + uri = URI("file:///fake_singleton.rb") + @index.index_single(uri, <<~RUBY) + class MyClass + # Singleton method comment + def self.my_singleton_method + end + end + RUBY + + response = send_mcp_request("tools/call", { + name: "get_methods_details", + arguments: { "signatures" => ["MyClass.my_singleton_method"] }, + }) + + content_text = response.dig("content", 0, "text") + + assert_match(/receiver: MyClass/, content_text) + assert_match(/method: my_singleton_method/, content_text) + assert_match(/entry_details: \[/, content_text) + end + + def test_get_methods_details_method_not_found + @index.index_single(URI("file:///fake_not_found.rb"), "class MyClass; end") + + response = send_mcp_request("tools/call", { + name: "get_methods_details", + arguments: { "signatures" => ["MyClass#non_existent_method"] }, + }) + + assert_equal("No results found", response.dig("content", 0, "text")) + end + + def test_get_class_module_details_class + uri = URI("file:///fake_class_details.rb") + @index.index_single(uri, <<~RUBY) + class MyDetailedClass + def instance_method; end + def self.singleton_method; end + end + RUBY + + response = send_mcp_request("tools/call", { + name: "get_class_module_details", + arguments: { "fully_qualified_names" => ["MyDetailedClass"] }, + }) + + content_text = response.dig("content", 0, "text") + + assert_match(/name: "MyDetailedClass"/, content_text) + assert_match(/type: "class"/, content_text) + assert_match(/nestings: \[\]/, content_text) + assert_match(/methods: \[.*"instance_method".*\]/, content_text) + end + + def test_get_class_module_details_module + uri = URI("file:///fake_module_details.rb") + @index.index_single(uri, <<~RUBY) + # Module Comment + module MyDetailedModule + def instance_method_in_module; end + end + RUBY + + response = send_mcp_request("tools/call", { + name: "get_class_module_details", + arguments: { "fully_qualified_names" => ["MyDetailedModule"] }, + }) + + content_text = response.dig("content", 0, "text") + + assert_match(/name: "MyDetailedModule"/, content_text) + assert_match(/type: "module"/, content_text) + assert_match(/methods: \[.*"instance_method_in_module".*\]/, content_text) + end + + def test_get_class_module_details_not_found + response = send_mcp_request("tools/call", { + name: "get_class_module_details", + arguments: { "fully_qualified_names" => ["NonExistentThing"] }, + }) + + content_text = response.dig("content", 0, "text") + + assert_match(/name: "NonExistentThing"/, content_text) + assert_match(/type: "unknown"/, content_text) + assert_match(/ancestors: \[\]/, content_text) + assert_match(/methods: \[\]/, content_text) + end + + def test_invalid_tool_name + response = send_mcp_request("tools/call", { + name: "non_existent_tool", + arguments: {}, + }) + + assert_equal("No results found", response.dig("content", 0, "text")) + end + + def test_server_handles_malformed_json + socket = TCPSocket.new("127.0.0.1", @mcp_port) + socket.puts("{ invalid json") + response_line = socket.gets #: as !nil + socket.close + + response_data = JSON.parse(response_line) + assert_equal("2.0", response_data["jsonrpc"]) + assert(response_data["error"]) + end + + private + + def send_mcp_request(method, params) + request_data = { + jsonrpc: "2.0", + id: 1, + method: method, + params: params, + }.to_json + + socket = TCPSocket.new("127.0.0.1", @mcp_port) + socket.puts(request_data) + response_line = socket.gets + socket.close + + if response_line + response_data = JSON.parse(response_line) + if response_data["error"] + raise "MCP request failed: #{response_data["error"]}" + end + + response_data["result"] + else + raise "No response received from TCP server" + end + end + end +end diff --git a/vscode/ruby-mcp-bridge b/vscode/ruby-mcp-bridge new file mode 100755 index 000000000..7729ac401 --- /dev/null +++ b/vscode/ruby-mcp-bridge @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu + +# Determine script location and workspace path +SCRIPT_DIR=$(dirname "$(realpath "$0")") +WORKSPACE_RUBY_LSP_DIR="$(dirname "$SCRIPT_DIR")/.ruby-lsp" +PORT_FILE="$WORKSPACE_RUBY_LSP_DIR/mcp-port" +LOG_FILE="$WORKSPACE_RUBY_LSP_DIR/mcp-bridge.log" + +# Ensure log directory exists +mkdir -p "$WORKSPACE_RUBY_LSP_DIR" + +# Check if port file exists +if [ ! -f "$PORT_FILE" ]; then + echo "Error: MCP port file not found at $PORT_FILE" >&2 + echo "MCP server may not be running." >&2 + exit 1 +fi + +# Read port from file +PORT=$(cat "$PORT_FILE") + +echo "Bridge started using TCP port $PORT" >> "$LOG_FILE" + +while IFS= read -r line; do + echo "================================" >> "$LOG_FILE" + echo "Input JSON: $line" >> "$LOG_FILE" + + # Send JSON-RPC request directly over TCP + response=$(echo "$line" | nc 127.0.0.1 "$PORT" 2>>"$LOG_FILE") || { + echo "TCP connection failed" >> "$LOG_FILE" + continue + } + + echo "--------------------------------" >> "$LOG_FILE" + echo "Response JSON: $response" >> "$LOG_FILE" + + echo "$response" +done diff --git a/vscode/src/workspace.ts b/vscode/src/workspace.ts index 36bd07b03..9478373a6 100644 --- a/vscode/src/workspace.ts +++ b/vscode/src/workspace.ts @@ -172,6 +172,7 @@ export class Workspace implements WorkspaceInterface { title: "Initializing Ruby LSP", }, async () => { + await this.copyRubyMcpBridge(); await this.lspClient!.start(); await this.lspClient!.afterStart(); }, @@ -442,6 +443,19 @@ export class Workspace implements WorkspaceInterface { ); } + private async copyRubyMcpBridge() { + const targetURI = vscode.Uri.joinPath( + this.workspaceFolder.uri, + ".ruby-lsp", + "ruby-mcp-bridge", + ); + await vscode.workspace.fs.copy( + vscode.Uri.joinPath(this.context.extensionUri, "ruby-mcp-bridge"), + targetURI, + { overwrite: true }, + ); + } + private async fileContentsSha(uri: vscode.Uri): Promise { let fileContents;