@cyanheads/mcp-ts-core 0.7.6 → 0.8.1

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 (107) hide show
  1. package/CLAUDE.md +22 -7
  2. package/README.md +2 -2
  3. package/changelog/0.8.x/0.8.0.md +33 -0
  4. package/changelog/0.8.x/0.8.1.md +17 -0
  5. package/changelog/template.md +13 -0
  6. package/dist/core/context.d.ts +67 -0
  7. package/dist/core/context.d.ts.map +1 -1
  8. package/dist/core/context.js +46 -1
  9. package/dist/core/context.js.map +1 -1
  10. package/dist/core/index.d.ts +2 -1
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/index.js +1 -0
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/linter/rules/error-contract-rules.d.ts +45 -0
  15. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -0
  16. package/dist/linter/rules/error-contract-rules.js +321 -0
  17. package/dist/linter/rules/error-contract-rules.js.map +1 -0
  18. package/dist/linter/rules/handler-body-rules.d.ts +18 -0
  19. package/dist/linter/rules/handler-body-rules.d.ts.map +1 -0
  20. package/dist/linter/rules/handler-body-rules.js +134 -0
  21. package/dist/linter/rules/handler-body-rules.js.map +1 -0
  22. package/dist/linter/rules/index.d.ts +2 -0
  23. package/dist/linter/rules/index.d.ts.map +1 -1
  24. package/dist/linter/rules/index.js +2 -0
  25. package/dist/linter/rules/index.js.map +1 -1
  26. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  27. package/dist/linter/rules/resource-rules.js +9 -0
  28. package/dist/linter/rules/resource-rules.js.map +1 -1
  29. package/dist/linter/rules/source-text.d.ts +19 -0
  30. package/dist/linter/rules/source-text.d.ts.map +1 -0
  31. package/dist/linter/rules/source-text.js +96 -0
  32. package/dist/linter/rules/source-text.js.map +1 -0
  33. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  34. package/dist/linter/rules/tool-rules.js +9 -0
  35. package/dist/linter/rules/tool-rules.js.map +1 -1
  36. package/dist/logs/combined.log +4 -4
  37. package/dist/logs/error.log +4 -4
  38. package/dist/mcp-server/apps/appBuilders.d.ts +9 -4
  39. package/dist/mcp-server/apps/appBuilders.d.ts.map +1 -1
  40. package/dist/mcp-server/apps/appBuilders.js +4 -0
  41. package/dist/mcp-server/apps/appBuilders.js.map +1 -1
  42. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  43. package/dist/mcp-server/resources/resource-registration.js +3 -2
  44. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  45. package/dist/mcp-server/resources/utils/resourceDefinition.d.ts +13 -5
  46. package/dist/mcp-server/resources/utils/resourceDefinition.d.ts.map +1 -1
  47. package/dist/mcp-server/resources/utils/resourceDefinition.js.map +1 -1
  48. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  49. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -4
  50. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  51. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  52. package/dist/mcp-server/tools/tool-registration.js +13 -7
  53. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  54. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +64 -16
  55. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  56. package/dist/mcp-server/tools/utils/toolDefinition.js +25 -11
  57. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  58. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  59. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +6 -4
  60. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  61. package/dist/testing/index.d.ts +8 -0
  62. package/dist/testing/index.d.ts.map +1 -1
  63. package/dist/testing/index.js +5 -1
  64. package/dist/testing/index.js.map +1 -1
  65. package/dist/types-global/errors.d.ts +82 -0
  66. package/dist/types-global/errors.d.ts.map +1 -1
  67. package/dist/types-global/errors.js +25 -0
  68. package/dist/types-global/errors.js.map +1 -1
  69. package/dist/utils/formatting/index.d.ts +1 -0
  70. package/dist/utils/formatting/index.d.ts.map +1 -1
  71. package/dist/utils/formatting/index.js +1 -0
  72. package/dist/utils/formatting/index.js.map +1 -1
  73. package/dist/utils/formatting/partialResult.d.ts +145 -0
  74. package/dist/utils/formatting/partialResult.d.ts.map +1 -0
  75. package/dist/utils/formatting/partialResult.js +145 -0
  76. package/dist/utils/formatting/partialResult.js.map +1 -0
  77. package/dist/utils/index.d.ts +2 -1
  78. package/dist/utils/index.d.ts.map +1 -1
  79. package/dist/utils/index.js +2 -1
  80. package/dist/utils/index.js.map +1 -1
  81. package/dist/utils/network/httpError.d.ts +112 -0
  82. package/dist/utils/network/httpError.d.ts.map +1 -0
  83. package/dist/utils/network/httpError.js +153 -0
  84. package/dist/utils/network/httpError.js.map +1 -0
  85. package/dist/utils/network/retry.d.ts.map +1 -1
  86. package/dist/utils/network/retry.js +0 -1
  87. package/dist/utils/network/retry.js.map +1 -1
  88. package/package.json +5 -4
  89. package/scripts/split-changelog.ts +133 -0
  90. package/skills/add-app-tool/SKILL.md +12 -0
  91. package/skills/add-resource/SKILL.md +40 -0
  92. package/skills/add-service/SKILL.md +54 -1
  93. package/skills/add-test/SKILL.md +39 -0
  94. package/skills/add-tool/SKILL.md +42 -5
  95. package/skills/api-context/SKILL.md +75 -1
  96. package/skills/api-errors/SKILL.md +183 -5
  97. package/skills/api-linter/SKILL.md +223 -3
  98. package/skills/api-testing/SKILL.md +79 -4
  99. package/skills/api-utils/SKILL.md +4 -2
  100. package/skills/design-mcp-server/SKILL.md +13 -10
  101. package/skills/field-test/SKILL.md +81 -15
  102. package/skills/maintenance/SKILL.md +5 -2
  103. package/skills/report-issue-framework/SKILL.md +2 -2
  104. package/skills/security-pass/SKILL.md +6 -5
  105. package/templates/AGENTS.md +23 -8
  106. package/templates/CLAUDE.md +23 -8
  107. package/templates/changelog/template.md +18 -5
@@ -48,7 +48,11 @@ Grouped by family. Jump to any rule ID via its anchor.
48
48
  | Names | `name-required`, `name-format`, `name-unique` | [Name rules](#name-rules) |
49
49
  | Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing` | [Tool rules](#tool-rules) |
50
50
  | Resources | `uri-template-required`, `uri-template-valid`, `resource-name-not-uri`, `template-params-align` | [Resource rules](#resource-rules) |
51
+ | Landing | `landing-*` (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme) | [Landing config rules](#landing-config-rules) |
51
52
  | Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
53
+ | Handler body | `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error` | [Handler body rules](#handler-body-rules) |
54
+ | Error contract (structural) | `error-contract-type`, `error-contract-empty`, `error-contract-entry-type`, `error-contract-code-type`, `error-contract-code-unknown`, `error-contract-code-unknown-error`, `error-contract-reason-required`, `error-contract-reason-format`, `error-contract-reason-unique`, `error-contract-when-required`, `error-contract-retryable-type` | [Error contract rules](#error-contract-rules) |
55
+ | Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail` | [Error contract rules](#error-contract-rules) |
52
56
  | server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
53
57
 
54
58
  ---
@@ -196,7 +200,9 @@ Every tool, resource, and prompt definition needs a non-empty `name` string. For
196
200
 
197
201
  **Severity:** error
198
202
 
199
- Names must match `^[a-zA-Z0-9._-]+$` (alphanumerics, dots, hyphens, underscores). Tools conventionally use `snake_case`, resources and prompts use `kebab-case` or `snake_case`.
203
+ **Scope:** tools only resources and prompts are checked by `name-required` only.
204
+
205
+ Tool names must match `^[A-Za-z0-9._-]{1,128}$` (alphanumerics, dots, hyphens, underscores; 1–128 chars). Tools conventionally use `snake_case`.
200
206
 
201
207
  **Fix:** rename to a valid identifier. If the legacy name is user-facing, keep `title` as the display string and use a valid `name` internally.
202
208
 
@@ -259,7 +265,7 @@ Every element in `auth` must be a non-empty string. Empty strings in the array a
259
265
 
260
266
  **Severity:** warning
261
267
 
262
- Contradictory annotation combinations. The canonical case: `readOnlyHint: true` with `destructiveHint: true` — a read-only tool cannot be destructive. `idempotentHint: true` alongside `readOnlyHint: true` is fine (explicit redundancy is allowed).
268
+ Catches `readOnlyHint: true` with **any** explicit `destructiveHint` value (even `false`)the destructive hint is meaningless on a read-only tool, so its presence signals authoring confusion. Drop `destructiveHint` entirely when the tool is read-only.
263
269
 
264
270
  ### meta-ui-type
265
271
 
@@ -322,7 +328,7 @@ resource('myscheme://{id}/data', {
322
328
 
323
329
  **Severity:** error
324
330
 
325
- Every variable in the URI template must appear as a key in the `params` schema, and vice versa. `test://{itemId}/data` with `params: z.object({ item_id: ... })` is rejected — casing mismatches count.
331
+ Every variable in the URI template must appear as a key in the `params` schema. `test://{itemId}/data` with `params: z.object({ item_id: ... })` is rejected — casing mismatches count. The check is template → schema only; extra schema keys not referenced by the template are not flagged.
326
332
 
327
333
  **Fix:** rename one side so they match exactly. The error message names which variables are on which side.
328
334
 
@@ -391,6 +397,220 @@ Most of these are mechanical — fix the manifest field named in the diagnostic'
391
397
 
392
398
  ---
393
399
 
400
+ ## Landing config rules
401
+
402
+ Validate the `landing` config passed to `createApp()` (the config object that drives the framework's landing page). Run only when `input.landing` is provided to `validateDefinitions`. All errors — landing config that's structurally broken would render incorrectly on the public page.
403
+
404
+ | Rule | Severity | Catches |
405
+ |:-----|:---------|:--------|
406
+ | `landing-shape` | error | `landing` is not a plain object |
407
+ | `landing-tagline-type` | error | `tagline` is present but not a string |
408
+ | `landing-tagline-length` | error | `tagline` exceeds the max length |
409
+ | `landing-logo-type` | error | `logo` is present but not a string |
410
+ | `landing-logo-size` | error | `logo` is too long for inline rendering |
411
+ | `landing-links-type` | error | `links` is present but not an array |
412
+ | `landing-links-count` | error | `links` exceeds the max count |
413
+ | `landing-link-shape` | error | A `links[]` entry is not a plain object |
414
+ | `landing-link-href` | error | A link entry's `href` is missing or not a non-empty string |
415
+ | `landing-link-label` | error | A link entry's `label` is missing or not a non-empty string |
416
+ | `landing-repo-root-type` | error | `repoRoot` is present but not a string |
417
+ | `landing-repo-root-shape` | error | `repoRoot` is not a recognized GitHub URL shape |
418
+ | `landing-env-example-type` | error | `envExample` is present but not a plain object |
419
+ | `landing-env-example-count` | error | `envExample` has too many entries |
420
+ | `landing-env-example-key` | error | An `envExample` key is empty or invalid |
421
+ | `landing-env-example-value` | error | An `envExample` value is not a string |
422
+ | `landing-connect-snippets-type` | error | `connectSnippets` is present but not a plain object |
423
+ | `landing-connect-snippets-key` | error | A `connectSnippets` key is empty |
424
+ | `landing-connect-snippets-value` | error | A `connectSnippets` value is not a string |
425
+ | `landing-connect-snippets-empty` | error | A `connectSnippets` value is an empty string |
426
+ | `landing-theme-type` | error | `theme` is present but not a plain object |
427
+ | `landing-theme-accent` | error | `theme.accent` is present but not a string |
428
+ | `landing-theme-accent-format` | error | `theme.accent` doesn't match the expected color format |
429
+
430
+ Diagnostic anchors for these rules are the rule ID — e.g. `skills/api-linter/SKILL.md#landing-shape`. Pass `landing` to `validateDefinitions({ landing, tools, resources, prompts })` to opt in.
431
+
432
+ ---
433
+
434
+ ## Handler body rules
435
+
436
+ Heuristic source-text checks that scan `handler.toString()` for common error-handling anti-patterns. All warnings — false positives are possible because the rules can't see code reached through wrappers, factories assigned to variables, or service-layer throws. Each rule fires at most once per handler to keep reports quiet.
437
+
438
+ ### prefer-mcp-error-in-handler
439
+
440
+ **Severity:** warning
441
+
442
+ Fires when a handler contains `throw new Error(...)`. Plain `Error` doesn't carry a JSON-RPC code — the framework's auto-classifier degrades to `InternalError`, hiding the actual failure mode.
443
+
444
+ **Fix:** use `McpError` or a factory:
445
+
446
+ ```ts
447
+ // instead of:
448
+ throw new Error('Item not found');
449
+ // use:
450
+ throw notFound('Item not found', { itemId });
451
+ ```
452
+
453
+ ### prefer-error-factory
454
+
455
+ **Severity:** warning
456
+
457
+ Fires when a handler builds an error via `new McpError(JsonRpcErrorCode.X, ...)` and a matching factory exists (`notFound`, `rateLimited`, `serviceUnavailable`, …). The factory form is shorter, self-documenting, and consistent with the rest of the codebase.
458
+
459
+ **Fix:** swap the constructor for the factory the diagnostic names:
460
+
461
+ ```ts
462
+ // instead of:
463
+ throw new McpError(JsonRpcErrorCode.NotFound, 'Item missing');
464
+ // use:
465
+ throw notFound('Item missing');
466
+ ```
467
+
468
+ ### preserve-cause-on-rethrow
469
+
470
+ **Severity:** warning
471
+
472
+ Fires when a `catch (e)` block throws a structured `McpError` (or factory) without passing `{ cause: e }`. Dropping the cause loses the original stack trace — observability platforms and `pino-pretty` rely on it to render error chains.
473
+
474
+ **Fix:** thread the cause through the 4th `McpError` argument or factory options:
475
+
476
+ ```ts
477
+ try {
478
+ await fetchUpstream();
479
+ } catch (e) {
480
+ throw serviceUnavailable('Upstream failed', { service: 'pubmed' }, { cause: e });
481
+ }
482
+ ```
483
+
484
+ ### no-stringify-upstream-error
485
+
486
+ **Severity:** warning
487
+
488
+ Fires when a handler throws an error message containing `JSON.stringify(...)`. Stringifying caught or upstream errors into the message risks leaking internal stack traces, AWS internal ARNs, or third-party trace IDs to clients.
489
+
490
+ **Fix:** sanitize first, or attach the raw blob to the error's `data` payload — never the message.
491
+
492
+ ```ts
493
+ // instead of:
494
+ throw new Error(`Upstream failed: ${JSON.stringify(e)}`);
495
+ // use:
496
+ throw serviceUnavailable('Upstream failed', { upstreamError: e }, { cause: e });
497
+ ```
498
+
499
+ ---
500
+
501
+ ## Error contract rules
502
+
503
+ Validate the optional `errors[]` declarative contract on tool/resource definitions. Structural rules check the shape of contract entries; conformance rules cross-check the handler body against the declared codes.
504
+
505
+ When a contract is declared, surfaced under `_meta['mcp-ts-core/errors']` in `tools/list` / `resources/list`, and the handler receives a typed `ctx.fail(reason, …)` keyed by the declared reason union. See `skills/api-errors/SKILL.md` for runtime semantics.
506
+
507
+ ### error-contract-type
508
+
509
+ **Severity:** error
510
+
511
+ Fires when `errors` is present but not an array. The contract must be a tuple of `ErrorContract` entries.
512
+
513
+ ### error-contract-empty
514
+
515
+ **Severity:** warning
516
+
517
+ Fires when `errors: []` is declared. An empty contract is a no-op — nothing to surface in `tools/list`, no reason union for `ctx.fail`, no conformance to check.
518
+
519
+ **Fix:** drop the field, or declare actual failure modes.
520
+
521
+ ### error-contract-entry-type
522
+
523
+ **Severity:** error
524
+
525
+ Fires when an entry in `errors[]` isn't an object. Each entry must be `{ code, reason, when }` (and optionally `retryable`).
526
+
527
+ ### error-contract-code-type
528
+
529
+ **Severity:** error
530
+
531
+ Fires when an entry's `code` is missing or not a number. Use the `JsonRpcErrorCode` enum:
532
+
533
+ ```ts
534
+ errors: [{ code: JsonRpcErrorCode.NotFound, reason: 'no_match', when: 'No items matched' }]
535
+ ```
536
+
537
+ ### error-contract-code-unknown
538
+
539
+ **Severity:** error
540
+
541
+ Fires when an entry's `code` is a number but not a known `JsonRpcErrorCode` value. Likely a typo or stale magic number — import the enum and use a member.
542
+
543
+ ### error-contract-code-unknown-error
544
+
545
+ **Severity:** warning
546
+
547
+ Fires when an entry uses `JsonRpcErrorCode.UnknownError` (-32099). That code is the auto-classifier's giveup-fallback; declaring it in a contract conveys nothing useful to clients.
548
+
549
+ **Fix:** pick a more specific code (`InternalError`, `ServiceUnavailable`, etc.) or drop the entry.
550
+
551
+ ### error-contract-reason-required
552
+
553
+ **Severity:** error
554
+
555
+ Fires when an entry's `reason` is missing or empty. `reason` is the stable machine-readable identifier clients switch on; it must always be present.
556
+
557
+ ### error-contract-reason-format
558
+
559
+ **Severity:** warning
560
+
561
+ Fires when `reason` isn't snake_case (matched against `^[a-z][a-z0-9_]*$`). Reasons are part of the public API — treat them like API constants. `'NotFound'`, `'no-match'`, `'1bad'` all warn.
562
+
563
+ **Fix:** rename to snake_case (`'no_match'`, `'rate_limited'`, …).
564
+
565
+ ### error-contract-reason-unique
566
+
567
+ **Severity:** error
568
+
569
+ Fires when two entries in the same contract share a `reason`. Reasons must be unique within a contract — they're how `ctx.fail(reason, …)` selects the entry.
570
+
571
+ ### error-contract-when-required
572
+
573
+ **Severity:** error
574
+
575
+ Fires when an entry's `when` field is missing or empty. `when` is the human-readable explanation surfaced to LLMs and UI clients; without it, the contract is opaque.
576
+
577
+ ### error-contract-retryable-type
578
+
579
+ **Severity:** warning
580
+
581
+ Fires when an entry's optional `retryable` field is present but isn't a boolean. Only `true` or `false` is meaningful — drop the field if you can't commit to either.
582
+
583
+ ### error-contract-conformance
584
+
585
+ **Severity:** warning
586
+
587
+ Cross-check rule. Fires when a handler throws a non-baseline code (via `JsonRpcErrorCode.X` or a factory like `notFound()`) that isn't declared in `errors[]`.
588
+
589
+ Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) are auto-allowed because they bubble from anywhere — services, framework utilities, the auto-classifier — and are implicitly always-possible on any tool. Only domain-specific codes need declaring.
590
+
591
+ **Fix:** add the missing code to `errors[]` with a stable reason, or route through `ctx.fail(reason, …)` if it maps to an existing entry.
592
+
593
+ **Heuristic limitations:** the scan reads `handler.toString()` and only catches direct `throw new McpError(JsonRpcErrorCode.X, …)` and `throw factory(…)` patterns. Indirect throws (`const e = notFound(); throw e;`), throws from called services, and throws via runtime helpers like `httpErrorFromResponse(...)` are invisible.
594
+
595
+ ### error-contract-prefer-fail
596
+
597
+ **Severity:** warning
598
+
599
+ Fires when a handler throws a code that **is** declared in the contract directly (via factory or `new McpError`) instead of routing through `ctx.fail(reason, …)`. Direct throws bypass the typed helper, leaving observers without a stable `data.reason` and disconnecting the throw site from the contract entry.
600
+
601
+ **Fix:** swap the direct throw for `ctx.fail` using the reason the diagnostic suggests:
602
+
603
+ ```ts
604
+ // instead of:
605
+ throw notFound('No items match');
606
+ // use:
607
+ throw ctx.fail('no_match', 'No items match');
608
+ ```
609
+
610
+ The diagnostic message includes the declared reason(s) for the code so you can copy-paste.
611
+
612
+ ---
613
+
394
614
  ## Escape hatches
395
615
 
396
616
  ### Dynamic upstream data
@@ -24,6 +24,7 @@ import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
24
24
 
25
25
  createMockContext() // minimal — ctx.state operations throw without tenantId
26
26
  createMockContext({ tenantId: 'test-tenant' }) // enables ctx.state (tenant-scoped in-memory storage)
27
+ createMockContext({ errors: myTool.errors }) // attaches typed ctx.fail keyed by the contract reasons
27
28
  createMockContext({ sample: vi.fn().mockResolvedValue(...) }) // with MCP sampling
28
29
  createMockContext({ elicit: vi.fn().mockResolvedValue(...) }) // with elicitation
29
30
  createMockContext({ progress: true }) // with task progress (ctx.progress populated)
@@ -41,6 +42,7 @@ createMockContext({ uri: new URL('myscheme://item/123') }) // for resource han
41
42
  interface MockContextOptions {
42
43
  auth?: AuthContext;
43
44
  elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
45
+ errors?: readonly ErrorContract[];
44
46
  notifyResourceListChanged?: () => void;
45
47
  notifyResourceUpdated?: (uri: string) => void;
46
48
  progress?: boolean;
@@ -57,6 +59,7 @@ interface MockContextOptions {
57
59
  | _(none)_ | Minimal context — `ctx.state` operations throw without `tenantId`; `ctx.elicit`/`ctx.sample`/`ctx.progress` are `undefined` |
58
60
  | `auth` | Sets `ctx.auth` for scope-checking tests |
59
61
  | `elicit` | Assigns a function to `ctx.elicit` for testing elicitation calls |
62
+ | `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly. |
60
63
  | `notifyResourceListChanged` | Assigns `ctx.notifyResourceListChanged` for resource notification tests |
61
64
  | `notifyResourceUpdated` | Assigns `ctx.notifyResourceUpdated` for resource update notification tests |
62
65
  | `progress` | Populates `ctx.progress` with real state-tracking implementation (see below) |
@@ -90,13 +93,13 @@ expect(progress._messages).toContain('step message');
90
93
 
91
94
  ### Mock logger
92
95
 
93
- `ctx.log` captures all log calls for inspection:
96
+ `ctx.log` captures all log calls for inspection. The mock returns the typed `MockContextLogger` from `@cyanheads/mcp-ts-core/testing` — import that instead of hand-casting:
94
97
 
95
98
  ```ts
99
+ import { createMockContext, type MockContextLogger } from '@cyanheads/mcp-ts-core/testing';
100
+
96
101
  const ctx = createMockContext();
97
- const log = ctx.log as ContextLogger & {
98
- calls: Array<{ level: string; msg: string; data?: unknown }>;
99
- };
102
+ const log = ctx.log as MockContextLogger;
100
103
 
101
104
  await myTool.handler(input, ctx);
102
105
  expect(log.calls.some(c => c.level === 'info' && c.msg.includes('Processing'))).toBe(true);
@@ -311,3 +314,75 @@ it('throws NotFound for missing resource', async () => {
311
314
  ```
312
315
 
313
316
  Use `.rejects.toThrow(McpError)` to assert type only. Use `.rejects.toMatchObject({ code: ... })` when the specific error code matters.
317
+
318
+ ---
319
+
320
+ ## Testing handlers with `errors[]` (typed contract)
321
+
322
+ Tools and resources that declare an `errors[]` contract receive a typed `ctx.fail` helper at runtime. Pass the definition's own `errors` to `createMockContext` and the mock wires `fail` the same way the production handler factory does:
323
+
324
+ ```ts
325
+ import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
326
+ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
327
+ import { fetchItems } from '@/mcp-server/tools/definitions/fetch-items.tool.js';
328
+
329
+ it('throws ctx.fail("no_match") when no items resolve', async () => {
330
+ const ctx = createMockContext({ errors: fetchItems.errors });
331
+
332
+ const input = fetchItems.input.parse({ ids: ['missing'] });
333
+ await expect(fetchItems.handler(input, ctx)).rejects.toMatchObject({
334
+ code: JsonRpcErrorCode.NotFound,
335
+ data: { reason: 'no_match' },
336
+ });
337
+ });
338
+ ```
339
+
340
+ For lower-level tests that need the raw `fail` helper without a full mock context (e.g. asserting the reason → code mapping), use `createFail` directly — see [Testing the handler-side `fail` plumbing](#testing-the-handler-side-fail-plumbing) below.
341
+
342
+ ### Why test `data.reason` and not just `code`?
343
+
344
+ The contract reason is the stable machine-readable identifier — clients switch on it the same way they would on an HTTP status. A code alone (`NotFound`) doesn't disambiguate between contract entries that share a code (`'no_match'` vs `'withdrawn'` both mapping to `NotFound`). Asserting on `data.reason` locks the test to the specific contract entry.
345
+
346
+ ### `data.reason` is overridable-proof
347
+
348
+ The framework spreads caller-supplied data first and writes `reason` last, so a handler that passes `data: { reason: 'something_else' }` cannot override the contract reason. Tests can rely on `data.reason` always equaling the contract entry's reason — write assertions that depend on it without paranoia.
349
+
350
+ ### Testing the handler-side `fail` plumbing
351
+
352
+ To verify the definition wires `ctx.fail` correctly without exercising the full handler factory, use the `errors` array directly:
353
+
354
+ ```ts
355
+ import { createFail } from '@cyanheads/mcp-ts-core';
356
+
357
+ it('builds an error with the contract code and reason', () => {
358
+ const fail = createFail(myTool.errors!);
359
+ const err = fail('no_match', 'not found', { itemId: '123' });
360
+ expect(err.code).toBe(JsonRpcErrorCode.NotFound);
361
+ expect(err.data).toEqual({ reason: 'no_match', itemId: '123' });
362
+ });
363
+ ```
364
+
365
+ ---
366
+
367
+ ## Fuzz testing
368
+
369
+ For schema-heavy or input-validation-critical handlers, the framework ships fuzz helpers under `@cyanheads/mcp-ts-core/testing/fuzz`. They generate valid + adversarial inputs from your Zod schemas via `fast-check` and assert handler invariants (no crashes, no prototype pollution, no stack-trace leaks).
370
+
371
+ ```ts
372
+ import { fuzzTool, fuzzResource, fuzzPrompt } from '@cyanheads/mcp-ts-core/testing/fuzz';
373
+
374
+ it('survives fuzz testing', async () => {
375
+ const report = await fuzzTool(myTool, { numRuns: 100, numAdversarial: 30 });
376
+ expect(report.crashes).toHaveLength(0);
377
+ expect(report.leaks).toHaveLength(0);
378
+ expect(report.prototypePollution).toBe(false);
379
+ });
380
+ ```
381
+
382
+ | Helper | Purpose |
383
+ |:-------|:--------|
384
+ | `fuzzTool(def, opts)` / `fuzzResource(def, opts)` / `fuzzPrompt(def, opts)` | Drive valid + adversarial inputs through the handler. Returns a `FuzzReport`. |
385
+ | `zodToArbitrary(schema)` | Convert a Zod schema to a `fast-check` `Arbitrary` for custom property-based tests. |
386
+ | `adversarialArbitrary()` / `ADVERSARIAL_STRINGS` | Targeted injection sets (prototype pollution probes, control characters, oversized payloads). |
387
+
388
+ `FuzzOptions`: `numRuns` (default 50), `numAdversarial` (default 30), `seed` (reproducibility), `timeout` (per-call ms, default 5000), `ctx` (`MockContextOptions` for stateful handlers).
@@ -30,7 +30,9 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
30
30
  | Export | API | Notes |
31
31
  |:-------|:----|:------|
32
32
  | `fetchWithTimeout` | `(url, timeoutMs, context: RequestContext, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean` and `signal?: AbortSignal` (external cancellation). SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node; hostname-only on Workers. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. |
33
- | `withRetry` | `<T>(fn: () => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. See `docs/service-resilience.md`. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate). |
33
+ | `withRetry` | `<T>(fn: () => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate). |
34
+ | `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` — full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `data?` (extra fields merged into `error.data`), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` — both classify codes the same way. |
35
+ | `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx/3xx. Use when you need just the code without a `Response` object handy. |
34
36
 
35
37
  ---
36
38
 
@@ -101,7 +103,7 @@ The `utils` export includes two type guards. The full set of guards lives in the
101
103
 
102
104
  | Export | API | Notes |
103
105
  |:-------|:----|:------|
104
- | `ErrorHandler` | `.tryCatch<T>(fn, opts) -> Promise<T>` `.handleError(error, opts) -> Error` `.determineErrorCode(error) -> JsonRpcErrorCode` `.mapError(error, mappings, defaultFactory?) -> T \| Error` `.formatError(error) -> Record<string, unknown>` | Service-level error handling. `tryCatch` wraps async or sync `fn`, logs via `handleError`, and always rethrows. No `.tryCatchSync()`. Use in services, NOT in tool handlers (those throw raw `McpError`). Options: `operation`, `context`, `errorCode`, `input`, `rethrow`, `includeStack`, `critical`, `errorMapper`. |
106
+ | `ErrorHandler` | `.tryCatch<T>(fn, opts) -> Promise<T>` `.handleError(error, opts) -> Error` `.classifyOnly(error) -> { code, message, data? }` `.determineErrorCode(error) -> JsonRpcErrorCode` `.mapError(error, mappings, defaultFactory?) -> T \| Error` `.formatError(error) -> Record<string, unknown>` | Service-level error handling. `tryCatch` wraps async or sync `fn`, logs via `handleError`, and always rethrows. No `.tryCatchSync()`. Use in services, NOT in tool handlers (those throw raw `McpError`). `tryCatch` accepts `Omit<ErrorHandlerOptions, 'rethrow'>` — required: `operation`. Optional: `context`, `errorCode`, `input`, `includeStack`, `critical`, `errorMapper`. `handleError` accepts the full `ErrorHandlerOptions` including `rethrow`. |
105
107
 
106
108
  ---
107
109
 
@@ -83,7 +83,7 @@ The user-goal list shapes the tool surface; the operation list fills in the gaps
83
83
  | Primitive | Use when | Examples |
84
84
  |:----------|:---------|:--------|
85
85
  | **Tool** | The default. Any operation or data access an agent needs to accomplish the server's purpose. | Search, create, update, analyze, fetch-by-ID, list reference data |
86
- | **App Tool** | Tool whose results benefit from interactive HTML UI (data visualization, forms, rich rendering). Uses `appTool()` + paired `appResource()`. Hosts without MCP Apps support receive the text fallback from `format()`. | Dashboards, data explorers, interactive charts, form-based workflows |
86
+ | **App Tool** | **Rare default to a standard tool.** Only when a human will actively interact with the result in real time *and* the target client supports MCP Apps. Most clients are tool-only and most agent workflows are read-by-LLM, not viewed-by-human. App tools add an iframe + CSP, `app.ontoolresult`/`callServerTool` plumbing, host-context wiring, and a `format()` text twin that still has to be content-complete (since most clients only see that). Two surfaces to keep in sync, two failure modes per change. | Dense tabular state a human scrubs through; form-based human approval in an MCP Apps-capable client |
87
87
  | **Resource** | *Additionally* expose as a resource when the data is addressable by stable URI, read-only, and useful as injectable context. | Config, schemas, status, entity-by-ID lookups |
88
88
  | **Prompt** | Reusable message template that structures how the LLM approaches a task | Analysis framework, report template, review checklist |
89
89
  | **Neither** | Internal detail, admin-only, not useful to an LLM | Token refresh, webhook setup, migrations |
@@ -321,7 +321,9 @@ The pattern: name the shortcut for what it does (`text_search`, `name_search`),
321
321
 
322
322
  #### Error design
323
323
 
324
- Errors are part of the tool's interface — design them during the design phase, not as an afterthought. Two aspects: **classification** (what error code) and **messaging** (what the LLM reads).
324
+ Errors are part of the tool's interface — design them during the design phase, not as an afterthought. Three aspects: **the contract** (which failures are public), **classification** (what error code), and **messaging** (what the LLM reads).
325
+
326
+ **Declare a typed contract for domain failures.** When a tool has known failure modes the agent should plan around (`no_match`, `queue_full`, `vendor_down`), enumerate them as `errors: [{ reason, code, when, retryable? }]` on the definition. The framework publishes the contract under `tools/list` `_meta['mcp-ts-core/errors']` so capable clients can preview failure modes, types `ctx.fail(reason, …)` against the declared reason union (typos become TS errors), and auto-populates `_meta.error.data.reason` on responses for stable observability. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble from anywhere and don't need to be enumerated. See `api-errors` skill for the full pattern.
325
327
 
326
328
  **Classify errors by origin.** Different error sources need different codes and different recovery guidance. Map the failure modes for each tool during design:
327
329
 
@@ -333,7 +335,7 @@ Errors are part of the tool's interface — design them during the design phase,
333
335
  | **Auth/permissions** | Insufficient scopes, expired token | `Forbidden` / `Unauthorized` | Maybe — escalate or re-auth |
334
336
  | **Server internal** | Parse failure, missing config, unexpected state | `InternalError` | No — server-side issue |
335
337
 
336
- The framework auto-classifies many of these at runtime (HTTP status codes, JS error types, common patterns), but explicit classification in the handler gives better error messages. Use error factories (`notFound()`, `validationError()`, etc.) when you want a specific code; plain `throw new Error()` when the framework's auto-classification is good enough.
338
+ The framework auto-classifies many of these at runtime (HTTP status codes, JS error types, common patterns), but explicit classification in the handler gives better error messages. For declared contract failures, throw via `ctx.fail('reason', …)`. For ad-hoc throws outside the contract, use error factories (`notFound()`, `validationError()`, etc.) when the code matters; plain `throw new Error()` when the framework's auto-classification is good enough.
337
339
 
338
340
  **Write error messages as recovery instructions.** The message is the agent's only signal for what to do next.
339
341
 
@@ -354,7 +356,7 @@ throw forbidden(
354
356
  throw notFound(`Paper '${id}' not found on arXiv. Verify the ID format (e.g., '2401.12345' or '2401.12345v2').`);
355
357
  ```
356
358
 
357
- **During design, list the expected failure modes for each tool.** Not every mode needs a custom message, but the common ones should have clear recovery guidance baked in. Include these in the tool's section of the design doc — they inform both the handler implementation and the error factory choices.
359
+ **During design, list the expected failure modes for each tool** with the reason, code, and when-clause that will land in the contract. Include these in the tool's section of the design doc — they become the literal `errors: [...]` entries during scaffolding and inform recovery messaging. Not every failure needs a contract entry; baseline infrastructure errors (5xx, timeouts, validation) are fine to let bubble.
358
360
 
359
361
  #### Design table
360
362
 
@@ -367,7 +369,7 @@ Summarize each tool:
367
369
  | **Description** | Concrete capability statement. Add operational guidance (prerequisites, constraints, gotchas) when non-obvious. |
368
370
  | **Input schema** | `.describe()` on every field. Constrained types (enums, literals, regex). Explain costs/tradeoffs of parameter choices. |
369
371
  | **Output schema** | Designed for the LLM's next action. Include chaining IDs. Communicate filtering. Post-write state where useful. |
370
- | **Error messages** | Name what went wrong and what the LLM should do about it. Include hints for common recovery paths. |
372
+ | **Errors** | Declare domain failure modes as a typed contract (`errors: [{ reason, code, when, retryable? }]`) so `ctx.fail` is type-checked and capable clients can preview failures via `tools/list`. Error messages name what went wrong and what the LLM should do about it. |
371
373
  | **Annotations** | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Helps clients auto-approve safely. |
372
374
  | **Auth scopes** | `tool:<snake_tool_name>:<verb>` or `resource:<kebab-resource-name>:<verb>` (e.g., `tool:inventory_search:read`, `resource:echo-app-ui:read`). Domain-led `<domain>:<verb>` (e.g., `inventory:read`) is an acceptable alternative — pick one convention per server and stay consistent. Skip for read-only or stdio-only servers. |
373
375
 
@@ -399,7 +401,7 @@ Skip for purely data/action-oriented servers.
399
401
 
400
402
  **Server-as-service.** When the server IS the source of truth (knowledge graph, in-memory task tracker, local scratchpad, embedded inference wrapper), the resilience table below doesn't apply — there's no upstream to retry. The design questions shift to state management: what's tenant-scoped vs. global, what TTLs apply, what survives a restart, what the storage backend is. Plan persistence via `ctx.state` for tenant-scoped KV (auto-namespaced by `tenantId`), or use a `StorageService` provider directly when data must cross tenants. Service init still happens in `setup()`, accessed via `getMyService()` at request time. Calls within the server are local and synchronous-ish — the API-efficiency table below also doesn't apply.
401
403
 
402
- For services wrapping external APIs, plan the resilience layer. See `docs/service-resilience.md` for full rationale.
404
+ For services wrapping external APIs, plan the resilience layer.
403
405
 
404
406
  | Concern | Decision |
405
407
  |:--------|:---------|
@@ -507,9 +509,9 @@ Execute the plan using the scaffolding skills:
507
509
 
508
510
  1. `add-service` for each service
509
511
  2. `add-tool` for each standard tool
510
- 3. `add-app-tool` for each MCP Apps tool (creates paired tool + UI resource)
511
- 4. `add-resource` for each standalone resource
512
- 5. `add-prompt` for each prompt
512
+ 3. `add-resource` for each standalone resource
513
+ 4. `add-prompt` for each prompt
514
+ 5. `add-app-tool` *only if any app tools survived the design step* (rare — see the App Tool row in Step 3)
513
515
  6. `devcheck` after each addition
514
516
 
515
517
  ## Checklist
@@ -529,6 +531,7 @@ Items without an `If …:` prefix apply to every design. Conditional items only
529
531
  - [ ] Output schemas designed for LLM's next action — chaining IDs, post-write state, filtering communicated
530
532
  - [ ] `format()` renders all data the LLM needs — different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data, not just a count or title
531
533
  - [ ] Error messages guide recovery — name what went wrong and what to do next
534
+ - [ ] **If a tool has known domain failure modes:** typed error contract declared (`errors: [{ reason, code, when, retryable? }]`) so `ctx.fail` is type-checked and capable clients see failures via `tools/list`
532
535
  - [ ] Annotations set correctly (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`)
533
536
  - [ ] Design doc written to `docs/design.md`
534
537
  - [ ] Design confirmed with user (or user pre-authorized implementation)
@@ -537,7 +540,7 @@ Items without an `If …:` prefix apply to every design. Conditional items only
537
540
  - [ ] **If state-aware procedural guidance adds value:** instruction tool considered with `nextToolSuggestions` pre-filled from diagnostics
538
541
  - [ ] **If workflow tools have destructive modes:** destructive arm guarded by `ctx.elicit` when available, with `destructiveHint` annotation as fallback for non-interactive clients
539
542
  - [ ] **If a parameter determines blast radius:** safe default set (e.g., `mode: 'preview'`, `dryRun: true`, `confirmCount` required)
540
- - [ ] **If interactive UI adds value to results:** MCP Apps tool identified (with `format()` text fallback for non-app hosts)
543
+ - [ ] **App tools default to no.** If one was proposed, verified there's a real human-in-the-loop in an MCP Apps-capable client justifying the iframe/CSP/`format()`-twin maintenance cost otherwise dropped in favor of a standard tool
541
544
  - [ ] **If the server exposes resources:** URIs use `{param}` templates, pagination planned for large lists
542
545
  - [ ] **If the server has external deps or shared state:** service layer planned (or explicitly skipped with reasoning)
543
546
  - [ ] **If services wrap external APIs:** resilience planned (retry boundary, backoff, parse classification)