zuplo 7.6.7 → 7.6.9
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.
- package/docs/ai-gateway/integrations/claude-code.mdx +51 -10
- package/docs/ai-gateway/usage-limits.mdx +56 -6
- package/docs/articles/configuring-auth0-for-mcp-auth.mdx +5 -0
- package/docs/articles/configuring-okta-for-mcp-auth.mdx +48 -10
- package/docs/articles/oauth-authentication.mdx +34 -0
- package/docs/handlers/mcp-server.mdx +17 -1
- package/docs/policies/ai-gateway-smart-router-inbound/doc.md +200 -29
- package/docs/policies/ai-gateway-smart-router-inbound/intro.md +1 -2
- package/docs/policies/ai-gateway-smart-router-inbound/schema.json +13 -11
- package/docs/programmable-api/oauth-protected-resource-plugin.mdx +78 -6
- package/package.json +5 -5
|
@@ -35,6 +35,11 @@ the AI Gateway.
|
|
|
35
35
|
```
|
|
36
36
|
ANTHROPIC_AUTH_TOKEN=<your-ai-gateway-app-api-key>
|
|
37
37
|
ANTHROPIC_BASE_URL=https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e
|
|
38
|
+
ANTHROPIC_MODEL=anthropic/claude-sonnet-5
|
|
39
|
+
ANTHROPIC_SMALL_FAST_MODEL=anthropic/claude-haiku-4-5
|
|
40
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL=anthropic/claude-opus-5
|
|
41
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL=anthropic/claude-sonnet-5
|
|
42
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL=anthropic/claude-haiku-4-5
|
|
38
43
|
```
|
|
39
44
|
|
|
40
45
|
### Using settings.json
|
|
@@ -43,7 +48,12 @@ ANTHROPIC_BASE_URL=https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d28
|
|
|
43
48
|
{
|
|
44
49
|
"env": {
|
|
45
50
|
"ANTHROPIC_AUTH_TOKEN": "<your-ai-gateway-app-api-key>",
|
|
46
|
-
"ANTHROPIC_BASE_URL": "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e"
|
|
51
|
+
"ANTHROPIC_BASE_URL": "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e",
|
|
52
|
+
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-5",
|
|
53
|
+
"ANTHROPIC_SMALL_FAST_MODEL": "anthropic/claude-haiku-4-5",
|
|
54
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-5",
|
|
55
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-5",
|
|
56
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4-5"
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
```
|
|
@@ -58,16 +68,47 @@ Claude Code appends `/v1/messages` to the base URL itself, so
|
|
|
58
68
|
Restart Claude and it will switch to using your new AI Gateway configuration and
|
|
59
69
|
all your Claude Code LLM requests will route through the AI Gateway.
|
|
60
70
|
|
|
61
|
-
###
|
|
62
|
-
|
|
63
|
-
The gateway routes
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
### Model names include your provider's name
|
|
72
|
+
|
|
73
|
+
The gateway routes every request by a model reference of the form
|
|
74
|
+
`providerName/model`, where `providerName` is the name of the provider **you
|
|
75
|
+
configured** in your gateway — not a fixed vendor id. A single gateway can
|
|
76
|
+
contain several providers that serve Anthropic models — Anthropic itself, Google
|
|
77
|
+
Vertex AI, AWS Bedrock, or multiple keyed instances of the same vendor — so the
|
|
78
|
+
gateway never guesses a provider for a bare model name like `claude-sonnet-5`.
|
|
79
|
+
|
|
80
|
+
The examples on this page assume the provider is named `anthropic`. Use the name
|
|
81
|
+
of your own provider: a provider named `my-anthropic` routes
|
|
82
|
+
`my-anthropic/claude-sonnet-5`, and a Bedrock provider named `bedrock` routes
|
|
83
|
+
`bedrock/anthropic.claude-sonnet-5` (the model part is always the id the
|
|
84
|
+
provider itself understands).
|
|
85
|
+
|
|
86
|
+
The model ids in these examples reflect what was current when this page was
|
|
87
|
+
written, and ids differ across providers — a Bedrock provider serves different
|
|
88
|
+
ids than Anthropic directly, and availability can vary by region. Check
|
|
89
|
+
[your provider's page](../managing-providers.mdx) in the portal for the models
|
|
90
|
+
it currently serves, and use those ids.
|
|
91
|
+
|
|
92
|
+
None of Claude Code's built-in model names carry a provider prefix, so each of
|
|
93
|
+
the model variables above must be set to a prefixed reference:
|
|
94
|
+
|
|
95
|
+
- `ANTHROPIC_MODEL` — the main model. Required: without it Claude Code sends its
|
|
96
|
+
built-in default model unprefixed and the first request fails.
|
|
97
|
+
- `ANTHROPIC_SMALL_FAST_MODEL` — the model for background tasks.
|
|
98
|
+
- `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`,
|
|
99
|
+
`ANTHROPIC_DEFAULT_HAIKU_MODEL` — what the `opus`/`sonnet`/`haiku` aliases
|
|
100
|
+
resolve to, so `claude --model opus` and `/model sonnet` keep working.
|
|
101
|
+
|
|
102
|
+
:::caution{title='HTTP 400: model must use "providerName/model"'}
|
|
103
|
+
|
|
104
|
+
If Claude Code fails immediately with
|
|
105
|
+
`The request body model must use "providerName/model"`, the request carried a
|
|
106
|
+
model name without a provider prefix. The tier alias variables
|
|
107
|
+
(`ANTHROPIC_DEFAULT_*_MODEL`) alone don't prevent this — they don't cover Claude
|
|
108
|
+
Code's built-in default model. Always set `ANTHROPIC_MODEL` to a prefixed model
|
|
109
|
+
reference.
|
|
66
110
|
|
|
67
|
-
|
|
68
|
-
ANTHROPIC_MODEL=anthropic/claude-sonnet-4-5-20250929
|
|
69
|
-
ANTHROPIC_SMALL_FAST_MODEL=anthropic/claude-haiku-4-5
|
|
70
|
-
```
|
|
111
|
+
:::
|
|
71
112
|
|
|
72
113
|
The app's
|
|
73
114
|
[Model Filtering](../../policies/ai-gateway-model-filtering-v2-inbound.mdx)
|
|
@@ -118,13 +118,13 @@ at $80, block at $100. The warn amount has to be the lower of the two.
|
|
|
118
118
|
The **Overview** tab charts daily and monthly usage only. Hourly and weekly rows
|
|
119
119
|
are enforced, but no meter on that tab tracks them.
|
|
120
120
|
|
|
121
|
-
:::note
|
|
121
|
+
:::note{title="Budget rule periods follow the UTC calendar"}
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
Hourly periods reset at the top of each hour, daily at 00:00 UTC, weekly on
|
|
124
|
+
Monday at 00:00 UTC, and monthly on the first of the month. A rule added
|
|
125
|
+
mid-period gets the full allowance for the rest of that period—the limit isn't
|
|
126
|
+
prorated—and resets at the next boundary. Changing a limit mid-period doesn't
|
|
127
|
+
reset the period's usage. Custom anchors aren't configurable.
|
|
128
128
|
|
|
129
129
|
:::
|
|
130
130
|
|
|
@@ -184,6 +184,56 @@ App rules are stored in `options.budgetRules` on the app's own Budgets and Costs
|
|
|
184
184
|
policy entry. For the app rule schema, see the
|
|
185
185
|
[Budgets and Costs policy reference](../policies/ai-gateway-metering-v2-inbound.mdx).
|
|
186
186
|
|
|
187
|
+
This rule set budgets each user, each organization, and the app as a whole:
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"budgetRules": [
|
|
192
|
+
{
|
|
193
|
+
"budgetBy": "expression",
|
|
194
|
+
"expression": "request.headers.get(\"x-user\")",
|
|
195
|
+
"meters": [
|
|
196
|
+
{
|
|
197
|
+
"meter": "tokens",
|
|
198
|
+
"period": "hourly",
|
|
199
|
+
"value": 20000,
|
|
200
|
+
"action": "block"
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"budgetBy": "expression",
|
|
206
|
+
"expression": "request.headers.get(\"x-organization\")",
|
|
207
|
+
"meters": [
|
|
208
|
+
{ "meter": "cost", "period": "daily", "value": 50, "action": "block" }
|
|
209
|
+
]
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"budgetBy": "app",
|
|
213
|
+
"meters": [
|
|
214
|
+
{
|
|
215
|
+
"meter": "cost",
|
|
216
|
+
"period": "monthly",
|
|
217
|
+
"value": 2000,
|
|
218
|
+
"action": "warn"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"meter": "cost",
|
|
222
|
+
"period": "monthly",
|
|
223
|
+
"value": 2500,
|
|
224
|
+
"action": "block"
|
|
225
|
+
}
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Every distinct `x-user` value gets its own 20,000 tokens per hour, resetting at
|
|
233
|
+
the top of the hour. Every distinct `x-organization` value gets its own $50 per
|
|
234
|
+
day, resetting at 00:00 UTC. The `app` rule caps the whole app at $2,500 per
|
|
235
|
+
month, warning at $2,000, resetting on the first of the month.
|
|
236
|
+
|
|
187
237
|
## Setting team and gateway limits
|
|
188
238
|
|
|
189
239
|
Team and gateway limits cover the node's own usage and all descendant usage.
|
|
@@ -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
|
-
"
|
|
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 `
|
|
155
|
+
- Replace `your-okta-domain` in the `issuerUrl` field with your actual Okta
|
|
140
156
|
domain.
|
|
141
|
-
- Replace `your-auth-server-id` in the `
|
|
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 `
|
|
188
|
-
domain.
|
|
189
|
-
- Replace `your-auth-server-id` in the `
|
|
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
|
|
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
|
|
@@ -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.
|
|
5
|
-
|
|
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
|
|
9
|
-
`modelsByComplexity`.
|
|
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
|
|
14
|
-
|
|
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
|
|
|
@@ -24,11 +17,12 @@ invalid options fail open: the original request is forwarded.
|
|
|
24
17
|
- `classifierAppApiKey` — bearer token for that app. Use
|
|
25
18
|
`$env(CLASSIFIER_APP_API_KEY)`.
|
|
26
19
|
- `classifierModel` — `providerName/model` sent on the classifier request.
|
|
20
|
+
- `modelsByComplexity` — `providerName/model` for each of `low`, `medium`, and
|
|
21
|
+
`high`. Used for routing when `smartRoutingEnabled` is true.
|
|
27
22
|
|
|
28
|
-
Omit `intents` and `classifierPrompt` to use the built-in
|
|
23
|
+
Omit `intents` and `classifierPrompt` to use the built-in dictionary (code,
|
|
29
24
|
summarization, translation, qa, conversation, classification, creative_writing,
|
|
30
|
-
agentic, document_qa, other) and the built-in
|
|
31
|
-
`{{intents}}` in a custom prompt to inject the configured intent list.
|
|
25
|
+
agentic, document_qa, other) and the built-in classification prompt.
|
|
32
26
|
|
|
33
27
|
## Example
|
|
34
28
|
|
|
@@ -65,30 +59,207 @@ filtering already selected a model. Filtering skips when routing is already set,
|
|
|
65
59
|
so putting this policy first would also skip allow-list checks on the client's
|
|
66
60
|
original model.
|
|
67
61
|
|
|
68
|
-
##
|
|
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):
|
|
69
148
|
|
|
70
149
|
```typescript
|
|
71
150
|
import { AIGatewaySmartRouter } from "@zuplo/runtime";
|
|
72
151
|
|
|
73
152
|
const result = AIGatewaySmartRouter.get(context);
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
};
|
|
76
189
|
}
|
|
77
190
|
```
|
|
78
191
|
|
|
79
|
-
|
|
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);
|
|
80
251
|
|
|
81
|
-
|
|
82
|
-
|
|
252
|
+
return new Response(response.body, {
|
|
253
|
+
headers,
|
|
254
|
+
status: response.status,
|
|
255
|
+
statusText: response.statusText,
|
|
256
|
+
});
|
|
257
|
+
```
|
|
83
258
|
|
|
84
|
-
|
|
85
|
-
provider is Anthropic or Google (Zuplo translates below the policy chain).
|
|
86
|
-
- `/v1/responses` — OpenAI Responses `input`.
|
|
87
|
-
- `/v1/messages` — native Anthropic `messages[]`.
|
|
259
|
+
## What content is evaluated
|
|
88
260
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
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.
|
|
92
263
|
|
|
93
264
|
## Fail-open behavior
|
|
94
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.
|
|
4
|
-
open so the original request still reaches the model.
|
|
3
|
+
optionally routes completions by classified complexity.
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"required": [
|
|
39
39
|
"classifierAppID",
|
|
40
40
|
"classifierAppApiKey",
|
|
41
|
-
"classifierModel"
|
|
41
|
+
"classifierModel",
|
|
42
|
+
"modelsByComplexity"
|
|
42
43
|
],
|
|
43
44
|
"examples": [
|
|
44
45
|
{
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
"classifierAppID": {
|
|
58
59
|
"type": "string",
|
|
59
60
|
"title": "Classifier App ID",
|
|
60
|
-
"description": "The AI Gateway application id
|
|
61
|
+
"description": "The AI Gateway application id used to run the classifier prompt and evaluate the user's request.",
|
|
61
62
|
"examples": ["config_1234"]
|
|
62
63
|
},
|
|
63
64
|
"classifierAppApiKey": {
|
|
@@ -69,21 +70,22 @@
|
|
|
69
70
|
"classifierModel": {
|
|
70
71
|
"type": "string",
|
|
71
72
|
"title": "Classifier Model",
|
|
72
|
-
"description": "The `providerName/model`
|
|
73
|
+
"description": "The model (`providerName/model`) the classifier uses to evaluate the user's request. E.g. `openai/gpt-4o-mini`.",
|
|
73
74
|
"pattern": "^[^/\\s]+/.+$",
|
|
74
75
|
"examples": ["openai/gpt-4o-mini"]
|
|
75
76
|
},
|
|
76
77
|
"smartRoutingEnabled": {
|
|
77
78
|
"type": "boolean",
|
|
78
79
|
"title": "Smart Routing Enabled",
|
|
79
|
-
"description": "When true, apply model routing
|
|
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.",
|
|
80
81
|
"default": false
|
|
81
82
|
},
|
|
82
83
|
"modelsByComplexity": {
|
|
83
84
|
"type": "object",
|
|
84
85
|
"title": "Models By Complexity",
|
|
85
|
-
"description": "
|
|
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.",
|
|
86
87
|
"additionalProperties": false,
|
|
88
|
+
"required": ["low", "medium", "high"],
|
|
87
89
|
"properties": {
|
|
88
90
|
"low": {
|
|
89
91
|
"type": "string",
|
|
@@ -111,7 +113,7 @@
|
|
|
111
113
|
"intents": {
|
|
112
114
|
"type": "array",
|
|
113
115
|
"title": "Intents",
|
|
114
|
-
"description": "
|
|
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).",
|
|
115
117
|
"minItems": 1,
|
|
116
118
|
"items": {
|
|
117
119
|
"type": "object",
|
|
@@ -121,14 +123,14 @@
|
|
|
121
123
|
"id": {
|
|
122
124
|
"type": "string",
|
|
123
125
|
"title": "Intent ID",
|
|
124
|
-
"description": "
|
|
126
|
+
"description": "Label used to classify the intent of the user message being evaluated.",
|
|
125
127
|
"minLength": 1,
|
|
126
128
|
"examples": ["code"]
|
|
127
129
|
},
|
|
128
130
|
"description": {
|
|
129
131
|
"type": "string",
|
|
130
132
|
"title": "Intent Description",
|
|
131
|
-
"description": "Short description
|
|
133
|
+
"description": "Short description of the intent label.",
|
|
132
134
|
"examples": [
|
|
133
135
|
"Write, edit, refactor, debug, or review source code."
|
|
134
136
|
]
|
|
@@ -138,7 +140,7 @@
|
|
|
138
140
|
},
|
|
139
141
|
"classifierPrompt": {
|
|
140
142
|
"title": "Classifier Prompt",
|
|
141
|
-
"description": "
|
|
143
|
+
"description": "Prompt used to analyze and classify the user message. Omit to use the built-in classifier prompt.",
|
|
142
144
|
"oneOf": [
|
|
143
145
|
{
|
|
144
146
|
"type": "string",
|
|
@@ -165,7 +167,7 @@
|
|
|
165
167
|
"classifierTimeoutMs": {
|
|
166
168
|
"type": "integer",
|
|
167
169
|
"title": "Classifier Timeout (ms)",
|
|
168
|
-
"description": "
|
|
170
|
+
"description": "Timeout configured for the classifier task. When exceeded, the request is forwarded without classification to the original model.",
|
|
169
171
|
"minimum": 1,
|
|
170
172
|
"maximum": 120000,
|
|
171
173
|
"default": 8000,
|
|
@@ -174,7 +176,7 @@
|
|
|
174
176
|
"maxPromptChars": {
|
|
175
177
|
"type": "integer",
|
|
176
178
|
"title": "Max Prompt Characters",
|
|
177
|
-
"description": "Maximum characters of user
|
|
179
|
+
"description": "Maximum characters of user message sent to the classifier. Longer messages get truncated.",
|
|
178
180
|
"minimum": 1,
|
|
179
181
|
"default": 8000,
|
|
180
182
|
"x-advanced": true
|
|
@@ -13,13 +13,16 @@ more details.
|
|
|
13
13
|
|
|
14
14
|
## Usage
|
|
15
15
|
|
|
16
|
-
This runtime plugin
|
|
17
|
-
|
|
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`,
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
3
|
+
"version": "7.6.9",
|
|
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.
|
|
36
|
-
"@zuplo/core": "7.6.
|
|
37
|
-
"@zuplo/runtime": "7.6.
|
|
38
|
-
"@zuplo/test": "7.6.
|
|
35
|
+
"@zuplo/cli": "7.6.9",
|
|
36
|
+
"@zuplo/core": "7.6.9",
|
|
37
|
+
"@zuplo/runtime": "7.6.9",
|
|
38
|
+
"@zuplo/test": "7.6.9"
|
|
39
39
|
}
|
|
40
40
|
}
|