forked from modelcontextprotocol/ruby-sdk
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhttp.rb
More file actions
226 lines (197 loc) · 8.34 KB
/
http.rb
File metadata and controls
226 lines (197 loc) · 8.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# frozen_string_literal: true
require_relative "../methods"
module MCP
class Client
# TODO: HTTP GET for SSE streaming is not yet implemented.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
# TODO: Resumability and redelivery with Last-Event-ID is not yet implemented.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery
class HTTP
ACCEPT_HEADER = "application/json, text/event-stream"
SESSION_ID_HEADER = "Mcp-Session-Id"
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
attr_reader :url, :session_id, :protocol_version
def initialize(url:, headers: {}, &block)
@url = url
@headers = headers
@faraday_customizer = block
@session_id = nil
@protocol_version = nil
end
# Sends a JSON-RPC request and returns the parsed response body.
# After a successful `initialize` handshake, the session ID and protocol
# version returned by the server are captured and automatically included
# on subsequent requests.
def send_request(request:)
method = request[:method] || request["method"]
params = request[:params] || request["params"]
response = client.post("", request, session_headers)
body = parse_response_body(response, method, params)
capture_session_info(method, response, body)
body
rescue Faraday::BadRequestError => e
raise RequestHandlerError.new(
"The #{method} request is invalid",
{ 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: method, params: params },
error_type: :unauthorized,
original_error: e,
)
rescue Faraday::ForbiddenError => e
raise RequestHandlerError.new(
"You are forbidden to make #{method} requests",
{ method: method, params: params },
error_type: :forbidden,
original_error: e,
)
rescue Faraday::ResourceNotFound => e
# Per spec, 404 is the session-expired signal only when the request
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
if @session_id
clear_session
raise SessionExpiredError.new(
"The #{method} request is not found",
{ method: method, params: params },
original_error: e,
)
else
raise RequestHandlerError.new(
"The #{method} request is not found",
{ method: method, params: params },
error_type: :not_found,
original_error: e,
)
end
rescue Faraday::UnprocessableEntityError => e
raise RequestHandlerError.new(
"The #{method} request is unprocessable",
{ 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: method, params: params },
error_type: :internal_error,
original_error: e,
)
end
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
# with the current `Mcp-Session-Id` header, and clears locally tracked
# session state afterward. No-op when no session has been established.
#
# Per spec, the server MAY respond with HTTP 405 Method Not Allowed when
# it does not support client-initiated termination, and returns 404 for
# a session it has already terminated. Both mean the session is gone —
# the desired end state. Other errors surface to the caller; local
# session state is cleared either way.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
def close
return unless @session_id
begin
client.delete("", nil, session_headers)
rescue Faraday::ClientError => e
raise unless [404, 405].include?(e.response&.dig(:status))
ensure
clear_session
end
end
private
attr_reader :headers
def client
require_faraday!
@client ||= Faraday.new(url) do |faraday|
faraday.request(:json)
faraday.response(:json)
faraday.response(:raise_error)
faraday.headers["Accept"] = ACCEPT_HEADER
headers.each do |key, value|
faraday.headers[key] = value
end
@faraday_customizer&.call(faraday)
end
end
# Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one)
# and `MCP-Protocol-Version` on all requests after `initialize`.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
def session_headers
request_headers = {}
request_headers[SESSION_ID_HEADER] = @session_id if @session_id
request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version
request_headers
end
def capture_session_info(method, response, body)
return unless method.to_s == Methods::INITIALIZE
# Faraday normalizes header names to lowercase.
session_id = response.headers[SESSION_ID_HEADER.downcase]
@session_id ||= session_id unless session_id.to_s.empty?
@protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
end
def clear_session
@session_id = nil
@protocol_version = nil
end
def require_faraday!
require "faraday"
rescue LoadError
raise LoadError, "The 'faraday' gem is required to use the MCP client HTTP transport. " \
"Add it to your Gemfile: gem 'faraday', '>= 2.0'" \
"See https://rubygems.org/gems/faraday for more details."
end
def require_event_stream_parser!
require "event_stream_parser"
rescue LoadError
raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
"Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
"See https://rubygems.org/gems/event_stream_parser for more details."
end
def parse_response_body(response, method, params)
# 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
return if response.status == 202
content_type = response.headers["Content-Type"]
if content_type&.include?("text/event-stream")
parse_sse_response(response.body, method, params)
elsif content_type&.include?("application/json")
response.body
else
raise RequestHandlerError.new(
"Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
{ method: method, params: params },
error_type: :unsupported_media_type,
)
end
end
def parse_sse_response(body, method, params)
require_event_stream_parser!
json_rpc_response = nil
parser = EventStreamParser::Parser.new
parser.feed(body.to_s) do |_type, data, _id|
next if data.empty?
begin
parsed = JSON.parse(data)
json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
rescue JSON::ParserError
next
end
end
return json_rpc_response if json_rpc_response
raise RequestHandlerError.new(
"No valid JSON-RPC response found in SSE stream",
{ method: method, params: params },
error_type: :parse_error,
)
end
end
end
end