Skip to content

Commit c7c6e19

Browse files
committed
fix(claw-server): admit Electron-based MCP clients past the /mcp hygiene filter
The loopback /mcp hygiene filter rejected any request carrying a `Sec-Fetch-*` header. Chromium/Electron-based desktop MCP clients (e.g. Cherry Studio) attach browser-default Fetch Metadata to every outgoing request and cannot strip it, so they were blocked with 403 "unsupported request". `Sec-Fetch-*` presence does not identify a webpage: only `Origin` does. An MCP call is a POST with `application/json`, which is never a CORS simple request, so any page capable of making one always carries `Origin`, which stays rejected. The content-type gate continues to cover the origin-less page vectors. Drop the `Sec-Fetch-*` presence check and keep the `Origin` rejection. Fixes #2458
1 parent 22a6d7e commit c7c6e19

2 files changed

Lines changed: 45 additions & 14 deletions

File tree

  • packages/browseros-agent/apps/claw-server-rust

packages/browseros-agent/apps/claw-server-rust/src/api/http/mod.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,21 @@ pub(super) fn internal(request_id: &RequestId, source: AppError) -> CanonicalErr
122122
)
123123
}
124124

125-
/// Rejects browser-page requests to the loopback MCP endpoint. Browser fetches
126-
/// carry `origin` or `sec-fetch-site`; native MCP clients do not.
125+
/// Rejects browser-page requests to the loopback MCP endpoint. `Origin` is the
126+
/// reliable signal: an MCP call is a POST with `application/json`, which is never
127+
/// a CORS simple request, so any webpage capable of making one always carries
128+
/// `Origin`. `Sec-Fetch-*` is not usable here: Chromium/Electron-based desktop
129+
/// MCP clients attach it to every request and cannot strip it, so its presence
130+
/// alone does not indicate a page. The content-type gate below covers the
131+
/// origin-less page vectors (navigations, no-cors simple requests).
127132
async fn mcp_request_hygiene(req: Request, next: Next) -> Response {
128133
// The nested /mcp service shadows the router's `/{*path}` preflight route,
129134
// so answer OPTIONS here to keep loopback preflight behavior consistent.
130135
if *req.method() == Method::OPTIONS {
131136
return StatusCode::NO_CONTENT.into_response();
132137
}
133138
let headers = req.headers();
134-
if headers.contains_key(header::ORIGIN) || headers.contains_key("sec-fetch-site") {
139+
if headers.contains_key(header::ORIGIN) {
135140
return AppError::forbidden("unsupported request").into_response();
136141
}
137142
let needs_json = match *req.method() {

packages/browseros-agent/apps/claw-server-rust/tests/routes.rs

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,8 @@ async fn system_shutdown_preserves_contract_body_and_defers_runtime_teardown() -
287287
async fn mcp_hygiene_rejects_browser_originated_requests() -> anyhow::Result<()> {
288288
let app = test_app().await?;
289289

290+
// `Origin` is the reliable page signal and stays blocked: any webpage that
291+
// can make an MCP call (POST application/json) always carries it.
290292
let (status, _headers, body) = request_json_with_headers(
291293
&app.router,
292294
"POST",
@@ -298,17 +300,6 @@ async fn mcp_hygiene_rejects_browser_originated_requests() -> anyhow::Result<()>
298300
assert_eq!(status, StatusCode::FORBIDDEN);
299301
assert_eq!(body, json!({ "error": "unsupported request" }));
300302

301-
let (status, _headers, body) = request_json_with_headers(
302-
&app.router,
303-
"GET",
304-
"/mcp",
305-
None,
306-
&[("sec-fetch-site", "cross-site")],
307-
)
308-
.await?;
309-
assert_eq!(status, StatusCode::FORBIDDEN);
310-
assert_eq!(body, json!({ "error": "unsupported request" }));
311-
312303
// Hygiene applies to /mcp only: the same origin header is fine elsewhere.
313304
let (status, _headers, _body) = request_json_with_headers(
314305
&app.router,
@@ -334,6 +325,41 @@ async fn mcp_hygiene_rejects_browser_originated_requests() -> anyhow::Result<()>
334325
Ok(())
335326
}
336327

328+
#[tokio::test]
329+
async fn mcp_hygiene_admits_electron_sec_fetch_clients() -> anyhow::Result<()> {
330+
let app = test_app().await?;
331+
332+
// Chromium/Electron-based desktop MCP clients (e.g. Cherry Studio) attach
333+
// browser-default Fetch Metadata to every request and cannot strip it. With
334+
// no `Origin`, such a request is not a page fetch and must reach the service.
335+
let initialize = json!({
336+
"jsonrpc": "2.0",
337+
"id": 1,
338+
"method": "initialize",
339+
"params": {
340+
"protocolVersion": "2025-06-18",
341+
"capabilities": {},
342+
"clientInfo": { "name": "Cherry Studio", "version": "1.0" }
343+
}
344+
});
345+
let (status, headers, body) = request_json_with_headers(
346+
&app.router,
347+
"POST",
348+
"/mcp",
349+
Some(initialize),
350+
&[
351+
("sec-fetch-site", "same-site"),
352+
("sec-fetch-mode", "cors"),
353+
("sec-fetch-dest", "empty"),
354+
],
355+
)
356+
.await?;
357+
assert_eq!(status, StatusCode::OK, "initialize body: {body:?}");
358+
assert_eq!(body["result"]["serverInfo"]["name"], "browseros-neo");
359+
assert!(headers.contains_key("mcp-session-id"));
360+
Ok(())
361+
}
362+
337363
#[tokio::test]
338364
async fn mcp_hygiene_rejects_non_json_writes() -> anyhow::Result<()> {
339365
let app = test_app().await?;

0 commit comments

Comments
 (0)