Skip to content

Commit 0a22bf1

Browse files
committed
Merge remote-tracking branch 'upstream/main' into add-meta-extraction-support
2 parents ad9677a + 400a56e commit 0a22bf1

File tree

12 files changed

+1008
-32
lines changed

12 files changed

+1008
-32
lines changed

Gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ gem "rubocop-rake", require: false
1111
gem "rubocop-shopify", require: false
1212

1313
gem "puma", ">= 5.0.0"
14-
gem "rack", ">= 2.0.0"
1514
gem "rackup", ">= 2.1.0"
1615

1716
gem "activesupport"

README.md

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,19 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
126126

127127
- **stdio**: Notifications are sent as JSON-RPC 2.0 messages to stdout
128128
- **Streamable HTTP**: Notifications are sent as JSON-RPC 2.0 messages over HTTP with streaming (chunked transfer or SSE)
129+
- **Stateless Streamable HTTP**: Notifications are not supported and all calls are request/response interactions; allows for easy multi-node deployment.
129130

130131
#### Usage Example
131132

132133
```ruby
133134
server = MCP::Server.new(name: "my_server")
135+
136+
# Default Streamable HTTP - session oriented
134137
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
138+
139+
# OR Stateless Streamable HTTP - session-less
140+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
141+
135142
server.transport = transport
136143

137144
# When tools change, notify clients
@@ -969,16 +976,3 @@ The client provides a wrapper class for tools returned by the server:
969976
- `MCP::Client::Tool` - Represents a single tool with its metadata
970977

971978
This class provides easy access to tool properties like name, description, input schema, and output schema.
972-
973-
## Releases
974-
975-
This gem is published to [RubyGems.org](https://rubygems.org/gems/mcp)
976-
977-
Releases are triggered by PRs to the `main` branch updating the version number in `lib/mcp/version.rb`.
978-
979-
1. **Update the version number** in `lib/mcp/version.rb`, following [semver](https://semver.org/)
980-
1. **Update CHANGELOG.md**, backfilling the changes since the last release if necessary, and adding a new section for the new version, clearing out the Unreleased section
981-
1. **Create a PR and get approval from a maintainer**
982-
1. **Merge your PR to the main branch** - This will automatically trigger the release workflow via GitHub Actions
983-
984-
When changes are merged to the `main` branch, the GitHub Actions workflow (`.github/workflows/release.yml`) is triggered and the gem is published to RubyGems.

RELEASE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
## Releases
2+
3+
This gem is published to [RubyGems.org](https://rubygems.org/gems/mcp)
4+
5+
Releases are triggered by PRs to the `main` branch updating the version number in `lib/mcp/version.rb`.
6+
7+
1. **Update the version number** in `lib/mcp/version.rb`, following [semver](https://semver.org/)
8+
2. **Update CHANGELOG.md**, backfilling the changes since the last release if necessary, and adding a new section for the new version, clearing out the Unreleased section
9+
3. **Create a PR and get approval from a maintainer**
10+
4. **Merge your PR to the main branch** - This will automatically trigger the release workflow via GitHub Actions
11+
12+
When changes are merged to the `main` branch, the GitHub Actions workflow (`.github/workflows/release.yml`) is triggered and the gem is published to RubyGems.

examples/http_server.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
44
require "mcp"
5-
require "rack"
65
require "rackup"
76
require "json"
87
require "logger"

examples/streamable_http_server.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
44
require "mcp"
5-
require "rack"
65
require "rackup"
76
require "json"
87
require "logger"

lib/json_rpc_handler.rb

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
5+
module JsonRpcHandler
6+
class Version
7+
V1_0 = "1.0"
8+
V2_0 = "2.0"
9+
end
10+
11+
class ErrorCode
12+
INVALID_REQUEST = -32600
13+
METHOD_NOT_FOUND = -32601
14+
INVALID_PARAMS = -32602
15+
INTERNAL_ERROR = -32603
16+
PARSE_ERROR = -32700
17+
end
18+
19+
DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/
20+
21+
extend self
22+
23+
def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
24+
if request.is_a?(Array)
25+
return error_response(id: :unknown_id, id_validation_pattern:, error: {
26+
code: ErrorCode::INVALID_REQUEST,
27+
message: "Invalid Request",
28+
data: "Request is an empty array",
29+
}) if request.empty?
30+
31+
# Handle batch requests
32+
responses = request.map { |req| process_request(req, id_validation_pattern:, &method_finder) }.compact
33+
34+
# A single item is hoisted out of the array
35+
return responses.first if responses.one?
36+
37+
# An empty array yields nil
38+
responses if responses.any?
39+
elsif request.is_a?(Hash)
40+
# Handle single request
41+
process_request(request, id_validation_pattern:, &method_finder)
42+
else
43+
error_response(id: :unknown_id, id_validation_pattern:, error: {
44+
code: ErrorCode::INVALID_REQUEST,
45+
message: "Invalid Request",
46+
data: "Request must be an array or a hash",
47+
})
48+
end
49+
end
50+
51+
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
52+
begin
53+
request = JSON.parse(request_json, symbolize_names: true)
54+
response = handle(request, id_validation_pattern:, &method_finder)
55+
rescue JSON::ParserError
56+
response = error_response(id: :unknown_id, id_validation_pattern:, error: {
57+
code: ErrorCode::PARSE_ERROR,
58+
message: "Parse error",
59+
data: "Invalid JSON",
60+
})
61+
end
62+
63+
response&.to_json
64+
end
65+
66+
def process_request(request, id_validation_pattern:, &method_finder)
67+
id = request[:id]
68+
69+
error = if !valid_version?(request[:jsonrpc])
70+
"JSON-RPC version must be 2.0"
71+
elsif !valid_id?(request[:id], id_validation_pattern)
72+
"Request ID must match validation pattern, or be an integer or null"
73+
elsif !valid_method_name?(request[:method])
74+
'Method name must be a string and not start with "rpc."'
75+
end
76+
77+
return error_response(id: :unknown_id, id_validation_pattern:, error: {
78+
code: ErrorCode::INVALID_REQUEST,
79+
message: "Invalid Request",
80+
data: error,
81+
}) if error
82+
83+
method_name = request[:method]
84+
params = request[:params]
85+
86+
unless valid_params?(params)
87+
return error_response(id:, id_validation_pattern:, error: {
88+
code: ErrorCode::INVALID_PARAMS,
89+
message: "Invalid params",
90+
data: "Method parameters must be an array or an object or null",
91+
})
92+
end
93+
94+
begin
95+
method = method_finder.call(method_name)
96+
97+
if method.nil?
98+
return error_response(id:, id_validation_pattern:, error: {
99+
code: ErrorCode::METHOD_NOT_FOUND,
100+
message: "Method not found",
101+
data: method_name,
102+
})
103+
end
104+
105+
result = method.call(params)
106+
107+
success_response(id:, result:)
108+
rescue StandardError => e
109+
error_response(id:, id_validation_pattern:, error: {
110+
code: ErrorCode::INTERNAL_ERROR,
111+
message: "Internal error",
112+
data: e.message,
113+
})
114+
end
115+
end
116+
117+
def valid_version?(version)
118+
version == Version::V2_0
119+
end
120+
121+
def valid_id?(id, pattern = nil)
122+
return true if id.nil? || id.is_a?(Integer)
123+
return false unless id.is_a?(String)
124+
125+
pattern ? id.match?(pattern) : true
126+
end
127+
128+
def valid_method_name?(method)
129+
method.is_a?(String) && !method.start_with?("rpc.")
130+
end
131+
132+
def valid_params?(params)
133+
params.nil? || params.is_a?(Array) || params.is_a?(Hash)
134+
end
135+
136+
def success_response(id:, result:)
137+
{
138+
jsonrpc: Version::V2_0,
139+
id:,
140+
result:,
141+
} unless id.nil?
142+
end
143+
144+
def error_response(id:, id_validation_pattern:, error:)
145+
{
146+
jsonrpc: Version::V2_0,
147+
id: valid_id?(id, id_validation_pattern) ? id : nil,
148+
error: error.compact,
149+
} unless id.nil?
150+
end
151+
end

lib/mcp.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require_relative "json_rpc_handler"
34
require_relative "mcp/configuration"
45
require_relative "mcp/content"
56
require_relative "mcp/instrumentation"

lib/mcp/server.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

3-
require "json_rpc_handler"
3+
require_relative "../json_rpc_handler"
44
require_relative "instrumentation"
55
require_relative "methods"
66

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ module MCP
88
class Server
99
module Transports
1010
class StreamableHTTPTransport < Transport
11-
def initialize(server)
12-
super
11+
def initialize(server, stateless: false)
12+
super(server)
1313
# { session_id => { stream: stream_object }
1414
@sessions = {}
1515
@mutex = Mutex.new
16+
17+
@stateless = stateless
1618
end
1719

1820
def handle_request(request)
@@ -24,7 +26,7 @@ def handle_request(request)
2426
when "DELETE"
2527
handle_delete(request)
2628
else
27-
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
29+
method_not_allowed_response
2830
end
2931
end
3032

@@ -35,6 +37,9 @@ def close
3537
end
3638

3739
def send_notification(method, params = nil, session_id: nil)
40+
# Stateless mode doesn't support notifications
41+
raise "Stateless mode does not support notifications" if @stateless
42+
3843
notification = {
3944
jsonrpc: "2.0",
4045
method:,
@@ -119,6 +124,10 @@ def handle_post(request)
119124
end
120125

121126
def handle_get(request)
127+
if @stateless
128+
return method_not_allowed_response
129+
end
130+
122131
session_id = extract_session_id(request)
123132

124133
return missing_session_id_response unless session_id
@@ -128,6 +137,13 @@ def handle_get(request)
128137
end
129138

130139
def handle_delete(request)
140+
success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
141+
142+
if @stateless
143+
# Stateless mode doesn't support sessions, so we can just return a success response
144+
return success_response
145+
end
146+
131147
session_id = request.env["HTTP_MCP_SESSION_ID"]
132148

133149
return [
@@ -137,7 +153,7 @@ def handle_delete(request)
137153
] unless session_id
138154

139155
cleanup_session(session_id)
140-
[200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
156+
success_response
141157
end
142158

143159
def cleanup_session(session_id)
@@ -177,21 +193,26 @@ def response?(body)
177193
end
178194

179195
def handle_initialization(body_string, body)
180-
session_id = SecureRandom.uuid
196+
session_id = nil
181197

182-
@mutex.synchronize do
183-
@sessions[session_id] = {
184-
stream: nil,
185-
}
198+
unless @stateless
199+
session_id = SecureRandom.uuid
200+
201+
@mutex.synchronize do
202+
@sessions[session_id] = {
203+
stream: nil,
204+
}
205+
end
186206
end
187207

188208
response = @server.handle_json(body_string)
189209

190210
headers = {
191211
"Content-Type" => "application/json",
192-
"Mcp-Session-Id" => session_id,
193212
}
194213

214+
headers["Mcp-Session-Id"] = session_id if session_id
215+
195216
[200, headers, [response]]
196217
end
197218

@@ -200,21 +221,32 @@ def handle_accepted
200221
end
201222

202223
def handle_regular_request(body_string, session_id)
203-
# If session ID is provided, but not in the sessions hash, return an error
204-
if session_id && !@sessions.key?(session_id)
205-
return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
224+
unless @stateless
225+
# If session ID is provided, but not in the sessions hash, return an error
226+
if session_id && !@sessions.key?(session_id)
227+
return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
228+
end
206229
end
207230

208-
response = @server.handle_json(body_string)
231+
response = @server.handle_json(body_string) || ""
232+
233+
# Stream can be nil since stateless mode doesn't retain streams
209234
stream = get_session_stream(session_id) if session_id
210235

211236
if stream
212237
send_response_to_stream(stream, response, session_id)
238+
elsif response.nil? && notification_request?(body_string)
239+
[202, { "Content-Type" => "application/json" }, [response]]
213240
else
214241
[200, { "Content-Type" => "application/json" }, [response]]
215242
end
216243
end
217244

245+
def notification_request?(body_string)
246+
body = parse_request_body(body_string)
247+
body.is_a?(Hash) && body["method"].start_with?("notifications/")
248+
end
249+
218250
def get_session_stream(session_id)
219251
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
220252
end
@@ -236,6 +268,10 @@ def session_exists?(session_id)
236268
@mutex.synchronize { @sessions.key?(session_id) }
237269
end
238270

271+
def method_not_allowed_response
272+
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
273+
end
274+
239275
def missing_session_id_response
240276
[400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
241277
end

0 commit comments

Comments
 (0)