zuplo 6.70.69 → 6.70.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/docs/ai-gateway/getting-started.mdx +12 -8
  2. package/docs/ai-gateway/introduction.mdx +11 -9
  3. package/docs/articles/api-key-buckets.mdx +4 -2
  4. package/docs/articles/archiving-requests-to-storage.mdx +4 -4
  5. package/docs/articles/branch-based-deployments.mdx +10 -8
  6. package/docs/articles/ci-cd-github/cleanup-on-branch-delete.mdx +52 -31
  7. package/docs/articles/ci-cd-github/pr-preview-environments.mdx +17 -6
  8. package/docs/articles/custom-ci-cd-azure.mdx +1 -1
  9. package/docs/articles/custom-ci-cd-bitbucket.mdx +1 -1
  10. package/docs/articles/custom-ci-cd-circleci.mdx +1 -1
  11. package/docs/articles/custom-ci-cd-github.mdx +1 -1
  12. package/docs/articles/custom-ci-cd-gitlab.mdx +1 -1
  13. package/docs/articles/graphql.mdx +276 -0
  14. package/docs/articles/monorepo-deployment.mdx +17 -3
  15. package/docs/articles/opentelemetry.mdx +5 -2
  16. package/docs/articles/per-user-rate-limits-using-db.mdx +5 -6
  17. package/docs/articles/securing-the-gateway-with-client-mtls.mdx +68 -43
  18. package/docs/articles/step-1-setup-basic-gateway.mdx +1 -3
  19. package/docs/articles/step-2-add-rate-limiting.mdx +1 -1
  20. package/docs/articles/testing.mdx +1 -1
  21. package/docs/articles/troubleshooting.md +7 -3
  22. package/docs/articles/waf-ddos-akamai.md +35 -16
  23. package/docs/articles/waf-ddos-aws-waf-shield.mdx +35 -16
  24. package/docs/articles/waf-ddos-fastly.mdx +10 -7
  25. package/docs/cli/deploy.mdx +13 -10
  26. package/docs/cli/deploy.partial.mdx +13 -10
  27. package/docs/dev-portal/zudoku/components/sidecar-box.mdx +131 -0
  28. package/docs/dev-portal/zudoku/configuration/api-catalog.md +62 -42
  29. package/docs/dev-portal/zudoku/configuration/api-reference.md +5 -4
  30. package/docs/dev-portal/zudoku/configuration/navigation.mdx +70 -7
  31. package/docs/guides/canary-routing-for-employees.mdx +103 -39
  32. package/docs/guides/modify-openapi-paths.mdx +3 -3
  33. package/docs/handlers/legacy-dev-portal-handler.mdx +1 -1
  34. package/docs/handlers/mcp-server.mdx +13 -11
  35. package/docs/handlers/url-forward.mdx +5 -1
  36. package/docs/handlers/url-rewrite.mdx +7 -2
  37. package/docs/handlers/websocket-handler.mdx +5 -1
  38. package/docs/mcp-gateway/observability/logging.mdx +19 -12
  39. package/docs/mcp-server/resources.mdx +27 -15
  40. package/docs/mcp-server/testing.mdx +0 -2
  41. package/docs/policies/archive-request-azure-storage-inbound/doc.md +1 -1
  42. package/docs/policies/archive-response-azure-storage-outbound/doc.md +1 -1
  43. package/docs/policies/ip-restriction-inbound/policy.ts +1 -1
  44. package/docs/programmable-api/http-problems.mdx +0 -18
  45. package/docs/programmable-api/jwt-service-plugin.mdx +131 -109
  46. package/docs/programmable-api/runtime-behaviors.mdx +4 -2
  47. package/docs/programmable-api/streaming-zone-cache.mdx +4 -6
  48. package/docs/programmable-api/web-crypto-apis.mdx +10 -6
  49. package/package.json +4 -4
  50. package/docs/errors/get-head-body-error.mdx +0 -41
@@ -32,6 +32,10 @@ If any condition is met, the request routes to canary backends.
32
32
 
33
33
  ## Creating a Canary Routing Policy
34
34
 
35
+ The policy doesn't set the backend URL directly. Instead, it stores the chosen
36
+ backend on `context.custom`, and the route's handler reads that value to forward
37
+ the request (see "Adding the Policy to Routes" below).
38
+
35
39
  Create a new policy file in your project:
36
40
 
37
41
  ```typescript
@@ -62,9 +66,9 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
62
66
  const isCanary =
63
67
  stageParam === "canary" || stageHeader === "canary" || canaryUser;
64
68
 
65
- // Route based on stage
69
+ // Store the backend URL for the route's handler to use
66
70
  if (isCanary) {
67
- context.route.url = environment.API_URL_CANARY;
71
+ context.custom.backendUrl = environment.API_URL_CANARY;
68
72
 
69
73
  // Log canary routing for debugging
70
74
  context.log.info("Routing to canary backend", {
@@ -73,13 +77,19 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
73
77
  stage: stageParam || stageHeader || "canary",
74
78
  });
75
79
  } else {
76
- context.route.url = environment.API_URL_PRODUCTION;
80
+ context.custom.backendUrl = environment.API_URL_PRODUCTION;
77
81
  }
78
82
 
79
83
  // Remove stage query parameter to avoid passing it to backend
80
84
  if (stageParam) {
81
85
  url.searchParams.delete("stage");
82
- return new ZuploRequest(url.toString(), request);
86
+ return new ZuploRequest(url.toString(), {
87
+ method: request.method,
88
+ headers: request.headers,
89
+ body: request.body,
90
+ user: request.user,
91
+ params: request.params,
92
+ });
83
93
  }
84
94
 
85
95
  return request;
@@ -139,24 +149,30 @@ export const multiServiceCanaryRoutingPolicy: InboundPolicyHandler = async (
139
149
  // Clean up query parameter
140
150
  if (stageParam) {
141
151
  url.searchParams.delete("stage");
142
- return new ZuploRequest(url.toString(), request);
152
+ return new ZuploRequest(url.toString(), {
153
+ method: request.method,
154
+ headers: request.headers,
155
+ body: request.body,
156
+ user: request.user,
157
+ params: request.params,
158
+ });
143
159
  }
144
160
 
145
161
  return request;
146
162
  };
147
163
  ```
148
164
 
165
+ Each service's route then reads the matching value in its handler options. For
166
+ example, the user API route would use a URL Forward handler with
167
+ `"baseUrl": "${context.custom.userApiUrl}"`.
168
+
149
169
  ## Percentage-Based Canary Routing
150
170
 
151
171
  Roll out canary deployments gradually with percentage-based routing:
152
172
 
153
173
  ```typescript
154
174
  // policies/percentage-canary-routing.ts
155
- import {
156
- InboundPolicyHandler,
157
- ZuploRequest,
158
- environment,
159
- } from "@zuplo/runtime";
175
+ import { InboundPolicyHandler, environment } from "@zuplo/runtime";
160
176
 
161
177
  export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
162
178
  request,
@@ -168,7 +184,7 @@ export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
168
184
  : [];
169
185
 
170
186
  if (request.user?.sub && CANARY_USERS.includes(request.user.sub)) {
171
- context.route.url = environment.API_URL_CANARY;
187
+ context.custom.backendUrl = environment.API_URL_CANARY;
172
188
  return request;
173
189
  }
174
190
 
@@ -176,8 +192,12 @@ export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
176
192
  const CANARY_PERCENTAGE = parseInt(environment.CANARY_PERCENTAGE || "0", 10);
177
193
 
178
194
  if (CANARY_PERCENTAGE > 0) {
179
- // Use a consistent hash for sticky sessions
180
- const sessionId = request.headers.get("x-session-id") || request.ip;
195
+ // Use a consistent hash for sticky sessions. The client IP is
196
+ // available on the true-client-ip header.
197
+ const sessionId =
198
+ request.headers.get("x-session-id") ??
199
+ request.headers.get("true-client-ip") ??
200
+ "unknown";
181
201
  const hash = await crypto.subtle.digest(
182
202
  "SHA-256",
183
203
  new TextEncoder().encode(sessionId),
@@ -186,13 +206,13 @@ export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
186
206
  const hashValue = hashArray[0] / 255; // Value between 0 and 1
187
207
 
188
208
  if (hashValue * 100 < CANARY_PERCENTAGE) {
189
- context.route.url = environment.API_URL_CANARY;
209
+ context.custom.backendUrl = environment.API_URL_CANARY;
190
210
  request.headers.set("X-Canary-Route", "percentage");
191
211
  } else {
192
- context.route.url = environment.API_URL_PRODUCTION;
212
+ context.custom.backendUrl = environment.API_URL_PRODUCTION;
193
213
  }
194
214
  } else {
195
- context.route.url = environment.API_URL_PRODUCTION;
215
+ context.custom.backendUrl = environment.API_URL_PRODUCTION;
196
216
  }
197
217
 
198
218
  return request;
@@ -221,7 +241,9 @@ CANARY_PERCENTAGE=10 # Route 10% of traffic to canary
221
241
 
222
242
  ### Adding the Policy to Routes
223
243
 
224
- Add the policy to your route configuration:
244
+ Add the policy to your route configuration. Use the built-in
245
+ [URL Forward handler](../handlers/url-forward.mdx) and reference the value the
246
+ policy stored on `context.custom` in the `baseUrl` option:
225
247
 
226
248
  ```json
227
249
  {
@@ -231,14 +253,14 @@ Add the policy to your route configuration:
231
253
  "x-zuplo-route": {
232
254
  "corsPolicy": "anything-goes",
233
255
  "handler": {
234
- "export": "urlRewriteHandler",
256
+ "export": "urlForwardHandler",
235
257
  "module": "$import(@zuplo/runtime)",
236
258
  "options": {
237
- "rewritePattern": "https://api.company.com$1"
259
+ "baseUrl": "${context.custom.backendUrl}"
238
260
  }
239
261
  },
240
262
  "policies": {
241
- "inbound": ["canary-routing", "auth-policy"]
263
+ "inbound": ["auth-policy", "canary-routing"]
242
264
  }
243
265
  }
244
266
  }
@@ -247,6 +269,9 @@ Add the policy to your route configuration:
247
269
  }
248
270
  ```
249
271
 
272
+ The authentication policy runs first so that `request.user` is populated when
273
+ the canary routing policy checks the allow list.
274
+
250
275
  ## Testing Your Policy
251
276
 
252
277
  ### 1. Using Query Parameters
@@ -289,7 +314,9 @@ curl https://your-api.zuplo.app/api/v1/users \
289
314
 
290
315
  ## Monitoring and Observability
291
316
 
292
- Add comprehensive logging to track canary routing:
317
+ Add comprehensive logging to track canary routing. Inbound policies run before a
318
+ response exists, so set debug response headers from a
319
+ [response sending hook](../programmable-api/hooks.mdx):
293
320
 
294
321
  ```typescript
295
322
  export const canaryRoutingPolicy: InboundPolicyHandler = async (
@@ -298,20 +325,26 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
298
325
  ) => {
299
326
  const startTime = Date.now();
300
327
 
328
+ const CANARY_USERS = environment.CANARY_USERS
329
+ ? environment.CANARY_USERS.split(",").map((user) => user.trim())
330
+ : [];
331
+
301
332
  // Check stage conditions
302
333
  const url = new URL(request.url);
303
334
  const stageParam = url.searchParams.get("stage");
304
335
  const stageHeader = request.headers.get("x-stage");
305
- const canaryUser = /* ... check if user is in canary list ... */;
336
+ const canaryUser =
337
+ request.user?.sub && CANARY_USERS.includes(request.user.sub);
306
338
 
307
339
  const isCanary =
308
- stageParam === "canary" ||
309
- stageHeader === "canary" ||
310
- canaryUser;
340
+ stageParam === "canary" || stageHeader === "canary" || canaryUser;
311
341
 
312
- if (isCanary) {
313
- context.route.url = environment.API_URL_CANARY;
342
+ const backendType = isCanary ? "canary" : "release";
343
+ context.custom.backendUrl = isCanary
344
+ ? environment.API_URL_CANARY
345
+ : environment.API_URL_PRODUCTION;
314
346
 
347
+ if (isCanary) {
315
348
  // Log canary routing metrics
316
349
  context.log.info("Canary route selected", {
317
350
  userId: request.user?.sub,
@@ -320,14 +353,14 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
320
353
  stage: "canary",
321
354
  duration: Date.now() - startTime,
322
355
  });
323
-
324
- // Add response header for debugging
325
- context.response.headers.set("X-Backend-Type", "canary");
326
- } else {
327
- context.route.url = environment.API_URL_PRODUCTION;
328
- context.response.headers.set("X-Backend-Type", "release");
329
356
  }
330
357
 
358
+ // Add a response header for debugging
359
+ context.addResponseSendingHook((response) => {
360
+ response.headers.set("X-Backend-Type", backendType);
361
+ return response;
362
+ });
363
+
331
364
  return request;
332
365
  };
333
366
  ```
@@ -359,14 +392,45 @@ if (isCanaryUser) {
359
392
 
360
393
  ### 3. Fallback Handling
361
394
 
362
- Implement fallback logic for canary backend failures:
395
+ To fall back to production when the canary backend fails, replace the URL
396
+ Forward handler with a [custom handler](../handlers/custom-handler.mdx) that
397
+ retries against production on server errors:
363
398
 
364
399
  ```typescript
365
- // In your error handling policy
366
- if (context.response.status >= 500 && context.custom.isCanary) {
367
- // Retry with production backend
368
- context.route.url = environment.API_URL_PRODUCTION;
369
- return context.retry();
400
+ // modules/canary-fallback-handler.ts
401
+ import { ZuploContext, ZuploRequest, environment } from "@zuplo/runtime";
402
+
403
+ export default async function handler(
404
+ request: ZuploRequest,
405
+ context: ZuploContext,
406
+ ) {
407
+ const url = new URL(request.url);
408
+ const backendUrl = context.custom.backendUrl;
409
+ const target = `${backendUrl}${url.pathname}${url.search}`;
410
+
411
+ // Buffer the body so the request can be retried
412
+ const body = request.body ? await request.arrayBuffer() : undefined;
413
+
414
+ const response = await fetch(target, {
415
+ method: request.method,
416
+ headers: request.headers,
417
+ body,
418
+ });
419
+
420
+ // Retry against production if the canary backend returns a server error
421
+ if (backendUrl === environment.API_URL_CANARY && response.status >= 500) {
422
+ context.log.warn("Canary backend failed, falling back to production", {
423
+ status: response.status,
424
+ });
425
+ const fallbackBase = environment.API_URL_PRODUCTION;
426
+ return fetch(`${fallbackBase}${url.pathname}${url.search}`, {
427
+ method: request.method,
428
+ headers: request.headers,
429
+ body,
430
+ });
431
+ }
432
+
433
+ return response;
370
434
  }
371
435
  ```
372
436
 
@@ -106,8 +106,8 @@ You can generate multiple versions as part of your build process:
106
106
  ```json title="package.json"
107
107
  {
108
108
  "scripts": {
109
- "build:api:v1": "npx tsx add-path-prefix.ts /v1 openapi.json dist/openapi-v1.json",
110
- "build:api:v2": "npx tsx add-path-prefix.ts /v2 openapi.json dist/openapi-v2.json",
109
+ "build:api:v1": "node add-path-prefix.mjs /v1 openapi.json dist/openapi-v1.json",
110
+ "build:api:v2": "node add-path-prefix.mjs /v2 openapi.json dist/openapi-v2.json",
111
111
  "build:api:all": "npm run build:api:v1 && npm run build:api:v2"
112
112
  }
113
113
  }
@@ -128,7 +128,7 @@ mkdir -p $OUTPUT_DIR
128
128
  for prefix in "${PREFIXES[@]}"; do
129
129
  echo "Building with prefix: /$prefix"
130
130
 
131
- npx tsx add-path-prefix.ts \
131
+ node add-path-prefix.mjs \
132
132
  "/$prefix" \
133
133
  "$BASE_FILE" \
134
134
  "$OUTPUT_DIR/openapi-$prefix.json"
@@ -35,7 +35,7 @@ as it provides better performance and usability.
35
35
  "title": "Dev Portal Redirect"
36
36
  },
37
37
  "paths": {
38
- "/docs{(.*)}": {
38
+ "/docs(.*)": {
39
39
  "x-zuplo-path": {
40
40
  "pathMode": "url-pattern"
41
41
  },
@@ -35,14 +35,15 @@ Open the **Route Designer** by navigating to the **Code** tab, then click
35
35
  **routes.oas.json**. For any route definition, select **MCP Server** from the
36
36
  **Request Handlers** drop-down. Set the method to **POST**.
37
37
 
38
- Configure the handler with the following required options:
38
+ Configure the handler with the following options:
39
39
 
40
- - **Server Name** - The name of the MCP server. AI MCP clients will read this
41
- name when they initialize with the server.
42
- - **Server Version** - The version of your MCP server. AI MCP clients read this
43
- version when they initialize with the server and may make autonomous decisions
44
- based on the versioning of your MCP server and the instructions they've been
45
- given.
40
+ - **Server Name** (optional) - The name of the MCP server. AI MCP clients will
41
+ read this name when they initialize with the server. If not set, the server
42
+ advertises the default name `Zuplo MCP Server`.
43
+ - **Server Version** (optional) - The version of your MCP server. AI MCP clients
44
+ read this version when they initialize with the server and may make autonomous
45
+ decisions based on the versioning of your MCP server and the instructions
46
+ they've been given. If not set, the version defaults to `0.0.0`.
46
47
 
47
48
  Next, configure your routes to be transformed into MCP tools or prompts (see
48
49
  Configuration section below).
@@ -65,7 +66,7 @@ with the following route configuration:
65
66
  "handler": {
66
67
 
67
68
  // The MCP Server Handler handler
68
- // and required options
69
+ // and example options
69
70
 
70
71
  "export": "mcpServerHandler",
71
72
  "module": "$import(@zuplo/runtime)",
@@ -86,10 +87,11 @@ with the following route configuration:
86
87
 
87
88
  ## Configuration
88
89
 
89
- The MCP Server handler requires the following configurations:
90
+ The MCP Server handler supports the following configuration options:
90
91
 
91
- - `name` (optional) - The name identifier of the MCP server.
92
- - `version` (optional) - The version of the MCP server.
92
+ - `name` (optional, default `Zuplo MCP Server`) - The name identifier of the MCP
93
+ server.
94
+ - `version` (optional, default `0.0.0`) - The version of the MCP server.
93
95
  - `debugMode` (optional, default `false`) - Verbose logs on server startup,
94
96
  initialization, tool listing, and tool calls. NOT recommended for production
95
97
  environments.
@@ -53,6 +53,8 @@ The following objects are available for substitution:
53
53
  `params.productId` would be the value of `:productId` in a route.
54
54
  - `query: Record<string, string>` - The query parameters of the route. For
55
55
  example, `query.filterBy` would be the value of `?filterBy=VALUE`.
56
+ - `method: string` - The HTTP method of the incoming request. For example, `GET`
57
+ or `POST`.
56
58
  - `headers: Headers` - the incoming request's
57
59
  [headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
58
60
  - `url: string` - The full incoming request as a string
@@ -62,6 +64,9 @@ The following objects are available for substitution:
62
64
  - `hostname: string` - The
63
65
  [`hostname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hostname)
64
66
  portion of the incoming URL
67
+ - `origin: string` - The
68
+ [`origin`](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin)
69
+ portion of the incoming URL
65
70
  - `pathname: string` - The
66
71
  [`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname)
67
72
  portion of the incoming URL
@@ -93,7 +98,6 @@ A few examples of the values of various substitutions.
93
98
  - `${params.productId}` - `":productId"`
94
99
  - `${pathname}` - `"/v1/products/:productId"`
95
100
  - `${port}` - `"8080"`
96
- - `${protocol}` - `"https:"`
97
101
  - `${query.category}` - `"cars"`
98
102
  - `${search}` - `"?category=cars"`
99
103
  - `${url}` - `"https://example.com:8080/v1/products/:productId?category=cars"`
@@ -43,6 +43,8 @@ The following objects are available for substitution:
43
43
  `params.productId` would be the value of `:productId` in a route.
44
44
  - `query: Record<string, string>` - The query parameters of the route. For
45
45
  example, `query.filterBy` would be the value of `?filterBy=VALUE`.
46
+ - `method: string` - The HTTP method of the incoming request. For example, `GET`
47
+ or `POST`.
46
48
  - `headers: Headers` - the incoming request's
47
49
  [headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
48
50
  - `url: string` - The full incoming request as a string
@@ -52,6 +54,9 @@ The following objects are available for substitution:
52
54
  - `hostname: string` - The
53
55
  [`hostname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hostname)
54
56
  portion of the incoming URL
57
+ - `origin: string` - The
58
+ [`origin`](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin)
59
+ portion of the incoming URL
55
60
  - `pathname: string` - The
56
61
  [`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname)
57
62
  portion of the incoming URL
@@ -83,7 +88,6 @@ A few examples of the values of various substitutions.
83
88
  - `${params.productId}` - `":productId"`
84
89
  - `${pathname}` - `"/v1/products/:productId"`
85
90
  - `${port}` - `"8080"`
86
- - `${protocol}` - `"https:"`
87
91
  - `${query.category}` - `"cars"`
88
92
  - `${search}` - `"?category=cars"`
89
93
  - `${url}` - `"https://example.com:8080/v1/products/:productId?category=cars"`
@@ -132,7 +136,8 @@ use-cases.
132
136
  - Type: `string`
133
137
  - Supports JavaScript template interpolation with request context
134
138
  - Available variables: `env`, `request`, `context`, `params`, `query`,
135
- `headers`, `url`, `host`, `hostname`, `pathname`, `port`, `search`
139
+ `method`, `headers`, `url`, `host`, `hostname`, `origin`, `pathname`,
140
+ `port`, `search`
136
141
  - Example: `"https://api-${params.version}.example.com/users/${params.id}"`
137
142
 
138
143
  - **`forwardSearch`** (optional): Controls whether query parameters are
@@ -84,6 +84,8 @@ The following objects are available for substitution:
84
84
  `params.productId` would be the value of `:productId` in a route.
85
85
  - `query: Record<string, string>` - The query parameters of the route. For
86
86
  example, `query.filterBy` would be the value of `?filterBy=VALUE`.
87
+ - `method: string` - The HTTP method of the incoming request. For example, `GET`
88
+ or `POST`.
87
89
  - `headers: Headers` - the incoming request's
88
90
  [headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
89
91
  - `url: string` - The full incoming request as a string
@@ -93,6 +95,9 @@ The following objects are available for substitution:
93
95
  - `hostname: string` - The
94
96
  [`hostname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hostname)
95
97
  portion of the incoming URL
98
+ - `origin: string` - The
99
+ [`origin`](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin)
100
+ portion of the incoming URL
96
101
  - `pathname: string` - The
97
102
  [`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname)
98
103
  portion of the incoming URL
@@ -124,7 +129,6 @@ A few examples of the values of various substitutions.
124
129
  - `${params.productId}` - `":productId"`
125
130
  - `${pathname}` - `"/v1/products/:productId"`
126
131
  - `${port}` - `"8080"`
127
- - `${protocol}` - `"https:"`
128
132
  - `${query.category}` - `"cars"`
129
133
  - `${search}` - `"?category=cars"`
130
134
  - `${url}` - `"https://example.com:8080/v1/products/:productId?category=cars"`
@@ -141,33 +141,40 @@ structured entries described above with their `event` field preserved.
141
141
  Registering both plugins is a small addition to `modules/zuplo.runtime.ts`:
142
142
 
143
143
  ```ts title="modules/zuplo.runtime.ts"
144
- import { RuntimeExtensions } from "@zuplo/runtime";
144
+ import { OpenTelemetryPlugin } from "@zuplo/otel";
145
+ import { environment, RuntimeExtensions } from "@zuplo/runtime";
145
146
  import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway";
146
- import { OpenTelemetryPlugin } from "@zuplo/runtime";
147
147
 
148
148
  export function runtimeInit(runtime: RuntimeExtensions) {
149
149
  runtime.addPlugin(new McpGatewayPlugin());
150
150
  runtime.addPlugin(
151
151
  new OpenTelemetryPlugin({
152
- url: process.env.OTLP_TRACES_ENDPOINT,
153
- logUrl: process.env.OTLP_LOGS_ENDPOINT,
154
- authorization: process.env.OTLP_AUTHORIZATION,
152
+ traceUrl: environment.OTLP_TRACES_ENDPOINT,
153
+ logUrl: environment.OTLP_LOGS_ENDPOINT,
154
+ headers: {
155
+ Authorization: environment.OTLP_AUTHORIZATION,
156
+ },
157
+ service: {
158
+ name: "mcp-gateway",
159
+ },
155
160
  }),
156
161
  );
157
162
  }
158
163
  ```
159
164
 
160
- Grafana Cloud is one common destination define the endpoint and credentials in
161
- `.env`:
165
+ Logs are only exported when `logUrl` is set alongside `traceUrl` configuring
166
+ only `exporter.url` exports traces alone. Grafana Cloud is one common
167
+ destination — define the endpoints and credentials as
168
+ [environment variables](../../articles/environment-variables.mdx):
162
169
 
163
170
  ```bash
164
- GRAFANA_OTLP_ENDPOINT=https://otlp-gateway-prod-<region>.grafana.net/otlp
165
- GRAFANA_OTLP_INSTANCE_ID=<instance-id>
166
- GRAFANA_OTLP_API_TOKEN=glc_<token>
171
+ OTLP_TRACES_ENDPOINT=https://otlp-gateway-prod-<region>.grafana.net/otlp/v1/traces
172
+ OTLP_LOGS_ENDPOINT=https://otlp-gateway-prod-<region>.grafana.net/otlp/v1/logs
173
+ OTLP_AUTHORIZATION=Basic <base64 of instance-id:api-token>
167
174
  ```
168
175
 
169
- Use the base OTLP endpoint that ends in `/otlp` the runtime appends
170
- `/v1/traces` and `/v1/logs` itself. Other OTLP-compatible destinations
176
+ The plugin sends OTLP data to the configured URLs exactly as given, so include
177
+ the full `/v1/traces` and `/v1/logs` paths. Other OTLP-compatible destinations
171
178
  (Honeycomb, Dynatrace, New Relic, Tempo, self-hosted Jaeger) work the same way;
172
179
  substitute the endpoint and credential headers.
173
180
 
@@ -191,27 +191,36 @@ Response:
191
191
  "result": {
192
192
  "resources": [
193
193
  {
194
- "name": "html",
195
- "uri": "mcp://resources/html",
196
- "title": "html",
197
- "description": "Returns the AI applet's HTML",
198
- "mimeType": "text/plain"
194
+ "name": "html_doc",
195
+ "uri": "ui://html",
196
+ "title": "html_doc",
197
+ "description": "The HTML document for the AI applet",
198
+ "mimeType": "text/html"
199
199
  },
200
200
  {
201
- "name": "css_doc",
202
- "uri": "ui://css",
203
- "title": "css_doc",
204
- "description": "The CSS resource",
205
- "mimeType": "text/css"
201
+ "name": "css",
202
+ "uri": "mcp://resources/css",
203
+ "title": "css",
204
+ "description": "Returns the AI applet's CSS",
205
+ "mimeType": "text/plain"
206
206
  }
207
207
  ]
208
208
  }
209
209
  }
210
210
  ```
211
211
 
212
+ The `html_doc` resource uses the custom `name`, `uri`, `description`, and
213
+ `mimeType` from the route configuration shown earlier. The `css` resource sets
214
+ only `"mcp": { "type": "resource" }`, so the defaults apply: the name comes from
215
+ the `operationId`, the URI defaults to `mcp://resources/{name}`, the description
216
+ falls back to the operation's `description`, and the MIME type defaults to
217
+ `text/plain`.
218
+
212
219
  ### Read a Resource
213
220
 
214
- Use the MCP `resources/read` method to read a resource by its URI:
221
+ Use the MCP `resources/read` method to read a resource by its URI. Resources
222
+ configured without a custom `uri` use the default `mcp://resources/{name}`
223
+ format:
215
224
 
216
225
  ```bash
217
226
  curl https://my-gateway.zuplo.dev/mcp \
@@ -222,7 +231,7 @@ curl https://my-gateway.zuplo.dev/mcp \
222
231
  "id": "0",
223
232
  "method": "resources/read",
224
233
  "params": {
225
- "uri": "mcp://resources/html"
234
+ "uri": "mcp://resources/css"
226
235
  }
227
236
  }'
228
237
  ```
@@ -236,9 +245,9 @@ Response:
236
245
  "result": {
237
246
  "contents": [
238
247
  {
239
- "uri": "mcp://resources/html",
248
+ "uri": "mcp://resources/css",
240
249
  "mimeType": "text/plain",
241
- "text": "<div id=\"my-ai-applet\"></div>"
250
+ "text": "div { color: blue; }"
242
251
  }
243
252
  ]
244
253
  }
@@ -247,6 +256,9 @@ Response:
247
256
 
248
257
  ### Read a Resource with Custom URI
249
258
 
259
+ Resources configured with a custom `uri`, like the `html_doc` resource above,
260
+ are read using that URI:
261
+
250
262
  ```bash
251
263
  curl https://my-gateway.zuplo.dev/mcp \
252
264
  -X POST \
@@ -256,7 +268,7 @@ curl https://my-gateway.zuplo.dev/mcp \
256
268
  "id": "0",
257
269
  "method": "resources/read",
258
270
  "params": {
259
- "uri": "ui://css"
271
+ "uri": "ui://html"
260
272
  }
261
273
  }'
262
274
  ```
@@ -3,8 +3,6 @@ title: MCP Server Testing
3
3
  sidebar_label: Testing
4
4
  ---
5
5
 
6
- # Testing Tools
7
-
8
6
  ## Using MCP Inspector
9
7
 
10
8
  The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is ideal
@@ -20,7 +20,7 @@ can generate one of these on the `Shared access tokens` tab.
20
20
  Note, you should minimize the permissions - and select only the `Create`
21
21
  permission. Choose a sensible start and expiration time for your token. Note, we
22
22
  don't recommend restricting IP addresses because Zuplo runs at the edge in over
23
- 200 data-centers world-wide.
23
+ 300 data centers worldwide.
24
24
 
25
25
  ![Permissions](../../public/media/guides/archiving-requests-to-storage/Untitled_1.png)
26
26
 
@@ -20,7 +20,7 @@ can generate one of these on the `Shared access tokens` tab.
20
20
  Note, you should minimize the permissions - and select only the `Create`
21
21
  permission. Choose a sensible start and expiration time for your token. Note, we
22
22
  don't recommend restricting IP addresses because Zuplo runs at the edge in over
23
- 200 data-centers world-wide.
23
+ 300 data centers worldwide.
24
24
 
25
25
  ![Create permission](../../public/media/guides/archiving-requests-to-storage/Untitled_1.png)
26
26
 
@@ -29,7 +29,7 @@ export default async function (
29
29
  // If the blocked IP addresses are set, then the incoming IP
30
30
  // can't be in that list
31
31
  if (options.blockedIpAddresses) {
32
- const blocked = ipRangeCheck(ip, options.allowedIpAddresses);
32
+ const blocked = ipRangeCheck(ip, options.blockedIpAddresses);
33
33
  if (blocked) {
34
34
  return HttpProblems.unauthorized(request, context);
35
35
  }
@@ -116,24 +116,6 @@ return HttpProblems.unauthorized(
116
116
  );
117
117
  ```
118
118
 
119
- ## HttpStatusCode Enum
120
-
121
- The `HttpStatusCode` enum provides numeric constants for all HTTP status codes:
122
-
123
- ```typescript
124
- import { HttpStatusCode } from "@zuplo/runtime";
125
-
126
- // Use in responses
127
- return new Response("Created", {
128
- status: HttpStatusCode.Created, // 201
129
- });
130
-
131
- // Use in comparisons
132
- if (response.status === HttpStatusCode.NotFound) {
133
- // Handle 404
134
- }
135
- ```
136
-
137
119
  ## See Also
138
120
 
139
121
  - [ProblemResponseFormatter](./problem-response-formatter.mdx)