@cyanheads/mcp-ts-core 0.1.28 → 0.2.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 +19 -1
- package/README.md +18 -1
- package/biome.json +10 -0
- package/dist/linter/rules/tool-rules.js +10 -22
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/testing/fuzz.d.ts +109 -0
- package/dist/testing/fuzz.d.ts.map +1 -0
- package/dist/testing/fuzz.js +558 -0
- package/dist/testing/fuzz.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/network/retry.d.ts +83 -0
- package/dist/utils/network/retry.d.ts.map +1 -0
- package/dist/utils/network/retry.js +152 -0
- package/dist/utils/network/retry.js.map +1 -0
- package/package.json +7 -3
- package/skills/add-service/SKILL.md +53 -0
- package/skills/api-utils/SKILL.md +1 -0
- package/skills/design-mcp-server/SKILL.md +37 -1
- package/skills/polish-docs-meta/SKILL.md +32 -8
- package/skills/report-issue-framework/SKILL.md +231 -0
- package/skills/report-issue-local/SKILL.md +225 -0
- package/skills/setup/SKILL.md +1 -0
- package/templates/.github/ISSUE_TEMPLATE/bug_report.yml +106 -0
- package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +36 -0
- package/templates/CLAUDE.md +2 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { RequestContext } from '../../utils/internal/requestContext.js';
|
|
2
|
+
/** Configuration for {@link withRetry}. */
|
|
3
|
+
export interface RetryOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Base delay in milliseconds before the first retry.
|
|
6
|
+
* Subsequent delays are `baseDelayMs * 2^attempt`. Default: `1000`.
|
|
7
|
+
*
|
|
8
|
+
* Calibrate to the upstream's recovery time:
|
|
9
|
+
* - 200–500ms for ephemeral failures (connection pool)
|
|
10
|
+
* - 1–2s for rate-limited APIs
|
|
11
|
+
* - 2–5s for service degradation / outages
|
|
12
|
+
*/
|
|
13
|
+
baseDelayMs?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Request context for correlated logging. When provided, log entries
|
|
16
|
+
* include `requestId`, `traceId`, etc.
|
|
17
|
+
*/
|
|
18
|
+
context?: RequestContext;
|
|
19
|
+
/**
|
|
20
|
+
* Custom predicate to determine if an error is transient and should be
|
|
21
|
+
* retried. When provided, this replaces the default `McpError` code check.
|
|
22
|
+
* Return `true` to retry, `false` to fail immediately.
|
|
23
|
+
*/
|
|
24
|
+
isTransient?: (error: unknown) => boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Jitter factor applied to each delay. `0` = no jitter, `1` = full jitter
|
|
27
|
+
* (delay randomized between 0 and calculated delay). Default: `0.25`.
|
|
28
|
+
*/
|
|
29
|
+
jitter?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Maximum delay cap in milliseconds. Prevents unbounded growth on high
|
|
32
|
+
* retry counts. Default: `30000` (30s).
|
|
33
|
+
*/
|
|
34
|
+
maxDelayMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Maximum number of retry attempts after the initial call.
|
|
37
|
+
* Total attempts = `maxRetries + 1`. Default: `3`.
|
|
38
|
+
*/
|
|
39
|
+
maxRetries?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Operation name for structured log messages. Used in log context and
|
|
42
|
+
* enriched error messages on exhaustion.
|
|
43
|
+
*/
|
|
44
|
+
operation?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Optional AbortSignal. When aborted, the retry loop exits immediately
|
|
47
|
+
* without further attempts.
|
|
48
|
+
*/
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Executes `fn` with retry logic and exponential backoff.
|
|
53
|
+
*
|
|
54
|
+
* The retry boundary should wrap the **full pipeline** — HTTP fetch, response
|
|
55
|
+
* parsing, and validation — not just the network call. This ensures that
|
|
56
|
+
* transient upstream errors (e.g., HTTP 200 with an error body) are retried.
|
|
57
|
+
*
|
|
58
|
+
* When retries exhaust, the final error is enriched with attempt count in both
|
|
59
|
+
* the message and structured data, so callers know retries were already attempted.
|
|
60
|
+
*
|
|
61
|
+
* @typeParam T - Return type of the operation.
|
|
62
|
+
* @param fn - The async operation to execute with retries.
|
|
63
|
+
* @param options - Retry configuration. All fields optional with sensible defaults.
|
|
64
|
+
* @returns The result of `fn` on success.
|
|
65
|
+
* @throws The enriched final error when all attempts are exhausted, or the original
|
|
66
|
+
* error immediately if it is not classified as transient.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* // Service method — retry covers fetch + parse
|
|
71
|
+
* async function fetchStudy(id: string, ctx: Context): Promise<Study> {
|
|
72
|
+
* return withRetry(
|
|
73
|
+
* async () => {
|
|
74
|
+
* const text = await apiClient.get(`/studies/${id}`);
|
|
75
|
+
* return responseHandler.parse<Study>(text);
|
|
76
|
+
* },
|
|
77
|
+
* { operation: 'fetchStudy', context: ctx, baseDelayMs: 1000 },
|
|
78
|
+
* );
|
|
79
|
+
* }
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
83
|
+
//# sourceMappingURL=retry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../../src/utils/network/retry.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAYzE,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;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"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Retry utility with exponential backoff for wrapping operations that
|
|
3
|
+
* may fail transiently. Designed so the retry boundary covers the full pipeline
|
|
4
|
+
* (HTTP fetch + response parsing/validation), not just the network call.
|
|
5
|
+
* @module src/utils/network/retry
|
|
6
|
+
* @see docs/service-resilience.md
|
|
7
|
+
*/
|
|
8
|
+
import { JsonRpcErrorCode, McpError } from '../../types-global/errors.js';
|
|
9
|
+
import { logger } from '../../utils/internal/logger.js';
|
|
10
|
+
/**
|
|
11
|
+
* Error codes considered transient — eligible for retry.
|
|
12
|
+
* Matches the framework's error classification in `mappings.ts`.
|
|
13
|
+
*/
|
|
14
|
+
const TRANSIENT_CODES = new Set([
|
|
15
|
+
JsonRpcErrorCode.ServiceUnavailable,
|
|
16
|
+
JsonRpcErrorCode.Timeout,
|
|
17
|
+
JsonRpcErrorCode.RateLimited,
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Computes the backoff delay for a given attempt with optional jitter.
|
|
21
|
+
*
|
|
22
|
+
* @param attempt - Zero-based attempt index (0 = first retry).
|
|
23
|
+
* @param baseDelayMs - Base delay in milliseconds.
|
|
24
|
+
* @param maxDelayMs - Maximum delay cap.
|
|
25
|
+
* @param jitter - Jitter factor (0–1).
|
|
26
|
+
* @returns Delay in milliseconds.
|
|
27
|
+
*/
|
|
28
|
+
function computeDelay(attempt, baseDelayMs, maxDelayMs, jitter) {
|
|
29
|
+
const exponential = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
30
|
+
if (jitter <= 0)
|
|
31
|
+
return exponential;
|
|
32
|
+
const jitterRange = exponential * jitter;
|
|
33
|
+
return exponential - jitterRange + Math.random() * jitterRange * 2;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Default transient check: `McpError` with a transient code, or any non-McpError
|
|
37
|
+
* (network failures, unexpected throws) which are assumed transient.
|
|
38
|
+
*/
|
|
39
|
+
function defaultIsTransient(error) {
|
|
40
|
+
if (error instanceof McpError) {
|
|
41
|
+
return TRANSIENT_CODES.has(error.code);
|
|
42
|
+
}
|
|
43
|
+
// Non-McpError (raw network errors, unexpected throws) — assume transient
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Enriches an error with retry exhaustion context.
|
|
48
|
+
* Appends attempt count to the message and to `data` for programmatic access.
|
|
49
|
+
*/
|
|
50
|
+
function enrichExhaustedError(error, totalAttempts, operation) {
|
|
51
|
+
if (error instanceof McpError) {
|
|
52
|
+
const suffix = `(failed after ${totalAttempts} attempt${totalAttempts > 1 ? 's' : ''})`;
|
|
53
|
+
const enrichedMessage = error.message ? `${error.message} ${suffix}` : suffix;
|
|
54
|
+
const enrichedData = {
|
|
55
|
+
...error.data,
|
|
56
|
+
retryAttempts: totalAttempts,
|
|
57
|
+
...(operation ? { operation } : {}),
|
|
58
|
+
};
|
|
59
|
+
return new McpError(error.code, enrichedMessage, enrichedData, { cause: error });
|
|
60
|
+
}
|
|
61
|
+
if (error instanceof Error) {
|
|
62
|
+
const suffix = `(failed after ${totalAttempts} attempt${totalAttempts > 1 ? 's' : ''})`;
|
|
63
|
+
const wrapped = new Error(`${error.message} ${suffix}`, { cause: error });
|
|
64
|
+
wrapped.name = error.name;
|
|
65
|
+
return wrapped;
|
|
66
|
+
}
|
|
67
|
+
return error;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Executes `fn` with retry logic and exponential backoff.
|
|
71
|
+
*
|
|
72
|
+
* The retry boundary should wrap the **full pipeline** — HTTP fetch, response
|
|
73
|
+
* parsing, and validation — not just the network call. This ensures that
|
|
74
|
+
* transient upstream errors (e.g., HTTP 200 with an error body) are retried.
|
|
75
|
+
*
|
|
76
|
+
* When retries exhaust, the final error is enriched with attempt count in both
|
|
77
|
+
* the message and structured data, so callers know retries were already attempted.
|
|
78
|
+
*
|
|
79
|
+
* @typeParam T - Return type of the operation.
|
|
80
|
+
* @param fn - The async operation to execute with retries.
|
|
81
|
+
* @param options - Retry configuration. All fields optional with sensible defaults.
|
|
82
|
+
* @returns The result of `fn` on success.
|
|
83
|
+
* @throws The enriched final error when all attempts are exhausted, or the original
|
|
84
|
+
* error immediately if it is not classified as transient.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* // Service method — retry covers fetch + parse
|
|
89
|
+
* async function fetchStudy(id: string, ctx: Context): Promise<Study> {
|
|
90
|
+
* return withRetry(
|
|
91
|
+
* async () => {
|
|
92
|
+
* const text = await apiClient.get(`/studies/${id}`);
|
|
93
|
+
* return responseHandler.parse<Study>(text);
|
|
94
|
+
* },
|
|
95
|
+
* { operation: 'fetchStudy', context: ctx, baseDelayMs: 1000 },
|
|
96
|
+
* );
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export async function withRetry(fn, options = {}) {
|
|
101
|
+
const { maxRetries = 3, baseDelayMs = 1000, maxDelayMs = 30_000, jitter = 0.25, operation, context, signal, isTransient = defaultIsTransient, } = options;
|
|
102
|
+
const totalAttempts = maxRetries + 1;
|
|
103
|
+
for (let attempt = 0; attempt < totalAttempts; attempt++) {
|
|
104
|
+
try {
|
|
105
|
+
return await fn();
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
// Abort signal — exit immediately, no more retries
|
|
109
|
+
if (signal?.aborted) {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const isLastAttempt = attempt >= maxRetries;
|
|
113
|
+
// Non-transient errors fail immediately
|
|
114
|
+
if (!isTransient(error)) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (isLastAttempt) {
|
|
118
|
+
throw enrichExhaustedError(error, totalAttempts, operation);
|
|
119
|
+
}
|
|
120
|
+
// Log and backoff
|
|
121
|
+
const delay = computeDelay(attempt, baseDelayMs, maxDelayMs, jitter);
|
|
122
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
123
|
+
logger.debug(`Retry ${attempt + 1}/${maxRetries} for ${operation ?? 'operation'}: ${errorMessage} — waiting ${Math.round(delay)}ms`, context);
|
|
124
|
+
await sleep(delay, signal);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Unreachable — the loop always returns or throws
|
|
128
|
+
throw new McpError(JsonRpcErrorCode.InternalError, 'withRetry: unexpected loop exit');
|
|
129
|
+
}
|
|
130
|
+
/** Sleeps for the given duration, aborting early if the signal fires. */
|
|
131
|
+
function sleep(ms, signal) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
if (signal?.aborted) {
|
|
134
|
+
reject(signal.reason);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
let onAbort;
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
if (onAbort)
|
|
140
|
+
signal?.removeEventListener('abort', onAbort);
|
|
141
|
+
resolve();
|
|
142
|
+
}, ms);
|
|
143
|
+
if (signal) {
|
|
144
|
+
onAbort = () => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
reject(signal.reason);
|
|
147
|
+
};
|
|
148
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=retry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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.2.0",
|
|
4
4
|
"mcpName": "io.github.cyanheads/mcp-ts-core",
|
|
5
5
|
"description": "Agent-native TypeScript framework for building MCP servers. Build tools, not infrastructure. Declarative definitions with auth, multi-backend storage, OpenTelemetry, and first-class support for Node.js and Cloudflare Workers.",
|
|
6
6
|
"main": "dist/core/index.js",
|
|
@@ -83,6 +83,10 @@
|
|
|
83
83
|
"types": "./dist/testing/index.d.ts",
|
|
84
84
|
"import": "./dist/testing/index.js"
|
|
85
85
|
},
|
|
86
|
+
"./testing/fuzz": {
|
|
87
|
+
"types": "./dist/testing/fuzz.d.ts",
|
|
88
|
+
"import": "./dist/testing/fuzz.js"
|
|
89
|
+
},
|
|
86
90
|
"./tsconfig.base.json": "./tsconfig.base.json",
|
|
87
91
|
"./vitest.config": "./vitest.config.base.ts",
|
|
88
92
|
"./biome": "./biome.json",
|
|
@@ -244,7 +248,7 @@
|
|
|
244
248
|
"dependencies": {
|
|
245
249
|
"@hono/mcp": "^0.2.4",
|
|
246
250
|
"@hono/node-server": "^1.19.11",
|
|
247
|
-
"@modelcontextprotocol/ext-apps": "^1.
|
|
251
|
+
"@modelcontextprotocol/ext-apps": "^1.3.1",
|
|
248
252
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
249
253
|
"@opentelemetry/api": "^1.9.0",
|
|
250
254
|
"dotenv": "^17.3.1",
|
|
@@ -267,7 +271,7 @@
|
|
|
267
271
|
"@supabase/supabase-js": "^2.99.3",
|
|
268
272
|
"chrono-node": "^2.9.0",
|
|
269
273
|
"diff": "^8.0.4",
|
|
270
|
-
"fast-xml-parser": "
|
|
274
|
+
"fast-xml-parser": "latest",
|
|
271
275
|
"js-yaml": "^4.1.0",
|
|
272
276
|
"node-cron": "^4.2.1",
|
|
273
277
|
"openai": "^6.32.0",
|
|
@@ -98,6 +98,58 @@ handler: async (input, ctx) => {
|
|
|
98
98
|
},
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
## Resilience (External API Services)
|
|
102
|
+
|
|
103
|
+
When a service wraps an external API, apply these patterns. See `docs/service-resilience.md` for full rationale.
|
|
104
|
+
|
|
105
|
+
### Retry wraps the full pipeline
|
|
106
|
+
|
|
107
|
+
Place retry at the service method level — covering both HTTP fetch and response parsing/validation. The HTTP client should be single-attempt; the service owns retry. Use `withRetry` from `@cyanheads/mcp-ts-core/utils`:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { withRetry, fetchWithTimeout } from '@cyanheads/mcp-ts-core/utils';
|
|
111
|
+
import type { Context } from '@cyanheads/mcp-ts-core';
|
|
112
|
+
|
|
113
|
+
async fetchItem(id: string, ctx: Context): Promise<Item> {
|
|
114
|
+
return withRetry(
|
|
115
|
+
async () => {
|
|
116
|
+
const response = await fetchWithTimeout(
|
|
117
|
+
`${this.baseUrl}/items/${id}`,
|
|
118
|
+
10_000,
|
|
119
|
+
ctx,
|
|
120
|
+
);
|
|
121
|
+
const text = await response.text();
|
|
122
|
+
return this.parseResponse<Item>(text);
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
operation: 'fetchItem',
|
|
126
|
+
context: ctx,
|
|
127
|
+
baseDelayMs: 1000, // calibrate to upstream recovery time
|
|
128
|
+
signal: ctx.signal,
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Key principles
|
|
135
|
+
|
|
136
|
+
1. **Calibrate backoff to the upstream.** 200–500ms for ephemeral failures, 1–2s for rate-limited APIs, 2–5s for service degradation. The default `baseDelayMs: 1000` suits most APIs.
|
|
137
|
+
2. **Check HTTP status before parsing.** `fetchWithTimeout` already throws `ServiceUnavailable` on non-OK responses — this prevents feeding HTML error pages into XML/JSON parsers.
|
|
138
|
+
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).
|
|
139
|
+
4. **Exhausted retries say so.** `withRetry` automatically enriches the final error with attempt count — callers know retries were already attempted.
|
|
140
|
+
|
|
141
|
+
### Response handler pattern
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
parseResponse<T>(text: string): T {
|
|
145
|
+
// Detect HTML error pages masquerading as successful responses
|
|
146
|
+
if (/^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text)) {
|
|
147
|
+
throw serviceUnavailable('API returned HTML instead of expected format — likely rate-limited.');
|
|
148
|
+
}
|
|
149
|
+
// Parse and validate...
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
101
153
|
## Checklist
|
|
102
154
|
|
|
103
155
|
- [ ] Directory created at `src/services/{{domain}}/`
|
|
@@ -106,4 +158,5 @@ handler: async (input, ctx) => {
|
|
|
106
158
|
- [ ] Service methods accept `Context` for logging and storage
|
|
107
159
|
- [ ] `init` function registered in `setup()` callback in `src/index.ts`
|
|
108
160
|
- [ ] Accessor throws `Error` if not initialized
|
|
161
|
+
- [ ] If wrapping external API: retry covers full pipeline (fetch + parse), backoff calibrated
|
|
109
162
|
- [ ] `bun run devcheck` passes
|
|
@@ -30,6 +30,7 @@ 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 protection: 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. |
|
|
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
34
|
|
|
34
35
|
---
|
|
35
36
|
|
|
@@ -120,6 +120,8 @@ const findEligibleStudies = tool('clinicaltrials_find_eligible_studies', {
|
|
|
120
120
|
|
|
121
121
|
There is no fixed ceiling on tool count — tools need to earn their keep, but don't artificially limit the surface. If the domain genuinely has 20 distinct workflows, expose 20 tools.
|
|
122
122
|
|
|
123
|
+
**Audit: does each tool earn its keep?** After mapping tools, review the full list critically. A tool that covers a niche use case, serves a tiny fraction of agents, or duplicates what another tool already handles is a candidate for deferral. Drop it from the design and note it as a future addition if demand warrants. Every tool in the surface is cognitive load for tool selection — a tight surface outperforms a comprehensive one.
|
|
124
|
+
|
|
123
125
|
#### Tool descriptions
|
|
124
126
|
|
|
125
127
|
The description is the LLM's primary signal for tool selection. It must answer: *what does this do, and when should I use it?*
|
|
@@ -193,6 +195,22 @@ output: z.object({
|
|
|
193
195
|
- **Truncate large output with counts.** When a list exceeds a reasonable display size, show the top N and append "...and X more". Don't silently drop results.
|
|
194
196
|
- **Use the `format` function for readable summaries** while keeping the full structured data in the output object for programmatic use.
|
|
195
197
|
|
|
198
|
+
#### Convenience shortcuts for complex inputs
|
|
199
|
+
|
|
200
|
+
When a tool wraps a complex query language or filter system, provide a simple shortcut parameter for the 80% case alongside the full-power escape hatch. This keeps simple queries simple while preserving full expressiveness.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// text_search handles the common case; query handles everything else
|
|
204
|
+
text_search: z.string().optional()
|
|
205
|
+
.describe('Convenience shortcut: full-text search across title and abstract. '
|
|
206
|
+
+ 'Equivalent to {"_or":[{"_text_any":{"title":"..."}},{"_text_any":{"abstract":"..."}}]}. '
|
|
207
|
+
+ 'For more control, use the query parameter directly.'),
|
|
208
|
+
query: z.record(z.unknown()).optional()
|
|
209
|
+
.describe('Full query object for structured filters. Supports operators: _eq, _gt, _and, _or, ...'),
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The pattern: name the shortcut for what it does (`text_search`, `name_search`), document what it expands to, and point to the full parameter for advanced use. Validate that at least one of the two is provided.
|
|
213
|
+
|
|
196
214
|
#### Error messages as LLM guidance
|
|
197
215
|
|
|
198
216
|
When a tool throws, the error message is the agent's only signal for recovery. A good error message tells the LLM *what happened and what to do next*.
|
|
@@ -219,7 +237,7 @@ Summarize each tool:
|
|
|
219
237
|
|
|
220
238
|
| Aspect | Decision |
|
|
221
239
|
|:-------|:---------|
|
|
222
|
-
| **Name** | `snake_case`, verb
|
|
240
|
+
| **Name** | `snake_case`, `{domain}_{verb}_{noun}` — aim for 3 words: `patentsview_search_patents`, `clinicaltrials_find_studies`. Use the **canonical platform/brand name** as prefix (not abbreviations — `patentsview_` not `patents_`, `clinicaltrials_` not `ct_`). The verb+noun pair should be unambiguous within the server — if two tools could plausibly share a name, the noun isn't specific enough (e.g., `read_fulltext` not `read_text` when structured metadata is a separate concept). |
|
|
223
241
|
| **Granularity** | One tool per user-meaningful workflow, not per API call. Consolidate related operations with `operation`/`mode` enum. |
|
|
224
242
|
| **Description** | Concrete capability statement. Add operational guidance (prerequisites, constraints, gotchas) when non-obvious. |
|
|
225
243
|
| **Input schema** | `.describe()` on every field. Constrained types (enums, literals, regex). Explain costs/tradeoffs of parameter choices. |
|
|
@@ -251,6 +269,16 @@ Skip for purely data/action-oriented servers.
|
|
|
251
269
|
|
|
252
270
|
**Services** — one per external dependency. Init/accessor pattern. Skip if all tools are thin wrappers with no shared state.
|
|
253
271
|
|
|
272
|
+
For services wrapping external APIs, plan the resilience layer. See `docs/service-resilience.md` for full rationale.
|
|
273
|
+
|
|
274
|
+
| Concern | Decision |
|
|
275
|
+
|:--------|:---------|
|
|
276
|
+
| **Retry boundary** | Service method wraps full pipeline (fetch + parse), not just the network call. Use `withRetry` from `/utils`. |
|
|
277
|
+
| **Backoff calibration** | Match base delay to upstream recovery time: 200–500ms (ephemeral), 1–2s (rate-limited), 2–5s (degraded). |
|
|
278
|
+
| **HTTP status check** | `fetchWithTimeout` already handles this — non-OK → `ServiceUnavailable`. |
|
|
279
|
+
| **Parse failure classification** | Response handler detects HTML error pages and throws transient errors, not `SerializationError`. |
|
|
280
|
+
| **Exhausted retry messaging** | `withRetry` enriches the final error with attempt count automatically. |
|
|
281
|
+
|
|
254
282
|
**Config** — list env vars (API keys, base URLs). Goes in `src/config/server-config.ts` as a separate Zod schema.
|
|
255
283
|
|
|
256
284
|
### 8. Write the Design Doc
|
|
@@ -301,6 +329,13 @@ What this server does, what system it wraps, who it's for.
|
|
|
301
329
|
6. Prompts
|
|
302
330
|
|
|
303
331
|
Each step is independently testable.
|
|
332
|
+
|
|
333
|
+
<!-- Optional sections for API-wrapping servers: -->
|
|
334
|
+
## Domain Mapping <!-- nouns × operations → API endpoints -->
|
|
335
|
+
## Workflow Analysis <!-- how tools chain for real tasks -->
|
|
336
|
+
## Design Decisions <!-- rationale for consolidation, naming, tradeoffs -->
|
|
337
|
+
## Known Limitations <!-- inherent API/data constraints the server can't solve -->
|
|
338
|
+
## API Reference <!-- query language, pagination, rate limits -->
|
|
304
339
|
```
|
|
305
340
|
|
|
306
341
|
Keep it concise. The design doc is a working reference, not a spec document — enough to orient a developer (or agent) implementing the server, not more.
|
|
@@ -333,6 +368,7 @@ Execute the plan using the scaffolding skills:
|
|
|
333
368
|
- [ ] Annotations set correctly (`readOnlyHint`, `destructiveHint`, etc.)
|
|
334
369
|
- [ ] Resource URIs use `{param}` templates, pagination planned for large lists
|
|
335
370
|
- [ ] Service layer planned (or explicitly skipped with reasoning)
|
|
371
|
+
- [ ] Resilience planned for external API services (retry boundary, backoff, parse classification)
|
|
336
372
|
- [ ] Server config env vars identified
|
|
337
373
|
- [ ] Design doc written to `docs/design.md`
|
|
338
374
|
- [ ] Design confirmed with user (or user pre-authorized implementation)
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Finalize documentation and project metadata for a ship-ready MCP server. Use after implementation is complete, tests pass, and devcheck is clean. Safe to run at any stage — each step checks current state and only acts on what still needs work.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.2"
|
|
8
8
|
audience: external
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -48,6 +48,8 @@ Capture: tool count, resource count, prompt count, service count, required env v
|
|
|
48
48
|
|
|
49
49
|
Read `references/readme.md` for structure and conventions. If `README.md` doesn't exist, create it from scratch. If it exists, diff the current content against the audit — update tool/resource/prompt tables, env var lists, and descriptions to match the actual surface area. Don't rewrite sections that are already accurate.
|
|
50
50
|
|
|
51
|
+
The header tagline (`<p><b>...</b></p>`) must match the `package.json` `description`.
|
|
52
|
+
|
|
51
53
|
### 3. Agent Protocol (CLAUDE.md / AGENTS.md)
|
|
52
54
|
|
|
53
55
|
Update the project's agent protocol file to reflect the actual server.
|
|
@@ -70,6 +72,8 @@ Check for empty or placeholder metadata fields. Read `references/package-meta.md
|
|
|
70
72
|
|
|
71
73
|
Key fields: `description`, `repository`, `author`, `homepage`, `bugs`, `keywords`.
|
|
72
74
|
|
|
75
|
+
**`description` is the canonical source.** Every other surface (README header, `server.json`, Dockerfile OCI label, GitHub repo description) derives from it. Write it here first, then propagate.
|
|
76
|
+
|
|
73
77
|
### 6. `server.json`
|
|
74
78
|
|
|
75
79
|
Read `references/server-json.md` for the official MCP server manifest schema. If `server.json` doesn't exist, create it from the surface area audit. If it exists, diff against current state and update stale fields.
|
|
@@ -82,7 +86,26 @@ Key sync points:
|
|
|
82
86
|
- `environmentVariables` reflect the server config Zod schema — server-specific required vars in both entries, transport vars only in HTTP entry
|
|
83
87
|
- Two package entries: one for stdio, one for HTTP (if both transports supported)
|
|
84
88
|
|
|
85
|
-
### 7.
|
|
89
|
+
### 7. GitHub Repository Metadata
|
|
90
|
+
|
|
91
|
+
Sync the GitHub repo with `package.json` using the `gh` CLI. Skip if the repo isn't hosted on GitHub or `gh` isn't available.
|
|
92
|
+
|
|
93
|
+
**Description:**
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
gh repo edit <owner>/<repo> --description "<package.json description>"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Topics ↔ Keywords:**
|
|
100
|
+
|
|
101
|
+
Compare GitHub topics (`gh repo view --json repositoryTopics`) against `package.json` `keywords`. They should be the union — add any that exist in one but not the other:
|
|
102
|
+
|
|
103
|
+
- Missing from GitHub → `gh repo edit --add-topic <topic>`
|
|
104
|
+
- Missing from `package.json` → add to `keywords` array
|
|
105
|
+
|
|
106
|
+
Common keywords shared across MCP servers (e.g., `mcp`, `mcp-server`, `model-context-protocol`, `typescript`) should appear in both. Domain-specific keywords should also be present in both.
|
|
107
|
+
|
|
108
|
+
### 8. `bunfig.toml`
|
|
86
109
|
|
|
87
110
|
Verify a `bunfig.toml` exists at the project root. If not, create one:
|
|
88
111
|
|
|
@@ -95,7 +118,7 @@ frozenLockfile = false
|
|
|
95
118
|
bun = true
|
|
96
119
|
```
|
|
97
120
|
|
|
98
|
-
###
|
|
121
|
+
### 9. `CHANGELOG.md`
|
|
99
122
|
|
|
100
123
|
If `CHANGELOG.md` doesn't exist, create it with an initial entry. If it exists, verify the latest entry reflects the current state:
|
|
101
124
|
|
|
@@ -112,22 +135,22 @@ Initial release.
|
|
|
112
135
|
|
|
113
136
|
Use a concrete version and date. Never `[Unreleased]`.
|
|
114
137
|
|
|
115
|
-
###
|
|
138
|
+
### 10. `LICENSE`
|
|
116
139
|
|
|
117
140
|
Confirm a license file exists. If not, ask the user which license to use (default: Apache-2.0, matching the scaffolded `package.json`). Create the file.
|
|
118
141
|
|
|
119
|
-
###
|
|
142
|
+
### 11. `Dockerfile`
|
|
120
143
|
|
|
121
144
|
If a `Dockerfile` exists, verify the OCI labels and runtime config match the actual server:
|
|
122
145
|
|
|
123
146
|
- `org.opencontainers.image.title` matches the package name
|
|
124
|
-
- `org.opencontainers.image.description`
|
|
147
|
+
- `org.opencontainers.image.description` matches `package.json` `description`
|
|
125
148
|
- `org.opencontainers.image.source` points to the real repository URL (add if missing)
|
|
126
149
|
- Log directory path in `mkdir` and `LOGS_DIR` uses the correct server name
|
|
127
150
|
|
|
128
151
|
If no `Dockerfile` exists and the server is deployed via HTTP transport, consider scaffolding one — the template is available via `npx @cyanheads/mcp-ts-core init`.
|
|
129
152
|
|
|
130
|
-
###
|
|
153
|
+
### 12. `docs/tree.md`
|
|
131
154
|
|
|
132
155
|
Regenerate the directory structure:
|
|
133
156
|
|
|
@@ -137,7 +160,7 @@ bun run tree
|
|
|
137
160
|
|
|
138
161
|
Review the output for anything unexpected (leftover files, missing directories).
|
|
139
162
|
|
|
140
|
-
###
|
|
163
|
+
### 13. Final Verification
|
|
141
164
|
|
|
142
165
|
Run the full check suite one last time:
|
|
143
166
|
|
|
@@ -156,6 +179,7 @@ Both must pass clean.
|
|
|
156
179
|
- [ ] `.env.example` in sync with server config schema
|
|
157
180
|
- [ ] `package.json` metadata complete (`description`, `mcpName`, `repository`, `author`, `keywords`, `engines`, `packageManager`)
|
|
158
181
|
- [ ] `server.json` matches official MCP schema, versions synced, env vars current
|
|
182
|
+
- [ ] GitHub repo description matches `package.json` description; topics ↔ keywords in sync
|
|
159
183
|
- [ ] `bunfig.toml` present
|
|
160
184
|
- [ ] `CHANGELOG.md` exists with current entry
|
|
161
185
|
- [ ] `LICENSE` file present
|