@cyanheads/mcp-ts-core 0.9.12 → 0.9.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CLAUDE.md +6 -3
  2. package/README.md +30 -41
  3. package/changelog/0.9.x/0.9.13.md +33 -0
  4. package/changelog/0.9.x/0.9.14.md +31 -0
  5. package/dist/config/index.d.ts +3 -0
  6. package/dist/config/index.d.ts.map +1 -1
  7. package/dist/config/index.js +20 -0
  8. package/dist/config/index.js.map +1 -1
  9. package/dist/core/context.d.ts +103 -14
  10. package/dist/core/context.d.ts.map +1 -1
  11. package/dist/core/context.js +66 -1
  12. package/dist/core/context.js.map +1 -1
  13. package/dist/core/index.d.ts +1 -1
  14. package/dist/core/index.d.ts.map +1 -1
  15. package/dist/core/index.js.map +1 -1
  16. package/dist/core/serverManifest.d.ts +12 -1
  17. package/dist/core/serverManifest.d.ts.map +1 -1
  18. package/dist/core/serverManifest.js +4 -1
  19. package/dist/core/serverManifest.js.map +1 -1
  20. package/dist/linter/rules/enrichment-rules.d.ts +32 -0
  21. package/dist/linter/rules/enrichment-rules.d.ts.map +1 -0
  22. package/dist/linter/rules/enrichment-rules.js +116 -0
  23. package/dist/linter/rules/enrichment-rules.js.map +1 -0
  24. package/dist/linter/rules/index.d.ts +1 -0
  25. package/dist/linter/rules/index.d.ts.map +1 -1
  26. package/dist/linter/rules/index.js +1 -0
  27. package/dist/linter/rules/index.js.map +1 -1
  28. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  29. package/dist/linter/rules/tool-rules.js +4 -0
  30. package/dist/linter/rules/tool-rules.js.map +1 -1
  31. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/tool-registration.js +7 -7
  33. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  34. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +41 -7
  35. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  37. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +23 -1
  38. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  39. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +90 -9
  40. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  41. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  42. package/dist/mcp-server/transports/http/httpTransport.js +40 -0
  43. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  44. package/dist/testing/index.d.ts +13 -0
  45. package/dist/testing/index.d.ts.map +1 -1
  46. package/dist/testing/index.js +21 -1
  47. package/dist/testing/index.js.map +1 -1
  48. package/dist/utils/internal/performance.d.ts +5 -1
  49. package/dist/utils/internal/performance.d.ts.map +1 -1
  50. package/dist/utils/internal/performance.js +10 -1
  51. package/dist/utils/internal/performance.js.map +1 -1
  52. package/dist/utils/telemetry/attributes.d.ts +2 -0
  53. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  54. package/dist/utils/telemetry/attributes.js +2 -0
  55. package/dist/utils/telemetry/attributes.js.map +1 -1
  56. package/package.json +2 -2
  57. package/scripts/check-docs-sync.ts +50 -32
  58. package/skills/add-tool/SKILL.md +57 -32
  59. package/skills/api-canvas/SKILL.md +106 -1
  60. package/skills/api-config/SKILL.md +2 -1
  61. package/skills/api-context/SKILL.md +65 -2
  62. package/skills/api-linter/SKILL.md +48 -1
  63. package/skills/design-mcp-server/SKILL.md +2 -1
  64. package/skills/orchestrations/SKILL.md +9 -5
  65. package/skills/polish-docs-meta/SKILL.md +1 -2
  66. package/skills/polish-docs-meta/references/readme.md +15 -1
  67. package/skills/report-issue-framework/SKILL.md +8 -3
  68. package/skills/report-issue-local/SKILL.md +8 -3
  69. package/templates/.env.example +1 -0
  70. package/templates/AGENTS.md +8 -35
  71. package/templates/CLAUDE.md +8 -36
  72. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +10 -0
@@ -4,7 +4,7 @@ description: >
4
4
  DataCanvas primitive reference — a Tier 3 SQL/analytical workspace for tabular MCP servers, backed by DuckDB. Use when registering tables from upstream APIs, running ad-hoc SQL across them, and exporting results. Covers the acquire → register → query → export flow, the token-sharing pattern for multi-agent collaboration, env config, and Cloudflare Workers fail-closed behavior.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.2"
7
+ version: "1.3"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -245,8 +245,113 @@ If your tool surfaces row data via `structuredContent`, the JSON-safe shape flow
245
245
 
246
246
  ---
247
247
 
248
+ ## Minimum viable spillover server
249
+
250
+ Most canvas use cases are public-data analytics: fetch from an upstream API, stage the full result, let the agent SQL it. The primitives are domain-neutral — `canvas.acquire()`, `spillover()`, `instance.query()` — so the minimum viable shape is small and generic. Reach for it first; add scoping only when a real multi-tenant requirement appears.
251
+
252
+ ### Simple-shape defaults
253
+
254
+ | Concern | Simple-shape answer |
255
+ |:--|:--|
256
+ | Canvas scoping | One shared canvas per tenant. Omit `canvas_id` on the first call to mint one; pass the returned id back to reuse it. |
257
+ | Table naming | `spillover()` auto-names the table `spilled_<id>`; pass `tableName` for a stable handle. A dataframe-query surface commonly adds its own `df_<id>` convention. |
258
+ | Access control | Possession of the `canvas_id` is access — unguessable in practice (see [token-sharing model](#the-token-sharing-model)). TTL + the framework rate limiter backstop brute force. |
259
+ | Enable flag | None of your own — canvas presence is the gate (`CANVAS_PROVIDER_TYPE=duckdb`; `getCanvas()` returns `undefined` otherwise). |
260
+ | Tools | A fetcher that spills, plus `dataframe_query` for SQL. `dataframe_describe` / `dataframe_drop` are optional consumer conventions, not framework-provided. |
261
+ | Fetcher output | Two things in one response: the inline preview (answer to the immediate question) and the table handle (escape hatch for follow-up SQL via `dataframe_query`). Neither replaces the other. |
262
+
263
+ > The `MCP_HTTP_MAX_BODY_BYTES` request-body cap is **inbound-only** — it bounds the JSON-RPC request, not the upstream data a handler stages into the canvas or the rows it returns. Canvas servers send small requests (queries, SQL, canvas IDs) regardless of dataset size, so the cap never constrains canvas ingestion.
264
+
265
+ ### Recipe
266
+
267
+ A fetcher that spills and a query tool that runs SQL across what was spilled — the whole surface. Swap `fetchUpstream` for any paginated or streamed source; nothing here is domain-specific.
268
+
269
+ ```ts
270
+ import { tool, z } from '@cyanheads/mcp-ts-core';
271
+ import { spillover } from '@cyanheads/mcp-ts-core/canvas';
272
+ import { getCanvas } from '@/services/canvas-accessor.js';
273
+
274
+ /** Fetch an upstream dataset, inline a preview, spill the full result to a canvas table. */
275
+ export const fetchDataset = tool('fetch_dataset', {
276
+ description:
277
+ 'Fetch a dataset and stage it on a DataCanvas. Returns an inline preview plus a ' +
278
+ 'canvas_id + table you can query with dataframe_query for the full result set.',
279
+ annotations: { readOnlyHint: true },
280
+ input: z.object({
281
+ query: z.string().describe('Upstream search/filter expression'),
282
+ canvas_id: z
283
+ .string()
284
+ .optional()
285
+ .describe('Canvas ID from a prior call. Omit to start fresh — the response returns a new one.'),
286
+ }),
287
+ output: z.object({
288
+ canvas_id: z.string().describe('Canvas ID — pass to dataframe_query or another fetch call'),
289
+ table_name: z.string().describe('Canvas table holding the full result (empty when not spilled)'),
290
+ spilled: z.boolean().describe('True when the result exceeded the preview and was staged'),
291
+ preview: z.array(z.record(z.string(), z.unknown())).describe('Inline rows — the immediate answer'),
292
+ row_count: z.number().describe('Rows staged on the canvas (preview length when not spilled)'),
293
+ }),
294
+ async handler(input, ctx) {
295
+ const canvas = getCanvas();
296
+ if (!canvas) throw new Error('DataCanvas is not enabled. Set CANVAS_PROVIDER_TYPE=duckdb.');
297
+
298
+ const instance = await canvas.acquire(input.canvas_id, ctx);
299
+ const result = await spillover({
300
+ canvas: instance,
301
+ source: fetchUpstream(input.query), // any AsyncIterable<Row> | Iterable<Row>
302
+ previewChars: 100_000, // ≈ 25k tokens inline
303
+ signal: ctx.signal,
304
+ });
305
+
306
+ return {
307
+ canvas_id: instance.canvasId,
308
+ table_name: result.spilled ? result.handle.tableName : '',
309
+ spilled: result.spilled,
310
+ preview: result.previewRows,
311
+ row_count: result.spilled ? result.handle.rowCount : result.previewRows.length,
312
+ };
313
+ },
314
+ });
315
+
316
+ /** Run read-only SQL across tables staged on a canvas. */
317
+ export const dataframeQuery = tool('dataframe_query', {
318
+ description: 'Run a read-only SQL SELECT against tables staged on a canvas by fetch_dataset.',
319
+ annotations: { readOnlyHint: true },
320
+ input: z.object({
321
+ canvas_id: z.string().describe('Canvas ID returned by fetch_dataset'),
322
+ sql: z.string().describe('Read-only SELECT. Reference tables by the names fetch_dataset returned.'),
323
+ }),
324
+ output: z.object({
325
+ rows: z.array(z.record(z.string(), z.unknown())).describe('Result rows (capped at the canvas row limit)'),
326
+ row_count: z.number().describe('Full result count before the row cap'),
327
+ }),
328
+ async handler(input, ctx) {
329
+ const canvas = getCanvas();
330
+ if (!canvas) throw new Error('DataCanvas is not enabled. Set CANVAS_PROVIDER_TYPE=duckdb.');
331
+
332
+ const instance = await canvas.acquire(input.canvas_id, ctx);
333
+ const result = await instance.query(input.sql, { signal: ctx.signal });
334
+ return { rows: result.rows, row_count: result.rowCount };
335
+ },
336
+ });
337
+ ```
338
+
339
+ ### When the simple shape is enough
340
+
341
+ | Condition | Simple shape suffices? |
342
+ |:--|:--|
343
+ | Underlying data is publicly accessible | ✅ |
344
+ | Single-user deployment (stdio, or HTTP with one user) | ✅ — no cross-user surface regardless of data sensitivity |
345
+ | Use case is research / analytics, not multi-tenant SaaS | ✅ |
346
+ | Dataframes must age individually | ⚠️ TTL is canvas-level today (a hot canvas keeps stale tables alive); per-table TTL is tracked in [#140](https://github.com/cyanheads/mcp-ts-core/issues/140). Backstop with `ctx.state` bookkeeping in the interim. |
347
+ | Per-user row visibility matters in a multi-user deployment | ❌ — add session/tenant scoping at the server level |
348
+
349
+ The germplasm-flavored [consumer tool template](#consumer-tool-template) below is the same pattern with domain-specific naming.
350
+
248
351
  ## Consumer tool template
249
352
 
353
+ A domain-specific instance of the [minimum viable spillover server](#minimum-viable-spillover-server) above — the same `acquire → register → return handle` flow with germplasm naming.
354
+
250
355
  ```ts
251
356
  import { tool, z } from '@cyanheads/mcp-ts-core';
252
357
  import { getCanvas } from '@/services/canvas-accessor.js';
@@ -4,7 +4,7 @@ description: >
4
4
  Reference for core and server configuration in `@cyanheads/mcp-ts-core`. Covers env var tables with defaults, priority order, server-specific Zod schema pattern, and Workers lazy-parsing requirement.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.4"
7
+ version: "1.5"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -52,6 +52,7 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod from environment variable
52
52
  | `MCP_HTTP_PORT` | `mcpHttpPort` | `3010` | Port for HTTP transport |
53
53
  | `MCP_HTTP_HOST` | `mcpHttpHost` | `127.0.0.1` | Bind address |
54
54
  | `MCP_HTTP_ENDPOINT_PATH` | `mcpHttpEndpointPath` | `/mcp` | HTTP endpoint path |
55
+ | `MCP_HTTP_MAX_BODY_BYTES` | `mcpHttpMaxBodyBytes` | `1048576` (1 MiB) | Max **inbound** JSON-RPC request body; oversized requests get `413` before per-request allocation. Does **not** cap upstream data staged into a canvas or response sizes. `0` disables (defer to runtime/proxy). |
55
56
  | `MCP_HTTP_MAX_PORT_RETRIES` | `mcpHttpMaxPortRetries` | `15` | Retry count if port is busy |
56
57
  | `MCP_HTTP_PORT_RETRY_DELAY_MS` | `mcpHttpPortRetryDelayMs` | `50` | Delay between port retries (ms) |
57
58
  | `MCP_SESSION_MODE` | `mcpSessionMode` | `auto` | `stateless` \| `stateful` \| `auto` |
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: api-context
3
3
  description: >
4
- Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.elicit`, `ctx.sample`, `ctx.progress`), and when to use each.
4
+ Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.elicit`, `ctx.sample`, `ctx.progress`, `ctx.enrich`), and when to use each.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.3"
7
+ version: "1.4"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -57,6 +57,12 @@ interface Context {
57
57
  // Raw URI — present only for resource handlers
58
58
  readonly uri?: URL;
59
59
 
60
+ // Agent-facing success-path enrichment — accumulates notices, query echo, totals
61
+ // onto the request; reaches structuredContent + content[]. Always present (no-op
62
+ // when no `enrichment` block), strictly typed on HandlerContext<R, E> against the
63
+ // declared fields. Kind-tagged helpers: enrich.notice / .total / .echo.
64
+ readonly enrich: Enrich;
65
+
60
66
  // Opt-in contract resolver — always present (returns {} when no contract is attached
61
67
  // or the reason is unknown), strictly typed on HandlerContext<R> against declared reasons.
62
68
  recoveryFor(reason: string): { recovery: { hint: string } } | {};
@@ -520,6 +526,62 @@ The `≥5 words` lint rule on contract `recovery` (validated at lint time) makes
520
526
 
521
527
  ---
522
528
 
529
+ ## `ctx.enrich`
530
+
531
+ Always present on `Context`. Accumulates agent-facing **success-path** context — empty-result notices, the query/filter as the server parsed it, pagination totals — onto the request. The framework merges it into `structuredContent`, advertises `output.extend(enrichment)` as the tool's `outputSchema`, and mirrors it into a `content[]` trailer. The success-path counterpart to `ctx.fail` / `ctx.recoveryFor`.
532
+
533
+ ```ts
534
+ export const search = tool('search', {
535
+ description: 'Search the catalog.',
536
+ input: z.object({ query: z.string().describe('Search terms') }),
537
+ output: z.object({ items: z.array(z.string()).describe('Matching items') }),
538
+ enrichment: {
539
+ effectiveQuery: z.string().describe('Query as the server parsed it'),
540
+ totalCount: z.number().describe('Total matches before the limit'),
541
+ notice: z.string().optional().describe('Guidance when nothing matched'),
542
+ },
543
+ async handler(input, ctx) {
544
+ const res = await runSearch(input.query);
545
+ ctx.enrich.echo(res.parsed); // → effectiveQuery + "Query: …" trailer
546
+ ctx.enrich.total(res.total); // → totalCount + "N total" trailer
547
+ if (res.items.length === 0) ctx.enrich.notice(`No matches for "${input.query}".`);
548
+ return { items: res.items }; // enrichment never rides in the domain return
549
+ },
550
+ });
551
+ ```
552
+
553
+ ### Signature
554
+
555
+ ```ts
556
+ // Loose (always present on Context — works without a block; service-callable):
557
+ ctx.enrich(fields: Record<string, unknown>): void
558
+
559
+ // Strict (HandlerContext<R, E> when the definition declares an enrichment block):
560
+ ctx.enrich(fields: Partial<z.infer<ZodObject<E>>>): void
561
+
562
+ // Kind-tagged field-helpers (always present) — write a conventional key and tag
563
+ // the content[] trailer rendering:
564
+ ctx.enrich.notice(text: string): void // writes `notice` → blockquote
565
+ ctx.enrich.total(count: number): void // writes `totalCount` → "N total"
566
+ ctx.enrich.echo(query: string): void // writes `effectiveQuery` → "Query: …"
567
+ ```
568
+
569
+ ### Behavior
570
+
571
+ | Aspect | Detail |
572
+ |:-------|:-------|
573
+ | Accumulation | Each call merges its fields onto the request; later calls override earlier keys. |
574
+ | Both surfaces | Merged into `structuredContent` (validated against `output.extend(enrichment)`) and appended to `content[]` as a trailer — even when the tool defines no `format()`. |
575
+ | Domain payload untouched | `content[]` renders the handler's return via `format()` (or the JSON default); enrichment is a separate trailer, never double-rendered. The handler return must NOT carry enrichment fields. |
576
+ | Required-field guard | A required enrichment field never populated fails the effective-output parse — the bug surfaces loudly rather than dropping silently. |
577
+ | No block | Calling `ctx.enrich` on a tool that declared no `enrichment` is a silent no-op (values are stripped by the parse) — the price of service-layer callability. |
578
+ | Service usage | Services accepting `ctx: Context` can call `ctx.enrich(...)`; the value reaches `structuredContent` exactly as if the handler had. |
579
+ | `format-parity` | Enrichment lives outside `output`, so the `format-parity` lint never requires it in `format()`. |
580
+
581
+ See `add-tool`'s **Tool Response Design** and `skills/api-linter` (`enrichment-*` rules) for the full pattern. Test enrichment with `getEnrichment(ctx)` from `@cyanheads/mcp-ts-core/testing`.
582
+
583
+ ---
584
+
523
585
  ## Quick reference
524
586
 
525
587
  | Property | Type | Present when |
@@ -534,6 +596,7 @@ The `≥5 words` lint rule on contract `recovery` (validated at lint time) makes
534
596
  | `ctx.log` | `ContextLogger` | Always |
535
597
  | `ctx.state` | `ContextState` | Always (throws if `tenantId` missing) |
536
598
  | `ctx.signal` | `AbortSignal` | Always |
599
+ | `ctx.enrich` | `Enrich` | Always; typed on `HandlerContext<R, E>` when an `enrichment` block is declared |
537
600
  | `ctx.elicit` | `function \| undefined` | Client supports elicitation |
538
601
  | `ctx.sample` | `function \| undefined` | Client supports sampling |
539
602
  | `ctx.notifyResourceListChanged` | `function \| undefined` | Transport supports resource notifications |
@@ -4,7 +4,7 @@ description: >
4
4
  MCP definition linter rules reference. Use when `bun run lint:mcp` or `bun run devcheck` reports a lint error or warning (`format-parity`, `schema-is-object`, `name-format`, `server-json-*`, etc.) and you need to understand the rule, its severity, and how to fix it. Every rule ID the linter emits has an entry in this doc.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.3"
7
+ version: "1.4"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -53,6 +53,7 @@ Grouped by family. Jump to any rule ID via its anchor.
53
53
  | Handler body | `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error` | [Handler body rules](#handler-body-rules) |
54
54
  | Error contract (structural) | `error-contract-type`, `error-contract-empty`, `error-contract-entry-type`, `error-contract-code-type`, `error-contract-code-unknown`, `error-contract-code-unknown-error`, `error-contract-reason-required`, `error-contract-reason-format`, `error-contract-reason-unique`, `error-contract-when-required`, `error-contract-retryable-type`, `error-contract-recovery-required`, `error-contract-recovery-empty`, `error-contract-recovery-min-words` | [Error contract rules](#error-contract-rules) |
55
55
  | Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail` | [Error contract rules](#error-contract-rules) |
56
+ | Enrichment | `enrichment-type`, `enrichment-empty`, `enrichment-field-type`, `enrichment-output-collision`, `enrichment-prefer-block` | [Enrichment rules](#enrichment-rules) |
56
57
  | server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
57
58
 
58
59
  ---
@@ -708,6 +709,52 @@ The diagnostic message includes the declared reason(s) for the code so you can c
708
709
 
709
710
  ---
710
711
 
712
+ ## Enrichment rules
713
+
714
+ Validate the `enrichment` block — the success-path counterpart to `errors[]`. Enrichment fields are merged into `structuredContent` and advertised as `output.extend(enrichment)`, so the linter guards the block's shape and its disjointness from `output`. See `api-context`'s `ctx.enrich` and `add-tool`'s **Tool Response Design**.
715
+
716
+ ### enrichment-type
717
+
718
+ **Severity:** error
719
+
720
+ Fires when `enrichment` is present but isn't a plain object mapping field names to Zod schemas (a `ZodRawShape`) — e.g. an array or a primitive.
721
+
722
+ **Fix:** declare `enrichment: { <name>: <ZodType>, … }`.
723
+
724
+ ### enrichment-empty
725
+
726
+ **Severity:** warning
727
+
728
+ Fires when `enrichment: {}` is declared with no fields — a no-op.
729
+
730
+ **Fix:** drop the field, or declare the agent-facing fields `ctx.enrich(...)` will populate.
731
+
732
+ ### enrichment-field-type
733
+
734
+ **Severity:** error
735
+
736
+ Fires when an enrichment field's value isn't a Zod schema.
737
+
738
+ **Fix:** use a Zod type (`z.string().describe(…)`, `z.number().describe(…)`, …) for every enrichment field.
739
+
740
+ ### enrichment-output-collision
741
+
742
+ **Severity:** error
743
+
744
+ Fires when an enrichment key matches an `output` key. The effective output schema is `output.extend(enrichment)`, so a collision silently overrides the `output` field.
745
+
746
+ **Fix:** rename one side so enrichment keys are disjoint from output keys.
747
+
748
+ ### enrichment-prefer-block
749
+
750
+ **Severity:** warning
751
+
752
+ Advisory. Fires when a tool has **no** `enrichment` block but an `output` field whose name strongly signals agent-facing context (`notice`, `effectiveQuery`, `queryEcho`) rather than domain payload.
753
+
754
+ **Fix:** move the field into an `enrichment` block and populate it via `ctx.enrich(...)` — it reaches both client surfaces without a `format()` entry. Ignore if the field is genuinely domain data. Deliberately conservative — common domain fields like `totalCount` are not flagged.
755
+
756
+ ---
757
+
711
758
  ## Escape hatches
712
759
 
713
760
  ### Dynamic upstream data
@@ -4,7 +4,7 @@ description: >
4
4
  Design the tool surface, resources, and service layer for a new MCP server. Use when starting a new server, planning a major feature expansion, or when the user describes a domain/API they want to expose via MCP. Produces a design doc at docs/design.md that drives implementation.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.12"
7
+ version: "2.13"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -351,6 +351,7 @@ output: z.object({
351
351
  - **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.
352
352
  - **Spill big tabular results to a queryable surface.** When a tool's row set can exceed any reasonable context budget — paginated APIs, streamed exports, big query results — pair an inline preview with a `DataCanvas` table holding the full set, returned as a token the agent can SQL. Compute distributions or refinement hints across the full result, not the preview, so aggregate signal stays honest. See `api-canvas` for the `spillover()` helper.
353
353
  - **`format()` is the markdown twin of `structuredContent` — make both content-complete.** Different MCP clients forward different surfaces to the model: some (e.g., Claude Code) read `structuredContent` from `output`, others (e.g., Claude Desktop) read `content[]` from `format()`. Both must carry the same data so every client sees the same picture — `format()` just dresses it up with markdown. A thin `format()` that returns only a count or title leaves `content[]`-only clients blind to data that `structuredContent` clients can see. Render all fields the LLM needs, with structured markdown (headers, bold labels, lists) for readability.
354
+ - **Agent-facing context must reach both client surfaces — put it in `enrichment`.** `structuredContent` (from `output`) and `content[]` (from `format()`) are read by different clients. Empty-result notices, the query/filter as the server parsed it, and pagination totals — the context the agent *reasons with*, distinct from the domain payload — reach only `content[]` if hand-authored into `format()` text alone, leaving `structuredContent`-only clients (Claude Code) blind. (The reverse can't happen: `format-parity` drags every `output` field into `format()`, so `output`-authored context already reaches both.) An `enrichment` block — the success-path counterpart to `errors[]`, populated via `ctx.enrich(...)` — reaches both automatically: merged into `structuredContent`, advertised as `output.extend(enrichment)`, mirrored into a `content[]` trailer, no `format()` entry needed. See `add-tool`'s **Tool Response Design**.
354
355
 
355
356
  #### Batch input design
356
357
 
@@ -69,7 +69,7 @@ The orchestrator owns the goals. Workflow phases are not "run skill X" — they
69
69
  Before running a phase (or spawning a sub-agent for it), write down four things:
70
70
 
71
71
  1. **Goal** — the verifiable end state this phase must produce. Concrete and testable: "v0.5.2 tag exists at HEAD with structured-markdown annotation; `bun run devcheck` green; `npm view <pkg>@0.5.2` resolves." Not fuzzy: "ran the release-and-publish skill."
72
- 2. **Primary sources** — the specific files, GH issues, and reference docs the sub-agent must read directly. Inlining content into the prompt is a paraphrase that loses nuance; agents grounded in the source catch details the orchestrator's summary missed. For GH issues, instruct `gh issue view N --comments` — body alone misses thread clarifications. The orchestrator reads these sources too (to construct the prompt), but that's prompt construction, not a substitute for the sub-agent reading them.
72
+ 2. **Primary sources** — the specific files, GH issues, and reference docs the sub-agent must read directly. Inlining content into the prompt is a paraphrase that loses nuance; agents grounded in the source catch details the orchestrator's summary missed. For GH issues, instruct both `gh issue view N --comments` (the comment thread) and the timeline cross-reference query in the Orient block (what references the issue, including cross-repo) the body alone misses both. The orchestrator reads these sources too (to construct the prompt), but that's prompt construction, not a substitute for the sub-agent reading them.
73
73
  3. **Path** — the Tier 1 skill(s) and steps that get to the goal. This is what gets handed to the sub-agent.
74
74
  4. **Verification** — the read-only checks that confirm the goal was hit. Defined upfront, not as an afterthought.
75
75
 
@@ -91,6 +91,8 @@ Sub-agents are optional. Match the mechanism to your platform's capability — t
91
91
 
92
92
  Phases, gates, goals, and constraints are identical across all three tiers — only the fanout mechanism changes. Use the most capable tier available, and don't hand-roll what the platform does natively (e.g., rolling concurrency). Choose by scope and capability, not by default.
93
93
 
94
+ **Model tier ≠ orchestration tier.** A higher orchestration tier is not automatically the right choice. On some platforms, programmatically-orchestrated or *nested* sub-agents (an agent spawning agents) silently run on a cheaper/downgraded model, while the strongest model is reachable only by sub-agents the **main loop spawns directly** (tier 2). When a phase needs the top model (heavy generation, design, framework adoption), prefer direct main-loop fanout even when a more "capable" orchestration primitive exists — the primitive can cost you the model. Verify the model your platform actually assigned via its UI/telemetry; never infer it from a sub-agent's transcript, which interleaves auxiliary calls (titles, summaries) on cheaper models and will mislead you.
95
+
94
96
  The decision tree below is orthogonal to tier — it governs *whether* a given phase fans out, by target count and conflict risk:
95
97
 
96
98
  | Situation | Strategy |
@@ -119,10 +121,12 @@ order. If any file does not exist, note it and continue.
119
121
  available skills with descriptions and locations.
120
122
  5. Read the skill file(s) for this task: `[Tier 1 skill paths]`.
121
123
  6. Read the primary sources for this task directly — design docs (`docs/design.md`),
122
- GH issues (use `gh issue view <N> --comments` to capture the full thread, not
123
- just the body), handoff documents, reference/gold-standard files. List each
124
- one explicitly: `[primary source paths and gh commands]`. Skip this step only
125
- if no primary source applies (rare).
124
+ GH issues, handoff documents, reference/gold-standard files. For a GH issue, read
125
+ both the comment thread and its cross-references the body alone misses both:
126
+ - `gh issue view <N> --comments` description + comment thread
127
+ - `gh api 'repos/{owner}/{repo}/issues/<N>/timeline' --paginate --jq '.[] | select(.event=="cross-referenced") | .source.issue | "\(.repository.full_name)#\(.number) — \(.title)"'` — issues/PRs that reference this one, including from other repos
128
+ List each source explicitly: `[primary source paths and gh commands]`. Skip this
129
+ step only if no primary source applies (rare).
126
130
 
127
131
  Only after that, begin the task below.
128
132
 
@@ -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: "2.3"
7
+ version: "2.4"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -212,7 +212,6 @@ If the project ships as an `.mcpb` bundle for Claude Desktop (check for `manifes
212
212
  - If `manifest.json` exists, the README should include the Claude Desktop install badge linking to `releases/latest/download/<name>.mcpb`
213
213
  - If the package is published to npm, include Cursor and VS Code install badges
214
214
  - See `references/readme.md` for badge format and config generation commands
215
- - See the **Bundling** section of `templates/CLAUDE.md` for `base64` / `encodeURIComponent` generation
216
215
 
217
216
  ### 12. `LICENSE`
218
217
 
@@ -54,7 +54,21 @@ Centered HTML. The `<h1>` is the server name — use the scoped package name if
54
54
  </div>
55
55
  ```
56
56
 
57
- See the **Bundling** section of `templates/CLAUDE.md` (or `templates/AGENTS.md`) for how to generate the `<BASE64_CONFIG>` and `<URLENCODED_JSON>` payloads. Omit any install badge whose target doesn't apply (e.g. no `.mcpb` bundle drop the Claude Desktop badge).
57
+ Generate the `<BASE64_CONFIG>` (Cursor) and `<URLENCODED_JSON>` (VS Code) payloads replace `<PACKAGE_NAME>` / `<SHORT_NAME>` and add `env` only for required API keys:
58
+
59
+ ```bash
60
+ # Cursor: base64-encoded JSON. Split command/args, add env when keys are needed.
61
+ echo -n '{"command":"npx","args":["-y","<PACKAGE_NAME>"],"env":{"API_KEY":"your-api-key"}}' | base64
62
+ # Without env (no required keys):
63
+ echo -n '{"command":"npx","args":["-y","<PACKAGE_NAME>"]}' | base64
64
+
65
+ # VS Code: URL-encoded JSON. Same shape plus a `name` field.
66
+ node -p 'encodeURIComponent(JSON.stringify({name:"<SHORT_NAME>",command:"npx",args:["-y","<PACKAGE_NAME>"],env:{API_KEY:"your-api-key"}}))'
67
+ # Without env:
68
+ node -p 'encodeURIComponent(JSON.stringify({name:"<SHORT_NAME>",command:"npx",args:["-y","<PACKAGE_NAME>"]}))'
69
+ ```
70
+
71
+ Both clients use the same `{command, args, env}` shape; VS Code adds a top-level `name`. Omit `env` entirely when no API keys are needed — don't include empty objects or framework-only vars like `MCP_TRANSPORT_TYPE`. Install links route through HTTPS endpoints (`cursor.com/en/install-mcp`, `vscode.dev/redirect`) because GitHub-rendered markdown strips non-HTTP schemes — a raw `cursor://` or `vscode:` link won't click through. Omit any install badge whose target doesn't apply (e.g. no `.mcpb` bundle → drop the Claude Desktop badge).
58
72
 
59
73
  The header tagline must match the `package.json` `description`.
60
74
 
@@ -30,7 +30,12 @@ For general `gh` CLI workflows outside issue filing (PRs, workflows, API access)
30
30
  4. **Search existing issues** — don't file duplicates:
31
31
 
32
32
  ```bash
33
- gh issue list -R cyanheads/mcp-ts-core --search "your error message or keyword"
33
+ gh issue list -R cyanheads/mcp-ts-core --search "your error message or keyword" --state all
34
+
35
+ # Assess a close match before commenting — is it already linked to a fix or referenced elsewhere?
36
+ gh issue view <number> -R cyanheads/mcp-ts-core --comments
37
+ gh api 'repos/cyanheads/mcp-ts-core/issues/<number>/timeline' --paginate \
38
+ --jq '.[] | select(.event=="cross-referenced") | .source.issue | "\(.repository.full_name)#\(.number) — \(.title)"'
34
39
  ```
35
40
 
36
41
  5. **For documentation- or contract-shaped requests, audit all three doc layers first** — proposals to add reference docs, public-API conventions, attribute/event catalogs, or stability commitments often duplicate surface that already exists. Check `src/` for behavior, `docs/` for human-facing reference, and `skills/` for agent-facing reference. Skill files marked `audience: external` are the framework's public contract — treat them as authoritative when evaluating whether a documentation gap exists. Also verify the constants or types you'd reference aren't already exported from `@cyanheads/mcp-ts-core` or one of its subpaths.
@@ -273,8 +278,8 @@ ISSUE
273
278
  ## Following Up
274
279
 
275
280
  ```bash
276
- # Check issue status
277
- gh issue view <number> -R cyanheads/mcp-ts-core
281
+ # Check issue status (with comment thread)
282
+ gh issue view <number> -R cyanheads/mcp-ts-core --comments
278
283
 
279
284
  # Add context or respond to maintainer questions
280
285
  gh issue comment <number> -R cyanheads/mcp-ts-core --body "Additional context..."
@@ -35,7 +35,12 @@ gh repo view --json nameWithOwner -q '.nameWithOwner'
35
35
  2. **Search existing issues** — if a close match exists (same symptom, different tool; same tool, different symptom; closed issue that might cover the new case), add a comment on that issue instead of filing a new one — unless the symptom or scope is distinct enough to warrant separate tracking:
36
36
 
37
37
  ```bash
38
- gh issue list --search "your error message or keyword"
38
+ gh issue list --search "your error message or keyword" --state all
39
+
40
+ # Assess a close match before commenting — is it already linked to a fix or referenced elsewhere?
41
+ gh issue view <number> --comments
42
+ gh api 'repos/{owner}/{repo}/issues/<number>/timeline' --paginate \
43
+ --jq '.[] | select(.event=="cross-referenced") | .source.issue | "\(.repository.full_name)#\(.number) — \(.title)"'
39
44
  ```
40
45
 
41
46
  3. **Reproduce the issue** — confirm it's reproducible. Note the exact input, transport mode, and any relevant env vars.
@@ -280,8 +285,8 @@ When genuinely ambiguous, file against this server's repo and note that it might
280
285
  ## Following Up
281
286
 
282
287
  ```bash
283
- # View issue details
284
- gh issue view <number>
288
+ # View issue details (with comment thread)
289
+ gh issue view <number> --comments
285
290
 
286
291
  # Add context
287
292
  gh issue comment <number> --body "Additional findings..."
@@ -3,6 +3,7 @@
3
3
  # MCP_HTTP_PORT=3010 # HTTP port (default: 3010)
4
4
  # MCP_HTTP_HOST=localhost # HTTP host (default: localhost)
5
5
  # MCP_HTTP_ENDPOINT_PATH=/mcp # HTTP endpoint path (default: /mcp)
6
+ # MCP_HTTP_MAX_BODY_BYTES=1048576 # Max request body bytes; 413 over limit, 0 disables (default: 1048576)
6
7
  # MCP_PUBLIC_URL= # Public origin behind a TLS-terminating proxy (e.g. https://mcp.example.com)
7
8
 
8
9
  # ── Auth ──────────────────────────────────────────────────────────────
@@ -50,6 +50,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
50
50
  - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly.
51
51
  - **Check `ctx.elicit` / `ctx.sample`** for presence before calling.
52
52
  - **Secrets in env vars only** — never hardcoded.
53
+ - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
53
54
 
54
55
  ---
55
56
 
@@ -151,6 +152,10 @@ export function getServerConfig() {
151
152
 
152
153
  `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`MY_API_KEY`) not the path (`apiKey`). Throws `ConfigurationError`, which the framework prints as a clean startup banner.
153
154
 
155
+ ### Server instructions
156
+
157
+ `createApp({ instructions })` — optional server-level orientation, sent to clients on every `initialize` as session-level context. Use it for deployment guidance (connection aliases, regional notes, scope hints) instead of repeating the same context across tool descriptions. Client adoption is uneven, but there's no downside when set.
158
+
154
159
  ---
155
160
 
156
161
  ## Context
@@ -177,6 +182,8 @@ Handlers throw — the framework catches, classifies, and formats.
177
182
  **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required descriptive metadata for the agent's next move (≥ 5 words, lint-validated); for the wire `data.recovery.hint` (mirrored into `content[]` text), pass explicitly at the throw site when dynamic context matters: `ctx.fail('reason', msg, { recovery: { hint: '...' } })`. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
178
183
 
179
184
  ```ts
185
+ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
186
+
180
187
  errors: [
181
188
  { reason: 'no_match', code: JsonRpcErrorCode.NotFound,
182
189
  when: 'No item matched the query',
@@ -271,7 +278,6 @@ Available skills:
271
278
  | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
272
279
  | `git-wrapup` | Land working-tree changes as a versioned commit + annotated tag — version bump, changelog, verify, tag. Local only. |
273
280
  | `release-and-publish` | Push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
274
- | `migrate-mcp-ts-template` | One-time migration of an existing project to the current framework template |
275
281
  | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
276
282
  | `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI |
277
283
  | `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI |
@@ -319,40 +325,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
319
325
 
320
326
  **Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match.
321
327
 
322
- **README install badges.** Drop these into the project README to give users one-click install paths. Fill in `<OWNER>` / `<REPO>` / `<PACKAGE_NAME>` and encode the per-server config. Cursor + VS Code badges assume the server is published to npm; Claude Desktop downloads the `.mcpb` directly so npm publishing isn't required.
323
-
324
- | Client | Mechanism |
325
- |:-------|:----------|
326
- | Claude Desktop | Browser downloads the `.mcpb` from the latest GitHub Release; OS file handler routes it to Claude Desktop, which opens the install dialog. No deep-link URL scheme yet — this is the canonical path. |
327
- | Cursor | Official `https://cursor.com/en/install-mcp` endpoint with base64 JSON config. |
328
- | VS Code / Insiders | Official `vscode:mcp/install?...` deep link, wrapped in `https://vscode.dev/redirect?url=` so GitHub-rendered markdown doesn't strip the non-HTTP scheme. |
329
- | Claude Code / Codex | CLI only (`claude mcp add` / `codex mcp add`); no URL scheme. |
330
-
331
- ```markdown
332
- [![Install in Claude Desktop](https://img.shields.io/badge/Install_in-Claude_Desktop-D97757?style=for-the-badge&logo=anthropic&logoColor=white)](https://github.com/<OWNER>/<REPO>/releases/latest/download/<PACKAGE_NAME>.mcpb)
333
- [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=<PACKAGE_NAME>&config=<BASE64_CONFIG>)
334
- [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=for-the-badge&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:mcp/install?<URLENCODED_JSON>)
335
- ```
336
-
337
- Both install links route through HTTPS endpoints (`cursor.com/en/install-mcp` and `vscode.dev/redirect`) — GitHub-rendered markdown strips non-HTTP URL schemes from anchors, so a raw `cursor://` or `vscode:` link won't click through from github.com.
338
-
339
- Generate the encoded configs (replace `<PACKAGE_NAME>` and add env vars for any required API keys):
340
-
341
- ```bash
342
- # Cursor: base64-encoded JSON. Split command/args, add env when keys are needed.
343
- echo -n '{"command":"npx","args":["-y","<PACKAGE_NAME>"],"env":{"API_KEY":"your-api-key"}}' | base64
344
- # Without env (no required keys):
345
- echo -n '{"command":"npx","args":["-y","<PACKAGE_NAME>"]}' | base64
346
-
347
- # VS Code: URL-encoded JSON. Same shape plus a `name` field.
348
- node -p 'encodeURIComponent(JSON.stringify({name:"<SHORT_NAME>",command:"npx",args:["-y","<PACKAGE_NAME>"],env:{API_KEY:"your-api-key"}}))'
349
- # Without env:
350
- node -p 'encodeURIComponent(JSON.stringify({name:"<SHORT_NAME>",command:"npx",args:["-y","<PACKAGE_NAME>"]}))'
351
- ```
352
-
353
- Both clients use the same `{command, args, env}` shape (matching `mcp.json` schema). VS Code adds a top-level `name` field. Omit `env` entirely when no API keys are needed — don't include empty objects or framework-only vars like `MCP_TRANSPORT_TYPE`.
354
-
355
- The Claude Desktop badge requires the bundle to ship with a stable filename — `bun run bundle` outputs `dist/<PACKAGE_NAME>.mcpb`, and `release-and-publish` attaches that file to the GitHub Release. `releases/latest/download/<PACKAGE_NAME>.mcpb` then redirects to the most recent release.
328
+ **README install badges** (Claude Desktop `.mcpb`, Cursor, VS Code) and the `base64` / `encodeURIComponent` config-generation commands are ship-time concerns run the `polish-docs-meta` skill, which carries the badge format, layout, and generation snippets in `skills/polish-docs-meta/references/readme.md`.
356
329
 
357
330
  ---
358
331