@cyanheads/mcp-ts-core 0.9.20 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/AGENTS.md +2 -2
  2. package/CLAUDE.md +2 -2
  3. package/README.md +1 -1
  4. package/changelog/0.10.x/0.10.0.md +19 -0
  5. package/changelog/0.9.x/0.9.21.md +15 -0
  6. package/dist/config/index.d.ts +6 -6
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +17 -19
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/config/parseEnvConfig.d.ts +4 -0
  11. package/dist/config/parseEnvConfig.d.ts.map +1 -1
  12. package/dist/config/parseEnvConfig.js +4 -0
  13. package/dist/config/parseEnvConfig.js.map +1 -1
  14. package/dist/logs/combined.log +3 -4
  15. package/dist/logs/error.log +2 -2
  16. package/dist/mcp-server/transports/http/httpTransport.d.ts +1 -1
  17. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  18. package/dist/mcp-server/transports/http/httpTransport.js +33 -16
  19. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  20. package/dist/utils/index.d.ts +1 -0
  21. package/dist/utils/index.d.ts.map +1 -1
  22. package/dist/utils/index.js +2 -0
  23. package/dist/utils/index.js.map +1 -1
  24. package/dist/utils/overflow/outlineOnOverflow.d.ts +119 -0
  25. package/dist/utils/overflow/outlineOnOverflow.d.ts.map +1 -0
  26. package/dist/utils/overflow/outlineOnOverflow.js +109 -0
  27. package/dist/utils/overflow/outlineOnOverflow.js.map +1 -0
  28. package/package.json +5 -5
  29. package/scripts/check-framework-antipatterns.ts +32 -9
  30. package/skills/add-tool/SKILL.md +3 -1
  31. package/skills/api-config/SKILL.md +5 -1
  32. package/skills/design-mcp-server/SKILL.md +2 -1
  33. package/skills/orchestrations/SKILL.md +1 -1
  34. package/skills/orchestrations/workflows/field-test-fix.md +1 -1
  35. package/skills/orchestrations/workflows/fix-wrapup-release.md +1 -1
  36. package/skills/orchestrations/workflows/greenfield-build.md +1 -1
  37. package/skills/orchestrations/workflows/maintenance-release.md +1 -1
  38. package/skills/release-and-publish/SKILL.md +2 -1
  39. package/skills/techniques/SKILL.md +32 -0
  40. package/skills/techniques/references/outline-on-overflow.md +124 -0
  41. package/templates/AGENTS.md +8 -1
  42. package/templates/CLAUDE.md +8 -1
  43. package/templates/Dockerfile +3 -0
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @fileoverview Guards against three SDK-coupling antipatterns. Scans `src/`
4
- * via `git grep` all rules target framework-internal paths. Shipped to
5
- * consumers via `package.json` `files:` because `devcheck` invokes it; in
6
- * consumer projects the scanned paths (`src/mcp-server/tools/`,
7
- * `src/mcp-server/transports/`) either don't exist or contain consumer code
8
- * that follows different conventions, so the script exits cleanly with 0
9
- * findings. Defense-in-depth: harmless when nothing matches, catches real
10
- * regressions in the framework.
3
+ * @fileoverview Guards against framework antipatterns via `git grep` over
4
+ * `src/`. Rules 1–3 are SDK-coupling regressions scoped to framework-internal
5
+ * paths they no-op in consumer projects, where those paths either don't exist
6
+ * or hold consumer code under different conventions. Rule 4 (`z.coerce.boolean()`)
7
+ * is intentionally consumer-facing: it catches the env-boolean footgun in both
8
+ * framework and scaffolded-server config. Shipped to consumers via
9
+ * `package.json` `files:` because `devcheck` invokes it. Defense-in-depth:
10
+ * harmless when nothing matches, catches real regressions.
11
11
  *
12
12
  * Rules:
13
13
  * 1. Framework must not downgrade the Zod `inputSchema` passed to
@@ -23,6 +23,11 @@
23
23
  * `"Input validation error"`) is brittle across SDK versions. Any fix for
24
24
  * #66 that intervenes at transport should use a structural signal, not a
25
25
  * string match.
26
+ * 4. `z.coerce.boolean()` on an env flag can't be turned off through the
27
+ * environment — `Boolean("false") === true`, so `"false"`/`"0"`/`"no"`
28
+ * all coerce to `true` and the only `false` is omitting the variable.
29
+ * Use `z.stringbool()` (parses `true/false/1/0/yes/no/on/off`, rejects
30
+ * the rest). Scoped to `src/` so it fires in consumer config too.
26
31
  *
27
32
  * Runs standalone (`bun run scripts/check-framework-antipatterns.ts`) and as
28
33
  * a devcheck step.
@@ -62,6 +67,13 @@ const RULES: Rule[] = [
62
67
  pathspec: ['src/mcp-server/transports/'],
63
68
  message: 'Matching SDK error text in transport layer is brittle across SDK versions',
64
69
  },
70
+ {
71
+ id: 'coerce-boolean-env-flag',
72
+ pattern: 'z\\.coerce\\.boolean\\(\\)',
73
+ pathspec: ['src/', ':!**/*.test.ts'],
74
+ message:
75
+ 'z.coerce.boolean() can\'t be disabled via env (Boolean("false") is true) — use z.stringbool() for boolean env flags',
76
+ },
65
77
  ];
66
78
 
67
79
  interface Finding {
@@ -72,6 +84,16 @@ interface Finding {
72
84
  ruleMessage: string;
73
85
  }
74
86
 
87
+ /**
88
+ * A matched line that is itself a comment is a mention (e.g. JSDoc naming the
89
+ * antipattern to document the rule), not a real usage. Real violations are
90
+ * code. Skipping comment lines keeps the rules sound when docs name the pattern.
91
+ */
92
+ function isCommentLine(line: string): boolean {
93
+ const t = line.trim();
94
+ return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*');
95
+ }
96
+
75
97
  function runRule(rule: Rule): Finding[] {
76
98
  const result = spawnSync('git', ['grep', '-nE', rule.pattern, '--', ...rule.pathspec], {
77
99
  encoding: 'utf-8',
@@ -97,7 +119,8 @@ function runRule(rule: Rule): Finding[] {
97
119
  const lineNo = Number(raw.slice(firstColon + 1, secondColon));
98
120
  const line = raw.slice(secondColon + 1);
99
121
  return { file, lineNo, line, ruleId: rule.id, ruleMessage: rule.message };
100
- });
122
+ })
123
+ .filter((finding) => !isCommentLine(finding.line));
101
124
  }
102
125
 
103
126
  const findings = RULES.flatMap(runRule);
@@ -4,7 +4,7 @@ description: >
4
4
  Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.12"
7
+ version: "2.13"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -575,6 +575,7 @@ Large payloads burn the agent's context window. Default to curated summaries; of
575
575
  - **Large objects**: Return key fields by default; accept a `fields` or `verbose` parameter for full data
576
576
  - **Binary/blob content**: Return metadata and a reference, not the raw content
577
577
  - **Analytical working sets**: When upstream returns more *analytical* rows (data an agent would SQL — aggregate, group, join) than fit in context, `DataCanvas` (`ctx.core.canvas?`, Tier 3 — opt-in via `CANVAS_PROVIDER_TYPE=duckdb`) lets you register the rows and return the `canvas_id` plus a preview so the agent can run SQL to slice down without a re-fetch. The `spillover()` helper (`@cyanheads/mcp-ts-core/canvas`) automates the overflow case: drain rows up to a character budget for the inline preview, auto-register the full source on overflow, return both as a discriminated union. **Two gates:** it must be analytical, not a discovery/search surface of categorical metadata (those don't earn a canvas regardless of row count — use MCP-side list filtering or pagination); and a tool emitting a `canvas_id` MUST be paired with a registered `dataframe_query` tool, or the handle is unreachable. Compute distributions or refinement hints across the full result — not the preview — so the agent gets honest aggregate signal on the rows it didn't read. See `api-canvas` for the register / query / export pattern and the spillover flow.
578
+ - **One large document**: When a single call returns one document-shaped record (not a row set) that can overflow context, return a section *outline* — top-level keys + per-section byte size — and let the agent re-call with `sections: [...]` for only what it needs, instead of truncating one surface. `outlineOnOverflow()` with `OUTLINE_VARIANT` / `selectSections()` / `formatOutline()` (`@cyanheads/mcp-ts-core/utils`) measures the payload and returns a `full | outline` discriminated-union `output`; declare `OUTLINE_VARIANT` as a branch so `format()`-parity holds per arm. Pure measure + key-slice — Workers-portable, unlike canvas `spillover()`. Use for one fat record; use `spillover()` for a row collection. See the `techniques` skill's `outline-on-overflow` reference.
578
579
 
579
580
  ## MCP-side list filtering
580
581
 
@@ -624,6 +625,7 @@ return { items: hits };
624
625
  - [ ] `task: true` added if the tool is long-running
625
626
  - [ ] If `task: true`: handler checks `ctx.signal.aborted` in its loop for cancellation support
626
627
  - [ ] If tool returns unbounded arrays: pagination with total count, or `spillover()` / DataCanvas for *analytical* working sets (an agent would SQL them — not a discovery/search surface). If any tool emits a `canvas_id`, a `dataframe_query` tool is registered in the same server — a token with no query tool is dead output
628
+ - [ ] If tool returns one large *document* (not a row set) that can overflow context: `outlineOnOverflow()` returns a `full | outline` union so the agent re-calls with `sections: [...]` — not one-sided truncation
627
629
  - [ ] If tool is feature-gated: evaluated whether `disabledTool()` wrapper is appropriate (present in manifest but uncallable)
628
630
  - [ ] If the tool filters a bounded list locally (no upstream search): a distinct local param (`filter`/`nameContains`, not `query`), filters the full set (not one page), strict token match by default
629
631
  - [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
@@ -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.5"
7
+ version: "1.6"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -220,6 +220,7 @@ import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
220
220
  const ServerConfigSchema = z.object({
221
221
  apiKey: z.string().describe('External API key'),
222
222
  maxResults: z.coerce.number().default(100),
223
+ verboseLogging: z.stringbool().default(false).describe('Enable verbose logging'),
223
224
  });
224
225
 
225
226
  export type ServerConfig = z.infer<typeof ServerConfigSchema>;
@@ -230,11 +231,14 @@ export function getServerConfig(): ServerConfig {
230
231
  _config ??= parseEnvConfig(ServerConfigSchema, {
231
232
  apiKey: 'MY_API_KEY',
232
233
  maxResults: 'MY_MAX_RESULTS',
234
+ verboseLogging: 'MY_VERBOSE_LOGGING',
233
235
  });
234
236
  return _config;
235
237
  }
236
238
  ```
237
239
 
240
+ **Env booleans — use `z.stringbool()`, never `z.coerce.boolean()`.** `z.coerce.boolean()` runs `Boolean(value)`, so `"false"`, `"0"`, and `"no"` all coerce to `true` — the flag becomes impossible to disable through the environment except by omitting it entirely. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` (case-insensitive) and rejects anything else, so `MY_VERBOSE_LOGGING=false` actually disables and a typo fails loudly at startup instead of silently coercing. Empty string and unset both fall through to `.default()`.
241
+
238
242
  **Why `parseEnvConfig`?** It maps Zod schema paths to env var names so validation errors name the actual variable at fault. A missing `MY_API_KEY` produces:
239
243
 
240
244
  ```
@@ -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.16"
7
+ version: "2.17"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -350,6 +350,7 @@ output: z.object({
350
350
 
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 *analytical* results to a queryable surface.** When a tool's row set is something an agent would run SQL over (aggregate, group, join) *and* 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. **Two rules gate this:** (1) it must earn its keep on *shape, not size* — a discovery/search surface of categorical metadata (titles, IDs) is not analytical and doesn't get a canvas regardless of row count; for name→ID resolution over a bounded list use [MCP-side list filtering](#mcp-side-list-filtering); (2) the `canvas_id` is reachable only if the same server **also exposes a `dataframe_query` tool** — emit one without the other and the handle is dead output. Compute distributions or refinement hints across the full result, not the preview, so aggregate signal stays honest. See `api-canvas` for the `spillover()` helper and both rules in full.
353
+ - **Outline one large *document* into sections.** When a single tool call returns one document-shaped record (not many rows) that can exceed context — a ~130KB FDA drug label, a big API entity dominated by a few fat fields — return a section *outline* (top-level keys + per-section byte size) instead of truncating, and let the agent re-call with `sections: [...]` to pull only what it needs. The `outlineOnOverflow()` helper (`@cyanheads/mcp-ts-core/utils`) measures the payload and returns a `full | outline` discriminated union; declare its `OUTLINE_VARIANT` as a branch of the tool's `output` so `format()`-parity is enforced per branch. Pure measure + key-slice — Workers-portable, unlike canvas-bound `spillover()`. Distinct from spillover on *shape*: spillover splits a row collection, this outlines one fat record. See the `techniques` skill's `outline-on-overflow` reference.
353
354
  - **Mirror a bulk upstream instead of paginating it live.** When the server wraps a large or slow API whose corpus is queried far more than it changes, sync it once into a persistent local index and query that as the primary data path — not the live API per request. Match the backend to corpus size: ≲ tens of thousands of rows → an in-memory index (server-level, no primitive); ~10⁴–10⁷ → the `MirrorService` (embedded SQLite + FTS5; declare a schema + a `sync` ingester via `defineMirror`/`sqliteMirrorStore`, then `runSync`/`query`, see `api-mirror`); ≳ 10⁸ → an external store. Distinct lifecycle from DataCanvas: a mirror is long-lived and cross-session, refreshed on a schedule; canvas is ephemeral and per-session.
354
355
  - **`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.
355
356
  - **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. How each field renders in that trailer is a per-tool call — a kind-tag (`notice`/`total`/`echo`/`delta`) when a canonical form fits, a domain key like `totalFound` otherwise, and an `enrichmentTrailer.render` for any structured (object/array) field so it doesn't ship as a JSON blob. See `add-tool`'s **Tool Response Design**.
@@ -5,7 +5,7 @@ description: >
5
5
  metadata:
6
6
  author: cyanheads
7
7
  version: "1.2"
8
- audience: internal
8
+ audience: external
9
9
  type: workflow
10
10
  ---
11
11
 
@@ -5,7 +5,7 @@ description: >
5
5
  metadata:
6
6
  author: cyanheads
7
7
  version: "1.0"
8
- audience: internal
8
+ audience: external
9
9
  type: workflow
10
10
  ---
11
11
 
@@ -5,7 +5,7 @@ description: >
5
5
  metadata:
6
6
  author: cyanheads
7
7
  version: "1.0"
8
- audience: internal
8
+ audience: external
9
9
  type: workflow
10
10
  ---
11
11
 
@@ -5,7 +5,7 @@ description: >
5
5
  metadata:
6
6
  author: cyanheads
7
7
  version: "1.0"
8
- audience: internal
8
+ audience: external
9
9
  type: workflow
10
10
  ---
11
11
 
@@ -5,7 +5,7 @@ description: >
5
5
  metadata:
6
6
  author: cyanheads
7
7
  version: "1.1"
8
- audience: internal
8
+ audience: external
9
9
  type: workflow
10
10
  ---
11
11
 
@@ -4,7 +4,7 @@ description: >
4
4
  Ship a release end-to-end across every registry the project targets (npm, MCP Registry, GitHub Releases for `.mcpb` bundles, GHCR). Runs the final verification gate, pushes commits and tags, then publishes to each applicable destination. Assumes git wrapup (version bumps, changelog, commit, annotated tag) is already complete — this skill is the post-wrapup publish workflow. Retries transient network failures on publish steps; halts with a partial-state report when retries are exhausted or the failure is terminal.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.8"
7
+ version: "2.9"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -174,6 +174,7 @@ Derive:
174
174
 
175
175
  ```bash
176
176
  docker buildx build --platform linux/amd64,linux/arm64 \
177
+ --build-arg APP_VERSION=<VERSION> \
177
178
  -t ghcr.io/<OWNER>/<REPO>:<VERSION> \
178
179
  -t ghcr.io/<OWNER>/<REPO>:latest \
179
180
  --push .
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: techniques
3
+ description: >
4
+ Catalog of reusable response- and data-shaping techniques for MCP servers built on `@cyanheads/mcp-ts-core` — overflow handling, payload shaping, retrieval patterns. Use when a tool's payload is too large, awkwardly shaped, or expensive to retrieve and you want a proven pattern instead of inventing one. Each technique has a self-contained reference under `references/`.
5
+ metadata:
6
+ author: cyanheads
7
+ version: "0.1"
8
+ audience: external
9
+ type: reference
10
+ ---
11
+
12
+ ## Overview
13
+
14
+ A directory of cross-cutting techniques for shaping what a handler returns and how a client retrieves it — patterns that don't belong to a single API surface. Each entry is a self-contained reference under `references/`: the problem it solves, when to reach for it (and when not to), and how to apply it with current framework primitives.
15
+
16
+ These are **patterns, not new primitives** — they compose `tool()`, discriminated-union `output`, `ctx.state`, and the existing helpers. Where a technique has (or will have) a dedicated helper, its reference says so and links the tracking issue.
17
+
18
+ ## Techniques
19
+
20
+ | Technique | Path | Use when |
21
+ |:----------|:-----|:---------|
22
+ | Outline-on-overflow | `references/outline-on-overflow.md` | A single tool call returns one **document-shaped** payload too big to inline (e.g. a ~130KB record), and you want an honest section outline + a re-call contract instead of truncating. |
23
+
24
+ ## Adding a technique
25
+
26
+ One file under `references/`, one row above. A technique earns a place here when it's a reusable response/retrieval pattern that (a) spans more than one tool or server and (b) isn't already covered by an `api-*` reference. Keep the reference concise: problem → when-to-use → how-to with current primitives → helper status. Bump `metadata.version` on any change (skill-versioning policy).
27
+
28
+ ## Related
29
+
30
+ - `design-mcp-server` — choosing the tool surface and output shapes up front.
31
+ - `add-tool` — the `tool()` builder, `format()` ⟷ `structuredContent` parity, matching response density to context budget.
32
+ - `api-canvas` — `spillover()`, the row-collection sibling of outline-on-overflow.
@@ -0,0 +1,124 @@
1
+ # Outline-on-overflow
2
+
3
+ Return a section **outline** when a single document-shaped payload is too big to inline, and let the agent re-call the same tool for only the sections it needs. The honest alternative to truncation for the *one fat document* case.
4
+
5
+ Ships as `outlineOnOverflow` + friends in `@cyanheads/mcp-ts-core/utils`.
6
+
7
+ ## The problem
8
+
9
+ Some tools fetch one large **document-shaped** record. An FDA drug label is a single ~130KB / ~32K-token payload dominated by raw HTML sections. Returning it whole burns the agent's context; truncating it either hides data or — when only `format()` is trimmed — silently desyncs `content[]` from `structuredContent`. Neither is acceptable.
10
+
11
+ This is distinct from the other two overflow shapes:
12
+
13
+ | Shape | Technique |
14
+ |:--|:--|
15
+ | Many rows (tabular) | `spillover()` → DataCanvas SQL handle (see `api-canvas`) |
16
+ | Capped list | honest truncation disclosure |
17
+ | **One large document** | **outline-on-overflow (this file)** |
18
+
19
+ ## Philosophy
20
+
21
+ **Never truncate to fit a budget.** When a payload is too big, return a complete, honest outline of what's available plus how to retrieve it — identically on `content[]` and `structuredContent`.
22
+
23
+ ## The shape — a discriminated-union `output`
24
+
25
+ The outline is the payload the agent acts on, so it lands in the **main body** (`structuredContent` + `content[]`), as a variant of the tool's own `output`. Not the enrichment block — enrichment is *additive* (`output.extend(...)` merged after `output.parse(result)`), so it can add fields to the fat document but never replace it. Not a post-hoc framework swap either — that would emit a `structuredContent` shape the advertised `outputSchema` (`tools/list`) doesn't describe. A discriminated-union variant is the only placement that replaces the payload, is advertised honestly, and gets `format()`-parity for free.
26
+
27
+ ```ts
28
+ import { tool, z } from '@cyanheads/mcp-ts-core';
29
+ import {
30
+ OUTLINE_VARIANT,
31
+ outlineOnOverflow,
32
+ selectSections,
33
+ formatOutline,
34
+ } from '@cyanheads/mcp-ts-core/utils';
35
+
36
+ const FullLabel = z.object({ /* every section field */ });
37
+
38
+ export const getLabel = tool('get_label', {
39
+ description: 'Fetch a drug label. Returns the full record, or a section outline when it overflows.',
40
+ input: z.object({
41
+ query: z.string().describe('Label query'),
42
+ sections: z
43
+ .array(z.string())
44
+ .optional()
45
+ .describe('Sections to return. Omit for the full label (or an outline if it overflows).'),
46
+ }),
47
+ output: z.discriminatedUnion('kind', [
48
+ FullLabel.extend({ kind: z.literal('full') }),
49
+ OUTLINE_VARIANT,
50
+ ]),
51
+ format: (r) => (r.kind === 'outline' ? formatOutline(r) : renderLabel(r)),
52
+ async handler(input) {
53
+ const doc = await fetchLabel(input.query); // deterministic from query
54
+ if (input.sections?.length) {
55
+ // selection path — slice to requested keys plus always-kept metadata
56
+ return { ...selectSections(doc, input.sections, { alwaysKeep: ['id', 'set_id'] }), kind: 'full' as const };
57
+ }
58
+ return outlineOnOverflow(doc, { budget: 24_000 }); // disclosure path → full | outline
59
+ },
60
+ });
61
+ ```
62
+
63
+ `format()`-parity is enforced **per branch** — the linter walks each discriminated-union arm separately, so both `full` and `outline` must render. `formatOutline` is the shipped renderer for the `outline` arm; you supply the `full` renderer. That keeps the two client surfaces in lockstep with no extra work.
64
+
65
+ ## The helper
66
+
67
+ `@cyanheads/mcp-ts-core/utils` ships the whole pattern — pure measurement + key-slicing, no DuckDB, so it runs on stdio / HTTP / Workers alike:
68
+
69
+ | Export | Purpose |
70
+ |:--|:--|
71
+ | `outlineOnOverflow(doc, options?)` | Returns `{ kind: 'full', ...doc }` under budget (or with `< 2` sections), else `{ kind: 'outline', sections, notice }`. |
72
+ | `OUTLINE_VARIANT` | The reusable `outline`-arm Zod schema for your discriminated-union `output`. |
73
+ | `selectSections(doc, want, { alwaysKeep })` | Projects the document to requested keys plus always-kept metadata. The selection-path counterpart. |
74
+ | `formatOutline(outline)` | Renders the outline to `content[]` for `format()`. |
75
+ | `DEFAULT_OUTLINE_BUDGET_BYTES` | The default budget (`24_000`) when `options.budget` is omitted. |
76
+
77
+ `outlineOnOverflow` options:
78
+
79
+ - `budget` — serialized-byte threshold (default `DEFAULT_OUTLINE_BUDGET_BYTES`). A helper argument, **not** an env var: a deploy-tunable threshold would drift a tool's output *shape* across environments.
80
+ - `extract` — custom section extractor. Default: one section per top-level key, sized by `JSON.stringify(value).length`. Override only when "section" means something other than a top-level key.
81
+ - `notice` — custom re-call notice builder. Default names the three largest sections as examples.
82
+
83
+ The flow:
84
+
85
+ 1. **Measure** the serialized payload (`JSON.stringify(doc).length`).
86
+ 2. **Under budget** → `{ kind: 'full', ...doc }`.
87
+ 3. **Over budget, ≥ 2 sections** → the outline (sections sorted largest-first). The agent re-calls with `sections: [...]`.
88
+ 4. **Over budget, < 2 sections** → `full` anyway (nothing to pick between). A single section that *alone* exceeds budget is a known limitation — sub-section outlining is out of scope.
89
+
90
+ ## Re-retrieval — why the selection call is stateless
91
+
92
+ The re-call is **self-contained**, so nothing is stored between the outline call and the selection call:
93
+
94
+ - The selection call sends the **same input** as the outline call, plus `sections: [...]`.
95
+ - The handler **re-fetches** the document — input-minus-`sections` is identical and the upstream query is deterministic, so it reproduces the exact same record — then applies `selectSections` (a pure projection: requested keys + `alwaysKeep` metadata).
96
+ - You **reconstruct rather than remember**. The agent holds the continuity (it passes `sections`); the upstream holds the document.
97
+
98
+ The only cost is the redundant fetch. For a **rate-limited or expensive upstream**, trade it for an optional cache:
99
+
100
+ ```ts
101
+ const key = `label:${input.query}`; // NOTE: excludes `sections`
102
+ let doc = await ctx.state.get<Label>(key).catch(() => null); // best-effort read
103
+ if (!doc) {
104
+ doc = await fetchLabel(input.query);
105
+ await ctx.state.set(key, doc, { ttl: 300 }).catch(() => {}); // best-effort write, 5 min
106
+ }
107
+ ```
108
+
109
+ - **Key excludes the `sections` selector** — otherwise the outline call and the selection call compute different keys and never share the doc.
110
+ - **Best-effort** — a miss or a read/write failure falls through to the stateless refetch, so correctness never depends on the cache.
111
+ - Rides `ctx.state` (the tenant-scoped KV abstraction), which is **independent of `MCP_SESSION_MODE`**. It does *not* require switching the server to stateful sessions and has no end-user-visible effect (a miss behaves exactly like the stateless path). Tenant-scoping isolates per-identity under `jwt`/`oauth`; the shared `default` tenant (stdio, HTTP + `none`) is benign because the cached value is a deterministic public-query → document map, not user state. If the upstream itself returns identity-scoped data, fold the auth principal into the key.
112
+
113
+ The framework ships no cache-key helper — the pattern above is one line and tool-specific (which fields key the doc, what TTL). **Default to stateless.** Reach for the cache only where the upstream cost is real.
114
+
115
+ ## When to use
116
+
117
+ - A single tool result is one **document-shaped** record that can exceed a context-meaningful size.
118
+ - The record has addressable parts (top-level sections) the agent can choose among.
119
+
120
+ ## When not to
121
+
122
+ - **Many rows** → `spillover()`. The document here is one row; spilling rows leaves the per-record size intact.
123
+ - **A capped list** → truncation disclosure.
124
+ - **No meaningful sub-structure** to outline → there's nothing to pick. Return it, or shrink it at the source (drop redundant fields before measuring).
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## First Session
15
15
 
16
- This project was just scaffolded with `bunx @cyanheads/mcp-ts-core init`. The framework, skills, and example definitions are in place — the domain isn't. The user's first messages will set direction; wait for them before proceeding.
16
+ This project was just scaffolded with `bunx @cyanheads/mcp-ts-core init`. You're holding a production-grade MCP framework with the hard parts already solved — error handling, telemetry, auth, transport, validation, lifecycle. What's missing is the **domain**. Your job: design the tool, resource, and service surface with the user, then implement it as small pure handlers that throw — the framework catches, classifies, and instruments the rest. Design before code; the user's first messages set direction, so wait for them before scaffolding definitions.
17
17
 
18
18
  > **Remove this section** from CLAUDE.md / AGENTS.md after completing these steps. The skills and conventions below remain — this block is one-time onboarding only.
19
19
 
@@ -138,6 +138,7 @@ import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
138
138
  const ServerConfigSchema = z.object({
139
139
  apiKey: z.string().describe('External API key'),
140
140
  maxResults: z.coerce.number().default(100),
141
+ verboseLogging: z.stringbool().default(false).describe('Enable verbose logging'),
141
142
  });
142
143
 
143
144
  let _config: z.infer<typeof ServerConfigSchema> | undefined;
@@ -145,6 +146,7 @@ export function getServerConfig() {
145
146
  _config ??= parseEnvConfig(ServerConfigSchema, {
146
147
  apiKey: 'MY_API_KEY',
147
148
  maxResults: 'MY_MAX_RESULTS',
149
+ verboseLogging: 'MY_VERBOSE_LOGGING',
148
150
  });
149
151
  return _config;
150
152
  }
@@ -152,6 +154,8 @@ export function getServerConfig() {
152
154
 
153
155
  `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.
154
156
 
157
+ For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
158
+
155
159
  ### Server instructions
156
160
 
157
161
  `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.
@@ -279,6 +283,7 @@ Available skills:
279
283
  | `git-wrapup` | Land working-tree changes as a versioned commit + annotated tag — version bump, changelog, verify, tag. Local only. |
280
284
  | `release-and-publish` | Push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
281
285
  | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
286
+ | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
282
287
  | `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI |
283
288
  | `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI |
284
289
  | `api-auth` | Auth modes, scopes, JWT/OAuth |
@@ -293,6 +298,8 @@ Available skills:
293
298
  | `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules |
294
299
  | `api-workers` | Cloudflare Workers runtime |
295
300
 
301
+ **Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase.
302
+
296
303
  When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`).
297
304
 
298
305
  ---
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## First Session
15
15
 
16
- This project was just scaffolded with `bunx @cyanheads/mcp-ts-core init`. The framework, skills, and example definitions are in place — the domain isn't. The user's first messages will set direction; wait for them before proceeding.
16
+ This project was just scaffolded with `bunx @cyanheads/mcp-ts-core init`. You're holding a production-grade MCP framework with the hard parts already solved — error handling, telemetry, auth, transport, validation, lifecycle. What's missing is the **domain**. Your job: design the tool, resource, and service surface with the user, then implement it as small pure handlers that throw — the framework catches, classifies, and instruments the rest. Design before code; the user's first messages set direction, so wait for them before scaffolding definitions.
17
17
 
18
18
  > **Remove this section** from CLAUDE.md / AGENTS.md after completing these steps. The skills and conventions below remain — this block is one-time onboarding only.
19
19
 
@@ -138,6 +138,7 @@ import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
138
138
  const ServerConfigSchema = z.object({
139
139
  apiKey: z.string().describe('External API key'),
140
140
  maxResults: z.coerce.number().default(100),
141
+ verboseLogging: z.stringbool().default(false).describe('Enable verbose logging'),
141
142
  });
142
143
 
143
144
  let _config: z.infer<typeof ServerConfigSchema> | undefined;
@@ -145,6 +146,7 @@ export function getServerConfig() {
145
146
  _config ??= parseEnvConfig(ServerConfigSchema, {
146
147
  apiKey: 'MY_API_KEY',
147
148
  maxResults: 'MY_MAX_RESULTS',
149
+ verboseLogging: 'MY_VERBOSE_LOGGING',
148
150
  });
149
151
  return _config;
150
152
  }
@@ -152,6 +154,8 @@ export function getServerConfig() {
152
154
 
153
155
  `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.
154
156
 
157
+ For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
158
+
155
159
  ### Server instructions
156
160
 
157
161
  `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.
@@ -279,6 +283,7 @@ Available skills:
279
283
  | `git-wrapup` | Land working-tree changes as a versioned commit + annotated tag — version bump, changelog, verify, tag. Local only. |
280
284
  | `release-and-publish` | Push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
281
285
  | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
286
+ | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
282
287
  | `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI |
283
288
  | `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI |
284
289
  | `api-auth` | Auth modes, scopes, JWT/OAuth |
@@ -293,6 +298,8 @@ Available skills:
293
298
  | `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules |
294
299
  | `api-workers` | Cloudflare Workers runtime |
295
300
 
301
+ **Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase.
302
+
296
303
  When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`).
297
304
 
298
305
  ---
@@ -37,9 +37,12 @@ WORKDIR /usr/src/app
37
37
  ENV NODE_ENV=production
38
38
 
39
39
  # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
40
+ ARG APP_VERSION
40
41
  LABEL org.opencontainers.image.title="{{PACKAGE_NAME}}"
41
42
  LABEL org.opencontainers.image.description=""
42
43
  LABEL org.opencontainers.image.licenses="Apache-2.0"
44
+ LABEL org.opencontainers.image.version="${APP_VERSION}"
45
+ LABEL org.opencontainers.image.source=""
43
46
 
44
47
  # Copy dependency manifests
45
48
  COPY package.json bun.lock ./