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.
@@ -140,6 +140,7 @@ const config = {
140
140
  graphqlPlugin({
141
141
  schema: "./schema.graphql", // Also accepts a URL, e.g. https://...
142
142
  path: graphqlPath,
143
+ // endpoint: "/my-graphql", // Optional, defaults to "/graphql"
143
144
  }),
144
145
  ],
145
146
  };
@@ -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
- :::tip{title="Using an intermediate CA"}
91
+ :::caution{title="Upload the self-signed root CA, not an intermediate"}
92
92
 
93
- If your client certificates are issued by an intermediate CA (rather than
94
- directly by your root), upload the root CA certificate that anchors the chain.
95
- Clients must send the leaf certificate plus any intermediate certificates when
96
- they connect.
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.9",
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.9",
23
- "@zuplo/core": "6.71.9",
24
- "@zuplo/runtime": "6.71.9",
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
  }