Repository: envoyproxy/envoy
File: test/extensions/filters/http/mcp_router/mcp_router_integration_test.cc (C++)

Code before:
  ASSERT_TRUE(response->waitForEndStream());
  EXPECT_EQ("200", response->headers().getStatusValue());

  // Verify the aggregated response contains tools from both backends with prefixes
  // Since we have 2 backends, tools should be prefixed with backend names
  EXPECT_THAT(response->body(), testing::HasSubstr("time__get_current_time"));
  EXPECT_THAT(response->body(), testing::HasSubstr("tools__calculator"));
  EXPECT_THAT(response->body(), testing::HasSubstr("tools__converter"));
}

// Test tools/call request routes to correct backend and strips prefix
TEST_P(McpRouterIntegrationTest, ToolCallRoutesToCorrectBackend) {
  initializeFilter();

  codec_client_ = makeHttpConnection(lookupPort("http"));

  const std::string request_body = R"({
    "jsonrpc": "2.0",
    "method": "tools/call",
    "id": 1,
    "params": {
      "name": "time__get_current_time",
      "arguments": {}
    }
  })";

  auto response = codec_client_->makeRequestWithBody(
      Http::TestRequestHeaderMapImpl{{":method", "POST"},
                                     {":path", "/mcp"},
                                     {":scheme", "http"},
                                     {":authority", "host"},
                                     {"accept", "application/json"},
                                     {"accept", "text/event-stream"},
                                     {"content-type", "application/json"}},
      request_body);

  // Request should be routed to time backend (fake_upstreams_[0]) based on "time__" prefix
  ASSERT_TRUE(fake_upstreams_[0]->waitForHttpConnection(*dispatcher_, time_backend_connection_));
  ASSERT_TRUE(time_backend_connection_->waitForNewStream(*dispatcher_, time_backend_request_));
  ASSERT_TRUE(time_backend_request_->waitForEndStream(*dispatcher_));

  // Verify upstream request body has prefix stripped
  EXPECT_THAT(time_backend_request_->body().toString(),
              testing::HasSubstr("\"name\": \"get_current_time\""));
  EXPECT_THAT(time_backend_request_->body().toString(), testing::Not(testing::HasSubstr("time__")));

  const std::string backend_response = R"({
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
      "content": [{"type": "text", "text": "2023-10-27T10:00:00Z"}]

Review comment (suggestion):
the all-session-less case is well tested here. consider also adding a test for the mixed case: one backend returns `mcp-session-id`, the other doesn't. the `sessions` map would only contain a subset of backends, and the composite session would encode only those. on subsequent requests `backend_sessions_[backend.name]` would return empty for the session-less backend. it works because `createUpstreamHeaders` handles empty session IDs at line 1461, but having a test would document that behavior explicitly.

also: this test covers the initialize path, but not a subsequent request (e.g. `tools/list`) in full session-less mode. the client wouldn't send back a session ID, `decodeAndParseSession` gets skipped (line 137), and `backend_sessions_` stays empty. the fanout still works since `createUpstreamHeaders` handles empty sessions. might be worth an e2e test covering that full flow to prevent regressions.

Code after:
  })";
  tools_backend_request_->encodeHeaders(
      Http::TestResponseHeaderMapImpl{{":status", "200"}, {"content-type", "application/json"}},
      false);
  Buffer::OwnedImpl tools_body(tools_response);
  tools_backend_request_->encodeData(tools_body, true);

  ASSERT_TRUE(response->waitForEndStream());
  EXPECT_EQ("200", response->headers().getStatusValue());

  // Verify response does NOT have mcp-session-id header when no backends returned one
  auto session_header = response->headers().get(Http::LowerCaseString("mcp-session-id"));
  EXPECT_TRUE(session_header.empty());

  // Verify the response body contains gateway capabilities
  EXPECT_THAT(response->body(), testing::HasSubstr("protocolVersion"));
  EXPECT_THAT(response->body(), testing::HasSubstr("envoy-mcp-gateway"));

  // Verify stats
  test_server_->waitForCounterEq("http.config_test.mcp_router.rq_total", 1);
  test_server_->waitForCounterEq("http.config_test.mcp_router.rq_fanout", 1);
}

// Test tools/list request fans out to both backends and aggregates tools with prefixes
TEST_P(McpRouterIntegrationTest, ToolsListFanoutAggregation) {
  initializeFilter();

  codec_client_ = makeHttpConnection(lookupPort("http"));

  const std::string request_body = R"({
    "jsonrpc": "2.0",
    "method": "tools/list",
    "id": 2
  })";

  auto response = codec_client_->makeRequestWithBody(
      Http::TestRequestHeaderMapImpl{{":method", "POST"},
                                     {":path", "/mcp"},
                                     {":scheme", "http"},
                                     {":authority", "host"},
                                     {"accept", "application/json"},
                                     {"accept", "text/event-stream"},
                                     {"content-type", "application/json"}},
      request_body);

  ASSERT_TRUE(fake_upstreams_[0]->waitForHttpConnection(*dispatcher_, time_backend_connection_));
  ASSERT_TRUE(time_backend_connection_->waitForNewStream(*dispatcher_, time_backend_request_));
  ASSERT_TRUE(time_backend_request_->waitForEndStream(*dispatcher_));

  ASSERT_TRUE(fake_upstreams_[1]->waitForHttpConnection(*dispatcher_, tools_backend_connection_));
  ASSERT_TRUE(tools_backend_connection_->waitForNewStream(*dispatcher_, tools_backend_request_));