Skip to content

Commit b53dcf2

Browse files
feat(auth): add Protected Resource Metadata endpoint (#2698)
## Description > Should include a concise description of the changes (bug or feature), it's > impact, along with a summary of the solution ## PR Checklist > Thank you for opening a Pull Request! Before submitting your PR, there are a > few things you can do to make sure it goes smoothly: - [ ] Make sure you reviewed [CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md) - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) - [ ] Make sure to add `!` if this involve a breaking change 🛠️ Fixes #<issue_number_goes_here> --------- Co-authored-by: Averi Kitsch <akitsch@google.com>
1 parent 5a3465e commit b53dcf2

File tree

8 files changed

+319
-6
lines changed

8 files changed

+319
-6
lines changed

cmd/internal/flags.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func ServeFlags(flags *pflag.FlagSet, opts *ToolboxOptions) {
6464
flags.BoolVar(&opts.Cfg.Stdio, "stdio", false, "Listens via MCP STDIO instead of acting as a remote HTTP server.")
6565
flags.BoolVar(&opts.Cfg.UI, "ui", false, "Launches the Toolbox UI web server.")
6666
flags.BoolVar(&opts.Cfg.EnableAPI, "enable-api", false, "Enable the /api endpoint.")
67-
67+
flags.StringVar(&opts.Cfg.ToolboxUrl, "toolbox-url", "", "Specifies the Toolbox URL. Used as the resource field in the MCP PRM file when MCP Auth is enabled. Falls back to TOOLBOX_URL environment variable.")
6868
flags.StringSliceVar(&opts.Cfg.AllowedOrigins, "allowed-origins", []string{"*"}, "Specifies a list of origins permitted to access this server. Defaults to '*'.")
6969
flags.StringSliceVar(&opts.Cfg.AllowedHosts, "allowed-hosts", []string{"*"}, "Specifies a list of hosts permitted to access this server. Defaults to '*'.")
7070
}

cmd/root.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"github.com/googleapis/genai-toolbox/cmd/internal/serve"
3838
"github.com/googleapis/genai-toolbox/cmd/internal/skills"
3939
"github.com/googleapis/genai-toolbox/internal/auth"
40+
"github.com/googleapis/genai-toolbox/internal/auth/generic"
4041
"github.com/googleapis/genai-toolbox/internal/embeddingmodels"
4142
"github.com/googleapis/genai-toolbox/internal/prompts"
4243
"github.com/googleapis/genai-toolbox/internal/server"
@@ -450,6 +451,21 @@ func run(cmd *cobra.Command, opts *internal.ToolboxOptions) error {
450451
return err
451452
}
452453

454+
// Validate ToolboxUrl if MCP Auth is enabled
455+
for _, authSvc := range opts.Cfg.AuthServiceConfigs {
456+
if genCfg, ok := authSvc.(generic.Config); ok && genCfg.McpEnabled {
457+
if opts.Cfg.ToolboxUrl == "" {
458+
opts.Cfg.ToolboxUrl = os.Getenv("TOOLBOX_URL")
459+
}
460+
if opts.Cfg.ToolboxUrl == "" {
461+
errMsg := fmt.Errorf("MCP Auth is enabled but Toolbox URL is missing. Please provide it via --toolbox-url flag or TOOLBOX_URL environment variable")
462+
opts.Logger.ErrorContext(ctx, errMsg.Error())
463+
return errMsg
464+
}
465+
break
466+
}
467+
}
468+
453469
// start server
454470
s, err := server.NewServer(ctx, opts.Cfg)
455471
if err != nil {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
---
2+
title: "Toolbox with MCP Authorization"
3+
type: docs
4+
weight: 4
5+
description: >
6+
How to set up and configure Toolbox with [MCP Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
7+
---
8+
9+
## Overview
10+
11+
Toolbox supports integration with Model Context Protocol (MCP) clients by acting as a Resource Server that implements OAuth 2.1 authorization. This enables Toolbox to validate JWT-based Bearer tokens before processing requests for resources or tool executions.
12+
13+
This guide details the specific configuration steps required to deploy Toolbox with MCP Auth enabled.
14+
15+
## Step 1: Configure the `generic` Auth Service
16+
17+
Update your `tools.yaml` file to use a `generic` authorization service with `mcpEnabled` set to `true`. This instructs Toolbox to intercept requests on the `/mcp` routes and validate Bearer tokens using the JWKS (JSON Web Key Set) fetched from your OIDC provider endpoint (`authorizationServer`).
18+
19+
```yaml
20+
kind: authServices
21+
name: my-mcp-auth
22+
type: generic
23+
mcpEnabled: true
24+
authorizationServer: "https://accounts.google.com" # Your authorization server URL
25+
audience: "your-mcp-audience" # Matches the `aud` claim in the JWT
26+
scopesRequired:
27+
- "mcp:tools"
28+
```
29+
30+
When `mcpEnabled` is true, Toolbox also provisions the `/.well-known/oauth-protected-resource` Protected Resource Metadata (PRM) endpoint automatically using the `authorizationServer`.
31+
32+
## Step 2: Deployment
33+
34+
Deploying Toolbox with MCP auth requires defining the `TOOLBOX_URL` that the deployed service will use, as this URL must be included as the `resource` field in the PRM returned to the client.
35+
36+
You can set this either through the `TOOLBOX_URL` environment variable or the `--toolbox-url` command-line flag during deployment.
37+
38+
### Local Deployment
39+
40+
To run Toolbox locally with MCP auth enabled, simply export the `TOOLBOX_URL` referencing your local port before running the binary:
41+
42+
```bash
43+
export TOOLBOX_URL="http://127.0.0.1:5000"
44+
./toolbox --tools-file tools.yaml
45+
```
46+
47+
If you prefer to use the `--toolbox-url` flag explicitly:
48+
49+
```bash
50+
./toolbox --tools-file tools.yaml --toolbox-url "http://127.0.0.1:5000"
51+
```
52+
53+
### Cloud Run Deployment
54+
55+
```bash
56+
export IMAGE="us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:latest"
57+
58+
# Pass your target Cloud Run URL to the `--toolbox-url` flag
59+
gcloud run deploy toolbox \
60+
--image $IMAGE \
61+
--service-account toolbox-identity \
62+
--region us-central1 \
63+
--set-secrets "/app/tools.yaml=tools:latest" \
64+
--args="--tools-file=/app/tools.yaml","--address=0.0.0.0","--port=8080","--toolbox-url=${CLOUD_RUN_TOOLBOX_URL}"
65+
```
66+
67+
### Alternative: Manual PRM File Override
68+
69+
If you strictly need to define your own Protected Resource Metadata instead of auto-generating it from the `AuthService` config, you can use the `--mcp-prm-file <path>` flag.
70+
71+
1. Create a `prm.json` containing the RFC-9207 compliant metadata. Note that the `resource` field must match the `TOOLBOX_URL`:
72+
```json
73+
{
74+
"resource": "https://toolbox-service-123456789-uc.a.run.app",
75+
"authorization_servers": ["https://your-auth-server.example.com"],
76+
"scopes_supported": ["mcp:tools"],
77+
"bearer_methods_supported": ["header"]
78+
}
79+
```
80+
2. Set the `--mcp-prm-file` flag to the path of the PRM file.
81+
82+
- If you are using local deployment, you can just provide the path to the file directly:
83+
```bash
84+
./toolbox --tools-file tools.yaml --mcp-prm-file prm.json
85+
```
86+
- If you are using Cloud Run, upload it to GCP Secret Manager and Attach the secret to the Cloud Run deployment and provide the flag.
87+
```bash
88+
gcloud secrets create prm_file --data-file=prm.json
89+
90+
gcloud run deploy toolbox \
91+
# ... previous args
92+
--set-secrets "/app/tools.yaml=tools:latest,/app/prm.json=prm_file:latest" \
93+
--args="--tools-file=/app/tools.yaml","--mcp-prm-file=/app/prm.json","--port=8080"
94+
```
95+
96+
## Step 3: Connecting to the Secure MCP Endpoint
97+
98+
Once the Cloud Run instance is deployed, your MCP client must obtain a valid JWT token from your authorization server (the `authorizationServer` in `tools.yaml`).
99+
100+
The client should provide this JWT via the standard HTTP `Authorization` header when connecting to the Streamable HTTP or SSE endpoint (`/mcp`):
101+
102+
```bash
103+
{
104+
"mcpServers": {
105+
"toolbox-secure": {
106+
"type": "http",
107+
"url": "https://toolbox-service-123456789-uc.a.run.app/mcp",
108+
"headers": {
109+
"Authorization": "Bearer <your-jwt-access-token>"
110+
}
111+
}
112+
}
113+
}
114+
```
115+
Important: The token provided in the Authorization header must be a JWT token (issued by the auth server you configured previously), not a Google Cloud Run access token.
116+
117+
Toolbox will intercept incoming connections, fetch the latest JWKS from your authorizationServer, and validate that the aud (audience), signature, and scopes on the JWT match the requirements defined by your mcpEnabled auth service.
118+
119+
If your Cloud Run service also requires IAM authentication, you must pass the Cloud Run identity token using [Cloud Run's alternate auth header][cloud-run-alternate-auth-header] to avoid conflicting with Toolbox's internal authentication.
120+
121+
[cloud-run-alternate-auth-header]: https://docs.cloud.google.com/run/docs/authenticating/service-to-service#acquire-token

internal/auth/generic/generic_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,6 @@ func TestGetClaimsFromHeader(t *testing.T) {
151151
}
152152
},
153153
},
154-
155154
{
156155
name: "wrong audience",
157156
setupHeader: func() http.Header {
@@ -167,7 +166,6 @@ func TestGetClaimsFromHeader(t *testing.T) {
167166
wantError: true,
168167
errContains: "audience validation failed",
169168
},
170-
171169
{
172170
name: "expired token",
173171
setupHeader: func() http.Header {

internal/server/config.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ type ServerConfig struct {
7272
UI bool
7373
// EnableAPI indicates if the /api endpoint is enabled.
7474
EnableAPI bool
75+
// ToolboxUrl specifies the URL to advertise in the MCP PRM file as the resource field.
76+
ToolboxUrl string
7577
// Specifies a list of origins permitted to access this server.
7678
AllowedOrigins []string
7779
// Specifies a list of hosts permitted to access this server.
@@ -80,8 +82,6 @@ type ServerConfig struct {
8082
UserAgentMetadata []string
8183
// PollInterval sets the polling frequency for configuration file updates.
8284
PollInterval int
83-
// ToolboxUrl specifies the Toolbox URL. Used as the resource field in the MCP PRM file when MCP Auth is enabled.
84-
ToolboxUrl string
8585
}
8686

8787
type logFormat string
@@ -258,7 +258,6 @@ func UnmarshalYAMLAuthServiceConfig(ctx context.Context, name string, r map[stri
258258
if !ok {
259259
return nil, fmt.Errorf("missing 'type' field or it is not a string")
260260
}
261-
262261
dec, err := util.NewStrictDecoder(r)
263262
if err != nil {
264263
return nil, fmt.Errorf("error creating decoder: %s", err)

internal/server/mcp.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/go-chi/chi/v5/middleware"
3131
"github.com/go-chi/render"
3232
"github.com/google/uuid"
33+
"github.com/googleapis/genai-toolbox/internal/auth/generic"
3334
"github.com/googleapis/genai-toolbox/internal/server/mcp"
3435
"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
3536
mcputil "github.com/googleapis/genai-toolbox/internal/server/mcp/util"
@@ -760,3 +761,41 @@ func processMcpMessage(ctx context.Context, body []byte, s *Server, protocolVers
760761
return "", result, err
761762
}
762763
}
764+
765+
type prmResponse struct {
766+
Resource string `json:"resource"`
767+
AuthorizationServers []string `json:"authorization_servers"`
768+
ScopesSupported []string `json:"scopes_supported,omitempty"`
769+
BearerMethodsSupported []string `json:"bearer_methods_supported"`
770+
}
771+
772+
// prmHandler generates the Protected Resource Metadata (PRM) file for MCP Authorization.
773+
func prmHandler(s *Server, w http.ResponseWriter, r *http.Request) {
774+
var server string
775+
scopes := []string{}
776+
for _, authSvc := range s.ResourceMgr.GetAuthServiceMap() {
777+
cfg := authSvc.ToConfig()
778+
if genCfg, ok := cfg.(generic.Config); ok {
779+
if genCfg.McpEnabled {
780+
server = genCfg.AuthorizationServer
781+
if genCfg.ScopesRequired != nil {
782+
scopes = genCfg.ScopesRequired
783+
}
784+
break
785+
}
786+
}
787+
}
788+
789+
res := prmResponse{
790+
Resource: s.toolboxUrl,
791+
AuthorizationServers: []string{server},
792+
ScopesSupported: scopes,
793+
BearerMethodsSupported: []string{"header"},
794+
}
795+
796+
w.Header().Set("Content-Type", "application/json")
797+
if err := json.NewEncoder(w).Encode(res); err != nil {
798+
s.logger.ErrorContext(r.Context(), fmt.Sprintf("Failed to encode PRM response: %v", err))
799+
http.Error(w, "Failed to encode PRM response", http.StatusInternalServerError)
800+
}
801+
}

internal/server/server.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ func NewServer(ctx context.Context, cfg ServerConfig) (*Server, error) {
381381
instrumentation: instrumentation,
382382
sseManager: sseManager,
383383
ResourceMgr: resourceManager,
384+
toolboxUrl: cfg.ToolboxUrl,
384385
}
385386

386387
// cors
@@ -410,6 +411,20 @@ func NewServer(ctx context.Context, cfg ServerConfig) (*Server, error) {
410411
}
411412
r.Use(hostCheck(allowedHostsMap))
412413

414+
// Host OAuth Protected Resource Metadata endpoint
415+
mcpAuthEnabled := false
416+
for _, authSvc := range s.ResourceMgr.GetAuthServiceMap() {
417+
if genCfg, ok := authSvc.ToConfig().(generic.Config); ok && genCfg.McpEnabled {
418+
mcpAuthEnabled = true
419+
break
420+
}
421+
}
422+
if mcpAuthEnabled {
423+
r.Get("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, req *http.Request) {
424+
prmHandler(s, w, req)
425+
})
426+
}
427+
413428
// control plane
414429
mcpR, err := mcpRouter(s)
415430
if err != nil {

0 commit comments

Comments
 (0)