stitchkit 0.24.0 → 0.26.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 (53) hide show
  1. package/dist/browser/client-multipart.d.ts +22 -0
  2. package/dist/browser/client-multipart.d.ts.map +1 -0
  3. package/dist/browser/client.d.ts.map +1 -1
  4. package/dist/cli.js +2 -2
  5. package/dist/contract/define.d.ts +20 -0
  6. package/dist/contract/define.d.ts.map +1 -1
  7. package/dist/contract/factory.d.ts +1 -0
  8. package/dist/contract/factory.d.ts.map +1 -1
  9. package/dist/contract/index.js +1 -1
  10. package/dist/{index-4gawbm74.js → index-0d0rb85d.js} +41 -3
  11. package/dist/{index-g8kyab85.js → index-bkccbx64.js} +10 -3
  12. package/dist/{index-x62gnfsk.js → index-h4y2wg3n.js} +1 -1
  13. package/dist/{index-a9n8m4ec.js → index-p6fge9a5.js} +1 -1
  14. package/dist/{index-x8m8e7dc.js → index-q5w3cvvp.js} +176 -87
  15. package/dist/index.js +20 -18
  16. package/dist/internal/errors.d.ts +6 -1
  17. package/dist/internal/errors.d.ts.map +1 -1
  18. package/dist/internal/write-download.d.ts +17 -0
  19. package/dist/internal/write-download.d.ts.map +1 -0
  20. package/dist/node.js +2 -2
  21. package/dist/server/create.d.ts.map +1 -1
  22. package/dist/server/error-hook.d.ts +14 -3
  23. package/dist/server/error-hook.d.ts.map +1 -1
  24. package/dist/server/implement.d.ts.map +1 -1
  25. package/dist/server/index.js +6 -6
  26. package/dist/server/types.d.ts +13 -0
  27. package/dist/server/types.d.ts.map +1 -1
  28. package/dist/tools/agent.d.ts +2 -0
  29. package/dist/tools/agent.d.ts.map +1 -1
  30. package/dist/tools/cli.d.ts.map +1 -1
  31. package/dist/tools/coerce.d.ts +2 -1
  32. package/dist/tools/coerce.d.ts.map +1 -1
  33. package/dist/tools/execute.d.ts +1 -1
  34. package/dist/tools/execute.d.ts.map +1 -1
  35. package/dist/tools/flatten.d.ts +16 -6
  36. package/dist/tools/flatten.d.ts.map +1 -1
  37. package/dist/tools/list-names.d.ts.map +1 -1
  38. package/dist/tools/mcp.d.ts +10 -3
  39. package/dist/tools/mcp.d.ts.map +1 -1
  40. package/dist/tools/mount-download.d.ts.map +1 -1
  41. package/dist/tools/mount-upload.d.ts.map +1 -1
  42. package/dist/tools/mount-wait.d.ts.map +1 -1
  43. package/dist/tools/mount.d.ts +13 -0
  44. package/dist/tools/mount.d.ts.map +1 -1
  45. package/dist/tools/names.d.ts +34 -0
  46. package/dist/tools/names.d.ts.map +1 -1
  47. package/dist/tools/remote.d.ts.map +1 -1
  48. package/dist/tools/schema.d.ts +43 -1
  49. package/dist/tools/schema.d.ts.map +1 -1
  50. package/dist/tools/transports.d.ts.map +1 -1
  51. package/dist/tools.js +48 -40
  52. package/llms-full.txt +113 -11
  53. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -2,11 +2,13 @@ import {
2
2
  inputIsQuery,
3
3
  signJwt,
4
4
  verifyPkce
5
- } from "./index-a9n8m4ec.js";
5
+ } from "./index-p6fge9a5.js";
6
6
  import {
7
7
  DEFAULT_CORS_ALLOW_HEADERS
8
8
  } from "./index-tje0q6gp.js";
9
9
  import {
10
+ assertToolName,
11
+ assertUniqueToolName,
10
12
  coerceJsonArgs,
11
13
  collectTools,
12
14
  createCli,
@@ -17,15 +19,17 @@ import {
17
19
  formatToolError,
18
20
  pollUntil,
19
21
  readCapped,
20
- toolResultFromError
21
- } from "./index-x8m8e7dc.js";
22
+ toolResultFromError,
23
+ writeDownload
24
+ } from "./index-q5w3cvvp.js";
22
25
  import {
23
26
  toJsonSchema
24
27
  } from "./index-0ed3bx43.js";
25
28
  import {
26
29
  AppError,
27
- isWithinDir
28
- } from "./index-4gawbm74.js";
30
+ isWithinDir,
31
+ mergeMeta
32
+ } from "./index-0d0rb85d.js";
29
33
  import"./index-khwedj16.js";
30
34
  import {
31
35
  isRecord,
@@ -44,16 +48,15 @@ function mountAgent(services, config = {}) {
44
48
  hooks: config.hooks,
45
49
  lifecycle: config.lifecycle,
46
50
  errorHint: config.errorHint,
47
- coerceJsonArgs: config.coerceJsonArgs
51
+ coerceJsonArgs: config.coerceJsonArgs,
52
+ onOutputStrip: config.onOutputStrip
48
53
  });
49
54
  for (const service of serviceList) {
50
55
  for (const mountable of collectTools(service, "AGENT", {
51
56
  extend: config.extend,
52
57
  flattenUnionInput: config.flattenUnionInput
53
58
  })) {
54
- if (mountable.name in tools) {
55
- throw new Error(`Duplicate agent tool name "${mountable.name}" across mounted services`);
56
- }
59
+ assertUniqueToolName(mountable.name, Object.hasOwn(tools, mountable.name), "agent tool name");
57
60
  tools[mountable.name] = tool({
58
61
  description: mountable.method.desc,
59
62
  inputSchema: zodSchema(mountable.schema),
@@ -79,7 +82,7 @@ function listToolNames(services) {
79
82
  const entries = new Map;
80
83
  for (const service of services) {
81
84
  for (const transport of TOOL_TRANSPORTS) {
82
- for (const tool2 of collectTools(service, transport)) {
85
+ for (const tool2 of collectTools(service, transport, { assertNames: false })) {
83
86
  const id = `${service.name}\x00${tool2.method.key}`;
84
87
  const existing = entries.get(id);
85
88
  if (existing) {
@@ -188,9 +191,7 @@ function throwIfFailures(failures) {
188
191
  }
189
192
  }
190
193
  function prepareMcpTool(mountable, policy, logger, failures, seen) {
191
- if (seen.has(mountable.name)) {
192
- throw new Error(`Duplicate MCP tool name "${mountable.name}" across mounted services`);
193
- }
194
+ assertUniqueToolName(mountable.name, seen.has(mountable.name), "MCP tool name");
194
195
  seen.add(mountable.name);
195
196
  if (!(mountable.schema instanceof z.ZodObject)) {
196
197
  reportIncompatible(`MCP tool "${mountable.name}" — input must be an object schema; a union, discriminated union or scalar cannot be an MCP tool input (flatten it in the contract, or drop MCP from \`expose\`)`, policy, logger, failures);
@@ -231,7 +232,8 @@ function mountMcp(mcpServer, services, config = {}) {
231
232
  hooks: config.hooks,
232
233
  lifecycle: config.lifecycle,
233
234
  errorHint: config.errorHint,
234
- coerceJsonArgs: config.coerceJsonArgs
235
+ coerceJsonArgs: config.coerceJsonArgs,
236
+ onOutputStrip: config.onOutputStrip
235
237
  });
236
238
  const seen = new Set;
237
239
  const failures = [];
@@ -283,7 +285,7 @@ function buildMcpServer(config, auth) {
283
285
  flattenUnionInput: config.flattenUnionInput,
284
286
  errorHint: config.errorHint
285
287
  });
286
- config.nativeTools?.(server);
288
+ config.nativeTools?.(server, auth);
287
289
  for (const resource of config.resources ?? []) {
288
290
  mountMcpResource(server, resource);
289
291
  }
@@ -490,7 +492,6 @@ async function createStdioMcpServer(config) {
490
492
  return server;
491
493
  }
492
494
  // src/tools/mount-download.ts
493
- import { mkdir, writeFile } from "node:fs/promises";
494
495
  import { basename, extname, join } from "node:path";
495
496
 
496
497
  // src/tools/native-result.ts
@@ -531,7 +532,9 @@ function baseNameFor(url) {
531
532
  }
532
533
  }
533
534
  function mountDownload(server, config) {
534
- server.registerTool(config.name ?? "download", { description: config.description, inputSchema: config.inputSchema }, async (rawArgs) => {
535
+ const name = config.name ?? "download";
536
+ assertToolName(name, "<native>", "download");
537
+ server.registerTool(name, { description: config.description, inputSchema: config.inputSchema }, async (rawArgs) => {
535
538
  const args = isRecord(rawArgs) ? rawArgs : {};
536
539
  try {
537
540
  const url = await config.resolveUrl(args);
@@ -551,9 +554,8 @@ function mountDownload(server, config) {
551
554
  if (!buffer)
552
555
  return textResult(`Download failed: file exceeds the ${max}-byte cap`, true);
553
556
  const dir = config.dirFromArgs?.(args) ?? config.defaultDir;
554
- await mkdir(dir, { recursive: true });
555
557
  const filePath = join(dir, `${baseNameFor(url)}${extensionFor(url, mimeType)}`);
556
- await writeFile(filePath, buffer);
558
+ await writeDownload(dir, filePath, buffer);
557
559
  return textResult(JSON.stringify({ path: filePath, size: buffer.length, mimeType }, null, 2));
558
560
  } catch (err) {
559
561
  return textResult(`Download failed: ${err instanceof Error ? err.message : String(err)}`, true);
@@ -563,7 +565,9 @@ function mountDownload(server, config) {
563
565
  // src/tools/mount-upload.ts
564
566
  import { z as z2 } from "zod";
565
567
  function mountUpload(server, config) {
566
- server.registerTool(config.name ?? "upload", {
568
+ const name = config.name ?? "upload";
569
+ assertToolName(name, "<native>", "upload");
570
+ server.registerTool(name, {
567
571
  description: config.description,
568
572
  inputSchema: { path: z2.string().describe("Path to a local file on this machine") }
569
573
  }, async (rawArgs) => {
@@ -581,7 +585,9 @@ function mountUpload(server, config) {
581
585
  }
582
586
  // src/tools/mount-wait.ts
583
587
  function mountWait(server, config) {
584
- server.registerTool(config.name ?? "wait", { description: config.description, inputSchema: config.inputSchema }, async (rawArgs) => {
588
+ const name = config.name ?? "wait";
589
+ assertToolName(name, "<native>", "wait");
590
+ server.registerTool(name, { description: config.description, inputSchema: config.inputSchema }, async (rawArgs) => {
585
591
  const args = isRecord(rawArgs) ? rawArgs : {};
586
592
  try {
587
593
  const { state, timedOut } = await pollUntil({
@@ -870,6 +876,25 @@ function mountOAuthProvider(config) {
870
876
  };
871
877
  return [metadataRoute, registerRoute, authorizeRoute, tokenRoute];
872
878
  }
879
+ // src/browser/client-multipart.ts
880
+ function isFileDescriptor(value) {
881
+ return typeof value === "object" && value !== null && !(value instanceof Blob) && "uri" in value && typeof value.uri === "string" && "name" in value && typeof value.name === "string" && "type" in value && typeof value.type === "string";
882
+ }
883
+ function isMultipartFile(value) {
884
+ return value instanceof Blob || isFileDescriptor(value);
885
+ }
886
+ function appendMultipartFile(form, field, file) {
887
+ const sink = form;
888
+ sink.append(field, file);
889
+ }
890
+ function appendFormFields(formData, values, skipKeys) {
891
+ for (const [key, value] of Object.entries(values)) {
892
+ if (skipKeys.has(key) || value === undefined || value === null)
893
+ continue;
894
+ formData.append(key, typeof value === "string" ? value : JSON.stringify(value));
895
+ }
896
+ }
897
+
873
898
  // src/browser/http.ts
874
899
  import ky, { isHTTPError } from "ky";
875
900
  class ApiError extends Error {
@@ -1079,23 +1104,6 @@ function createFetchMethod(endpoint, prefix, config, contractConfig) {
1079
1104
  return endpoint.output ? endpoint.output.parse(json2) : json2;
1080
1105
  };
1081
1106
  }
1082
- function isFileDescriptor(value) {
1083
- return typeof value === "object" && value !== null && !(value instanceof Blob) && "uri" in value && typeof value.uri === "string" && "name" in value && typeof value.name === "string" && "type" in value && typeof value.type === "string";
1084
- }
1085
- function isMultipartFile(value) {
1086
- return value instanceof Blob || isFileDescriptor(value);
1087
- }
1088
- function appendMultipartFile(form, field, file) {
1089
- const sink = form;
1090
- sink.append(field, file);
1091
- }
1092
- function appendFormFields(formData, values, skipKeys) {
1093
- for (const [key, value] of Object.entries(values)) {
1094
- if (skipKeys.has(key) || value === undefined || value === null)
1095
- continue;
1096
- formData.append(key, typeof value === "string" ? value : JSON.stringify(value));
1097
- }
1098
- }
1099
1107
  async function throwForErrorResponse(res, config, fallbackBody) {
1100
1108
  const body = await res.json().catch(() => fallbackBody);
1101
1109
  config.onError?.(res.status, body);
@@ -1159,7 +1167,7 @@ function implementRemote(contract, http, options) {
1159
1167
  expose: endpoint.expose,
1160
1168
  ui: "ui" in endpoint ? endpoint.ui : undefined,
1161
1169
  annotations: "annotations" in endpoint ? endpoint.annotations : undefined,
1162
- meta: endpoint.meta,
1170
+ meta: mergeMeta(contract.meta.meta, endpoint.meta),
1163
1171
  scope: endpoint.scope ?? groupScope,
1164
1172
  paramsSchema: endpoint.params,
1165
1173
  inputSchema: endpoint.input,
@@ -1236,7 +1244,7 @@ function summarizeTransports(services) {
1236
1244
  counts.HTTP += 1;
1237
1245
  }
1238
1246
  for (const transport of TOOL_TRANSPORTS2) {
1239
- counts[transport] = collectTools(service, transport).length;
1247
+ counts[transport] = collectTools(service, transport, { assertNames: false }).length;
1240
1248
  }
1241
1249
  for (const transport of Object.keys(counts)) {
1242
1250
  totals[transport] += counts[transport];
package/llms-full.txt CHANGED
@@ -237,7 +237,7 @@ export const users = defineContract({ prefix: 'users' }, {
237
237
  | `output` | no | Zod schema for the **response body** |
238
238
  | `scope` | no | access scope for this endpoint — see [Auth & errors](./auth-and-errors.md) |
239
239
  | `expose` | no | which transports carry this endpoint — see [below](#transports) |
240
- | `toolName` | no | explicit MCP / agent tool name (defaults to `prefix_key`) |
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
242
  | `timeout` | no | per-endpoint client timeout in ms, for slow endpoints |
243
243
  | `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) |
@@ -326,8 +326,41 @@ derivation from the method key + prefix (`users` + `create` ⇒ `create_user`,
326
326
  { method: 'POST', path: '/', desc: 'Create a user', toolName: 'create_user', /* … */ }
327
327
  ```
328
328
 
329
+ **Every tool name — derived or explicit — must match `[a-zA-Z0-9_-]` and be at
330
+ most 64 characters**, the character class every major provider accepts.
331
+
332
+ Note the two classes differ. What is *accepted* includes the hyphen; what
333
+ derivation *keeps* does not — a derived name normalises everything outside
334
+ `[a-zA-Z0-9_]` to `_`, the hyphen included, so `bot-status` + `get` ⇒
335
+ `get_bot_status` and `admin/analytics` + `get` ⇒ `get_admin_analytics`. A hyphen
336
+ survives only in an explicit `toolName`, which is taken verbatim.
337
+
338
+ A prefix with *no* usable character at all (`'///'`, `'_'`, a fully non-ASCII
339
+ prefix) and any explicit `toolName` outside the accepted class **throw at
340
+ mount**. An unusable prefix is rescued by setting an explicit `toolName` — the
341
+ prefix then never enters the name. An over-long name is fixed by a shorter
342
+ explicit `toolName` (or a shorter prefix / method key). Nothing downstream checks this: the provider rejects the whole
343
+ request, so one bad name takes every tool of that mount down with it.
344
+ → [ADR 0035](../decisions/0035-tool-name-derivation-and-validation.md).
345
+
329
346
  ## Endpoint metadata (`meta`)
330
347
 
348
+ A contract can declare a **default** `meta` that every endpoint inherits, and an
349
+ endpoint's own keys are **shallow-merged over** it:
350
+
351
+ ```ts
352
+ defineContract({ prefix: 'admin', meta: { public: true } }, {
353
+ list: { method: 'GET', path: '/', desc: 'List', /* meta → { public: true } */ },
354
+ purge: { method: 'POST', path: '/purge', desc: 'Purge', meta: { rateTier: 2 } },
355
+ // meta → { public: true, rateTier: 2 }
356
+ })
357
+ ```
358
+
359
+ One level deep — no deep merge, no way to unset an inherited key; declare it on
360
+ the endpoint instead. `expose` deliberately has **no** contract-level equivalent
361
+ (→ [ADR 0036](../decisions/0036-contract-level-meta.md)).
362
+
363
+
331
364
  `meta` is an **opaque, app-defined** bag the core attaches no meaning to — the
332
365
  same escape-hatch spirit as `scope` being a free string ([ADR 0002](../decisions/0002-generic-core.md) /
333
366
  [ADR 0021](../decisions/0021-endpoint-meta-passthrough.md)). Declare app concerns
@@ -1172,7 +1205,14 @@ By default every endpoint is a tool on every transport. `expose` narrows it:
1172
1205
  `desc` is the tool description the model reads — write it for the model, not
1173
1206
  just for a human. A `multipart` endpoint is never a tool. The tool name defaults
1174
1207
  to a verb-aware name from the method + prefix (`list` → `list_widgets`, `get` →
1175
- `get_widget`); set `toolName` for an explicit one. See
1208
+ `get_widget`); set `toolName` for an explicit one. Derivation normalises every
1209
+ character outside `[a-zA-Z0-9_]` to `_` — the hyphen included, so `bot-status`
1210
+ derives `get_bot_status` — while a name is *accepted* if it matches
1211
+ `[a-zA-Z0-9_-]`, so a hyphen survives in an explicit `toolName`. A name that
1212
+ still cannot be delivered (illegal explicit `toolName`, over 64 characters, or a
1213
+ prefix with no usable character) throws at mount rather than at the first model
1214
+ call —
1215
+ → [ADR 0035](../decisions/0035-tool-name-derivation-and-validation.md). See
1176
1216
  [Contracts → transports](./contracts.md#transports).
1177
1217
 
1178
1218
  ### Pinning tool names — `listToolNames`
@@ -1196,6 +1236,14 @@ now fails this test instead of silently breaking the clients that call the
1196
1236
  tool. It is also the mechanical diff when migrating a service: run it before
1197
1237
  and after, compare.
1198
1238
 
1239
+ > **This is also the guard against a forgotten `expose`.** An endpoint that
1240
+ > declares none is a tool on MCP **and** AGENT — the default is fail-open, and
1241
+ > there is no contract-level `expose` to set once (→
1242
+ > [ADR 0036](../decisions/0036-contract-level-meta.md)). A snapshot of
1243
+ > `listToolNames` fails the build the moment an endpoint you meant to keep
1244
+ > HTTP-only shows up in the list, which is the one check that catches it however
1245
+ > many places the line was forgotten.
1246
+
1199
1247
  ## MCP — `createMcpHandler`
1200
1248
 
1201
1249
  `createMcpHandler` builds a complete Streamable-HTTP MCP server as a single
@@ -1234,11 +1282,11 @@ createServer({
1234
1282
  | `hooks` | tool-call observability hooks — `afterToolCall` fires on every result |
1235
1283
  | `onIncompatibleSchema` | `'throw'` (default) · `'skip'` · `'warn'` — see below |
1236
1284
  | `logger` | a `StitchLogger` for the `'warn'` policy |
1237
- | `nativeTools` | register non-contract tools directly on the `McpServer` |
1285
+ | `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) |
1238
1286
  | `instructions` | a short host-facing usage hint, surfaced to MCP tool-search |
1239
1287
 
1240
- `services` and `context` receive the resolved identity, so a tenant can be
1241
- shown only its own tools and every handler can read `ctx.tenantId`.
1288
+ `services`, `context` and `nativeTools` all receive the resolved identity, so a
1289
+ tenant can be shown only its own tools and every handler can read `ctx.tenantId`.
1242
1290
 
1243
1291
  ### Guarding tools — `lifecycle`
1244
1292
 
@@ -1303,11 +1351,23 @@ each variant's fields become optional with a `Required if <disc> = …` hint.
1303
1351
  It is **deep**: unions are flattened at every depth — top level, object fields,
1304
1352
  array items, and through `optional` / `nullable` / `default` / intersection
1305
1353
  wrappers — so no `oneOf` survives anywhere (e.g. a `content.parts[]` that is an
1306
- array of a discriminated union). It is **advertised-only and lossy**: the original
1307
- schemas stay the validation schemas in `executeToolMethod`, so requests are still
1308
- validated against the real union. Schemas a transform cannot safely rebuild
1354
+ array of a discriminated union). Schemas a transform cannot safely rebuild
1309
1355
  (refined / piped / lazy / plain non-discriminated unions) are left as-is.
1310
1356
 
1357
+ The flattened form is **lossy but never destructive**. Lossy: per-variant
1358
+ strictness and object-level refinements are not advertised — the original schemas
1359
+ enforce them in `executeToolMethod`. Not destructive: every object keeps its own
1360
+ **key policy** (`.strict()` stays strict, `.loose()` / `.catchall()` still keep
1361
+ extra keys), because the advertised schema is not advertised-only — the MCP and
1362
+ AI SDKs parse the caller's arguments *with it* and hand the handler the parsed
1363
+ result. An object advertised without its policy would silently delete keys the
1364
+ contract would have rejected. → [ADR 0034](../decisions/0034-advertised-schema-key-policy.md).
1365
+
1366
+ A consequence worth knowing when you read logs: a `.strict()` violation is caught
1367
+ by the SDK **before** the tool callback runs, so it comes back as an MCP
1368
+ `InvalidParams` protocol error rather than a stitchkit `VALIDATION_ERROR`
1369
+ envelope, and `beforeToolCall` / `afterToolCall` do not fire for it.
1370
+
1311
1371
  ## `mountMcp`
1312
1372
 
1313
1373
  If you already run an `McpServer` from the SDK, `mountMcp` adds contract tools
@@ -2467,7 +2527,13 @@ const onError = createErrorHook({
2467
2527
  CONFLICT: 'conflict', RATE_LIMITED: 'rate_limited',
2468
2528
  INTERNAL_SERVER_ERROR: 'internal',
2469
2529
  } satisfies Record<StitchErrorCode, string>,
2470
- render: (info) => ({ ok: false, error: { code: info.code, message: info.message } }),
2530
+ // `ctx` is the request's RuntimeContext — read `ctx.traceId` for a
2531
+ // correlation id in the envelope. Declaring it is optional.
2532
+ render: (info, ctx) => ({
2533
+ ok: false,
2534
+ error: { code: info.code, message: info.message },
2535
+ traceId: ctx.traceId,
2536
+ }),
2471
2537
  })
2472
2538
 
2473
2539
  createServer({ services, hooks: { onError } })
@@ -3111,6 +3177,42 @@ current one *up to* your target, and apply each snippet.
3111
3177
  (`STITCH_ERROR_STATUS`, `serveFile`, `scopePrefixes`, `afterToolCall`'s
3112
3178
  `MethodDef`, `maxUploadBytes`) are available to adopt, not required.
3113
3179
 
3180
+ ## Your handlers may be returning more than the contract declares
3181
+
3182
+ stitchkit validates every handler's return value against the endpoint's `output`
3183
+ schema and **passes on the parsed result** — so any field the schema does not
3184
+ declare is silently removed. That is deliberate (the contract is the published
3185
+ shape of the response), but when you are moving a *live* API onto stitchkit it is
3186
+ invisible: TypeScript does not reject excess properties, nothing logs it, and the
3187
+ client just receives fewer fields.
3188
+
3189
+ While migrating, turn the diagnostic on:
3190
+
3191
+ ```ts
3192
+ createServer({ services, warnOnOutputStrip: true }) // off by default
3193
+ ```
3194
+
3195
+ Every removed key is logged as a dot-path with the endpoint that produced it
3196
+ (`notes.get: secret, nested.alsoSecret`). Tool transports strip identically —
3197
+ `mountMcp` / `mountAgent` take `onOutputStrip: (toolName, paths) => …`. Read the
3198
+ list, then either widen the contract or stop returning the field, and turn the
3199
+ flag back off: it is for the migration window, not for production.
3200
+
3201
+ ## Tool names may shift between versions
3202
+
3203
+ Derived tool names are part of your public surface — an MCP client config or an
3204
+ agent prompt refers to them by string. Before and after any upgrade that touches
3205
+ name derivation, diff them mechanically:
3206
+
3207
+ ```ts
3208
+ import { listToolNames } from 'stitchkit/tools'
3209
+ console.log(JSON.stringify(listToolNames(services), null, 2))
3210
+ ```
3211
+
3212
+ `listToolNames` never throws on an illegal name — that is deliberate, so it can
3213
+ show you the offending row when a mount would refuse it. Pin it in a snapshot
3214
+ test and a shift fails your build instead of your clients.
3215
+
3114
3216
  ## When you author a breaking change in stitchkit
3115
3217
 
3116
3218
  You are on the other side of this flow — see
@@ -3200,7 +3302,7 @@ from the root `stitchkit`.
3200
3302
  | `ScopedDefineContract` | _type_ | the `defineContract` `createContractFactory` returns |
3201
3303
  | `ALL_TRANSPORTS` | constant | `['HTTP', 'MCP', 'AGENT', 'CLI']` |
3202
3304
  | `ContractDef` | _type_ | a defined contract |
3203
- | `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` |
3305
+ | `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` and `meta` (a default every endpoint shallow-merges over) |
3204
3306
  | `EndpointDef` | _type_ | a single endpoint definition |
3205
3307
  | `HttpMethod` | _type_ | `GET \| POST \| PUT \| PATCH \| DELETE` |
3206
3308
  | `Transport` | _type_ | `HTTP \| MCP \| AGENT \| CLI` |
@@ -3529,7 +3631,7 @@ Advanced building blocks — the shared machinery the mounts are built on.
3529
3631
  | `TransportCounts` | _type_ | per-transport counts (`{ HTTP, MCP, AGENT, CLI }`) |
3530
3632
  | `coerceJsonArgs` | function | coerce JSON-stringified array/object tool arguments |
3531
3633
  | `flattenDiscriminatedUnion` | function | flatten one discriminated union into a single object schema |
3532
- | `flattenUnionsDeep` | function | flatten discriminated unions at every depth (advertised schema only) |
3634
+ | `flattenUnionsDeep` | function | flatten discriminated unions at every depth — union shape only; each object keeps its own key policy (`.strict()` / `.loose()` / `.catchall()`) |
3533
3635
  | `MountableTool` | _type_ | one contract method resolved for mounting |
3534
3636
  | `ToolManifestEntry` | _type_ | one `buildToolManifest` row |
3535
3637
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.24.0",
3
+ "version": "0.26.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",