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:
@@ -295,7 +295,10 @@ pub async fn gateway_mcp_post_handler(
|
||||
|
||||
// SSE proxy: tools/call with Accept: text/event-stream + progressToken for
|
||||
// non-gateway tools is forwarded to the sled's SSE endpoint so progress
|
||||
// notifications flow through to the gateway client unchanged.
|
||||
// notifications flow through to the gateway client unchanged. This must
|
||||
// apply the same explicit-project resolution and create-guard as the
|
||||
// buffered `tools/call` path below (story 1229: they had drifted apart,
|
||||
// silently bypassing both on the SSE path).
|
||||
if rpc.method == "tools/call" {
|
||||
let accepts_sse = req
|
||||
.header("accept")
|
||||
@@ -313,7 +316,15 @@ pub async fn gateway_mcp_post_handler(
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !GATEWAY_TOOLS.contains(&tool_name) {
|
||||
return proxy_and_respond_sse(&state, &bytes, rpc.id).await;
|
||||
return match extract_explicit_project(&rpc.params) {
|
||||
Some(project) => {
|
||||
proxy_and_respond_sse_for_project(&state, project, &bytes, rpc.id).await
|
||||
}
|
||||
None => match create_guard_error(&state, tool_name).await {
|
||||
Some(msg) => sse_error_response(rpc.id, -32602, msg),
|
||||
None => proxy_and_respond_sse(&state, &bytes, rpc.id).await,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,35 +351,20 @@ pub async fn gateway_mcp_post_handler(
|
||||
// Story 1208 AC 1: an explicit `project` argument on any
|
||||
// proxied tool call targets that project directly, without
|
||||
// requiring a prior `switch_project`.
|
||||
let explicit_project = rpc
|
||||
.params
|
||||
.get("arguments")
|
||||
.and_then(|a| a.get("project"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|p| !p.is_empty());
|
||||
match explicit_project {
|
||||
match extract_explicit_project(&rpc.params) {
|
||||
Some(project) => {
|
||||
proxy_and_respond_for_project(&state, project, &bytes, rpc.id).await
|
||||
}
|
||||
None if is_create_tool(tool_name) => {
|
||||
None => match create_guard_error(&state, tool_name).await {
|
||||
// Story 1225 AC 3: with >1 project registered, a
|
||||
// create call omitting `project` is ambiguous — fail
|
||||
// loudly instead of silently filing into whichever
|
||||
// project happens to be active.
|
||||
let project_count = state.projects.read().await.len();
|
||||
if project_count > 1 {
|
||||
to_json_response(JsonRpcResponse::error(
|
||||
rpc.id,
|
||||
-32602,
|
||||
format!(
|
||||
"'{tool_name}' requires an explicit `project` argument when more than one project is registered (see list_projects) — the active project is not used implicitly for creates."
|
||||
),
|
||||
))
|
||||
} else {
|
||||
Some(msg) => to_json_response(JsonRpcResponse::error(rpc.id, -32602, msg)),
|
||||
None => {
|
||||
proxy_and_respond_with_resolved_project(&state, &bytes, rpc.id).await
|
||||
}
|
||||
}
|
||||
None => proxy_and_respond_with_resolved_project(&state, &bytes, rpc.id).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,6 +372,36 @@ pub async fn gateway_mcp_post_handler(
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the optional per-call `project` argument out of a `tools/call`
|
||||
/// request's params (story 1208 AC 1), shared by the SSE and buffered
|
||||
/// `tools/call` paths so both resolve routing identically (story 1229).
|
||||
fn extract_explicit_project(params: &Value) -> Option<&str> {
|
||||
params
|
||||
.get("arguments")
|
||||
.and_then(|a| a.get("project"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// Returns an error message when `tool_name` is a create-tool called without
|
||||
/// an explicit `project` while more than one project is registered (story
|
||||
/// 1225 AC 3), or `None` when the call may proceed against the active
|
||||
/// project. Shared by the SSE and buffered `tools/call` paths so a fix to one
|
||||
/// can't silently miss the other, as happened in story 1229.
|
||||
async fn create_guard_error(state: &GatewayState, tool_name: &str) -> Option<String> {
|
||||
if !is_create_tool(tool_name) {
|
||||
return None;
|
||||
}
|
||||
let project_count = state.projects.read().await.len();
|
||||
if project_count > 1 {
|
||||
Some(format!(
|
||||
"'{tool_name}' requires an explicit `project` argument when more than one project is registered (see list_projects) — the active project is not used implicitly for creates."
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy a request to the active project and format the response.
|
||||
///
|
||||
/// Prefers the live sled-uplink WebSocket when one is attached (story 899
|
||||
@@ -473,14 +499,43 @@ async fn proxy_and_respond_for_project(
|
||||
///
|
||||
/// On sled disconnect mid-stream a JSON-RPC error event is emitted so the
|
||||
/// client does not hang forever.
|
||||
#[allow(clippy::string_slice)] // pos from buf.find('\n'); '\n' is ASCII so pos and pos+1 are valid boundaries
|
||||
async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option<Value>) -> Response {
|
||||
let url = match state.active_url().await {
|
||||
Ok(u) => u,
|
||||
Err(e) => return sse_error_response(id, -32603, e.to_string()),
|
||||
};
|
||||
stream_mcp_call_sse(state, &url, bytes, id).await
|
||||
}
|
||||
|
||||
let resp = match gateway::io::proxy_mcp_call_sse(&state.client, &url, bytes).await {
|
||||
/// Stream an MCP tool call via SSE to an explicitly named project (story
|
||||
/// 1229), rather than always targeting the active project — the SSE
|
||||
/// counterpart of `proxy_and_respond_for_project`.
|
||||
async fn proxy_and_respond_sse_for_project(
|
||||
state: &GatewayState,
|
||||
project: &str,
|
||||
bytes: &[u8],
|
||||
id: Option<Value>,
|
||||
) -> Response {
|
||||
let url = match state.url_for_project(project).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => return sse_error_response(id, -32602, e.to_string()),
|
||||
};
|
||||
stream_mcp_call_sse(state, &url, bytes, id).await
|
||||
}
|
||||
|
||||
/// Shared SSE streaming body for `proxy_and_respond_sse` and
|
||||
/// `proxy_and_respond_sse_for_project` — proxies to `url` and re-emits each
|
||||
/// `data:` event from the sled to the originating gateway client without
|
||||
/// buffering. On sled disconnect mid-stream a JSON-RPC error event is emitted
|
||||
/// so the client does not hang forever.
|
||||
#[allow(clippy::string_slice)] // pos from buf.find('\n'); '\n' is ASCII so pos and pos+1 are valid boundaries
|
||||
async fn stream_mcp_call_sse(
|
||||
state: &GatewayState,
|
||||
url: &str,
|
||||
bytes: &[u8],
|
||||
id: Option<Value>,
|
||||
) -> Response {
|
||||
let resp = match gateway::io::proxy_mcp_call_sse(&state.client, url, bytes).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return sse_error_response(id, -32603, format!("proxy error: {e}")),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user