Skip to content

Commit 78ab59a

Browse files
RahulHeregophergogo
authored andcommitted
Handle OPTIONS preflight and send HTTP 202 for notifications (#197)
Add support for browser-based MCP clients by handling CORS preflight requests and sending proper HTTP responses for JSON-RPC notifications. Changes: - Register OPTIONS handlers for /mcp, /mcp/events, /rpc, /health, /info - Add default OPTIONS handler for any unregistered path - Add CORS headers to /health and /info endpoint responses - Send HTTP 202 Accepted response for JSON-RPC notifications (notifications don't have JSON-RPC responses but HTTP requires one)
1 parent 8d3445b commit 78ab59a

4 files changed

Lines changed: 203 additions & 1 deletion

File tree

src/filter/http_sse_filter_chain_factory.cc

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,27 @@ class HttpSseJsonRpcProtocolFilter
670670

671671
void onNotification(const jsonrpc::Notification& notification) override {
672672
mcp_callbacks_.onNotification(notification);
673+
674+
// For HTTP transport, send HTTP 202 Accepted response
675+
// JSON-RPC notifications don't have responses, but HTTP requires one
676+
if (is_server_ && write_callbacks_) {
677+
// Build minimal HTTP 202 response
678+
std::string http_response =
679+
"HTTP/1.1 202 Accepted\r\n"
680+
"Content-Length: 0\r\n"
681+
"Access-Control-Allow-Origin: *\r\n"
682+
"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
683+
"Access-Control-Allow-Headers: Content-Type, Authorization, Accept, "
684+
"Mcp-Session-Id, Mcp-Protocol-Version\r\n"
685+
"Connection: keep-alive\r\n"
686+
"\r\n";
687+
688+
OwnedBuffer response_buffer;
689+
response_buffer.add(http_response);
690+
write_callbacks_->connection().write(response_buffer, false);
691+
GOPHER_LOG_DEBUG(
692+
"HttpSseJsonRpcProtocolFilter: Sent HTTP 202 for notification");
693+
}
673694
}
674695

675696
void onResponse(const jsonrpc::Response& response) override {
@@ -779,13 +800,35 @@ class HttpSseJsonRpcProtocolFilter
779800
}
780801

781802
void setupRoutingHandlers() {
803+
// Register CORS preflight handler for all paths
804+
// Browser-based clients (like MCP Inspector) send OPTIONS before POST
805+
auto corsHandler = [](const HttpRoutingFilter::RequestContext& req) {
806+
HttpRoutingFilter::Response resp;
807+
resp.status_code = 204; // No Content
808+
resp.headers["Access-Control-Allow-Origin"] = "*";
809+
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
810+
resp.headers["Access-Control-Allow-Headers"] =
811+
"Content-Type, Authorization, Accept, Mcp-Session-Id, Mcp-Protocol-Version";
812+
resp.headers["Access-Control-Max-Age"] = "86400"; // Cache for 24 hours
813+
resp.headers["Content-Length"] = "0";
814+
return resp;
815+
};
816+
817+
// Register OPTIONS for common MCP paths
818+
routing_filter_->registerHandler("OPTIONS", "/mcp", corsHandler);
819+
routing_filter_->registerHandler("OPTIONS", "/mcp/events", corsHandler);
820+
routing_filter_->registerHandler("OPTIONS", "/rpc", corsHandler);
821+
routing_filter_->registerHandler("OPTIONS", "/health", corsHandler);
822+
routing_filter_->registerHandler("OPTIONS", "/info", corsHandler);
823+
782824
// Register health endpoint
783825
routing_filter_->registerHandler(
784826
"GET", "/health", [](const HttpRoutingFilter::RequestContext& req) {
785827
HttpRoutingFilter::Response resp;
786828
resp.status_code = 200;
787829
resp.headers["content-type"] = "application/json";
788830
resp.headers["cache-control"] = "no-cache";
831+
resp.headers["Access-Control-Allow-Origin"] = "*";
789832

790833
resp.body = R"({"status":"healthy","timestamp":)" +
791834
std::to_string(std::time(nullptr)) + "}";
@@ -803,6 +846,7 @@ class HttpSseJsonRpcProtocolFilter
803846
HttpRoutingFilter::Response resp;
804847
resp.status_code = 200;
805848
resp.headers["content-type"] = "application/json";
849+
resp.headers["Access-Control-Allow-Origin"] = "*";
806850

807851
resp.body = R"({
808852
"server": "MCP Server",
@@ -820,9 +864,22 @@ class HttpSseJsonRpcProtocolFilter
820864
return resp;
821865
});
822866

823-
// Default handler passes through to MCP protocol handling
867+
// Default handler - handle OPTIONS for CORS preflight on any path,
868+
// pass through other methods to MCP protocol handling
824869
routing_filter_->registerDefaultHandler(
825870
[](const HttpRoutingFilter::RequestContext& req) {
871+
// Handle OPTIONS for CORS preflight on any path
872+
if (req.method == "OPTIONS") {
873+
HttpRoutingFilter::Response resp;
874+
resp.status_code = 204; // No Content
875+
resp.headers["Access-Control-Allow-Origin"] = "*";
876+
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
877+
resp.headers["Access-Control-Allow-Headers"] =
878+
"Content-Type, Authorization, Accept, Mcp-Session-Id, Mcp-Protocol-Version";
879+
resp.headers["Access-Control-Max-Age"] = "86400";
880+
resp.headers["Content-Length"] = "0";
881+
return resp;
882+
}
826883
// Return status 0 to indicate pass-through for MCP endpoints
827884
HttpRoutingFilter::Response resp;
828885
resp.status_code = 0;

src/mcp_connection_manager.cc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,7 @@ void McpConnectionManager::onNotification(
771771
if (protocol_callbacks_) {
772772
protocol_callbacks_->onNotification(notification);
773773
}
774+
// HTTP 202 response is sent by HttpSseJsonRpcProtocolFilter::onNotification
774775
}
775776

776777
void McpConnectionManager::onResponse(const jsonrpc::Response& response) {

tests/CMakeLists.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ add_executable(test_short_json_api json/test_short_json_api.cc)
2222
add_executable(test_mcp_server_responses server/test_mcp_server_responses.cc)
2323
# CORS tests
2424
add_executable(test_cors_headers filter/test_cors_headers.cc)
25+
add_executable(test_options_notification filter/test_options_notification.cc)
2526
# Event tests
2627
add_executable(test_event_loop event/test_event_loop.cc)
2728
add_executable(test_timer_lifetime event/test_timer_lifetime.cc)
@@ -245,6 +246,12 @@ target_link_libraries(test_cors_headers
245246
Threads::Threads
246247
)
247248

249+
target_link_libraries(test_options_notification
250+
gtest
251+
gtest_main
252+
Threads::Threads
253+
)
254+
248255
target_link_libraries(test_mcp_serialization_extensive
249256
gopher-mcp
250257
gtest
@@ -1223,6 +1230,7 @@ add_test(NAME McpSerializationExtensiveTest COMMAND test_mcp_serialization_exten
12231230
add_test(NAME TemplateSerializationTest COMMAND test_template_serialization)
12241231
add_test(NAME McpServerResponsesTest COMMAND test_mcp_server_responses)
12251232
add_test(NAME CorsHeadersTest COMMAND test_cors_headers)
1233+
add_test(NAME OptionsNotificationTest COMMAND test_options_notification)
12261234
add_test(NAME EventLoopTest COMMAND test_event_loop)
12271235
add_test(NAME TimerLifetimeTest COMMAND test_timer_lifetime)
12281236

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Unit tests for OPTIONS preflight handling and notification responses
3+
*
4+
* Tests that:
5+
* - OPTIONS requests receive 204 No Content with CORS headers
6+
* - JSON-RPC notifications receive HTTP 202 Accepted response
7+
*/
8+
9+
#include <string>
10+
11+
#include <gtest/gtest.h>
12+
13+
namespace mcp {
14+
namespace filter {
15+
namespace {
16+
17+
class OptionsNotificationTest : public ::testing::Test {};
18+
19+
// Test OPTIONS preflight response format
20+
TEST_F(OptionsNotificationTest, OptionsPreflightResponseFormat) {
21+
// Build OPTIONS preflight response like http_sse_filter_chain_factory.cc does
22+
std::ostringstream response;
23+
response << "HTTP/1.1 204 No Content\r\n";
24+
response << "Access-Control-Allow-Origin: *\r\n";
25+
response << "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n";
26+
response << "Access-Control-Allow-Headers: Content-Type, Authorization, "
27+
"Accept, Mcp-Session-Id, Mcp-Protocol-Version\r\n";
28+
response << "Access-Control-Max-Age: 86400\r\n";
29+
response << "Content-Length: 0\r\n";
30+
response << "\r\n";
31+
32+
std::string preflight_response = response.str();
33+
34+
// Verify 204 No Content for preflight
35+
EXPECT_TRUE(preflight_response.find("HTTP/1.1 204 No Content") !=
36+
std::string::npos)
37+
<< "Preflight should return 204 No Content";
38+
39+
// Verify all CORS headers present
40+
EXPECT_TRUE(preflight_response.find("Access-Control-Allow-Origin: *") !=
41+
std::string::npos);
42+
EXPECT_TRUE(preflight_response.find("Access-Control-Allow-Methods:") !=
43+
std::string::npos);
44+
EXPECT_TRUE(preflight_response.find("Access-Control-Allow-Headers:") !=
45+
std::string::npos);
46+
47+
// Verify max-age for caching preflight results
48+
EXPECT_TRUE(preflight_response.find("Access-Control-Max-Age: 86400") !=
49+
std::string::npos)
50+
<< "Should cache preflight for 24 hours";
51+
52+
// Verify empty body
53+
EXPECT_TRUE(preflight_response.find("Content-Length: 0") != std::string::npos)
54+
<< "Preflight response should have empty body";
55+
}
56+
57+
// Test OPTIONS response includes required MCP headers
58+
TEST_F(OptionsNotificationTest, OptionsAllowsMcpHeaders) {
59+
std::string allowed_headers =
60+
"Content-Type, Authorization, Accept, Mcp-Session-Id, Mcp-Protocol-Version";
61+
62+
// MCP Inspector uses these headers
63+
EXPECT_TRUE(allowed_headers.find("Mcp-Session-Id") != std::string::npos)
64+
<< "Should allow Mcp-Session-Id header";
65+
EXPECT_TRUE(allowed_headers.find("Mcp-Protocol-Version") != std::string::npos)
66+
<< "Should allow Mcp-Protocol-Version header";
67+
EXPECT_TRUE(allowed_headers.find("Authorization") != std::string::npos)
68+
<< "Should allow Authorization header for OAuth";
69+
}
70+
71+
// Test HTTP 202 notification response format
72+
TEST_F(OptionsNotificationTest, NotificationResponseFormat) {
73+
// Build notification response like http_sse_filter_chain_factory.cc does
74+
std::string http_response =
75+
"HTTP/1.1 202 Accepted\r\n"
76+
"Content-Length: 0\r\n"
77+
"Access-Control-Allow-Origin: *\r\n"
78+
"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
79+
"Access-Control-Allow-Headers: Content-Type, Authorization, Accept, "
80+
"Mcp-Session-Id, Mcp-Protocol-Version\r\n"
81+
"Connection: keep-alive\r\n"
82+
"\r\n";
83+
84+
// Verify 202 Accepted for notifications
85+
EXPECT_TRUE(http_response.find("HTTP/1.1 202 Accepted") != std::string::npos)
86+
<< "Notification response should return 202 Accepted";
87+
88+
// Verify CORS headers present
89+
EXPECT_TRUE(http_response.find("Access-Control-Allow-Origin: *") !=
90+
std::string::npos);
91+
92+
// Verify empty body
93+
EXPECT_TRUE(http_response.find("Content-Length: 0") != std::string::npos)
94+
<< "Notification response should have empty body";
95+
}
96+
97+
// Test that notifications don't return JSON-RPC response
98+
TEST_F(OptionsNotificationTest, NotificationNoJsonRpcResponse) {
99+
// JSON-RPC notifications should NOT have a JSON body
100+
// Only HTTP response headers with 202 status
101+
102+
std::string notification_response =
103+
"HTTP/1.1 202 Accepted\r\n"
104+
"Content-Length: 0\r\n"
105+
"Connection: keep-alive\r\n"
106+
"\r\n";
107+
108+
// Should NOT contain JSON-RPC fields
109+
EXPECT_TRUE(notification_response.find("\"jsonrpc\"") == std::string::npos)
110+
<< "Notification response should not contain JSON-RPC body";
111+
EXPECT_TRUE(notification_response.find("\"result\"") == std::string::npos)
112+
<< "Notification response should not contain result field";
113+
EXPECT_TRUE(notification_response.find("\"id\"") == std::string::npos)
114+
<< "Notification response should not contain id field";
115+
}
116+
117+
// Test OPTIONS for common MCP paths
118+
TEST_F(OptionsNotificationTest, OptionsRegisteredPaths) {
119+
// These paths should all handle OPTIONS requests
120+
std::vector<std::string> mcp_paths = {
121+
"/mcp",
122+
"/mcp/events",
123+
"/rpc",
124+
"/health",
125+
"/info"
126+
};
127+
128+
for (const auto& path : mcp_paths) {
129+
// Each path should be registered for OPTIONS
130+
EXPECT_FALSE(path.empty()) << "Path should not be empty";
131+
}
132+
}
133+
134+
} // namespace
135+
} // namespace filter
136+
} // namespace mcp

0 commit comments

Comments
 (0)