Skip to content

Commit ca1b389

Browse files
committed
feat(mcp): structured AuthError for 401 responses
Captures status code + WWW-Authenticate + resource_metadata URL on 401, wraps in *AuthError, propagates via errors.As back to fc-safari through new ErrorType / AuthMetadata fields on MCPResultPayload. Old MCPResult senders omit the new fields — fc-safari falls back to opaque-error path.
1 parent 8f04eba commit ca1b389

5 files changed

Lines changed: 125 additions & 1 deletion

File tree

mcp/auth_error.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package mcp
2+
3+
import "fmt"
4+
5+
// AuthError wraps a 401 from a remote MCP server with structured metadata
6+
// for fc-safari to translate into an authorization_required tool result.
7+
type AuthError struct {
8+
StatusCode int
9+
WWWAuthenticate string
10+
ResourceMetadataURL string
11+
Underlying error
12+
}
13+
14+
func (e *AuthError) Error() string {
15+
return fmt.Sprintf("mcp auth error: status=%d www-authenticate=%q resource-metadata-url=%q",
16+
e.StatusCode, e.WWWAuthenticate, e.ResourceMetadataURL)
17+
}
18+
19+
func (e *AuthError) Unwrap() error { return e.Underlying }

mcp/transport.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ type headerTransport struct {
6464
base http.RoundTripper
6565
}
6666

67+
// newHeaderTransport creates a headerTransport with the given static headers
68+
// and http.DefaultTransport as the base. Used by tests and NewSSETransport.
69+
func newHeaderTransport(headers map[string]string) *headerTransport {
70+
return &headerTransport{
71+
headers: headers,
72+
base: http.DefaultTransport,
73+
}
74+
}
75+
6776
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
6877
for k, v := range t.headers {
6978
req.Header.Set(k, v)
@@ -111,5 +120,32 @@ func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
111120
"status", resp.StatusCode,
112121
)
113122

123+
// Surface 401 as a structured AuthError so fc-safari can route it to the
124+
// per-user auth flow instead of treating it as an opaque failure string.
125+
if resp.StatusCode == 401 {
126+
www := resp.Header.Get("WWW-Authenticate")
127+
return resp, &AuthError{
128+
StatusCode: resp.StatusCode,
129+
WWWAuthenticate: www,
130+
ResourceMetadataURL: parseResourceMetadataURL(www),
131+
}
132+
}
133+
114134
return resp, nil
115135
}
136+
137+
// parseResourceMetadataURL extracts the `resource_metadata="..."` param
138+
// from a Bearer challenge per RFC 6750 §3.
139+
func parseResourceMetadataURL(wwwAuth string) string {
140+
const key = `resource_metadata="`
141+
i := strings.Index(wwwAuth, key)
142+
if i < 0 {
143+
return ""
144+
}
145+
rest := wwwAuth[i+len(key):]
146+
j := strings.IndexByte(rest, '"')
147+
if j < 0 {
148+
return ""
149+
}
150+
return rest[:j]
151+
}

mcp/transport_auth_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package mcp
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestRoundTripWrapsAuthError(t *testing.T) {
12+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13+
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", resource_metadata="https://ex/.well-known/oauth-protected-resource"`)
14+
w.WriteHeader(401)
15+
w.Write([]byte("unauth")) //nolint:errcheck
16+
}))
17+
defer ts.Close()
18+
19+
transport := newHeaderTransport(map[string]string{})
20+
req, _ := http.NewRequest("GET", ts.URL, nil)
21+
_, err := transport.RoundTrip(req)
22+
if err == nil {
23+
t.Fatal("expected error for 401")
24+
}
25+
var ae *AuthError
26+
if !errors.As(err, &ae) {
27+
t.Fatalf("expected *AuthError, got %T: %v", err, err)
28+
}
29+
if ae.StatusCode != 401 {
30+
t.Fatalf("status: got %d", ae.StatusCode)
31+
}
32+
if !strings.Contains(ae.WWWAuthenticate, "invalid_token") {
33+
t.Fatalf("WWWAuthenticate not captured: %q", ae.WWWAuthenticate)
34+
}
35+
if !strings.Contains(ae.ResourceMetadataURL, "/.well-known/") {
36+
t.Fatalf("ResourceMetadataURL not captured: %q", ae.ResourceMetadataURL)
37+
}
38+
}

protocol/messages.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,18 @@ type MCPResultPayload struct {
399399
Success bool `json:"success"`
400400
Result json.RawMessage `json:"result,omitempty"`
401401
Error string `json:"error,omitempty"`
402+
403+
// P1: structured auth-error metadata (additive; old senders omit).
404+
ErrorType string `json:"error_type,omitempty"` // "auth_error" | ""
405+
AuthMetadata *AuthErrorMeta `json:"auth_metadata,omitempty"` // non-nil iff ErrorType=="auth_error"
406+
}
407+
408+
// AuthErrorMeta carries the 401 challenge details so fc-safari can initiate
409+
// the per-user OAuth flow without re-parsing opaque error strings.
410+
type AuthErrorMeta struct {
411+
StatusCode int `json:"status_code,omitempty"`
412+
WWWAuthenticate string `json:"www_authenticate,omitempty"`
413+
ResourceMetadataURL string `json:"resource_metadata_url,omitempty"`
402414
}
403415

404416
// KnowledgeFile is a single file entry in a stage_knowledge_files request.

ws/handler.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package ws
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"log/slog"
89
"sync"
910
"time"
1011

1112
"github.com/flashcatcloud/flashduty-runner/environment"
13+
"github.com/flashcatcloud/flashduty-runner/mcp"
1214
"github.com/flashcatcloud/flashduty-runner/protocol"
1315
)
1416

@@ -311,6 +313,22 @@ func (h *Handler) sendMCPResult(callID string, success bool, result any, errMsg
311313
})
312314
}
313315

316+
// buildMCPResultPayload constructs a failure payload, enriching it with
317+
// structured AuthError metadata when the error chain contains *mcp.AuthError.
318+
func buildMCPResultPayload(callID string, err error) protocol.MCPResultPayload {
319+
p := protocol.MCPResultPayload{CallID: callID, Success: false, Error: err.Error()}
320+
var ae *mcp.AuthError
321+
if errors.As(err, &ae) {
322+
p.ErrorType = "auth_error"
323+
p.AuthMetadata = &protocol.AuthErrorMeta{
324+
StatusCode: ae.StatusCode,
325+
WWWAuthenticate: ae.WWWAuthenticate,
326+
ResourceMetadataURL: ae.ResourceMetadataURL,
327+
}
328+
}
329+
return p
330+
}
331+
314332
func (h *Handler) sendPayload(msgType protocol.MessageType, payload any) {
315333
if h.client == nil {
316334
slog.Error("client not set, cannot send message", "type", msgType)
@@ -364,7 +382,8 @@ func (h *Handler) handleMCPCall(ctx context.Context, msg *protocol.Message) erro
364382
}, logger)
365383
if err != nil {
366384
logger.Error("mcp call failed", "error", err)
367-
h.sendMCPResult(payload.CallID, false, nil, err.Error())
385+
p := buildMCPResultPayload(payload.CallID, err)
386+
h.sendPayload(protocol.MessageTypeMCPResult, p)
368387
} else {
369388
logger.Info("mcp call completed")
370389
h.sendMCPResult(payload.CallID, true, result, "")

0 commit comments

Comments
 (0)