stitchkit 0.90.7 → 0.91.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 (88) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/agent-runtime-coding-tools.js +4 -4
  3. package/dist/agent-runtime-harness.js +6 -6
  4. package/dist/agent-runtime-sandbox.js +4 -4
  5. package/dist/agent-runtime.js +18 -17
  6. package/dist/application-grammy.js +1 -1
  7. package/dist/application.js +3 -3
  8. package/dist/cli.js +7 -7
  9. package/dist/contract/errors-factory.d.ts +12 -0
  10. package/dist/contract/errors-factory.d.ts.map +1 -1
  11. package/dist/contract/errors.d.ts +35 -1
  12. package/dist/contract/errors.d.ts.map +1 -1
  13. package/dist/contract/index.d.ts +1 -1
  14. package/dist/contract/index.d.ts.map +1 -1
  15. package/dist/contract/index.js +4 -2
  16. package/dist/{index-nrqg8tks.js → index-0a349c33.js} +3 -3
  17. package/dist/index-0j3vcp5m.js +28 -0
  18. package/dist/{index-vy2nxwg8.js → index-137jjs4v.js} +2 -2
  19. package/dist/{index-2hryh65w.js → index-13cjtmrw.js} +54 -13
  20. package/dist/{index-7gqx71yd.js → index-13e1k7v9.js} +9 -3
  21. package/dist/{index-1bnkzq95.js → index-1qvdwnp2.js} +10 -3
  22. package/dist/{index-scs3f1eg.js → index-2vtcmrj9.js} +8 -2
  23. package/dist/{index-xbppf54a.js → index-6kyksz9y.js} +12 -7
  24. package/dist/{index-aj9geeez.js → index-709nh7z7.js} +37 -17
  25. package/dist/{index-zkcqhxx8.js → index-a11g2kry.js} +1 -1
  26. package/dist/{index-1txvygak.js → index-b971qjes.js} +1 -1
  27. package/dist/{index-tgh3ksfh.js → index-bh70rk41.js} +3 -3
  28. package/dist/{index-bva395we.js → index-bv7mytab.js} +2 -2
  29. package/dist/{index-s208wab5.js → index-bx6yq2m4.js} +23 -495
  30. package/dist/{index-83fafqw8.js → index-f4p4b8f6.js} +10 -11
  31. package/dist/index-kw9t5ayn.js +507 -0
  32. package/dist/{index-8pjqv3zh.js → index-mqhjmadk.js} +43 -21
  33. package/dist/{index-3yqgj323.js → index-qjeyx5t2.js} +2 -2
  34. package/dist/{index-gte38nbm.js → index-qmy6zq75.js} +1 -1
  35. package/dist/{index-zsdgd1tz.js → index-qrbdq8jk.js} +1 -1
  36. package/dist/{index-fqg5mfk7.js → index-ya611vx3.js} +4 -4
  37. package/dist/{index-640tz39q.js → index-zsqy3xfn.js} +5 -5
  38. package/dist/index.js +8 -6
  39. package/dist/internal/canonical-json.d.ts +20 -0
  40. package/dist/internal/canonical-json.d.ts.map +1 -0
  41. package/dist/internal/errors.d.ts +13 -4
  42. package/dist/internal/errors.d.ts.map +1 -1
  43. package/dist/node.js +6 -6
  44. package/dist/observability/bounded-logger.d.ts +2 -0
  45. package/dist/observability/bounded-logger.d.ts.map +1 -1
  46. package/dist/observability/index.js +7 -4
  47. package/dist/observability/sanitize.d.ts +15 -0
  48. package/dist/observability/sanitize.d.ts.map +1 -1
  49. package/dist/primitives.js +2 -2
  50. package/dist/realtime/rejected-frame.d.ts +6 -0
  51. package/dist/realtime/rejected-frame.d.ts.map +1 -1
  52. package/dist/remote.js +5 -5
  53. package/dist/server/implement.d.ts +33 -7
  54. package/dist/server/implement.d.ts.map +1 -1
  55. package/dist/server/index.d.ts +1 -1
  56. package/dist/server/index.d.ts.map +1 -1
  57. package/dist/server/index.js +8 -8
  58. package/dist/testing/surface-manifest.d.ts +2 -1
  59. package/dist/testing/surface-manifest.d.ts.map +1 -1
  60. package/dist/testing.js +11 -27
  61. package/dist/tool-invoker.js +6 -6
  62. package/dist/tools/agent.d.ts +12 -0
  63. package/dist/tools/agent.d.ts.map +1 -1
  64. package/dist/tools/cli-format.d.ts +1 -1
  65. package/dist/tools/connections/index.js +1 -1
  66. package/dist/tools/durability-context.d.ts +1 -1
  67. package/dist/tools/durability-context.d.ts.map +1 -1
  68. package/dist/tools/durability-port.d.ts +55 -0
  69. package/dist/tools/durability-port.d.ts.map +1 -0
  70. package/dist/tools/execute.d.ts +59 -1
  71. package/dist/tools/execute.d.ts.map +1 -1
  72. package/dist/tools/mcp-catalog.d.ts +68 -0
  73. package/dist/tools/mcp-catalog.d.ts.map +1 -0
  74. package/dist/tools/mcp.d.ts +3 -0
  75. package/dist/tools/mcp.d.ts.map +1 -1
  76. package/dist/tools/mount.d.ts +2 -2
  77. package/dist/tools/mount.d.ts.map +1 -1
  78. package/dist/tools/native-mcp.d.ts +3 -0
  79. package/dist/tools/native-mcp.d.ts.map +1 -1
  80. package/dist/tools/runtime-tool.d.ts +1 -1
  81. package/dist/tools/runtime-tool.d.ts.map +1 -1
  82. package/dist/tools.d.ts +5 -0
  83. package/dist/tools.d.ts.map +1 -1
  84. package/dist/tools.js +99 -20
  85. package/dist/tracking.js +2 -2
  86. package/llms-full.txt +258 -8
  87. package/package.json +2 -2
  88. package/dist/index-7rkhw9ec.js +0 -34
package/dist/tools.js CHANGED
@@ -1,10 +1,14 @@
1
+ import {
2
+ compareCodepoints,
3
+ serializeCanonicalJson
4
+ } from "./index-0j3vcp5m.js";
1
5
  import {
2
6
  ManagedFileError
3
7
  } from "./index-actkayzn.js";
4
8
  import {
5
9
  signJwt,
6
10
  verifyPkce
7
- } from "./index-bva395we.js";
11
+ } from "./index-bv7mytab.js";
8
12
  import"./index-hr4tzbwq.js";
9
13
  import {
10
14
  DEFAULT_CORS_ALLOW_HEADERS,
@@ -14,16 +18,20 @@ import {
14
18
  guardSignalCallback,
15
19
  mediaTypeEssence,
16
20
  reportSignalError
17
- } from "./index-aj9geeez.js";
21
+ } from "./index-709nh7z7.js";
18
22
  import {
19
23
  redact
20
- } from "./index-xbppf54a.js";
24
+ } from "./index-6kyksz9y.js";
21
25
  import {
22
26
  buildToolManifest,
23
27
  describeToolCatalog,
24
28
  mountAgent
25
- } from "./index-83fafqw8.js";
26
- import"./index-7rkhw9ec.js";
29
+ } from "./index-f4p4b8f6.js";
30
+ import {
31
+ AgentToolError,
32
+ createLocalStepDurability,
33
+ isAgentToolError
34
+ } from "./index-kw9t5ayn.js";
27
35
  import {
28
36
  argumentsDigest
29
37
  } from "./index-a916km3r.js";
@@ -38,7 +46,7 @@ import {
38
46
  } from "./index-4fpa9dy2.js";
39
47
  import {
40
48
  createToolInvoker
41
- } from "./index-tgh3ksfh.js";
49
+ } from "./index-bh70rk41.js";
42
50
  import {
43
51
  WaitTimeoutError,
44
52
  createCli,
@@ -47,22 +55,22 @@ import {
47
55
  fetchPinnedDocument,
48
56
  readCapped,
49
57
  runWaitOperation
50
- } from "./index-fqg5mfk7.js";
58
+ } from "./index-ya611vx3.js";
51
59
  import"./index-sbdmyz75.js";
52
60
  import {
53
61
  collectToolSurface
54
- } from "./index-zsdgd1tz.js";
62
+ } from "./index-qrbdq8jk.js";
55
63
  import {
56
64
  collectTools,
57
65
  createToolRunner,
58
66
  formatToolError
59
- } from "./index-1bnkzq95.js";
67
+ } from "./index-1qvdwnp2.js";
60
68
  import {
61
69
  ToolExecutionControlError,
62
70
  executeToolMethod,
63
71
  isToolExecutionControlError,
64
72
  toolResultFromError
65
- } from "./index-8pjqv3zh.js";
73
+ } from "./index-mqhjmadk.js";
66
74
  import {
67
75
  getRequestContext,
68
76
  getTraceId,
@@ -71,10 +79,10 @@ import {
71
79
  import"./index-f9mb610r.js";
72
80
  import {
73
81
  normalizeError
74
- } from "./index-2hryh65w.js";
82
+ } from "./index-13cjtmrw.js";
75
83
  import {
76
84
  defineContract
77
- } from "./index-nrqg8tks.js";
85
+ } from "./index-0a349c33.js";
78
86
  import"./index-rxfy4cq7.js";
79
87
  import {
80
88
  ManagedFilePathSchema,
@@ -101,11 +109,13 @@ import {
101
109
  presentationMetadata,
102
110
  validateMcpRoundPolicy
103
111
  } from "./index-fenaekmk.js";
104
- import"./index-cby4ar3v.js";
112
+ import {
113
+ toJsonSchema
114
+ } from "./index-cby4ar3v.js";
105
115
  import {
106
116
  AppError,
107
117
  STITCH_ERROR_STATUS
108
- } from "./index-scs3f1eg.js";
118
+ } from "./index-2vtcmrj9.js";
109
119
  import"./index-kzfs85xp.js";
110
120
  import {
111
121
  isRecord
@@ -946,6 +956,63 @@ function inlineMcpAppBundle(html) {
946
956
  return html.replace(EXT_APPS_BUNDLE_PLACEHOLDER, () => bundle);
947
957
  }
948
958
 
959
+ // src/tools/mcp-catalog.ts
960
+ import { createHash } from "node:crypto";
961
+ var MCP_CATALOG_META_KEY = "stitchkit/catalog";
962
+ var stampCache = new WeakMap;
963
+ function toolFingerprint(descriptor) {
964
+ const { mountable } = descriptor;
965
+ return {
966
+ name: mountable.name,
967
+ description: mountable.method.desc,
968
+ input: descriptor.inputSchema,
969
+ output: descriptor.outputSchema ? toJsonSchema(descriptor.outputSchema, "output", "any") : null,
970
+ annotations: mountable.method.annotations ?? null
971
+ };
972
+ }
973
+ function mcpCatalogStamp(surface) {
974
+ const cached = stampCache.get(surface);
975
+ if (cached)
976
+ return cached;
977
+ const descriptors = [
978
+ ...surface.contractTools,
979
+ ...surface.runtimeTools.map((entry) => entry.descriptor)
980
+ ].sort((left, right) => compareCodepoints(left.mountable.name, right.mountable.name));
981
+ const bytes = serializeCanonicalJson(descriptors.map(toolFingerprint));
982
+ const stamp = {
983
+ digest: createHash("sha256").update(bytes).digest("hex").slice(0, 16),
984
+ tools: descriptors.length
985
+ };
986
+ stampCache.set(surface, stamp);
987
+ return stamp;
988
+ }
989
+ function mcpCatalogMeta(stamp) {
990
+ return { [MCP_CATALOG_META_KEY]: { ...stamp } };
991
+ }
992
+ function readMcpCatalogStamp(meta) {
993
+ if (!isRecord(meta))
994
+ return null;
995
+ const value = meta[MCP_CATALOG_META_KEY];
996
+ if (!isRecord(value))
997
+ return null;
998
+ const { digest, tools } = value;
999
+ if (typeof digest !== "string" || digest.length === 0)
1000
+ return null;
1001
+ if (typeof tools !== "number" || !Number.isInteger(tools) || tools < 0)
1002
+ return null;
1003
+ return { digest, tools };
1004
+ }
1005
+ function stampToolRegistration(config, stamp) {
1006
+ if (!stamp)
1007
+ return config;
1008
+ return { ...config, _meta: { ...config._meta, ...mcpCatalogMeta(stamp) } };
1009
+ }
1010
+ function stampToolResult(result, stamp) {
1011
+ if (!stamp)
1012
+ return result;
1013
+ return { ...result, _meta: { ...result._meta, ...mcpCatalogMeta(stamp) } };
1014
+ }
1015
+
949
1016
  // src/tools/mcp-prepare.ts
950
1017
  function textBlock(text) {
951
1018
  return [{ type: "text", text }];
@@ -1283,7 +1350,7 @@ function mountPreparedRuntimeMcp(server, tools, config) {
1283
1350
  "ui/resourceUri": definition.ui.resourceUri
1284
1351
  };
1285
1352
  }
1286
- server.registerTool(definition.name, toolConfig, async (rawArgs, mcpContext) => runInMcpRequestContext(mcpContext, definition.name, async () => {
1353
+ server.registerTool(definition.name, stampToolRegistration(toolConfig, config.catalog), async (rawArgs, mcpContext) => stampToolResult(await runInMcpRequestContext(mcpContext, definition.name, async () => {
1287
1354
  const args = isRecord(rawArgs) ? rawArgs : {};
1288
1355
  const runTool = (tool, toolArgs, roundContext) => executeToolMethod(tool.method, definition.name, toolArgs, { ...config.context, ...roundContext, source: "mcp" }, config.hooks, config.lifecycle, config.coerceJsonArgs ?? true, config.onOutputStrip ? (paths) => config.onOutputStrip?.(definition.name, paths) : undefined);
1289
1356
  const round = await resolveMcpRound({
@@ -1323,7 +1390,7 @@ function mountPreparedRuntimeMcp(server, tools, config) {
1323
1390
  throw new Error("[stitchkit] Runtime MCP presenter invariant failed");
1324
1391
  }
1325
1392
  return presentedResult.data;
1326
- }));
1393
+ }), config.catalog));
1327
1394
  }
1328
1395
  }
1329
1396
 
@@ -1343,7 +1410,10 @@ function mountMcp(mcpServer, services, config = {}) {
1343
1410
  schemaValidation: config.schemaValidation,
1344
1411
  logger: config.logger
1345
1412
  });
1346
- mountPreparedMcp(mcpServer, prepared, config);
1413
+ mountPreparedMcp(mcpServer, prepared, {
1414
+ ...config,
1415
+ catalog: mcpCatalogStamp({ contractTools: prepared, runtimeTools: [] })
1416
+ });
1347
1417
  }
1348
1418
  function mountPreparedMcp(mcpServer, prepared, config = {}) {
1349
1419
  const runTool = createToolRunner({
@@ -1373,7 +1443,7 @@ function mountPreparedMcp(mcpServer, prepared, config = {}) {
1373
1443
  "ui/resourceUri": mountable.method.ui.resourceUri
1374
1444
  };
1375
1445
  }
1376
- mcpServer.registerTool(mountable.name, toolConfig, async (rawArgs, mcpContext) => runInMcpRequestContext(mcpContext, mountable.name, async () => {
1446
+ mcpServer.registerTool(mountable.name, stampToolRegistration(toolConfig, config.catalog), async (rawArgs, mcpContext) => stampToolResult(await runInMcpRequestContext(mcpContext, mountable.name, async () => {
1377
1447
  const args = isRecord(rawArgs) ? rawArgs : {};
1378
1448
  try {
1379
1449
  const round = await resolveMcpRound({
@@ -1392,7 +1462,7 @@ function mountPreparedMcp(mcpServer, prepared, config = {}) {
1392
1462
  } catch (err) {
1393
1463
  return formatMcpResult(toolResultFromError(err), "none", mountable.name, config.errorHint);
1394
1464
  }
1395
- }));
1465
+ }), config.catalog));
1396
1466
  }
1397
1467
  }
1398
1468
  function buildMcpServer(config, auth) {
@@ -1454,7 +1524,9 @@ function buildMcpServerFromPrepared(config, auth, prepared) {
1454
1524
  };
1455
1525
  const server = new McpServer(config.serverInfo, serverOptions);
1456
1526
  const context = config.context?.(auth);
1527
+ const catalog = mcpCatalogStamp(prepared);
1457
1528
  mountPreparedMcp(server, prepared.contractTools, {
1529
+ catalog,
1458
1530
  context,
1459
1531
  hooks: config.hooks,
1460
1532
  lifecycle: config.lifecycle,
@@ -1473,6 +1545,7 @@ function buildMcpServerFromPrepared(config, auth, prepared) {
1473
1545
  }
1474
1546
  });
1475
1547
  mountPreparedRuntimeMcp(server, prepared.runtimeTools, {
1548
+ catalog,
1476
1549
  context,
1477
1550
  hooks: config.hooks,
1478
1551
  lifecycle: config.lifecycle,
@@ -2683,6 +2756,7 @@ export {
2683
2756
  validateMcpSchemas,
2684
2757
  summarizeTransports,
2685
2758
  resolveMedia,
2759
+ readMcpCatalogStamp,
2686
2760
  protectedResourceMetadataUrl,
2687
2761
  oauthProtectedResourceRoute,
2688
2762
  mountWait,
@@ -2693,9 +2767,11 @@ export {
2693
2767
  mountMcp,
2694
2768
  mountDownload,
2695
2769
  mountAgent,
2770
+ mcpCatalogMeta,
2696
2771
  listToolNames,
2697
2772
  listContractToolNames,
2698
2773
  isToolExecutionControlError,
2774
+ isAgentToolError,
2699
2775
  inlineMcpAppBundle,
2700
2776
  flattenToolJsonSchema,
2701
2777
  findUntypedProperties,
@@ -2718,6 +2794,7 @@ export {
2718
2794
  createRuntimeToolFactory,
2719
2795
  createMcpHttpRoute,
2720
2796
  createMcpHandler,
2797
+ createLocalStepDurability,
2721
2798
  createCli,
2722
2799
  createAsyncOperationSnapshotSchema,
2723
2800
  composeToolLifecycle,
@@ -2737,6 +2814,8 @@ export {
2737
2814
  PORTABLE_JSON_SCHEMA_FORMATS,
2738
2815
  McpMediaContentSchema,
2739
2816
  McpAnnotationsSchema,
2817
+ MCP_CATALOG_META_KEY,
2740
2818
  EXT_APPS_BUNDLE_PLACEHOLDER,
2741
- AsyncOperationCancelResultSchema
2819
+ AsyncOperationCancelResultSchema,
2820
+ AgentToolError
2742
2821
  };
package/dist/tracking.js CHANGED
@@ -3,11 +3,11 @@ import {
3
3
  } from "./index-3xwxfj4z.js";
4
4
  import {
5
5
  defineContract
6
- } from "./index-nrqg8tks.js";
6
+ } from "./index-0a349c33.js";
7
7
  import"./index-rxfy4cq7.js";
8
8
  import"./index-6k1937bx.js";
9
9
  import"./index-zcgf3gqf.js";
10
- import"./index-scs3f1eg.js";
10
+ import"./index-2vtcmrj9.js";
11
11
  import"./index-kzfs85xp.js";
12
12
  import"./index-77fekveh.js";
13
13
 
package/llms-full.txt CHANGED
@@ -66,11 +66,11 @@ own, recorded as an ADR.
66
66
  | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
67
67
  | `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
68
68
  | `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
69
- | `stitchkit/observability` | server | stable<br>_redefined in 1 of the 35 minors since 0.56.2, most recently 0.83.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
69
+ | `stitchkit/observability` | server | stable<br>_redefined in 1 of the 36 minors since 0.56.2, most recently 0.83.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
70
70
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the store and managed-resource conformance kits |
71
71
  | `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
72
72
  | `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
73
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 20 of the 35 minors since 0.56.2, most recently 0.89.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
73
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 20 of the 36 minors since 0.56.2, most recently 0.89.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
74
74
  | `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
75
75
  | `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults and deterministic race controls |
76
76
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
@@ -80,7 +80,7 @@ own, recorded as an ADR.
80
80
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
81
81
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
82
82
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
83
- | `stitchkit/application` | browser + server | evolving<br>_redefined in 7 of the 35 minors since 0.56.2, most recently 0.83.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
83
+ | `stitchkit/application` | browser + server | evolving<br>_redefined in 7 of the 36 minors since 0.56.2, most recently 0.83.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
84
84
  | `stitchkit/application/grammy` | server | evolving | isolated grammY polling and webhook lifecycle adapters |
85
85
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
86
86
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
@@ -1252,6 +1252,43 @@ scope** — the same prefix under two different group scopes is legal (their URL
1252
1252
  are separated by `scopePrefixes`, and a genuine path clash is the router's own
1253
1253
  construction error).
1254
1254
 
1255
+ #### A dev stand that survives half a feature — `onMissingHandler`
1256
+
1257
+ Adding an endpoint is two edits by construction: the contract, then the handler.
1258
+ An editor saves one file at a time and a watcher restarts on the first save, so
1259
+ between the two the application refuses to start — and on a shared dev stand that
1260
+ is everyone's stand, not just yours. A project rule ("make both edits at once")
1261
+ cannot outrun the filesystem.
1262
+
1263
+ ```ts
1264
+ implementRegistry(apiContractRegistry, handlers, {
1265
+ onMissingHandler: process.env.NODE_ENV === 'production' ? 'throw' : 'stub',
1266
+ })
1267
+ ```
1268
+
1269
+ With `'stub'` the application starts, the unimplemented endpoint answers `501` and
1270
+ names itself in the log at every mount, so it cannot be forgotten. Both save
1271
+ orders are covered: a contract ahead of its handler, and a handler ahead of its
1272
+ contract. The option is accepted by `implement`, `implementRegistry`,
1273
+ `createImplement`, `createScopedImplement`, `createImplementRegistry` and
1274
+ `createScopedImplementRegistry`.
1275
+
1276
+ Three things it deliberately does not do. It stays `'throw'` by default, and
1277
+ `'throw'` is the only right answer in production: a release is atomic, so there is
1278
+ no window to protect, and a surface with a hole must not ship. It does not relax
1279
+ the **compile-time** exactness check — that lives in the generic constraint, your
1280
+ editor still shows the missing handler, and a watcher that only transpiles is
1281
+ precisely why the runtime refusal was the one that bit. And it does not stub a
1282
+ streaming multipart endpoint, which needs `defineMultipartStream()` receivers; the
1283
+ stand still stops on one of those.
1284
+
1285
+ The stub answers `NOT_IMPLEMENTED` with status `501` everywhere — over HTTP and on
1286
+ a tool call that crosses a process boundary alike, because the code is registered
1287
+ in `STITCH_ERROR_STATUS`. One consequence follows from that and is worth knowing:
1288
+ a stubbed endpoint keeps its `expose`, so on a dev stand it is advertised to a
1289
+ model as a real tool that always refuses. The refusal is not retryable, so a model
1290
+ stops rather than loops; it still costs one turn to find out.
1291
+
1255
1292
  The registry is intentionally flat: every key must point to one concrete
1256
1293
  `defineContract()` result. Composed namespace arrays are mounted explicitly with
1257
1294
  `implement()` because they do not have a one-key-to-one-handler-map boundary.
@@ -3446,6 +3483,138 @@ finite `surfaces` registry when identities share a provably immutable surface.
3446
3483
  Stitchkit never advertises list-change or subscription capabilities
3447
3484
  without an implementation.
3448
3485
 
3486
+ ### Knowing your catalog is stale — the catalog stamp
3487
+
3488
+ A client lists the tools once and keeps that list. If the server's contract
3489
+ moves afterwards, the client's copy is wrong and *nothing tells it so*: calls
3490
+ are refused, and from inside the client a refusal looks like a broken server.
3491
+ This is the most expensive shape a contract disagreement takes, because the
3492
+ party able to fix it is the one with no evidence.
3493
+
3494
+ Every advertised tool and every tool result therefore carries the fingerprint of
3495
+ the catalog it came from:
3496
+
3497
+ ```ts
3498
+ import { readMcpCatalogStamp } from 'stitchkit/tools';
3499
+
3500
+ const listed = await client.listTools();
3501
+ let known = readMcpCatalogStamp(listed.tools[0]?._meta); // { digest, tools }
3502
+
3503
+ const result = await client.callTool({ name: 'record_journal', arguments });
3504
+ const live = readMcpCatalogStamp(result._meta);
3505
+ if (live && known && live.digest !== known.digest) {
3506
+ // The catalog moved under us. Re-list before blaming the result.
3507
+ known = readMcpCatalogStamp((await client.listTools()).tools[0]?._meta);
3508
+ }
3509
+ ```
3510
+
3511
+ The comparison costs nothing and runs on a response you were already receiving —
3512
+ including a refusal, which is exactly when a stale catalog is the likeliest
3513
+ explanation. `notifications/tools/list_changed` is the push-shaped answer to the
3514
+ same problem and `createMcpHandler` cannot send it: it is stateless by
3515
+ construction, so there is no retained session to push down.
3516
+
3517
+ The digest covers each advertised tool's name, description, input schema, output
3518
+ schema and annotations, and nothing else — it moves when the contract moves and
3519
+ not when a handler does. Raw SDK registrations stay consumer-owned and are
3520
+ outside it.
3521
+
3522
+ ### Shaping arguments before validation — `beforeToolCall`
3523
+
3524
+ `beforeToolCall` sees the arguments as they arrived and may **replace** them:
3525
+
3526
+ ```ts
3527
+ hooks: {
3528
+ beforeToolCall: async ({ args }) =>
3529
+ typeof args.id === 'string' && args.id.startsWith('@')
3530
+ ? { ...args, id: await resolveReference(args.id) }
3531
+ : undefined, // undefined / null / nothing → unchanged
3532
+ }
3533
+ ```
3534
+
3535
+ The replacement is validated by the contract schema like any other input, so this
3536
+ shapes a call and never bypasses the schema — a hook returning something the
3537
+ contract refuses produces an ordinary `VALIDATION_ERROR`. It runs before the
3538
+ params/input split, before argument coercion and before both schemas, which is
3539
+ why a field declared `z.string()` can receive a reference and still be a string by
3540
+ the time it is checked. `lifecycle.beforeHandle` is too late for this: the schemas
3541
+ have already answered.
3542
+
3543
+ The audit trail is unaffected. `afterToolCall` reports what the caller sent as
3544
+ `args` and the replacement separately as `effectiveArgs`, so a rewrite is visible
3545
+ as a rewrite rather than by overwriting the evidence.
3546
+
3547
+ ### Knowing the outcome of a call — `afterToolCall` and `toolCallId`
3548
+
3549
+ `afterToolCall` receives the whole `ToolResult` — `{ok: true, data}` or
3550
+ `{ok: false, code, …}` — on every exit, including a validation failure that never
3551
+ reached the handler, and on all three transports. It is the answer to "did this
3552
+ call fail"; deriving that from the serialized error text instead is the mistake
3553
+ this section exists to prevent, and it is a mistake that does not announce itself:
3554
+ one consumer had five places asking the question and getting five answers.
3555
+
3556
+ To correlate an outcome with the call the model made, read `context.toolCallId`:
3557
+
3558
+ ```ts
3559
+ hooks: {
3560
+ afterToolCall: ({ context, result }) => {
3561
+ loop.record(context.toolCallId, result.ok ? 'ok' : result.code);
3562
+ },
3563
+ }
3564
+ ```
3565
+
3566
+ Two exits are deliberately **not** reported: a runtime tool presenter that throws
3567
+ on the agent surface (the AI SDK calls it after `execute` has already returned)
3568
+ and `ToolExecutionControlError`, which is a run being stopped rather than a tool
3569
+ failing — check it with `isToolExecutionControlError`.
3570
+
3571
+ ### Restartable tool bodies — `durability`
3572
+
3573
+ A tool that starts slow work and must report exactly once, including after a host
3574
+ restart, can keep that bookkeeping **inside** the tool:
3575
+
3576
+ ```ts
3577
+ mountAgent(services, {
3578
+ durability: (toolCallId, signal) => myLedgerFor(toolCallId, signal),
3579
+ });
3580
+
3581
+ // in the handler
3582
+ const asset = await ctx.step('render', () => renderAsset(ctx.input));
3583
+ await ctx.waitFor({ event: 'render-done', id: assetId });
3584
+ ```
3585
+
3586
+ `ToolDurability` is the port a tool body sees — `step`, `sleep`, `waitFor`. You
3587
+ do not have to implement it: the engine ships from `stitchkit/tools`, and it needs
3588
+ a ledger of two methods over storage you already have:
3589
+
3590
+ ```ts
3591
+ import { createLocalStepDurability, type StepDurabilityLedger } from 'stitchkit/tools';
3592
+
3593
+ const ledger: StepDurabilityLedger = {
3594
+ appendEvent: (event) => db.durabilityEvents.insert(event), // returns the row with `seq`
3595
+ readEvents: ({ conversationId, fromSeq, toSeq }) => db.durabilityEvents.page(…),
3596
+ };
3597
+
3598
+ mountAgent(services, {
3599
+ durability: (toolCallId, signal) =>
3600
+ createLocalStepDurability({ store: ledger, conversationId, runId: toolCallId, signal }),
3601
+ });
3602
+ ```
3603
+
3604
+ That gives replay, absolute deadlines and decode refusal without writing them.
3605
+ The port is there for an application that already has its own ledger engine.
3606
+
3607
+ One thing the port deliberately leaves out: **delivery**. `waitFor` parks until
3608
+ `{ event, id }` arrives, and a tool body has no business delivering to itself —
3609
+ so `deliver` lives on the engine object you constructed, not in the handler
3610
+ context. Whatever produces the event (a webhook, a queue consumer, a cron) calls
3611
+ `engine.deliver({ event, id, payload })` against the same ledger; the parked
3612
+ `waitFor` resolves, and a `waitFor` that starts after the delivery returns at
3613
+ once. Taking `agent-runtime` is not required and nothing of the runtime proper
3614
+ enters the `stitchkit/tools` graph. An application that *does* run on
3615
+ `agent-runtime` gets durability through the run context and needs no option;
3616
+ where both exist the runtime's wins, because the run is recorded in its ledger.
3617
+
3449
3618
  ### Typed MCP call metadata
3450
3619
 
3451
3620
  Contract handlers, runtime-tool handlers/factories, lifecycle and tool hooks all
@@ -5475,7 +5644,7 @@ or it keeps the failed attempt's partial text on screen.
5475
5644
  usage, files and sources do not count;
5476
5645
  - `tool-status` is transient lifecycle presentation with JSON-safe input on
5477
5646
  start and output on completion. A mounted typed failure carries the same safe
5478
- `{ error, details?, _hint? }` envelope as the durable result; an unknown
5647
+ `{ error, retryable, details?, _hint? }` envelope as the durable result; an unknown
5479
5648
  internal cause remains generic and stays in local observability only;
5480
5649
  - `terminal` follows the winning terminal CAS.
5481
5650
 
@@ -9867,7 +10036,8 @@ core never models them (ADR 0002). But stitchkit itself emits a set of its own:
9867
10036
  `CONFLICT`, `RATE_LIMITED`, `VALIDATION_ERROR`, `FILE_INVALID_PATH`,
9868
10037
  `FILE_OUTSIDE_ROOT`, `FILE_NOT_FOUND`, `FILE_NOT_REGULAR`,
9869
10038
  `FILE_INSPECTION_REJECTED`, `FILE_TOO_LARGE`, `FILE_EXISTS`,
9870
- `REALTIME_CONTRACT_VIOLATION`, `APPLICATION_NOT_ACCEPTING`, `WAIT_TIMEOUT`,
10039
+ `REALTIME_CONTRACT_VIOLATION`, `APPLICATION_NOT_ACCEPTING`, `NOT_IMPLEMENTED`,
10040
+ `WAIT_TIMEOUT`,
9871
10041
  `WAIT_FAILED`, `DOWNLOAD_NOT_FOUND`, `VIEW_HTTP_ERROR`,
9872
10042
  `OPERATION_NOT_SUCCEEDED`, `STREAM_ITEM_INVALID`, `STREAM_FRAME_TOO_LARGE`,
9873
10043
  `STREAM_TERMINAL_MISSING`, `STREAM_LIFETIME_EXCEEDED`,
@@ -9995,7 +10165,7 @@ whether the code declares `details`:
9995
10165
  | code **with** a `details` schema | the declared message | no text at all — only `details` and `hint` |
9996
10166
  | code **without** one | the declared message | the same text, delivered as `details.message` |
9997
10167
 
9998
- The model-facing envelope is `{ error, details?, _hint? }`; for a code with no
10168
+ The model-facing envelope is `{ error, retryable, details?, _hint? }`; for a code with no
9999
10169
  details schema the framework fills `details` with `{ message }`, so declaring a
10000
10170
  message changes what the model reads there — it used to be the code itself. Put
10001
10171
  anything the model must reliably read in `details` or `hint`, not in `message`.
@@ -10967,6 +11137,24 @@ applied. Active trace, span, user and dimensions are reserved framework fields;
10967
11137
  caller data cannot overwrite them. A throwing getter, cyclic value or failing
10968
11138
  sink never escapes into application code.
10969
11139
 
11140
+ One string bound for the whole record cuts an error's stack from the bottom —
11141
+ which is exactly where your own frames are. Raise the ceiling for that key alone,
11142
+ wherever it sits (`stack` at the top of one record and under `errorDetail` in the
11143
+ next is the same field), without touching the bound for everything else:
11144
+
11145
+ ```ts
11146
+ createBoundedLogger({
11147
+ sink,
11148
+ bounds: { stringLength: 4_000, stringLengthByKey: { stack: 32_000 } },
11149
+ })
11150
+ // or directly: redact(value, { maxStringLength: 4_000, maxStringLengthByKey: { stack: 32_000 } })
11151
+ ```
11152
+
11153
+ Keyed by name, not by path — the question is "what is this field", not "where is
11154
+ it". The entry ceiling (`entryBytes`) keeps the last word: a record that does not
11155
+ fit still collapses, but the decision is made for the record, not by cutting one
11156
+ field short in advance.
11157
+
10970
11158
 
10971
11159
  ==============================================================================
10972
11160
  # Guide: Testing & deployment (docs/guide/testing-and-deployment.md)
@@ -11935,6 +12123,23 @@ analytics policy belong to the application.
11935
12123
 
11936
12124
  # Upgrading stitchkit
11937
12125
 
12126
+ ## Released migration: 0.91.0
12127
+
12128
+ 1. `STITCH_ERROR_STATUS` gained `NOT_IMPLEMENTED` (501). An **exhaustive** error
12129
+ vocabulary must name it — without the row `defineErrors({ mapping: { exhaustive:
12130
+ true } })` stops compiling and then throws at startup. Nothing else in this
12131
+ release requires action.
12132
+
12133
+ ```ts
12134
+ // before
12135
+ map: { …, WAIT_TIMEOUT: 'timeout' }
12136
+ // after
12137
+ map: { …, NOT_IMPLEMENTED: 'not_implemented', WAIT_TIMEOUT: 'timeout' }
12138
+ ```
12139
+
12140
+ The code is thrown only by the new dev-only `onMissingHandler: 'stub'` policy;
12141
+ a vocabulary that is not exhaustive is unaffected.
12142
+
11938
12143
  ## Released migration: 0.90.0
11939
12144
 
11940
12145
  1. `stitchkit.watch.value` is a discriminated union on `kind` — `full`, `delta`,
@@ -15666,6 +15871,7 @@ from the root `stitchkit`.
15666
15871
  | `STITCH_ERROR_STATUS` | const | `code → HTTP status` map for stitchkit's own error codes — [guide](../guide/auth-and-errors.md#stitch-codes-vs-your-codes) |
15667
15872
  | `StitchErrorCode` | _type_ | a code stitchkit itself emits (`keyof STITCH_ERROR_STATUS`) |
15668
15873
  | `isStitchErrorCode` | function | type guard — is a code one of stitchkit's own? |
15874
+ | `isRetryableStatus` | function | whether a status class means "the same call could work later" — `408`, `429`, `502`, `503`, `504`, and deliberately not every `5xx` |
15669
15875
 
15670
15876
  ### Pagination
15671
15877
 
@@ -15777,6 +15983,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
15777
15983
  | `implementRegistry` | function | bind one exact contract registry to one exact backend handler registry |
15778
15984
  | `createImplementRegistry` | function | context-typed factory for `implementRegistry` |
15779
15985
  | `ImplementationRegistry` | _type_ | flat literal registry of concrete contracts accepted by `implementRegistry` |
15986
+ | `ImplementOptions` | _type_ | `{ onMissingHandler }` — how a binding treats a contract endpoint with no handler |
15987
+ | `MissingHandlerPolicy` | _type_ | `'throw'` (default) or `'stub'`, the dev-stand policy that mounts a `501` instead of refusing to start |
15780
15988
  | `KeyedServices` | _type_ | registry result: the mount-ordered `ServiceDef[]` carrying the same services under `.byKey` |
15781
15989
  | `RegistryHandlers` | _type_ | exact backend handler registry inferred from a contract registry |
15782
15990
  | `ExactRegistryHandlers` | _type_ | fail-first handler shape that rejects extra registry and endpoint keys |
@@ -16730,7 +16938,7 @@ audit event. See the [Observability guide](../guide/observability.md).
16730
16938
  | `DimensionCollision` / `SetRequestDimensionsOptions` | _type_ | explicit overwrite, preserve or error policy for dimension keys |
16731
16939
  | `createBoundedLogger` | function | decorate a `StitchLogger` with request context, shared sanitisation, redaction and total bounds |
16732
16940
  | `DEFAULT_REDACT_PATHS` | constant | baseline credential/token paths added to the sanitizer's sensitive-key policy |
16733
- | `BoundedLoggerBounds` / `BoundedLoggerOptions` | _type_ | per-value and total record ceilings plus sink/redaction configuration |
16941
+ | `BoundedLoggerBounds` / `BoundedLoggerOptions` | _type_ | per-value and total record ceilings (`stringLengthByKey` raises the string bound for named keys such as `stack`) plus sink/redaction configuration |
16734
16942
  | `RequestEvent` | _type_ | the normalised audit event handed to the sink; opt-in HTTP cancellation rows carry `outcome: 'cancelled'` |
16735
16943
  | `ObservabilityConfig` | _type_ | independent request and tool sink configuration |
16736
16944
  | `Observability` | _type_ | `{ request?, toolCall, getStatus(), flush(bound?): Promise<boolean>, close(bound?) }` with bounded sink lifecycle |
@@ -16784,7 +16992,7 @@ audit event. See the [Observability guide](../guide/observability.md).
16784
16992
  | `truncatePreview` | function | cap a value by serialised size |
16785
16993
  | `measureSize` | function | item count + byte size of a result |
16786
16994
  | `JsonValue` | _type_ | a JSON-serialisable value |
16787
- | `SanitizeOptions` | _type_ | tuning for `redact` / `sanitizePayload` |
16995
+ | `SanitizeOptions` | _type_ | tuning for `redact` / `sanitizePayload` — `maxStringLengthByKey` bounds a named key wherever it sits without changing the record-wide bound |
16788
16996
  | `SizeMeasure` | _type_ | the result of `measureSize` |
16789
16997
 
16790
16998
  ---
@@ -17008,6 +17216,48 @@ A native remote-connector auth surface for MCP — [guide](../guide/mcp-and-agen
17008
17216
  | `RefreshData` | _type_ | a stored refresh-token record |
17009
17217
  | `RegisteredClient` | _type_ | a registered OAuth client |
17010
17218
 
17219
+ ### Durable tool bodies
17220
+
17221
+ A mounted tool body can be made restartable without adopting `agent-runtime`: declare a
17222
+ factory and `step` / `sleep` / `waitFor` appear in the handler context, backed by whatever
17223
+ ledger the application already has.
17224
+
17225
+ ```ts
17226
+ mountAgent(services, { durability: (toolCallId) => myLedgerFor(toolCallId) });
17227
+ ```
17228
+
17229
+ | Export | Kind | Summary |
17230
+ |--------|------|---------|
17231
+ | `ToolDurability` | _type_ | the port a tool body needs: `step`, `sleep`, `waitFor` |
17232
+ | `ToolDurabilityFactory` | _type_ | `(toolCallId, signal) => ToolDurability`, called once per call |
17233
+ | `DurableJsonValue` | _type_ | what a durable step result may hold — a record is read back in another process, so it must be JSON |
17234
+ | `createLocalStepDurability` | function | the durability engine — replay, absolute deadlines, park/deliver — over a two-method ledger the application owns |
17235
+ | `StepDurabilityLedger` | _type_ | the two methods an engine needs: `appendEvent`, `readEvents` |
17236
+ | `LocalStepDurability` | _type_ | what the engine returns; satisfies `ToolDurability` and adds `deliver` and record introspection for the ledger's owner |
17237
+ | `LocalStepDurabilityOptions` | _type_ | `{ store, conversationId, runId, signal?, subscribe?, clock? }` |
17238
+ | `AppendAgentStoreEvent` / `ReadAgentStoreEvents` / `AgentStoreEventEnvelope` / `AgentStoreEventPage` | _type_ | the ledger's row shapes, re-exported so a ledger is typed from the entrypoint that consumes it |
17239
+ | `AgentToolError` | class | the model-safe envelope a failed agent tool throws |
17240
+ | `isAgentToolError` | function | brand check for it — a consumer running its own loop no longer parses the message |
17241
+
17242
+ ### Catalog stamp
17243
+
17244
+ Every advertised tool and every tool result carries the fingerprint of the catalog it came
17245
+ from, so a long-lived consumer can see that its own copy is stale **without making a
17246
+ request for it**. `createMcpHandler` is stateless by construction and therefore has no
17247
+ retained session to push `notifications/tools/list_changed` down; the stamp needs none.
17248
+
17249
+ Store the stamp beside the tools when you list them, compare it against the one on every
17250
+ result, and re-list when they differ — including on a refusal, which is exactly when a
17251
+ stale catalog is the likeliest explanation and the least visible one. The server side
17252
+ needs no wiring: every mount stamps what it advertises.
17253
+
17254
+ | Export | Kind | Summary |
17255
+ |--------|------|---------|
17256
+ | `readMcpCatalogStamp` | function | read a peer's stamp out of `_meta`, or `null` when there is none |
17257
+ | `mcpCatalogMeta` | function | the `_meta` fragment a stamped tool or result carries |
17258
+ | `MCP_CATALOG_META_KEY` | const | the namespaced `_meta` key (`stitchkit/catalog`) |
17259
+ | `McpCatalogStamp` | _type_ | `{ digest, tools }` — the catalog fingerprint and its tool count |
17260
+
17011
17261
  ### MCP Apps (widgets)
17012
17262
 
17013
17263
  Interactive MCP resources — [ADR 0019](../decisions/0019-generic-native-tools.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.90.7",
3
+ "version": "0.91.0",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",
@@ -211,7 +211,7 @@
211
211
  "build:js": "bun run build:entrypoints && bun scripts/preserve-webpack-ignore.mjs",
212
212
  "build:types": "bun x tsc -p tsconfig.build.json --emitDeclarationOnly && bun scripts/rewrite-declaration-specifiers.mjs",
213
213
  "build": "bun ../../scripts/package-build-lock.ts -- bun run build:unlocked",
214
- "build:unlocked": "rm -rf dist && cp ../../CHANGELOG.md ./CHANGELOG.md && bun run build:js && chmod +x dist/upgrade-cli.js && bun run build:types && bun scripts/check-browser-clean.mjs && bun scripts/check-browser-executes.mjs && bun scripts/check-env-live.mjs && bun scripts/check-public-types.mjs && bun scripts/check-declarations-strict.mjs",
214
+ "build:unlocked": "rm -rf dist && cp ../../CHANGELOG.md ./CHANGELOG.md && bun run build:js && chmod +x dist/upgrade-cli.js && bun run build:types && bun scripts/check-browser-clean.mjs && bun scripts/check-browser-executes.mjs && bun scripts/check-env-live.mjs && bun scripts/check-public-types.mjs && bun scripts/check-durability-port-standalone.mjs && bun scripts/check-declarations-strict.mjs",
215
215
  "dev": "bun run build:entrypoints -- --watch",
216
216
  "prepack": "cp ../../README.md ./README.md && bun ../../scripts/gen-llms.ts && bun run build",
217
217
  "test": "bun test",