Skip to content

Commit 3ac2303

Browse files
fix(oauth): use POST fallback when HEAD returns 405 for resource detection
MCP servers often only allow POST requests, returning 405 Method Not Allowed for HEAD. This fix adds fallback logic to try POST with empty JSON body when HEAD is rejected, enabling proper WWW-Authenticate header retrieval for servers like AnySource/Runlayer. Added test: TestCreateOAuthConfig_FallsBackToPostWhenHeadNotAllowed
1 parent 1f74ecc commit 3ac2303

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

internal/oauth/config.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ func CreateOAuthConfigWithExtraParams(serverConfig *config.ServerConfig, storage
357357
// autoDetectResource attempts to discover the RFC 8707 resource parameter.
358358
// Returns the detected resource URL, or server URL as fallback, or empty string on failure.
359359
func autoDetectResource(serverConfig *config.ServerConfig, logger *zap.Logger) string {
360-
// Make a preflight HEAD request to get WWW-Authenticate header
360+
// Try to get WWW-Authenticate header - first with HEAD, then POST if HEAD not allowed
361361
resp, err := http.Head(serverConfig.URL)
362362
if err != nil {
363363
logger.Debug("Failed to make preflight request for resource detection",
@@ -366,6 +366,23 @@ func autoDetectResource(serverConfig *config.ServerConfig, logger *zap.Logger) s
366366
// Fallback to server URL
367367
return serverConfig.URL
368368
}
369+
370+
// If HEAD returns 405 Method Not Allowed, try POST (MCP servers often only allow POST)
371+
if resp.StatusCode == http.StatusMethodNotAllowed {
372+
resp.Body.Close()
373+
logger.Debug("HEAD not allowed, trying POST for resource detection",
374+
zap.String("server", serverConfig.Name))
375+
376+
// Try POST with empty JSON body (minimal MCP request)
377+
postResp, err := http.Post(serverConfig.URL, "application/json", strings.NewReader("{}"))
378+
if err != nil {
379+
logger.Debug("Failed to make POST preflight request for resource detection",
380+
zap.String("server", serverConfig.Name),
381+
zap.Error(err))
382+
return serverConfig.URL
383+
}
384+
resp = postResp
385+
}
369386
defer resp.Body.Close()
370387

371388
// Check for 401 with WWW-Authenticate header containing resource_metadata

internal/oauth/config_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,3 +499,58 @@ func TestCreateOAuthConfig_FallsBackToServerURL(t *testing.T) {
499499
assert.True(t, hasResource, "extraParams should contain 'resource' key (fallback)")
500500
assert.Equal(t, mockMetadataServer.URL+"/mcp", resource, "Resource should fall back to server URL")
501501
}
502+
503+
// Test CreateOAuthConfig auto-detects resource when HEAD returns 405 and POST is needed
504+
func TestCreateOAuthConfig_FallsBackToPostWhenHeadNotAllowed(t *testing.T) {
505+
// Variable to hold server URL for use in handler
506+
var serverURL string
507+
508+
// Create a mock server that returns 405 for HEAD but 401 with WWW-Authenticate for POST
509+
mockMetadataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
510+
if r.URL.Path == "/.well-known/oauth-protected-resource" {
511+
w.Header().Set("Content-Type", "application/json")
512+
w.WriteHeader(http.StatusOK)
513+
w.Write([]byte(`{
514+
"resource": "https://api.example.com/mcp/v1",
515+
"authorization_servers": ["https://auth.example.com"],
516+
"scopes_supported": ["mcp"]
517+
}`))
518+
return
519+
}
520+
// HEAD returns 405 Method Not Allowed (like MCP servers)
521+
if r.Method == "HEAD" {
522+
w.Header().Set("Allow", "POST")
523+
w.WriteHeader(http.StatusMethodNotAllowed)
524+
return
525+
}
526+
// POST returns 401 with WWW-Authenticate header
527+
if r.Method == "POST" {
528+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="invalid_token", resource_metadata="%s/.well-known/oauth-protected-resource"`, serverURL))
529+
w.WriteHeader(http.StatusUnauthorized)
530+
return
531+
}
532+
w.WriteHeader(http.StatusNotFound)
533+
}))
534+
defer mockMetadataServer.Close()
535+
536+
// Assign server URL after server is created
537+
serverURL = mockMetadataServer.URL
538+
539+
testStorage := setupTestStorage(t)
540+
serverConfig := &config.ServerConfig{
541+
Name: "test-post-fallback",
542+
URL: mockMetadataServer.URL + "/mcp",
543+
// No OAuth config - should auto-detect via POST fallback
544+
}
545+
546+
// Call CreateOAuthConfigWithExtraParams to get both config and extraParams
547+
oauthConfig, extraParams := CreateOAuthConfigWithExtraParams(serverConfig, testStorage)
548+
549+
require.NotNil(t, oauthConfig, "OAuth config should be created")
550+
require.NotNil(t, extraParams, "Extra params should be returned")
551+
552+
// Verify resource was auto-detected via POST fallback
553+
resource, hasResource := extraParams["resource"]
554+
assert.True(t, hasResource, "extraParams should contain 'resource' key")
555+
assert.Equal(t, "https://api.example.com/mcp/v1", resource, "Resource should be auto-detected via POST fallback")
556+
}

0 commit comments

Comments
 (0)