Skip to content

Commit e377aa9

Browse files
authored
fix: normalize external URL with trailing slash per RFC 3986 (#125)
Ensure the resource URI in /.well-known/oauth-protected-resource includes a trailing slash, matching the canonical form expected by clients like claude.ai (RFC 3986 Section 6.2.3).
1 parent ef5731d commit e377aa9

3 files changed

Lines changed: 80 additions & 1 deletion

File tree

pkg/mcp-proxy/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,11 @@ func Run(
8484
if err != nil {
8585
return fmt.Errorf("failed to parse external URL: %w", err)
8686
}
87-
if parsedExternalURL.Path != "" {
87+
if parsedExternalURL.Path != "" && parsedExternalURL.Path != "/" {
8888
return fmt.Errorf("external URL must not have a path, got: %s", parsedExternalURL.Path)
8989
}
90+
parsedExternalURL.Path = "/"
91+
externalURL = parsedExternalURL.String()
9092

9193
if (tlsCertFile == "") != (tlsKeyFile == "") {
9294
return fmt.Errorf("both TLS certificate and key files must be provided together")

pkg/mcp-proxy/main_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,56 @@ import (
1010
"github.com/stretchr/testify/require"
1111
)
1212

13+
func TestRun_NormalizesExternalURLTrailingSlash(t *testing.T) {
14+
originalNewProxyRouter := newProxyRouter
15+
t.Cleanup(func() {
16+
newProxyRouter = originalNewProxyRouter
17+
})
18+
19+
cases := []struct {
20+
name string
21+
input string
22+
wantURL string
23+
wantErr bool
24+
errContains string
25+
}{
26+
{name: "no trailing slash", input: "https://example.com", wantURL: "https://example.com/"},
27+
{name: "with trailing slash", input: "https://example.com/", wantURL: "https://example.com/"},
28+
{name: "with path", input: "https://example.com/foo", wantErr: true, errContains: "must not have a path"},
29+
}
30+
31+
for _, tt := range cases {
32+
t.Run(tt.name, func(t *testing.T) {
33+
var receivedURL string
34+
newProxyRouter = func(externalURL string, proxyHandler http.Handler, publicKey *rsa.PublicKey, proxyHeaders http.Header, httpStreamingOnly bool) (*proxy.ProxyRouter, error) {
35+
receivedURL = externalURL
36+
return nil, errors.New("stop early")
37+
}
38+
39+
err := Run(
40+
":0", ":0", false, "", "", false, "", "",
41+
t.TempDir(), "local", "",
42+
tt.input,
43+
"", "", nil, nil,
44+
"", "", nil, nil,
45+
"", "", "", nil, "", "", nil, nil, nil, nil,
46+
false, "", "", nil, nil, "",
47+
[]string{"http://example.com"}, false,
48+
)
49+
50+
if tt.wantErr {
51+
require.Error(t, err)
52+
require.Contains(t, err.Error(), tt.errContains)
53+
return
54+
}
55+
56+
require.Error(t, err)
57+
require.Contains(t, err.Error(), "stop early")
58+
require.Equal(t, tt.wantURL, receivedURL)
59+
})
60+
}
61+
}
62+
1363
func TestRun_PassesHTTPStreamingOnlyToProxyRouter(t *testing.T) {
1464
originalNewProxyRouter := newProxyRouter
1565
t.Cleanup(func() {

pkg/proxy/proxy_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package proxy
33
import (
44
"crypto/rand"
55
"crypto/rsa"
6+
"encoding/json"
67
"fmt"
78
"net/http"
89
"net/http/httptest"
@@ -103,6 +104,32 @@ func TestProxyRouter_HandleProxy_ValidToken(t *testing.T) {
103104
assert.Equal(t, http.StatusUnauthorized, w.Code)
104105
}
105106

107+
func TestProxyRouter_ProtectedResourceTrailingSlash(t *testing.T) {
108+
_, publicKey, err := generateRSAKeyPair()
109+
require.NoError(t, err)
110+
111+
proxyRouter, err := NewProxyRouter("https://example.com/", http.NotFoundHandler(), publicKey, http.Header{}, false)
112+
require.NoError(t, err)
113+
114+
gin.SetMode(gin.TestMode)
115+
router := gin.New()
116+
proxyRouter.SetupRoutes(router)
117+
118+
req, err := http.NewRequest("GET", OauthProtectedResourceEndpoint, nil)
119+
require.NoError(t, err)
120+
121+
w := httptest.NewRecorder()
122+
router.ServeHTTP(w, req)
123+
124+
assert.Equal(t, http.StatusOK, w.Code)
125+
126+
var resp protectedResourceResponse
127+
err = json.NewDecoder(w.Body).Decode(&resp)
128+
require.NoError(t, err)
129+
assert.Equal(t, "https://example.com/", resp.Resource)
130+
assert.Equal(t, []string{"https://example.com/"}, resp.AuthorizationServers)
131+
}
132+
106133
func TestProxyRouter_HTTPStreamingOnlyRejectsSSE(t *testing.T) {
107134
privateKey, publicKey, err := generateRSAKeyPair()
108135
require.NoError(t, err)

0 commit comments

Comments
 (0)