Skip to content

Commit 695b823

Browse files
authored
mcp: serve well-known resources at the mcp route path (#1596)
**Description** Only serve the `.well-known` endpoints at the paths exposed by the `MCPRoute` and not serve on `/` by default. According to the spec [1], clients **MUST** use the URL returned in the `WWW-Authenticate` header and support requesting a `well-known` resource in the configured path. The "root" resource is just a fallback. We can't expose the resource at the root because we can have multiple MCPRoutes with different security configurations, so we need to rely on clients properly implementing the spec and fetching the well-known resources from the right path. **Related Issues/PRs (if applicable)** Fixes #1585 **Special notes for reviewers (if applicable)** This has been tested with the MCP inspector and Goose as two of the popular clients, with Descope as the Authentication provider, and validated that it works fine with them. 1: https://mcp.mintlify.app/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements Signed-off-by: Ignasi Barrera <[email protected]>
1 parent 9262362 commit 695b823

File tree

5 files changed

+112
-209
lines changed

5 files changed

+112
-209
lines changed

internal/controller/mcp_route.go

Lines changed: 21 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"cmp"
1010
"context"
1111
"fmt"
12+
"strings"
1213

1314
egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
1415
"github.com/go-logr/logr"
@@ -251,23 +252,20 @@ func (c *MCPRouteController) newMainHTTPRoute(dst *gwapiv1.HTTPRoute, mcpRoute *
251252

252253
// Add OAuth metadata endpoints if authentication is configured.
253254
if mcpRoute.Spec.SecurityPolicy != nil && mcpRoute.Spec.SecurityPolicy.OAuth != nil {
254-
// Extract path component for RFC 8414 compliant well-known URI construction
255-
// RFC 8414: https://datatracker.ietf.org/doc/html/rfc8414#section-3.1
256-
// Pattern: "/.well-known/oauth-authorization-server" + issuer_path_component.
257-
258255
// OAuth 2.0 Protected Resource Metadata (RFC 9728) - serve in both root and suffix paths because different clients
259256
// may expect either.
260257
// TODO: only one MCPRoute targeting the same listener can be configured with OAuth due to the fixed well-known path.
261258
httpRouteFilterName := oauthProtectedResourceMetadataName(mcpRoute.Name)
262259

263-
// Root path: /.well-known/oauth-protected-resource.
264-
protectedResourceRootRule := gwapiv1.HTTPRouteRule{
265-
Name: ptr.To(gwapiv1.SectionName("oauth-protected-resource-metadata-root")),
260+
// Suffix path: /.well-known/oauth-protected-resource{pathPrefix} (if pathPrefix exists).
261+
protectedResourceSuffixPath := fmt.Sprintf("/.well-known/oauth-protected-resource%s", strings.TrimSuffix(servingPath, "/"))
262+
protectedResourceSuffixRule := gwapiv1.HTTPRouteRule{
263+
Name: ptr.To(gwapiv1.SectionName("oauth-protected-resource-metadata")),
266264
Matches: []gwapiv1.HTTPRouteMatch{
267265
{
268266
Path: &gwapiv1.HTTPPathMatch{
269267
Type: ptr.To(gwapiv1.PathMatchExact),
270-
Value: ptr.To(oauthWellKnownProtectedResourceMetadataPath),
268+
Value: ptr.To(protectedResourceSuffixPath),
271269
},
272270
},
273271
},
@@ -282,47 +280,22 @@ func (c *MCPRouteController) newMainHTTPRoute(dst *gwapiv1.HTTPRoute, mcpRoute *
282280
},
283281
},
284282
}
285-
rules = append(rules, protectedResourceRootRule)
286-
287-
// Suffix path: /.well-known/oauth-protected-resource{pathPrefix} (if pathPrefix exists).
288-
if servingPath != "/" {
289-
protectedResourceSuffixPath := fmt.Sprintf("/.well-known/oauth-protected-resource%s", servingPath)
290-
protectedResourceSuffixRule := gwapiv1.HTTPRouteRule{
291-
Name: ptr.To(gwapiv1.SectionName("oauth-protected-resource-metadata-suffix")),
292-
Matches: []gwapiv1.HTTPRouteMatch{
293-
{
294-
Path: &gwapiv1.HTTPPathMatch{
295-
Type: ptr.To(gwapiv1.PathMatchExact),
296-
Value: ptr.To(protectedResourceSuffixPath),
297-
},
298-
},
299-
},
300-
Filters: []gwapiv1.HTTPRouteFilter{
301-
{
302-
Type: gwapiv1.HTTPRouteFilterExtensionRef,
303-
ExtensionRef: &gwapiv1.LocalObjectReference{
304-
Group: gwapiv1.Group("gateway.envoyproxy.io"),
305-
Kind: gwapiv1.Kind("HTTPRouteFilter"),
306-
Name: gwapiv1.ObjectName(httpRouteFilterName),
307-
},
308-
},
309-
},
310-
}
311-
rules = append(rules, protectedResourceSuffixRule)
312-
}
283+
rules = append(rules, protectedResourceSuffixRule)
313284

314-
// OAuth 2.0 Authorization Server Metadata (RFC 8414) - serve in both root and suffix paths because different clients
315-
// may expect either.
285+
// Extract path component for RFC 8414 compliant well-known URI construction
286+
// RFC 8414: https://datatracker.ietf.org/doc/html/rfc8414#section-3.1
287+
// Pattern: "/.well-known/oauth-authorization-server" + issuer_path_component.
316288
authServerMeataFilterName := oauthAuthServerMetadataFilterName(mcpRoute.Name)
317289

318-
// Root path: /.well-known/oauth-authorization-server.
319-
authServerRootRule := gwapiv1.HTTPRouteRule{
320-
Name: ptr.To(gwapiv1.SectionName("oauth-authorization-server-metadata-root")),
290+
// Suffix path: /.well-known/oauth-authorization-server{pathPrefix} (if pathPrefix exists).
291+
authServerSuffixPath := fmt.Sprintf("%s%s", oauthWellKnownAuthorizationServerMetadataPath, strings.TrimSuffix(servingPath, "/"))
292+
authServerSuffixRule := gwapiv1.HTTPRouteRule{
293+
Name: ptr.To(gwapiv1.SectionName("oauth-authorization-server-metadata")),
321294
Matches: []gwapiv1.HTTPRouteMatch{
322295
{
323296
Path: &gwapiv1.HTTPPathMatch{
324297
Type: ptr.To(gwapiv1.PathMatchExact),
325-
Value: ptr.To(oauthWellKnownAuthorizationServerMetadataPath),
298+
Value: ptr.To(authServerSuffixPath),
326299
},
327300
},
328301
},
@@ -337,14 +310,15 @@ func (c *MCPRouteController) newMainHTTPRoute(dst *gwapiv1.HTTPRoute, mcpRoute *
337310
},
338311
},
339312
}
340-
// Root path: /.well-known/openid-configuration.
341-
authServerRootOIDCRule := gwapiv1.HTTPRouteRule{
342-
Name: ptr.To(gwapiv1.SectionName("oauth-authorization-server-metadata-root-oidc")),
313+
// Suffix path: /.well-known/openid-configuration{pathPrefix} (if pathPrefix exists).
314+
authServerSuffixPathOIDC := fmt.Sprintf("%s%s", oidcWellKnownMetadataPath, servingPath)
315+
authServerSuffixRuleOIDC := gwapiv1.HTTPRouteRule{
316+
Name: ptr.To(gwapiv1.SectionName("oauth-authorization-server-metadata-oidc")),
343317
Matches: []gwapiv1.HTTPRouteMatch{
344318
{
345319
Path: &gwapiv1.HTTPPathMatch{
346320
Type: ptr.To(gwapiv1.PathMatchExact),
347-
Value: ptr.To(oidcWellKnownMetadataPath),
321+
Value: ptr.To(authServerSuffixPathOIDC),
348322
},
349323
},
350324
},
@@ -359,57 +333,7 @@ func (c *MCPRouteController) newMainHTTPRoute(dst *gwapiv1.HTTPRoute, mcpRoute *
359333
},
360334
},
361335
}
362-
rules = append(rules, authServerRootRule, authServerRootOIDCRule)
363-
364-
if servingPath != "/" {
365-
// Suffix path: /.well-known/oauth-authorization-server{pathPrefix} (if pathPrefix exists).
366-
authServerSuffixPath := fmt.Sprintf("%s%s", oauthWellKnownAuthorizationServerMetadataPath, servingPath)
367-
authServerSuffixRule := gwapiv1.HTTPRouteRule{
368-
Name: ptr.To(gwapiv1.SectionName("oauth-authorization-server-metadata-suffix")),
369-
Matches: []gwapiv1.HTTPRouteMatch{
370-
{
371-
Path: &gwapiv1.HTTPPathMatch{
372-
Type: ptr.To(gwapiv1.PathMatchExact),
373-
Value: ptr.To(authServerSuffixPath),
374-
},
375-
},
376-
},
377-
Filters: []gwapiv1.HTTPRouteFilter{
378-
{
379-
Type: gwapiv1.HTTPRouteFilterExtensionRef,
380-
ExtensionRef: &gwapiv1.LocalObjectReference{
381-
Group: gwapiv1.Group("gateway.envoyproxy.io"),
382-
Kind: gwapiv1.Kind("HTTPRouteFilter"),
383-
Name: gwapiv1.ObjectName(authServerMeataFilterName),
384-
},
385-
},
386-
},
387-
}
388-
// Suffix path: /.well-known/openid-configuration{pathPrefix} (if pathPrefix exists).
389-
authServerSuffixPathOIDC := fmt.Sprintf("%s%s", oidcWellKnownMetadataPath, servingPath)
390-
authServerSuffixRuleOIDC := gwapiv1.HTTPRouteRule{
391-
Name: ptr.To(gwapiv1.SectionName("oauth-authorization-server-metadata-suffix-oidc")),
392-
Matches: []gwapiv1.HTTPRouteMatch{
393-
{
394-
Path: &gwapiv1.HTTPPathMatch{
395-
Type: ptr.To(gwapiv1.PathMatchExact),
396-
Value: ptr.To(authServerSuffixPathOIDC),
397-
},
398-
},
399-
},
400-
Filters: []gwapiv1.HTTPRouteFilter{
401-
{
402-
Type: gwapiv1.HTTPRouteFilterExtensionRef,
403-
ExtensionRef: &gwapiv1.LocalObjectReference{
404-
Group: gwapiv1.Group("gateway.envoyproxy.io"),
405-
Kind: gwapiv1.Kind("HTTPRouteFilter"),
406-
Name: gwapiv1.ObjectName(authServerMeataFilterName),
407-
},
408-
},
409-
},
410-
}
411-
rules = append(rules, authServerSuffixRule, authServerSuffixRuleOIDC)
412-
}
336+
rules = append(rules, authServerSuffixRule, authServerSuffixRuleOIDC)
413337
}
414338
dst.Spec.Rules = rules
415339

internal/controller/mcp_route_security_policy.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,11 @@ func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata
290290
// Extract base URL and path from resource identifier.
291291
resourceURL := strings.TrimSuffix(metadata.Resource, "/")
292292

293-
var baseURL string
294-
var prefixLen int
293+
var (
294+
baseURL string
295+
prefixLen int
296+
pathComponent string
297+
)
295298
switch {
296299
case strings.HasPrefix(resourceURL, "https://"):
297300
prefixLen = 8
@@ -303,14 +306,17 @@ func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata
303306

304307
if idx := strings.Index(resourceURL[prefixLen:], "/"); idx != -1 {
305308
baseURL = resourceURL[:prefixLen+idx]
309+
pathComponent = resourceURL[prefixLen+idx:]
306310
} else {
307311
baseURL = resourceURL
308312
}
309313

310-
// Some agents do not expect the path component to be included in the resource_metadata URL.
311-
// TODO: test with different agents and see if this would cause issues.
312-
// resourceMetadataURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource%s", baseURL, pathComponent).
313-
resourceMetadataURL := fmt.Sprintf("%s%s", baseURL, oauthWellKnownProtectedResourceMetadataPath)
314+
// Some agents do not expect the path component to be included in the resource_metadata URL, but according to the
315+
// spec https://mcp.mintlify.app/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
316+
// they should honor hte value returned here.
317+
// We can't expose these resource at the root, because there may be multiple MCP routes with different OAuth settings, so we need
318+
// to rely on clients properly implementing the spec and using this value returned in the header.
319+
resourceMetadataURL := fmt.Sprintf("%s%s%s", baseURL, oauthWellKnownProtectedResourceMetadataPath, pathComponent)
314320
// Build the basic Bearer challenge.
315321
headerValue := `Bearer error="invalid_request", error_description="No access token was provided in this request"`
316322

internal/controller/mcp_route_security_policy_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ func Test_buildWWWAuthenticateHeaderValue(t *testing.T) {
596596
metadata: &aigv1a1.ProtectedResourceMetadata{
597597
Resource: "https://api.example.com/mcp/v1",
598598
},
599-
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"`,
599+
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp/v1"`,
600600
},
601601
{
602602
name: "https URL without path",
@@ -610,14 +610,14 @@ func Test_buildWWWAuthenticateHeaderValue(t *testing.T) {
610610
metadata: &aigv1a1.ProtectedResourceMetadata{
611611
Resource: "https://api.example.com/mcp/",
612612
},
613-
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"`,
613+
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"`,
614614
},
615615
{
616616
name: "http URL with path",
617617
metadata: &aigv1a1.ProtectedResourceMetadata{
618618
Resource: "http://api.example.com/mcp/v1",
619619
},
620-
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="http://api.example.com/.well-known/oauth-protected-resource"`,
620+
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="http://api.example.com/.well-known/oauth-protected-resource/mcp/v1"`,
621621
},
622622
{
623623
name: "http URL without path",
@@ -631,28 +631,28 @@ func Test_buildWWWAuthenticateHeaderValue(t *testing.T) {
631631
metadata: &aigv1a1.ProtectedResourceMetadata{
632632
Resource: "http://api.example.com/mcp/",
633633
},
634-
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="http://api.example.com/.well-known/oauth-protected-resource"`,
634+
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="http://api.example.com/.well-known/oauth-protected-resource/mcp"`,
635635
},
636636
{
637637
name: "URL with port number https",
638638
metadata: &aigv1a1.ProtectedResourceMetadata{
639639
Resource: "https://api.example.com:8080/mcp",
640640
},
641-
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.example.com:8080/.well-known/oauth-protected-resource"`,
641+
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.example.com:8080/.well-known/oauth-protected-resource/mcp"`,
642642
},
643643
{
644644
name: "URL with port number http",
645645
metadata: &aigv1a1.ProtectedResourceMetadata{
646646
Resource: "http://api.example.com:8080/mcp",
647647
},
648-
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="http://api.example.com:8080/.well-known/oauth-protected-resource"`,
648+
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="http://api.example.com:8080/.well-known/oauth-protected-resource/mcp"`,
649649
},
650650
{
651651
name: "complex path with multiple segments",
652652
metadata: &aigv1a1.ProtectedResourceMetadata{
653653
Resource: "https://api.example.com/v1/mcp/endpoint",
654654
},
655-
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"`,
655+
expected: `Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/v1/mcp/endpoint"`,
656656
},
657657
}
658658

internal/controller/mcp_route_test.go

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -217,19 +217,14 @@ func Test_newHTTPRoute_MCPOauth(t *testing.T) {
217217
err := ctrlr.newMainHTTPRoute(httpRoute, mcpRoute)
218218
require.NoError(t, err)
219219

220-
require.Len(t, httpRoute.Spec.Rules, 7) // 6 default routes for oauth which begins from index 1.
220+
require.Len(t, httpRoute.Spec.Rules, 4) // 3 default routes for oauth which begins from index 1.
221221
oauthRules := httpRoute.Spec.Rules[1:]
222-
require.Equal(t, "oauth-protected-resource-metadata-root", string(ptr.Deref(oauthRules[0].Name, "")))
223-
require.Equal(t, "oauth-protected-resource-metadata-suffix", string(ptr.Deref(oauthRules[1].Name, "")))
224-
require.Equal(t, "oauth-authorization-server-metadata-root", string(ptr.Deref(oauthRules[2].Name, "")))
225-
require.Equal(t, "oauth-authorization-server-metadata-root-oidc", string(ptr.Deref(oauthRules[3].Name, "")))
226-
require.Equal(t, "oauth-authorization-server-metadata-suffix", string(ptr.Deref(oauthRules[4].Name, "")))
227-
require.Equal(t, "oauth-authorization-server-metadata-suffix-oidc", string(ptr.Deref(oauthRules[5].Name, "")))
228-
229-
require.Equal(t, "/.well-known/oauth-authorization-server", ptr.Deref(oauthRules[2].Matches[0].Path.Value, ""))
230-
require.Equal(t, "/.well-known/openid-configuration", ptr.Deref(oauthRules[3].Matches[0].Path.Value, ""))
231-
require.Equal(t, "/.well-known/oauth-authorization-server/mcp", ptr.Deref(oauthRules[4].Matches[0].Path.Value, ""))
232-
require.Equal(t, "/.well-known/openid-configuration/mcp", ptr.Deref(oauthRules[5].Matches[0].Path.Value, ""))
222+
require.Equal(t, "oauth-protected-resource-metadata", string(ptr.Deref(oauthRules[0].Name, "")))
223+
require.Equal(t, "oauth-authorization-server-metadata", string(ptr.Deref(oauthRules[1].Name, "")))
224+
require.Equal(t, "oauth-authorization-server-metadata-oidc", string(ptr.Deref(oauthRules[2].Name, "")))
225+
require.Equal(t, "/.well-known/oauth-protected-resource/mcp", ptr.Deref(oauthRules[0].Matches[0].Path.Value, ""))
226+
require.Equal(t, "/.well-known/oauth-authorization-server/mcp", ptr.Deref(oauthRules[1].Matches[0].Path.Value, ""))
227+
require.Equal(t, "/.well-known/openid-configuration/mcp", ptr.Deref(oauthRules[2].Matches[0].Path.Value, ""))
233228
}
234229

235230
func TestMCPRouteController_updateMCPRouteStatus(t *testing.T) {

0 commit comments

Comments
 (0)