huskies: merge 1229 bug Explicit project arg ignored on the SSE MCP path — 1225's routing and create-guard are bypassed
This commit is contained in:
@@ -848,6 +848,196 @@ async fn gateway_mcp_sse_proxy_streams_progress_and_final_response() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── SSE path honors explicit `project` and the create-guard (story 1229) ──
|
||||
//
|
||||
// Story 1225 added explicit-project routing and the create-without-project
|
||||
// guard, but only on the buffered `tools/call` path — the SSE branch above
|
||||
// (Accept: text/event-stream + `_meta.progressToken`) still always proxied
|
||||
// to `state.active_url()` and never ran the guard. These tests exercise the
|
||||
// SSE branch specifically so a regression here fails a test, unlike 1225's
|
||||
// tests at `create_tool_with_explicit_project_lands_in_non_active_project` /
|
||||
// `read_tool_with_explicit_project_reads_from_non_active_project` in
|
||||
// `http/gateway/mcp.rs`, which bypass SSE entirely.
|
||||
|
||||
fn sse_body_for(id: i64, text: &str) -> String {
|
||||
let final_resp = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": { "content": [{ "type": "text", "text": text }] }
|
||||
});
|
||||
format!("data: {final_resp}\n\n")
|
||||
}
|
||||
|
||||
async fn make_two_http_project_state() -> (
|
||||
Arc<GatewayState>,
|
||||
mockito::ServerGuard,
|
||||
mockito::ServerGuard,
|
||||
) {
|
||||
let mut mock_alpha = mockito::Server::new_async().await;
|
||||
let mut mock_beta = mockito::Server::new_async().await;
|
||||
mock_alpha
|
||||
.mock("POST", "/mcp")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/event-stream")
|
||||
.with_body(sse_body_for(1, "handled_by:alpha"))
|
||||
.create_async()
|
||||
.await;
|
||||
mock_beta
|
||||
.mock("POST", "/mcp")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/event-stream")
|
||||
.with_body(sse_body_for(1, "handled_by:beta"))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"alpha".to_string(),
|
||||
ProjectEntry::with_url(mock_alpha.url()),
|
||||
);
|
||||
projects.insert("beta".to_string(), ProjectEntry::with_url(mock_beta.url()));
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
release_channels: BTreeMap::new(),
|
||||
};
|
||||
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
||||
assert_eq!(*state.active_project.read().await, "alpha");
|
||||
(state, mock_alpha, mock_beta)
|
||||
}
|
||||
|
||||
fn sse_data_lines(body: &str) -> Vec<&str> {
|
||||
body.lines()
|
||||
.filter_map(|l| l.strip_prefix("data: "))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_create_tool_with_explicit_project_lands_in_non_active_project() {
|
||||
let (state, _mock_alpha, _mock_beta) = make_two_http_project_state().await;
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state);
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "create_bug",
|
||||
"arguments": { "project": "beta", "title": "x", "description": "y" },
|
||||
"_meta": { "progressToken": "tok1" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
let lines = sse_data_lines(&body);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
1,
|
||||
"expected exactly one SSE data event: {body}"
|
||||
);
|
||||
let ev: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(
|
||||
ev["result"]["content"][0]["text"], "handled_by:beta",
|
||||
"explicit project=beta on the SSE path must land in beta even though \
|
||||
alpha is active: {ev}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_read_tool_with_explicit_project_reads_from_non_active_project() {
|
||||
let (state, _mock_alpha, _mock_beta) = make_two_http_project_state().await;
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state);
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "list_upcoming",
|
||||
"arguments": { "project": "beta" },
|
||||
"_meta": { "progressToken": "tok1" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
let lines = sse_data_lines(&body);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
1,
|
||||
"expected exactly one SSE data event: {body}"
|
||||
);
|
||||
let ev: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(
|
||||
ev["result"]["content"][0]["text"], "handled_by:beta",
|
||||
"explicit project=beta on the SSE path must read from beta (audited \
|
||||
generically, not via a hand-listed subset — story 1229 AC 4): {ev}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_create_tool_without_project_errors_when_multiple_projects_registered() {
|
||||
let (state, _mock_alpha, _mock_beta) = make_two_http_project_state().await;
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state);
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "create_bug",
|
||||
"arguments": { "title": "x", "description": "y" },
|
||||
"_meta": { "progressToken": "tok1" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
let lines = sse_data_lines(&body);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
1,
|
||||
"expected exactly one SSE error event: {body}"
|
||||
);
|
||||
let ev: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert!(
|
||||
ev["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("requires an explicit `project`"),
|
||||
"SSE create call omitting `project` with >1 project registered must \
|
||||
error, not silently proxy to the active project: {ev}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-SSE `tools/call` requests must be routed over the live sled-uplink WS
|
||||
/// (story 1180: the gateway no longer falls back to HTTP for MCP proxying)
|
||||
/// and return a plain `application/json` body.
|
||||
|
||||
Reference in New Issue
Block a user