stitchkit 0.118.0 → 0.119.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 (151) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/browser/http.d.ts.map +1 -1
  3. package/dist/contract/client-types.d.ts +6 -3
  4. package/dist/contract/client-types.d.ts.map +1 -1
  5. package/dist/contract/define-validation.d.ts.map +1 -1
  6. package/dist/contract/define.d.ts +18 -12
  7. package/dist/contract/define.d.ts.map +1 -1
  8. package/dist/contract/endpoint-capabilities.d.ts +28 -0
  9. package/dist/contract/endpoint-capabilities.d.ts.map +1 -0
  10. package/dist/contract/factory.d.ts +49 -19
  11. package/dist/contract/factory.d.ts.map +1 -1
  12. package/dist/contract/tool-view.d.ts +1 -1
  13. package/dist/contract/tool-view.d.ts.map +1 -1
  14. package/dist/contract/transports.d.ts +5 -0
  15. package/dist/contract/transports.d.ts.map +1 -0
  16. package/dist/entrypoints/agent-runtime/coding-tools.js +6 -5
  17. package/dist/entrypoints/agent-runtime/harness.js +6 -5
  18. package/dist/entrypoints/agent-runtime/sandbox.js +6 -5
  19. package/dist/entrypoints/agent-runtime/sqlite/bun.js +9 -8
  20. package/dist/entrypoints/agent-runtime/sqlite/node.js +9 -8
  21. package/dist/entrypoints/agent-runtime/testing.js +9 -8
  22. package/dist/entrypoints/agent-runtime.js +14 -13
  23. package/dist/entrypoints/application/diagnostic-journal.js +3 -3
  24. package/dist/entrypoints/application/directory-inbox.js +4 -4
  25. package/dist/entrypoints/application/opentelemetry.js +1 -1
  26. package/dist/entrypoints/application.js +5 -5
  27. package/dist/entrypoints/cli/publish.js +4 -4
  28. package/dist/entrypoints/cli.js +8 -7
  29. package/dist/entrypoints/contract.d.ts +1 -1
  30. package/dist/entrypoints/contract.d.ts.map +1 -1
  31. package/dist/entrypoints/contract.js +6 -4
  32. package/dist/entrypoints/files.js +3 -3
  33. package/dist/entrypoints/geo/contract.d.ts +7 -0
  34. package/dist/entrypoints/geo/contract.d.ts.map +1 -0
  35. package/dist/entrypoints/geo/contract.js +6 -0
  36. package/dist/entrypoints/geo.js +60 -23
  37. package/dist/entrypoints/http.js +2 -2
  38. package/dist/entrypoints/index.js +8 -6
  39. package/dist/entrypoints/live.js +1 -1
  40. package/dist/entrypoints/node.js +7 -6
  41. package/dist/entrypoints/primitives.js +1 -1
  42. package/dist/entrypoints/process/owner-loss.js +1 -1
  43. package/dist/entrypoints/process.js +3 -3
  44. package/dist/entrypoints/react.js +1 -1
  45. package/dist/entrypoints/remote.js +4 -3
  46. package/dist/entrypoints/server.d.ts +1 -1
  47. package/dist/entrypoints/server.d.ts.map +1 -1
  48. package/dist/entrypoints/server.js +42 -27
  49. package/dist/entrypoints/telegram.js +2 -2
  50. package/dist/entrypoints/testing.js +7 -6
  51. package/dist/entrypoints/tools/connections.js +3 -2
  52. package/dist/entrypoints/tools/contract.js +1 -1
  53. package/dist/entrypoints/tools/invoker.js +5 -4
  54. package/dist/entrypoints/tools/mcp.js +7 -6
  55. package/dist/entrypoints/tools.js +17 -15
  56. package/dist/entrypoints/tracking/contract.js +5 -3
  57. package/dist/entrypoints/tracking/performance.js +3 -3
  58. package/dist/entrypoints/tracking.d.ts +1 -1
  59. package/dist/entrypoints/tracking.d.ts.map +1 -1
  60. package/dist/entrypoints/tracking.js +63 -83
  61. package/dist/entrypoints/xlsx.js +1 -1
  62. package/dist/geo/index.d.ts +2 -1
  63. package/dist/geo/index.d.ts.map +1 -1
  64. package/dist/geo/record.d.ts +8 -0
  65. package/dist/geo/record.d.ts.map +1 -0
  66. package/dist/geo/resolver.d.ts +1 -3
  67. package/dist/geo/resolver.d.ts.map +1 -1
  68. package/dist/geo/schema.d.ts +44 -0
  69. package/dist/geo/schema.d.ts.map +1 -0
  70. package/dist/geo/types.d.ts +2 -13
  71. package/dist/geo/types.d.ts.map +1 -1
  72. package/dist/{index-n5sv4z0w.js → index-1k18qzqa.js} +13 -15
  73. package/dist/{index-tsngj2b5.js → index-2dfvv1jg.js} +206 -206
  74. package/dist/{index-d9hq291n.js → index-4jpykzvw.js} +70 -81
  75. package/dist/{index-ghtpfz4z.js → index-57gqaq8b.js} +2 -2
  76. package/dist/index-69t14s4a.js +39 -0
  77. package/dist/{index-6p3frkv5.js → index-6a69xzrx.js} +4 -4
  78. package/dist/{index-dky0dcmf.js → index-72r9k0av.js} +6 -2
  79. package/dist/{index-vv1sch8a.js → index-8vzh5wpv.js} +13 -8
  80. package/dist/{index-xx1j310k.js → index-8yxgcyt6.js} +171 -169
  81. package/dist/{index-p6apg0jg.js → index-9frf01t9.js} +16 -9
  82. package/dist/{index-q0th8q0z.js → index-9mjrnqxs.js} +1 -3
  83. package/dist/{index-ega3yz4c.js → index-aqwg2dm5.js} +1 -1
  84. package/dist/{index-073nw9qr.js → index-ba7are04.js} +524 -522
  85. package/dist/{index-095ya5hm.js → index-bbvc59gw.js} +13 -11
  86. package/dist/{index-txg1npmg.js → index-cyxp8rbf.js} +1 -1
  87. package/dist/index-d3xdyjt6.js +38 -0
  88. package/dist/{index-n8z3rvzw.js → index-f4njz9j0.js} +3 -5
  89. package/dist/{index-90p34rts.js → index-fcqj44mb.js} +3 -1
  90. package/dist/{index-86nbfysb.js → index-ff39f9dr.js} +3 -3
  91. package/dist/{index-75ns2ybj.js → index-fqd4yqwd.js} +1 -1
  92. package/dist/{index-nqxrm176.js → index-g444vfz2.js} +1 -1
  93. package/dist/{index-vwhrnp67.js → index-kz9734jc.js} +3 -3
  94. package/dist/{index-dr3t5n1v.js → index-nn9qvkww.js} +46 -46
  95. package/dist/{index-qezhmp51.js → index-nz06qw5a.js} +3 -1
  96. package/dist/{index-1bx1xv1k.js → index-pfvde2fz.js} +5 -2
  97. package/dist/{index-s80awwsw.js → index-qch1vcaa.js} +3 -1
  98. package/dist/{index-cycp3r3e.js → index-rxcgp4b9.js} +2 -2
  99. package/dist/{index-w7jq3fz9.js → index-s0qjs6ee.js} +1198 -1196
  100. package/dist/{index-p0rf7sq9.js → index-s20s2mad.js} +209 -209
  101. package/dist/{index-v6vb5qx8.js → index-s3yxw6kt.js} +1 -1
  102. package/dist/{index-cqmqyjax.js → index-sa99p98w.js} +1 -1
  103. package/dist/{index-0gxvns6s.js → index-sba3e6m4.js} +1 -0
  104. package/dist/{index-f24bdt13.js → index-v1swr26p.js} +3 -1
  105. package/dist/{index-rhzkndr5.js → index-x4p3kw1p.js} +533 -530
  106. package/dist/{index-hdjmdwsj.js → index-x8d9fdeg.js} +513 -507
  107. package/dist/{index-htxh46ej.js → index-ycqhyars.js} +2 -2
  108. package/dist/{index-9bwcthxw.js → index-yj6saztf.js} +31 -13
  109. package/dist/{index-tdpmfks8.js → index-ynrf908v.js} +1 -1
  110. package/dist/{index-f6m4c545.js → index-zrdcmg6j.js} +4 -6
  111. package/dist/process/bun-child.d.ts +3 -2
  112. package/dist/process/bun-child.d.ts.map +1 -1
  113. package/dist/server/contract-method.d.ts.map +1 -1
  114. package/dist/server/file.d.ts +12 -6
  115. package/dist/server/file.d.ts.map +1 -1
  116. package/dist/tools/internal/surface-projector.d.ts.map +1 -1
  117. package/dist/tools/operations/async-operation-canonical-types.d.ts +1 -1
  118. package/dist/tracking/attribution.d.ts +21 -25
  119. package/dist/tracking/attribution.d.ts.map +1 -1
  120. package/dist/tracking/client-visit.d.ts +5 -7
  121. package/dist/tracking/client-visit.d.ts.map +1 -1
  122. package/dist/tracking/client.d.ts +2 -2
  123. package/dist/tracking/client.d.ts.map +1 -1
  124. package/dist/tracking/contract.d.ts +1 -1
  125. package/dist/tracking/host.d.ts +0 -1
  126. package/dist/tracking/host.d.ts.map +1 -1
  127. package/dist/tracking/privacy.d.ts +6 -0
  128. package/dist/tracking/privacy.d.ts.map +1 -1
  129. package/dist/tracking/schemas.d.ts +2 -2
  130. package/dist/tracking/schemas.d.ts.map +1 -1
  131. package/llms/contract.txt +46 -24
  132. package/llms/geo-contract.txt +23 -0
  133. package/llms/geo.txt +40 -4
  134. package/llms/server.2.txt +19 -6
  135. package/llms/server.5.txt +2 -1
  136. package/llms/stitchkit.txt +4 -3
  137. package/llms/testing.txt +3 -3
  138. package/llms/tools.2.txt +2 -2
  139. package/llms/tools.3.txt +1 -1
  140. package/llms/tracking-contract.txt +1 -1
  141. package/llms/tracking.txt +27 -5
  142. package/llms/{upgrading-0.105.0-0.118.0.txt → upgrading-0.106.0-0.119.0.txt} +121 -410
  143. package/llms/upgrading-0.37.0-0.53.0.txt +969 -0
  144. package/llms/{upgrading-0.44.0-0.62.0.txt → upgrading-0.55.0-0.68.0.txt} +509 -528
  145. package/llms/{upgrading-0.63.0-0.83.0.txt → upgrading-0.69.0-0.95.0.txt} +506 -509
  146. package/llms/{upgrading-0.84.0-0.104.0.txt → upgrading-0.96.0-0.105.0.txt} +410 -506
  147. package/llms/upgrading.txt +5 -5
  148. package/llms-full.txt +271 -48
  149. package/llms.txt +11 -10
  150. package/package.json +23 -19
  151. package/llms/upgrading-0.37.0-0.44.0.txt +0 -444
@@ -0,0 +1,969 @@
1
+ # Upgrading 0.37.0 → 0.53.0 — stitchkit docs slice
2
+
3
+ > Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
4
+ > `llms.txt` in this package.
5
+
6
+ Contains: Guide: Upgrading, 0.37.0 → 0.53.0 (docs/guide/upgrading.md)
7
+
8
+ ==============================================================================
9
+ # Guide: Upgrading, 0.37.0 → 0.53.0 (docs/guide/upgrading.md)
10
+ ==============================================================================
11
+
12
+ ## Released migration: 0.53.0
13
+
14
+ ### Realtime `emit` returns `boolean` instead of `void`
15
+
16
+ Every **call site** compiles and behaves exactly as before — the return value
17
+ is new information (`true` = accepted by the transport, `false` = dropped
18
+ while the browser client was disconnected), not a behavior change. What breaks
19
+ is **implementing** the interfaces: a test mock or app-side adapter of
20
+ `SocketIOClient` / `RealtimeClient` / `ValidatedRealtimeSocket` /
21
+ `RealtimeServer` whose `emit` returns `void` no longer typechecks.
22
+
23
+ ```ts
24
+ // before — a void mock satisfied the interface
25
+ const mock: Pick<RealtimeClient<S, C>, 'emit'> = { emit: () => {} }
26
+ // after — report acceptance (true is what a live server-side emit reports)
27
+ const mock: Pick<RealtimeClient<S, C>, 'emit'> = { emit: () => true }
28
+ ```
29
+
30
+ While migrating, consider replacing hand-rolled `if (client.connected)` guards
31
+ with the new honest surface: check `client.emit(...) === false`, or observe
32
+ drops centrally with `onDroppedEmit`.
33
+
34
+ ## Released migration: 0.50.0
35
+
36
+ ### The factory's scope union now covers per-endpoint overrides
37
+
38
+ `createContractFactory<Scope>()` already required a typed `scope` on the
39
+ contract. It now holds a per-endpoint `scope` override to the same union. Nothing
40
+ to migrate unless an override is outside the union — which was always a bug: the
41
+ scope reached no auth rule, and `createAuthHook` threw
42
+ `[stitchkit] auth: no rule for scope "…"` on the first request to it.
43
+
44
+ ```ts
45
+ const { defineContract } = createContractFactory<'public' | 'user' | 'admin'>()
46
+
47
+ defineContract({ prefix: 'posts', scope: 'user' }, {
48
+ // before: compiled; failed at request time
49
+ // after: compile error naming the scope (TypeScript even suggests 'admin')
50
+ purge: { method: 'DELETE', path: '/all', desc: 'Purge', scope: 'admn', output },
51
+ })
52
+ ```
53
+
54
+ Fix the typo, or widen the factory union if the scope is real. Contracts built
55
+ with plain `defineContract` are unaffected.
56
+
57
+ ### A declared `defineErrors` message is now used
58
+
59
+ `ErrorDefinition` accepts `message`. Nothing to migrate unless a registry already
60
+ wrote that key: it type-checked before (excess-property checking does not fire
61
+ through a `const` generic) and was ignored, so the code itself went on the wire.
62
+
63
+ ```ts
64
+ const { errors } = defineErrors({ GONE: { status: 410, message: 'Long gone' } })
65
+ // before: errors.GONE().message === 'GONE'
66
+ // after: errors.GONE().message === 'Long gone'
67
+ ```
68
+
69
+ If a registry carried `message` as a note to the reader rather than as
70
+ user-facing text, either fix the text or drop the key. A code with no `details`
71
+ schema also shows that text to a model (its tool `details` is `{ message }`).
72
+
73
+ ### Optional: adopt `createScopedImplement`
74
+
75
+ If the app declares one superset handler context because different scopes inject
76
+ different fields, replace it with a scope map. This is additive — `createImplement`
77
+ still works.
78
+
79
+ ```ts
80
+ // before — one context for every scope; `ctx.userId` is typed even in a
81
+ // `public` handler, where the runtime never injects it
82
+ interface AppContext extends RuntimeContext { userId: string; isAdmin: boolean }
83
+ export const implement = createImplement<AppContext>()
84
+
85
+ // after — each handler typed by its endpoint's effective scope
86
+ export const implementFor = createScopedImplement<{
87
+ public: object
88
+ user: { userId: string }
89
+ admin: { userId: string; isAdmin: boolean }
90
+ }>()
91
+ ```
92
+
93
+ `'public'` must be a key (a contract with no `scope` is `'public'`). Write
94
+ endpoints inline in the contract literal: an endpoint hoisted into a variable
95
+ widens its `scope` to `string` and is reported as undeclared.
96
+
97
+ ## Released migration: 0.49.0
98
+
99
+ ### The server handle became managed
100
+
101
+ `createServer()` and `serveNode()` return a handle that owns admission, HTTP
102
+ drain, realtime closure and one deadline-bounded runtime stop:
103
+
104
+ ```ts
105
+ // before
106
+ server.stop()
107
+ await socket.io.close()
108
+ // after
109
+ await server.shutdown({ gracePeriodMs: 30_000 })
110
+ ```
111
+
112
+ The runtime-specific instance stays reachable at `.runtime`, so an escape hatch
113
+ you already rely on does not disappear — but code that closed transports itself,
114
+ in its own order, is now racing the handle. Delete the manual closes rather than
115
+ keeping both; the handle's result tells you what it drained and what it forced.
116
+
117
+ ### Socket.IO mounts through the whole handle
118
+
119
+ ```ts
120
+ // before
121
+ createServer({ websocket: socket.websocket, rawRoutes: [socket.route] })
122
+ // after
123
+ createServer({ socket })
124
+ ```
125
+
126
+ One owner for the route, the WebSocket attachment and the closure. For a raw Bun
127
+ lane, keep the composed `websocket` handler and pass `socket` beside it.
128
+
129
+ ### Bun native `routes` are gone
130
+
131
+ Native routes run before the Fetch handler, so they bypassed managed admission —
132
+ which means they also bypassed shutdown, logging and observability, and that is
133
+ why they had to go rather than be wired up:
134
+
135
+ ```ts
136
+ // before
137
+ createServer({ routes: { '/health': () => Response.json({ ok: true }) } })
138
+ // after
139
+ createServer({ rawRoutes: [{ method: 'GET', path: '/health', handler: () => Response.json({ ok: true }) }] })
140
+ ```
141
+
142
+ ### The handshake policy takes a Web `Request`
143
+
144
+ ```ts
145
+ // before
146
+ createSocketIOServer({ serverOptions: { allowRequest: (req, done) => done(null, allowed(req)) } })
147
+ // after
148
+ createSocketIOServer({ allowRequest: (request) => allowed(request) })
149
+ ```
150
+
151
+ The Node-shaped callback is gone, and the policy is now composed with shutdown
152
+ admission on both runtimes: a handshake arriving during drain is refused for
153
+ you.
154
+
155
+ ## Released migration: 0.48.0
156
+
157
+ ### Typed-client request options move to `.withOptions`
158
+
159
+ Generated endpoint methods reserve their ordinary call signature for contract
160
+ variables. This keeps them directly assignable to callback APIs whose runtime
161
+ supplies its own second context argument, including `react-query-kit` and
162
+ TanStack Query. Move imperative cancellation to the callable's explicit method:
163
+
164
+ ```ts
165
+ // before — endpoint with arguments
166
+ await api.create({ name: 'Max' }, { signal })
167
+
168
+ // after
169
+ await api.create.withOptions({ name: 'Max' }, { signal })
170
+
171
+ // before — endpoint without arguments
172
+ await api.health({ signal })
173
+
174
+ // after
175
+ await api.health.withOptions({ signal })
176
+ ```
177
+
178
+ Direct query and mutation composition remains unchanged:
179
+
180
+ ```ts
181
+ createMutation({ mutationFn: api.create })
182
+ createQuery({ queryKey: ['search'], fetcher: api.search })
183
+ ```
184
+
185
+ There is no positional-options alias. Ordinary generated methods ignore extra
186
+ runtime callback arguments; only `.withOptions` reads `ClientRequestOptions`.
187
+
188
+ ## Released migration: 0.47.0
189
+
190
+ ### HTTP auth moves to the pre-body `authorize` phase
191
+
192
+ Move the HTTP wiring of `createAuthHook` from `beforeHandle` to `authorize`.
193
+ This lets Stitchkit reject an unauthorized JSON or multipart request after path
194
+ parameter validation but before reading a body chunk. Keep application
195
+ preconditions that depend on validated input in `beforeHandle`.
196
+
197
+ ```ts
198
+ const auth = createAuthHook({ authenticate, authorize })
199
+
200
+ // before
201
+ createServer({ services, hooks: { beforeHandle: auth } })
202
+
203
+ // after
204
+ createServer({ services, hooks: { authorize: auth } })
205
+ ```
206
+
207
+ Tool transports already receive parsed input, so their wiring does not move:
208
+
209
+ ```ts
210
+ createMcpHandler({ services, lifecycle: { beforeHandle: auth } })
211
+ ```
212
+
213
+ If a custom HTTP authorization hook read `ctx.input`, `ctx.files` or raw body
214
+ state, split it: identity/scope checks belong in `authorize`; validated payload
215
+ preconditions belong in `beforeHandle` or the domain service.
216
+
217
+ ### Multipart uses a typed descriptor and `ctx.files`
218
+
219
+ Replace every string multipart declaration, top-level `maxUploadBytes` and
220
+ `ctx.file`. The descriptor is now the only source of request, per-file,
221
+ cardinality and declared media-type policy.
222
+
223
+ ```ts
224
+ // before
225
+ upload: {
226
+ method: 'POST',
227
+ path: '/',
228
+ multipart: 'file',
229
+ maxUploadBytes: 25 * 1024 * 1024,
230
+ }
231
+ upload: ({ file, input }) => store(file, input)
232
+
233
+ // after
234
+ upload: {
235
+ method: 'POST',
236
+ path: '/',
237
+ multipart: {
238
+ maxRequestBytes: 25 * 1024 * 1024,
239
+ files: {
240
+ file: {
241
+ maxBytes: 20 * 1024 * 1024,
242
+ contentTypes: ['image/*', 'application/pdf'],
243
+ },
244
+ },
245
+ },
246
+ }
247
+ upload: ({ files, input }) => store(files.file, input)
248
+ ```
249
+
250
+ Multiple files are repeated under one multipart field name and arrive in the
251
+ same order:
252
+
253
+ ```ts
254
+ files: {
255
+ attachments: { multiple: true, maxFiles: 8 },
256
+ }
257
+
258
+ await api.upload({ attachments: [firstFile, secondFile] })
259
+ // handler: files.attachments is File[]
260
+ ```
261
+
262
+ For direct-to-storage delivery, set `delivery: 'stream'` and implement the
263
+ endpoint with `defineMultipartStream`. A receiver must consume its Web Stream
264
+ and return `{ value, cleanup }`; the final handler sees only receiver values.
265
+ There is no deprecated overload or buffered compatibility path under the old
266
+ contract shape.
267
+
268
+ ## Released migration: 0.46.0
269
+
270
+ ### `REALTIME_CONTRACT_VIOLATION` joined the error registry
271
+
272
+ Realtime contract failures use the framework error model instead of a bare
273
+ `ZodError`, so an exhaustive map stops compiling until the code is added:
274
+
275
+ ```ts
276
+ // before
277
+ { …, INTERNAL_SERVER_ERROR: 'internal' } satisfies Record<StitchErrorCode, string>
278
+ // after
279
+ { …, INTERNAL_SERVER_ERROR: 'internal', REALTIME_CONTRACT_VIOLATION: 'internal' } satisfies Record<StitchErrorCode, string>
280
+ ```
281
+
282
+ Only an exhaustive map breaks. Since 0.56.1 `codeMap` itself is partial, so a
283
+ map without the `satisfies` keeps compiling and lets the code travel as itself.
284
+
285
+ ### `RealtimeRejectedEvent.error` is an `AppError`
286
+
287
+ ```ts
288
+ // before
289
+ onRejected: ({ error }) => error.issues
290
+ // after
291
+ onRejected: ({ error }) => error.details?.issues // the ZodError moves to error.cause
292
+ ```
293
+
294
+ The envelope gained `reason` and `fault`. The consequence worth checking: code
295
+ reading `.issues` directly does not fail to compile if the handler is loosely
296
+ typed — it silently reads `undefined`. Grep for `.issues` on rejection handlers.
297
+
298
+ ### CLI construction refuses reserved names
299
+
300
+ A contract field or tool named `json`, `wait`, `quiet`, `dry-run`, `help`,
301
+ `version`, `wait-timeout` or `output-dir` now **throws while the CLI is built**,
302
+ instead of being silently shadowed:
303
+
304
+ ```ts
305
+ // before: app schedule_job --wait 2h → {"path":"2h"}, exit 0
306
+ // after: building a CLI over a contract with a "wait" field throws
307
+ ```
308
+
309
+ This one fires at startup, not at call time, so an application shipping such a
310
+ field crashes on boot after the upgrade. That is deliberate — the old behaviour
311
+ corrupted arguments silently — but it means the upgrade is not safe to deploy
312
+ without building the CLI once locally.
313
+
314
+ ### `createToolLogger` writes to stderr
315
+
316
+ stdout is the JSON-RPC channel of a stdio MCP server, and the previous
317
+ `console.info` default corrupted it. Pass `log` to redirect if your process
318
+ collected tool logs from stdout.
319
+
320
+ ## Released migration: 0.44.0
321
+
322
+ ### MCP TypeScript SDK v2 and protocol `2026-07-28`
323
+
324
+ This is a hard cut: there is one Stitchkit API and no v1 aliases. Applications
325
+ that expose MCP install the split server package; applications that implement an
326
+ MCP host or run client E2E install the split client package.
327
+
328
+ ```bash
329
+ bun remove @modelcontextprotocol/sdk
330
+ bun add @modelcontextprotocol/server@^2 ai@^7
331
+ # MCP hosts and client-side E2E only:
332
+ bun add -d @modelcontextprotocol/client@^2
333
+ ```
334
+
335
+ Direct SDK imports move to the split packages too:
336
+
337
+ ```ts
338
+ // before
339
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
340
+
341
+ // after
342
+ import { McpServer } from '@modelcontextprotocol/server'
343
+ ```
344
+
345
+ MCP Apps additionally install `@modelcontextprotocol/ext-apps`. That adapter may
346
+ carry its own isolated v1-era transitive/peer relationship while the ecosystem
347
+ finishes its cutover; it does not permit application code to import the removed
348
+ monolithic SDK. Application-owned MCP server and client code uses the v2 split
349
+ packages exclusively.
350
+
351
+ ```ts
352
+ // before
353
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
354
+ import { createMcpHandler } from 'stitchkit/tools'
355
+
356
+ const handleMcp = createMcpHandler({
357
+ serverInfo,
358
+ auth,
359
+ services,
360
+ sessionMode: 'stateless',
361
+ })
362
+ rawRoutes: [{ method: 'ALL', path: '/mcp', handler: handleMcp }]
363
+
364
+ // after
365
+ import { Client } from '@modelcontextprotocol/client'
366
+ import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools'
367
+
368
+ const mcp = createMcpHandler({
369
+ serverInfo,
370
+ auth,
371
+ services,
372
+ legacy: 'serve',
373
+ })
374
+ rawRoutes: [createMcpHttpRoute({ path: '/mcp', handler: mcp })]
375
+ // graceful shutdown: await mcp.close()
376
+ ```
377
+
378
+ Keep the owned handle and close it from the same shutdown path as the HTTP
379
+ server. A minimal runtime smoke should list tools before shutdown and prove the
380
+ closed handler no longer serves requests:
381
+
382
+ ```ts
383
+ const mcp = createMcpHandler(config)
384
+ const route = createMcpHttpRoute({ path: '/mcp', handler: mcp })
385
+
386
+ const beforeClose = await route.handler(listToolsRequest)
387
+ if (!beforeClose.ok) throw new Error('MCP list-tools smoke failed')
388
+
389
+ await mcp.close()
390
+ ```
391
+
392
+ Remove `@modelcontextprotocol/sdk`, `sessionMode`, `McpSessionMode`, session
393
+ stores and all `Mcp-Session-Id` handling. HTTP is always request-isolated and
394
+ stateless. `legacy: 'serve'` (default) lets the official SDK negotiate supported
395
+ pre-2026 stateless clients on the same endpoint; `legacy: 'reject'` makes it
396
+ modern-only. This is not a stateful compatibility transport.
397
+
398
+ #### Output shape depends on the negotiated protocol era
399
+
400
+ Stitchkit always validates the handler result against the declared output
401
+ schema. The negotiated MCP era determines only its wire representation:
402
+
403
+ | Negotiated era | Non-object `structuredContent` |
404
+ |---|---|
405
+ | MCP `2026-07-28` | the exact schema-valid JSON root: array, scalar or `null` |
406
+ | supported legacy era | the official SDK codec adapts it to `{ result: value }` |
407
+
408
+ Object roots keep their object shape in both eras. Do not change every consumer
409
+ expectation to the modern shape while `legacy: 'serve'` remains enabled. Pin
410
+ both protocol versions in the consumer's transport E2E and assert the boundary
411
+ explicitly:
412
+
413
+ ```ts
414
+ await expectToolOutputForProtocol('2026-07-28', ['a', 'b'])
415
+ await expectToolOutputForProtocol('2025-11-25', { result: ['a', 'b'] })
416
+
417
+ async function expectToolOutputForProtocol(
418
+ protocolVersion: '2026-07-28' | '2025-11-25',
419
+ expected: unknown,
420
+ ) {
421
+ const client = await connectConsumerMcpClient({ protocolVersion })
422
+ const result = await client.callTool({ name: 'list_notes', arguments: {} })
423
+ expect(result.structuredContent).toEqual(expected)
424
+ await client.close()
425
+ }
426
+ ```
427
+
428
+ `connectConsumerMcpClient` represents the consumer's real HTTP or stdio setup;
429
+ configure its official client with `versionNegotiation.mode.pin` so the test
430
+ cannot silently negotiate a different era.
431
+
432
+ The stdio helper now returns an owned lifecycle handle:
433
+
434
+ ```ts
435
+ // before
436
+ await createStdioMcpServer(config)
437
+
438
+ // after
439
+ const stdio = await createStdioMcpServer({ ...config, legacy: 'serve' })
440
+ await stdio.close()
441
+ ```
442
+
443
+ OAuth client registration is now one explicit policy object. CIMD is secure and
444
+ enabled by default; DCR is disabled unless supplied:
445
+
446
+ ```ts
447
+ // before
448
+ mountOAuthProvider({ ...oauth, clients, codes, refreshTokens })
449
+
450
+ // after
451
+ mountOAuthProvider({
452
+ ...oauth,
453
+ clientRegistration: {
454
+ preRegistered: { get: clients.get },
455
+ // optional: dcr: { register: clients.register, get: clients.get }
456
+ },
457
+ codes,
458
+ refreshTokens,
459
+ })
460
+ ```
461
+
462
+ A URL client id must be HTTPS and serve a document whose `client_id` exactly
463
+ matches that URL, with explicit `redirect_uris` and
464
+ `token_endpoint_auth_method: 'none'`. Do not keep a consumer-side metadata
465
+ fetcher or DCR fallback; Stitchkit owns SSRF-safe resolution and caching.
466
+
467
+ Multi-round input is opt-in on the operation and does not change Agent, CLI or
468
+ ordinary HTTP handlers:
469
+
470
+ ```ts
471
+ const ConfirmationSchema = z.object({ confirmed: z.boolean() })
472
+
473
+ mcp: {
474
+ inputRequired: [{
475
+ key: 'confirmation',
476
+ message: 'Confirm this action',
477
+ schema: ConfirmationSchema,
478
+ }],
479
+ }
480
+ ```
481
+
482
+ Configure `multiRound.state` on the MCP server with a key of at least 32 bytes
483
+ and a stable authenticated `principal`. Read accepted typed content from
484
+ `ctx.mcpInput.confirmation`. Multiple declarations run in array order, the
485
+ aggregate remains exactly typed by key, keys must be unique, and declarations
486
+ must fit `multiRound.serving.maxRounds` (default `10`). Do not execute a
487
+ destructive side effect before the complete aggregate is accepted. A modern
488
+ request missing the declared elicitation capability receives JSON-RPC error
489
+ code `-32021`. The official per-request legacy HTTP bridge cannot issue
490
+ server-to-client elicitation, so it returns a deterministic failed tool result;
491
+ multi-round input is never silently treated as complete.
492
+
493
+ #### Compatibility matrix
494
+
495
+ | Host / transport | Tools and Apps | Multi-round input | Continuity |
496
+ |---|---|---|---|
497
+ | `2026-07-28` HTTP | yes | yes | request-isolated |
498
+ | supported legacy stateless HTTP | yes | unsupported result | request-isolated |
499
+ | `2026-07-28` stdio | yes | yes | one process connection |
500
+ | supported legacy stdio | yes | official SDK bridge | one process connection |
501
+ | Agent / CLI / ordinary HTTP | unchanged | not exposed | unchanged |
502
+
503
+ Subscriptions, cross-request progress and resumable stateful SSE are not
504
+ implemented or advertised.
505
+
506
+ #### Consumer checklist
507
+
508
+ 1. Replace the monolithic SDK with `@modelcontextprotocol/server@^2` and, only
509
+ for hosts/tests, `@modelcontextprotocol/client@^2`.
510
+ 2. Replace raw MCP route wiring with `createMcpHttpRoute`; retain and close the
511
+ returned handler/stdio handle during shutdown.
512
+ 3. Delete all session mode, event-store and session-id code.
513
+ 4. Move OAuth client policy under `clientRegistration`; publish CIMD or enable
514
+ DCR explicitly.
515
+ 5. Make `authorizeUser` return the exact consented scope subset. The framework
516
+ validates that it is a subset of the request before saving the authorization
517
+ code:
518
+
519
+ ```ts
520
+ // before
521
+ authorizeUser: async () => ({ userId })
522
+
523
+ // after
524
+ authorizeUser: async (_req, request) => ({
525
+ userId,
526
+ approvedScopes: request.scope?.split(' ') ?? [],
527
+ })
528
+ ```
529
+
530
+ 6. Snapshot `listToolNames`, run one contract tool, one runtime tool, any raw
531
+ multimodal tool and every MCP App resource you use.
532
+ 7. Exercise modern HTTP and stdio with protocol `2026-07-28`; exercise legacy
533
+ only if `legacy: 'serve'` is part of your support policy.
534
+ 8. Run the consumer's typecheck and runtime gates. A browser/HTTP-only consumer
535
+ must continue to work without either MCP package.
536
+
537
+ ## Historical breaking migrations through 0.44.0
538
+
539
+ HTTP observability now completes inside the framework handler instead of a
540
+ nested fetch wrapper. Configure request and tool sinks explicitly:
541
+
542
+ ```ts
543
+ // before
544
+ const audit = createAuditHook({ write })
545
+ createServer({
546
+ services,
547
+ wrapFetch: (handler) => wrapInRequestContext(audit.http(handler)),
548
+ })
549
+ mountAgent(services, { hooks: audit.toolCall })
550
+
551
+ // after
552
+ const observability = createObservability({
553
+ request: { write, includePayload: true },
554
+ tools: { write },
555
+ })
556
+ createServer({ services, observability: observability.request })
557
+ mountAgent(services, { hooks: observability.toolCall })
558
+ ```
559
+
560
+ Body capture changed from always-on for body methods to opt-in. Set
561
+ `includePayload: true` only when the request sink needs the sanitized JSON body.
562
+ There is no `createAuditHook` or `audit.http` compatibility path;
563
+ `wrapInRequestContext` remains only for custom fetch pipelines.
564
+
565
+ Tool introspection now accepts one object-shaped contract/runtime surface. Stop
566
+ calling the internal contract collector or merging a locally converted runtime
567
+ manifest:
568
+
569
+ ```ts
570
+ // before
571
+ buildToolManifest(services.flatMap((service) => collectTools(service, 'AGENT')))
572
+ listToolNames(services)
573
+ summarizeTransports(services)
574
+
575
+ // after
576
+ const surface = { services, runtimeTools }
577
+ buildToolManifest({ ...surface, transport: 'AGENT' })
578
+ listToolNames(surface)
579
+ summarizeTransports(surface)
580
+ ```
581
+
582
+ `ToolNameEntry` adds `kind: 'contract' | 'runtime'`. `TransportSummary` is now
583
+ `{ contractServices, runtimeTools, totals, sources }`; replace `services` and
584
+ `perService` reads with the explicit counts and mixed-source breakdown. There
585
+ is no positional overload and no `buildRuntimeToolManifest`: Stitchkit owns the
586
+ combined order, transport filtering, canonical presentation schema and
587
+ cross-origin collision checks.
588
+
589
+ `defineErrors` now uses one Zod-first definition object and returns constructors
590
+ instead of positional throwers. Add explicit `throw`, move message/details/hint
591
+ into one options object, and declare a details schema when that code carries
592
+ structured context:
593
+
594
+ ```ts
595
+ // before
596
+ const { errors } = defineErrors({ QUOTA_EXCEEDED: 429 })
597
+ errors.QUOTA_EXCEEDED('Try later', { retryAfterSeconds: 30 }, 'Wait')
598
+
599
+ // after
600
+ const { errors, definitions } = defineErrors({
601
+ QUOTA_EXCEEDED: {
602
+ status: 429,
603
+ details: z.object({ retryAfterSeconds: z.number().positive() }),
604
+ },
605
+ })
606
+ throw errors.QUOTA_EXCEEDED({
607
+ message: 'Try later',
608
+ details: { retryAfterSeconds: 30 },
609
+ hint: 'Wait',
610
+ })
611
+ ```
612
+
613
+ There is no positional overload. A code without `details` forbids them; use an
614
+ optional object schema when the details object itself is optional. Read status
615
+ and schemas from the frozen `definitions` registry instead of maintaining a
616
+ parallel map.
617
+
618
+ Managed MCP runtime tools are now declared as immutable data. Move protected
619
+ registrar calls to `runtimeTools`; rename deliberate raw SDK registration to
620
+ `rawTools`. There is no registrar alias:
621
+
622
+ ```ts
623
+ // before — protected
624
+ createMcpHandler({
625
+ services,
626
+ nativeTools: ({ registerTool }) => registerTool(preview),
627
+ })
628
+
629
+ // after — protected and prepared with the rest of the surface
630
+ createMcpHandler({ services, runtimeTools: [preview] })
631
+
632
+ // before — deliberate raw SDK opt-out
633
+ nativeTools: ({ rawServer }, auth) => mountRaw(rawServer, auth)
634
+
635
+ // after — still a deliberate raw SDK opt-out
636
+ rawTools: (server, auth) => mountRaw(server, auth)
637
+ ```
638
+
639
+ When identity selects from a bounded set, replace a repeatedly prepared
640
+ `services(auth)` factory with a finite registry:
641
+
642
+ ```ts
643
+ createMcpHandler({
644
+ surfaces: {
645
+ admin: { services: allServices, runtimeTools: [preview] },
646
+ member: { services: memberServices, runtimeTools: [preview] },
647
+ },
648
+ selectSurface: (auth) => auth.isAdmin ? 'admin' : 'member',
649
+ })
650
+ ```
651
+
652
+ Keep direct `services(auth)` / `runtimeTools(auth)` only for genuinely
653
+ unbounded definitions; Stitchkit intentionally does not cache arbitrary auth
654
+ values.
655
+
656
+ Contract success bodies are now determined by the presence of `output`, not by
657
+ the handler's runtime value. A nullable output returns JSON `null` with status
658
+ `200`; `undefined` with a declared output and non-null data without an output
659
+ schema are contract violations:
660
+
661
+ ```ts
662
+ // nullable JSON data: 200 with body `null`
663
+ session: {
664
+ method: 'GET', path: '/session', desc: 'Current session',
665
+ output: SessionSchema.nullable(),
666
+ }
667
+
668
+ // bodyless operation: 204 with no body
669
+ logout: {
670
+ method: 'POST', path: '/logout', desc: 'End the session',
671
+ }
672
+ ```
673
+
674
+ Add an output schema to every handler that returns data. Omit `output` and
675
+ return nothing for bodyless operations; runtime tools follow the same rule and
676
+ type no-output handlers as `void`.
677
+
678
+ `createToolInvoker` now separates immutable registry preparation from per-call
679
+ runtime state. Move source/context/lifecycle/hooks/output-strip reporting from
680
+ the factory config to the third invocation argument. Use `invokeOrThrow` when a
681
+ nested operation should preserve the normalized `AppError` instead of returning
682
+ a model-facing failure envelope:
683
+
684
+ ```ts
685
+ // before
686
+ const invoker = createToolInvoker(services, {
687
+ transport: 'AGENT', context: { identity }, lifecycle, hooks,
688
+ })
689
+ const result = await invoker.invoke(name, args)
690
+
691
+ // after
692
+ const invoker = createToolInvoker(services, { transport: 'AGENT' })
693
+ const result = await invoker.invoke(name, args, {
694
+ context: { identity }, lifecycle, hooks,
695
+ })
696
+ const data = await invoker.invokeOrThrow(name, args, {
697
+ context: { identity }, lifecycle, hooks,
698
+ })
699
+ ```
700
+
701
+ There is no static runtime-config overload: request identity must not be retained
702
+ by a reusable compiled registry.
703
+
704
+ Entity cache handlers now require the cached list shape and CRUD policies. Move
705
+ `listKey` under `list`, make detail keys event-aware, and state the list-item
706
+ identity/projection explicitly:
707
+
708
+ ```ts
709
+ // before
710
+ createEntityCacheHandlers<Entity>({
711
+ getId,
712
+ listKey: ['entities'],
713
+ detailKey: (id) => ['entities', id],
714
+ })
715
+
716
+ // after
717
+ createEntityCacheHandlers<Entity, EntityListItem>({
718
+ getId,
719
+ getListItemId: (item) => item.id,
720
+ toListItem: (entity) => ({ id: entity.id, name: entity.name }),
721
+ list: {
722
+ key: ['entities'],
723
+ shape: 'paginated',
724
+ createAt: 'start',
725
+ updateMissing: 'skip',
726
+ },
727
+ detailKey: (event) => ['entities', event.id],
728
+ })
729
+ ```
730
+
731
+ Choose `array`, `paginated`, `infinite-array` or `infinite-paginated` to match
732
+ the actual cached data. A dynamic `list.key` / `detailKey` receives a
733
+ discriminated event and can derive scoped keys from the created/updated entity
734
+ or deleted payload. Add `compare` only when the backend has a canonical order;
735
+ the framework does not guess it or mutate pagination metadata.
736
+
737
+ Protected native MCP operations now use the transport-neutral runtime tool
738
+ definition. Return the schema-owned value from the handler and move MCP content
739
+ or metadata into `present.mcp`; `structuredContent` and `isError` are
740
+ framework-owned:
741
+
742
+ ```ts
743
+ // before
744
+ registerTool({ input, output, handler: async () => ({
745
+ content: [{ type: 'image', data, mimeType: 'image/png' }],
746
+ structuredContent: { assetId },
747
+ }) })
748
+
749
+ // after
750
+ const preview = defineRuntimeTool({
751
+ name: 'render_preview', description, identity, input, output,
752
+ handler: async () => ({ assetId, data }),
753
+ present: {
754
+ mcp: (result) => ({
755
+ content: [{ type: 'image', data: result.data, mimeType: 'image/png' }],
756
+ }),
757
+ },
758
+ })
759
+ runtimeTools: [preview]
760
+ ```
761
+
762
+ The removed `NativeMcp*` types have no aliases. Use `RuntimeToolDefinition`,
763
+ `RuntimeToolIdentity`, `RuntimeToolHandlerContext` and
764
+ `RuntimeMcpPresentation`. The same definition can now be passed to
765
+ `mountAgent(services, { runtimeTools: [preview] })`; add `present.agent` only
766
+ when the model needs rich text/file content instead of the neutral JSON result.
767
+
768
+ Trailing wildcards must be named consistently across the path and params schema:
769
+
770
+ ```ts
771
+ // before
772
+ path: '/app/:slug/*'
773
+ params: z.object({ slug: z.string(), '*': z.string() })
774
+ ctx.params['*']
775
+ api.app({ slug: 'foo', '*': 'a/b' })
776
+
777
+ // after
778
+ path: '/app/:slug/*filePath'
779
+ params: z.object({ slug: z.string(), filePath: z.string() })
780
+ ctx.params.filePath
781
+ api.app({ slug: 'foo', filePath: 'a/b' })
782
+ ```
783
+
784
+ Bare wildcards have no compatibility alias; raw routes use the same named form.
785
+
786
+ ### Expected-401 matchers
787
+
788
+ `HttpClientConfig.authEndpoints` is removed. Replace manual path prefixes with
789
+ the operations whose 401 response is expected:
790
+
791
+ ```ts
792
+ // before
793
+ createHttpClient({ baseUrl, authEndpoints: ['/api/auth/'] })
794
+
795
+ // after
796
+ createHttpClient({
797
+ baseUrl,
798
+ suppressUnauthorizedFor: contractEndpointMatchers(authContract, ['login', 'verify']),
799
+ })
800
+ ```
801
+
802
+ There is no implicit `/auth/` suppression. Omit `suppressUnauthorizedFor` when
803
+ every 401 should emit the global `unauthorized` event.
804
+
805
+ ## The 0.37 migration
806
+
807
+ Tool presentation is no longer an executable Zod parser. Replace the removed
808
+ flatten helpers with the JSON Schema compiler, and choose the explicit
809
+ `MountableTool` surface when using the advanced collection API:
810
+
811
+ ```ts
812
+ // before
813
+ const flat = flattenUnionsDeep(zodSchema)
814
+ mountable.schema
815
+
816
+ // after
817
+ const flat = flattenToolJsonSchema(
818
+ z.toJSONSchema(zodSchema, { target: 'draft-07', io: 'input' }),
819
+ )
820
+ mountable.presentationSchema // model/MCP/manifest JSON Schema
821
+ mountable.argumentSchema // executable CLI argument adapter only
822
+ ```
823
+
824
+ Contract and native handlers keep their original Zod schemas; MCP and agent SDK
825
+ adapters now forward raw arguments so defaults, coercions, refinements and
826
+ transforms execute exactly once inside Stitchkit. There are no compatibility
827
+ exports for `flattenDiscriminatedUnion`, `flattenUnionsDeep` or
828
+ `MountableTool.schema`. → ADR 0050
829
+
830
+ Tool-call hooks now take one options object. Migrate all three callbacks; there
831
+ are no positional overloads:
832
+
833
+ ```ts
834
+ // before
835
+ beforeToolCall: (toolName, args, context, endpoint) => {}
836
+ afterToolCall: (toolName, args, result, durationMs, context, endpoint, error) => {}
837
+ onToolError: (toolName, error, context, endpoint) => {}
838
+
839
+ // after
840
+ beforeToolCall: ({ toolName, args, context, endpoint }) => {}
841
+ afterToolCall: ({ toolName, args, result, durationMs, context, endpoint, error }) => {}
842
+ onToolError: ({ toolName, error, context, endpoint }) => {}
843
+ ```
844
+
845
+ ### MCP schema validation
846
+
847
+ The standalone validator now takes one object and live MCP configs carry the
848
+ same rules under `schemaValidation`. Migrate every positional call and every
849
+ `onIncompatibleSchema` field; there is no old-shape overload:
850
+
851
+ ```ts
852
+ // before
853
+ validateMcpSchemas(services, 'throw', logger, { requireTypedProperties: true })
854
+ createMcpHandler({ services, onIncompatibleSchema: 'throw' })
855
+
856
+ // after
857
+ validateMcpSchemas({ services, policy: 'throw', logger, requireTypedProperties: true })
858
+ createMcpHandler({ services, schemaValidation: { policy: 'throw' } })
859
+ ```
860
+
861
+ Put `extend` and `flattenUnionInput` beside `services`, not inside
862
+ `schemaValidation`. The handler applies the profile to the exact prepared schema
863
+ it advertises. Add `requirePortableFormats: true` when every custom JSON Schema
864
+ format must be rejected before a client sees it.
865
+
866
+ Native MCP registration also changed shape in 0.37. Move protected tools to the
867
+ framework registrar; keep an SDK-raw tool only by naming the opt-out:
868
+
869
+ ```ts
870
+ // before
871
+ nativeTools: (server, auth) => server.registerTool(name, config, handler)
872
+
873
+ // after — lifecycle, hooks and schema policy apply
874
+ nativeTools: ({ registerTool }, auth) => registerTool({
875
+ name, description, identity, input, output, handler,
876
+ })
877
+
878
+ // after — intentionally raw
879
+ nativeTools: ({ rawServer }, auth) => rawServer.registerTool(name, config, handler)
880
+ ```
881
+
882
+ If a tool hook annotated `endpoint` as `MethodDef`, remove that annotation or
883
+ use `OperationIdentity`: native operations have service/action/scope/method but
884
+ no HTTP path.
885
+
886
+ ### Node-facing server types
887
+
888
+ `stitchkit/server` remains Bun-concrete: an explicitly annotated `RawRoute`
889
+ still receives `BunServer`. `stitchkit/node` no longer drags Bun declarations
890
+ into a Node project. Its raw routes default the host server to `unknown`, and
891
+ its Socket.IO handle exposes only Node capabilities:
892
+
893
+ ```ts
894
+ // before — Node entry still leaked Bun-only fields and ambient types
895
+ const route: RawRoute = { handler: (_req, ctx) => ctx.server?.upgrade(...) }
896
+ const socket = await createSocketIOServer(config)
897
+ socket.websocket
898
+
899
+ // after — name a custom embedding host only when one exists
900
+ const route: RawRoute<MyHostServer> = {
901
+ handler: (_req, ctx) => useHost(ctx.server),
902
+ }
903
+ const socket = await createSocketIOServer(config)
904
+ socket.io
905
+ socket.attach(nodeHttpServer)
906
+ ```
907
+
908
+ Node consumers can remove `@types/bun` unless another dependency independently
909
+ requires it.
910
+
911
+ ### Managed server shutdown
912
+
913
+ `createServer()` and `serveNode()` now return the same structural managed
914
+ lifecycle. Replace every direct runtime stop and parallel Socket.IO close:
915
+
916
+ ```ts
917
+ // before — split ownership
918
+ const socket = await createSocketIOServer(config)
919
+ const server = createServer({
920
+ services,
921
+ websocket: socket.websocket,
922
+ rawRoutes: [socket.route],
923
+ })
924
+ server.stop()
925
+ await socket.io.close()
926
+
927
+ // after — one owner and one total deadline
928
+ const socket = await createSocketIOServer(config)
929
+ const server = createServer({ services, socket })
930
+ const result = await server.shutdown({ gracePeriodMs: 30_000 })
931
+ ```
932
+
933
+ On Node, keep the same `socket` field and replace `handle.close()` with
934
+ `handle.shutdown()`. Runtime-specific diagnostics move under `handle.runtime`;
935
+ do not use it as a second shutdown path. Standalone CLI/tools that create a
936
+ Socket.IO handle without an HTTP server call `await socket.close()`.
937
+
938
+ If Bun Socket.IO shares the port with a raw lane, keep the explicit composition
939
+ but let the server mount the Socket.IO route:
940
+
941
+ ```ts
942
+ createServer({
943
+ services,
944
+ socket,
945
+ websocket: composeWebSocketHandlers([
946
+ webSocketLane({ match: isRaw, handlers: rawHandlers }),
947
+ socketIoLane(socket.websocket),
948
+ ]),
949
+ rawRoutes: [rawUpgradeRoute],
950
+ })
951
+ ```
952
+
953
+ Move native Bun `routes` entries to `rawRoutes`. Native routes run before the
954
+ Fetch handler and therefore cannot participate in admission or drain. Wire
955
+ `SIGTERM`/`SIGINT` in the application; the first signal starts `shutdown()`, and
956
+ a later signal may abort the same controller. Close MCP, databases and queues
957
+ after the server result—those resources remain application-owned.
958
+
959
+ Move a handshake policy from the Node-only callback shape inside
960
+ `serverOptions` to the runtime-neutral top-level policy. It receives a Web
961
+ `Request`, may be async, and returns whether to admit the handshake:
962
+
963
+ ```ts
964
+ // before
965
+ serverOptions: { allowRequest: (request, done) => done(null, allowed(request)) }
966
+
967
+ // after
968
+ allowRequest: (request) => allowed(request)
969
+ ```