zuplo 7.6.8 → 7.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/docs/ai-gateway/integrations/claude-code.mdx +62 -20
  2. package/docs/ai-gateway/integrations/claude-desktop.mdx +157 -0
  3. package/docs/articles/configuring-auth0-for-mcp-auth.mdx +5 -0
  4. package/docs/articles/configuring-okta-for-mcp-auth.mdx +48 -10
  5. package/docs/articles/oauth-authentication.mdx +34 -0
  6. package/docs/handlers/mcp-server.mdx +17 -1
  7. package/docs/mcp-gateway/connect-clients/claude-desktop.mdx +2 -0
  8. package/docs/policies/ai-gateway-smart-router-inbound/doc.md +198 -29
  9. package/docs/policies/ai-gateway-smart-router-inbound/intro.md +1 -2
  10. package/docs/policies/ai-gateway-smart-router-inbound/schema.json +10 -10
  11. package/docs/policies/auth0-jwt-auth-inbound/doc.md +5 -0
  12. package/docs/policies/auth0-jwt-auth-inbound/schema.json +1 -1
  13. package/docs/policies/clerk-jwt-auth-inbound/doc.md +5 -0
  14. package/docs/policies/clerk-jwt-auth-inbound/schema.json +1 -1
  15. package/docs/policies/cognito-jwt-auth-inbound/doc.md +17 -0
  16. package/docs/policies/cognito-jwt-auth-inbound/schema.json +1 -1
  17. package/docs/policies/firebase-jwt-inbound/doc.md +17 -0
  18. package/docs/policies/firebase-jwt-inbound/schema.json +1 -1
  19. package/docs/policies/okta-jwt-auth-inbound/doc.md +23 -0
  20. package/docs/policies/okta-jwt-auth-inbound/schema.json +1 -1
  21. package/docs/policies/open-id-jwt-auth-inbound/doc.md +18 -0
  22. package/docs/policies/open-id-jwt-auth-inbound/schema.json +1 -1
  23. package/docs/policies/propel-auth-jwt-inbound/doc.md +5 -0
  24. package/docs/policies/propel-auth-jwt-inbound/schema.json +1 -1
  25. package/docs/policies/supabase-jwt-auth-inbound/doc.md +5 -0
  26. package/docs/policies/supabase-jwt-auth-inbound/schema.json +1 -1
  27. package/docs/programmable-api/oauth-protected-resource-plugin.mdx +78 -6
  28. package/package.json +5 -5
@@ -6,10 +6,9 @@ description:
6
6
  authenticated, metered, and routed by the gateway.
7
7
  ---
8
8
 
9
- The Zuplo AI Gateway supports the [Anthropic](https://docs.claude.com/en/home)
10
- `/v1/messages` API endpoint. This means that you can configure
11
- [Claude Code](https://www.claude.com/product/claude-code) to work seamlessly via
12
- the AI Gateway.
9
+ You can configure [Claude Code](https://www.claude.com/product/claude-code) to
10
+ send its model requests through the Zuplo AI Gateway, so the gateway
11
+ authenticates, meters, and routes every Claude Code session.
13
12
 
14
13
  ## Claude Code Setup
15
14
 
@@ -25,25 +24,48 @@ the AI Gateway.
25
24
 
26
25
  4. Copy the **API URL** and **API Key** shown at the top of the app page
27
26
 
28
- 5. Add the API key and gateway URL to your environment, or Claude Code settings,
29
- using either approach below
27
+ 5. Add the API key, gateway URL, and model mappings to `.claude/settings.json`
30
28
 
31
29
  </Stepper>
32
30
 
33
- ### Environment
31
+ ### Before copying: replace the provider name
34
32
 
35
- ```
36
- ANTHROPIC_AUTH_TOKEN=<your-ai-gateway-app-api-key>
37
- ANTHROPIC_BASE_URL=https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e
38
- ```
33
+ :::warning
34
+
35
+ The examples below assume your provider is named `anthropic`. Replace that
36
+ prefix with the name of **your** provider before copying the configuration. For
37
+ example, a provider named `my-anthropic` routes `my-anthropic/claude-sonnet-5`,
38
+ while a Bedrock provider named `bedrock` routes
39
+ `bedrock/anthropic.claude-sonnet-5`. The model part must be an id that provider
40
+ understands.
41
+
42
+ :::
43
+
44
+ The gateway routes every request using `providerName/model`. The provider prefix
45
+ picks which of your providers handles the request — a gateway can contain
46
+ several providers that serve Anthropic models.
39
47
 
40
48
  ### Using settings.json
41
49
 
50
+ You can configure Claude Code either globally or per project by editing the
51
+ settings file:
52
+
53
+ - **Global:** `~/.claude/settings.json`
54
+ - **Project-specific:** `.claude/settings.json` in your project directory
55
+
56
+ Add the following environment configuration to the settings file:
57
+
42
58
  ```json
43
59
  {
44
60
  "env": {
45
61
  "ANTHROPIC_AUTH_TOKEN": "<your-ai-gateway-app-api-key>",
46
- "ANTHROPIC_BASE_URL": "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e"
62
+ "ANTHROPIC_BASE_URL": "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e",
63
+ "ANTHROPIC_MODEL": "anthropic/claude-sonnet-5",
64
+ "ANTHROPIC_SMALL_FAST_MODEL": "anthropic/claude-haiku-4-5",
65
+ "ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-5",
66
+ "ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-5",
67
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4-5",
68
+ "ANTHROPIC_DEFAULT_FABLE_MODEL": "anthropic/claude-fable-5"
47
69
  }
48
70
  }
49
71
  ```
@@ -58,16 +80,36 @@ Claude Code appends `/v1/messages` to the base URL itself, so
58
80
  Restart Claude and it will switch to using your new AI Gateway configuration and
59
81
  all your Claude Code LLM requests will route through the AI Gateway.
60
82
 
61
- ### Choosing a model
83
+ ### Configure every Claude Code model variable
62
84
 
63
- The gateway routes models named as `providerName/model`, where `providerName` is
64
- the provider name configured in your gateway. Claude Code's default model names
65
- don't carry that prefix, so set the model explicitly:
85
+ The model ids in these examples reflect what was current when this page was
86
+ written, and ids differ across providers a Bedrock provider serves different
87
+ ids than Anthropic directly, and availability can vary by region. Check
88
+ [your provider's page](../managing-providers.mdx) in the portal for the models
89
+ it currently serves, and use those ids.
66
90
 
67
- ```
68
- ANTHROPIC_MODEL=anthropic/claude-sonnet-4-5-20250929
69
- ANTHROPIC_SMALL_FAST_MODEL=anthropic/claude-haiku-4-5
70
- ```
91
+ None of Claude Code's built-in model names carry a provider prefix, so each of
92
+ the model variables above must be set to a prefixed reference:
93
+
94
+ - `ANTHROPIC_MODEL` — the main model. Required: without it Claude Code sends its
95
+ built-in default model unprefixed and the first request fails.
96
+ - `ANTHROPIC_SMALL_FAST_MODEL` — the model for background tasks.
97
+ - `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`,
98
+ `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL` — what the
99
+ `opus`/`sonnet`/`haiku`/`fable` aliases resolve to, so `claude --model opus`
100
+ and `/model sonnet` keep working. In these examples,
101
+ `ANTHROPIC_DEFAULT_FABLE_MODEL` maps `fable` to `anthropic/claude-fable-5`.
102
+
103
+ :::caution{title='HTTP 400: model must use "providerName/model"'}
104
+
105
+ If Claude Code fails immediately with
106
+ `The request body model must use "providerName/model"`, the request carried a
107
+ model name without a provider prefix. The tier alias variables
108
+ (`ANTHROPIC_DEFAULT_*_MODEL`) alone don't prevent this — they don't cover Claude
109
+ Code's built-in default model. Always set `ANTHROPIC_MODEL` to a prefixed model
110
+ reference.
111
+
112
+ :::
71
113
 
72
114
  The app's
73
115
  [Model Filtering](../../policies/ai-gateway-model-filtering-v2-inbound.mdx)
@@ -0,0 +1,157 @@
1
+ ---
2
+ title: Claude Desktop
3
+ sidebar_label: Claude Desktop
4
+ description:
5
+ Point Claude Desktop at an AI Gateway app so every Chat, Cowork, and Code
6
+ session is authenticated, metered, and routed by the gateway.
7
+ ---
8
+
9
+ [Claude Desktop](https://claude.com/download) can use the Zuplo AI Gateway as
10
+ its inference provider, so the gateway authenticates, meters, and routes every
11
+ Chat, Cowork, and Code session started from the desktop app.
12
+
13
+ This page covers routing Claude Desktop's model requests through the AI Gateway.
14
+ To give Claude Desktop tools from a Zuplo MCP route instead, see
15
+ [Connect Claude Desktop and Claude.ai](../../mcp-gateway/connect-clients/claude-desktop.mdx).
16
+
17
+ ## Claude Desktop Setup
18
+
19
+ <Stepper>
20
+
21
+ 1. Create a [new provider](../managing-providers.mdx) in the AI Gateway for
22
+ Anthropic
23
+
24
+ 2. [Set up a new team](../managing-teams.mdx)
25
+
26
+ 3. Create a [new app](../managing-apps.mdx) to use with Claude Desktop and
27
+ assign it to the team you created
28
+
29
+ 4. Copy the **API URL** and **API Key** shown at the top of the app page
30
+
31
+ 5. Enter the API URL, API key, and model list in Claude Desktop's third-party
32
+ inference settings
33
+
34
+ </Stepper>
35
+
36
+ ### Before copying: replace the provider name
37
+
38
+ :::warning
39
+
40
+ The examples below assume your provider is named `anthropic`. Replace that
41
+ prefix with the name of **your** provider before copying the configuration. For
42
+ example, a provider named `my-anthropic` routes `my-anthropic/claude-sonnet-5`,
43
+ while a Bedrock provider named `bedrock` routes
44
+ `bedrock/anthropic.claude-sonnet-5`. The model part must be an id that provider
45
+ understands.
46
+
47
+ :::
48
+
49
+ The gateway routes every request using `providerName/model`. The provider prefix
50
+ picks which of your providers handles the request — a gateway can contain
51
+ several providers that serve Anthropic models.
52
+
53
+ ### Configure third-party inference
54
+
55
+ Configure Claude Desktop's inference provider in its in-app configuration
56
+ window, available on macOS and Windows:
57
+
58
+ <Stepper>
59
+
60
+ 1. From the menu bar (macOS) or the application menu (Windows), select **Help →
61
+ Troubleshooting → Enable Developer Mode**.
62
+
63
+ 2. Open **Developer → Configure Third-Party Inference**.
64
+
65
+ 3. In the **Connection** section, set **Inference provider** to **Gateway**.
66
+
67
+ 4. In the **Gateway credentials** card, set **Gateway base URL** to the app's
68
+ API URL, for example
69
+ `https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e`,
70
+ and **Gateway API key** to the app's API key.
71
+
72
+ 5. Leave **Credential kind** set to **Static API key** and **Gateway auth
73
+ scheme** set to **Bearer**.
74
+
75
+ 6. In the **Models** section, add the models the app may use to the **Model
76
+ list**. See [Add models to the Model list](#add-models-to-the-model-list).
77
+
78
+ 7. Click **Apply locally**. Claude Desktop writes the configuration and
79
+ relaunches into it.
80
+
81
+ </Stepper>
82
+
83
+ :::note
84
+
85
+ Claude Desktop appends `/v1/messages` to the base URL itself, so **Gateway base
86
+ URL** is the app's URL _without_ the `/v1` suffix.
87
+
88
+ :::
89
+
90
+ :::caution{title="Keep the auth scheme set to Bearer"}
91
+
92
+ The gateway reads the API key from the `Authorization: Bearer` header, so keep
93
+ **Gateway auth scheme** set to **Bearer**. Likewise, keep **Credential kind**
94
+ set to **Static API key** — AI Gateway apps authenticate with their own API
95
+ keys, not identity-provider tokens.
96
+
97
+ :::
98
+
99
+ ### Add models to the Model list
100
+
101
+ The **Model list** in the **Models** section overrides the model list Claude
102
+ Desktop discovers on its own. Click **Add** once per model the app may use, and
103
+ fill in each entry:
104
+
105
+ - **Model ID** — the full `providerName/model` reference, for example
106
+ `anthropic/claude-sonnet-5`. A bare id such as `claude-sonnet-5` fails to
107
+ route through the gateway.
108
+ - **Display name** — the name shown in the model picker, for example
109
+ `Claude Sonnet 5`. Leave it blank and Claude Desktop formats a name from the
110
+ id.
111
+ - **Offer 1M-context variant** — leave this off unless your provider's
112
+ deployment accepts 1M-token context for the model.
113
+ - **Tier alias** — the Claude tier (Opus, Sonnet, Haiku, or Fable) this model
114
+ stands in for. Setting it pins what the bare alias (for example `opus`)
115
+ resolves to, so tier names route to your prefixed model reference.
116
+
117
+ The first entry is the default selection in the model picker. Include a
118
+ Haiku-tier model — Claude Desktop runs background and sub-agent tasks on a
119
+ small, fast model.
120
+
121
+ The model ids in these examples reflect what was current when this page was
122
+ written, and ids differ across providers — a Bedrock provider serves different
123
+ ids than Anthropic directly, and availability can vary by region. Check
124
+ [your provider's page](../managing-providers.mdx) in the portal for the models
125
+ it currently serves, and use those ids.
126
+
127
+ The app's
128
+ [Model Filtering](../../policies/ai-gateway-model-filtering-v2-inbound.mdx)
129
+ policy controls which models the app may use.
130
+
131
+ ### Verify the configuration
132
+
133
+ After Claude Desktop relaunches, send a message in a Chat conversation and start
134
+ a Cowork session. Both requests route through the gateway and count against the
135
+ [app](../managing-apps.mdx) in the Zuplo Portal, as do Code sessions started
136
+ from the desktop app. Terminal Claude Code sessions outside the desktop app use
137
+ a separate configuration — see [Claude Code](./claude-code.mdx).
138
+
139
+ :::caution{title='HTTP 400: model must use "providerName/model"'}
140
+
141
+ If a request fails with `The request body model must use "providerName/model"`,
142
+ the selected model reference is missing its provider prefix. Give every entry in
143
+ the model list a full `providerName/model` reference.
144
+
145
+ :::
146
+
147
+ ### Deploy to a fleet
148
+
149
+ To roll the configuration out to many devices, use the configuration window's
150
+ **Export** menu instead of **Apply locally**. It generates a `.mobileconfig`
151
+ profile for macOS MDM tools such as Jamf, a `.reg` policy file for Intune or
152
+ Group Policy, and related deployment artifacts. Managed configuration takes
153
+ precedence over local settings, so users can't point the app at a different
154
+ endpoint. For the deployment workflow and the full configuration key reference,
155
+ see Anthropic's
156
+ [Deploy Claude Desktop with an LLM gateway](https://claude.com/docs/third-party/claude-desktop/gateway)
157
+ and [Deploy with MDM](https://claude.com/docs/third-party/claude-desktop/mdm).
@@ -174,11 +174,16 @@ will need to do the following:
174
174
  new OAuthProtectedResourcePlugin({
175
175
  authorizationServers: ["https://your-auth0-domain.us.auth0.com"],
176
176
  resourceName: "My MCP OAuth Resource",
177
+ scopesSupported: ["openid", "profile", "email", "offline_access"],
177
178
  }),
178
179
  );
179
180
  }
180
181
  ```
181
182
 
183
+ `scopesSupported` lists the scopes MCP clients request. Replace the example
184
+ values with the permissions your Auth0 API defines; keep `offline_access` if
185
+ clients need refresh tokens.
186
+
182
187
  See the
183
188
  [OAuth Protected Resource Plugin docs](../programmable-api/oauth-protected-resource-plugin)
184
189
  for more details.
@@ -50,6 +50,19 @@ Next, you'll need to configure the scopes for your authorization server.
50
50
  tools").
51
51
  5. Check **Set as a default scope** and click **Create**.
52
52
 
53
+ :::caution{title="Reserved Okta scopes break MCP clients"}
54
+
55
+ Every Okta custom authorization server also publishes `device_sso` and
56
+ `interclient_access` in its `scopes_supported` metadata. The **Include in public
57
+ metadata** toggle for those two scopes is disabled, so you can't remove them. An
58
+ MCP client that builds its scope list from the authorization server's metadata
59
+ requests them together with `mcp:access`, and Okta rejects the authorization
60
+ request with `illegal_scope_combination`. To keep clients off that list, set
61
+ `scopesSupported` on the `OAuthProtectedResourcePlugin` in
62
+ [Configure OAuth on Zuplo](#configure-oauth-on-zuplo) below.
63
+
64
+ :::
65
+
53
66
  ## Create an OAuth Client Application
54
67
 
55
68
  Next, you'll need to create an OAuth client application for your MCP server.
@@ -102,7 +115,10 @@ Now create a rule for this policy:
102
115
  **Device Authorization**
103
116
  - **User is**: Select **Any user assigned the app**
104
117
  - **Scopes requested**: Select **The following scopes** and choose the scope
105
- you created for the authorization server (that is, `mcp:access`)
118
+ you created for the authorization server (that is, `mcp:access`) and
119
+ `offline_access`, so clients can obtain refresh tokens. Okta rejects a
120
+ request that includes a scope no rule allows, so this list must cover every
121
+ scope you put in `scopesSupported` later in this guide.
106
122
  4. In the **THEN AND** section:
107
123
  - **Use this inline hook**: None (disabled)
108
124
  - **Access token lifetime is**: Set to desired value (for example, 1 hour)
@@ -128,7 +144,7 @@ will need to do the following:
128
144
  "options": {
129
145
  "oAuthResourceMetadataEnabled": true,
130
146
  "audience": "https://my-gateway.zuplo.dev/mcp",
131
- "issuer": "https://your-okta-domain.okta.com/oauth2/your-auth-server-id"
147
+ "issuerUrl": "https://your-okta-domain.okta.com/oauth2/your-auth-server-id"
132
148
  }
133
149
  }
134
150
  }
@@ -136,10 +152,10 @@ will need to do the following:
136
152
 
137
153
  - Replace `my-gateway.zuplo.dev/mcp` with the audience you defined in your
138
154
  authorization server.
139
- - Replace `your-okta-domain` in the `issuer` field with your actual Okta
155
+ - Replace `your-okta-domain` in the `issuerUrl` field with your actual Okta
140
156
  domain.
141
- - Replace `your-auth-server-id` in the `issuer` field with the actual ID of
142
- your Okta authorization server.
157
+ - Replace `your-auth-server-id` in the `issuerUrl` field with the actual ID
158
+ of your Okta authorization server.
143
159
 
144
160
  2. Add the OAuth policy to the MCP Server route. For example:
145
161
 
@@ -179,19 +195,24 @@ will need to do the following:
179
195
  "https://your-okta-domain.okta.com/oauth2/your-auth-server-id",
180
196
  ],
181
197
  resourceName: "My MCP OAuth Resource",
198
+ scopesSupported: ["mcp:access", "offline_access"],
182
199
  }),
183
200
  );
184
201
  }
185
202
  ```
186
203
 
187
- - Replace `your-okta-domain` in the `issuer` field with your actual Okta
188
- domain.
189
- - Replace `your-auth-server-id` in the `issuer` field with the actual ID of
190
- your Okta authorization server.
204
+ - Replace `your-okta-domain` in the `authorizationServers` value with your
205
+ actual Okta domain.
206
+ - Replace `your-auth-server-id` in the `authorizationServers` value with the
207
+ actual ID of your Okta authorization server.
208
+ - Set `scopesSupported` to the scopes you created in
209
+ [Configure Scopes](#configure-scopes). Keep `offline_access` if clients
210
+ need refresh tokens. Add `openid`, `profile`, or `email` only if your
211
+ tokens need those claims.
191
212
 
192
213
  This plugin populates the `.well-known` routes for the MCP server auth
193
214
  metadata discovery. This enables MCP clients to automatically discover the
194
- authorization issuer endpoint. See the
215
+ authorization issuer endpoint and the scopes to request. See the
195
216
  [OAuth Protected Resource Plugin docs](../programmable-api/oauth-protected-resource-plugin)
196
217
  for more details on this runtime plugin.
197
218
 
@@ -206,3 +227,20 @@ further instructions on testing your MCP server with `curl`.
206
227
 
207
228
  If you need more help debugging, see
208
229
  [Testing OAuth on Zuplo](../handlers/mcp-server.mdx#oauth-testing).
230
+
231
+ ## Troubleshooting
232
+
233
+ ### `illegal_scope_combination` in the Okta system log
234
+
235
+ **Cause:** The MCP client requested `device_sso` or `interclient_access`
236
+ together with your custom scopes. Okta publishes those two scopes in every
237
+ custom authorization server's metadata and can't hide them, and a client that
238
+ finds no scopes in the 401 challenge or the protected resource metadata falls
239
+ back to that list. Okta rejects the combination and grants no scopes.
240
+
241
+ **Fix:** Set `scopesSupported` on the `OAuthProtectedResourcePlugin` to the
242
+ scopes you created, as shown in
243
+ [Configure OAuth on Zuplo](#configure-oauth-on-zuplo), and redeploy. The gateway
244
+ then advertises exactly those scopes in both the 401 challenge and the metadata
245
+ document, so the client never consults the authorization server's list.
246
+ Reconnect the client so it repeats discovery.
@@ -52,3 +52,37 @@ If you would like to remove the authorization header after you use one of the
52
52
  authorization policies, simply add the
53
53
  [Remove Request Headers](/docs/policies/remove-headers-inbound) policy after the
54
54
  authorization policy and set it to remove the `Authorization` header.
55
+
56
+ ## OAuth 2.0 Protected Resource Metadata
57
+
58
+ OAuth clients, including MCP clients, discover how to obtain a token for your
59
+ API through
60
+ [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728).
61
+ Zuplo implements it in two parts:
62
+
63
+ - The
64
+ [`OAuthProtectedResourcePlugin`](../programmable-api/oauth-protected-resource-plugin.mdx)
65
+ serves the metadata document at `/.well-known/oauth-protected-resource` and
66
+ every path beneath it. The document lists your authorization servers, a
67
+ human-readable resource name, and, when you set `scopesSupported`, the scopes
68
+ clients should request.
69
+ - The `oAuthResourceMetadataEnabled` option on the JWT authentication policies
70
+ makes the policy answer requests without a bearer token with a 401 response
71
+ that carries a `WWW-Authenticate` header. The header's `resource_metadata`
72
+ parameter points at the metadata document for that route, and its `scope`
73
+ parameter repeats `scopesSupported` when the plugin sets it.
74
+
75
+ ```http
76
+ HTTP/1.1 401 Unauthorized
77
+ WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp", scope="mcp:access offline_access"
78
+ ```
79
+
80
+ The
81
+ [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
82
+ requires clients to discover the authorization server this way, and tells them
83
+ to take the scopes they request from the challenge first and from the metadata
84
+ document second. Set `scopesSupported` whenever a policy protects an MCP server,
85
+ so clients request the scopes your resource expects instead of every scope the
86
+ authorization server advertises. See
87
+ [Advertising supported scopes](../programmable-api/oauth-protected-resource-plugin.mdx#advertising-supported-scopes)
88
+ for the full order of precedence and an Okta-specific pitfall.
@@ -412,14 +412,24 @@ enable OAuth authentication on your MCP Server:
412
412
  new OAuthProtectedResourcePlugin({
413
413
  authorizationServers: ["https://your-auth0-domain.us.auth0.com"],
414
414
  resourceName: "My MCP OAuth Resource",
415
+ scopesSupported: ["mcp:access", "offline_access"],
415
416
  }),
416
417
  );
417
418
  }
418
419
  ```
419
420
 
421
+ `scopesSupported` lists the scopes MCP clients should request. The plugin
422
+ emits it as `scopes_supported` in the metadata document, and the OAuth policy
423
+ adds it as the `scope` parameter of its 401 `WWW-Authenticate` challenge.
424
+ Without it, some clients request every scope the authorization server
425
+ advertises. Replace the example values with the scopes your authorization
426
+ server defines for this resource.
427
+
420
428
  See the
421
429
  [OAuth Protected Resource Plugin docs](../programmable-api/oauth-protected-resource-plugin.mdx)
422
- for more details.
430
+ for all options and
431
+ [Advertising supported scopes](../programmable-api/oauth-protected-resource-plugin.mdx#advertising-supported-scopes)
432
+ for how clients pick scopes.
423
433
 
424
434
  ### API Key Auth
425
435
 
@@ -697,6 +707,12 @@ in the MCP Inspector UI to move to the next step.
697
707
  canonical URL of your Authorization server, and registered an OAuth policy to
698
708
  the route of your MCP server.
699
709
 
710
+ If the authorization server later rejects the authorization request with a
711
+ scope error (Okta reports `illegal_scope_combination`), the client built its
712
+ scope list from the authorization server's metadata. Set `scopesSupported` on
713
+ the plugin so the client requests only those scopes. See
714
+ [Advertising supported scopes](../programmable-api/oauth-protected-resource-plugin.mdx#advertising-supported-scopes).
715
+
700
716
  2. **Client Registration**: The MCP Inspector will try to use
701
717
  [Dynamic Client Registration](https://modelcontextprotocol.io/specification/draft/basic/authorization#dynamic-client-registration)
702
718
  to register a new client with the Authorization server. Note that not all MCP
@@ -153,6 +153,8 @@ file-based configuration.
153
153
  ## Related
154
154
 
155
155
  - [Connect MCP clients overview](./overview.mdx)
156
+ - To route Claude Desktop's model requests through Zuplo instead, see the
157
+ [Claude Desktop AI Gateway integration](../../ai-gateway/integrations/claude-desktop.mdx)
156
158
  - Anthropic's official guide:
157
159
  [Connect to remote MCP servers](https://modelcontextprotocol.io/docs/develop/connect-remote-servers)
158
160
  - Anthropic's setup article:
@@ -1,21 +1,14 @@
1
1
  # AI Gateway Smart Router
2
2
 
3
3
  Use this policy to classify the last user prompt on Chat Completions, Responses,
4
- and Anthropic Messages requests. It calls a dedicated AI Gateway application
5
- (`/{classifierAppID}/v1/chat/completions`) and stores the result on
6
- `AIGatewaySmartRouter` for later policies in the same request.
4
+ and Anthropic Messages requests. Using the classification results, configure
5
+ where to route the request based on its complexity.
7
6
 
8
- When `smartRoutingEnabled` is true, it overwrites completions routing from
9
- `modelsByComplexity`. Place it **after** Model Filtering so an invalid client
10
- model is still rejected before classification runs. The classified model then
11
- replaces that selection.
7
+ When `smartRoutingEnabled` is true, it overwrites completions routing based on
8
+ the configuration in `modelsByComplexity`.
12
9
 
13
- Classification is optional. Timeouts, classifier errors, unreadable bodies, and
14
- invalid options fail open: the original request is forwarded.
15
-
16
- > **Loop prevention.** The classifier hop is an `invokeRoute` sub-request. The
17
- > policy no-ops when `context.parentContext` is set, so the classifier app can
18
- > share the same route chain without classifying its own request.
10
+ > **Classification failure handling.** If the message classification fails or
11
+ > times out, the request is forwarded to the original model.
19
12
 
20
13
  ## Required options
21
14
 
@@ -27,10 +20,9 @@ invalid options fail open: the original request is forwarded.
27
20
  - `modelsByComplexity` — `providerName/model` for each of `low`, `medium`, and
28
21
  `high`. Used for routing when `smartRoutingEnabled` is true.
29
22
 
30
- Omit `intents` and `classifierPrompt` to use the built-in taxonomy (code,
23
+ Omit `intents` and `classifierPrompt` to use the built-in dictionary (code,
31
24
  summarization, translation, qa, conversation, classification, creative_writing,
32
- agentic, document_qa, other) and the built-in system prompt. Include
33
- `{{intents}}` in a custom prompt to inject the configured intent list.
25
+ agentic, document_qa, other) and the built-in classification prompt.
34
26
 
35
27
  ## Example
36
28
 
@@ -67,30 +59,207 @@ filtering already selected a model. Filtering skips when routing is already set,
67
59
  so putting this policy first would also skip allow-list checks on the client's
68
60
  original model.
69
61
 
70
- ## Read the result from custom code
62
+ ## How classification drives routing
63
+
64
+ Complexity is classified independently of intent, as `low`, `medium`, or `high`.
65
+ Smart Router looks up `modelsByComplexity[complexity]` and applies it only when
66
+ **all** of the following hold:
67
+
68
+ - `smartRoutingEnabled` is `true`.
69
+ - The classified intent is a known one (not capped as an unknown intent).
70
+ - `modelsByComplexity` has a model configured for that complexity.
71
+ - `profile.confidence >= minConfidenceForRouting` (default `0.5`).
72
+
73
+ When routing isn't applied, read `smartRouting.reason` from the result (see
74
+ [Using classification results in custom code](#using-classification-results-in-custom-code))
75
+ to see why: `disabled`, `unknown-intent`, `no-model`, `low-confidence`,
76
+ `internal-error`, or `applied`.
77
+
78
+ ### Other advanced options
79
+
80
+ | Option | Default | Purpose |
81
+ | ------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `minConfidenceForRouting` | `0.5` | Raise it to route only on confident classifications; lower it to route more aggressively. Unknown intents are always capped just below this value, so they never qualify regardless of the setting. |
83
+ | `classifierTimeoutMs` | `8000` | How long to wait for the classifier before giving up and forwarding the request unclassified. |
84
+ | `maxPromptChars` | `8000` | Truncates the prompt sent to the classifier, to keep classifier cost and latency bounded on very long prompts. |
85
+
86
+ ## Advanced configuration
87
+
88
+ Use these options to control what the classifier evaluates and how strongly its
89
+ result influences routing.
90
+
91
+ ### Custom intents
92
+
93
+ `intents` replaces the built-in dictionary entirely — it's not additive. Provide
94
+ a non-empty list of `{ id, description }` pairs:
95
+
96
+ ```json
97
+ {
98
+ "intents": [
99
+ {
100
+ "id": "billing",
101
+ "description": "Questions about invoices, payments, or subscription plans."
102
+ },
103
+ {
104
+ "id": "support",
105
+ "description": "Troubleshooting or how-to questions about the product."
106
+ }
107
+ ]
108
+ }
109
+ ```
110
+
111
+ The `id` values become the enum the classifier model must return — the
112
+ classifier calls a strict JSON-schema chat completion, so it can only return one
113
+ of your configured ids. The `description` values are only shown to the
114
+ classifier if your prompt includes `{{intents}}` (see below).
115
+
116
+ If the classifier returns an `id` outside this list, Smart Router keeps it as an
117
+ "unknown intent" and caps its confidence just below `minConfidenceForRouting`,
118
+ so it's never eligible for routing.
119
+
120
+ ### Custom classifier prompt
121
+
122
+ `classifierPrompt` replaces the built-in classifier prompt. It accepts either a
123
+ string or an array of lines:
124
+
125
+ ```json
126
+ {
127
+ "classifierPrompt": [
128
+ "You are an intent classifier for a billing support bot.",
129
+ "Return JSON only that matches the schema.",
130
+ "Intent (pick the single most specific match):",
131
+ "{{intents}}"
132
+ ]
133
+ }
134
+ ```
135
+
136
+ Include the literal placeholder `{{intents}}` anywhere in the prompt to have it
137
+ replaced with a `- id: description` line for each configured intent (or the
138
+ built-in ones, if `intents` is also omitted). Pairing a custom `intents` list
139
+ with the built-in `classifierPrompt` (by omitting `classifierPrompt` entirely)
140
+ works out of the box, because the built-in prompt already contains
141
+ `{{intents}}`.
142
+
143
+ ## Using classification results in custom code
144
+
145
+ `AIGatewaySmartRouter.get(context)` returns the result Smart Router stored on
146
+ the request, or `undefined` if it didn't run (non-AI request, unreadable body,
147
+ or a fail-open error):
71
148
 
72
149
  ```typescript
73
150
  import { AIGatewaySmartRouter } from "@zuplo/runtime";
74
151
 
75
152
  const result = AIGatewaySmartRouter.get(context);
76
- if (result?.profile.intent === "code") {
77
- // ...
153
+ ```
154
+
155
+ `result` has this shape:
156
+
157
+ ```typescript
158
+ interface AIGatewaySmartRouterResult {
159
+ profile: {
160
+ intent: string;
161
+ complexity: "low" | "medium" | "high";
162
+ confidence: number;
163
+ reasons: string[];
164
+ };
165
+ usage: {
166
+ promptTokens: number;
167
+ completionTokens: number;
168
+ totalTokens: number;
169
+ };
170
+ classifierModel: string;
171
+ routing: { model?: string };
172
+ durationMs: number;
173
+ promptSource: "user" | "prior-user";
174
+ promptLength: number;
175
+ promptTruncated: boolean;
176
+ unknownIntent: boolean;
177
+ smartRouting: {
178
+ enabled: boolean;
179
+ applied: boolean;
180
+ reason:
181
+ | "applied"
182
+ | "disabled"
183
+ | "low-confidence"
184
+ | "no-model"
185
+ | "internal-error"
186
+ | "unknown-intent";
187
+ minConfidence: number;
188
+ };
78
189
  }
79
190
  ```
80
191
 
81
- ## How prompt text is chosen
192
+ Read it from any policy placed after Smart Router in the chain. Common uses:
193
+
194
+ - **Branch on intent or complexity** — apply a stricter rate limit, a different
195
+ DLP policy, or a longer timeout for `high` complexity or an `agentic` intent:
196
+
197
+ ```typescript
198
+ const result = AIGatewaySmartRouter.get(context);
199
+ if (result?.profile.complexity === "high") {
200
+ // e.g. apply a stricter rate limit or route to a review queue
201
+ }
202
+ ```
203
+
204
+ - **Layer business logic on top of `modelsByComplexity`** — for example, cap the
205
+ model for free-tier callers regardless of classified complexity. Read the plan
206
+ from the caller's API key metadata, not from a raw request header (the caller
207
+ controls headers and could set or omit them to bypass the cap). The built-in
208
+ API Key Auth policy puts a key's metadata on `request.user.data` — set `plan`
209
+ there when you create the key, and it lands on every request that key makes:
210
+
211
+ ```typescript
212
+ import { AIGatewayModelRouting, AIGatewaySmartRouter } from "@zuplo/runtime";
213
+
214
+ const result = AIGatewaySmartRouter.get(context);
215
+ const isFreeTier = request.user?.data.plan === "free";
216
+ if (result?.profile.complexity === "high" && isFreeTier) {
217
+ await AIGatewayModelRouting.set(context, {
218
+ completions: "openai/gpt-4o-mini",
219
+ });
220
+ }
221
+ ```
222
+
223
+ - **Observe why routing wasn't applied** — log when `smartRouting.reason` is
224
+ `low-confidence` or `unknown-intent` to tune `minConfidenceForRouting` or the
225
+ intent taxonomy:
226
+
227
+ ```typescript
228
+ const result = AIGatewaySmartRouter.get(context);
229
+ if (result && !result.smartRouting.applied) {
230
+ context.log.info(
231
+ { reason: result.smartRouting.reason },
232
+ "Smart routing skipped"
233
+ );
234
+ }
235
+ ```
236
+
237
+ - **Surface classification for debugging** — add response headers in a
238
+ non-production environment to see what the classifier returned. This runs in
239
+ an outbound policy, so return a new `Response` carrying the headers — mutating
240
+ a cloned `Headers` object alone has no effect on what the caller receives:
241
+
242
+ ```typescript
243
+ const result = AIGatewaySmartRouter.get(context);
244
+ if (!result) {
245
+ return response;
246
+ }
247
+
248
+ const headers = new Headers(response.headers);
249
+ headers.set("x-classified-intent", result.profile.intent);
250
+ headers.set("x-classified-complexity", result.profile.complexity);
82
251
 
83
- The policy reads the typed body from the route (`getFormat` / `getRequestBody`),
84
- then takes the last real user text:
252
+ return new Response(response.body, {
253
+ headers,
254
+ status: response.status,
255
+ statusText: response.statusText,
256
+ });
257
+ ```
85
258
 
86
- - `/v1/chat/completions` OpenAI chat `messages[]`, even when the downstream
87
- provider is Anthropic or Google (Zuplo translates below the policy chain).
88
- - `/v1/responses` — OpenAI Responses `input`.
89
- - `/v1/messages` — native Anthropic `messages[]`.
259
+ ## What content is evaluated
90
260
 
91
- Tool follow-up turns (`tool` / `function` roles, `tool_result` blocks,
92
- `function_call_output` items) are skipped so a prior user message is classified
93
- instead. Embeddings and other non-AI paths are skipped.
261
+ The policy reads the last user message in order to classify its intent and
262
+ complexity. Embeddings, tool messages and other non-AI paths are skipped.
94
263
 
95
264
  ## Fail-open behavior
96
265
 
@@ -1,4 +1,3 @@
1
1
  Classifies the last user prompt by calling a dedicated classifier AI Gateway
2
2
  app, stores the result on `AIGatewaySmartRouter` for later policies, and
3
- optionally routes completions by classified complexity. Classifier failures fail
4
- open so the original request still reaches the model.
3
+ optionally routes completions by classified complexity.
@@ -58,7 +58,7 @@
58
58
  "classifierAppID": {
59
59
  "type": "string",
60
60
  "title": "Classifier App ID",
61
- "description": "The AI Gateway application id whose `/v1/chat/completions` route runs the classifier. The policy calls `/{classifierAppID}/v1/chat/completions` via `context.invokeRoute`.",
61
+ "description": "The AI Gateway application id used to run the classifier prompt and evaluate the user's request.",
62
62
  "examples": ["config_1234"]
63
63
  },
64
64
  "classifierAppApiKey": {
@@ -70,20 +70,20 @@
70
70
  "classifierModel": {
71
71
  "type": "string",
72
72
  "title": "Classifier Model",
73
- "description": "The `providerName/model` reference the classifier chat/completions request should use.",
73
+ "description": "The model (`providerName/model`) the classifier uses to evaluate the user's request. E.g. `openai/gpt-4o-mini`.",
74
74
  "pattern": "^[^/\\s]+/.+$",
75
75
  "examples": ["openai/gpt-4o-mini"]
76
76
  },
77
77
  "smartRoutingEnabled": {
78
78
  "type": "boolean",
79
79
  "title": "Smart Routing Enabled",
80
- "description": "When true, apply model routing from `modelsByComplexity` when confidence is high enough and the intent is known. Classification still runs when false.",
80
+ "description": "When true, apply model routing based on the models set in `modelsByComplexity` when confidence score meets the `minConfidenceForRouting` threshold. When set to false, classification still runs but model routing is not applied, useful for debugging or testing the classifier prompt.",
81
81
  "default": false
82
82
  },
83
83
  "modelsByComplexity": {
84
84
  "type": "object",
85
85
  "title": "Models By Complexity",
86
- "description": "`providerName/model` routing by classified complexity. All of `low`, `medium`, and `high` are required. Used only when `smartRoutingEnabled` is true.",
86
+ "description": "Enables which model will be used based on the classifier results (`low`, `medium` and `high`). This configuration is applied only when `smartRoutingEnabled` is true and confidence score meets the `minConfidenceForRouting` threshold.",
87
87
  "additionalProperties": false,
88
88
  "required": ["low", "medium", "high"],
89
89
  "properties": {
@@ -113,7 +113,7 @@
113
113
  "intents": {
114
114
  "type": "array",
115
115
  "title": "Intents",
116
- "description": "Labels the classifier may assign. Used to build the JSON schema enum and injected into `classifierPrompt` at `{{intents}}`. Omit to use the built-in taxonomy (code, summarization, translation, qa, conversation, classification, creative\\_writing, agentic, document\\_qa, other).",
116
+ "description": "Dictionary of intents used to classify the user message being evaluated. Omit to use the built-in dictionary (code, summarization, translation, qa, conversation, classification, creative\\_writing, agentic, document\\_qa, other).",
117
117
  "minItems": 1,
118
118
  "items": {
119
119
  "type": "object",
@@ -123,14 +123,14 @@
123
123
  "id": {
124
124
  "type": "string",
125
125
  "title": "Intent ID",
126
- "description": "Stable intent identifier written into the classifier JSON schema enum.",
126
+ "description": "Label used to classify the intent of the user message being evaluated.",
127
127
  "minLength": 1,
128
128
  "examples": ["code"]
129
129
  },
130
130
  "description": {
131
131
  "type": "string",
132
132
  "title": "Intent Description",
133
- "description": "Short description shown to the classifier for this intent.",
133
+ "description": "Short description of the intent label.",
134
134
  "examples": [
135
135
  "Write, edit, refactor, debug, or review source code."
136
136
  ]
@@ -140,7 +140,7 @@
140
140
  },
141
141
  "classifierPrompt": {
142
142
  "title": "Classifier Prompt",
143
- "description": "System prompt for the classifier. If it includes `{{intents}}`, that placeholder is replaced with the configured intent id/description list. A string or an array of lines (joined with newlines). Omit to use the built-in classifier prompt.",
143
+ "description": "Prompt used to analyze and classify the user message. Omit to use the built-in classifier prompt.",
144
144
  "oneOf": [
145
145
  {
146
146
  "type": "string",
@@ -167,7 +167,7 @@
167
167
  "classifierTimeoutMs": {
168
168
  "type": "integer",
169
169
  "title": "Classifier Timeout (ms)",
170
- "description": "How long to wait for the classifier `invokeRoute` call before skipping classification and forwarding the original request.",
170
+ "description": "Timeout configured for the classifier task. When exceeded, the request is forwarded without classification to the original model.",
171
171
  "minimum": 1,
172
172
  "maximum": 120000,
173
173
  "default": 8000,
@@ -176,7 +176,7 @@
176
176
  "maxPromptChars": {
177
177
  "type": "integer",
178
178
  "title": "Max Prompt Characters",
179
- "description": "Maximum characters of user prompt sent to the classifier. Longer prompts are truncated.",
179
+ "description": "Maximum characters of user message sent to the classifier. Longer messages get truncated.",
180
180
  "minimum": 1,
181
181
  "default": 8000,
182
182
  "x-advanced": true
@@ -121,5 +121,10 @@ When configured, this enables OAuth clients to find metadata information about
121
121
  how to interact with your OAuth 2.0 protected resources according to
122
122
  [`RFC 9728`](https://datatracker.ietf.org/doc/html/rfc9728).
123
123
 
124
+ When the plugin is configured with `scopesSupported`, the 401 response also
125
+ lists those scopes in the `scope` parameter of its `WWW-Authenticate` header, so
126
+ MCP clients request exactly the scopes your resource expects instead of every
127
+ scope the authorization server advertises.
128
+
124
129
  See [this document](/docs/articles/oauth-authentication) for more information
125
130
  about OAuth authorization in Zuplo.
@@ -56,7 +56,7 @@
56
56
  "type": "boolean",
57
57
  "default": false,
58
58
  "x-advanced": true,
59
- "description": "Flag that determines whether OAuth protected resource metadata is enabled."
59
+ "description": "Enables OAuth 2.0 Protected Resource Metadata discovery (RFC 9728). When `true`, requests without a bearer token receive a 401 whose `WWW-Authenticate` header points `resource_metadata` at the `/.well-known/oauth-protected-resource` document for the request path and, when the `OAuthProtectedResourcePlugin` declares `scopesSupported`, lists those scopes in `scope`. Requires the `OAuthProtectedResourcePlugin` in `zuplo.runtime.ts` or a user-defined route at that path."
60
60
  }
61
61
  }
62
62
  }
@@ -81,5 +81,10 @@ When configured, this enables OAuth clients to find metadata information about
81
81
  how to interact with your OAuth 2.0 protected resources according to
82
82
  [`RFC 9728`](https://datatracker.ietf.org/doc/html/rfc9728).
83
83
 
84
+ When the plugin is configured with `scopesSupported`, the 401 response also
85
+ lists those scopes in the `scope` parameter of its `WWW-Authenticate` header, so
86
+ MCP clients request exactly the scopes your resource expects instead of every
87
+ scope the authorization server advertises.
88
+
84
89
  See [this document](/docs/articles/oauth-authentication) for more information
85
90
  about OAuth authorization in Zuplo.
@@ -51,7 +51,7 @@
51
51
  "type": "boolean",
52
52
  "default": false,
53
53
  "x-advanced": true,
54
- "description": "Flag that determines whether OAuth protected resource metadata is enabled."
54
+ "description": "Enables OAuth 2.0 Protected Resource Metadata discovery (RFC 9728). When `true`, requests without a bearer token receive a 401 whose `WWW-Authenticate` header points `resource_metadata` at the `/.well-known/oauth-protected-resource` document for the request path and, when the `OAuthProtectedResourcePlugin` declares `scopesSupported`, lists those scopes in `scope`. Requires the `OAuthProtectedResourcePlugin` in `zuplo.runtime.ts` or a user-defined route at that path."
55
55
  }
56
56
  }
57
57
  }
@@ -0,0 +1,17 @@
1
+ ## OAuth 2.0 Protected Resource Metadata
2
+
3
+ The Cognito JWT Auth policy supports OAuth protected resource metadata
4
+ discovery. To enable this feature, set the `oAuthResourceMetadataEnabled` option
5
+ to `true` and add the
6
+ [`OAuthProtectedResourcePlugin` to `modules/zuplo.runtime.ts`](/docs/programmable-api/oauth-protected-resource-plugin).
7
+ When configured, this enables OAuth clients to find metadata information about
8
+ how to interact with your OAuth 2.0 protected resources according to
9
+ [`RFC 9728`](https://datatracker.ietf.org/doc/html/rfc9728).
10
+
11
+ When the plugin is configured with `scopesSupported`, the 401 response also
12
+ lists those scopes in the `scope` parameter of its `WWW-Authenticate` header, so
13
+ MCP clients request exactly the scopes your resource expects instead of every
14
+ scope the authorization server advertises.
15
+
16
+ See [this document](/docs/articles/oauth-authentication) for more information
17
+ about OAuth authorization in Zuplo.
@@ -56,7 +56,7 @@
56
56
  "type": "boolean",
57
57
  "default": false,
58
58
  "x-advanced": true,
59
- "description": "Flag that determines whether OAuth protected resource metadata is enabled."
59
+ "description": "Enables OAuth 2.0 Protected Resource Metadata discovery (RFC 9728). When `true`, requests without a bearer token receive a 401 whose `WWW-Authenticate` header points `resource_metadata` at the `/.well-known/oauth-protected-resource` document for the request path and, when the `OAuthProtectedResourcePlugin` declares `scopesSupported`, lists those scopes in `scope`. Requires the `OAuthProtectedResourcePlugin` in `zuplo.runtime.ts` or a user-defined route at that path."
60
60
  }
61
61
  }
62
62
  }
@@ -0,0 +1,17 @@
1
+ ## OAuth 2.0 Protected Resource Metadata
2
+
3
+ The Firebase JWT Auth policy supports OAuth protected resource metadata
4
+ discovery. To enable this feature, set the `oAuthResourceMetadataEnabled` option
5
+ to `true` and add the
6
+ [`OAuthProtectedResourcePlugin` to `modules/zuplo.runtime.ts`](/docs/programmable-api/oauth-protected-resource-plugin).
7
+ When configured, this enables OAuth clients to find metadata information about
8
+ how to interact with your OAuth 2.0 protected resources according to
9
+ [`RFC 9728`](https://datatracker.ietf.org/doc/html/rfc9728).
10
+
11
+ When the plugin is configured with `scopesSupported`, the 401 response also
12
+ lists those scopes in the `scope` parameter of its `WWW-Authenticate` header, so
13
+ MCP clients request exactly the scopes your resource expects instead of every
14
+ scope the authorization server advertises.
15
+
16
+ See [this document](/docs/articles/oauth-authentication) for more information
17
+ about OAuth authorization in Zuplo.
@@ -51,7 +51,7 @@
51
51
  "type": "boolean",
52
52
  "default": false,
53
53
  "x-advanced": true,
54
- "description": "Flag that determines whether OAuth protected resource metadata is enabled."
54
+ "description": "Enables OAuth 2.0 Protected Resource Metadata discovery (RFC 9728). When `true`, requests without a bearer token receive a 401 whose `WWW-Authenticate` header points `resource_metadata` at the `/.well-known/oauth-protected-resource` document for the request path and, when the `OAuthProtectedResourcePlugin` declares `scopesSupported`, lists those scopes in `scope`. Requires the `OAuthProtectedResourcePlugin` in `zuplo.runtime.ts` or a user-defined route at that path."
55
55
  }
56
56
  }
57
57
  }
@@ -0,0 +1,23 @@
1
+ ## OAuth 2.0 Protected Resource Metadata
2
+
3
+ The Okta JWT Auth policy supports OAuth protected resource metadata discovery.
4
+ To enable this feature, set the `oAuthResourceMetadataEnabled` option to `true`
5
+ and add the
6
+ [`OAuthProtectedResourcePlugin` to `modules/zuplo.runtime.ts`](/docs/programmable-api/oauth-protected-resource-plugin).
7
+ When configured, this enables OAuth clients to find metadata information about
8
+ how to interact with your OAuth 2.0 protected resources according to
9
+ [`RFC 9728`](https://datatracker.ietf.org/doc/html/rfc9728).
10
+
11
+ When the plugin is configured with `scopesSupported`, the 401 response also
12
+ lists those scopes in the `scope` parameter of its `WWW-Authenticate` header, so
13
+ MCP clients request exactly the scopes your resource expects instead of every
14
+ scope the authorization server advertises.
15
+
16
+ Okta custom authorization servers always advertise the `device_sso` and
17
+ `interclient_access` scopes in their own metadata and reject authorization
18
+ requests that combine them with your scopes (`illegal_scope_combination`). Set
19
+ `scopesSupported` on the plugin when this policy protects an MCP server, so
20
+ clients never fall back to that list.
21
+
22
+ See [this document](/docs/articles/oauth-authentication) for more information
23
+ about OAuth authorization in Zuplo.
@@ -56,7 +56,7 @@
56
56
  "type": "boolean",
57
57
  "default": false,
58
58
  "x-advanced": true,
59
- "description": "Flag that determines whether OAuth protected resource metadata is enabled."
59
+ "description": "Enables OAuth 2.0 Protected Resource Metadata discovery (RFC 9728). When `true`, requests without a bearer token receive a 401 whose `WWW-Authenticate` header points `resource_metadata` at the `/.well-known/oauth-protected-resource` document for the request path and, when the `OAuthProtectedResourcePlugin` declares `scopesSupported`, lists those scopes in `scope`. Requires the `OAuthProtectedResourcePlugin` in `zuplo.runtime.ts` or a user-defined route at that path."
60
60
  }
61
61
  }
62
62
  }
@@ -56,3 +56,21 @@ export async function myHandler(request: ZuploRequest, context: ZuploContext) {
56
56
  For a complete example of using the user object in a
57
57
  [RequestHandler](../handlers/custom-handler.md), see
58
58
  [Setting up JWT auth with Auth0](../policies/auth0-jwt-auth-inbound.md).
59
+
60
+ ## OAuth 2.0 Protected Resource Metadata
61
+
62
+ The OpenID JWT Auth policy supports OAuth protected resource metadata discovery.
63
+ To enable this feature, set the `oAuthResourceMetadataEnabled` option to `true`
64
+ and add the
65
+ [`OAuthProtectedResourcePlugin` to `modules/zuplo.runtime.ts`](/docs/programmable-api/oauth-protected-resource-plugin).
66
+ When configured, this enables OAuth clients to find metadata information about
67
+ how to interact with your OAuth 2.0 protected resources according to
68
+ [`RFC 9728`](https://datatracker.ietf.org/doc/html/rfc9728).
69
+
70
+ When the plugin is configured with `scopesSupported`, the 401 response also
71
+ lists those scopes in the `scope` parameter of its `WWW-Authenticate` header, so
72
+ MCP clients request exactly the scopes your resource expects instead of every
73
+ scope the authorization server advertises.
74
+
75
+ See [this document](/docs/articles/oauth-authentication) for more information
76
+ about OAuth authorization in Zuplo.
@@ -89,7 +89,7 @@
89
89
  "type": "boolean",
90
90
  "default": false,
91
91
  "x-advanced": true,
92
- "description": "Flag that determines whether OAuth protected resource metadata is enabled."
92
+ "description": "Enables OAuth 2.0 Protected Resource Metadata discovery (RFC 9728). When `true`, requests without a bearer token receive a 401 whose `WWW-Authenticate` header points `resource_metadata` at the `/.well-known/oauth-protected-resource` document for the request path and, when the `OAuthProtectedResourcePlugin` declares `scopesSupported`, lists those scopes in `scope`. Requires the `OAuthProtectedResourcePlugin` in `zuplo.runtime.ts` or a user-defined route at that path."
93
93
  }
94
94
  },
95
95
  "examples": [
@@ -84,5 +84,10 @@ When configured, this enables OAuth clients to find metadata information about
84
84
  how to interact with your OAuth 2.0 protected resources according to
85
85
  [`RFC 9728`](https://datatracker.ietf.org/doc/html/rfc9728).
86
86
 
87
+ When the plugin is configured with `scopesSupported`, the 401 response also
88
+ lists those scopes in the `scope` parameter of its `WWW-Authenticate` header, so
89
+ MCP clients request exactly the scopes your resource expects instead of every
90
+ scope the authorization server advertises.
91
+
87
92
  See [this document](/docs/articles/oauth-authentication) for more information
88
93
  about OAuth authorization in Zuplo.
@@ -56,7 +56,7 @@
56
56
  "type": "boolean",
57
57
  "default": false,
58
58
  "x-advanced": true,
59
- "description": "Flag that determines whether OAuth protected resource metadata is enabled."
59
+ "description": "Enables OAuth 2.0 Protected Resource Metadata discovery (RFC 9728). When `true`, requests without a bearer token receive a 401 whose `WWW-Authenticate` header points `resource_metadata` at the `/.well-known/oauth-protected-resource` document for the request path and, when the `OAuthProtectedResourcePlugin` declares `scopesSupported`, lists those scopes in `scope`. Requires the `OAuthProtectedResourcePlugin` in `zuplo.runtime.ts` or a user-defined route at that path."
60
60
  }
61
61
  }
62
62
  }
@@ -27,3 +27,8 @@ to `true` and add the
27
27
  When configured, this enables OAuth clients to find metadata information about
28
28
  how to interact with your OAuth 2.0 protected resources according to
29
29
  [`RFC 9728`](https://datatracker.ietf.org/doc/html/rfc9728).
30
+
31
+ When the plugin is configured with `scopesSupported`, the 401 response also
32
+ lists those scopes in the `scope` parameter of its `WWW-Authenticate` header, so
33
+ MCP clients request exactly the scopes your resource expects instead of every
34
+ scope the authorization server advertises.
@@ -66,7 +66,7 @@
66
66
  "type": "boolean",
67
67
  "default": false,
68
68
  "x-advanced": true,
69
- "description": "Flag that determines whether OAuth protected resource metadata is enabled."
69
+ "description": "Enables OAuth 2.0 Protected Resource Metadata discovery (RFC 9728). When `true`, requests without a bearer token receive a 401 whose `WWW-Authenticate` header points `resource_metadata` at the `/.well-known/oauth-protected-resource` document for the request path and, when the `OAuthProtectedResourcePlugin` declares `scopesSupported`, lists those scopes in `scope`. Requires the `OAuthProtectedResourcePlugin` in `zuplo.runtime.ts` or a user-defined route at that path."
70
70
  }
71
71
  }
72
72
  }
@@ -13,13 +13,16 @@ more details.
13
13
 
14
14
  ## Usage
15
15
 
16
- This runtime plugin will register the `.well-known/oauth-protected-resource`
17
- route on your behalf. If you configure an
16
+ This runtime plugin registers the `.well-known/oauth-protected-resource` route
17
+ on your behalf, along with every path beneath it, such as
18
+ `/.well-known/oauth-protected-resource/mcp`. If you configure an
18
19
  [OAuth Policy](../articles/oauth-authentication.mdx) on a route with the
19
- `oAuthResourceMetadataEnabled` option set to `true`, then the OAuth policy will
20
- automatically add the necessary `WWW-Authenticate` header to 401 responses, with
21
- the `resource_metadata` parameter set to the URL of the
22
- `.well-known/oauth-protected-resource` endpoint.
20
+ `oAuthResourceMetadataEnabled` option set to `true`, the policy answers requests
21
+ that carry no bearer token with a 401 response and a `WWW-Authenticate` header.
22
+ The header's `resource_metadata` parameter is the URL of the
23
+ `.well-known/oauth-protected-resource` endpoint for that route. When you set
24
+ `scopesSupported`, the header also carries a `scope` parameter that lists those
25
+ scopes.
23
26
 
24
27
  ```ts
25
28
  import {
@@ -32,6 +35,7 @@ export function runtimeInit(runtime: RuntimeExtensions) {
32
35
  new OAuthProtectedResourcePlugin({
33
36
  authorizationServers: ["https://your-auth0-domain.us.auth0.com"],
34
37
  resourceName: "My MCP OAuth Resource",
38
+ scopesSupported: ["mcp:access", "offline_access"],
35
39
  }),
36
40
  );
37
41
  }
@@ -44,3 +48,71 @@ a human readable name for the resource.
44
48
  Note that the `.well-known/oauth-protected-resource` endpoint explicitly has a
45
49
  CORS policy of `anything-goes` since this is a public endpoint that should be
46
50
  accessible to anyone to check the server's OAuth configuration.
51
+
52
+ ## Options
53
+
54
+ Construct the plugin inside `runtimeInit`. The plugin validates its options when
55
+ you construct it, so an invalid value fails at startup with a
56
+ `ConfigurationError` instead of failing a client's first login.
57
+
58
+ | Option | Type | Description |
59
+ | ---------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
60
+ | `authorizationServers` | `string[]` | Canonical issuer URLs of the authorization servers that issue tokens for this resource. Each should comply with [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414). Emitted as `authorization_servers`. |
61
+ | `resourceName` | `string` | Human-readable name of the resource, intended for display to end users. RFC 9728 recommends setting it. Emitted as `resource_name`. |
62
+ | `scopesSupported` | `string[]` | Scopes that clients should request when they obtain an access token for this resource. Emitted as `scopes_supported`, and as the `scope` parameter of the 401 `WWW-Authenticate` header by OAuth policies that have `oAuthResourceMetadataEnabled` set. Each entry must be one OAuth scope token ([RFC 6749 section 3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3)): printable ASCII with no spaces, double quotes, or backslashes. Use one entry per scope, never a space-delimited string. An empty array is rejected; omit the option to advertise no scopes. |
63
+
64
+ ## Advertising supported scopes
65
+
66
+ An MCP client has to decide which scopes to request from the authorization
67
+ server. The
68
+ [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy)
69
+ tells clients to consider these sources in order:
70
+
71
+ 1. **The `scope` parameter of the `WWW-Authenticate` challenge** on the 401
72
+ response. Clients treat these scopes as authoritative.
73
+ 2. **The `scopes_supported` array in the protected resource metadata** document,
74
+ when the challenge has no `scope` parameter.
75
+ 3. **The `scopes_supported` array in the authorization server's own metadata.**
76
+ The specification doesn't list this source, but some clients fall back to it
77
+ when the first two are empty. It usually contains every scope the server
78
+ knows about.
79
+ 4. **No `scope` parameter at all.**
80
+
81
+ Setting `scopesSupported` fills the first two sources with the same list, so no
82
+ client reaches the third. For example, a plugin configured with an Okta
83
+ authorization server, `resourceName: "Acme MCP"`, and
84
+ `scopesSupported: ["mcp:access", "offline_access"]` serves this document at
85
+ `/.well-known/oauth-protected-resource/mcp`:
86
+
87
+ ```json
88
+ {
89
+ "resource": "https://api.example.com/mcp",
90
+ "authorization_servers": ["https://acme.okta.com/oauth2/aus1a2b3c"],
91
+ "resource_name": "Acme MCP",
92
+ "scopes_supported": ["mcp:access", "offline_access"]
93
+ }
94
+ ```
95
+
96
+ A request to `/mcp` without a bearer token receives:
97
+
98
+ ```http
99
+ HTTP/1.1 401 Unauthorized
100
+ WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp", scope="mcp:access offline_access"
101
+ ```
102
+
103
+ If you don't set `scopesSupported`, the document has no `scopes_supported` key
104
+ and the header has no `scope` parameter. Requests that carry an invalid or
105
+ expired token receive a 401 without the `WWW-Authenticate` header in either
106
+ case.
107
+
108
+ :::caution{title="Okta rejects its own default scopes"}
109
+
110
+ Okta custom authorization servers always publish `device_sso` and
111
+ `interclient_access` in their `scopes_supported` metadata. The **Include in
112
+ public metadata** toggle for those two scopes is disabled, so you can't remove
113
+ them. A client that falls back to that list requests them together with your
114
+ scopes, and Okta rejects the authorization request with
115
+ `illegal_scope_combination` and grants no scopes. Set `scopesSupported` whenever
116
+ Okta is your authorization server.
117
+
118
+ :::
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zuplo",
3
- "version": "7.6.8",
3
+ "version": "7.6.10",
4
4
  "type": "module",
5
5
  "description": "The official Zuplo CLI for local development and platform management",
6
6
  "homepage": "https://zuplo.com/docs/cli/overview",
@@ -32,9 +32,9 @@
32
32
  "zuplo": "zuplo.js"
33
33
  },
34
34
  "dependencies": {
35
- "@zuplo/cli": "7.6.8",
36
- "@zuplo/core": "7.6.8",
37
- "@zuplo/runtime": "7.6.8",
38
- "@zuplo/test": "7.6.8"
35
+ "@zuplo/cli": "7.6.10",
36
+ "@zuplo/core": "7.6.10",
37
+ "@zuplo/runtime": "7.6.10",
38
+ "@zuplo/test": "7.6.10"
39
39
  }
40
40
  }