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.
This commit is contained in:
Koichi ITO 2025-12-21 23:21:19 +09:00
commit aa8bbad491
26 changed files with 106 additions and 100 deletions

View file

@ -7,6 +7,9 @@ jobs:
strategy:
matrix:
entry:
- { ruby: '2.7', allowed-failure: false }
- { ruby: '3.0', allowed-failure: false }
- { ruby: '3.1', allowed-failure: false }
- { ruby: '3.2', allowed-failure: false }
- { ruby: '3.3', allowed-failure: false }
- { ruby: '3.4', allowed-failure: false }
@ -29,7 +32,7 @@ jobs:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2 # Specify the oldest supported Ruby version.
ruby-version: 4.0 # Specify the latest supported Ruby version.
bundler-cache: true
- run: bundle exec rake rubocop
@ -40,6 +43,6 @@ jobs:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2 # Specify the oldest supported Ruby version.
ruby-version: 4.0 # Specify the latest supported Ruby version.
bundler-cache: true
- run: bundle exec yard --no-output

View file

@ -5,6 +5,9 @@ plugins:
- rubocop-minitest
- rubocop-rake
AllCops:
TargetRubyVersion: 2.7
Gemspec/DevelopmentDependencies:
Enabled: true

View file

@ -6,7 +6,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
## Dev environment setup
- Ruby 3.2.0+ required
- Ruby 3.2.0+ required to run the full test suite, including all Sorbet-related features
- Run `bundle install` to install dependencies
- Dependencies: `json-schema` >= 4.1 - Schema validation

View file

@ -8,17 +8,18 @@ gemspec
# Specify development dependencies below
gem "rubocop-minitest", require: false
gem "rubocop-rake", require: false
gem "rubocop-shopify", require: false
gem "rubocop-shopify", ">= 2.18", require: false if RUBY_VERSION >= "3.1"
gem "puma", ">= 5.0.0"
gem "rackup", ">= 2.1.0"
gem "activesupport"
gem "debug"
# Fix io-console install error when Ruby 3.0.
gem "debug" if RUBY_VERSION >= "3.1"
gem "rake", "~> 13.0"
gem "sorbet-static-and-runtime"
gem "sorbet-static-and-runtime" if RUBY_VERSION >= "3.0"
gem "yard", "~> 0.9"
gem "yard-sorbet", "~> 0.9"
gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1"
group :test do
gem "faraday", ">= 2.0"

View file

@ -22,14 +22,14 @@ module JsonRpcHandler
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:, error: {
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:, &method_finder) }.compact
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?
@ -38,9 +38,9 @@ module JsonRpcHandler
responses if responses.any?
elsif request.is_a?(Hash)
# Handle single request
process_request(request, id_validation_pattern:, &method_finder)
process_request(request, id_validation_pattern: id_validation_pattern, &method_finder)
else
error_response(id: :unknown_id, id_validation_pattern:, error: {
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",
@ -51,9 +51,9 @@ module JsonRpcHandler
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:, &method_finder)
response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder)
rescue JSON::ParserError
response = error_response(id: :unknown_id, id_validation_pattern:, error: {
response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::PARSE_ERROR,
message: "Parse error",
data: "Invalid JSON",
@ -74,7 +74,7 @@ module JsonRpcHandler
'Method name must be a string and not start with "rpc."'
end
return error_response(id: :unknown_id, id_validation_pattern:, error: {
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_REQUEST,
message: "Invalid Request",
data: error,
@ -84,7 +84,7 @@ module JsonRpcHandler
params = request[:params]
unless valid_params?(params)
return error_response(id:, id_validation_pattern:, error: {
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",
@ -95,7 +95,7 @@ module JsonRpcHandler
method = method_finder.call(method_name)
if method.nil?
return error_response(id:, id_validation_pattern:, error: {
return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::METHOD_NOT_FOUND,
message: "Method not found",
data: method_name,
@ -104,9 +104,9 @@ module JsonRpcHandler
result = method.call(params)
success_response(id:, result:)
success_response(id: id, result: result)
rescue StandardError => e
error_response(id:, id_validation_pattern:, error: {
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INTERNAL_ERROR,
message: "Internal error",
data: e.message,
@ -136,8 +136,8 @@ module JsonRpcHandler
def success_response(id:, result:)
{
jsonrpc: Version::V2_0,
id:,
result:,
id: id,
result: result,
} unless id.nil?
end

View file

@ -18,42 +18,42 @@ module MCP
rescue Faraday::BadRequestError => e
raise RequestHandlerError.new(
"The #{method} request is invalid",
{ method:, params: },
{ method: method, params: params },
error_type: :bad_request,
original_error: e,
)
rescue Faraday::UnauthorizedError => e
raise RequestHandlerError.new(
"You are unauthorized to make #{method} requests",
{ method:, params: },
{ method: method, params: params },
error_type: :unauthorized,
original_error: e,
)
rescue Faraday::ForbiddenError => e
raise RequestHandlerError.new(
"You are forbidden to make #{method} requests",
{ method:, params: },
{ method: method, params: params },
error_type: :forbidden,
original_error: e,
)
rescue Faraday::ResourceNotFound => e
raise RequestHandlerError.new(
"The #{method} request is not found",
{ method:, params: },
{ method: method, params: params },
error_type: :not_found,
original_error: e,
)
rescue Faraday::UnprocessableEntityError => e
raise RequestHandlerError.new(
"The #{method} request is unprocessable",
{ method:, params: },
{ method: method, params: params },
error_type: :unprocessable_entity,
original_error: e,
)
rescue Faraday::Error => e # Catch-all
raise RequestHandlerError.new(
"Internal error handling #{method} request",
{ method:, params: },
{ method: method, params: params },
error_type: :internal_error,
original_error: e,
)

View file

@ -85,10 +85,10 @@ module MCP
validate_tool_call_arguments = other.validate_tool_call_arguments
Configuration.new(
exception_reporter:,
instrumentation_callback:,
protocol_version:,
validate_tool_call_arguments:,
exception_reporter: exception_reporter,
instrumentation_callback: instrumentation_callback,
protocol_version: protocol_version,
validate_tool_call_arguments: validate_tool_call_arguments,
)
end

View file

@ -11,7 +11,7 @@ module MCP
end
def to_h
{ text:, annotations:, type: "text" }.compact
{ text: text, annotations: annotations, type: "text" }.compact
end
end
@ -25,7 +25,7 @@ module MCP
end
def to_h
{ data:, mime_type:, annotations:, type: "image" }.compact
{ data: data, mime_type: mime_type, annotations: annotations, type: "image" }.compact
end
end
end

View file

@ -6,7 +6,7 @@ module MCP
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
begin
@instrumentation_data = {}
add_instrumentation_data(method:)
add_instrumentation_data(method: method)
result = yield block

View file

@ -96,7 +96,7 @@ module MCP
icons icons
arguments arguments
define_singleton_method(:template) do |args, server_context: nil|
instance_exec(args, server_context:, &block)
instance_exec(args, server_context: server_context, &block)
end
meta meta
end

View file

@ -11,7 +11,7 @@ module MCP
end
def to_h
{ role:, content: content.to_h }.compact
{ role: role, content: content.to_h }.compact
end
end
end

View file

@ -11,7 +11,7 @@ module MCP
end
def to_h
{ description:, messages: messages.map(&:to_h) }.compact
{ description: description, messages: messages.map(&:to_h) }.compact
end
end
end

View file

@ -11,7 +11,7 @@ module MCP
end
def to_h
{ uri:, mime_type: }.compact
{ uri: uri, mime_type: mime_type }.compact
end
end

View file

@ -10,7 +10,7 @@ module MCP
end
def to_h
{ resource: resource.to_h, annotations: }.compact
{ resource: resource.to_h, annotations: annotations }.compact
end
end
end

View file

@ -114,7 +114,7 @@ module MCP
end
def define_tool(name: nil, title: nil, description: nil, input_schema: nil, annotations: nil, meta: nil, &block)
tool = Tool.define(name:, title:, description:, input_schema:, annotations:, meta:, &block)
tool = Tool.define(name: name, title: title, description: description, input_schema: input_schema, annotations: annotations, meta: meta, &block)
tool_name = tool.name_value
@tool_names << tool_name
@ -124,7 +124,7 @@ module MCP
end
def define_prompt(name: nil, title: nil, description: nil, arguments: [], &block)
prompt = Prompt.define(name:, title:, description:, arguments:, &block)
prompt = Prompt.define(name: name, title: title, description: description, arguments: arguments, &block)
@prompts[prompt.name_value] = prompt
validate!
@ -289,11 +289,11 @@ module MCP
def server_info
@server_info ||= {
description:,
icons:,
name:,
title:,
version:,
description: description,
icons: icons,
name: name,
title: title,
version: version,
websiteUrl: website_url,
}.compact
end
@ -316,13 +316,13 @@ module MCP
tool = tools[tool_name]
unless tool
add_instrumentation_data(tool_name:, error: :tool_not_found)
add_instrumentation_data(tool_name: tool_name, error: :tool_not_found)
return error_tool_response("Tool not found: #{tool_name}")
end
arguments = request[:arguments] || {}
add_instrumentation_data(tool_name:)
add_instrumentation_data(tool_name: tool_name)
if tool.input_schema&.missing_required_arguments?(arguments)
add_instrumentation_data(error: :missing_required_arguments)
@ -360,7 +360,7 @@ module MCP
raise RequestHandlerError.new("Prompt not found #{prompt_name}", request, error_type: :prompt_not_found)
end
add_instrumentation_data(prompt_name:)
add_instrumentation_data(prompt_name: prompt_name)
prompt_args = request[:arguments]
prompt.validate_arguments!(prompt_args)

View file

@ -42,7 +42,7 @@ module MCP
notification = {
jsonrpc: "2.0",
method:,
method: method,
}
notification[:params] = params if params

View file

@ -19,7 +19,7 @@ module MCP
idempotentHint: idempotent_hint,
openWorldHint: open_world_hint,
readOnlyHint: read_only_hint,
title:,
title: title,
}.compact
end
end

View file

@ -23,7 +23,7 @@ module MCP
end
def to_h
{ content:, isError: error?, structuredContent: @structured_content }.compact
{ content: content, isError: error?, structuredContent: @structured_content }.compact
end
end
end

View file

@ -13,7 +13,8 @@ Gem::Specification.new do |spec|
spec.homepage = "https://github.com/modelcontextprotocol/ruby-sdk"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2.0"
# Since this library is used by a broad range of users, it does not align its support policy with Ruby's EOL.
spec.required_ruby_version = ">= 2.7.0"
spec.metadata["allowed_push_host"] = "https://rubygems.org"
spec.metadata["changelog_uri"] = "https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v#{spec.version}"

View file

@ -94,7 +94,7 @@ describe JsonRpcHandler do
register("add") { |params| params[:a] + params[:b] }
id = "request-123_abc"
handle jsonrpc: "2.0", id:, method: "add", params: { a: 1, b: 2 }
handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 }
assert_rpc_success expected_result: 3
assert_equal id, @response[:id]
@ -104,7 +104,7 @@ describe JsonRpcHandler do
register("add") { |params| params[:a] + params[:b] }
id = 42
handle jsonrpc: "2.0", id:, method: "add", params: { a: 1, b: 2 }
handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 }
assert_rpc_success expected_result: 3
assert_equal id, @response[:id]
@ -124,7 +124,7 @@ describe JsonRpcHandler do
register("add") { |params| params[:a] + params[:b] }
id = "request-123_ABC"
handle jsonrpc: "2.0", id:, method: "add", params: { a: 1, b: 2 }
handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 }
assert_rpc_success expected_result: 3
assert_equal id, @response[:id]
@ -134,7 +134,7 @@ describe JsonRpcHandler do
register("add") { |params| params[:a] + params[:b] }
id = "550e8400-e29b-41d4-a716-446655440000"
handle jsonrpc: "2.0", id:, method: "add", params: { a: 1, b: 2 }
handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 }
assert_rpc_success expected_result: 3
assert_equal id, @response[:id]

Some files were not shown because too many files have changed in this diff Show more