## Summary Unlike applications, libraries provide value by remaining usable for a broad range of users, even when that means supporting older Ruby versions beyond Ruby's own EOL timeline. I maintain RuboCop, a Ruby linter. Because linters need to work across the ecosystem, they often need to keep compatibility with older Ruby versions to some extent. The trade-off is that the MCP Ruby SDK must remain compatible with Ruby 2.7. However, given the ecosystem impact, supporting Ruby 2.7 is still worthwhile. For example, bundled gems such as `csv` (Ruby >= 2.5), `bigdecimal` (Ruby >= 2.5), `json` (Ruby >= 2.7), and `language_server-protocol` (Ruby >= 2.5) tend to keep compatibility with older Ruby versions. Unlike dropping support, which imposes restrictions on users, this change broadens compatibility and does not introduce breaking changes. ## Development Note `rubocop-shopify` (2.18) supports Ruby 3.1+, and this PR does not change that. Since RuboCop can configure the runtime Ruby version and the target Ruby version for analysis independently, CI continues to run RuboCop on Ruby 4.0, while analyzing code with `TargetRubyVersion: 2.7`. When setting `TargetRubyVersion: 2.7`, several RuboCop offenses were resolved. Additionally, Sorbet-related libraries support Ruby 3.0+. Since these are not dependencies of the MCP Ruby SDK itself, they are versioned and tested in the development Gemfile.
151 lines
4.3 KiB
Ruby
151 lines
4.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
|
|
module JsonRpcHandler
|
|
class Version
|
|
V1_0 = "1.0"
|
|
V2_0 = "2.0"
|
|
end
|
|
|
|
class ErrorCode
|
|
INVALID_REQUEST = -32600
|
|
METHOD_NOT_FOUND = -32601
|
|
INVALID_PARAMS = -32602
|
|
INTERNAL_ERROR = -32603
|
|
PARSE_ERROR = -32700
|
|
end
|
|
|
|
DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/
|
|
|
|
extend self
|
|
|
|
def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
|
|
if request.is_a?(Array)
|
|
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
|
|
code: ErrorCode::INVALID_REQUEST,
|
|
message: "Invalid Request",
|
|
data: "Request is an empty array",
|
|
}) if request.empty?
|
|
|
|
# Handle batch requests
|
|
responses = request.map { |req| process_request(req, id_validation_pattern: id_validation_pattern, &method_finder) }.compact
|
|
|
|
# A single item is hoisted out of the array
|
|
return responses.first if responses.one?
|
|
|
|
# An empty array yields nil
|
|
responses if responses.any?
|
|
elsif request.is_a?(Hash)
|
|
# Handle single request
|
|
process_request(request, id_validation_pattern: id_validation_pattern, &method_finder)
|
|
else
|
|
error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
|
|
code: ErrorCode::INVALID_REQUEST,
|
|
message: "Invalid Request",
|
|
data: "Request must be an array or a hash",
|
|
})
|
|
end
|
|
end
|
|
|
|
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
|
|
begin
|
|
request = JSON.parse(request_json, symbolize_names: true)
|
|
response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder)
|
|
rescue JSON::ParserError
|
|
response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
|
|
code: ErrorCode::PARSE_ERROR,
|
|
message: "Parse error",
|
|
data: "Invalid JSON",
|
|
})
|
|
end
|
|
|
|
response&.to_json
|
|
end
|
|
|
|
def process_request(request, id_validation_pattern:, &method_finder)
|
|
id = request[:id]
|
|
|
|
error = if !valid_version?(request[:jsonrpc])
|
|
"JSON-RPC version must be 2.0"
|
|
elsif !valid_id?(request[:id], id_validation_pattern)
|
|
"Request ID must match validation pattern, or be an integer or null"
|
|
elsif !valid_method_name?(request[:method])
|
|
'Method name must be a string and not start with "rpc."'
|
|
end
|
|
|
|
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
|
|
code: ErrorCode::INVALID_REQUEST,
|
|
message: "Invalid Request",
|
|
data: error,
|
|
}) if error
|
|
|
|
method_name = request[:method]
|
|
params = request[:params]
|
|
|
|
unless valid_params?(params)
|
|
return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
|
|
code: ErrorCode::INVALID_PARAMS,
|
|
message: "Invalid params",
|
|
data: "Method parameters must be an array or an object or null",
|
|
})
|
|
end
|
|
|
|
begin
|
|
method = method_finder.call(method_name)
|
|
|
|
if method.nil?
|
|
return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
|
|
code: ErrorCode::METHOD_NOT_FOUND,
|
|
message: "Method not found",
|
|
data: method_name,
|
|
})
|
|
end
|
|
|
|
result = method.call(params)
|
|
|
|
success_response(id: id, result: result)
|
|
rescue StandardError => e
|
|
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
|
|
code: ErrorCode::INTERNAL_ERROR,
|
|
message: "Internal error",
|
|
data: e.message,
|
|
})
|
|
end
|
|
end
|
|
|
|
def valid_version?(version)
|
|
version == Version::V2_0
|
|
end
|
|
|
|
def valid_id?(id, pattern = nil)
|
|
return true if id.nil? || id.is_a?(Integer)
|
|
return false unless id.is_a?(String)
|
|
|
|
pattern ? id.match?(pattern) : true
|
|
end
|
|
|
|
def valid_method_name?(method)
|
|
method.is_a?(String) && !method.start_with?("rpc.")
|
|
end
|
|
|
|
def valid_params?(params)
|
|
params.nil? || params.is_a?(Array) || params.is_a?(Hash)
|
|
end
|
|
|
|
def success_response(id:, result:)
|
|
{
|
|
jsonrpc: Version::V2_0,
|
|
id: id,
|
|
result: result,
|
|
} unless id.nil?
|
|
end
|
|
|
|
def error_response(id:, id_validation_pattern:, error:)
|
|
{
|
|
jsonrpc: Version::V2_0,
|
|
id: valid_id?(id, id_validation_pattern) ? id : nil,
|
|
error: error.compact,
|
|
} unless id.nil?
|
|
end
|
|
end
|