Skip to content

Commit 7664ead

Browse files
passcodclaude
andcommitted
fix(private-server): return 404 for /.well-known/ OAuth discovery
MCP clients (Claude Code) probe /.well-known/oauth-protected-resource to discover OAuth. The SPA fallback answered 200 text/html (index.html), which the client fails to parse as JSON and reports as 'needs authentication / SDK auth failed: Failed to parse JSON'. Serve 404 for /.well-known/ so the client concludes there's no OAuth and connects with the ambient Tailscale identity instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a035c09 commit 7664ead

2 files changed

Lines changed: 33 additions & 0 deletions

File tree

crates/private-server/src/spa.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ pub async fn handler(uri: Uri) -> impl IntoResponse {
1414
let path = uri.path().trim_start_matches('/');
1515
let is_asset = path.starts_with("assets/");
1616

17+
// Don't serve the SPA for `/.well-known/` probes. MCP clients (e.g. Claude
18+
// Code) discover OAuth by fetching `/.well-known/oauth-protected-resource`;
19+
// answering with `index.html` makes them fail with "Failed to parse JSON"
20+
// instead of concluding there's no OAuth. A 404 means "not a protected
21+
// resource — connect without OAuth" (this endpoint authenticates via the
22+
// Tailscale ingress, not OAuth).
23+
if path.starts_with(".well-known/") {
24+
return (StatusCode::NOT_FOUND, "not found").into_response();
25+
}
26+
1727
let resolved = if !is_asset && Assets::get(path).is_none() {
1828
"index.html"
1929
} else {

crates/private-server/tests/mcp.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,29 @@ async fn initialize_and_list_tools() {
143143
.await
144144
}
145145

146+
#[tokio::test(flavor = "multi_thread")]
147+
async fn oauth_discovery_is_404_not_spa() {
148+
// Regression: MCP clients probe `/.well-known/oauth-*` for OAuth metadata.
149+
// The SPA fallback used to answer 200 text/html, which clients fail to parse
150+
// as JSON and report as "needs authentication". A 404 tells them there's no
151+
// OAuth, so they connect with the ambient (Tailscale) identity instead.
152+
commons_tests::server::run(async |_conn, _public, private| {
153+
for path in [
154+
"/.well-known/oauth-protected-resource",
155+
"/.well-known/oauth-protected-resource/api/mcp",
156+
"/.well-known/oauth-authorization-server",
157+
] {
158+
let resp = private.get(path).await;
159+
assert_eq!(
160+
resp.status_code().as_u16(),
161+
404,
162+
"{path} should be 404, not the SPA"
163+
);
164+
}
165+
})
166+
.await
167+
}
168+
146169
#[tokio::test(flavor = "multi_thread")]
147170
async fn accepts_non_loopback_host() {
148171
// Regression: rmcp's DNS-rebinding guard defaults to a loopback-only Host

0 commit comments

Comments
 (0)