@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.
- package/CLAUDE.md +22 -7
- package/README.md +2 -2
- package/changelog/0.8.x/0.8.0.md +31 -0
- package/dist/core/context.d.ts +67 -0
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +46 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/linter/rules/error-contract-rules.d.ts +45 -0
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -0
- package/dist/linter/rules/error-contract-rules.js +321 -0
- package/dist/linter/rules/error-contract-rules.js.map +1 -0
- package/dist/linter/rules/handler-body-rules.d.ts +18 -0
- package/dist/linter/rules/handler-body-rules.d.ts.map +1 -0
- package/dist/linter/rules/handler-body-rules.js +134 -0
- package/dist/linter/rules/handler-body-rules.js.map +1 -0
- package/dist/linter/rules/index.d.ts +2 -0
- package/dist/linter/rules/index.d.ts.map +1 -1
- package/dist/linter/rules/index.js +2 -0
- package/dist/linter/rules/index.js.map +1 -1
- package/dist/linter/rules/resource-rules.d.ts.map +1 -1
- package/dist/linter/rules/resource-rules.js +9 -0
- package/dist/linter/rules/resource-rules.js.map +1 -1
- package/dist/linter/rules/source-text.d.ts +19 -0
- package/dist/linter/rules/source-text.d.ts.map +1 -0
- package/dist/linter/rules/source-text.js +96 -0
- package/dist/linter/rules/source-text.js.map +1 -0
- package/dist/linter/rules/tool-rules.d.ts.map +1 -1
- package/dist/linter/rules/tool-rules.js +9 -0
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/logs/combined.log +4 -4
- package/dist/logs/error.log +4 -4
- package/dist/mcp-server/apps/appBuilders.d.ts +9 -4
- package/dist/mcp-server/apps/appBuilders.d.ts.map +1 -1
- package/dist/mcp-server/apps/appBuilders.js +4 -0
- package/dist/mcp-server/apps/appBuilders.js.map +1 -1
- package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
- package/dist/mcp-server/resources/resource-registration.js +3 -2
- package/dist/mcp-server/resources/resource-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceDefinition.d.ts +13 -5
- package/dist/mcp-server/resources/utils/resourceDefinition.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceDefinition.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -4
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +13 -7
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts +64 -16
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.js +25 -11
- package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +6 -4
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +5 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/types-global/errors.d.ts +82 -0
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/types-global/errors.js +25 -0
- package/dist/types-global/errors.js.map +1 -1
- package/dist/utils/formatting/index.d.ts +1 -0
- package/dist/utils/formatting/index.d.ts.map +1 -1
- package/dist/utils/formatting/index.js +1 -0
- package/dist/utils/formatting/index.js.map +1 -1
- package/dist/utils/formatting/partialResult.d.ts +145 -0
- package/dist/utils/formatting/partialResult.d.ts.map +1 -0
- package/dist/utils/formatting/partialResult.js +145 -0
- package/dist/utils/formatting/partialResult.js.map +1 -0
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/network/httpError.d.ts +112 -0
- package/dist/utils/network/httpError.d.ts.map +1 -0
- package/dist/utils/network/httpError.js +153 -0
- package/dist/utils/network/httpError.js.map +1 -0
- package/dist/utils/network/retry.d.ts.map +1 -1
- package/dist/utils/network/retry.js +0 -1
- package/dist/utils/network/retry.js.map +1 -1
- package/package.json +5 -4
- package/scripts/split-changelog.ts +133 -0
- package/skills/add-app-tool/SKILL.md +12 -0
- package/skills/add-resource/SKILL.md +40 -0
- package/skills/add-service/SKILL.md +47 -0
- package/skills/add-test/SKILL.md +39 -0
- package/skills/add-tool/SKILL.md +39 -4
- package/skills/api-context/SKILL.md +75 -1
- package/skills/api-errors/SKILL.md +162 -4
- package/skills/api-linter/SKILL.md +223 -3
- package/skills/api-testing/SKILL.md +79 -4
- package/skills/api-utils/SKILL.md +4 -2
- package/skills/design-mcp-server/SKILL.md +13 -10
- package/skills/field-test/SKILL.md +8 -2
- package/skills/maintenance/SKILL.md +2 -2
- package/skills/report-issue-framework/SKILL.md +2 -2
- package/skills/security-pass/SKILL.md +6 -5
- package/templates/AGENTS.md +23 -8
- package/templates/CLAUDE.md +23 -8
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Helpers for converting HTTP `Response` objects into properly
|
|
3
|
+
* classified `McpError` instances. Replaces the hand-rolled status → code ladder
|
|
4
|
+
* that consumer servers tend to write (and get wrong, especially for 401/403/408).
|
|
5
|
+
* @module src/utils/network/httpError
|
|
6
|
+
*/
|
|
7
|
+
import { JsonRpcErrorCode, McpError } from '../../types-global/errors.js';
|
|
8
|
+
/**
|
|
9
|
+
* Maps an HTTP status code to a `JsonRpcErrorCode`. Covers the full client/server
|
|
10
|
+
* 4xx/5xx range, with specific mappings for the codes most upstream APIs use to
|
|
11
|
+
* signal authoritative outcomes (auth failures, conflicts, validation, rate limits).
|
|
12
|
+
*
|
|
13
|
+
* Returns `undefined` when the status is in the 1xx/2xx/3xx range — those are not
|
|
14
|
+
* errors and the caller should not be invoking error mapping on them.
|
|
15
|
+
*
|
|
16
|
+
* | Status | Code |
|
|
17
|
+
* |:-------|:-----|
|
|
18
|
+
* | 400 | `InvalidParams` |
|
|
19
|
+
* | 401 | `Unauthorized` |
|
|
20
|
+
* | 402 | `Forbidden` (payment-required, treated as access denial) |
|
|
21
|
+
* | 403 | `Forbidden` |
|
|
22
|
+
* | 404 | `NotFound` |
|
|
23
|
+
* | 405, 406, 410, 415 | `InvalidRequest` |
|
|
24
|
+
* | 408 | `Timeout` |
|
|
25
|
+
* | 409 | `Conflict` |
|
|
26
|
+
* | 412, 416, 417 | `InvalidRequest` |
|
|
27
|
+
* | 422 | `ValidationError` |
|
|
28
|
+
* | 423, 424 | `Conflict` |
|
|
29
|
+
* | 425 | `Timeout` |
|
|
30
|
+
* | 428 | `InvalidRequest` |
|
|
31
|
+
* | 429 | `RateLimited` |
|
|
32
|
+
* | 431, 451 | `InvalidRequest` |
|
|
33
|
+
* | 4xx (other) | `InvalidRequest` |
|
|
34
|
+
* | 500, 501 | `InternalError` |
|
|
35
|
+
* | 502, 503 | `ServiceUnavailable` |
|
|
36
|
+
* | 504 | `Timeout` |
|
|
37
|
+
* | 5xx (other) | `ServiceUnavailable` |
|
|
38
|
+
*/
|
|
39
|
+
export function httpStatusToErrorCode(status) {
|
|
40
|
+
if (status < 400)
|
|
41
|
+
return;
|
|
42
|
+
switch (status) {
|
|
43
|
+
case 400:
|
|
44
|
+
return JsonRpcErrorCode.InvalidParams;
|
|
45
|
+
case 401:
|
|
46
|
+
return JsonRpcErrorCode.Unauthorized;
|
|
47
|
+
case 402:
|
|
48
|
+
case 403:
|
|
49
|
+
return JsonRpcErrorCode.Forbidden;
|
|
50
|
+
case 404:
|
|
51
|
+
return JsonRpcErrorCode.NotFound;
|
|
52
|
+
case 408:
|
|
53
|
+
return JsonRpcErrorCode.Timeout;
|
|
54
|
+
case 409:
|
|
55
|
+
return JsonRpcErrorCode.Conflict;
|
|
56
|
+
case 422:
|
|
57
|
+
return JsonRpcErrorCode.ValidationError;
|
|
58
|
+
case 423:
|
|
59
|
+
case 424:
|
|
60
|
+
return JsonRpcErrorCode.Conflict;
|
|
61
|
+
case 425:
|
|
62
|
+
return JsonRpcErrorCode.Timeout;
|
|
63
|
+
case 429:
|
|
64
|
+
return JsonRpcErrorCode.RateLimited;
|
|
65
|
+
case 500:
|
|
66
|
+
case 501:
|
|
67
|
+
return JsonRpcErrorCode.InternalError;
|
|
68
|
+
case 502:
|
|
69
|
+
case 503:
|
|
70
|
+
return JsonRpcErrorCode.ServiceUnavailable;
|
|
71
|
+
case 504:
|
|
72
|
+
return JsonRpcErrorCode.Timeout;
|
|
73
|
+
default:
|
|
74
|
+
return status >= 500 ? JsonRpcErrorCode.ServiceUnavailable : JsonRpcErrorCode.InvalidRequest;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const DEFAULT_BODY_LIMIT = 500;
|
|
78
|
+
/**
|
|
79
|
+
* Builds an `McpError` from an HTTP `Response`, with status-aware classification
|
|
80
|
+
* and optional body capture.
|
|
81
|
+
*
|
|
82
|
+
* Reads the response body (consuming it) when `captureBody` is true, so callers
|
|
83
|
+
* must `response.clone()` first if they intend to read the body elsewhere.
|
|
84
|
+
*
|
|
85
|
+
* Always returns an `McpError` even for 1xx/2xx/3xx — the caller is expected to
|
|
86
|
+
* have verified `!response.ok` first, but the helper falls back to a sensible
|
|
87
|
+
* code (`InternalError`) instead of silently producing nothing.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* const response = await fetch(url);
|
|
92
|
+
* if (!response.ok) {
|
|
93
|
+
* throw await httpErrorFromResponse(response, {
|
|
94
|
+
* service: 'NCBI',
|
|
95
|
+
* data: { endpoint: 'esearch' },
|
|
96
|
+
* });
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* @example Wrapping a network failure
|
|
101
|
+
* ```ts
|
|
102
|
+
* try {
|
|
103
|
+
* const response = await fetch(url);
|
|
104
|
+
* if (!response.ok) {
|
|
105
|
+
* throw await httpErrorFromResponse(response, { service: 'NCBI' });
|
|
106
|
+
* }
|
|
107
|
+
* return await response.text();
|
|
108
|
+
* } catch (error) {
|
|
109
|
+
* if (error instanceof McpError) throw error;
|
|
110
|
+
* throw new McpError(JsonRpcErrorCode.ServiceUnavailable, 'NCBI request failed', { url }, { cause: error });
|
|
111
|
+
* }
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export async function httpErrorFromResponse(response, options = {}) {
|
|
115
|
+
const { captureBody = true, bodyLimit = DEFAULT_BODY_LIMIT, service, data: extraData, cause, codeOverride, } = options;
|
|
116
|
+
const code = codeOverride?.(response.status) ??
|
|
117
|
+
httpStatusToErrorCode(response.status) ??
|
|
118
|
+
JsonRpcErrorCode.InternalError;
|
|
119
|
+
const subject = service ?? safeHost(response.url) ?? 'Upstream';
|
|
120
|
+
const statusText = response.statusText ? ` ${response.statusText}` : '';
|
|
121
|
+
let body;
|
|
122
|
+
if (captureBody) {
|
|
123
|
+
try {
|
|
124
|
+
const raw = await response.text();
|
|
125
|
+
body = raw.length > bodyLimit ? `${raw.slice(0, bodyLimit)}…` : raw;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* body unreadable (already consumed, network error mid-stream) */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const retryAfter = response.headers.get('retry-after') ?? undefined;
|
|
132
|
+
const data = {
|
|
133
|
+
url: response.url || undefined,
|
|
134
|
+
status: response.status,
|
|
135
|
+
statusText: response.statusText || undefined,
|
|
136
|
+
...(body !== undefined && { body }),
|
|
137
|
+
...(retryAfter !== undefined && { retryAfter }),
|
|
138
|
+
...extraData,
|
|
139
|
+
};
|
|
140
|
+
return new McpError(code, `${subject} returned HTTP ${response.status}${statusText}.`, data, cause !== undefined ? { cause } : undefined);
|
|
141
|
+
}
|
|
142
|
+
/** Returns the hostname from a URL string, or `undefined` if it can't be parsed. */
|
|
143
|
+
function safeHost(url) {
|
|
144
|
+
if (!url)
|
|
145
|
+
return;
|
|
146
|
+
try {
|
|
147
|
+
return new URL(url).host;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=httpError.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"httpError.js","sourceRoot":"","sources":["../../../src/utils/network/httpError.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../../src/utils/network/retry.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../../src/utils/network/retry.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAYzE,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;IAEzB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;IAE1C;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AA6DD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAsB,SAAS,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,CAAC,CAAC,CAiD/F"}
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* may fail transiently. Designed so the retry boundary covers the full pipeline
|
|
4
4
|
* (HTTP fetch + response parsing/validation), not just the network call.
|
|
5
5
|
* @module src/utils/network/retry
|
|
6
|
-
* @see docs/service-resilience.md
|
|
7
6
|
*/
|
|
8
7
|
import { JsonRpcErrorCode, McpError } from '../../types-global/errors.js';
|
|
9
8
|
import { logger } from '../../utils/internal/logger.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"retry.js","sourceRoot":"","sources":["../../../src/utils/network/retry.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"retry.js","sourceRoot":"","sources":["../../../src/utils/network/retry.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyanheads/mcp-ts-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"mcpName": "io.github.cyanheads/mcp-ts-core",
|
|
5
5
|
"description": "Agent-native TypeScript framework for building MCP servers. Declarative definitions with auth, multi-backend storage, OpenTelemetry, and first-class support for Bun/Node/Cloudflare Workers.",
|
|
6
6
|
"main": "dist/core/index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"scripts/clean.ts",
|
|
17
17
|
"scripts/devcheck.ts",
|
|
18
18
|
"scripts/lint-mcp.ts",
|
|
19
|
+
"scripts/split-changelog.ts",
|
|
19
20
|
"scripts/tree.ts",
|
|
20
21
|
"skills/",
|
|
21
22
|
"templates/",
|
|
@@ -173,7 +174,7 @@
|
|
|
173
174
|
"@opentelemetry/sdk-node": "^0.215.0",
|
|
174
175
|
"@opentelemetry/sdk-trace-node": "^2.7.0",
|
|
175
176
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
|
176
|
-
"@supabase/supabase-js": "^2.105.
|
|
177
|
+
"@supabase/supabase-js": "^2.105.1",
|
|
177
178
|
"@types/bun": "^1.3.13",
|
|
178
179
|
"@types/js-yaml": "^4.0.9",
|
|
179
180
|
"@types/node": "^25.6.0",
|
|
@@ -194,7 +195,7 @@
|
|
|
194
195
|
"js-yaml": "^4.1.1",
|
|
195
196
|
"linkedom": "^0.18.12",
|
|
196
197
|
"node-cron": "^4.2.1",
|
|
197
|
-
"openai": "^6.
|
|
198
|
+
"openai": "^6.35.0",
|
|
198
199
|
"papaparse": "^5.5.3",
|
|
199
200
|
"partial-json": "^0.1.7",
|
|
200
201
|
"pdf-lib": "^1.17.1",
|
|
@@ -204,7 +205,7 @@
|
|
|
204
205
|
"tsc-alias": "^1.8.16",
|
|
205
206
|
"typedoc": "^0.28.19",
|
|
206
207
|
"typescript": "^6.0.3",
|
|
207
|
-
"unpdf": "^1.6.
|
|
208
|
+
"unpdf": "^1.6.1",
|
|
208
209
|
"validator": "^13.15.35",
|
|
209
210
|
"vite": "8.0.10",
|
|
210
211
|
"vitest": "^4.1.5"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview One-shot migration: split the monolithic CHANGELOG.md into
|
|
4
|
+
* per-version files under `changelog/<major.minor>.x/<version>.md`.
|
|
5
|
+
*
|
|
6
|
+
* After the initial migration, this script is no longer needed — the
|
|
7
|
+
* per-version files become the source of truth and `scripts/build-changelog.ts`
|
|
8
|
+
* regenerates CHANGELOG.md from them. Keep this around only long enough to
|
|
9
|
+
* verify the round-trip (split → build → diff).
|
|
10
|
+
*
|
|
11
|
+
* Behavior:
|
|
12
|
+
* • Reads CHANGELOG.md, splits on `## [X.Y.Z] - YYYY-MM-DD` headers
|
|
13
|
+
* • Groups by minor series: 0.1.4 → changelog/0.1.x/0.1.4.md
|
|
14
|
+
* • Writes each version block with an H1 heading
|
|
15
|
+
* • Creates changelog/template.md template if missing
|
|
16
|
+
* • Overwrites existing per-version files (idempotent)
|
|
17
|
+
* • Drops the preamble (title + description) — that's handled by the build script
|
|
18
|
+
*
|
|
19
|
+
* Usage: bun run scripts/split-changelog.ts
|
|
20
|
+
*
|
|
21
|
+
* @module scripts/split-changelog
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const CHANGELOG_PATH = resolve('CHANGELOG.md');
|
|
28
|
+
const CHANGELOG_DIR = resolve('changelog');
|
|
29
|
+
const TEMPLATE_PATH = resolve(CHANGELOG_DIR, 'template.md');
|
|
30
|
+
|
|
31
|
+
const TEMPLATE_CONTENT = `# <version> — YYYY-MM-DD
|
|
32
|
+
|
|
33
|
+
<!-- Brief summary of the upcoming release — delete this comment when filled in. -->
|
|
34
|
+
|
|
35
|
+
## Added
|
|
36
|
+
|
|
37
|
+
-
|
|
38
|
+
|
|
39
|
+
## Changed
|
|
40
|
+
|
|
41
|
+
-
|
|
42
|
+
|
|
43
|
+
## Fixed
|
|
44
|
+
|
|
45
|
+
-
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
interface Section {
|
|
49
|
+
body: string;
|
|
50
|
+
date: string;
|
|
51
|
+
version: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractSections(content: string): Section[] {
|
|
55
|
+
const regex = /^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})$/gm;
|
|
56
|
+
const matches = [...content.matchAll(regex)];
|
|
57
|
+
if (matches.length === 0) {
|
|
58
|
+
throw new Error('No version sections found in CHANGELOG.md (expected ## [X.Y.Z] - YYYY-MM-DD)');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sections: Section[] = [];
|
|
62
|
+
for (let i = 0; i < matches.length; i++) {
|
|
63
|
+
const match = matches[i] as RegExpMatchArray & { index: number };
|
|
64
|
+
const next = matches[i + 1] as (RegExpMatchArray & { index: number }) | undefined;
|
|
65
|
+
const start = match.index + match[0].length;
|
|
66
|
+
const end = next ? next.index : content.length;
|
|
67
|
+
|
|
68
|
+
let body = content.slice(start, end);
|
|
69
|
+
body = body.replace(/^\n+/, '');
|
|
70
|
+
body = body.replace(/\n+---\n*$/, '\n');
|
|
71
|
+
body = body.replace(/\n*$/, '\n');
|
|
72
|
+
|
|
73
|
+
sections.push({
|
|
74
|
+
version: match[1] as string,
|
|
75
|
+
date: match[2] as string,
|
|
76
|
+
body,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return sections;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Promote heading levels by stripping one `#` from H2+ lines. Assumes no H1
|
|
84
|
+
* or H2 in section bodies (the version H2 was already stripped); the existing
|
|
85
|
+
* CHANGELOG.md uses H3 for Added/Changed/Fixed etc., which become H2 here.
|
|
86
|
+
*/
|
|
87
|
+
function promoteHeadings(body: string): string {
|
|
88
|
+
return body.replace(/^(#{2,6}) /gm, (_, hashes: string) => `${hashes.slice(1)} `);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toPerVersionFile(section: Section): string {
|
|
92
|
+
return `# ${section.version} — ${section.date}\n\n${promoteHeadings(section.body)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Extract the `major.minor.x` series directory for a version string. */
|
|
96
|
+
function seriesOf(version: string): string {
|
|
97
|
+
const [major, minor] = version.split('.');
|
|
98
|
+
if (!major || !minor) throw new Error(`Cannot derive series from version: ${version}`);
|
|
99
|
+
return `${major}.${minor}.x`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function main(): void {
|
|
103
|
+
const content = readFileSync(CHANGELOG_PATH, 'utf-8');
|
|
104
|
+
const sections = extractSections(content);
|
|
105
|
+
|
|
106
|
+
mkdirSync(CHANGELOG_DIR, { recursive: true });
|
|
107
|
+
|
|
108
|
+
const seriesCounts = new Map<string, number>();
|
|
109
|
+
|
|
110
|
+
for (const section of sections) {
|
|
111
|
+
const series = seriesOf(section.version);
|
|
112
|
+
const seriesDir = resolve(CHANGELOG_DIR, series);
|
|
113
|
+
mkdirSync(seriesDir, { recursive: true });
|
|
114
|
+
const path = resolve(seriesDir, `${section.version}.md`);
|
|
115
|
+
writeFileSync(path, toPerVersionFile(section));
|
|
116
|
+
seriesCounts.set(series, (seriesCounts.get(series) ?? 0) + 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const [series, count] of [...seriesCounts.entries()].sort()) {
|
|
120
|
+
console.log(` + changelog/${series}/ (${count} file${count === 1 ? '' : 's'})`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!existsSync(TEMPLATE_PATH)) {
|
|
124
|
+
writeFileSync(TEMPLATE_PATH, TEMPLATE_CONTENT);
|
|
125
|
+
console.log(' + changelog/template.md (format reference)');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(`\nSplit ${sections.length} versions into ${seriesCounts.size} series.`);
|
|
129
|
+
console.log('Next: `bun run scripts/build-changelog.ts` to regenerate CHANGELOG.md,');
|
|
130
|
+
console.log(' then `diff` against the original to verify byte-equality.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
main();
|
|
@@ -9,6 +9,18 @@ metadata:
|
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## When to Use
|
|
13
|
+
|
|
14
|
+
App tools are **rarely the right choice**. Reach for one only when all of the following hold:
|
|
15
|
+
|
|
16
|
+
1. A *human* will actively interact with the result in real time — not just an LLM consuming text.
|
|
17
|
+
2. The target deployment runs in a client that supports MCP Apps. Many clients (Claude Code, Cursor, most chat UIs) are tool-only and will only ever see the `format()` text fallback you have to maintain anyway.
|
|
18
|
+
3. The interaction the UI enables — scrubbing a dense table, approving a multi-step plan, filling a structured form — is core to the workflow, not nice-to-have rendering.
|
|
19
|
+
|
|
20
|
+
App tools cost more than standard tools: an iframe + CSP setup, `app.ontoolresult` / `callServerTool` plumbing, host-context wiring (theme, fonts, styles), and a `format()` text path that has to be content-complete because most clients see only that. Two surfaces to keep in sync, two failure modes per change.
|
|
21
|
+
|
|
22
|
+
Default to `add-tool`. This skill is the how-to once that bar is cleared — the "whether to" decision belongs in `design-mcp-server`.
|
|
23
|
+
|
|
12
24
|
## Context
|
|
13
25
|
|
|
14
26
|
MCP Apps extend the standard tool pattern with an interactive HTML UI rendered in a sandboxed iframe by the host. Each MCP App consists of two definitions:
|
|
@@ -107,6 +107,46 @@ await createApp({
|
|
|
107
107
|
|
|
108
108
|
If the repo already uses `src/mcp-server/resources/definitions/index.ts`, update that barrel instead of changing the registration style.
|
|
109
109
|
|
|
110
|
+
### Optional: declarative `errors[]` contract
|
|
111
|
+
|
|
112
|
+
Resources can opt into the same typed error contract as tools — surfaced in `resources/list` under `_meta['mcp-ts-core/errors']` and bound to a typed `ctx.fail(reason, …)` keyed by the declared reason union:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
116
|
+
|
|
117
|
+
export const articleResource = resource('article://{pmid}', {
|
|
118
|
+
description: 'Read an article by PMID.',
|
|
119
|
+
errors: [
|
|
120
|
+
{ reason: 'no_pmid_match', code: JsonRpcErrorCode.NotFound,
|
|
121
|
+
when: 'PMID not found in the index.' },
|
|
122
|
+
{ reason: 'withdrawn', code: JsonRpcErrorCode.NotFound,
|
|
123
|
+
when: 'Article was withdrawn upstream.' },
|
|
124
|
+
{ reason: 'upstream_throttled', code: JsonRpcErrorCode.RateLimited,
|
|
125
|
+
when: 'Upstream PubMed quota hit.', retryable: true },
|
|
126
|
+
],
|
|
127
|
+
params: z.object({ pmid: z.string().describe('PubMed ID') }),
|
|
128
|
+
async handler(params, ctx) {
|
|
129
|
+
const article = await fetchOne(params.pmid);
|
|
130
|
+
if (!article) throw ctx.fail('no_pmid_match', `PMID ${params.pmid} not indexed`);
|
|
131
|
+
if (article.withdrawn) throw ctx.fail('withdrawn');
|
|
132
|
+
return article;
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Without `errors[]`, the handler receives plain `Context` (no `fail` method) and throws via error factories (`notFound`, `serviceUnavailable`, …) directly. The contract is opt-in. See `skills/api-errors/SKILL.md` for the full pattern, baseline codes, and conformance rules.
|
|
138
|
+
|
|
139
|
+
### Other `resource()` options
|
|
140
|
+
|
|
141
|
+
Beyond `description`, `params`, `handler`, and `list`, the builder also supports:
|
|
142
|
+
|
|
143
|
+
| Field | Purpose |
|
|
144
|
+
|:------|:--------|
|
|
145
|
+
| `output` | Optional Zod schema for runtime validation of the handler return value (parity with `tool()`'s `output`). |
|
|
146
|
+
| `format` | Optional formatter mapping the handler's return to the `ReadResourceResult.contents[]` shape. Default: string passthrough; objects serialized to JSON. Override when you need to attach permissions, custom encodings, or split into multiple content items. |
|
|
147
|
+
| `annotations` | Resource annotations (e.g., `audience`, `priority`) — see `ResourceAnnotations`. |
|
|
148
|
+
| `title` | Human-readable display title (defaults to `name`). |
|
|
149
|
+
|
|
110
150
|
## Checklist
|
|
111
151
|
|
|
112
152
|
- [ ] File created at `src/mcp-server/resources/definitions/{{resource-name}}.resource.ts`
|
|
@@ -115,6 +115,7 @@ async fetchItem(id: string, ctx: Context): Promise<Item> {
|
|
|
115
115
|
`${this.baseUrl}/items/${id}`,
|
|
116
116
|
10_000,
|
|
117
117
|
ctx,
|
|
118
|
+
{ signal: ctx.signal },
|
|
118
119
|
);
|
|
119
120
|
const text = await response.text();
|
|
120
121
|
return this.parseResponse<Item>(text);
|
|
@@ -136,9 +137,37 @@ async fetchItem(id: string, ctx: Context): Promise<Item> {
|
|
|
136
137
|
3. **Classify parse failures by content.** If the upstream returns HTTP 200 with an HTML error page, detect it and throw `ServiceUnavailable` (transient) instead of `SerializationError` (non-transient).
|
|
137
138
|
4. **Exhausted retries say so.** `withRetry` automatically enriches the final error with attempt count — callers know retries were already attempted.
|
|
138
139
|
|
|
140
|
+
### When you need finer-grained HTTP error classification
|
|
141
|
+
|
|
142
|
+
`fetchWithTimeout` collapses every non-2xx into `ServiceUnavailable`. That's the safe default but it isn't always right — a `401` should be `Unauthorized`, a `429` should be `RateLimited` (and is retryable), a `408` should be `Timeout` (and is retryable). When you need the nuance, drop down to raw `fetch` + `httpErrorFromResponse`:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
|
|
146
|
+
|
|
147
|
+
async fetchItem(id: string, ctx: Context): Promise<Item> {
|
|
148
|
+
return withRetry(
|
|
149
|
+
async () => {
|
|
150
|
+
const response = await fetch(`${this.baseUrl}/items/${id}`, { signal: ctx.signal });
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
throw await httpErrorFromResponse(response, {
|
|
153
|
+
service: 'MyAPI',
|
|
154
|
+
data: { itemId: id },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return this.parseResponse<Item>(await response.text());
|
|
158
|
+
},
|
|
159
|
+
{ operation: 'fetchItem', context: ctx, signal: ctx.signal },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`httpErrorFromResponse` maps the full status table (401/403/408/422/429/5xx) to the appropriate `JsonRpcErrorCode`, captures the response body (truncated), and forwards `Retry-After` headers into `error.data.retryAfter`. The codes it produces line up with `withRetry`'s transient-code set, so retryable HTTP failures (429, 503, 504) are retried automatically and non-retryable ones (401, 404, 422) fail immediately.
|
|
165
|
+
|
|
139
166
|
### Response handler pattern
|
|
140
167
|
|
|
141
168
|
```typescript
|
|
169
|
+
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
|
|
170
|
+
|
|
142
171
|
parseResponse<T>(text: string): T {
|
|
143
172
|
// Detect HTML error pages masquerading as successful responses
|
|
144
173
|
if (/^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text)) {
|
|
@@ -188,6 +217,24 @@ function normalizeRepo(raw: RawRepo): Repo {
|
|
|
188
217
|
}
|
|
189
218
|
```
|
|
190
219
|
|
|
220
|
+
## Error Handling in Services
|
|
221
|
+
|
|
222
|
+
Services don't declare `errors: [...]` contracts and don't have `ctx.fail` — that contract surface is tool/resource-only. Inside services:
|
|
223
|
+
|
|
224
|
+
- **Throw via factories** when a specific code matters: `throw notFound(...)`, `throw rateLimited(...)`, `throw serviceUnavailable(...)`. The framework's auto-classifier catches anything else.
|
|
225
|
+
- **Wrap risky pipelines in `ErrorHandler.tryCatch`** when you want structured logging + auto-classification without writing try/catch boilerplate. It always rethrows — never swallows. Useful for parsing untrusted input (JSON, config) or third-party SDK calls whose error types you don't control:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { ErrorHandler } from '@cyanheads/mcp-ts-core/utils';
|
|
229
|
+
|
|
230
|
+
const parsed = await ErrorHandler.tryCatch(
|
|
231
|
+
() => JSON.parse(rawConfig),
|
|
232
|
+
{ operation: 'MyService.parseConfig', errorCode: JsonRpcErrorCode.ConfigurationError },
|
|
233
|
+
);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- **Tool/resource handlers bubble service errors unchanged** — the contract advertises the *advertised* failure surface, and any code thrown from a service still reaches the client correctly via the auto-classifier. The conformance lint scans handler source text only, so service-thrown codes aren't flagged.
|
|
237
|
+
|
|
191
238
|
## API Efficiency
|
|
192
239
|
|
|
193
240
|
When a service wraps an external API, design methods to minimize upstream calls. These patterns compound — a tool calling 3 service methods that each make N requests is 3N calls; batching drops it to 3.
|
package/skills/add-test/SKILL.md
CHANGED
|
@@ -38,6 +38,7 @@ Read the handler and identify:
|
|
|
38
38
|
| **`ctx.state` usage** | Use `createMockContext({ tenantId: 'test' })` to enable storage |
|
|
39
39
|
| **`ctx.elicit` / `ctx.sample`** | Mock with `vi.fn()`, also test the absent case (undefined) |
|
|
40
40
|
| **`ctx.progress`** | Use `createMockContext({ progress: true })` for task tools |
|
|
41
|
+
| **`ctx.fail` (typed contract)** | Definitions with `errors[]` need `fail` attached to the mock ctx — `createMockContext({ errors: myTool.errors })` does it for you. Assert on `data.reason` (stable per-contract entry), not just `code`. |
|
|
41
42
|
| **`format` function** | Test separately if defined — it's pure, no ctx needed. Verify it renders the IDs and fields the model needs, not just a count or title. For projection-style tools, test non-default field selections. |
|
|
42
43
|
| **Sparse upstream payloads** | For third-party API integrations, build a fixture with omitted fields. Assert normalized output still validates and `format()` preserves unknown values instead of inventing facts. |
|
|
43
44
|
| **Auth scopes** | Not tested at handler level (framework enforces) — skip |
|
|
@@ -76,6 +77,17 @@ describe('{{TOOL_EXPORT}}', () => {
|
|
|
76
77
|
await expect({{TOOL_EXPORT}}.handler(input, ctx)).rejects.toThrow();
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
// Only when the tool declares `errors: [...]`. Drop this block otherwise.
|
|
81
|
+
it('throws ctx.fail("{{REASON}}") for the declared failure mode', async () => {
|
|
82
|
+
const ctx = createMockContext({ errors: {{TOOL_EXPORT}}.errors });
|
|
83
|
+
const input = {{TOOL_EXPORT}}.input.parse({
|
|
84
|
+
// input that triggers the declared failure mode
|
|
85
|
+
});
|
|
86
|
+
await expect({{TOOL_EXPORT}}.handler(input, ctx)).rejects.toMatchObject({
|
|
87
|
+
data: { reason: '{{REASON}}' },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
79
91
|
it('formats output completely', () => {
|
|
80
92
|
const output = { /* mock output matching the output schema */ };
|
|
81
93
|
const blocks = {{TOOL_EXPORT}}.format!(output);
|
|
@@ -115,6 +127,13 @@ describe('{{RESOURCE_EXPORT}}', () => {
|
|
|
115
127
|
await expect({{RESOURCE_EXPORT}}.handler(params, ctx)).rejects.toThrow();
|
|
116
128
|
});
|
|
117
129
|
|
|
130
|
+
// For resources that declare an `errors: [...]` contract, pass the contract via
|
|
131
|
+
// `createMockContext` so the typed `ctx.fail` is wired automatically:
|
|
132
|
+
// const ctx = createMockContext({ errors: {{RESOURCE_EXPORT}}.errors });
|
|
133
|
+
// const err = await {{RESOURCE_EXPORT}}.handler(params, ctx).catch((e) => e);
|
|
134
|
+
// expect(err.code).toBe(JsonRpcErrorCode.NotFound);
|
|
135
|
+
// expect(err.data.reason).toBe('no_match');
|
|
136
|
+
|
|
118
137
|
it('lists available resources', async () => {
|
|
119
138
|
const listing = await {{RESOURCE_EXPORT}}.list!();
|
|
120
139
|
expect(listing.resources).toBeInstanceOf(Array);
|
|
@@ -139,9 +158,12 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
|
|
139
158
|
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
|
|
140
159
|
import { get{{ServiceClass}}, init{{ServiceClass}} } from '@/services/{{domain}}/{{domain}}-service.js';
|
|
141
160
|
|
|
161
|
+
import { createInMemoryStorage } from '@cyanheads/mcp-ts-core/testing';
|
|
162
|
+
|
|
142
163
|
describe('{{ServiceClass}}', () => {
|
|
143
164
|
beforeEach(() => {
|
|
144
165
|
// Re-initialize with fresh config/storage for each test
|
|
166
|
+
const mockStorage = createInMemoryStorage();
|
|
145
167
|
init{{ServiceClass}}(mockConfig, mockStorage);
|
|
146
168
|
});
|
|
147
169
|
|
|
@@ -189,6 +211,23 @@ it('respects cancellation', async () => {
|
|
|
189
211
|
});
|
|
190
212
|
```
|
|
191
213
|
|
|
214
|
+
## Fuzz Testing
|
|
215
|
+
|
|
216
|
+
For schema-heavy or input-validation-critical handlers, the framework ships fuzz helpers that generate valid + adversarial inputs from your Zod schemas via `fast-check` and assert handler invariants (no crashes, no prototype pollution, no stack-trace leaks):
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
import { fuzzTool } from '@cyanheads/mcp-ts-core/testing/fuzz';
|
|
220
|
+
|
|
221
|
+
it('survives fuzz testing', async () => {
|
|
222
|
+
const report = await fuzzTool({{TOOL_EXPORT}}, { numRuns: 100 });
|
|
223
|
+
expect(report.crashes).toHaveLength(0);
|
|
224
|
+
expect(report.leaks).toHaveLength(0);
|
|
225
|
+
expect(report.prototypePollution).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Available helpers from `@cyanheads/mcp-ts-core/testing/fuzz`: `fuzzTool`, `fuzzResource`, `fuzzPrompt`, `zodToArbitrary` (custom property-based tests), `adversarialArbitrary` and `ADVERSARIAL_STRINGS` (targeted injection sets). Returns a `FuzzReport` you can assert against. Options: `numRuns`, `numAdversarial`, `seed` (reproducibility), `timeout`, `ctx` (`MockContextOptions` for stateful handlers).
|
|
230
|
+
|
|
192
231
|
## Generating Tests from Schemas
|
|
193
232
|
|
|
194
233
|
When scaffolding tests for an existing handler, use the Zod schemas to generate meaningful test cases:
|
package/skills/add-tool/SKILL.md
CHANGED
|
@@ -58,10 +58,16 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
|
|
|
58
58
|
// All fields need .describe(). Only JSON-Schema-serializable Zod types allowed.
|
|
59
59
|
}),
|
|
60
60
|
// auth: ['tool:{{tool_name}}:read'],
|
|
61
|
+
// errors: [
|
|
62
|
+
// { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No items matched the query.' },
|
|
63
|
+
// { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited, when: 'Local queue at capacity.', retryable: true },
|
|
64
|
+
// ],
|
|
61
65
|
|
|
62
66
|
async handler(input, ctx) {
|
|
63
67
|
ctx.log.info('Processing', { /* relevant input fields */ });
|
|
64
|
-
// Pure logic — throw on failure, no try/catch
|
|
68
|
+
// Pure logic — throw on failure, no try/catch.
|
|
69
|
+
// With an `errors[]` contract: `throw ctx.fail('reason_id', message?, data?)`.
|
|
70
|
+
// Without: throw via factories (`notFound`, `validationError`, …) or plain `Error`.
|
|
65
71
|
return { /* output */ };
|
|
66
72
|
},
|
|
67
73
|
|
|
@@ -233,9 +239,37 @@ format: (result) => [{
|
|
|
233
239
|
|
|
234
240
|
### Error classification and messaging
|
|
235
241
|
|
|
236
|
-
|
|
242
|
+
**Recommended: declare an `errors[]` contract.** A typed contract surfaces in `tools/list` and gives the handler a typed `ctx.fail(reason, …)` keyed by the declared reason union — TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated and tamper-proof, and the linter enforces conformance against the handler body.
|
|
237
243
|
|
|
238
|
-
|
|
244
|
+
```typescript
|
|
245
|
+
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
246
|
+
|
|
247
|
+
export const fetchArticles = tool('fetch_articles', {
|
|
248
|
+
description: 'Fetch articles by PMID.',
|
|
249
|
+
errors: [
|
|
250
|
+
{ reason: 'no_pmid_match', code: JsonRpcErrorCode.NotFound,
|
|
251
|
+
when: 'None of the requested PMIDs returned data.' },
|
|
252
|
+
{ reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
|
|
253
|
+
when: 'Local request queue at capacity.', retryable: true },
|
|
254
|
+
],
|
|
255
|
+
input: z.object({ pmids: z.array(z.string()).describe('PMIDs to fetch') }),
|
|
256
|
+
output: z.object({ articles: z.array(ArticleSchema).describe('Resolved articles') }),
|
|
257
|
+
async handler(input, ctx) {
|
|
258
|
+
if (queue.full()) throw ctx.fail('queue_full');
|
|
259
|
+
const articles = await fetch(input.pmids);
|
|
260
|
+
if (articles.length === 0) {
|
|
261
|
+
throw ctx.fail('no_pmid_match', `No data for ${input.pmids.length} PMIDs`, { pmids: input.pmids });
|
|
262
|
+
}
|
|
263
|
+
return { articles };
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring. Omit the contract only for throwaway prototypes — declare it everywhere else. Wire-level behavior is identical when omitted, but you lose the type-checked `ctx.fail`, the `tools/list` advertisement, and conformance lint coverage.
|
|
269
|
+
|
|
270
|
+
`ctx.fail` accepts an optional 4th `options` argument for ES2022 cause chaining: `throw ctx.fail('upstream_error', 'Upstream returned 500', { url }, { cause: e })`.
|
|
271
|
+
|
|
272
|
+
**Fallback: error factories.** Use when no contract entry fits — ad-hoc throws, prototype tools, or service-layer code. The framework also auto-classifies plain `throw new Error()` from message patterns as a last resort.
|
|
239
273
|
|
|
240
274
|
```typescript
|
|
241
275
|
// Client input error — agent can fix and retry
|
|
@@ -259,7 +293,7 @@ throw invalidParams(
|
|
|
259
293
|
);
|
|
260
294
|
```
|
|
261
295
|
|
|
262
|
-
**Error messages are recovery instructions.** Name what went wrong, why, and what action to take. The message is the agent's only signal — a bare "Not found" is a dead end.
|
|
296
|
+
**Error messages are recovery instructions.** Name what went wrong, why, and what action to take. The message is the agent's only signal — a bare "Not found" is a dead end. See `skills/api-errors/SKILL.md` for the full contract pattern, factories list, and auto-classification table.
|
|
263
297
|
|
|
264
298
|
### Include operational metadata
|
|
265
299
|
|
|
@@ -329,6 +363,7 @@ Large payloads burn the agent's context window. Default to curated summaries; of
|
|
|
329
363
|
- [ ] `format()` renders every field in the output schema — enforced at lint time via sentinel injection, startup fails with `format-parity` errors otherwise. Different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data. Primary fix: render the missing field in `format()` (use `z.discriminatedUnion` for list/detail variants). Escape hatch: if the output schema was over-typed for a genuinely dynamic upstream API, relax it (`z.object({}).passthrough()`) rather than maintaining aspirational typing
|
|
330
364
|
- [ ] If wrapping external API: output schema and `format()` preserve uncertainty from sparse upstream payloads instead of inventing concrete values
|
|
331
365
|
- [ ] `auth` scopes declared if the tool needs authorization
|
|
366
|
+
- [ ] `errors: [...]` contract declared for known domain failure modes (recommended; omit only for throwaway prototypes)
|
|
332
367
|
- [ ] `task: true` added if the tool is long-running
|
|
333
368
|
- [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
|
|
334
369
|
- [ ] `bun run devcheck` passes
|