File tree Expand file tree Collapse file tree
Expand file tree Collapse file tree Original file line number Diff line number Diff 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 {
Original file line number Diff line number Diff 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" ) ]
147170async fn accepts_non_loopback_host ( ) {
148171 // Regression: rmcp's DNS-rebinding guard defaults to a loopback-only Host
You can’t perform that action at this time.
0 commit comments