stitchkit 0.36.1 → 0.38.0

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 (92) hide show
  1. package/README.md +4 -2
  2. package/dist/browser/client-url.d.ts +11 -0
  3. package/dist/browser/client-url.d.ts.map +1 -0
  4. package/dist/browser/client.d.ts +16 -4
  5. package/dist/browser/client.d.ts.map +1 -1
  6. package/dist/browser/http.d.ts +7 -3
  7. package/dist/browser/http.d.ts.map +1 -1
  8. package/dist/cli.js +3 -3
  9. package/dist/contract/define.d.ts +102 -2
  10. package/dist/contract/define.d.ts.map +1 -1
  11. package/dist/contract/errors.d.ts.map +1 -1
  12. package/dist/contract/index.d.ts +1 -1
  13. package/dist/contract/index.d.ts.map +1 -1
  14. package/dist/contract/index.js +1 -1
  15. package/dist/{index-bx49hskg.js → index-bgdd42pt.js} +1 -1
  16. package/dist/{index-0ed3bx43.js → index-frfyw9fa.js} +2 -2
  17. package/dist/{index-82gncajj.js → index-n5t4gnfz.js} +557 -337
  18. package/dist/{index-dvrn81q4.js → index-p3kwf73n.js} +165 -55
  19. package/dist/{index-pmftwk2a.js → index-x4wbc8sz.js} +54 -0
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +147 -114
  23. package/dist/node.d.ts +1 -1
  24. package/dist/node.d.ts.map +1 -1
  25. package/dist/node.js +7 -3
  26. package/dist/observability/index.js +3 -3
  27. package/dist/server/bun.d.ts +32 -0
  28. package/dist/server/bun.d.ts.map +1 -0
  29. package/dist/server/context.d.ts +1 -1
  30. package/dist/server/context.d.ts.map +1 -1
  31. package/dist/server/create.d.ts +2 -3
  32. package/dist/server/create.d.ts.map +1 -1
  33. package/dist/server/implement.d.ts.map +1 -1
  34. package/dist/server/index.d.ts +3 -2
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +41 -7
  37. package/dist/server/logger.d.ts +5 -5
  38. package/dist/server/logger.d.ts.map +1 -1
  39. package/dist/server/logging.d.ts +4 -1
  40. package/dist/server/logging.d.ts.map +1 -1
  41. package/dist/server/middleware/auth.d.ts +2 -2
  42. package/dist/server/middleware/auth.d.ts.map +1 -1
  43. package/dist/server/openapi.d.ts.map +1 -1
  44. package/dist/server/request-body.d.ts +4 -0
  45. package/dist/server/request-body.d.ts.map +1 -0
  46. package/dist/server/response-metadata.d.ts +9 -0
  47. package/dist/server/response-metadata.d.ts.map +1 -0
  48. package/dist/server/router.d.ts +3 -3
  49. package/dist/server/router.d.ts.map +1 -1
  50. package/dist/server/socket-io-config.d.ts +20 -0
  51. package/dist/server/socket-io-config.d.ts.map +1 -0
  52. package/dist/server/socket-io-node.d.ts +14 -0
  53. package/dist/server/socket-io-node.d.ts.map +1 -0
  54. package/dist/server/socket-io.d.ts +5 -32
  55. package/dist/server/socket-io.d.ts.map +1 -1
  56. package/dist/server/types.d.ts +63 -50
  57. package/dist/server/types.d.ts.map +1 -1
  58. package/dist/tools/agent.d.ts.map +1 -1
  59. package/dist/tools/execute.d.ts +46 -16
  60. package/dist/tools/execute.d.ts.map +1 -1
  61. package/dist/tools/flatten-join.d.ts +8 -0
  62. package/dist/tools/flatten-join.d.ts.map +1 -0
  63. package/dist/tools/flatten.d.ts +7 -44
  64. package/dist/tools/flatten.d.ts.map +1 -1
  65. package/dist/tools/json-schema.d.ts +1 -1
  66. package/dist/tools/json-schema.d.ts.map +1 -1
  67. package/dist/tools/manifest.d.ts.map +1 -1
  68. package/dist/tools/mcp-handler.d.ts +7 -13
  69. package/dist/tools/mcp-handler.d.ts.map +1 -1
  70. package/dist/tools/mcp.d.ts +61 -24
  71. package/dist/tools/mcp.d.ts.map +1 -1
  72. package/dist/tools/mount.d.ts +9 -6
  73. package/dist/tools/mount.d.ts.map +1 -1
  74. package/dist/tools/names.d.ts +1 -1
  75. package/dist/tools/native-mcp.d.ts +57 -0
  76. package/dist/tools/native-mcp.d.ts.map +1 -0
  77. package/dist/tools/portable-formats.d.ts +15 -0
  78. package/dist/tools/portable-formats.d.ts.map +1 -0
  79. package/dist/tools/presentation.d.ts +15 -0
  80. package/dist/tools/presentation.d.ts.map +1 -0
  81. package/dist/tools/remote.d.ts.map +1 -1
  82. package/dist/tools/schema.d.ts +3 -8
  83. package/dist/tools/schema.d.ts.map +1 -1
  84. package/dist/tools/tool-logger.d.ts.map +1 -1
  85. package/dist/tools/view-file.d.ts +5 -3
  86. package/dist/tools/view-file.d.ts.map +1 -1
  87. package/dist/tools.d.ts +7 -4
  88. package/dist/tools.d.ts.map +1 -1
  89. package/dist/tools.js +481 -205
  90. package/llms-full.txt +536 -87
  91. package/package.json +14 -11
  92. /package/dist/{index-17rdjw68.js → index-mzx0an0s.js} +0 -0
package/llms-full.txt CHANGED
@@ -230,7 +230,7 @@ export const users = defineContract({ prefix: 'users' }, {
230
230
  | Field | Required | Purpose |
231
231
  |-------|----------|---------|
232
232
  | `method` | yes | `GET` · `POST` · `PUT` · `PATCH` · `DELETE` |
233
- | `path` | yes | route path under the contract `prefix`; `:name` marks a path param |
233
+ | `path` | yes | route path under the contract `prefix`; `:name` marks a path param and a terminal `/*` captures the remaining path |
234
234
  | `desc` | yes | human description — also the MCP / agent tool description |
235
235
  | `params` | no | Zod schema for **path params** (`:id`, …) |
236
236
  | `input` | no | Zod schema for the **request body** (or query, for GET/DELETE) |
@@ -239,10 +239,13 @@ export const users = defineContract({ prefix: 'users' }, {
239
239
  | `expose` | no | which transports carry this endpoint — see [below](#transports) |
240
240
  | `toolName` | no | explicit MCP / agent tool name (default: a verb-aware derivation, see below — not a literal `prefix_key`) |
241
241
  | `multipart` | no | field name of a file upload — see [below](#file-uploads) |
242
+ | `maxJsonBodyBytes` | no | per-route JSON body ceiling; overrides the server default |
242
243
  | `timeout` | no | per-endpoint client timeout in ms, for slow endpoints |
243
244
  | `idempotent` | no | safe to call twice with the same input (like `PUT`/`DELETE`); a retrying transport reads it — see [Realtime](./realtime.md#bring-your-own-transport) |
244
245
  | `meta` | no | opaque app metadata — read in hooks / on tool mounts, never in OpenAPI ([below](#endpoint-metadata-meta)) |
245
246
  | `rawResponse` | no | the handler returns the `Response` itself — a download, a file, an SSE stream. HTTP-only, never a tool, no `output`. See [Raw-response endpoints](./server.md#raw-response-endpoints) |
247
+ | `rawBody` | no | retain original JSON text for a signed HTTP webhook. See [Signed JSON webhooks](./server.md#signed-json-webhooks) |
248
+ | `responseMeta` | no | make a typed-data endpoint HTTP-only, optionally declare its success status and expose `ctx.response.headers` — [Typed JSON response metadata](./server.md#typed-json-response-metadata) |
246
249
  | `contentType` | no | documented response media type of a `rawResponse` endpoint (OpenAPI only) |
247
250
 
248
251
  ## `params` vs `input` vs `output`
@@ -251,7 +254,10 @@ The three schemas are distinct on purpose:
251
254
 
252
255
  - **`params`** — values in the URL path. `path: '/:id'` ⇒
253
256
  `params: z.object({ id: z.string() })`. The client takes them from the call
254
- argument and substitutes them into the URL.
257
+ argument and substitutes them into the URL. A terminal wildcard is the
258
+ quoted `'*'` field: `path: '/:slug/*'` with
259
+ `params: z.object({ slug: z.string(), '*': z.string() })` matches both
260
+ `/foo/page` and `/foo/a/b`; the handler receives `'page'` or `'a/b'`.
255
261
  - **`input`** — the request payload. For `POST` / `PUT` / `PATCH` it is the JSON
256
262
  body; for `GET` / `DELETE` it is the query string. The handler reads it as
257
263
  `ctx.input`.
@@ -265,6 +271,9 @@ the body.
265
271
  ```ts
266
272
  // path: '/:id', params: { id }, input: { text }
267
273
  await api.update({ id: '1', text: 'new' }) // PUT /users/1 body: { text: 'new' }
274
+
275
+ // path: '/:slug/*', params: { slug, '*': remainder }
276
+ await api.app({ slug: 'foo', '*': 'a/b' }) // GET /apps/foo/a/b
268
277
  ```
269
278
 
270
279
  ### Input vs. output types
@@ -315,10 +324,12 @@ tools. Narrow it with `expose`:
315
324
  - `expose: ['MCP', 'AGENT']` — a tool only; no HTTP route.
316
325
  - omit `expose` — all transports.
317
326
 
318
- Tool transports (`MCP`, `AGENT`) skip two kinds of endpoint automatically:
327
+ Tool transports (`MCP`, `AGENT`) skip three kinds of endpoint automatically:
319
328
  `multipart` (a file upload is not a tool call) and
320
329
  [`rawResponse`](./server.md#raw-response-endpoints) (its answer is bytes, which
321
- a tool result cannot carry — it would serialize to `{}`).
330
+ a tool result cannot carry — it would serialize to `{}`), plus
331
+ [`responseMeta`](./server.md#typed-json-response-metadata) (outbound HTTP
332
+ headers have no meaning on a tool call).
322
333
 
323
334
  ## `toolName`
324
335
 
@@ -617,6 +628,7 @@ server. See [Testing & deployment](./testing-and-deployment.md).
617
628
  | `scopePrefixes` | `scope → path prefix` map — mount `services` by `service.scope` (see below) |
618
629
  | `rawRoutes` | non-contract routes (see below) |
619
630
  | `maxUploadBytes` | default multipart upload cap (bytes); per-route `EndpointDef.maxUploadBytes` overrides |
631
+ | `maxJsonBodyBytes` | optional JSON body cap (bytes); per-route value overrides; unset preserves existing behaviour |
620
632
  | `port` / `hostname` | listen address — port defaults to `3000` |
621
633
  | `cors` | CORS policy — `{ origin, credentials, methods, headers, exposeHeaders }` |
622
634
  | `hooks` | lifecycle hooks (see below) |
@@ -682,10 +694,18 @@ is not involved.
682
694
  way to see what `enrich` and the request context actually put on the record;
683
695
  changing `NODE_ENV` is not needed, and neither is deploying.
684
696
 
685
- Three more things about `enrich`: it runs at close, when the request body is
686
- already consumed; framework fields (`traceId`, `status`, `path`, …) always win a
687
- key collision; and a throw in `skip` or `enrich` is swallowed — neither can fail
688
- a request.
697
+ Three more things about `enrich`:
698
+
699
+ - It runs at close, when the request body is already consumed.
700
+ - The framework owns `traceId`, `method`, `path`, `status`, `durationMs`,
701
+ `errorCode` and `ip` in both structured sinks; the built-in JSON line also
702
+ owns `ts`, `level` and `msg`. Its value wins a collision, and a discarded key
703
+ warns once per handler rather than disappearing silently.
704
+ - `errorCode` has one outcome-aware exception: enrichment may supply it for a
705
+ `4xx`/`5xx` response when the framework derived no code, such as an error
706
+ `Response` returned by a raw route. It cannot add one to a `2xx`/`3xx` or
707
+ replace a framework-derived code.
708
+ - A throw in `skip` or `enrich` is swallowed; neither can fail a request.
689
709
 
690
710
  With an observability context active, the structured line also carries `userId`,
691
711
  `serviceName`, `action` and `dimensions` for free. See
@@ -753,6 +773,17 @@ prefix param in the schema, use a non-strict `z.object` (extra keys are dropped
753
773
  from `ctx.params`, but `ctx.tenantId` still works), or read the param off the
754
774
  context root.
755
775
 
776
+ **Trailing wildcard.** A contract path may end in `/*`. `/app/:slug/*` matches
777
+ both `/app/foo` and nested paths such as `/app/foo/a/b`; the collected params are
778
+ `{ slug: 'foo', '*': '' }` and `{ slug: 'foo', '*': 'a/b' }` respectively. Put
779
+ the quoted `'*'` field in the endpoint's `params` schema to keep it in typed
780
+ `ctx.params`. Each captured segment is URL-decoded before the remainder is
781
+ joined, so encoded spaces and reserved characters reach the handler as their
782
+ semantic values while `/` remains the segment boundary. Static and named-param
783
+ routes are matched before a catch-all, so a
784
+ more specific endpoint wins regardless of declaration order. The same matcher
785
+ drives `405 Allow` resolution.
786
+
756
787
  ### Scope-driven mounting (`scopePrefixes`)
757
788
 
758
789
  With several scopes, hand-partitioning services into `groups` duplicates the
@@ -785,7 +816,7 @@ createServer({
785
816
  hooks: {
786
817
  onRequest(req) { /* logging, global rate limit — may return a Response to short-circuit */ },
787
818
  beforeHandle(ctx, endpoint) { /* auth, scope checks — throw to reject */ },
788
- afterHandle(ctx, result, ep) { /* transform the result, set cache headers */ },
819
+ afterHandle(ctx, result, ep) { /* transform the result data */ },
789
820
  onError(ctx, error, ep) { /* custom error response — return a Response */ },
790
821
  },
791
822
  })
@@ -804,6 +835,80 @@ createServer({
804
835
  Hooks see `RuntimeContext` (loose types); handlers see `HandlerContext` (typed).
805
836
  That split is deliberate — see [ADR 0003](../decisions/0003-two-context-types.md).
806
837
 
838
+ ## Signed JSON webhooks
839
+
840
+ A provider signs the original JSON text, not `JSON.stringify(ctx.input)`.
841
+ Declare `rawBody: true` to retain the same decoded text the router reads while
842
+ keeping normal Zod validation:
843
+
844
+ ```ts
845
+ const webhooks = defineContract(
846
+ { prefix: 'webhooks' },
847
+ {
848
+ receive: {
849
+ method: 'POST', path: '/provider', desc: 'Receive a signed event',
850
+ rawBody: true,
851
+ maxJsonBodyBytes: 256 * 1024,
852
+ input: ProviderEventSchema,
853
+ output: z.object({ accepted: z.boolean() }),
854
+ },
855
+ },
856
+ )
857
+
858
+ receive: async (ctx) => {
859
+ const signature = ctx.req.headers.get('x-signature')
860
+ await verifyWebhookHmac(ctx.rawBody, signature) // guaranteed string
861
+ return { accepted: true }
862
+ }
863
+ ```
864
+
865
+ This endpoint is HTTP-only and cannot be multipart or exposed as a tool. The
866
+ router sets `ctx.rawBody` before JSON/Zod validation, so `onError` can inspect it
867
+ after malformed JSON or a schema failure. Endpoints without `rawBody: true` do
868
+ not retain the text. `maxJsonBodyBytes` may also be set once on `createServer` /
869
+ `createHandler`; a route value wins. Both limits are opt-in and abort an
870
+ oversized stream before it is fully buffered. → ADR 0051
871
+
872
+ ## Typed JSON response metadata
873
+
874
+ A JSON endpoint that must attach dynamic HTTP headers while preserving typed
875
+ output declares `responseMeta`. The handler still returns ordinary data:
876
+
877
+ ```ts
878
+ const auth = defineContract({ prefix: 'auth' }, {
879
+ complete: {
880
+ method: 'POST', path: '/complete', desc: 'Complete authentication',
881
+ input: CompleteAuthSchema,
882
+ output: AuthUserSchema,
883
+ responseMeta: { status: 200 },
884
+ },
885
+ })
886
+
887
+ complete: async ({ input, response }) => {
888
+ const result = await authenticate(input.token)
889
+ response.headers.append('Set-Cookie', session.set(result.sessionId))
890
+ response.headers.append('Set-Cookie', preferences.set(result.preferencesId))
891
+ return result.user
892
+ }
893
+ ```
894
+
895
+ `ctx.response.headers` is a fresh Web Fetch `Headers` bag per request. `append`
896
+ preserves repeated `Set-Cookie` values on Bun and Node. The endpoint is
897
+ HTTP-only, but its typed client method still resolves to `AuthUser` — not
898
+ `Response` — and the final data still passes group/global `afterHandle` and the
899
+ declared `output` schema exactly once.
900
+
901
+ `responseMeta.status` is static contract metadata and OpenAPI publishes the same
902
+ 2xx code. Without it, data keeps status `200` and no-data keeps `204`. Bodyless
903
+ `204`/`205` cannot be combined with `output`. Redirects, streams, files and
904
+ handler-owned status/body logic remain [`rawResponse: true`](#raw-response-endpoints).
905
+
906
+ Collected headers are merged only after the complete success pipeline. A
907
+ handler, hook or output-validation failure discards them. `Content-Type`,
908
+ `Content-Length`, `x-request-id` and every `Access-Control-*` header remain
909
+ framework-owned; trying to set one fails loudly with the endpoint identity.
910
+ → [ADR 0052](../decisions/0052-typed-json-response-metadata.md)
911
+
807
912
  ## Raw-response endpoints
808
913
 
809
914
  An endpoint that answers with **bytes rather than data** — a PDF download, a
@@ -846,6 +951,10 @@ const name = res.headers.get('Content-Disposition')
846
951
  const blob = await res.blob()
847
952
  ```
848
953
 
954
+ When the browser should navigate or assign the endpoint directly to `src`, use
955
+ [`createUrlBuilder`](./client.md#contract-url-builders). Raw-response GET methods
956
+ are included, while mutation and multipart methods are intentionally absent.
957
+
849
958
  Cross-origin, remember that those headers are readable only because CORS exposes
850
959
  them — see [`cors.exposeHeaders`](#serving-files--range-requests).
851
960
 
@@ -853,8 +962,14 @@ them — see [`cors.exposeHeaders`](#serving-files--range-requests).
853
962
  code. A raw-response *endpoint* stays in the contract: only its response is
854
963
  raw — it is still routed, gated, typed and documented like every other endpoint.
855
964
  A raw *route* is outside the contract entirely — no schemas, no auth gate, no
856
- client — which is what you want for an OAuth redirect or a webhook, and what you
857
- do not want for a download.
965
+ client — which is what you want for an OAuth redirect or a non-JSON webhook. A
966
+ signed JSON webhook can stay validated through
967
+ [`rawBody: true`](#signed-json-webhooks).
968
+
969
+ If an endpoint returns typed JSON and only needs an additional status/header,
970
+ use [`responseMeta`](#typed-json-response-metadata), not `rawResponse`: the raw
971
+ variant deliberately transfers response ownership and changes the client result
972
+ to `Response`.
858
973
 
859
974
  ⚠️ **Delete the old raw route when you move an endpoint into the contract.** Raw
860
975
  routes are matched **first**, so a leftover one keeps serving the bytes and the
@@ -1089,6 +1204,14 @@ createServer({ services: [users, orders], rawRoutes: [openApiRoute('/openapi.jso
1089
1204
 
1090
1205
  Only HTTP-exposed methods appear (an MCP/agent-only tool is skipped).
1091
1206
 
1207
+ OpenAPI 3.1 has no standard multi-segment path parameter. For a contract path
1208
+ ending in `/*`, Stitchkit keeps the literal runtime path, omits `*` from the
1209
+ standard `in: path` parameter list, and emits
1210
+ `x-stitchkit-trailing-wildcard` on the operation with its parameter name,
1211
+ schema and semantics. A generic OpenAPI client therefore cannot invent
1212
+ catch-all expansion; use Stitchkit's typed client or teach the generator that
1213
+ extension.
1214
+
1092
1215
  ### Curating the spec — `includeMethod`
1093
1216
 
1094
1217
  To publish a **subset** — a public spec that advertises only some methods
@@ -1148,6 +1271,10 @@ import { createHttpClient } from 'stitchkit'
1148
1271
  const http = createHttpClient({ baseUrl: '/api' })
1149
1272
  ```
1150
1273
 
1274
+ The returned `ConfiguredHttpClient` keeps that `baseUrl` as a readonly public
1275
+ field. Besides executing requests, it can therefore seed contract URL builders
1276
+ without repeating transport configuration.
1277
+
1151
1278
  ### `HttpClientConfig`
1152
1279
 
1153
1280
  | Field | Default | Purpose |
@@ -1193,6 +1320,8 @@ Each call takes one argument object. The client routes each field by the
1193
1320
  contract:
1194
1321
 
1195
1322
  - a **path param** (`:id`) is substituted into the URL,
1323
+ - a terminal wildcard (`/*`) consumes the `'*'` field and preserves its path
1324
+ segments (`{ '*': 'a/b' }` → `/a/b`, not `/%2Fa%2Fb` or a query field),
1196
1325
  - for `GET` / `DELETE`, the remaining fields become the **query string**
1197
1326
  (arrays become repeated keys),
1198
1327
  - for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
@@ -1212,7 +1341,53 @@ await api.posts.create({ title: 'Hi' })
1212
1341
  ```
1213
1342
 
1214
1343
  `createClients` builds one typed client per contract from a registry — list the
1215
- contracts once, get the whole API typed.
1344
+ contracts once, get the whole API typed. It accepts the same optional scoped
1345
+ config as `createClient`, so a whole registry can share one resource prefix:
1346
+
1347
+ ```ts
1348
+ const tenantApi = createClients({ users, posts }, http, {
1349
+ stripPrefixKeys: ['tenantId'],
1350
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
1351
+ })
1352
+ ```
1353
+
1354
+ Every method now requires `tenantId`, and the callback sees it as a `string`.
1355
+ The batch form delegates to the same single-contract client runtime, including
1356
+ HTTP exposure filtering, multipart, raw responses and output validation.
1357
+
1358
+ ## Contract URL builders
1359
+
1360
+ Browser-native consumers such as `<img src>`, downloads and navigation need a
1361
+ URL, not a fetched response. `createUrlBuilder` derives those URLs from the same
1362
+ contract path planner used by both typed-client transports:
1363
+
1364
+ ```ts
1365
+ import { createUrlBuilder, createUrlBuilders } from 'stitchkit'
1366
+
1367
+ const mediaUrls = createUrlBuilder(media, http, {
1368
+ stripPrefixKeys: ['tenantId'],
1369
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
1370
+ })
1371
+
1372
+ const src = mediaUrls.file({
1373
+ tenantId: 't_123',
1374
+ fileId: 'f_456',
1375
+ thumbnail: true,
1376
+ })
1377
+
1378
+ const urls = createUrlBuilders({ media, exports }, http)
1379
+ ```
1380
+
1381
+ Only HTTP-exposed, non-multipart `GET` endpoints appear on a URL builder. Raw
1382
+ response GET endpoints are included, so downloads and streams stay
1383
+ contract-driven. Path and scoped-prefix keys are consumed by the path; remaining
1384
+ GET input becomes the query string, including repeated keys for arrays.
1385
+
1386
+ Building a URL is synchronous and performs no request, auth event, header
1387
+ resolution or output validation. A `ConfiguredHttpClient` created by
1388
+ `createHttpClient` supplies its base URL; custom transports can pass an explicit
1389
+ `{ baseUrl: 'https://api.example.com' }` instead. Relative bases produce relative
1390
+ URLs.
1216
1391
 
1217
1392
  ## `ApiError`
1218
1393
 
@@ -1283,7 +1458,7 @@ segment to every URL — the client half of a multi-tenant API
1283
1458
  interface ContractClientConfig {
1284
1459
  /** Prepended to every request URL. A function is called per request with the
1285
1460
  * call's argument object, so the prefix can depend on the arguments. */
1286
- pathPrefix?: string | ((args: Record<string, unknown>) => string)
1461
+ pathPrefix?: string | ((args: { [K in ConsumedKey]: string }) => string)
1287
1462
  /** Argument keys consumed by `pathPrefix` — stripped from the query/body so
1288
1463
  * they are not also sent there (the endpoint's own path `:params` are
1289
1464
  * stripped automatically; list any *extra* keys here). */
@@ -1295,8 +1470,8 @@ A per-tenant client — `tenantId` goes into the URL, not the body:
1295
1470
 
1296
1471
  ```ts
1297
1472
  const widgets = createClient(widgetsContract, http, {
1298
- pathPrefix: (args) => `tenants/${args.tenantId}/`,
1299
1473
  stripPrefixKeys: ['tenantId'],
1474
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}/`,
1300
1475
  })
1301
1476
 
1302
1477
  widgets.list({ tenantId: 't_123' }) // GET /tenants/t_123/widgets
@@ -1432,9 +1607,8 @@ and after, compare.
1432
1607
  ## MCP — `createMcpHandler`
1433
1608
 
1434
1609
  `createMcpHandler` builds a complete Streamable-HTTP MCP server as a single
1435
- `Request → Response` handler. It owns the whole MCP lifecycle the SSE event
1436
- store, per-session transports, the server instances — so your app never imports
1437
- `@modelcontextprotocol/sdk` itself.
1610
+ `Request → Response` handler. It owns the SDK server and transport lifecycle, so
1611
+ your app never imports `@modelcontextprotocol/sdk` itself.
1438
1612
 
1439
1613
  ```ts
1440
1614
  import { createMcpHandler } from 'stitchkit/tools'
@@ -1465,14 +1639,34 @@ createServer({
1465
1639
  | `context` | `(auth) => {…}` — values merged into every tool handler's `ctx` |
1466
1640
  | `lifecycle` | `beforeHandle` / `afterHandle` — the tool-side auth gate (see below) |
1467
1641
  | `hooks` | tool-call observability hooks — `afterToolCall` fires on every result |
1468
- | `onIncompatibleSchema` | `'throw'` (default) · `'skip'` · `'warn'` see below |
1642
+ | `extend` | extra advertised arguments resolved into handler context |
1643
+ | `schemaValidation` | compatibility policy, typed-property guard and portable-format guard |
1469
1644
  | `logger` | a `StitchLogger` for the `'warn'` policy |
1470
- | `nativeTools` | `(server, auth) => …` — register non-contract tools directly on the `McpServer`; receives the resolved identity, but is **not** a scope gate (`lifecycle` does not run for native tools) |
1645
+ | `nativeTools` | `({ registerTool, rawServer }, auth) => …` — protected native registration plus an explicit raw SDK escape hatch |
1646
+ | `resources` | MCP Apps `ui://` resources mounted on every server |
1471
1647
  | `instructions` | a short host-facing usage hint, surfaced to MCP tool-search |
1648
+ | `coerceJsonArgs` | coerce JSON-stringified object/array arguments (default `true`) |
1649
+ | `flattenUnionInput` | advertise discriminated unions as one object (default `false`) |
1650
+ | `errorHint` | add a project-owned hint to failed tool results |
1651
+ | `onOutputStrip` | observe output keys removed by contract validation |
1652
+ | `protectedResource` | RFC 9728 metadata used by HTTP `401` responses |
1653
+ | `sessionMode` | `'stateless'` (default) or explicit `'stateful'` session/SSE continuity |
1472
1654
 
1473
1655
  `services`, `context` and `nativeTools` all receive the resolved identity, so a
1474
1656
  tenant can be shown only its own tools and every handler can read `ctx.tenantId`.
1475
1657
 
1658
+ ### Stateless by default; stateful only when required
1659
+
1660
+ The default `sessionMode: 'stateless'` creates a fresh SDK server, transport,
1661
+ resolved auth/context and runner for each HTTP request. Static contract schemas
1662
+ are still prepared once when the handler is constructed. There is no session
1663
+ map, event store, sweep timer or `Mcp-Session-Id`, so process replacement and
1664
+ load balancing cannot strand a client on an in-memory session.
1665
+
1666
+ Opt into `sessionMode: 'stateful'` only when the client needs server-initiated
1667
+ messages, cross-request progress or resumable SSE. That mode issues a server
1668
+ session id and retains the bounded session/event stores until idle expiry.
1669
+
1476
1670
  ### Guarding tools — `lifecycle`
1477
1671
 
1478
1672
  A tool call runs the same handler an HTTP request would. `lifecycle` makes it
@@ -1490,15 +1684,20 @@ Without it, a tool call bypasses the HTTP `beforeHandle` — the contract's
1490
1684
  `buildMcpServer` take `lifecycle` too.
1491
1685
 
1492
1686
  The observability `hooks` are symmetric with the HTTP side too: `beforeToolCall`
1493
- and `afterToolCall` receive the resolved **`MethodDef`** as their last argument
1687
+ and `afterToolCall` receive the resolved **`MethodDef`** as `endpoint`
1494
1688
  the tool-side twin of `afterHandle(ctx, result, endpoint)`. Read
1495
1689
  `endpoint.serviceName` / `.key` / `.meta` directly for an audit row; you do not
1496
1690
  need to rebuild a `toolName → identity` map:
1497
1691
 
1498
1692
  ```ts
1499
1693
  hooks: {
1500
- afterToolCall: (toolName, args, result, ms, ctx, endpoint) => {
1501
- audit({ service: endpoint.serviceName, action: endpoint.key, ok: result.ok, ms })
1694
+ afterToolCall: ({ result, durationMs, endpoint }) => {
1695
+ audit({
1696
+ service: endpoint.serviceName,
1697
+ action: endpoint.key,
1698
+ ok: result.ok,
1699
+ ms: durationMs,
1700
+ })
1502
1701
  },
1503
1702
  }
1504
1703
  ```
@@ -1509,10 +1708,10 @@ hooks: {
1509
1708
  > has no identity and **fails closed**. See
1510
1709
  > [Auth on the tool surface](./auth-and-errors.md#auth-on-the-tool-surface--resolvefromcontext).
1511
1710
 
1512
- ### Incompatible schemas `onIncompatibleSchema`
1711
+ ### MCP schema validation profile
1513
1712
 
1514
1713
  A contract schema that JSON Schema cannot represent (a `z.date()`, a `z.map()`)
1515
- cannot become a tool. `onIncompatibleSchema` decides what happens:
1714
+ cannot become a tool. `schemaValidation.policy` decides what happens:
1516
1715
 
1517
1716
  - `'throw'` (default) — fail the build, listing every offending tool. A static
1518
1717
  `services` array is checked when `createMcpHandler` is constructed, so a bad
@@ -1521,9 +1720,16 @@ cannot become a tool. `onIncompatibleSchema` decides what happens:
1521
1720
  - `'warn'` — log through `logger` and drop the tool.
1522
1721
  - `'skip'` — drop the tool silently.
1523
1722
 
1524
- `validateMcpSchemas(services)` runs the same check on its own — useful in a
1723
+ `validateMcpSchemas({ services })` runs the same check on its own — useful in a
1525
1724
  startup assertion or a test.
1526
1725
 
1726
+ For a static `services` array, collection, schema conversion and every enabled
1727
+ validation guard run once when the handler is created. Each HTTP request or
1728
+ stateful session still receives a fresh `McpServer`, runner, context and native
1729
+ registration over that immutable prepared surface. A `services(auth)` factory
1730
+ is deliberately prepared after resolving each identity because its tool set may
1731
+ change by tenant.
1732
+
1527
1733
  #### Is every property actually usable by a model?
1528
1734
 
1529
1735
  A tool schema is the only instruction a model gets about the shape of its
@@ -1533,17 +1739,30 @@ mount succeeds, the tool is advertised, and the model then guesses, retrying the
1533
1739
  same wrong guess because the error does not say what the right one would be.
1534
1740
 
1535
1741
  ```ts
1536
- validateMcpSchemas(services, 'throw', logger, {
1537
- flattenUnionInput: true, // mirror the live mount
1742
+ validateMcpSchemas({
1743
+ services,
1744
+ policy: 'throw',
1745
+ logger,
1746
+ extend,
1747
+ flattenUnionInput: true,
1538
1748
  requireTypedProperties: true,
1539
1749
  allowUntyped: ['docs_create.payload'], // deliberately free-form
1750
+ requirePortableFormats: true,
1751
+ allowFormats: [],
1540
1752
  })
1541
1753
  ```
1542
1754
 
1543
1755
  Off by default, because a contract may legitimately declare `z.unknown()`.
1544
1756
  `allowUntyped` takes dotted `tool.property` paths — an entry there is a decision,
1545
- anything else is a finding. Pass the **same** `extend` / `flattenUnionInput` the
1546
- mount uses, or the check vets a different document than the one advertised.
1757
+ anything else is a finding. On `createMcpHandler`, put the policy under
1758
+ `schemaValidation`; the handler supplies its real `extend` and
1759
+ `flattenUnionInput`, so the check cannot vet a different document from the one
1760
+ advertised.
1761
+
1762
+ `requirePortableFormats` rejects custom `format` values common MCP/AJV clients
1763
+ do not know, such as the `cuid2` emitted by `z.cuid2()`. Use a portable
1764
+ schema/pattern, or list it in `allowFormats` only when every client supports it.
1765
+ stitchkit never removes or rewrites the keyword.
1547
1766
 
1548
1767
  `findUntypedProperties(jsonSchema)` is the same walk, exported on its own if you
1549
1768
  want to assert on a schema you built elsewhere.
@@ -1566,25 +1785,19 @@ A field that is a number in every variant is advertised as a number, not as a
1566
1785
  bare description. Only genuinely different kinds (a string in one variant, a
1567
1786
  number in another) fall back to unconstrained. → ADR 0044
1568
1787
 
1569
- It is **deep**: unions are flattened at every depth top level, object fields,
1570
- array items, and through `optional` / `nullable` / `default` / intersection
1571
- wrappers so no `oneOf` survives anywhere (e.g. a `content.parts[]` that is an
1572
- array of a discriminated union). Schemas a transform cannot safely rebuild
1573
- (refined / piped / lazy / plain non-discriminated unions) are left as-is.
1574
-
1575
- The flattened form is **lossy but never destructive**. Lossy: per-variant
1576
- strictness and object-level refinements are not advertised the original schemas
1577
- enforce them in `executeToolMethod`. Not destructive: every object keeps its own
1578
- **key policy** (`.strict()` stays strict, `.loose()` / `.catchall()` still keep
1579
- extra keys), because the advertised schema is not advertised-only — the MCP and
1580
- AI SDKs parse the caller's arguments *with it* and hand the handler the parsed
1581
- result. An object advertised without its policy would silently delete keys the
1582
- contract would have rejected. → [ADR 0034](../decisions/0034-advertised-schema-key-policy.md).
1583
-
1584
- A consequence worth knowing when you read logs: a `.strict()` violation is caught
1585
- by the SDK **before** the tool callback runs, so it comes back as an MCP
1586
- `InvalidParams` protocol error rather than a stitchkit `VALIDATION_ERROR`
1587
- envelope, and `beforeToolCall` / `afterToolCall` do not fire for it.
1788
+ It is **deep** because the projection walks the generated JSON Schema document,
1789
+ including objects, arrays, tuples and schema-definition nodes. Structurally
1790
+ identifiable discriminated object unions are flattened wherever they occur.
1791
+ Plain unions and unions hidden behind unresolved external references remain
1792
+ unions because Stitchkit cannot soundly invent a discriminator.
1793
+
1794
+ The flattened form is **lossy but never executable**. Per-variant refinements
1795
+ and incompatible constraints are widened in the presentation document; the
1796
+ original Zod contract enforces them exactly once inside `executeToolMethod`.
1797
+ MCP and AI adapters forward the raw argument object unchanged, so defaults,
1798
+ coercions and transforms cannot run before Stitchkit. Strict violations return
1799
+ the normal `VALIDATION_ERROR` tool envelope and fire `beforeToolCall` /
1800
+ `afterToolCall`. [ADR 0050](../decisions/0050-presentation-schema-is-not-a-parser.md).
1588
1801
 
1589
1802
  ## `mountMcp`
1590
1803
 
@@ -1820,10 +2033,60 @@ so one handler serves both surfaces. Pair `extend` with `lifecycle` (your
1820
2033
 
1821
2034
  ## Native multimodal tools
1822
2035
 
1823
- Contract tools return JSON. For a tool that returns an image or other
1824
- multimodal content, register a native tool. `mountViewFile` is the built-in one
1825
- it lets a model fetch and view a file (with SSRF and path-traversal
1826
- defenses):
2036
+ Contract tools return JSON. A native tool can return MCP text/image/audio/
2037
+ resource content directly while still using stitchkit's input/output validation,
2038
+ isolated per-call context, lifecycle/RBAC and tool hooks:
2039
+
2040
+ ```ts
2041
+ import { createMcpHandler } from 'stitchkit/tools'
2042
+ import { z } from 'zod'
2043
+
2044
+ const handleMcp = createMcpHandler({
2045
+ serverInfo: { name: 'my-app', version: '1.0.0' },
2046
+ auth,
2047
+ services: [service],
2048
+ lifecycle: { beforeHandle: authHook },
2049
+ hooks: audit.toolCall,
2050
+ nativeTools: ({ registerTool }, identity) => {
2051
+ registerTool({
2052
+ name: 'render_preview',
2053
+ description: 'Render and inspect a preview',
2054
+ identity: {
2055
+ serviceName: 'mediaTools',
2056
+ action: 'renderPreview',
2057
+ scope: 'admin',
2058
+ method: 'POST',
2059
+ },
2060
+ input: z.object({ prompt: z.string() }),
2061
+ output: z.object({ assetId: z.string() }),
2062
+ handler: async ({ input, traceId }) => ({
2063
+ content: [
2064
+ { type: 'image', data: await renderBase64(input.prompt), mimeType: 'image/png' },
2065
+ { type: 'text', text: `trace: ${traceId}` },
2066
+ ],
2067
+ structuredContent: { assetId: await saveAsset(identity) },
2068
+ }),
2069
+ })
2070
+ },
2071
+ })
2072
+ ```
2073
+
2074
+ The configured identity becomes the hook/lifecycle `OperationIdentity` and the
2075
+ tool `RequestEvent` (`serviceName`, `action`, `httpMethod`). A native operation
2076
+ has no HTTP route, so no fake `path` is added to that identity. If `output` is
2077
+ declared, stitchkit parses `structuredContent` with it after `afterHandle`; all
2078
+ other MCP fields and content blocks are preserved.
2079
+
2080
+ The MCP registration uses an identity carrier: the SDK advertises the compiled
2081
+ JSON Schema but forwards the raw object into Stitchkit. Input failures therefore
2082
+ use the same validation, lifecycle and hook path as contract tools.
2083
+
2084
+ ### Explicit raw SDK registration
2085
+
2086
+ `rawServer` is deliberately named as an escape hatch. A tool registered there
2087
+ does **not** receive stitchkit schema policy, lifecycle, per-call context or
2088
+ hooks. The built-in `mountViewFile` helper remains raw for callers that choose
2089
+ that boundary; it fetches media with SSRF and path-traversal defenses:
1827
2090
 
1828
2091
  ```ts
1829
2092
  import { createMcpHandler, mountViewFile } from 'stitchkit/tools'
@@ -1832,10 +2095,15 @@ const handleMcp = createMcpHandler({
1832
2095
  serverInfo: { name: 'my-app', version: '1.0.0' },
1833
2096
  auth,
1834
2097
  services: [service],
1835
- nativeTools: (server) => mountViewFile(server, { baseDir: '/srv/uploads' }),
2098
+ nativeTools: ({ rawServer }) =>
2099
+ mountViewFile(rawServer, { baseDir: '/srv/uploads' }),
1836
2100
  })
1837
2101
  ```
1838
2102
 
2103
+ Use raw registration only when opting out is intentional. For a protected
2104
+ `view_file`, define it through `registerTool` and call the exported
2105
+ `resolveMedia` core from its handler.
2106
+
1839
2107
  ## Logging tool calls — `createToolLogger`
1840
2108
 
1841
2109
  Every tool mount fires an `afterToolCall` hook. `createToolLogger` is a ready
@@ -2605,6 +2873,20 @@ session.clear() // → a Set-Cookie value that expires it
2605
2873
  config is not repeated at every call site. `parseCookies(header)` and
2606
2874
  `serializeCookie(name, value, opts)` are the lower-level primitives.
2607
2875
 
2876
+ To set a cookie from a schema-validated JSON endpoint without losing its typed
2877
+ client result, declare [`responseMeta`](./server.md#typed-json-response-metadata)
2878
+ and append the generated value:
2879
+
2880
+ ```ts
2881
+ complete: async ({ response }) => {
2882
+ response.headers.append('Set-Cookie', session.set('abc123'))
2883
+ return authenticatedUser
2884
+ }
2885
+ ```
2886
+
2887
+ Append once per cookie; Stitchkit preserves separate `Set-Cookie` fields through
2888
+ both Bun and Node. Cookie/session policy remains application logic.
2889
+
2608
2890
  ## The error model
2609
2891
 
2610
2892
  One error type, `AppError`, is shared by the contract, the server and the
@@ -2946,7 +3228,16 @@ either way, so your sink reads it as a column instead of re-parsing the path:
2946
3228
  > same hooks object is assignable to `ToolLifecycle`, so this recipe is the one
2947
3229
  > people apply to tools — and since ADR 0045 each tool call runs in its own
2948
3230
  > context. Read the value off the tool row (`event.toolName != null`); both rows
2949
- > carry the same `traceId`.
3231
+ > carry the same `traceId`, so recovering the enclosing request is one filter:
3232
+ >
3233
+ > ```bash
3234
+ > jq -s '[.[] | select(.dimensions.botId == "B7") | .traceId] as $t
3235
+ > | .[] | select(.traceId | IN($t[]))' audit.jsonl
3236
+ > ```
3237
+ >
3238
+ > Worth checking your **runbooks** as well as your code when this lands: a
3239
+ > dimension filter written against request rows keeps parsing and starts
3240
+ > returning nothing.
2950
3241
 
2951
3242
  ```ts
2952
3243
  // beforeHandle (success) and onError (failure) alike:
@@ -3076,11 +3367,11 @@ metric, a custom log line, anything that is not a full audit row.
3076
3367
  |---------|------|-------|
3077
3368
  | HTTP | `LifecycleHooks.afterHandle` / `onError` | after each HTTP request |
3078
3369
  | MCP & agent tools | `ToolCallHooks.afterToolCall` | after each tool call |
3079
- | MCP & agent tools | `ToolCallHooks.onToolError` | when a tool handler throws |
3370
+ | MCP & agent tools | `ToolCallHooks.onToolError` | when executable parsing, extension resolution, lifecycle or the handler throws |
3080
3371
 
3081
3372
  `afterHandle(ctx, result, endpoint)` runs after a handler returns;
3082
- `onError(ctx, error, endpoint)` when one throws. `afterToolCall(toolName, args,
3083
- result, durationMs, context, endpoint, error)` runs after every tool call —
3373
+ `onError(ctx, error, endpoint)` when one throws. `afterToolCall(options)` runs
3374
+ after every tool call —
3084
3375
  success and error alike — carrying the tool name, the arguments, the result, the
3085
3376
  duration, the call context, the endpoint identity, and (only when the call failed
3086
3377
  by throwing) the raw thrown value.
@@ -3089,7 +3380,7 @@ by throwing) the raw thrown value.
3089
3380
  createMcpHandler({
3090
3381
  serverInfo, auth, services,
3091
3382
  hooks: {
3092
- afterToolCall: (toolName, _args, result, durationMs, context) => {
3383
+ afterToolCall: ({ toolName, result, durationMs }) => {
3093
3384
  metrics.timing(`tool.${toolName}`, durationMs, { ok: String(result.ok) })
3094
3385
  },
3095
3386
  },
@@ -3115,7 +3406,7 @@ normalisation, stack and `cause` intact:
3115
3406
  createMcpHandler({
3116
3407
  serverInfo, auth, services,
3117
3408
  hooks: {
3118
- onToolError: (toolName, error, _context, endpoint) => {
3409
+ onToolError: ({ toolName, error, endpoint }) => {
3119
3410
  reportToolFailure({
3120
3411
  tool: toolName,
3121
3412
  action: endpoint.key,
@@ -3127,13 +3418,12 @@ createMcpHandler({
3127
3418
  })
3128
3419
  ```
3129
3420
 
3130
- It fires for a throw from `beforeHandle`, the handler or `afterHandle` — the
3131
- span where information is destroyed and runs **before** `afterToolCall`, so
3132
- whatever it records is in place when the audit hook reads it. It deliberately
3133
- does not fire for an argument-validation failure, an output-schema mismatch or a
3134
- `beforeToolCall` rejection: each of those is already described in full by the
3135
- `ToolResult`, and a second path to the same information only invites
3136
- double-logging.
3421
+ It fires for a throw from executable params/input/`ToolExtend` parsing,
3422
+ extension resolution, `beforeHandle`, the handler or `afterHandle` every span
3423
+ where information is destroyed and runs **before** `afterToolCall`. It does
3424
+ not fire for an ordinary validation result, an output-schema mismatch or a
3425
+ `beforeToolCall` rejection: each is already described in full by its
3426
+ `ToolResult`.
3137
3427
 
3138
3428
  It observes, it does not handle: the tool envelope is always the framework's, a
3139
3429
  returned value is ignored, and a throw from the hook itself is reported to
@@ -3155,11 +3445,11 @@ either — the call is simply not where that helper belongs.)
3155
3445
  ### One row that names the cause
3156
3446
 
3157
3447
  You do not need to correlate the two hooks yourself. `afterToolCall` receives the
3158
- same raw value as a **seventh parameter**, so one hook can build one record:
3448
+ same raw value as the named `error` field, so one hook can build one record:
3159
3449
 
3160
3450
  ```ts
3161
3451
  hooks: {
3162
- afterToolCall: (toolName, args, result, durationMs, context, endpoint, error) => {
3452
+ afterToolCall: ({ toolName, result, durationMs, endpoint, error }) => {
3163
3453
  void writeRow({ toolName, result, durationMs, cause: error, endpoint })
3164
3454
  },
3165
3455
  }
@@ -3167,8 +3457,8 @@ hooks: {
3167
3457
 
3168
3458
  `error` is present only when the call failed by **throwing** — a
3169
3459
  validation failure or a `beforeToolCall` rejection leaves it `undefined`, because
3170
- neither ever had a raw value to lose. The parameter is additive: a six-parameter
3171
- hook written before it keeps compiling and keeps firing.
3460
+ neither ever had a raw value to lose. Consumers destructure only the fields they
3461
+ use; future optional fields do not change callback arity.
3172
3462
 
3173
3463
  `createAuditHook` uses it already. Where the envelope was scrubbed to
3174
3464
  `INTERNAL_SERVER_ERROR`, the row's `errorMessage` becomes the real message
@@ -3186,11 +3476,13 @@ sink of your own (a tracker, a stack, an alert), `afterToolCall` for the record.
3186
3476
  `createAuditHook` already keys every event by **service** and **action**
3187
3477
  (`event.serviceName` / `event.action`, → ADR 0029) — reach for the raw hook only
3188
3478
  when you also need the handler **output**, which the audit wrapper never sees. For
3189
- that, read the endpoint identity off the `MethodDef` the hook receives —
3190
- `endpoint.serviceName` (the contract prefix) and `endpoint.key` (the endpoint key,
3479
+ that, read the endpoint identity off the `OperationIdentity` the tool hook
3480
+ receives — `endpoint.serviceName` and `endpoint.key` (the endpoint key or native
3481
+ action,
3191
3482
  e.g. `updatePartial`). They are stable and always present (→ ADR 0022); the action
3192
3483
  is not in the URL and `toolName` is absent on HTTP-only endpoints, so this is the
3193
- only reliable pair. `afterHandle` also gives you the handler `result` so it is
3484
+ only reliable pair. Contract operations are full `MethodDef` values; native MCP
3485
+ operations intentionally have no HTTP `path`. `afterHandle` also gives you the handler `result` — so it is
3194
3486
  the home for a rich mutation audit that records output:
3195
3487
 
3196
3488
  ```ts
@@ -3324,6 +3616,10 @@ serveNode({
3324
3616
 
3325
3617
  Notes for a Node host:
3326
3618
 
3619
+ - `stitchkit/node` declarations do not require `@types/bun`. Runtime-neutral
3620
+ raw routes use `RawRoute<TServer = unknown>`; supply a host server generic
3621
+ only when an embedding adapter passes one to `createHandler`.
3622
+
3327
3623
  - Add **`@types/bun`** as a dev dependency — it is an optional peer that types the
3328
3624
  shared `stitchkit/server` surface (without it `tsc` reports a missing `Bun`
3329
3625
  namespace).
@@ -3456,7 +3752,7 @@ keeps `tenantId` out of the body/query
3456
3752
 
3457
3753
  ```ts
3458
3754
  const widgetsApi = createClient(widgets, http, {
3459
- pathPrefix: (args) => `tenants/${args.tenantId}/`,
3755
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}/`,
3460
3756
  stripPrefixKeys: ['tenantId'],
3461
3757
  })
3462
3758
 
@@ -3561,6 +3857,130 @@ current one *up to* your target, and apply each snippet.
3561
3857
  (`STITCH_ERROR_STATUS`, `serveFile`, `scopePrefixes`, `afterToolCall`'s
3562
3858
  `MethodDef`, `maxUploadBytes`) are available to adopt, not required.
3563
3859
 
3860
+ ## The 0.37 migration
3861
+
3862
+ Tool presentation is no longer an executable Zod parser. Replace the removed
3863
+ flatten helpers with the JSON Schema compiler, and choose the explicit
3864
+ `MountableTool` surface when using the advanced collection API:
3865
+
3866
+ ```ts
3867
+ // before
3868
+ const flat = flattenUnionsDeep(zodSchema)
3869
+ mountable.schema
3870
+
3871
+ // after
3872
+ const flat = flattenToolJsonSchema(
3873
+ z.toJSONSchema(zodSchema, { target: 'draft-07', io: 'input' }),
3874
+ )
3875
+ mountable.presentationSchema // model/MCP/manifest JSON Schema
3876
+ mountable.argumentSchema // executable CLI argument adapter only
3877
+ ```
3878
+
3879
+ Contract and native handlers keep their original Zod schemas; MCP and agent SDK
3880
+ adapters now forward raw arguments so defaults, coercions, refinements and
3881
+ transforms execute exactly once inside Stitchkit. There are no compatibility
3882
+ exports for `flattenDiscriminatedUnion`, `flattenUnionsDeep` or
3883
+ `MountableTool.schema`. → ADR 0050
3884
+
3885
+ Tool-call hooks now take one options object. Migrate all three callbacks; there
3886
+ are no positional overloads:
3887
+
3888
+ ```ts
3889
+ // before
3890
+ beforeToolCall: (toolName, args, context, endpoint) => {}
3891
+ afterToolCall: (toolName, args, result, durationMs, context, endpoint, error) => {}
3892
+ onToolError: (toolName, error, context, endpoint) => {}
3893
+
3894
+ // after
3895
+ beforeToolCall: ({ toolName, args, context, endpoint }) => {}
3896
+ afterToolCall: ({ toolName, args, result, durationMs, context, endpoint, error }) => {}
3897
+ onToolError: ({ toolName, error, context, endpoint }) => {}
3898
+ ```
3899
+
3900
+ ### MCP schema validation
3901
+
3902
+ The standalone validator now takes one object and live MCP configs carry the
3903
+ same rules under `schemaValidation`. Migrate every positional call and every
3904
+ `onIncompatibleSchema` field; there is no old-shape overload:
3905
+
3906
+ ```ts
3907
+ // before
3908
+ validateMcpSchemas(services, 'throw', logger, { requireTypedProperties: true })
3909
+ createMcpHandler({ services, onIncompatibleSchema: 'throw' })
3910
+
3911
+ // after
3912
+ validateMcpSchemas({ services, policy: 'throw', logger, requireTypedProperties: true })
3913
+ createMcpHandler({ services, schemaValidation: { policy: 'throw' } })
3914
+ ```
3915
+
3916
+ Put `extend` and `flattenUnionInput` beside `services`, not inside
3917
+ `schemaValidation`. The handler applies the profile to the exact prepared schema
3918
+ it advertises. Add `requirePortableFormats: true` when every custom JSON Schema
3919
+ format must be rejected before a client sees it.
3920
+
3921
+ Native MCP registration also changed shape in 0.37. Move protected tools to the
3922
+ framework registrar; keep an SDK-raw tool only by naming the opt-out:
3923
+
3924
+ ```ts
3925
+ // before
3926
+ nativeTools: (server, auth) => server.registerTool(name, config, handler)
3927
+
3928
+ // after — lifecycle, hooks and schema policy apply
3929
+ nativeTools: ({ registerTool }, auth) => registerTool({
3930
+ name, description, identity, input, output, handler,
3931
+ })
3932
+
3933
+ // after — intentionally raw
3934
+ nativeTools: ({ rawServer }, auth) => rawServer.registerTool(name, config, handler)
3935
+ ```
3936
+
3937
+ If a tool hook annotated `endpoint` as `MethodDef`, remove that annotation or
3938
+ use `OperationIdentity`: native operations have service/action/scope/method but
3939
+ no HTTP path.
3940
+
3941
+ ### MCP HTTP sessions
3942
+
3943
+ Finally, replace the MCP HTTP session boolean. Omission changed meaning:
3944
+
3945
+ ```ts
3946
+ // before → after
3947
+ stateless: true // → sessionMode: 'stateless'
3948
+ stateless: false // → sessionMode: 'stateful'
3949
+
3950
+ // before: omission was stateful
3951
+ // after: omission is stateless
3952
+ ```
3953
+
3954
+ If the client relies on `Mcp-Session-Id`, server push, progress across requests
3955
+ or resumable SSE, set `sessionMode: 'stateful'` explicitly. Synchronous tool
3956
+ servers should omit it and use the new restart-safe default.
3957
+
3958
+ ### Node-facing server types
3959
+
3960
+ `stitchkit/server` remains Bun-concrete: an explicitly annotated `RawRoute`
3961
+ still receives `BunServer`. `stitchkit/node` no longer drags Bun declarations
3962
+ into a Node project. Its raw routes default the host server to `unknown`, and
3963
+ its Socket.IO handle exposes only Node capabilities:
3964
+
3965
+ ```ts
3966
+ // before — Node entry still leaked Bun-only fields and ambient types
3967
+ const route: RawRoute = { handler: (_req, ctx) => ctx.server?.upgrade(...) }
3968
+ const socket = await createSocketIOServer(config)
3969
+ socket.websocket
3970
+
3971
+ // after — name a custom embedding host only when one exists
3972
+ const route: RawRoute<MyHostServer> = {
3973
+ handler: (_req, ctx) => useHost(ctx.server),
3974
+ }
3975
+ const socket = await createSocketIOServer(config)
3976
+ socket.io
3977
+ socket.attach(nodeHttpServer)
3978
+ ```
3979
+
3980
+ Inline Bun routes passed to `createServer` continue to infer `BunServer`; no
3981
+ annotation is needed. Node consumers can remove `@types/bun` unless another
3982
+ dependency independently requires it.
3983
+
3564
3984
  ## Your handlers may be returning more than the contract declares
3565
3985
 
3566
3986
  stitchkit validates every handler's return value against the endpoint's `output`
@@ -3631,12 +4051,17 @@ The browser-and-server entrypoint. Re-exports everything from
3631
4051
  | Export | Kind | Summary |
3632
4052
  |--------|------|---------|
3633
4053
  | `createClient` | function | build a typed client from a contract — [guide](../guide/client.md#createclient) |
3634
- | `createClients` | function | build one typed client per contract from a registry |
4054
+ | `createClients` | function | build one exact typed client per contract from a registry; accepts the same scoped config and transports as `createClient` |
4055
+ | `createUrlBuilder` | function | build synchronous browser-native URLs for one contract's HTTP GET endpoints — [guide](../guide/client.md#contract-url-builders) |
4056
+ | `createUrlBuilders` | function | build one exact URL builder per contract in a registry |
4057
+ | `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
3635
4058
  | `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
3636
4059
  | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
4060
+ | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
3637
4061
  | `createHttpClient` | function | the Ky-based HTTP transport — [guide](../guide/client.md#createhttpclient) |
3638
4062
  | `ApiError` | class | a non-2xx response, with `code` / `status` / `details` / `hint` |
3639
4063
  | `HttpClient` | _type_ | the transport interface `createClient` builds on |
4064
+ | `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
3640
4065
  | `HttpClientConfig` | _type_ | config for `createHttpClient` |
3641
4066
  | `RequestOptions` | _type_ | per-call options — params, timeout, response type |
3642
4067
  | `HeaderProvider` | _type_ | static or per-request headers |
@@ -3688,6 +4113,10 @@ from the root `stitchkit`.
3688
4113
  | `ContractDef` | _type_ | a defined contract |
3689
4114
  | `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` and `meta` (a default every endpoint shallow-merges over) |
3690
4115
  | `EndpointDef` | _type_ | a single endpoint definition |
4116
+ | `EndpointResponseMeta` | _type_ | static success metadata declared by an HTTP-only typed-data endpoint |
4117
+ | `ResponseMetadata` | _type_ | per-request outbound collector exposed as `ctx.response` only for a `responseMeta` endpoint |
4118
+ | `HttpSuccessStatus` | _type_ | supported declared 2xx success statuses |
4119
+ | `BodyHttpSuccessStatus` | _type_ | supported 2xx statuses excluding bodyless 204/205 |
3691
4120
  | `HttpMethod` | _type_ | `GET \| POST \| PUT \| PATCH \| DELETE` |
3692
4121
  | `Transport` | _type_ | `HTTP \| MCP \| AGENT \| CLI` |
3693
4122
  | `TransportSource` | _type_ | `http \| mcp \| agent \| cli` — the value of `ctx.source` |
@@ -3698,6 +4127,9 @@ from the root `stitchkit`.
3698
4127
  | `TypedHttpClient` | _type_ | the typed client, HTTP endpoints only (`= ScopedHttpClient<C, unknown>`) |
3699
4128
  | `ScopedHttpClient` | _type_ | a client whose `stripPrefixKeys` become required args ([guide](../guide/multi-tenant.md)) |
3700
4129
  | `ScopedEndpointFn` | _type_ | one method's signature with the consumed keys folded in |
4130
+ | `TypedUrlBuilder` | _type_ | one contract's HTTP, non-multipart GET endpoints as synchronous URL functions |
4131
+ | `ScopedUrlBuilder` | _type_ | a URL builder whose scoped-prefix keys are required method arguments |
4132
+ | `ScopedUrlFn` | _type_ | one URL method's signature with scoped-prefix keys folded in |
3701
4133
  | `MultipartFile` | _type_ | a `multipart` file field — `Blob \| FileDescriptor` |
3702
4134
  | `FileDescriptor` | _type_ | a React Native / Expo file — `{ uri, name, type }` |
3703
4135
  | `EndpointToolAnnotations` | _type_ | MCP behavioural hints on an endpoint (`readOnlyHint` / `destructiveHint` / `title`) |
@@ -3762,23 +4194,24 @@ Also re-exports the error helpers from `stitchkit/contract`.
3762
4194
  | `zodIssues` | function | a `ZodError` → structured `{ path, code, message }[]` — the machine-readable sibling of `formatZodError` |
3763
4195
  | `ZodIssueSummary` | _type_ | one structured validation issue (`{ path, code, message }`) |
3764
4196
  | `parseBody` | function | parse + Zod-validate a JSON body → `data` or `null` (no throw) |
3765
- | `HandlerConfig` | _type_ | config for `createHandler` (runtime-agnostic) |
4197
+ | `HandlerConfig` | _type_ | config for `createHandler`, including optional `maxJsonBodyBytes`; bound to `BunServer` on this entrypoint |
3766
4198
  | `BunServerConfig` | _type_ | config for `createServer` (Bun) |
3767
4199
  | `ServiceDef` | _type_ | the result of `implement` |
3768
4200
  | `MethodDef` | _type_ | one resolved endpoint inside a service |
4201
+ | `OperationIdentity` | _type_ | path-free service/action/scope/method identity shared by contract and native tool operations |
3769
4202
  | `Handlers` | _type_ | the typed handler map `implement` expects |
3770
4203
  | `LifecycleHooks` | _type_ | `onRequest` / `beforeHandle` / `afterHandle` / `onError` |
3771
4204
  | `RouteGroup` | _type_ | a prefixed group of services with its own hooks |
3772
- | `RawRoute` | _type_ | a non-contract `Request → Response` route |
3773
- | `RawRouteContext` | _type_ | the routing context a raw handler receives |
4205
+ | `RawRoute` | _type_ | a non-contract `Request → Response` route with a concrete `BunServer` context |
4206
+ | `RawRouteContext` | _type_ | the Bun-bound routing context a raw handler receives |
3774
4207
  | `BunServer` | _type_ | the `Bun.serve` instance type |
3775
4208
  | `ServerPassthrough` | _type_ | extra `Bun.serve` options |
3776
4209
  | `StitchLogger` | _type_ | the custom-logger interface |
3777
4210
  | `LoggingConfig` | _type_ | the `logging` object — `logger` / `format` / `skip` / `enrich` |
3778
4211
  | `LogFormat` | _type_ | `'pretty'` or `'json'` — what the built-in formatter writes |
3779
4212
  | `LogOutcome` | _type_ | how a request finished, as `enrich` sees it |
3780
- | `FetchHandler` | _type_ | what `createHandler` returns |
3781
- | `FetchComposition` | _type_ | the `wrapFetch` seam shared by the servers |
4213
+ | `FetchHandler` | _type_ | what `createHandler` returns, bound to `BunServer` here |
4214
+ | `FetchComposition` | _type_ | the Bun-bound `wrapFetch` seam |
3782
4215
 
3783
4216
  ### Auth
3784
4217
 
@@ -3943,13 +4376,21 @@ Server-only. Turns contracts into MCP and AI-agent tools. Needs the
3943
4376
  | `createToolkit` | function | context-typed tool mounts — [guide](../guide/cli.md#typed-context) |
3944
4377
  | `mountViewFile` | function | a native multimodal "view file" MCP tool |
3945
4378
  | `resolveMedia` | function | resolve a media reference for a tool result |
3946
- | `validateMcpSchemas` | function | assert every tool schema is JSON Schema-compatible — [guide](../guide/mcp-and-agents.md#incompatible-schemas--onincompatibleschema) |
4379
+ | `validateMcpSchemas` | function | object-shaped assertion over the exact advertised schema surface compatibility, typed properties and portable formats ([guide](../guide/mcp-and-agents.md#mcp-schema-validation-profile)) |
3947
4380
  | `listToolNames` | function | every mounted tool name with its `(service, method)` identity — for name-baseline snapshots — [guide](../guide/mcp-and-agents.md#pinning-tool-names--listtoolnames) |
3948
4381
  | `McpHandlerConfig` | _type_ | config for `createMcpHandler` |
4382
+ | `McpSessionMode` | _type_ | `'stateless' \| 'stateful'`; HTTP defaults to request-isolated stateless mode |
3949
4383
  | `StdioMcpServerConfig` | _type_ | config for `createStdioMcpServer` |
3950
4384
  | `McpServerBuildConfig` | _type_ | shared config for `buildMcpServer` |
3951
4385
  | `ImplementRemoteOptions` | _type_ | options for `implementRemote` |
3952
4386
  | `McpMountConfig` | _type_ | config for `mountMcp` |
4387
+ | `McpSchemaValidationConfig` | _type_ | shared `{ policy, requireTypedProperties, allowUntyped, requirePortableFormats, allowFormats }` profile |
4388
+ | `ValidateMcpSchemasConfig` | _type_ | standalone validation profile plus `services`, `extend`, flattening and logger |
4389
+ | `NativeMcpRegistrar` | _type_ | protected `registerTool` plus explicit unprotected `rawServer` access |
4390
+ | `NativeMcpToolDefinition` | _type_ | native name, operation identity, Zod schemas and MCP-result handler |
4391
+ | `NativeMcpOperationIdentity` | _type_ | `{ serviceName, action, scope?, method, meta? }` for native lifecycle/audit |
4392
+ | `NativeMcpHandlerContext` | _type_ | runtime context with the definition's parsed native input |
4393
+ | `NativeMcpResult` | _type_ | MCP content result, with typed `structuredContent` when output is declared |
3953
4394
  | `AgentMountConfig` | _type_ | config for `mountAgent` |
3954
4395
  | `AgentContext` | _type_ | the context merged into agent tool handlers |
3955
4396
  | `CliConfig` | _type_ | config for `createCli` |
@@ -3958,7 +4399,11 @@ Server-only. Turns contracts into MCP and AI-agent tools. Needs the
3958
4399
  | `Toolkit` | _type_ | the context-pinned tool surface from `createToolkit` |
3959
4400
  | `ToolExtend` | _type_ | extra-args extension for `mountMcp` / `mountAgent` |
3960
4401
  | `ToolLifecycle` | _type_ | `beforeHandle` / `afterHandle` gate for tool calls — [guide](../guide/mcp-and-agents.md#guarding-tools--lifecycle) |
3961
- | `ToolCallHooks` | _type_ | `beforeToolCall` / `afterToolCall` / `onToolError` observability hooks the raw thrown value reaches both of the last two (`afterToolCall`'s 7th parameter), because normalisation scrubs an unexpected error to `INTERNAL_SERVER_ERROR` ([guide](../guide/observability.md#the-cause-behind-a-failed-tool-call)) |
4402
+ | `ToolOperation` | _type_ | executable path-free operation shape shared by contract and framework-native runners |
4403
+ | `ToolCallHooks` | _type_ | object-shaped `beforeToolCall` / `afterToolCall` / `onToolError` observability hooks; the raw thrown value reaches the last two as `error` ([guide](../guide/observability.md#the-cause-behind-a-failed-tool-call)) |
4404
+ | `BeforeToolCallOptions` | _type_ | `{ toolName, args, context, endpoint }` passed before execution |
4405
+ | `AfterToolCallOptions` | _type_ | completed call options plus `{ result, durationMs, error? }` |
4406
+ | `ToolErrorOptions` | _type_ | `{ toolName, error, context, endpoint }` for a thrown handler-path value |
3962
4407
  | `ErrorHintFn` | _type_ | `(toolName, errorCode) => string \| null` — a per-tool recovery hint, shared by every mount |
3963
4408
  | `ToolResult` | _type_ | the result of one tool call |
3964
4409
  | `ToolCallContext` | _type_ | the context every tool hook receives — `{ source }` plus whatever the mount's `context` added |
@@ -3967,6 +4412,9 @@ Server-only. Turns contracts into MCP and AI-agent tools. Needs the
3967
4412
  | `CollectToolsConfig` | _type_ | options for `collectTools` |
3968
4413
  | `findUntypedProperties` | function | every property in a JSON Schema with no `type`/`enum`/`$ref` — what a model is shown and cannot obey ([guide](../guide/mcp-and-agents.md)) |
3969
4414
  | `UntypedProperty` | _type_ | one such property — `{ path, description? }` |
4415
+ | `findNonPortableFormats` | function | deep finder for formats outside the portable MCP/AJV baseline |
4416
+ | `NonPortableFormat` | _type_ | one `{ path, format }` portability finding |
4417
+ | `PORTABLE_JSON_SCHEMA_FORMATS` | constant | portable-format baseline used by MCP validation |
3970
4418
  | `ToolNameEntry` | _type_ | one `listToolNames` row — `{ name, service, method, transports }` |
3971
4419
  | `IncompatibleSchemaPolicy` | _type_ | `'throw' \| 'skip' \| 'warn'` |
3972
4420
  | `McpMediaContent` | _type_ | a multimodal MCP content item |
@@ -4033,9 +4481,9 @@ Advanced building blocks — the shared machinery the mounts are built on.
4033
4481
  | `TransportSummary` | _type_ | the result of `summarizeTransports` |
4034
4482
  | `TransportCounts` | _type_ | per-transport counts (`{ HTTP, MCP, AGENT, CLI }`) |
4035
4483
  | `coerceJsonArgs` | function | coerce JSON-stringified array/object tool arguments |
4036
- | `flattenDiscriminatedUnion` | function | flatten one discriminated union into a single object schema |
4037
- | `flattenUnionsDeep` | function | flatten discriminated unions at every depth union shape only; each object keeps its own key policy (`.strict()` / `.loose()` / `.catchall()`) |
4038
- | `MountableTool` | _type_ | one contract method resolved for mounting |
4484
+ | `flattenToolJsonSchema` | function | project structurally identifiable discriminated unions in a JSON Schema document into conservative object joins; never executes validation |
4485
+ | `ToolPresentationSchema` | _type_ | immutable model-facing JSON Schema document shared by tool transports |
4486
+ | `MountableTool` | _type_ | one operation with separate executable CLI argument schema and model-facing presentation schema |
4039
4487
  | `ToolManifestEntry` | _type_ | one `buildToolManifest` row |
4040
4488
 
4041
4489
  ---
@@ -4051,11 +4499,12 @@ runtime-agnostic pieces of `stitchkit/server` and the error helpers.
4051
4499
  |--------|------|---------|
4052
4500
  | `serveNode` | function | build the router and start a Node HTTP server (via `srvx`) |
4053
4501
  | `createHandler` | function | the router as a bare `(req) => Response` (same as `/server`) |
4054
- | `createSocketIOServer` | function | the typed Socket.IO server (same as `/server`) |
4502
+ | `createSocketIOServer` | function | the typed Node Socket.IO server (`io` + `attach`; no Bun engine declarations) |
4055
4503
  | `implement` / `createImplement` | function | bind a contract to typed handlers (same as `/server`) |
4056
4504
  | `NodeServerConfig` | _type_ | config for `serveNode` |
4057
4505
  | `NodeServerHandle` | _type_ | the `serveNode` handle (`{ port, stop }`) |
4058
- | `HandlerConfig` / `ServiceDef` / `RawRoute` / `RawRouteContext` / `SocketIOServerConfig` / `SocketIOServerHandle` | _type_ | re-exported from `/server` |
4506
+ | `HandlerConfig` / `ServiceDef` / `RawRoute` / `RawRouteContext` | _type_ | runtime-neutral handler types; raw routes default their host server to `unknown` |
4507
+ | `SocketIOServerConfig` / `SocketIOServerHandle` | _type_ | shared config and the Node-only `{ io, attach }` handle |
4059
4508
  | `AppError` + `appError` / `badRequest` / `unauthorized` / `forbidden` / `notFound` / `conflict` / `rateLimited` | — | error helpers (same as `/contract`) |
4060
4509
 
4061
4510
  ---