Skip to content

Add Experimental MCP Support #3396

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
7 changes: 7 additions & 0 deletions .cursor/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"mcpServers": {
"rubyMcp": {
"command": "./.ruby-lsp/ruby-mcp-bridge"
}
}
}
9 changes: 9 additions & 0 deletions .vscode/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"inputs": [],
"servers": {
"rubyMcp": {
"type": "stdio",
"command": "${workspaceFolder}/.ruby-lsp/ruby-mcp-bridge"
}
}
}
29 changes: 29 additions & 0 deletions lib/ruby_lsp/global_state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_lsp/internal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
require "open3"
require "securerandom"
require "shellwords"
require "set"

require "ruby-lsp"
require "ruby_lsp/base_server"
Expand All @@ -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"
Expand Down
251 changes: 251 additions & 0 deletions lib/ruby_lsp/mcp/tool.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading