Skip to content

Commit 98f0916

Browse files
authored
Spec-compliant Streamable HTTP transport + Rack 3 headers (#102)
1 parent 863174c commit 98f0916

4 files changed

Lines changed: 333 additions & 46 deletions

File tree

lib/tidewave.rb

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ def call(env)
7272
when [ "POST", [ TIDEWAVE_ROUTE, MCP_ROUTE ] ]
7373
mcp_endpoint(request)
7474
else
75-
not_found
75+
# The MCP Streamable HTTP transport requires the MCP endpoint to answer
76+
# non-POST methods with 405 (GET without SSE support, DELETE, etc.)
77+
path == [ TIDEWAVE_ROUTE, MCP_ROUTE ] ? method_not_allowed() : not_found()
7678
end
7779
else
7880
strip_x_frame_options(@app.call(env))
@@ -83,7 +85,7 @@ def call(env)
8385

8486
def strip_x_frame_options(response)
8587
status, headers, body = response
86-
headers.delete("X-Frame-Options")
88+
headers.delete("x-frame-options")
8789
[ status, headers, body ]
8890
end
8991

@@ -105,27 +107,46 @@ def home_endpoint(_request)
105107
end
106108

107109
def config_endpoint(request)
108-
json_response(config_data(request), headers: { "Access-Control-Allow-Origin" => "*" })
110+
json_response(config_data(request), headers: { "access-control-allow-origin" => "*" })
109111
end
110112

111113
def mcp_endpoint(request)
112-
body = request.body.read
114+
message = JSON.parse(request.body.read)
113115

114-
message = JSON.parse(body)
115-
validation_error = validate_jsonrpc_message(message)
116-
return jsonrpc_error_response(nil, -32600, validation_error) if validation_error
117-
118-
response = handle_mcp_message(message)
119-
return json_response({ "status" => "ok" }, status: 202) if response.nil?
120-
121-
json_response(response)
116+
if message.is_a?(Array)
117+
handle_mcp_batch(message)
118+
else
119+
handle_mcp_single(message)
120+
end
122121
rescue JSON::ParserError
123-
jsonrpc_error_response(nil, -32700, "Parse error")
122+
jsonrpc_error_response(nil, -32700, "Parse error", status: 400)
124123
rescue StandardError => error
125124
@logger&.error("Error handling MCP request: #{error.message}")
126125
jsonrpc_error_response(nil, -32603, "Internal error")
127126
end
128127

128+
def handle_mcp_single(message)
129+
validation_error = validate_jsonrpc_message(message)
130+
return jsonrpc_error_response(nil, -32600, validation_error, status: 400) if validation_error
131+
132+
response = handle_mcp_message(message)
133+
response.nil? ? accepted_response : json_response(response)
134+
end
135+
136+
def handle_mcp_batch(messages)
137+
return jsonrpc_error_response(nil, -32600, "Invalid Request", status: 400) if messages.empty?
138+
139+
responses = messages.map { |message| handle_mcp_batch_message(message) }.compact
140+
responses.empty? ? accepted_response : json_response(responses)
141+
end
142+
143+
def handle_mcp_batch_message(message)
144+
validation_error = validate_jsonrpc_message(message)
145+
return jsonrpc_error_response_body(nil, -32600, validation_error) if validation_error
146+
147+
handle_mcp_message(message)
148+
end
149+
129150
def config_data(request)
130151
{
131152
"project_name" => @options[:project_name],
@@ -151,14 +172,27 @@ def not_found
151172
text_response(404, "Not Found")
152173
end
153174

175+
def method_not_allowed
176+
status, headers, body = text_response(405, "Method Not Allowed")
177+
[ status, headers.merge("allow" => "POST"), body ]
178+
end
179+
180+
def accepted_response
181+
[ 202, { "content-length" => "0" }, [] ]
182+
end
183+
154184
def text_response(status, message)
155185
[ status, response_headers("text/plain; charset=utf-8", message), [ message ] ]
156186
end
157187

188+
# Rack 3 requires response header keys to be lowercase. Capitalized keys break
189+
# case-sensitive middleware such as Rack::Deflater, which strips "content-length"
190+
# before gzipping; a surviving "Content-Length" leaves a stale (uncompressed)
191+
# length on the compressed body and hangs spec-compliant HTTP clients.
158192
def response_headers(content_type, body)
159193
{
160-
"Content-Type" => content_type,
161-
"Content-Length" => body.bytesize.to_s
194+
"content-type" => content_type,
195+
"content-length" => body.bytesize.to_s
162196
}
163197
end
164198

@@ -192,22 +226,25 @@ def validate_jsonrpc_message(message)
192226

193227
has_id = message.key?("id")
194228
has_method = message.key?("method")
195-
has_result = message.key?("result")
229+
has_result = message.key?("result") || message.key?("error")
196230

197231
return nil if has_method
198232
return nil if has_id && has_result
199233

200234
"Invalid JSON-RPC message structure"
201235
end
202236

237+
# Returns the JSON-RPC response for a request, or nil for messages that
238+
# must not be replied to (notifications and client-sent responses), which
239+
# the transport acknowledges with 202 Accepted.
203240
def handle_mcp_message(message)
241+
return nil unless message.key?("method") && message.key?("id")
242+
204243
method = message["method"]
205244
request_id = message["id"]
206245
params = message["params"].is_a?(Hash) ? message["params"] : {}
207246

208247
case method
209-
when "notifications/initialized", "notifications/cancelled"
210-
nil
211248
when "ping"
212249
jsonrpc_success_response_body(request_id, {})
213250
when "initialize"
@@ -216,6 +253,12 @@ def handle_mcp_message(message)
216253
jsonrpc_success_response_body(request_id, { "tools" => tool_definitions })
217254
when "tools/call"
218255
handle_tool_call(request_id, params)
256+
when "prompts/list"
257+
jsonrpc_success_response_body(request_id, { "prompts" => [] })
258+
when "resources/list"
259+
jsonrpc_success_response_body(request_id, { "resources" => [] })
260+
when "resources/templates/list"
261+
jsonrpc_success_response_body(request_id, { "resourceTemplates" => [] })
219262
else
220263
{
221264
"jsonrpc" => "2.0",
@@ -233,14 +276,9 @@ def handle_initialize(request_id, params)
233276
client_version = params["protocolVersion"]
234277
return jsonrpc_error_response_body(request_id, -32602, "Protocol version is required") if client_version.nil? || client_version.empty?
235278

236-
if client_version < PROTOCOL_VERSION
237-
return jsonrpc_error_response_body(
238-
request_id,
239-
-32602,
240-
"Unsupported protocol version. Server supports #{PROTOCOL_VERSION} or later"
241-
)
242-
end
243-
279+
# Version negotiation: when the client requests a version we don't
280+
# support, we respond with the version we do support and the client
281+
# decides whether to continue or disconnect.
244282
jsonrpc_success_response_body(request_id, {
245283
"protocolVersion" => PROTOCOL_VERSION,
246284
"capabilities" => { "tools" => { "listChanged" => false } },
@@ -276,8 +314,8 @@ def jsonrpc_success_response_body(request_id, result)
276314
}
277315
end
278316

279-
def jsonrpc_error_response(request_id, code, message)
280-
json_response(jsonrpc_error_response_body(request_id, code, message))
317+
def jsonrpc_error_response(request_id, code, message, status: 200)
318+
json_response(jsonrpc_error_response_body(request_id, code, message), status: status)
281319
end
282320

283321
def jsonrpc_error_response_body(request_id, code, message)

0 commit comments

Comments
 (0)