Skip to content

Conversation

@zhaohuabing
Copy link
Member

@zhaohuabing zhaohuabing commented Nov 4, 2025

Description

This PR introduces MCP spec compatible scope based authorization for MCPRoutes.

According to the 2025-11-25 version of the MCP spec, the MCP Gateway should enforce scope-based authorization on behalf of the backend MCP server, and include the required scopes in the WWW-Authenticate header of the 403 response if authoriation fails.

https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors

When a client makes a request with an access token with insufficient scope during runtime operations, the server SHOULD respond with:
HTTP 403 Forbidden status code
WWW-Authenticate header with the Bearer scheme and additional parameters:
error="insufficient_scope" - indicating the specific type of authorization failure
scope="required_scope1 required_scope2" - specifying the minimum scopes needed for the operation
resource_metadata - the URI of the Protected Resource Metadata document (for consistency with 401 responses)
error_description (optional) - human-readable description of the error

Example:

spec:
  ...
  securityPolicy:
    oauth:
      ...
    authorization:
      rules:
        - source:
            jwt:
              scopes:
                - echo
          target:
            tools:
              - backend: mcp-backend-authorization
                toolName: echo
                arguments: args.text.matches("^Hello, .*!$")
        - source:
            jwt:
              scopes:
                - sum
          target:
            tools:
              - backend: mcp-backend-authorization
                toolName: sum

Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling

Implements #1459

@zhaohuabing zhaohuabing requested a review from a team as a code owner November 4, 2025 07:14
@zhaohuabing zhaohuabing marked this pull request as draft November 4, 2025 07:14
@dosubot dosubot bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Nov 4, 2025
@codecov-commenter
Copy link

codecov-commenter commented Nov 4, 2025

Codecov Report

❌ Patch coverage is 62.28571% with 66 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.92%. Comparing base (e0326cd) to head (9271f0b).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
internal/controller/gateway.go 0.00% 30 Missing and 1 partial ⚠️
internal/mcpproxy/authorization.go 81.74% 16 Missing and 7 partials ⚠️
internal/mcpproxy/handlers.go 20.00% 7 Missing and 1 partial ⚠️
internal/mcpproxy/mcpproxy.go 0.00% 4 Missing ⚠️

❌ Your patch status has failed because the patch coverage (62.28%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1482      +/-   ##
==========================================
- Coverage   83.24%   82.92%   -0.32%     
==========================================
  Files         137      138       +1     
  Lines       12042    12213     +171     
==========================================
+ Hits        10024    10128     +104     
- Misses       1411     1470      +59     
- Partials      607      615       +8     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@zhaohuabing zhaohuabing force-pushed the mcp-authorization branch 2 times, most recently from d1ea251 to 877d598 Compare November 4, 2025 07:45
@zhaohuabing zhaohuabing force-pushed the mcp-authorization branch 2 times, most recently from 4d2e8d5 to 0ccf737 Compare November 4, 2025 08:05
@nacx nacx self-assigned this Nov 4, 2025
@zhaohuabing zhaohuabing force-pushed the mcp-authorization branch 4 times, most recently from 29db138 to 2dd862c Compare November 28, 2025 09:32
@zhaohuabing zhaohuabing force-pushed the mcp-authorization branch 3 times, most recently from 2167726 to a95087c Compare December 2, 2025 02:14
@zhaohuabing zhaohuabing marked this pull request as ready for review December 2, 2025 03:21
@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Dec 2, 2025
@zhaohuabing zhaohuabing force-pushed the mcp-authorization branch 5 times, most recently from b77c937 to f8a07cc Compare December 3, 2025 03:19
// +kubebuilder:validation:Optional
// +kubebuilder:validation:MaxLength=4096
// +optional
Arguments *string `json:"arguments,omitempty"`
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After discussing this with @nacx offline, we chose CEL for argument evaluation.

CEL is easier to write and understand than regex evaluation when the arguments are complex object types.

For example:

To check against this argument:

{
  "key-level1": {
    "key-level2": {
      "key-level3": "value"
    }
  }
}

The regex match:

/"key-level1"\s*:\s*\{\s*"key-level2"\s*:\s*\{\s*"key-level3"\s*:\s*"value"\s*\}\s*\}/gm

The CEL match:

args["key-level1"]["key-level2"]["key-level3"] == "value"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about renaming this to condition? If we do this, we could also easily support meta in addition to args to have policies based on the contents of the metadata.

Copy link
Member Author

@zhaohuabing zhaohuabing Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should meta be inside the ToolCall, or MCPAuthorizationTarget?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh meta is part of the CallToolParams - then condition is a good.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about it again, condition feels a bit vague and also used in the status field, some alternatives:

  • When
  • Predicate
  • Matcher

When reads naturally in the context of authorization rules:

tools:
  - backend: backend1
    tool: listFiles
    when: args.folder == "restricted"

Signed-off-by: Huabing Zhao <[email protected]>
Signed-off-by: Huabing Zhao <[email protected]>
Signed-off-by: Huabing Zhao <[email protected]>
Signed-off-by: Huabing Zhao <[email protected]>
Signed-off-by: Huabing Zhao <[email protected]>
Signed-off-by: Huabing Zhao <[email protected]>
@zhaohuabing zhaohuabing force-pushed the mcp-authorization branch 2 times, most recently from 0cab6d9 to 746bdab Compare December 3, 2025 03:55
//
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:MaxItems=16
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason for this limitation?

Copy link
Member Author

@zhaohuabing zhaohuabing Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to set a sane limitation for the size of the tool list for one rule. We can adjust it as needed in the future, but I think 16 should be OK for now.

// +kubebuilder:validation:Optional
// +kubebuilder:validation:MaxLength=4096
// +optional
Arguments *string `json:"arguments,omitempty"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about renaming this to condition? If we do this, we could also easily support meta in addition to args to have policies based on the contents of the metadata.

}

// If the rules are defined, a valid bearer token is required.
token, err := bearerToken(headers.Get("Authorization"))
Copy link
Member

@nacx nacx Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should decouple the "authenticated principal extraction" from the authorization code (see my other comments around interfaces, etc).


for _, rule := range authorization.Rules {
if !m.toolMatches(backendName, toolName, rule.Target, arguments) {
continue
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, with the current logic, if the MCPRoute has, say 20 tools, and I just want to limit access so that for one particular tool I can not use a particular argument (e.g. you can't call the filesystem tool passing "/root" as an argument), then I could have to create 20 auth rules to allow all tools, and then the rule with the argument filter for that particular rule, right?

Is this the UX we want?
Should we consider bringing back the default action? Having a default allow and explicit denies can also help when MCP servers add more tools, etc (and we can consider that with the existing "tool filtering" we're only exposing the tools that we want to be called... so having to always list all allowed tools if you have any rule could not be the best UX.

WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or perhaps we should also support wildcards in tool names? We need to come up with a way to facilitate users doing what they need without being excessively verbose on the API.

Copy link
Member Author

@zhaohuabing zhaohuabing Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I think we should bring the default action back. Supporting wildcard is not enough here - the tool names may not share a similar name pattern.

Copy link
Member Author

@zhaohuabing zhaohuabing Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use case in your example should work now, with a Deny rule for restricted folders and a Allow rule with the required sopes.

authorization:
  defaultAction: Deny
  rules:
    - action: Deny
      target:
        tools:
          - backend: backend1
            tool: listFiles
            arguments: 'args.folder == "restricted"'

    - action: Allow
      source:
        jwt:
          scopes:
            - read
      target:
        tools:
          - backend: backend1
            tool: listFiles

nacx added a commit that referenced this pull request Dec 3, 2025
**Description**

Add the configured scopes to the `WWW-Authenticate` headers. At
initialization time, which is when the first authentication will occur,
we don't have enough information to provide a fine-grained list of
scopes, so the best we can do is to default to the ones defined in the
protected resource metadata.

**Related Issues/PRs (if applicable)**

Fixes #1578

The addition of the header on 403 requests is implemented in
#1482, but this issue can
be closed as soon as this PR is merged, because we'll be compatible with
the latest spec.

**Special notes for reviewers (if applicable)**

cc @zhaohuabing can you take a look?

Signed-off-by: Ignasi Barrera <[email protected]>
Signed-off-by: Huabing Zhao <[email protected]>
} else {
claims := jwt.MapClaims{}
// JWT verification is performed by Envoy before reaching here. So we only need to parse the token without verification.
if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil {

Check failure

Code scanning / CodeQL

Missing JWT signature check High

This JWT is parsed without verification and received from
this user-controlled source
.
Signed-off-by: Huabing Zhao <[email protected]>
@zhaohuabing
Copy link
Member Author

zhaohuabing commented Dec 4, 2025

WDYT about renaming this to condition? If we do this, we could also easily support meta in addition to args to have policies based on the contents of the metadata.

https://github.com/envoyproxy/ai-gateway/pull/1482/files#r2587966887

Thinking about it again, condition feels a bit vague and also used in the status field, some alternatives:

  • When
  • Predicate
  • Matcher

When reads naturally in the context of authorization rules:

tools:
  - backend: backend1
    tool: listFiles
    when: args.folder == "restricted"

Signed-off-by: Huabing Zhao <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants