zuplo 6.71.8 → 6.71.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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)
|
|
@@ -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.10",
|
|
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.10",
|
|
23
|
+
"@zuplo/core": "6.71.10",
|
|
24
|
+
"@zuplo/runtime": "6.71.10",
|
|
25
25
|
"@zuplo/test": "1.4.0"
|
|
26
26
|
}
|
|
27
27
|
}
|