@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.
@@ -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.1.28",
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.2.2",
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": "^5.5.9",
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-noun: `search_papers`, `create_task`. Prefix with server domain if ambiguous. |
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.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. `bunfig.toml`
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
- ### 8. `CHANGELOG.md`
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
- ### 9. `LICENSE`
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
- ### 10. `Dockerfile`
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` is filled in (not empty placeholder)
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
- ### 11. `docs/tree.md`
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
- ### 12. Final Verification
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