ruby-sdk/lib/json_rpc_handler.rb
Koichi ITO aa8bbad491 Support Ruby 2.7 - Ruby 3.1
## 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.
2026-01-06 10:38:56 +09:00

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