@cyanheads/mcp-ts-core 0.7.6 → 0.8.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 (104) hide show
  1. package/CLAUDE.md +22 -7
  2. package/README.md +2 -2
  3. package/changelog/0.8.x/0.8.0.md +31 -0
  4. package/dist/core/context.d.ts +67 -0
  5. package/dist/core/context.d.ts.map +1 -1
  6. package/dist/core/context.js +46 -1
  7. package/dist/core/context.js.map +1 -1
  8. package/dist/core/index.d.ts +2 -1
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js +1 -0
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/linter/rules/error-contract-rules.d.ts +45 -0
  13. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -0
  14. package/dist/linter/rules/error-contract-rules.js +321 -0
  15. package/dist/linter/rules/error-contract-rules.js.map +1 -0
  16. package/dist/linter/rules/handler-body-rules.d.ts +18 -0
  17. package/dist/linter/rules/handler-body-rules.d.ts.map +1 -0
  18. package/dist/linter/rules/handler-body-rules.js +134 -0
  19. package/dist/linter/rules/handler-body-rules.js.map +1 -0
  20. package/dist/linter/rules/index.d.ts +2 -0
  21. package/dist/linter/rules/index.d.ts.map +1 -1
  22. package/dist/linter/rules/index.js +2 -0
  23. package/dist/linter/rules/index.js.map +1 -1
  24. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  25. package/dist/linter/rules/resource-rules.js +9 -0
  26. package/dist/linter/rules/resource-rules.js.map +1 -1
  27. package/dist/linter/rules/source-text.d.ts +19 -0
  28. package/dist/linter/rules/source-text.d.ts.map +1 -0
  29. package/dist/linter/rules/source-text.js +96 -0
  30. package/dist/linter/rules/source-text.js.map +1 -0
  31. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  32. package/dist/linter/rules/tool-rules.js +9 -0
  33. package/dist/linter/rules/tool-rules.js.map +1 -1
  34. package/dist/logs/combined.log +4 -4
  35. package/dist/logs/error.log +4 -4
  36. package/dist/mcp-server/apps/appBuilders.d.ts +9 -4
  37. package/dist/mcp-server/apps/appBuilders.d.ts.map +1 -1
  38. package/dist/mcp-server/apps/appBuilders.js +4 -0
  39. package/dist/mcp-server/apps/appBuilders.js.map +1 -1
  40. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  41. package/dist/mcp-server/resources/resource-registration.js +3 -2
  42. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  43. package/dist/mcp-server/resources/utils/resourceDefinition.d.ts +13 -5
  44. package/dist/mcp-server/resources/utils/resourceDefinition.d.ts.map +1 -1
  45. package/dist/mcp-server/resources/utils/resourceDefinition.js.map +1 -1
  46. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  47. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -4
  48. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  49. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  50. package/dist/mcp-server/tools/tool-registration.js +13 -7
  51. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  52. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +64 -16
  53. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  54. package/dist/mcp-server/tools/utils/toolDefinition.js +25 -11
  55. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  56. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  57. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +6 -4
  58. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  59. package/dist/testing/index.d.ts +8 -0
  60. package/dist/testing/index.d.ts.map +1 -1
  61. package/dist/testing/index.js +5 -1
  62. package/dist/testing/index.js.map +1 -1
  63. package/dist/types-global/errors.d.ts +82 -0
  64. package/dist/types-global/errors.d.ts.map +1 -1
  65. package/dist/types-global/errors.js +25 -0
  66. package/dist/types-global/errors.js.map +1 -1
  67. package/dist/utils/formatting/index.d.ts +1 -0
  68. package/dist/utils/formatting/index.d.ts.map +1 -1
  69. package/dist/utils/formatting/index.js +1 -0
  70. package/dist/utils/formatting/index.js.map +1 -1
  71. package/dist/utils/formatting/partialResult.d.ts +145 -0
  72. package/dist/utils/formatting/partialResult.d.ts.map +1 -0
  73. package/dist/utils/formatting/partialResult.js +145 -0
  74. package/dist/utils/formatting/partialResult.js.map +1 -0
  75. package/dist/utils/index.d.ts +2 -1
  76. package/dist/utils/index.d.ts.map +1 -1
  77. package/dist/utils/index.js +2 -1
  78. package/dist/utils/index.js.map +1 -1
  79. package/dist/utils/network/httpError.d.ts +112 -0
  80. package/dist/utils/network/httpError.d.ts.map +1 -0
  81. package/dist/utils/network/httpError.js +153 -0
  82. package/dist/utils/network/httpError.js.map +1 -0
  83. package/dist/utils/network/retry.d.ts.map +1 -1
  84. package/dist/utils/network/retry.js +0 -1
  85. package/dist/utils/network/retry.js.map +1 -1
  86. package/package.json +5 -4
  87. package/scripts/split-changelog.ts +133 -0
  88. package/skills/add-app-tool/SKILL.md +12 -0
  89. package/skills/add-resource/SKILL.md +40 -0
  90. package/skills/add-service/SKILL.md +47 -0
  91. package/skills/add-test/SKILL.md +39 -0
  92. package/skills/add-tool/SKILL.md +39 -4
  93. package/skills/api-context/SKILL.md +75 -1
  94. package/skills/api-errors/SKILL.md +162 -4
  95. package/skills/api-linter/SKILL.md +223 -3
  96. package/skills/api-testing/SKILL.md +79 -4
  97. package/skills/api-utils/SKILL.md +4 -2
  98. package/skills/design-mcp-server/SKILL.md +13 -10
  99. package/skills/field-test/SKILL.md +8 -2
  100. package/skills/maintenance/SKILL.md +2 -2
  101. package/skills/report-issue-framework/SKILL.md +2 -2
  102. package/skills/security-pass/SKILL.md +6 -5
  103. package/templates/AGENTS.md +23 -8
  104. package/templates/CLAUDE.md +23 -8
@@ -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)
@@ -132,8 +132,8 @@ Runs `initialize`, captures the session id, sends `notifications/initialized`.
132
132
 
133
133
  ```bash
134
134
  . /tmp/mcp-field-test.sh
135
- mcp_call tools/list | jq '.result.tools[] | {name, description, inputSchema, outputSchema}'
136
- mcp_call resources/list | jq '.result.resources[] | {uri, name, mimeType}'
135
+ mcp_call tools/list | jq '.result.tools[] | {name, description, inputSchema, outputSchema, errors: ._meta["mcp-ts-core/errors"]}'
136
+ mcp_call resources/list | jq '.result.resources[] | {uri, name, mimeType, errors: ._meta["mcp-ts-core/errors"]}'
137
137
  mcp_call prompts/list | jq '.result.prompts[] | {name, description, arguments}'
138
138
  ```
139
139
 
@@ -171,6 +171,8 @@ Treat any hit as a `ux` finding in the report. The authoring rule lives under *T
171
171
  | Hits external API / live upstream | One call that exercises upstream; note rate-limit / timeout / transient-failure behavior |
172
172
  | Chained with other tools (search → detail → act) | Run one representative chain end-to-end; does each step return the IDs/cursors the next needs? |
173
173
  | `cursor` / `offset` / `limit` params | Pagination: second page, end-of-list |
174
+ | Tool declared an `errors: [...]` contract | Error contract (tool): trigger ≥1 declared failure mode. Verify `result._meta.error.code` matches the contract entry, `result._meta.error.data.reason` is the declared reason (only present when the handler threw an `McpError` — `ctx.fail` always does, plain `throw new Error(...)` does not), and `content[0].text` is actionable. Reasons declared but unreachable from any input are dead contract entries. |
175
+ | Resource declared an `errors: [...]` contract | Error contract (resource): trigger ≥1 declared failure mode by reading a URI that exercises it. Resources re-throw errors at the JSON-RPC level — verify `error.code` matches the contract entry and `error.data.reason` is the declared reason. (Resources don't use the `result.isError` envelope — they fail the request itself.) |
174
176
 
175
177
  **Resources.** Happy path, not-found URI, `list` if defined, pagination if used.
176
178
  **Prompts.** Happy path, defaults omitted, skim message quality.
@@ -191,6 +193,8 @@ For each call, capture: input sent, response (trim huge payloads to files), whet
191
193
  **Interpreting responses**
192
194
 
193
195
  - Tool domain errors return `{result: {content: [...], isError: true}}` — they live in `result`, not `error`. Check `isError`, not the JSON-RPC error field.
196
+ - **Tool error code/reason** rides on `result._meta.error.{code, data.reason}` — inspect that, not just the text. `data` is only spread when the handler threw an `McpError` (or `ZodError`); plain `throw new Error(...)` won't populate `data.reason`. Use `ctx.fail`-thrown errors when the contract reason matters.
197
+ - **Resource errors** are JSON-RPC-level — they appear in the top-level `error.{code, data.reason}` field, not inside `result`. Resource handlers re-throw rather than producing an `isError` envelope.
194
198
  - JSON-RPC `error` only appears for protocol issues (bad session, malformed envelope, unknown method).
195
199
  - `mcp_call` already strips SSE framing. Pipe to `jq` for readability.
196
200
 
@@ -253,6 +257,8 @@ End with:
253
257
  - [ ] Catalog surfaced and presented; descriptions audited for leaks (implementation details, meta-coaching, consumer-aware phrasing)
254
258
  - [ ] Universal battery run on every definition
255
259
  - [ ] Situational categories applied only when triggered
260
+ - [ ] **If a tool declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; `result._meta.error.code` and `data.reason` verified against the contract entry
261
+ - [ ] **If a resource declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; top-level JSON-RPC `error.code` and `error.data.reason` verified against the contract entry
256
262
  - [ ] External-state / auth-gated tools handled explicitly (run, skip, or confirm)
257
263
  - [ ] Server stopped; state file removed
258
264
  - [ ] Report: summary paragraph → grouped findings → numbered options
@@ -4,7 +4,7 @@ description: >
4
4
  Investigate, adopt, and verify dependency updates — with special handling for `@cyanheads/mcp-ts-core`. Captures what changed, understands why, cross-references against the codebase, adopts framework improvements, syncs project skills, and runs final checks. Supports two entry modes: run the full flow end-to-end, or review updates you already applied.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.7"
7
+ version: "1.8"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -65,7 +65,7 @@ Scan specifically for:
65
65
 
66
66
  | Area | Adoption Check |
67
67
  |:-----|:---------------|
68
- | New error factories in `/errors` | Replace ad-hoc `new McpError(...)` with factories where applicable |
68
+ | New `/errors` surface — factories, typed contracts (`errors[]` + `ctx.fail`), `httpErrorFromResponse` | Replace ad-hoc `new McpError(...)` with factories; declare `errors: [...]` on tools that surface domain-specific failure modes; route declared throws through `ctx.fail(reason, …)` so the conformance lint is happy |
69
69
  | New utilities in `/utils` | Identify any that supersede local helper code |
70
70
  | New context capabilities | Added `ctx.*` methods worth adopting |
71
71
  | Provider/service APIs | Updates to `OpenRouterProvider`, `SpeechService`, `GraphService`, etc. |
@@ -4,7 +4,7 @@ description: >
4
4
  File a bug or feature request against @cyanheads/mcp-ts-core when you hit a framework issue. Use when a builder, utility, context method, or config behaves contrary to the documented API — not for server-specific application bugs.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.3"
7
+ version: "1.4"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -141,7 +141,7 @@ Format: `bug(<scope>): concise description`
141
141
  | `prompt` | Prompt builder, generate, args |
142
142
  | `context` | Context, logger, state, progress, elicit, sample |
143
143
  | `config` | AppConfig, parseConfig, env parsing |
144
- | `errors` | McpError, error factories, auto-classification |
144
+ | `errors` | McpError, error factories, typed contracts (`errors[]` / `ctx.fail`), conformance lint, `httpErrorFromResponse`, auto-classification |
145
145
  | `auth` | Auth modes, scope checking, JWT/OAuth |
146
146
  | `storage` | StorageService, providers |
147
147
  | `transport` | stdio/http transport, SSE, session handling |
@@ -4,7 +4,7 @@ description: >
4
4
  Review an MCP server for common security gaps: LLM-facing surfaces as injection vector (tools, resources, prompts, descriptions), scope blast radius, destructive ops without consent, upstream auth shape, input sinks (URL / path / roots / shell / sampling / schema strictness / ReDoS), tenant isolation, leakage through errors and telemetry, unbounded resources, and HTTP-mode deployment surface. Use before a release, after a batch of handler changes, or when the user asks for a security review, audit, or hardening pass. Produces grouped findings and a numbered options list.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.1"
7
+ version: "1.2"
8
8
  audience: external
9
9
  type: audit
10
10
  ---
@@ -203,10 +203,10 @@ grep -rn "^let " src/services/
203
203
 
204
204
  What accidentally reaches the LLM, user, or observability sinks.
205
205
 
206
- **Look in:** `throw new McpError(...)` sites, `McpError.data` fields, output schemas, and every logging / telemetry surface — not just `ctx.log`.
206
+ **Look in:** `throw new McpError(...)` and `ctx.fail(reason, msg, data)` sites, error factory calls (`notFound`, `httpErrorFromResponse`, …), `McpError.data` fields (the `data` arg flows through both paths), output schemas, and every logging / telemetry surface — not just `ctx.log`.
207
207
 
208
208
  ```bash
209
- grep -rn "new McpError" src/
209
+ grep -rnE "new McpError|ctx\.fail\(|httpErrorFromResponse\(" src/
210
210
  grep -rnE "\b(ctx\.log|console\.(log|info|warn|error|debug)|logger\.)" src/
211
211
  grep -rnE "(Sentry\.|captureException|setTag|setContext|addBreadcrumb)" src/
212
212
  grep -rnE "(setAttribute|setAttributes|span\.)" src/ # OpenTelemetry
@@ -214,7 +214,8 @@ grep -rnE "(setAttribute|setAttributes|span\.)" src/ # OpenTelemetry
214
214
 
215
215
  **Check:**
216
216
 
217
- - Error `data` fields carry upstream response bodies, auth headers, stack traces?
217
+ - Error `data` fields (whether passed via `ctx.fail(reason, msg, data)`, `new McpError(code, msg, data)`, or factory calls) carry upstream response bodies, auth headers, stack traces?
218
+ - `httpErrorFromResponse` body capture sweeping in too much (default 500-byte cap is fine for most APIs but consider `captureBody: false` when the upstream returns auth-bearing payloads)?
218
219
  - Output schemas include token prefixes, internal IDs, session identifiers?
219
220
  - `format()` renders fields that shouldn't leave the server?
220
221
  - `ctx.log.info(msg, body)` where `body` is the raw request (may contain secrets)?
@@ -222,7 +223,7 @@ grep -rnE "(setAttribute|setAttributes|span\.)" src/ # OpenTelemetry
222
223
  - OpenTelemetry span attributes / Sentry breadcrumbs carry tokens, PII, or full request bodies?
223
224
  - Secret / token / HMAC comparisons use `===` or `==` instead of constant-time (`timingSafeEqual` / `crypto.timingSafeEqual`) — leaks length and prefix via timing?
224
225
 
225
- **Smell:** `throw new McpError(code, upstream.message, { raw: upstream.body })`. Or: `if (apiKey === expected)` on a request-auth path.
226
+ **Smell:** `throw new McpError(code, upstream.message, { raw: upstream.body })` or `throw ctx.fail('upstream_failed', e.message, { raw: e.response.body })`. Or: `if (apiKey === expected)` on a request-auth path.
226
227
 
227
228
  #### Axis 8 — Resource bounds
228
229
 
@@ -165,24 +165,39 @@ Handlers receive a unified `ctx` object. Key properties:
165
165
 
166
166
  ## Errors
167
167
 
168
- Handlers throw — the framework catches, classifies, and formats. Three escalation levels:
168
+ Handlers throw — the framework catches, classifies, and formats.
169
+
170
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, retryable? }]` on `tool()` / `resource()` to advertise the failure surface in `tools/list` (under `_meta['mcp-ts-core/errors']`) and receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
169
171
 
170
172
  ```ts
171
- // 1. Plain Error — framework auto-classifies from message patterns
172
- throw new Error('Item not found'); // NotFound
173
- throw new Error('Invalid query format'); // → ValidationError
173
+ errors: [
174
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No item matched the query' },
175
+ ],
176
+ async handler(input, ctx) {
177
+ const item = await db.find(input.id);
178
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
179
+ return item;
180
+ }
181
+ ```
174
182
 
175
- // 2. Error factories explicit code, concise
176
- import { notFound, validationError, forbidden, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
183
+ **Fallback (no contract entry fits):** throw via factories or plain `Error`.
184
+
185
+ ```ts
186
+ // Error factories — explicit code
187
+ import { notFound, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
177
188
  throw notFound('Item not found', { itemId });
178
189
  throw serviceUnavailable('API unavailable', { url }, { cause: err });
179
190
 
180
- // 3. McpErrorfull control over code and data
191
+ // Plain Errorframework auto-classifies from message patterns
192
+ throw new Error('Item not found'); // → NotFound
193
+ throw new Error('Invalid query format'); // → ValidationError
194
+
195
+ // McpError — when no factory exists for the code
181
196
  import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
182
197
  throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection failed', { pool: 'primary' });
183
198
  ```
184
199
 
185
- Plain `Error` is fine for most cases. Use factories when the error code matters. See framework CLAUDE.md for the full auto-classification table and all available factories.
200
+ See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all available factories, and the contract reference.
186
201
 
187
202
  ---
188
203
 
@@ -165,24 +165,39 @@ Handlers receive a unified `ctx` object. Key properties:
165
165
 
166
166
  ## Errors
167
167
 
168
- Handlers throw — the framework catches, classifies, and formats. Three escalation levels:
168
+ Handlers throw — the framework catches, classifies, and formats.
169
+
170
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, retryable? }]` on `tool()` / `resource()` to advertise the failure surface in `tools/list` (under `_meta['mcp-ts-core/errors']`) and receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
169
171
 
170
172
  ```ts
171
- // 1. Plain Error — framework auto-classifies from message patterns
172
- throw new Error('Item not found'); // NotFound
173
- throw new Error('Invalid query format'); // → ValidationError
173
+ errors: [
174
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No item matched the query' },
175
+ ],
176
+ async handler(input, ctx) {
177
+ const item = await db.find(input.id);
178
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
179
+ return item;
180
+ }
181
+ ```
174
182
 
175
- // 2. Error factories explicit code, concise
176
- import { notFound, validationError, forbidden, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
183
+ **Fallback (no contract entry fits):** throw via factories or plain `Error`.
184
+
185
+ ```ts
186
+ // Error factories — explicit code
187
+ import { notFound, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
177
188
  throw notFound('Item not found', { itemId });
178
189
  throw serviceUnavailable('API unavailable', { url }, { cause: err });
179
190
 
180
- // 3. McpErrorfull control over code and data
191
+ // Plain Errorframework auto-classifies from message patterns
192
+ throw new Error('Item not found'); // → NotFound
193
+ throw new Error('Invalid query format'); // → ValidationError
194
+
195
+ // McpError — when no factory exists for the code
181
196
  import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
182
197
  throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection failed', { pool: 'primary' });
183
198
  ```
184
199
 
185
- Plain `Error` is fine for most cases. Use factories when the error code matters. See framework CLAUDE.md for the full auto-classification table and all available factories.
200
+ See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all available factories, and the contract reference.
186
201
 
187
202
  ---
188
203