Class: MCP::Client::HTTP
- Inherits:
-
Object
- Object
- MCP::Client::HTTP
- Defined in:
- lib/mcp/client/http.rb
Overview
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
Defined Under Namespace
Classes: InsecureURLError, OAuthURLGuard
Constant Summary collapse
- ACCEPT_HEADER =
"application/json, text/event-stream"- SESSION_ID_HEADER =
"Mcp-Session-Id"- PROTOCOL_VERSION_HEADER =
"MCP-Protocol-Version"
Instance Attribute Summary collapse
-
#oauth ⇒ Object
readonly
Returns the value of attribute oauth.
-
#protocol_version ⇒ Object
readonly
Returns the value of attribute protocol_version.
-
#server_info ⇒ Object
readonly
Returns the value of attribute server_info.
-
#session_id ⇒ Object
readonly
Returns the value of attribute session_id.
-
#url ⇒ Object
readonly
Returns the value of attribute url.
Instance Method Summary collapse
-
#close ⇒ Object
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.
-
#connect(client_info: nil, protocol_version: nil, capabilities: {}) ⇒ Hash
Performs the MCP ‘initialize` handshake: sends an `initialize` request followed by the required `notifications/initialized` notification.
-
#connected? ⇒ Boolean
Returns true once ‘connect` has completed the full handshake (`initialize` response received and `notifications/initialized` sent).
-
#initialize(url:, headers: {}, oauth: nil, &block) ⇒ HTTP
constructor
A new instance of HTTP.
-
#send_request(request:) ⇒ Object
Sends a JSON-RPC request and returns the parsed response body.
Constructor Details
#initialize(url:, headers: {}, oauth: nil, &block) ⇒ HTTP
Returns a new instance of HTTP.
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 |
# File 'lib/mcp/client/http.rb', line 57 def initialize(url:, headers: {}, oauth: nil, &block) if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url) # Mask credentials (userinfo) and query parameters before quoting the URL in the error message # so they cannot leak into logs. safe_url = MCP::Client::OAuth::Discovery.canonicalize_origin_and_path(url) raise InsecureURLError, "MCP URL #{safe_url.inspect} must use https or be a loopback http URL when an oauth provider is set; " \ "sending bearer tokens over plain http to a remote host would leak them on the wire." end @url = url @headers = headers @faraday_customizer = block @oauth = oauth # Snapshot the canonical URL at construction time. This single value # serves two related roles, both of which need to see the query string: # # - As the RFC 8707 `resource` claim sent on the authorization and # token requests (and as the base for PRM discovery URLs) - # matching the TS / Python SDKs' `resourceUrlFromServerUrl` / # `resource_url_from_server_url` so multi-tenant servers that scope # by `?tenant=...` round-trip correctly. # - As the comparison value for the URL guard middleware. Comparing # query strings as well as origin + path is required so a Faraday # middleware that rewrites `env.url.query` to a different tenant # cannot send the bearer token to the wrong audience while # the resource binding on the OAuth side stays correct. # # Saved only when `oauth:` is set so non-OAuth transports keep their # existing behavior. @oauth_server_url = oauth ? MCP::Client::OAuth::Discovery.canonicalize_url(url) : nil @session_id = nil @protocol_version = nil @server_info = nil @connected = false end |
Instance Attribute Details
#oauth ⇒ Object (readonly)
Returns the value of attribute oauth.
55 56 57 |
# File 'lib/mcp/client/http.rb', line 55 def oauth @oauth end |
#protocol_version ⇒ Object (readonly)
Returns the value of attribute protocol_version.
55 56 57 |
# File 'lib/mcp/client/http.rb', line 55 def protocol_version @protocol_version end |
#server_info ⇒ Object (readonly)
Returns the value of attribute server_info.
55 56 57 |
# File 'lib/mcp/client/http.rb', line 55 def server_info @server_info end |
#session_id ⇒ Object (readonly)
Returns the value of attribute session_id.
55 56 57 |
# File 'lib/mcp/client/http.rb', line 55 def session_id @session_id end |
#url ⇒ Object (readonly)
Returns the value of attribute url.
55 56 57 |
# File 'lib/mcp/client/http.rb', line 55 def url @url end |
Instance Method Details
#close ⇒ Object
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. modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
273 274 275 276 277 278 279 280 281 282 283 284 285 286 |
# File 'lib/mcp/client/http.rb', line 273 def close unless @session_id clear_session return end begin client.delete("", nil, session_headers) rescue Faraday::ClientError => e raise unless [404, 405].include?(e.response&.dig(:status)) ensure clear_session end end |
#connect(client_info: nil, protocol_version: nil, capabilities: {}) ⇒ Hash
Performs the MCP ‘initialize` handshake: sends an `initialize` request followed by the required `notifications/initialized` notification. The server’s ‘InitializeResult` (protocol version, capabilities, server info, instructions) is cached on the transport and returned.
Idempotent: a second call returns the cached ‘InitializeResult` without contacting the server. After `close`, state is cleared and `connect` will handshake again.
modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
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 |
# File 'lib/mcp/client/http.rb', line 112 def connect(client_info: nil, protocol_version: nil, capabilities: {}) return @server_info if connected? client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION response = send_request(request: { jsonrpc: JsonRpcHandler::Version::V2_0, id: SecureRandom.uuid, method: MCP::Methods::INITIALIZE, params: { protocolVersion: protocol_version, capabilities: capabilities, clientInfo: client_info, }, }) if response.is_a?(Hash) && response.key?("error") clear_session error = response["error"] raise RequestHandlerError.new( "Server initialization failed: #{error["message"]}", { method: MCP::Methods::INITIALIZE }, error_type: :internal_error, ) end unless response.is_a?(Hash) && response["result"].is_a?(Hash) clear_session raise RequestHandlerError.new( "Server initialization failed: missing result in response", { method: MCP::Methods::INITIALIZE }, error_type: :internal_error, ) end @server_info = response["result"] negotiated_protocol_version = @server_info["protocolVersion"] unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) clear_session raise RequestHandlerError.new( "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", { method: MCP::Methods::INITIALIZE }, error_type: :internal_error, ) end begin send_request(request: { jsonrpc: JsonRpcHandler::Version::V2_0, method: MCP::Methods::NOTIFICATIONS_INITIALIZED, }) rescue StandardError clear_session raise end @connected = true @server_info end |
#connected? ⇒ Boolean
Returns true once ‘connect` has completed the full handshake (`initialize` response received and `notifications/initialized` sent). Returns false before the first handshake and after `close`.
176 177 178 |
# File 'lib/mcp/client/http.rb', line 176 def connected? @connected end |
#send_request(request:) ⇒ Object
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.
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 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/mcp/client/http.rb', line 184 def send_request(request:) method = request[:method] || request["method"] params = request[:params] || request["params"] oauth_retried = false begin 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 # Run the OAuth flow at most once per `send_request` invocation. # The `oauth_retried` flag lives outside the `begin` so it survives `retry`, # ensuring a server returning 401 indefinitely raises rather than loops. if @oauth && !oauth_retried oauth_retried = true run_oauth_flow!(unauthorized_error: e) retry end 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 raise RequestHandlerError.new( "Internal error handling #{method} request", { method: method, params: params }, error_type: :internal_error, original_error: e, ) end end |