zuplo 6.71.9 → 6.71.11
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/articles/graphql.mdx +1 -0
- package/docs/articles/securing-the-gateway-with-client-mtls.mdx +102 -5
- package/docs/guides/proxying-between-zuplo-gateways.mdx +409 -0
- package/docs/guides/transform-route-params-url-rewrite.mdx +307 -0
- package/docs/handlers/mcp-server.mdx +11 -0
- package/docs/mcp-server/troubleshooting.mdx +195 -0
- package/package.json +4 -4
|
@@ -88,12 +88,26 @@ zuplo ca-certificate list --account your-account
|
|
|
88
88
|
See the [`ca-certificate` CLI reference](../cli/ca-certificate-create.mdx) for
|
|
89
89
|
all available subcommands (`create`, `list`, `describe`, `update`, `delete`).
|
|
90
90
|
|
|
91
|
-
:::
|
|
91
|
+
:::caution{title="Upload the self-signed root CA, not an intermediate"}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
Zuplo must build a complete chain from the presented client certificate up to a
|
|
94
|
+
trust anchor. Upload the **self-signed root** CA that anchors the chain — not an
|
|
95
|
+
intermediate or subordinate CA. Uploading a subordinate CA is the most common
|
|
96
|
+
cause of the `FAILED to get issuer certificate` error (see
|
|
97
|
+
[Troubleshooting](#failed-to-get-issuer-certificate)).
|
|
98
|
+
|
|
99
|
+
To confirm a certificate is a self-signed root, check that its subject and
|
|
100
|
+
issuer are identical:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
openssl x509 -in ca.pem -noout -subject -issuer -nameopt RFC2253
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If `subject` and `issuer` match, it's a root. If they differ, the file is an
|
|
107
|
+
intermediate CA — trace the chain to the root and upload that instead. When your
|
|
108
|
+
client certificates are issued by an intermediate CA, clients still send the
|
|
109
|
+
leaf certificate plus any intermediates when they connect (see
|
|
110
|
+
[Test with curl](#4-test-with-curl)).
|
|
97
111
|
|
|
98
112
|
:::
|
|
99
113
|
|
|
@@ -315,11 +329,73 @@ the policy in a working-copy or preview environment.
|
|
|
315
329
|
`context.incomingRequestProperties.clientMtlsVerificationReason` to see why
|
|
316
330
|
verification failed.
|
|
317
331
|
|
|
332
|
+
### `FAILED to get issuer certificate`
|
|
333
|
+
|
|
334
|
+
This error means Zuplo can't build a complete chain from the presented client
|
|
335
|
+
certificate up to a trusted root. The usual cause is uploading an **intermediate
|
|
336
|
+
or subordinate CA** instead of the **self-signed root** CA that anchors the
|
|
337
|
+
chain.
|
|
338
|
+
|
|
339
|
+
Confirm what you uploaded. A self-signed root has an identical subject and
|
|
340
|
+
issuer:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
openssl x509 -in ca.pem -noout -subject -issuer -nameopt RFC2253
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
If `subject` and `issuer` differ, the file is an intermediate CA. Find the root
|
|
347
|
+
that anchors the chain and re-upload it:
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
# Remove the incorrect CA
|
|
351
|
+
zuplo ca-certificate delete --cert-id mtlsca_abc123 --account your-account
|
|
352
|
+
|
|
353
|
+
# Upload the self-signed root instead
|
|
354
|
+
zuplo ca-certificate create --name my_ca --cert ./root-ca.pem --account your-account
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
If your CA is an Active Directory Certificate Services (AD CS) deployment,
|
|
358
|
+
export the issuing CA's own certificate and inspect it:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
# Export the CA certificate (often DER-encoded)
|
|
362
|
+
certutil -ca.cert ca.cer
|
|
363
|
+
|
|
364
|
+
# Convert DER to the PEM format Zuplo requires
|
|
365
|
+
openssl x509 -inform der -in ca.cer -out ca.pem
|
|
366
|
+
|
|
367
|
+
# Verify subject == issuer (a root); if not, export the root above it instead
|
|
368
|
+
openssl x509 -in ca.pem -noout -subject -issuer -nameopt RFC2253
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### `client certificate metadata not provided`
|
|
372
|
+
|
|
373
|
+
The certificate verified at the edge, but the parsed certificate wasn't
|
|
374
|
+
forwarded to your gateway workers, so `request.user.data.mtlsAuth` is empty even
|
|
375
|
+
though the request was authenticated.
|
|
376
|
+
|
|
377
|
+
The most common cause is an **oversized leaf client certificate**. Zuplo's edge
|
|
378
|
+
can only forward client certificates up to roughly 10 KB of DER-encoded data.
|
|
379
|
+
Certificates with large RSA keys, many Subject Alternative Names, or large
|
|
380
|
+
custom extensions can exceed this. Check the DER size of the leaf certificate:
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
openssl x509 -in client.pem -outform der | wc -c
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
If the result is near or above ~10,000 bytes, reissue a smaller leaf
|
|
387
|
+
certificate. Trim unnecessary extensions and SANs, or switch to ECDSA keys
|
|
388
|
+
instead of large (4096-bit) RSA keys. This limit applies to the **leaf**
|
|
389
|
+
certificate the edge forwards, not the full chain.
|
|
390
|
+
|
|
318
391
|
### `request.user.data.mtlsAuth` is missing
|
|
319
392
|
|
|
320
393
|
- The policy only attaches metadata when a parseable client certificate is
|
|
321
394
|
present on the request. Confirm the client is sending one.
|
|
322
395
|
- Verify the route includes the `mtls-auth-inbound` policy.
|
|
396
|
+
- If the certificate verifies but metadata is still missing, check the leaf
|
|
397
|
+
certificate size (see
|
|
398
|
+
[`client certificate metadata not provided`](#client-certificate-metadata-not-provided)).
|
|
323
399
|
|
|
324
400
|
### Custom domains
|
|
325
401
|
|
|
@@ -331,6 +407,27 @@ that custom domain.
|
|
|
331
407
|
If you add a custom domain later and your clients aren't being verified against
|
|
332
408
|
it, contact [support@zuplo.com](mailto:support@zuplo.com).
|
|
333
409
|
|
|
410
|
+
### Custom domains behind your own CDN
|
|
411
|
+
|
|
412
|
+
Inbound mTLS requires the TLS handshake to terminate at Zuplo's edge, because
|
|
413
|
+
that's where the client certificate is verified and parsed. If you front your
|
|
414
|
+
Zuplo gateway with **your own CDN** (for example, your own Cloudflare zone) that
|
|
415
|
+
terminates TLS before traffic reaches Zuplo, the handshake — and the client
|
|
416
|
+
certificate — ends at your CDN. Zuplo never sees the certificate, so the
|
|
417
|
+
`mtls-auth-inbound` policy has nothing to verify.
|
|
418
|
+
|
|
419
|
+
You have two supported options:
|
|
420
|
+
|
|
421
|
+
- **Let Zuplo terminate TLS.** Point clients at a Zuplo-managed gateway domain,
|
|
422
|
+
or configure your custom domain directly on Zuplo so Zuplo terminates TLS. The
|
|
423
|
+
client certificate then reaches Zuplo's edge and inbound mTLS works as
|
|
424
|
+
documented above.
|
|
425
|
+
- **Verify mTLS at your CDN.** If you must keep your own CDN in front, terminate
|
|
426
|
+
and verify the client certificate at the CDN, then forward the verified
|
|
427
|
+
identity to Zuplo in a request header. Validate that header in a Zuplo policy
|
|
428
|
+
instead of relying on `mtls-auth-inbound`. Make sure the header can't be
|
|
429
|
+
spoofed by clients that bypass your CDN.
|
|
430
|
+
|
|
334
431
|
## Additional resources
|
|
335
432
|
|
|
336
433
|
- [`mtls-auth-inbound` policy reference](../policies/mtls-auth-inbound.mdx)
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Proxying Between Zuplo Gateways
|
|
3
|
+
sidebar_label: Proxying Between Gateways
|
|
4
|
+
description:
|
|
5
|
+
Learn how to proxy requests from one Zuplo project to another using the URL
|
|
6
|
+
Forward Handler or a custom fetch handler, propagate authentication, surface
|
|
7
|
+
upstream errors, and troubleshoot 522 timeouts.
|
|
8
|
+
tags:
|
|
9
|
+
- backends
|
|
10
|
+
- deployment
|
|
11
|
+
- custom-code
|
|
12
|
+
- authentication
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
Some architectures call for one Zuplo gateway to sit in front of one or more
|
|
16
|
+
other Zuplo gateways. A "product" gateway might aggregate several team-owned
|
|
17
|
+
"member" gateways, a BFF might fan out to multiple internal APIs, or a migration
|
|
18
|
+
might route a subset of traffic through a new project while the old one still
|
|
19
|
+
serves the rest.
|
|
20
|
+
|
|
21
|
+
This guide covers the patterns, auth propagation strategies, error-handling
|
|
22
|
+
pitfalls, and troubleshooting steps you need when the upstream is another Zuplo
|
|
23
|
+
project.
|
|
24
|
+
|
|
25
|
+
## When this pattern makes sense
|
|
26
|
+
|
|
27
|
+
- **Product-of-products** — A single public API endpoint forwards different path
|
|
28
|
+
prefixes to separate Zuplo projects, each owned by a different team.
|
|
29
|
+
- **Backend for frontend (BFF)** — A gateway aggregates data from multiple
|
|
30
|
+
downstream Zuplo-managed APIs into a single response.
|
|
31
|
+
- **Tenant routing** — Requests are routed to different Zuplo projects based on
|
|
32
|
+
tenant identity or API key metadata. See
|
|
33
|
+
[User-Based Backend Routing](./user-based-backend-routing.mdx) for a detailed
|
|
34
|
+
walkthrough of this approach.
|
|
35
|
+
- **Gradual migration** — During a migration, a new gateway forwards unhandled
|
|
36
|
+
routes to the old gateway.
|
|
37
|
+
|
|
38
|
+
If you use a Managed Dedicated deployment with an enterprise plan, consider
|
|
39
|
+
[Federated Gateways](../dedicated/federated-gateways.mdx) instead. Federated
|
|
40
|
+
Gateways is an enterprise add-on that uses the `local://` protocol for
|
|
41
|
+
inter-environment communication within the same dedicated instance, avoiding the
|
|
42
|
+
public internet and providing lower latency.
|
|
43
|
+
|
|
44
|
+
## Choosing an approach
|
|
45
|
+
|
|
46
|
+
### Pattern A: URL Forward Handler
|
|
47
|
+
|
|
48
|
+
The [URL Forward Handler](../handlers/url-forward.mdx) is the simplest option.
|
|
49
|
+
It proxies the request — method, headers, and body — to the downstream Zuplo
|
|
50
|
+
project without writing any code.
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"handler": {
|
|
55
|
+
"export": "urlForwardHandler",
|
|
56
|
+
"module": "$import(@zuplo/runtime)",
|
|
57
|
+
"options": {
|
|
58
|
+
"baseUrl": "${env.DOWNSTREAM_GATEWAY_URL}"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Store the downstream URL (for example
|
|
65
|
+
`https://member-api-main-abc123.zuplo.app`) in an
|
|
66
|
+
[environment variable](../articles/environment-variables.mdx) so you can change
|
|
67
|
+
it per environment without modifying route configuration.
|
|
68
|
+
|
|
69
|
+
The URL Forward Handler appends the incoming path to the `baseUrl`. If the outer
|
|
70
|
+
gateway receives `GET /orders/123` and the `baseUrl` is
|
|
71
|
+
`https://member-api-main-abc123.zuplo.app`, the forwarded request goes to
|
|
72
|
+
`https://member-api-main-abc123.zuplo.app/orders/123`.
|
|
73
|
+
|
|
74
|
+
**When to use this pattern:**
|
|
75
|
+
|
|
76
|
+
- You want zero-code proxying and are happy forwarding the request as-is.
|
|
77
|
+
- You do not need to inspect or transform the upstream response before returning
|
|
78
|
+
it to the caller.
|
|
79
|
+
|
|
80
|
+
### Pattern B: Custom fetch handler
|
|
81
|
+
|
|
82
|
+
A [Function Handler](../handlers/custom-handler.mdx) gives you full control over
|
|
83
|
+
the outbound request and lets you inspect the upstream response before returning
|
|
84
|
+
it to the caller. This is the recommended pattern when you need to propagate
|
|
85
|
+
authentication credentials, transform the response, or surface upstream error
|
|
86
|
+
details.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { ZuploContext, ZuploRequest, environment } from "@zuplo/runtime";
|
|
90
|
+
|
|
91
|
+
export default async function (
|
|
92
|
+
request: ZuploRequest,
|
|
93
|
+
context: ZuploContext,
|
|
94
|
+
): Promise<Response> {
|
|
95
|
+
const url = new URL(request.url);
|
|
96
|
+
const upstreamUrl = `${environment.DOWNSTREAM_GATEWAY_URL}${url.pathname}${url.search}`;
|
|
97
|
+
|
|
98
|
+
const upstreamResponse = await fetch(upstreamUrl, {
|
|
99
|
+
method: request.method,
|
|
100
|
+
headers: request.headers,
|
|
101
|
+
body: request.body,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Return the upstream response directly, preserving status and headers
|
|
105
|
+
return new Response(upstreamResponse.body, {
|
|
106
|
+
status: upstreamResponse.status,
|
|
107
|
+
headers: upstreamResponse.headers,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**When to use this pattern:**
|
|
113
|
+
|
|
114
|
+
- You need to add, remove, or transform headers before forwarding.
|
|
115
|
+
- You need to read the upstream response body (for example, to merge responses
|
|
116
|
+
from multiple downstreams).
|
|
117
|
+
- You want to return the exact upstream status code and body to the caller
|
|
118
|
+
instead of receiving an opaque 522. See
|
|
119
|
+
[Surfacing upstream errors](#surfacing-upstream-errors-instead-of-522) below.
|
|
120
|
+
|
|
121
|
+
### Pattern C: Federated Gateways (Managed Dedicated)
|
|
122
|
+
|
|
123
|
+
On a [Managed Dedicated](../dedicated/federated-gateways.mdx) plan, use the
|
|
124
|
+
`local://` protocol to call other environments in the same instance:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"handler": {
|
|
129
|
+
"export": "urlForwardHandler",
|
|
130
|
+
"module": "$import(@zuplo/runtime)",
|
|
131
|
+
"options": {
|
|
132
|
+
"baseUrl": "local://member-api-main-abc123"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
This avoids the public internet entirely. The Lambda handler is not supported
|
|
139
|
+
for federated calls — use URL Forward, URL Rewrite, or a Function Handler
|
|
140
|
+
instead.
|
|
141
|
+
|
|
142
|
+
## Propagating authentication
|
|
143
|
+
|
|
144
|
+
When the outer gateway authenticates a request (using
|
|
145
|
+
[API Key Authentication](../concepts/api-keys.md),
|
|
146
|
+
[JWT authentication](../concepts/authentication.mdx), or another method), the
|
|
147
|
+
inner gateway still needs to trust that request. There are several patterns for
|
|
148
|
+
propagating identity between gateways.
|
|
149
|
+
|
|
150
|
+
### Forward the original credential
|
|
151
|
+
|
|
152
|
+
The simplest approach is to forward the caller's original `Authorization` header
|
|
153
|
+
(or API key header) to the downstream gateway. Both the URL Forward Handler and
|
|
154
|
+
the custom fetch handler forward request headers by default, so if the
|
|
155
|
+
downstream gateway accepts the same credentials, this works without extra
|
|
156
|
+
configuration.
|
|
157
|
+
|
|
158
|
+
:::caution
|
|
159
|
+
|
|
160
|
+
If the outer and inner gateways use different API key buckets or different JWT
|
|
161
|
+
issuers, forwarding the original credential does not work. Use one of the
|
|
162
|
+
patterns below instead.
|
|
163
|
+
|
|
164
|
+
:::
|
|
165
|
+
|
|
166
|
+
### Shared secret header
|
|
167
|
+
|
|
168
|
+
Store a shared secret in an
|
|
169
|
+
[environment variable](../articles/environment-variables.mdx) on both projects.
|
|
170
|
+
On the outer gateway, use a
|
|
171
|
+
[Set Headers policy](../policies/set-headers-inbound.mdx) to add the secret as a
|
|
172
|
+
custom header. On the inner gateway, validate the header in an inbound policy or
|
|
173
|
+
use the same Set Headers policy to check the value.
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"name": "set-backend-secret",
|
|
178
|
+
"policyType": "set-headers-inbound",
|
|
179
|
+
"handler": {
|
|
180
|
+
"export": "SetHeadersInboundPolicy",
|
|
181
|
+
"module": "$import(@zuplo/runtime)",
|
|
182
|
+
"options": {
|
|
183
|
+
"headers": [
|
|
184
|
+
{
|
|
185
|
+
"name": "x-gateway-secret",
|
|
186
|
+
"value": "$env(DOWNSTREAM_SECRET)"
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
See [Securing your backend](../articles/securing-your-backend.mdx) for a
|
|
195
|
+
complete walkthrough of this approach.
|
|
196
|
+
|
|
197
|
+
### Upstream Zuplo JWT
|
|
198
|
+
|
|
199
|
+
The [Upstream Zuplo JWT policy](../policies/upstream-zuplo-jwt-auth-inbound.mdx)
|
|
200
|
+
generates a short-lived, self-signed JWT and attaches it to the outbound
|
|
201
|
+
request. Configure the inner gateway to validate this JWT using the
|
|
202
|
+
[OpenID JWT Authentication policy](../policies/open-id-jwt-auth-inbound.mdx)
|
|
203
|
+
with Zuplo's JWKS endpoint.
|
|
204
|
+
|
|
205
|
+
This is the most robust option for service-to-service authentication between
|
|
206
|
+
Zuplo projects because it does not require sharing static secrets and the token
|
|
207
|
+
includes claims you can use for authorization on the downstream side.
|
|
208
|
+
|
|
209
|
+
## Surfacing upstream errors instead of 522
|
|
210
|
+
|
|
211
|
+
A common problem when proxying between Zuplo gateways: the downstream gateway
|
|
212
|
+
returns a `401 Unauthorized` (or another error), but the caller sees a `522`
|
|
213
|
+
instead.
|
|
214
|
+
|
|
215
|
+
### Why this happens
|
|
216
|
+
|
|
217
|
+
Zuplo's managed edge environment uses connection-level timeouts between the
|
|
218
|
+
gateway and the origin server. A `522` status code means a connection-level
|
|
219
|
+
failure occurred between the gateway and the upstream. The
|
|
220
|
+
[Platform Limits](../articles/limits.mdx) documentation lists two scenarios that
|
|
221
|
+
produce a 522: a Complete TCP Connection timeout at 19 seconds and a TCP ACK
|
|
222
|
+
Timeout at 90 seconds.
|
|
223
|
+
|
|
224
|
+
A `522` can also appear when the upstream closes the connection unexpectedly —
|
|
225
|
+
for example, if the downstream gateway rejects the TLS handshake, returns a
|
|
226
|
+
connection reset, or takes too long to send the response headers.
|
|
227
|
+
|
|
228
|
+
When the downstream Zuplo project returns an HTTP error like `401` or `500`,
|
|
229
|
+
that is **not** a 522. The 522 means the connection itself failed before an HTTP
|
|
230
|
+
response was received. If you are seeing 522 instead of the expected upstream
|
|
231
|
+
error, the issue is at the network or TLS layer, not the HTTP layer.
|
|
232
|
+
|
|
233
|
+
### Common causes of 522 between Zuplo projects
|
|
234
|
+
|
|
235
|
+
- **DNS resolution failure** — The downstream URL is incorrect or the
|
|
236
|
+
environment no longer exists.
|
|
237
|
+
- **TLS handshake failure** — Misconfigured custom domain or certificate issue
|
|
238
|
+
on the downstream project.
|
|
239
|
+
- **Connection timeout** — The downstream project takes longer than 19 seconds
|
|
240
|
+
to accept the TCP connection, usually because it is overloaded or
|
|
241
|
+
misconfigured.
|
|
242
|
+
- **Egress restrictions** — In some network configurations, outbound connections
|
|
243
|
+
from one Zuplo project to another may be restricted.
|
|
244
|
+
|
|
245
|
+
### Returning the actual upstream error
|
|
246
|
+
|
|
247
|
+
If the TCP connection succeeds but the upstream returns an HTTP error (like 401
|
|
248
|
+
or 500), the URL Forward Handler already returns that status code to the caller.
|
|
249
|
+
You do not need to do anything extra — the upstream's status and body flow
|
|
250
|
+
through.
|
|
251
|
+
|
|
252
|
+
If you need more control (for example, to log the upstream error or transform it
|
|
253
|
+
before returning), use a custom fetch handler:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import { ZuploContext, ZuploRequest, environment } from "@zuplo/runtime";
|
|
257
|
+
|
|
258
|
+
export default async function (
|
|
259
|
+
request: ZuploRequest,
|
|
260
|
+
context: ZuploContext,
|
|
261
|
+
): Promise<Response> {
|
|
262
|
+
const url = new URL(request.url);
|
|
263
|
+
const upstreamUrl = `${environment.DOWNSTREAM_GATEWAY_URL}${url.pathname}${url.search}`;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const upstreamResponse = await fetch(upstreamUrl, {
|
|
267
|
+
method: request.method,
|
|
268
|
+
headers: request.headers,
|
|
269
|
+
body: request.body,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (!upstreamResponse.ok) {
|
|
273
|
+
context.log.warn(
|
|
274
|
+
`Upstream returned ${upstreamResponse.status} for ${url.pathname}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return new Response(upstreamResponse.body, {
|
|
279
|
+
status: upstreamResponse.status,
|
|
280
|
+
headers: upstreamResponse.headers,
|
|
281
|
+
});
|
|
282
|
+
} catch (error) {
|
|
283
|
+
context.log.error(`Failed to reach upstream: ${error}`);
|
|
284
|
+
return new Response(
|
|
285
|
+
JSON.stringify({
|
|
286
|
+
type: "https://httpproblems.com/http-status/502",
|
|
287
|
+
title: "Bad Gateway",
|
|
288
|
+
status: 502,
|
|
289
|
+
detail: "The upstream service is unreachable.",
|
|
290
|
+
}),
|
|
291
|
+
{
|
|
292
|
+
status: 502,
|
|
293
|
+
headers: { "content-type": "application/problem+json" },
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
This handler catches connection-level errors (which would otherwise surface as
|
|
301
|
+
a 522) and returns a structured `502 Bad Gateway` response. When the upstream
|
|
302
|
+
does return an HTTP response, the status and body pass through unchanged.
|
|
303
|
+
|
|
304
|
+
## Custom domains across the fleet
|
|
305
|
+
|
|
306
|
+
When multiple Zuplo projects form a gateway chain, decide where to attach your
|
|
307
|
+
[custom domain](../articles/custom-domains.mdx):
|
|
308
|
+
|
|
309
|
+
- **Outer gateway only** — The most common setup. Attach your custom domain (for
|
|
310
|
+
example, `api.example.com`) to the outer gateway project and let the inner
|
|
311
|
+
gateways use their default `*.zuplo.app` URLs. Callers only see your custom
|
|
312
|
+
domain.
|
|
313
|
+
- **Every gateway** — Useful when internal teams also call the inner gateways
|
|
314
|
+
directly for testing or monitoring. Each project gets its own custom domain.
|
|
315
|
+
|
|
316
|
+
:::tip
|
|
317
|
+
|
|
318
|
+
Putting the custom domain only on the outer gateway simplifies DNS management
|
|
319
|
+
and certificate renewal. The inner gateways are implementation details that
|
|
320
|
+
callers do not need to know about.
|
|
321
|
+
|
|
322
|
+
:::
|
|
323
|
+
|
|
324
|
+
## Cost considerations
|
|
325
|
+
|
|
326
|
+
Request traffic is metered at the account level, and every project in the chain
|
|
327
|
+
counts against the same shared allowance. A single client request that fans out
|
|
328
|
+
to three downstream Zuplo projects results in four billed requests: one on the
|
|
329
|
+
outer gateway and one on each downstream project.
|
|
330
|
+
|
|
331
|
+
Review [Platform Limits](../articles/limits.mdx) and your plan's monthly request
|
|
332
|
+
allowance before designing a fan-out architecture. If the request volume is
|
|
333
|
+
high, consider whether a single Zuplo project with path-based routing can
|
|
334
|
+
replace the multi-project topology.
|
|
335
|
+
|
|
336
|
+
## Troubleshooting
|
|
337
|
+
|
|
338
|
+
### 522 with no logs on the downstream project
|
|
339
|
+
|
|
340
|
+
The outer gateway's runtime could not establish a TCP connection to the
|
|
341
|
+
downstream project. The request never reached the inner gateway, so there are no
|
|
342
|
+
logs there.
|
|
343
|
+
|
|
344
|
+
**Checklist:**
|
|
345
|
+
|
|
346
|
+
1. Verify the downstream URL is correct. Check the environment variable value in
|
|
347
|
+
the outer gateway project. A typo in the environment name (for example,
|
|
348
|
+
`main` instead of `main-abc123`) produces a DNS failure.
|
|
349
|
+
2. Confirm the downstream project is deployed and its environment is active.
|
|
350
|
+
Open the downstream project in the [Zuplo Portal](https://portal.zuplo.com)
|
|
351
|
+
and check the environment status.
|
|
352
|
+
3. If using a custom domain on the downstream project, verify the DNS CNAME
|
|
353
|
+
record points to `cname.zuplo.app` and the certificate is valid.
|
|
354
|
+
|
|
355
|
+
### 522 only when forwarding to another Zuplo project
|
|
356
|
+
|
|
357
|
+
Requests to `httpbin.org` or other external services work fine, but requests to
|
|
358
|
+
`*.zuplo.app` return 522.
|
|
359
|
+
|
|
360
|
+
**Checklist:**
|
|
361
|
+
|
|
362
|
+
1. Check that the downstream Zuplo project's environment is not a development
|
|
363
|
+
environment (ending in `.zuplo.dev`). Development environments have stricter
|
|
364
|
+
rate limits (1,000 requests per minute) and may reject connections under
|
|
365
|
+
load.
|
|
366
|
+
2. Verify TLS is working — the outer gateway connects to the downstream over
|
|
367
|
+
HTTPS. If the downstream has a custom domain with certificate issues, the TLS
|
|
368
|
+
handshake fails and produces a 522.
|
|
369
|
+
3. Look at the outer gateway's logs for connection error details. The Zuplo
|
|
370
|
+
runtime logs include the error message when an outbound `fetch` fails.
|
|
371
|
+
|
|
372
|
+
### Caller receives the upstream 401 directly
|
|
373
|
+
|
|
374
|
+
This is the expected behavior. The URL Forward Handler and custom fetch handlers
|
|
375
|
+
both return the upstream's HTTP status and body as-is. If the caller sees `401`,
|
|
376
|
+
the downstream project rejected the request at the HTTP level (not a connection
|
|
377
|
+
failure).
|
|
378
|
+
|
|
379
|
+
If the downstream uses API key authentication and the caller's key is not valid
|
|
380
|
+
on the downstream project, the downstream returns `401`. Review the
|
|
381
|
+
[Propagating authentication](#propagating-authentication) section to choose the
|
|
382
|
+
right credential strategy.
|
|
383
|
+
|
|
384
|
+
### Mismatched response content types
|
|
385
|
+
|
|
386
|
+
The downstream project returns JSON but the caller receives an unexpected
|
|
387
|
+
content type or an empty body.
|
|
388
|
+
|
|
389
|
+
**Checklist:**
|
|
390
|
+
|
|
391
|
+
1. Verify the downstream route is configured to return the expected content
|
|
392
|
+
type. Check the handler and outbound policies on the downstream project.
|
|
393
|
+
2. If using a custom fetch handler on the outer gateway, make sure you are
|
|
394
|
+
forwarding the upstream's `content-type` header. The example handler in
|
|
395
|
+
[Pattern B](#pattern-b-custom-fetch-handler) preserves all upstream headers.
|
|
396
|
+
3. Check whether an outbound policy on the outer gateway transforms or strips
|
|
397
|
+
the response body.
|
|
398
|
+
|
|
399
|
+
## Related resources
|
|
400
|
+
|
|
401
|
+
- [URL Forward Handler](../handlers/url-forward.mdx)
|
|
402
|
+
- [Function Handler](../handlers/custom-handler.mdx)
|
|
403
|
+
- [Federated Gateways (Managed Dedicated)](../dedicated/federated-gateways.mdx)
|
|
404
|
+
- [Securing your backend](../articles/securing-your-backend.mdx)
|
|
405
|
+
- [Platform Limits](../articles/limits.mdx)
|
|
406
|
+
- [Gateway Timeout error](../errors/gateway-timeout.mdx)
|
|
407
|
+
- [Request Lifecycle](../concepts/request-lifecycle.mdx)
|
|
408
|
+
- [Custom Domains](../articles/custom-domains.mdx)
|
|
409
|
+
- [Environment Variables](../articles/environment-variables.mdx)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Transform Route Parameters for URL Rewrite
|
|
3
|
+
sidebar_label: Transform Route Parameters
|
|
4
|
+
description:
|
|
5
|
+
Learn how to use an inbound policy to transform route parameter values before
|
|
6
|
+
the URL Rewrite handler forwards the request to your backend.
|
|
7
|
+
tags:
|
|
8
|
+
- custom-code
|
|
9
|
+
- backends
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
This guide explains how to transform incoming route parameter values in an
|
|
13
|
+
inbound policy before the [URL Rewrite handler](../handlers/url-rewrite.mdx)
|
|
14
|
+
uses them to build the upstream URL. This pattern is useful when your public API
|
|
15
|
+
paths use different naming conventions than your internal backend.
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
When you use the URL Rewrite handler, it builds the upstream URL by
|
|
20
|
+
interpolating values like `${params.resourceType}` directly from the incoming
|
|
21
|
+
route parameters. Sometimes, however, you need to **change** those values before
|
|
22
|
+
the rewrite happens. Common scenarios include:
|
|
23
|
+
|
|
24
|
+
- **Value mapping** — translating a public-facing parameter like `order` to an
|
|
25
|
+
internal value like `customerorder`
|
|
26
|
+
- **Case normalization** — converting `Products` to `products` before forwarding
|
|
27
|
+
- **Path translation** — mapping user-friendly slugs to internal identifiers
|
|
28
|
+
|
|
29
|
+
The recommended approach is to read the route parameters in an inbound policy,
|
|
30
|
+
transform them, store the results on
|
|
31
|
+
[`context.custom`](../programmable-api/zuplo-context.mdx), and reference the
|
|
32
|
+
transformed values in the URL Rewrite pattern.
|
|
33
|
+
|
|
34
|
+
## Step-by-Step Example
|
|
35
|
+
|
|
36
|
+
The solution has three parts: an **inbound policy** that reads `request.params`
|
|
37
|
+
and stores transformed values on `context.custom`, a **URL Rewrite handler**
|
|
38
|
+
that references those values using `${context.custom.*}` in the
|
|
39
|
+
`rewritePattern`, and **route configuration** that wires the two together.
|
|
40
|
+
|
|
41
|
+
Imagine your public API exposes a route like `/api/:resourceType/:resourceId`,
|
|
42
|
+
but your backend expects the resource type to be prefixed with `customer`. A
|
|
43
|
+
request to `/api/order/123` should be forwarded to
|
|
44
|
+
`https://backend.example.com/api/customerorder/123`.
|
|
45
|
+
|
|
46
|
+
### 1. Write the Inbound Policy
|
|
47
|
+
|
|
48
|
+
Create a custom inbound policy that reads the route parameters, transforms the
|
|
49
|
+
values, and stores them on `context.custom`:
|
|
50
|
+
|
|
51
|
+
```ts title="modules/transform-params.ts"
|
|
52
|
+
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
|
|
53
|
+
|
|
54
|
+
export default async function (
|
|
55
|
+
request: ZuploRequest,
|
|
56
|
+
context: ZuploContext,
|
|
57
|
+
options: any,
|
|
58
|
+
policyName: string,
|
|
59
|
+
): Promise<ZuploRequest | Response> {
|
|
60
|
+
// Read the original route parameter
|
|
61
|
+
const resourceType = request.params.resourceType;
|
|
62
|
+
|
|
63
|
+
// Transform the value — prefix with "customer"
|
|
64
|
+
const transformedResourceType = `customer${resourceType}`;
|
|
65
|
+
|
|
66
|
+
// Store the transformed value on context.custom
|
|
67
|
+
context.custom.transformedResourceType = transformedResourceType;
|
|
68
|
+
|
|
69
|
+
context.log.info({
|
|
70
|
+
message: "Transformed route parameter",
|
|
71
|
+
original: resourceType,
|
|
72
|
+
transformed: transformedResourceType,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return request;
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 2. Register the Policy
|
|
80
|
+
|
|
81
|
+
Add the policy to `config/policies.json`:
|
|
82
|
+
|
|
83
|
+
```json title="config/policies.json"
|
|
84
|
+
{
|
|
85
|
+
"policies": [
|
|
86
|
+
{
|
|
87
|
+
"name": "transform-params",
|
|
88
|
+
"policyType": "custom-code-inbound",
|
|
89
|
+
"handler": {
|
|
90
|
+
"export": "default",
|
|
91
|
+
"module": "$import(./modules/transform-params)"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. Configure the Route
|
|
99
|
+
|
|
100
|
+
Define the route in `config/routes.oas.json` with the inbound policy and a URL
|
|
101
|
+
Rewrite handler that references `context.custom`:
|
|
102
|
+
|
|
103
|
+
```json title="config/routes.oas.json"
|
|
104
|
+
{
|
|
105
|
+
"paths": {
|
|
106
|
+
"/api/{resourceType}/{resourceId}": {
|
|
107
|
+
"x-zuplo-path": {
|
|
108
|
+
"pathMode": "open-api"
|
|
109
|
+
},
|
|
110
|
+
"get": {
|
|
111
|
+
"summary": "Get resource by type and ID",
|
|
112
|
+
"x-zuplo-route": {
|
|
113
|
+
"corsPolicy": "none",
|
|
114
|
+
"handler": {
|
|
115
|
+
"export": "urlRewriteHandler",
|
|
116
|
+
"module": "$import(@zuplo/runtime)",
|
|
117
|
+
"options": {
|
|
118
|
+
"rewritePattern": "https://backend.example.com/api/${context.custom.transformedResourceType}/${params.resourceId}"
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"policies": {
|
|
122
|
+
"inbound": ["transform-params"]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
With this configuration, a request to `/api/order/123` flows through the
|
|
132
|
+
pipeline as follows:
|
|
133
|
+
|
|
134
|
+
1. The route matches with `params.resourceType = "order"` and
|
|
135
|
+
`params.resourceId = "123"`
|
|
136
|
+
2. The `transform-params` inbound policy runs and sets
|
|
137
|
+
`context.custom.transformedResourceType = "customerorder"`
|
|
138
|
+
3. The URL Rewrite handler builds the upstream URL:
|
|
139
|
+
`https://backend.example.com/api/customerorder/123`
|
|
140
|
+
|
|
141
|
+
## Common Pitfall: Modifying `request.params` Directly
|
|
142
|
+
|
|
143
|
+
:::caution
|
|
144
|
+
|
|
145
|
+
Do not try to transform route parameters by constructing a new `ZuploRequest`
|
|
146
|
+
with modified `params` and expecting the URL Rewrite handler to pick them up.
|
|
147
|
+
|
|
148
|
+
:::
|
|
149
|
+
|
|
150
|
+
A common first attempt is to create a new
|
|
151
|
+
[`ZuploRequest`](../programmable-api/zuplo-request.mdx) with different `params`:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// ⚠️ This approach does NOT work as expected with URL Rewrite
|
|
155
|
+
const newRequest = new ZuploRequest(request, {
|
|
156
|
+
params: {
|
|
157
|
+
...request.params,
|
|
158
|
+
resourceType: "customerorder",
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
return newRequest;
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
In practice, the URL Rewrite handler evaluates `${params.*}` against the
|
|
165
|
+
route-level parameters rather than the request object returned by a policy. This
|
|
166
|
+
means the rewritten URL may contain `undefined` segments instead of your
|
|
167
|
+
transformed values. Use `context.custom` for reliable interpolation of
|
|
168
|
+
transformed values — the URL Rewrite handler's `rewritePattern` fully supports
|
|
169
|
+
`${context.custom.*}`, and values set in an inbound policy are available when
|
|
170
|
+
the handler runs.
|
|
171
|
+
|
|
172
|
+
## Variations
|
|
173
|
+
|
|
174
|
+
### Using a Lookup Map
|
|
175
|
+
|
|
176
|
+
For more complex mappings where the transformation is not a simple string
|
|
177
|
+
operation, use a lookup object:
|
|
178
|
+
|
|
179
|
+
```ts title="modules/transform-params-map.ts"
|
|
180
|
+
import { ZuploContext, ZuploRequest, HttpProblems } from "@zuplo/runtime";
|
|
181
|
+
|
|
182
|
+
// Map public resource types to internal names
|
|
183
|
+
const RESOURCE_TYPE_MAP: Record<string, string> = {
|
|
184
|
+
order: "customerorder",
|
|
185
|
+
invoice: "billing-invoice",
|
|
186
|
+
profile: "user-profile",
|
|
187
|
+
subscription: "recurring-plan",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export default async function (
|
|
191
|
+
request: ZuploRequest,
|
|
192
|
+
context: ZuploContext,
|
|
193
|
+
options: any,
|
|
194
|
+
policyName: string,
|
|
195
|
+
): Promise<ZuploRequest | Response> {
|
|
196
|
+
const resourceType = request.params.resourceType;
|
|
197
|
+
const mappedType = RESOURCE_TYPE_MAP[resourceType];
|
|
198
|
+
|
|
199
|
+
if (!mappedType) {
|
|
200
|
+
return HttpProblems.notFound(request, context, {
|
|
201
|
+
detail: `Unknown resource type: ${resourceType}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
context.custom.transformedResourceType = mappedType;
|
|
206
|
+
|
|
207
|
+
return request;
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Transforming Multiple Parameters
|
|
212
|
+
|
|
213
|
+
You can transform any number of route parameters and store each on
|
|
214
|
+
`context.custom`. Reference them individually in the rewrite pattern:
|
|
215
|
+
|
|
216
|
+
```ts title="modules/transform-multiple-params.ts"
|
|
217
|
+
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
|
|
218
|
+
|
|
219
|
+
export default async function (
|
|
220
|
+
request: ZuploRequest,
|
|
221
|
+
context: ZuploContext,
|
|
222
|
+
options: any,
|
|
223
|
+
policyName: string,
|
|
224
|
+
): Promise<ZuploRequest | Response> {
|
|
225
|
+
// Normalize casing
|
|
226
|
+
context.custom.version = request.params.version?.toLowerCase();
|
|
227
|
+
|
|
228
|
+
// Map resource type
|
|
229
|
+
context.custom.resource =
|
|
230
|
+
request.params.resource === "users" ? "customers" : request.params.resource;
|
|
231
|
+
|
|
232
|
+
return request;
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Then use both values in the rewrite pattern:
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"rewritePattern": "https://backend.example.com/${context.custom.version}/${context.custom.resource}/${params.id}"
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Combining with Body Transformation
|
|
245
|
+
|
|
246
|
+
If your API also needs to transform values in the request body alongside route
|
|
247
|
+
parameters, you can handle both in the same inbound policy. Create a new
|
|
248
|
+
`ZuploRequest` with a modified body while storing the route parameter
|
|
249
|
+
transformations on `context.custom`:
|
|
250
|
+
|
|
251
|
+
```ts title="modules/transform-params-and-body.ts"
|
|
252
|
+
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
|
|
253
|
+
|
|
254
|
+
export default async function (
|
|
255
|
+
request: ZuploRequest,
|
|
256
|
+
context: ZuploContext,
|
|
257
|
+
options: any,
|
|
258
|
+
policyName: string,
|
|
259
|
+
): Promise<ZuploRequest | Response> {
|
|
260
|
+
// Transform route parameter
|
|
261
|
+
context.custom.transformedResourceType = `customer${request.params.resourceType}`;
|
|
262
|
+
|
|
263
|
+
// Transform the request body if present
|
|
264
|
+
if (request.headers.get("content-type")?.includes("application/json")) {
|
|
265
|
+
const body = await request.json();
|
|
266
|
+
|
|
267
|
+
// Map fields in the body to match the backend schema
|
|
268
|
+
const transformedBody = {
|
|
269
|
+
...body,
|
|
270
|
+
type: context.custom.transformedResourceType,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Return a new request with the modified body
|
|
274
|
+
return new ZuploRequest(request, {
|
|
275
|
+
body: JSON.stringify(transformedBody),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return request;
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Best Practices
|
|
284
|
+
|
|
285
|
+
- **Use descriptive keys on `context.custom`** — names like
|
|
286
|
+
`context.custom.transformedResourceType` are easier to debug than generic keys
|
|
287
|
+
like `context.custom.value`
|
|
288
|
+
- **Log transformations** — use `context.log` to record original and transformed
|
|
289
|
+
values so you can trace issues in production
|
|
290
|
+
- **Validate before transforming** — return an appropriate error response (using
|
|
291
|
+
[`HttpProblems`](../programmable-api/http-problems.mdx)) if a parameter value
|
|
292
|
+
is unexpected, rather than forwarding bad data to your backend
|
|
293
|
+
- **Keep the policy focused** — if your transformation logic is complex,
|
|
294
|
+
consider splitting it into a separate utility module imported by the policy
|
|
295
|
+
|
|
296
|
+
## Next Steps
|
|
297
|
+
|
|
298
|
+
- [URL Rewrite Handler](../handlers/url-rewrite.mdx) — full reference for
|
|
299
|
+
rewrite patterns and available interpolation variables
|
|
300
|
+
- [Custom Code Patterns](../articles/custom-code-patterns.md) — common patterns
|
|
301
|
+
for writing inbound policies, outbound policies, and handlers
|
|
302
|
+
- [ZuploContext](../programmable-api/zuplo-context.mdx) — reference for
|
|
303
|
+
`context.custom` and other context properties
|
|
304
|
+
- [ZuploRequest](../programmable-api/zuplo-request.mdx) — reference for
|
|
305
|
+
`request.params` and constructing new requests
|
|
306
|
+
- [User-Based Backend Routing](./user-based-backend-routing.mdx) — a related
|
|
307
|
+
pattern using `context.custom` with URL Rewrite for routing by user identity
|
|
@@ -121,6 +121,17 @@ client, ensure your `outputSchema` is a valid `type: object` JSON Schema and
|
|
|
121
121
|
included as `structuredContent`. When `false`, only `text` content will be
|
|
122
122
|
returned.
|
|
123
123
|
|
|
124
|
+
:::caution
|
|
125
|
+
|
|
126
|
+
Enable `includeOutputSchema` and `includeStructuredContent` together. Turning on
|
|
127
|
+
`includeOutputSchema` alone makes the server advertise an output schema while
|
|
128
|
+
returning only `text` content, which causes MCP clients to fail tool calls with
|
|
129
|
+
`Tool call failed: 500`. See
|
|
130
|
+
[Troubleshooting the MCP Server Handler](../mcp-server/troubleshooting.mdx) for
|
|
131
|
+
how to diagnose and fix this.
|
|
132
|
+
|
|
133
|
+
:::
|
|
134
|
+
|
|
124
135
|
### Operations
|
|
125
136
|
|
|
126
137
|
Configure MCP tools, prompts, and resources using the `operations`
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Troubleshooting the MCP Server Handler"
|
|
3
|
+
sidebar_label: "Troubleshooting"
|
|
4
|
+
description:
|
|
5
|
+
Diagnose MCP Server handler tool calls that fail in an AI client even though
|
|
6
|
+
the gateway route returns 200, using debug logging and the structured-content
|
|
7
|
+
configuration.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
When an AI client calls a tool exposed by the
|
|
11
|
+
[MCP Server handler](../handlers/mcp-server.mdx), a failure often surfaces as
|
|
12
|
+
nothing more than `Tool call failed: 500`, with no indication of _why_. The
|
|
13
|
+
underlying gateway route can return `200` while the MCP client still rejects the
|
|
14
|
+
response, which makes these failures hard to diagnose from the client alone.
|
|
15
|
+
|
|
16
|
+
This page shows how to turn on the handler's debug logging, read the log lines
|
|
17
|
+
that reveal a misconfiguration, and fix the most common cause: a tool that
|
|
18
|
+
advertises an output schema but returns no structured content.
|
|
19
|
+
|
|
20
|
+
:::tip
|
|
21
|
+
|
|
22
|
+
Most "200 on the gateway, error in the client" failures come from the
|
|
23
|
+
[`includeOutputSchema` and `includeStructuredContent` options](../handlers/mcp-server.mdx#mcp-2025-06-18-global-options).
|
|
24
|
+
If you enabled one without the other, jump straight to
|
|
25
|
+
[Tool call failed: 500](#tool-call-failed-500--has-an-output-schema-but-did-not-return-structured-content).
|
|
26
|
+
|
|
27
|
+
:::
|
|
28
|
+
|
|
29
|
+
## Enable debug logging
|
|
30
|
+
|
|
31
|
+
The MCP Server handler can emit verbose logs covering server startup, tool
|
|
32
|
+
registration, and each tool call. These logs are the fastest way to confirm what
|
|
33
|
+
the server actually advertised to the client.
|
|
34
|
+
|
|
35
|
+
<Stepper>
|
|
36
|
+
|
|
37
|
+
1. **Turn on `debugMode`.** Set `debugMode: true` in the handler options for
|
|
38
|
+
your MCP route in `routes.oas.json`:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
"post": {
|
|
42
|
+
"x-zuplo-route": {
|
|
43
|
+
"handler": {
|
|
44
|
+
"export": "mcpServerHandler",
|
|
45
|
+
"module": "$import(@zuplo/runtime)",
|
|
46
|
+
"options": {
|
|
47
|
+
"name": "example-mcp-server",
|
|
48
|
+
"version": "1.0.0",
|
|
49
|
+
"debugMode": true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
2. **Redeploy so the server cold-starts.** Some of the most useful log lines —
|
|
57
|
+
server startup and tool registration — are written only when the MCP server
|
|
58
|
+
initializes (a cold start). An already-running worker won't re-emit them.
|
|
59
|
+
Deploy the working copy or environment again so a fresh worker boots with
|
|
60
|
+
`debugMode` enabled.
|
|
61
|
+
|
|
62
|
+
3. **View the logs.** Open your project in the Portal and go to **Observability
|
|
63
|
+
→ Logs**. Trigger a tool call from your MCP client, then read the entries for
|
|
64
|
+
the MCP route. Filter on the request's `zuplo-request-id` to isolate a single
|
|
65
|
+
call.
|
|
66
|
+
|
|
67
|
+
</Stepper>
|
|
68
|
+
|
|
69
|
+
:::caution
|
|
70
|
+
|
|
71
|
+
Leave `debugMode` off in production. It logs verbose per-call detail that adds
|
|
72
|
+
noise and overhead. Turn it on to diagnose an issue, then turn it back off and
|
|
73
|
+
redeploy.
|
|
74
|
+
|
|
75
|
+
:::
|
|
76
|
+
|
|
77
|
+
### Key log lines to read
|
|
78
|
+
|
|
79
|
+
With `debugMode: true`, the handler writes lines at each stage of its lifecycle.
|
|
80
|
+
The exact format may change between runtime versions, but the fields below are
|
|
81
|
+
what you're looking for:
|
|
82
|
+
|
|
83
|
+
| Log line | When it's written | What to check |
|
|
84
|
+
| ------------------------------ | ----------------------- | ---------------------------------------------------------------------- |
|
|
85
|
+
| `MCP Server cold start` | A fresh worker boots | Confirms `debugMode` is active and a new worker started |
|
|
86
|
+
| `MCP tool registered` | Each tool is registered | The `includeStructuredContent` and `hasOutputSchema` fields for a tool |
|
|
87
|
+
| `MCP Server response complete` | A tool call finishes | The response was produced and sent back to the client |
|
|
88
|
+
|
|
89
|
+
The `MCP tool registered` line is the important one. For a tool whose calls fail
|
|
90
|
+
in the client, you'll typically see a line resembling:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
MCP tool registered { name: "get_current_weather", hasOutputSchema: true, includeStructuredContent: false }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`hasOutputSchema: true` with `includeStructuredContent: false` is the exact
|
|
97
|
+
misconfiguration described in the next section.
|
|
98
|
+
|
|
99
|
+
## Tool call failed: 500 / "has an output schema but did not return structured content"
|
|
100
|
+
|
|
101
|
+
**Symptom.** An AI client (such as Claude) reports `Tool call failed: 500` when
|
|
102
|
+
it invokes a tool. With more verbose client logging, the underlying error reads:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
Tool <tool_name> has an output schema but did not return structured content
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Meanwhile, the gateway's own logs show the route that backs the tool returned
|
|
109
|
+
`200`.
|
|
110
|
+
|
|
111
|
+
**Likely cause.** The handler is configured with `includeOutputSchema: true` but
|
|
112
|
+
not `includeStructuredContent: true`. Under the
|
|
113
|
+
[MCP `2025-06-18`](https://modelcontextprotocol.io/specification/2025-06-18)
|
|
114
|
+
structured-content behavior, when a tool advertises an `outputSchema`, the
|
|
115
|
+
client expects every successful result to include a matching `structuredContent`
|
|
116
|
+
object. With `includeOutputSchema` on and `includeStructuredContent` off, the
|
|
117
|
+
server advertises the schema but returns only `text` content, so a
|
|
118
|
+
spec-compliant client rejects an otherwise-successful `200` response.
|
|
119
|
+
|
|
120
|
+
The `MCP tool registered` debug line confirms it:
|
|
121
|
+
`hasOutputSchema: true, includeStructuredContent: false`.
|
|
122
|
+
|
|
123
|
+
**Fix.** Enable both options together on the MCP Server handler:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
"options": {
|
|
127
|
+
"name": "example-mcp-server",
|
|
128
|
+
"version": "1.0.0",
|
|
129
|
+
"includeOutputSchema": true,
|
|
130
|
+
"includeStructuredContent": true
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Then redeploy. The next `MCP tool registered` line should read
|
|
135
|
+
`hasOutputSchema: true, includeStructuredContent: true`, and the tool call
|
|
136
|
+
succeeds.
|
|
137
|
+
|
|
138
|
+
:::note
|
|
139
|
+
|
|
140
|
+
If you don't need output schemas at all, the alternative fix is to turn both off
|
|
141
|
+
(the defaults). The two options are designed to move together: enable
|
|
142
|
+
`includeOutputSchema` _only_ alongside `includeStructuredContent`.
|
|
143
|
+
|
|
144
|
+
:::
|
|
145
|
+
|
|
146
|
+
For the full definitions of both options, see the
|
|
147
|
+
[MCP `2025-06-18` Global Options](../handlers/mcp-server.mdx#mcp-2025-06-18-global-options)
|
|
148
|
+
in the handler reference.
|
|
149
|
+
|
|
150
|
+
## Client-side debugging tools
|
|
151
|
+
|
|
152
|
+
When the gateway looks healthy, reproduce the failure against the client to see
|
|
153
|
+
the protocol-level error the AI client hides:
|
|
154
|
+
|
|
155
|
+
- **MCP Inspector** — the
|
|
156
|
+
[official inspector](https://github.com/modelcontextprotocol/inspector)
|
|
157
|
+
(`npx @modelcontextprotocol/inspector`) connects to your remote server, lists
|
|
158
|
+
tools, and calls them while showing the raw JSON-RPC messages. See
|
|
159
|
+
[Testing](./testing.mdx) for connection steps.
|
|
160
|
+
- **mcpjam** — the [mcpjam inspector](https://github.com/mcpjam/inspector) is an
|
|
161
|
+
open-source MCP testing client that's useful for exercising tool calls and
|
|
162
|
+
inspecting responses outside of an AI client.
|
|
163
|
+
- **Claude Code** — run with the `--debug` flag (for example, `claude --debug`)
|
|
164
|
+
to print MCP protocol traffic, including the full tool-call error that the
|
|
165
|
+
chat UI summarizes as `Tool call failed: 500`.
|
|
166
|
+
|
|
167
|
+
## Checklist: the gateway returns 200 but the client errors
|
|
168
|
+
|
|
169
|
+
When a tool call fails in the client but the gateway reports success, verify
|
|
170
|
+
each of these in order:
|
|
171
|
+
|
|
172
|
+
1. **`debugMode` is on and a fresh worker booted.** Confirm a
|
|
173
|
+
`MCP Server cold start` line appears after your latest deploy. Without a cold
|
|
174
|
+
start, you're reading stale logs.
|
|
175
|
+
2. **Schema and structured content move together.** In the `MCP tool registered`
|
|
176
|
+
line, `hasOutputSchema` and `includeStructuredContent` should either both be
|
|
177
|
+
`true` or both be `false` — never `hasOutputSchema: true` with
|
|
178
|
+
`includeStructuredContent: false`.
|
|
179
|
+
3. **The output schema is a valid `type: object` JSON Schema.** Some clients
|
|
180
|
+
reject schemas that aren't `type: object`. The same applies to the
|
|
181
|
+
`structuredContent` the server returns. See the
|
|
182
|
+
[compatibility caveat](../handlers/mcp-server.mdx#mcp-2025-06-18-global-options).
|
|
183
|
+
4. **The route really returns JSON.** `includeStructuredContent` parses the
|
|
184
|
+
response body as JSON to build `structuredContent`. A non-JSON `200` body
|
|
185
|
+
can't be parsed into the advertised schema.
|
|
186
|
+
5. **Reproduce in a raw client.** Call the tool through the
|
|
187
|
+
[MCP Inspector](./testing.mdx) or `curl` to read the JSON-RPC error directly,
|
|
188
|
+
rather than the client's summarized `500`.
|
|
189
|
+
|
|
190
|
+
## Next steps
|
|
191
|
+
|
|
192
|
+
- [MCP Server handler reference](../handlers/mcp-server.mdx) — every handler
|
|
193
|
+
configuration option.
|
|
194
|
+
- [Testing your MCP server](./testing.mdx) — MCP Inspector and `curl` recipes.
|
|
195
|
+
- [MCP Server tools](./tools.mdx) — editing tool definitions and `outputSchema`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zuplo",
|
|
3
|
-
"version": "6.71.
|
|
3
|
+
"version": "6.71.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The programmable API Gateway",
|
|
6
6
|
"author": "Zuplo, Inc.",
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"zuplo": "zuplo.js"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@zuplo/cli": "6.71.
|
|
23
|
-
"@zuplo/core": "6.71.
|
|
24
|
-
"@zuplo/runtime": "6.71.
|
|
22
|
+
"@zuplo/cli": "6.71.11",
|
|
23
|
+
"@zuplo/core": "6.71.11",
|
|
24
|
+
"@zuplo/runtime": "6.71.11",
|
|
25
25
|
"@zuplo/test": "1.4.0"
|
|
26
26
|
}
|
|
27
27
|
}
|