@cyanheads/mcp-ts-core 0.11.2 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +14 -10
- package/CLAUDE.md +14 -10
- package/README.md +3 -3
- package/changelog/0.11.x/0.11.3.md +49 -0
- package/changelog/0.11.x/0.11.4.md +67 -0
- package/dist/config/index.d.ts +0 -10
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +1 -25
- package/dist/config/index.js.map +1 -1
- package/dist/config/logLevelAlias.d.ts +15 -0
- package/dist/config/logLevelAlias.d.ts.map +1 -0
- package/dist/config/logLevelAlias.js +30 -0
- package/dist/config/logLevelAlias.js.map +1 -0
- package/dist/core/context.d.ts +10 -0
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +10 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/worker.js +1 -1
- package/dist/core/worker.js.map +1 -1
- package/dist/linter/rules/enrichment-rules.d.ts +3 -2
- package/dist/linter/rules/enrichment-rules.d.ts.map +1 -1
- package/dist/linter/rules/enrichment-rules.js +35 -7
- package/dist/linter/rules/enrichment-rules.js.map +1 -1
- package/dist/linter/rules/format-parity-rules.d.ts +8 -4
- package/dist/linter/rules/format-parity-rules.d.ts.map +1 -1
- package/dist/linter/rules/format-parity-rules.js +72 -20
- package/dist/linter/rules/format-parity-rules.js.map +1 -1
- package/dist/linter/rules/index.d.ts +1 -1
- package/dist/linter/rules/index.d.ts.map +1 -1
- package/dist/linter/rules/index.js +1 -1
- package/dist/linter/rules/index.js.map +1 -1
- package/dist/linter/rules/prompt-rules.d.ts.map +1 -1
- package/dist/linter/rules/prompt-rules.js +6 -3
- package/dist/linter/rules/prompt-rules.js.map +1 -1
- package/dist/linter/rules/resource-rules.d.ts.map +1 -1
- package/dist/linter/rules/resource-rules.js +11 -5
- package/dist/linter/rules/resource-rules.js.map +1 -1
- package/dist/linter/rules/schema-rules.d.ts +21 -2
- package/dist/linter/rules/schema-rules.d.ts.map +1 -1
- package/dist/linter/rules/schema-rules.js +110 -2
- package/dist/linter/rules/schema-rules.js.map +1 -1
- package/dist/linter/rules/tool-rules.d.ts.map +1 -1
- package/dist/linter/rules/tool-rules.js +11 -5
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +44 -10
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts +10 -10
- package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts.map +1 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.js +29 -24
- package/dist/services/mirror/sqlite/sqliteMirrorStore.js.map +1 -1
- package/dist/storage/core/storageValidation.d.ts +4 -4
- package/dist/storage/core/storageValidation.d.ts.map +1 -1
- package/dist/storage/core/storageValidation.js +4 -20
- package/dist/storage/core/storageValidation.js.map +1 -1
- package/dist/testing/fuzz.d.ts.map +1 -1
- package/dist/testing/fuzz.js +57 -2
- package/dist/testing/fuzz.js.map +1 -1
- package/dist/testing/index.d.ts +45 -13
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +29 -76
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/vitest.d.ts +1 -1
- package/dist/utils/formatting/diffFormatter.d.ts +5 -5
- package/dist/utils/formatting/diffFormatter.d.ts.map +1 -1
- package/dist/utils/formatting/diffFormatter.js +1 -1
- package/dist/utils/formatting/diffFormatter.js.map +1 -1
- package/dist/utils/formatting/tableFormatter.d.ts +3 -3
- package/dist/utils/formatting/tableFormatter.d.ts.map +1 -1
- package/dist/utils/formatting/tableFormatter.js +1 -1
- package/dist/utils/formatting/tableFormatter.js.map +1 -1
- package/dist/utils/formatting/treeFormatter.d.ts +3 -3
- package/dist/utils/formatting/treeFormatter.d.ts.map +1 -1
- package/dist/utils/formatting/treeFormatter.js +1 -1
- package/dist/utils/formatting/treeFormatter.js.map +1 -1
- package/dist/utils/metrics/tokenCounter.d.ts +3 -3
- package/dist/utils/metrics/tokenCounter.d.ts.map +1 -1
- package/dist/utils/metrics/tokenCounter.js.map +1 -1
- package/dist/utils/network/fetchWithTimeout.d.ts +43 -15
- package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
- package/dist/utils/network/fetchWithTimeout.js +163 -44
- package/dist/utils/network/fetchWithTimeout.js.map +1 -1
- package/dist/utils/network/httpError.d.ts.map +1 -1
- package/dist/utils/network/httpError.js +1 -2
- package/dist/utils/network/httpError.js.map +1 -1
- package/dist/utils/network/responseBody.d.ts +27 -7
- package/dist/utils/network/responseBody.d.ts.map +1 -1
- package/dist/utils/network/responseBody.js +54 -13
- package/dist/utils/network/responseBody.js.map +1 -1
- package/dist/utils/network/retry.d.ts +2 -2
- package/dist/utils/network/retry.d.ts.map +1 -1
- package/dist/utils/overflow/outlineOnOverflow.d.ts +4 -3
- package/dist/utils/overflow/outlineOnOverflow.d.ts.map +1 -1
- package/dist/utils/overflow/outlineOnOverflow.js +27 -8
- package/dist/utils/overflow/outlineOnOverflow.js.map +1 -1
- package/dist/utils/pagination/pagination.d.ts +3 -3
- package/dist/utils/pagination/pagination.d.ts.map +1 -1
- package/dist/utils/pagination/pagination.js.map +1 -1
- package/dist/utils/parsing/csvParser.d.ts +2 -2
- package/dist/utils/parsing/csvParser.d.ts.map +1 -1
- package/dist/utils/parsing/csvParser.js +1 -1
- package/dist/utils/parsing/csvParser.js.map +1 -1
- package/dist/utils/parsing/dateParser.d.ts +11 -7
- package/dist/utils/parsing/dateParser.d.ts.map +1 -1
- package/dist/utils/parsing/dateParser.js +8 -4
- package/dist/utils/parsing/dateParser.js.map +1 -1
- package/dist/utils/parsing/frontmatterParser.d.ts +5 -4
- package/dist/utils/parsing/frontmatterParser.d.ts.map +1 -1
- package/dist/utils/parsing/frontmatterParser.js +5 -4
- package/dist/utils/parsing/frontmatterParser.js.map +1 -1
- package/dist/utils/parsing/htmlExtractor.d.ts +5 -4
- package/dist/utils/parsing/htmlExtractor.d.ts.map +1 -1
- package/dist/utils/parsing/htmlExtractor.js +4 -3
- package/dist/utils/parsing/htmlExtractor.js.map +1 -1
- package/dist/utils/parsing/inputBudget.d.ts +18 -11
- package/dist/utils/parsing/inputBudget.d.ts.map +1 -1
- package/dist/utils/parsing/inputBudget.js +20 -17
- package/dist/utils/parsing/inputBudget.js.map +1 -1
- package/dist/utils/parsing/jsonParser.d.ts +5 -4
- package/dist/utils/parsing/jsonParser.d.ts.map +1 -1
- package/dist/utils/parsing/jsonParser.js +5 -4
- package/dist/utils/parsing/jsonParser.js.map +1 -1
- package/dist/utils/parsing/pdfParser.d.ts +38 -24
- package/dist/utils/parsing/pdfParser.d.ts.map +1 -1
- package/dist/utils/parsing/pdfParser.js +42 -31
- package/dist/utils/parsing/pdfParser.js.map +1 -1
- package/dist/utils/parsing/xmlParser.d.ts +3 -3
- package/dist/utils/parsing/xmlParser.d.ts.map +1 -1
- package/dist/utils/parsing/xmlParser.js +3 -6
- package/dist/utils/parsing/xmlParser.js.map +1 -1
- package/dist/utils/parsing/yamlParser.d.ts +3 -3
- package/dist/utils/parsing/yamlParser.d.ts.map +1 -1
- package/dist/utils/parsing/yamlParser.js +3 -3
- package/dist/utils/parsing/yamlParser.js.map +1 -1
- package/dist/utils/security/rateLimiter.d.ts +2 -2
- package/dist/utils/security/rateLimiter.d.ts.map +1 -1
- package/dist/utils/security/rateLimiter.js +1 -1
- package/dist/utils/security/rateLimiter.js.map +1 -1
- package/dist/utils/telemetry/trace.d.ts +3 -3
- package/dist/utils/telemetry/trace.d.ts.map +1 -1
- package/dist/utils/telemetry/trace.js.map +1 -1
- package/package.json +1 -1
- package/scripts/tree.ts +9 -3
- package/skills/add-test/SKILL.md +2 -2
- package/skills/api-auth/SKILL.md +5 -5
- package/skills/api-context/SKILL.md +14 -12
- package/skills/api-linter/SKILL.md +59 -5
- package/skills/api-testing/SKILL.md +31 -10
- package/skills/api-utils/SKILL.md +4 -2
- package/skills/api-utils/references/parsing.md +3 -2
- package/skills/setup/SKILL.md +6 -3
- package/skills/techniques/SKILL.md +1 -1
- package/skills/techniques/references/outline-on-overflow.md +3 -1
- package/templates/AGENTS.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/Dockerfile +7 -2
- package/templates/_tsconfig.build.json +5 -0
- package/templates/_tsconfig.json +4 -2
- package/templates/package.json +1 -0
- package/templates/tests/prompts/echo.prompt.test.ts +4 -3
- package/templates/tests/resources/echo.resource.test.ts +11 -3
- package/templates/tests/smoke/definitions.smoke.test.ts +8 -4
- package/templates/tests/tools/echo.tool.test.ts +23 -4
package/scripts/tree.ts
CHANGED
|
@@ -131,9 +131,15 @@ async function loadIgnoreHandler(
|
|
|
131
131
|
return ig;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
/**
|
|
135
|
+
* A directory-only pattern (`/data/`, `.claude/`) matches only when the tested
|
|
136
|
+
* path carries the trailing slash too — `ignore` has no other way to tell a
|
|
137
|
+
* directory from a file of the same name. The caller holds the `Dirent`, so the
|
|
138
|
+
* flag is free.
|
|
139
|
+
*/
|
|
140
|
+
function isIgnored(entryPath: string, root: string, ig: Ignore, isDirectory: boolean): boolean {
|
|
135
141
|
const rel = relative(root, entryPath).split(sep).join(posix.sep);
|
|
136
|
-
return ig.ignores(rel);
|
|
142
|
+
return ig.ignores(isDirectory ? `${rel}/` : rel);
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
/**
|
|
@@ -182,7 +188,7 @@ async function generateTree(
|
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
const filteredEntries = entries
|
|
185
|
-
.filter((entry) => !isIgnored(join(resolvedDir, entry.name), root, ig))
|
|
191
|
+
.filter((entry) => !isIgnored(join(resolvedDir, entry.name), root, ig, entry.isDirectory()))
|
|
186
192
|
.sort((a, b) => {
|
|
187
193
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
188
194
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
package/skills/add-test/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Scaffold a test file for an existing tool, resource, or service. Use when the user asks to add tests, improve coverage, or when a definition exists without a matching test file.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.5"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -35,7 +35,7 @@ Read the handler and identify:
|
|
|
35
35
|
| **Happy path** | Valid input → expected output. Include at least one. |
|
|
36
36
|
| **Input variations** | Optional fields omitted, defaults applied, boundary values |
|
|
37
37
|
| **Error paths** | Invalid state, missing resources, service failures → correct error thrown |
|
|
38
|
-
| **`ctx.state` usage** |
|
|
38
|
+
| **`ctx.state` usage** | Available on any mock context (tenant `'default'` unless `{ tenantId }` says otherwise). It runs the production storage path, so use storage-legal keys (`cache/v1/abc`, never `cache:v1:abc`) and assert TTL expiry with fake timers. |
|
|
39
39
|
| **`ctx.elicit`** | Mock with `vi.fn()`, also test the absent case (undefined) |
|
|
40
40
|
| **`ctx.progress`** | Use `createMockContext({ progress: true })` for task tools |
|
|
41
41
|
| **`ctx.fail` (typed contract)** | Definitions with `errors[]` need `fail` attached to the mock ctx — `createMockContext({ errors: myTool.errors })` does it for you. Assert on `data.reason` (stable per-contract entry), not just `code`. |
|
package/skills/api-auth/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Authentication, authorization, and multi-tenancy patterns for `@cyanheads/mcp-ts-core`. Use when implementing auth scopes on tools/resources, configuring auth modes (none/jwt/oauth), working with JWT/OAuth env vars, or understanding how tenantId flows through ctx.state.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.3"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -175,11 +175,11 @@ A `WARNING`-level log is emitted at startup whenever the flag is active so opera
|
|
|
175
175
|
```ts
|
|
176
176
|
handler: async (input, ctx) => {
|
|
177
177
|
// Automatically scoped to ctx.tenantId — no manual prefixing
|
|
178
|
-
await ctx.state.set('item
|
|
179
|
-
const item = await ctx.state.get<Item>('item
|
|
180
|
-
await ctx.state.delete('item
|
|
178
|
+
await ctx.state.set('item/123', { name: 'Widget', count: 42 });
|
|
179
|
+
const item = await ctx.state.get<Item>('item/123');
|
|
180
|
+
await ctx.state.delete('item/123');
|
|
181
181
|
|
|
182
|
-
const page = await ctx.state.list('item
|
|
182
|
+
const page = await ctx.state.list('item/', { cursor, limit: 20 });
|
|
183
183
|
// page: { items: Array<{ key, value }>, cursor?: string }
|
|
184
184
|
},
|
|
185
185
|
```
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
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.progress`, `ctx.enrich`, `ctx.content`), and when to use each.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.11"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -155,23 +155,23 @@ interface ContextState {
|
|
|
155
155
|
|
|
156
156
|
```ts
|
|
157
157
|
// Store — accepts any serializable value, no manual JSON.stringify needed
|
|
158
|
-
await ctx.state.set('item
|
|
159
|
-
await ctx.state.set('session
|
|
158
|
+
await ctx.state.set('item/123', { name: 'Widget', count: 42 });
|
|
159
|
+
await ctx.state.set('session/xyz', token, { ttl: 3600 }); // TTL in seconds
|
|
160
160
|
|
|
161
161
|
// Retrieve — generic type assertion or Zod-validated
|
|
162
|
-
const item = await ctx.state.get<Item>('item
|
|
163
|
-
const safe = await ctx.state.get('item
|
|
162
|
+
const item = await ctx.state.get<Item>('item/123'); // T | null (type assertion)
|
|
163
|
+
const safe = await ctx.state.get('item/123', ItemSchema); // T | null (runtime validated)
|
|
164
164
|
|
|
165
165
|
// Delete
|
|
166
|
-
await ctx.state.delete('item
|
|
166
|
+
await ctx.state.delete('item/123');
|
|
167
167
|
|
|
168
168
|
// Batch operations
|
|
169
|
-
const values = await ctx.state.getMany<Item>(['item
|
|
169
|
+
const values = await ctx.state.getMany<Item>(['item/1', 'item/2']); // Map<string, T>
|
|
170
170
|
await ctx.state.setMany(new Map([['a', 1], ['b', 2]]));
|
|
171
|
-
const deleted = await ctx.state.deleteMany(['item
|
|
171
|
+
const deleted = await ctx.state.deleteMany(['item/1', 'item/2']); // number
|
|
172
172
|
|
|
173
173
|
// List with prefix + pagination
|
|
174
|
-
const page = await ctx.state.list('item
|
|
174
|
+
const page = await ctx.state.list('item/', { cursor, limit: 20 });
|
|
175
175
|
for (const { key, value } of page.items) { /* ... */ }
|
|
176
176
|
if (page.cursor) { /* more pages available */ }
|
|
177
177
|
```
|
|
@@ -180,6 +180,7 @@ if (page.cursor) { /* more pages available */ }
|
|
|
180
180
|
|
|
181
181
|
- Throws `McpError(InvalidRequest)` if `tenantId` is missing. Won't happen in stdio (any auth mode) or HTTP+`MCP_AUTH_MODE=none` — both default to `'default'`. Can happen in HTTP+`MCP_AUTH_MODE=jwt`/`oauth` when the token lacks a `tid` claim (intentional fail-closed: distinct authenticated callers must not silently share state).
|
|
182
182
|
- Keys are tenant-prefixed internally; handlers never need to namespace manually.
|
|
183
|
+
- **Key charset:** `^[a-zA-Z0-9_.\-/]+$`, 1024 chars max, no `..`. Slashes are the namespace separator — a colon (`item:123`) throws `McpError(ValidationError)` on every call. The rule covers `list` prefixes and every key in a batch operation. `createMockContext().state` enforces it identically, so an illegal key fails in the test rather than in a deployment.
|
|
183
184
|
- **Workers persistence:** The `in-memory` provider loses data on cold starts. Use `cloudflare-kv`, `cloudflare-r2`, or `cloudflare-d1` for durable storage in Workers.
|
|
184
185
|
|
|
185
186
|
---
|
|
@@ -237,14 +238,14 @@ import { invalidRequest } from '@cyanheads/mcp-ts-core/errors';
|
|
|
237
238
|
if (!ctx.sessionId) {
|
|
238
239
|
throw invalidRequest('Session required for this operation.');
|
|
239
240
|
}
|
|
240
|
-
await ctx.state.set(`session
|
|
241
|
+
await ctx.state.set(`session/${ctx.sessionId}/${baseKey}`, value);
|
|
241
242
|
```
|
|
242
243
|
|
|
243
244
|
**Lax — fall back to tenant-shared key:**
|
|
244
245
|
|
|
245
246
|
```ts
|
|
246
247
|
const sessionKey = ctx.sessionId
|
|
247
|
-
? `session
|
|
248
|
+
? `session/${ctx.sessionId}/${baseKey}`
|
|
248
249
|
: baseKey;
|
|
249
250
|
await ctx.state.set(sessionKey, value);
|
|
250
251
|
```
|
|
@@ -253,7 +254,7 @@ await ctx.state.set(sessionKey, value);
|
|
|
253
254
|
|
|
254
255
|
### Behavior notes
|
|
255
256
|
|
|
256
|
-
- **Not a tenant boundary.** `ctx.state` is still tenant-scoped. Building session-scoped state is the consumer's responsibility — prefix with `session
|
|
257
|
+
- **Not a tenant boundary.** `ctx.state` is still tenant-scoped. Building session-scoped state is the consumer's responsibility — prefix with `session/${ctx.sessionId}/` as shown above.
|
|
257
258
|
- **Auto-task tools.** `task: true` handlers run in a detached background context with no session attachment — `ctx.sessionId` is always `undefined` regardless of mode.
|
|
258
259
|
- **Worker bundle.** Workers use the same HTTP transport plumbing; session behavior matches Node HTTP.
|
|
259
260
|
|
|
@@ -603,6 +604,7 @@ ctx.enrich.truncated({ shown, cap, ceiling?, guidance? }): void
|
|
|
603
604
|
| Service usage | Services accepting `ctx: Context` can call `ctx.enrich(...)`; the value reaches `structuredContent` exactly as if the handler had. |
|
|
604
605
|
| `format-parity` | Enrichment lives outside `output`, so the `format-parity` lint never requires it in `format()`. |
|
|
605
606
|
| Trailer rendering | Per field: kind-tag if set (notice/total/echo/delta), else the definition's `enrichmentTrailer.render`/`label`, else `**key:** value` (objects/arrays `JSON.stringify`'d). A structured field with no `render` errors under `enrichment-trailer-render` — supply one so it renders as markdown; `structuredContent` keeps the full value regardless. |
|
|
607
|
+
| Trailer layout | One field per line. A field whose last line opens a block quote or a list item (`notice`, or a `render` ending in `>`, `-`, `*`, `1.`) gets a blank line after it, so the next field renders as its own block instead of being folded into that container by CommonMark lazy continuation. |
|
|
606
608
|
|
|
607
609
|
### `ctx.enrich.truncated()` — capped-list disclosure
|
|
608
610
|
|
|
@@ -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.
|
|
7
|
+
version: "1.9"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -43,8 +43,8 @@ Grouped by family. Jump to any rule ID via its anchor.
|
|
|
43
43
|
| Family | Rules | Section |
|
|
44
44
|
|:-------|:------|:--------|
|
|
45
45
|
| Definition | `definition-invalid` | [Definition rules](#definition-rules) |
|
|
46
|
-
| Format parity | `format-parity`, `format-parity-threw`, `format-parity-walk-failed` | [Format parity](#format-parity) |
|
|
47
|
-
| Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable` | [Schema rules](#schema-rules) |
|
|
46
|
+
| Format parity | `format-parity`, `format-parity-threw`, `format-parity-walk-failed`, `format-parity-depth-limit` | [Format parity](#format-parity) |
|
|
47
|
+
| Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable`, `schema-unsatisfiable` | [Schema rules](#schema-rules) |
|
|
48
48
|
| Portability | `schema-format-portability`, `schema-anyof-needs-type`, `schema-no-discriminator-keyword`, `schema-no-defs`, `schema-dialect-tag` | [Portability rules](#portability-rules) |
|
|
49
49
|
| Names | `name-required`, `name-format`, `name-unique` | [Name rules](#name-rules) |
|
|
50
50
|
| Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing`, `canvas-consumer-missing` | [Tool rules](#tool-rules) |
|
|
@@ -75,6 +75,19 @@ Fires when a `tools`, `resources`, or `prompts` array passed to `validateDefinit
|
|
|
75
75
|
|
|
76
76
|
Why this family exists: different MCP clients forward different surfaces of a tool response to the model. Claude Code reads `structuredContent` (from your handler's return value, typed by `output`). Claude Desktop reads `content[]` (from your `format()` function). Every field must be visible on both surfaces or one class of client sees less than another. The linter enforces this by synthesizing a sample value where every leaf is a uniquely identifiable sentinel, calling `format()` once, then verifying each sentinel (or its key name, for permissive types like booleans) appears in the rendered text.
|
|
77
77
|
|
|
78
|
+
**How leaves are matched.** Two strategies, picked by leaf type:
|
|
79
|
+
|
|
80
|
+
| Leaf type | Sentinel | Match |
|
|
81
|
+
|:--|:--|:--|
|
|
82
|
+
| string | `MCPPARITY<path>` — alphanumeric only | substring, anywhere in the rendered text |
|
|
83
|
+
| number / int / bigint | a large distinctive integer | substring, retried against locale digit grouping (`900,000,001` → `900000001`) |
|
|
84
|
+
| boolean, enum member, literal, unrecognized type | the value the schema dictates (`true`, the first enum member, the literal) | **delimited token** — must not be flanked by another alphanumeric or `_`; falls back to the field's key name as a whole word or camelCase segment |
|
|
85
|
+
|
|
86
|
+
Two consequences worth knowing when writing a `format()`:
|
|
87
|
+
|
|
88
|
+
- **The string sentinel is alphanumeric so escaping does not break it.** `content[]` is markdown carrying upstream text you do not control, so escaping `_`, `*`, `` ` ``, `[`, `<` at the render boundary is correct — and it leaves an alphanumeric probe byte-identical. Markdown escaping, HTML escaping, and URL encoding all pass. You never need to carve an exception into your escape set to keep `lint:mcp` green.
|
|
89
|
+
- **Schema-dictated values must render as their own token.** A required `kind: z.enum(['full', 'outline'])` that `format()` never renders is not satisfied by the letters `full` appearing inside a longer word elsewhere in the output — `case_name_full`, `inactive`, `listing`. Render the field, or render its key name as a label.
|
|
90
|
+
|
|
78
91
|
### format-parity
|
|
79
92
|
|
|
80
93
|
**Severity:** error
|
|
@@ -125,7 +138,17 @@ Add narrow guards. The linter feeds a synthetic but schema-valid value; if your
|
|
|
125
138
|
|
|
126
139
|
Fires when the linter cannot walk the output schema to build a synthetic sample (usually because the schema uses an unusual composition the walker doesn't recognize). Parity is not verified for that tool — nothing is broken at runtime, but the check is silently disabled.
|
|
127
140
|
|
|
128
|
-
**Fix:** inspect the walker error message in the diagnostic. Usually caused by
|
|
141
|
+
**Fix:** inspect the walker error message in the diagnostic. Usually caused by custom Zod extensions or mixing Zod 3 and 4 schema internals. File an issue against `@cyanheads/mcp-ts-core` with the schema shape — this is a linter gap, not user error.
|
|
142
|
+
|
|
143
|
+
### format-parity-depth-limit
|
|
144
|
+
|
|
145
|
+
**Severity:** warning
|
|
146
|
+
|
|
147
|
+
Fires when an output field is nested deeper than the sentinel walker's depth limit (8). Everything at and below that path was **not evaluated** — parity for the subtree is unknown, not verified. Four array hops from the output root is enough to reach the limit, so it turns up on ordinary shapes, not just pathological ones.
|
|
148
|
+
|
|
149
|
+
The bound exists because every array / union / record hop multiplies the variant set, and a self-referential schema would otherwise recurse forever. What changed is the reporting: an unevaluated subtree used to be indistinguishable from a field that resolved to nothing, so it read as a pass.
|
|
150
|
+
|
|
151
|
+
**Fix:** flatten the output shape so the field sits within the limit, or verify by hand that `format()` renders it (and treat the warning as the standing reminder that the linter is not covering it).
|
|
129
152
|
|
|
130
153
|
---
|
|
131
154
|
|
|
@@ -200,6 +223,25 @@ z.string().describe('ISO 8601 timestamp, e.g., 2026-04-20T12:00:00Z')
|
|
|
200
223
|
|
|
201
224
|
Parse the string to a `Date` inside the handler if you need one.
|
|
202
225
|
|
|
226
|
+
### schema-unsatisfiable
|
|
227
|
+
|
|
228
|
+
**Severity:** error
|
|
229
|
+
|
|
230
|
+
Fires when a node in the emitted JSON Schema describes an **empty value set** — a field no value can ever satisfy. Nothing downstream reports this: the tool registers, the schema is forwarded to the model, and the argument simply can never be populated.
|
|
231
|
+
|
|
232
|
+
Evaluated on the emitted schema rather than on the Zod schema, because the two disagree in exactly the case that matters most.
|
|
233
|
+
|
|
234
|
+
| What you wrote | What is emitted |
|
|
235
|
+
|:--|:--|
|
|
236
|
+
| `z.enum([1, 2, 3, 4, 5])` — a numeric array handed to a string-only constructor | `{"type": "string", "enum": []}` |
|
|
237
|
+
| `z.enum([])` | `{"type": "string", "enum": []}` |
|
|
238
|
+
| `z.union([])` | `{"anyOf": []}` |
|
|
239
|
+
| `z.never()` | `{"not": {}}` |
|
|
240
|
+
|
|
241
|
+
**Fix:** for a closed set of non-string values, use a multi-value literal — `z.literal([1, 2, 3, 4, 5])` emits `{"type": "number", "enum": [1, 2, 3, 4, 5]}`. For an empty enum or union, the field has no legal values at all; drop it or give it real members.
|
|
242
|
+
|
|
243
|
+
Not flagged, deliberately: `allOf: []` is vacuously true (matches everything), and empty `required` / `properties` / `prefixItems` are absent constraints rather than impossible ones.
|
|
244
|
+
|
|
203
245
|
---
|
|
204
246
|
|
|
205
247
|
## Portability rules
|
|
@@ -818,10 +860,22 @@ Fires when an `enrichmentTrailer` key doesn't match any declared `enrichment` fi
|
|
|
818
860
|
**Severity:** warning
|
|
819
861
|
|
|
820
862
|
Fires when a tool:
|
|
821
|
-
1. has a depth-0 input field
|
|
863
|
+
1. has a depth-0 input field whose name is cap-*shaped*, AND
|
|
822
864
|
2. has at least one depth-0 array-typed `output` field, AND
|
|
823
865
|
3. declares no truncation disclosure.
|
|
824
866
|
|
|
867
|
+
Cap-shaped means, after normalizing camelCase to snake_case (so `maxRecords` and `max_records` are one case):
|
|
868
|
+
|
|
869
|
+
| Shape | Examples |
|
|
870
|
+
|:--|:--|
|
|
871
|
+
| `limit`, `<noun>_limit` / `<noun>Limit` | `limit`, `result_limit`, `resultLimit` |
|
|
872
|
+
| `max_<noun>` / `max<Noun>` | `max_results`, `maxResults`, `max_items`, `maxRecords`, `maxRows` |
|
|
873
|
+
| page-size idioms | `per_page`, `perPage`, `page_size`, `pageSize` |
|
|
874
|
+
|
|
875
|
+
Matched by shape rather than an enumerated list, so a new cap noun is covered on arrival instead of silently disabling the rule for that tool. Deliberately not matched: bare `count`, `size`, `n`, `rows`, `records`, and words that merely begin with the letters (`maximum`).
|
|
876
|
+
|
|
877
|
+
The shape does not distinguish a cap on *how many* from an upper bound on a *value*, so a range filter written as `max_<noun>` — `max_magnitude`, `max_depth_km`, `maxLat`, `max_date` — matches too. On a tool that also returns an array and discloses nothing, that reads as a warning about truncation the tool does not perform. Disclose `totalCount` if the array is paged at all (which silences it honestly), or exempt the tool via `truncationAllowlist` / `MCP_LINT_TRUNCATION_ALLOWLIST`.
|
|
878
|
+
|
|
825
879
|
**Disclosure-present (rule silent) when** any of the following is true:
|
|
826
880
|
- The declared `enrichment` shape has a `truncated` or `totalCount` key (`ctx.enrich.truncated()` and `ctx.enrich.total()` satisfy this).
|
|
827
881
|
- The `output` schema has a depth-0 `truncated` or `totalCount` field.
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.8"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -51,7 +51,7 @@ mcpTest('stubs an upstream HTTP boundary', async ({ fetchMock }) => {
|
|
|
51
51
|
| Fixture | Type | Per-test? | Notes |
|
|
52
52
|
|:--------|:-----|:----------|:------|
|
|
53
53
|
| `ctx` | `Context` | Yes | Fresh `createMockContext()` each test |
|
|
54
|
-
| `session` | `MockSession` | Yes | Fresh `{ sessionId, tenantId
|
|
54
|
+
| `session` | `MockSession` | Yes | Fresh `{ sessionId, tenantId, ctx }` from `createMockSession()` |
|
|
55
55
|
| `fetchMock` | `FetchMockHarness` | Yes | Strict fetch fake installed/restored around the requesting test |
|
|
56
56
|
| `storage` | `StorageService` | Yes | Fresh `createInMemoryStorage()` each test |
|
|
57
57
|
|
|
@@ -131,8 +131,8 @@ Use `runToolContract(definition, input, { context })` from `/testing` when a cus
|
|
|
131
131
|
```ts
|
|
132
132
|
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
|
|
133
133
|
|
|
134
|
-
createMockContext() //
|
|
135
|
-
createMockContext({ tenantId: 'test-tenant' }) //
|
|
134
|
+
createMockContext() // working ctx.state on tenant 'default'
|
|
135
|
+
createMockContext({ tenantId: 'test-tenant' }) // explicit tenant scope for ctx.state
|
|
136
136
|
createMockContext({ errors: myTool.errors }) // attaches typed ctx.fail keyed by the contract reasons
|
|
137
137
|
createMockContext({ elicit: vi.fn().mockResolvedValue(...) }) // with elicitation
|
|
138
138
|
createMockContext({ progress: true }) // with task progress (ctx.progress populated)
|
|
@@ -147,10 +147,10 @@ createMockContext({ uri: new URL('myscheme://item/123') }) // for resource han
|
|
|
147
147
|
`MockContextOptions` interface:
|
|
148
148
|
|
|
149
149
|
```ts
|
|
150
|
-
interface MockContextOptions {
|
|
150
|
+
interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefined> {
|
|
151
151
|
auth?: AuthContext;
|
|
152
152
|
elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
|
|
153
|
-
errors?:
|
|
153
|
+
errors?: TErrors | undefined;
|
|
154
154
|
notifyPromptListChanged?: () => void;
|
|
155
155
|
notifyResourceListChanged?: () => void;
|
|
156
156
|
notifyResourceUpdated?: (uri: string) => void;
|
|
@@ -166,10 +166,10 @@ interface MockContextOptions {
|
|
|
166
166
|
|
|
167
167
|
| Option | Effect |
|
|
168
168
|
|:-------|:-------|
|
|
169
|
-
| _(none)_ |
|
|
169
|
+
| _(none)_ | Working `ctx.state` on tenant `'default'`; `ctx.elicit`/`ctx.progress` are `undefined` |
|
|
170
170
|
| `auth` | Sets `ctx.auth` for scope-checking tests |
|
|
171
171
|
| `elicit` | Assigns a function to `ctx.elicit` for testing elicitation calls |
|
|
172
|
-
| `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly. |
|
|
172
|
+
| `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly; the return type narrows to `HandlerContext<ReasonOf<…>>`, so the context is assignable to that definition's handler parameter. |
|
|
173
173
|
| `notifyPromptListChanged` | Assigns `ctx.notifyPromptListChanged` for prompt-list change notification tests |
|
|
174
174
|
| `notifyResourceListChanged` | Assigns `ctx.notifyResourceListChanged` for resource notification tests |
|
|
175
175
|
| `notifyResourceUpdated` | Assigns `ctx.notifyResourceUpdated` for resource update notification tests |
|
|
@@ -178,9 +178,28 @@ interface MockContextOptions {
|
|
|
178
178
|
| `progress` | Populates `ctx.progress` with real state-tracking implementation (see below) |
|
|
179
179
|
| `requestId` | Overrides `ctx.requestId` (default: `'test-request-id'`) |
|
|
180
180
|
| `signal` | Overrides `ctx.signal` — useful for cancellation testing |
|
|
181
|
-
| `tenantId` |
|
|
181
|
+
| `tenantId` | Scopes `ctx.state` to a specific tenant. Defaults to `'default'` — the value stdio (and HTTP with `MCP_AUTH_MODE=none`) resolves |
|
|
182
182
|
| `uri` | Sets `ctx.uri` for resource handler testing |
|
|
183
183
|
|
|
184
|
+
### Mock state
|
|
185
|
+
|
|
186
|
+
`ctx.state` is a real `StorageService` over an `InMemoryProvider` — the production storage path, not a `Map`. A test therefore sees the same rules a deployed server enforces:
|
|
187
|
+
|
|
188
|
+
- **Keys** match `^[a-zA-Z0-9_.\-/]+$` and may not contain `..`. Colons are rejected, so `cache:v1:abc` throws `McpError(ValidationError)` in the test exactly as it would in a deployment; use `cache/v1/abc`.
|
|
189
|
+
- **TTL** is honored. An entry written with `{ ttl: 30 }` reads back as `null` once 30 seconds elapse — drive the clock with `vi.useFakeTimers()` to assert expiry.
|
|
190
|
+
- **`getMany` / `setMany` / `deleteMany` / `list`** validate every key and prefix, and `list` paginates with the same opaque cursors.
|
|
191
|
+
- **Cancellation** applies: once `ctx.signal` aborts, state operations reject.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
const ctx = createMockContext();
|
|
195
|
+
|
|
196
|
+
await ctx.state.set('cache/v1/abc', { hits: 1 }, { ttl: 30 });
|
|
197
|
+
await expect(ctx.state.get('cache/v1/abc')).resolves.toEqual({ hits: 1 });
|
|
198
|
+
await expect(ctx.state.set('cache:v1:abc', {})).rejects.toThrow(McpError);
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Reach for `createInMemoryStorage()` when a service takes a `StorageService` directly — it builds the same pair.
|
|
202
|
+
|
|
184
203
|
### Mock progress
|
|
185
204
|
|
|
186
205
|
When `progress: true`, `ctx.progress` is a real state-tracking object — not `vi.fn()` spies. It maintains internal state accessible via inspection properties:
|
|
@@ -396,7 +415,7 @@ describe('myTool with service', () => {
|
|
|
396
415
|
|
|
397
416
|
- Re-init services with `initMyService()` (or equivalent) in `beforeEach` when tests share a module-level singleton.
|
|
398
417
|
- Vitest runs test files in separate workers — parallel file execution is safe by default.
|
|
399
|
-
-
|
|
418
|
+
- Pass `createMockContext({ tenantId })` when a test needs a specific tenant; omitting it scopes state to `'default'`, not to a broken state surface.
|
|
400
419
|
|
|
401
420
|
---
|
|
402
421
|
|
|
@@ -503,3 +522,5 @@ it('survives fuzz testing', async () => {
|
|
|
503
522
|
| `adversarialArbitrary()` / `ADVERSARIAL_STRINGS` | Targeted injection sets (prototype pollution probes, control characters, oversized payloads). |
|
|
504
523
|
|
|
505
524
|
`FuzzOptions`: `numRuns` (default 50), `numAdversarial` (default 30), `seed` (reproducibility), `timeout` (per-call ms, default 5000), `ctx` (`MockContextOptions` for stateful handlers).
|
|
525
|
+
|
|
526
|
+
`report.leaks` looks for a stack frame or a server-side path in what a client can observe — the `code`, `message`, and `data` of the thrown `McpError`. Strings the input itself supplied are removed before that check, so naming the offending value in error data (`throw validationError(msg, { key })`) never registers as a leak: the client sent those bytes and learns nothing from seeing them again.
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
API reference for all utilities exported from `@cyanheads/mcp-ts-core/utils`. Use when looking up utility method signatures, options, peer dependencies, or usage patterns.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.6"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -15,6 +15,8 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
|
|
|
15
15
|
|
|
16
16
|
**Tier 3** = optional peer dependency. Install as needed (e.g., `bun add js-yaml`). All Tier 3 methods are **async** (lazy-load deps on first call).
|
|
17
17
|
|
|
18
|
+
**Context parameters.** Every helper below that takes a `context` accepts the handler `Context` as well as a `RequestContext` bag — pass `ctx` straight through, no slicing.
|
|
19
|
+
|
|
18
20
|
## References
|
|
19
21
|
|
|
20
22
|
| Reference | Path | Covers |
|
|
@@ -29,7 +31,7 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
|
|
|
29
31
|
|
|
30
32
|
| Export | API | Notes |
|
|
31
33
|
|:-------|:----|:------|
|
|
32
|
-
| `fetchWithTimeout` | `(url, timeoutMs, context
|
|
34
|
+
| `fetchWithTimeout` | `(url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `timeoutMs` bounds the **whole exchange**: on a 2xx carrying a body the returned `Response` is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's `.text()`/`.json()` with the same `Timeout` error the header phase raises. `status`, `statusText`, `headers`, `url`, `redirected`, and `type` carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), `errorBodyLimit?: number` (bytes of a non-2xx body kept, default `500`), and `signal?: AbortSignal` (external cancellation). On a non-2xx, `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values; consolidating in a future major); a body over `errorBodyLimit` is captured from both ends — 40% head, 60% tail, joined by `…[N bytes elided]…` — so a diagnostic behind a boilerplate preamble survives the cap, while a body still streaming at the 16 KiB scan ceiling stays head-only with a trailing `…`. SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
|
|
33
35
|
| `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. Honors an upstream `Retry-After` on `data.retryAfter` (delta-seconds or HTTP-date) over exponential backoff, capped at `maxDelayMs`; a requested wait beyond the cap fails fast rather than sleeping. 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. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate). |
|
|
34
36
|
| `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` — full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values), so a consumer can classify either helper's error without knowing which raised it. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `data?` (extra fields merged into `error.data`), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` — both classify codes the same way. |
|
|
35
37
|
| `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx/3xx. Use when you need just the code without a `Response` object handy. |
|
|
@@ -10,8 +10,9 @@ All parsers are **Tier 3** — lazy-load their peer dependency on first call. Al
|
|
|
10
10
|
|
|
11
11
|
- Singleton instances exported alongside classes
|
|
12
12
|
- `<think>...</think>` blocks at the start of input are automatically stripped and logged at `debug` level (except `dateParser` and `pdfParser`)
|
|
13
|
-
-
|
|
14
|
-
-
|
|
13
|
+
- Every `context?` parameter is optional (synthetic context created if omitted) and accepts the handler `Context` as well as a `RequestContext` bag
|
|
14
|
+
- Input budgets are opt-in: a parser is unbounded unless the caller passes `maxBytes`, which then rejects an over-budget input with `ValidationError` (`reason: 'parser_input_too_large'`). `DEFAULT_TEXT_PARSER_MAX_BYTES` (1 MiB) and `DEFAULT_BINARY_PARSER_MAX_BYTES` (25 MiB) are exported as starting points, not applied defaults
|
|
15
|
+
- Errors throw `McpError` — never return error values. The message is `<summary>: <library message>`, so it carries the underlying parser's diagnostic; `data` carries only `{ reason }`, and the input sample and stack stay on `cause`
|
|
15
16
|
|
|
16
17
|
---
|
|
17
18
|
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Post-init orientation for an MCP server built on @cyanheads/mcp-ts-core. Use after running `@cyanheads/mcp-ts-core init` to understand the project structure, conventions, and skill sync model. Also use when onboarding to an existing project for the first time.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.9"
|
|
8
8
|
audience: external
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -27,8 +27,8 @@ What `init` actually creates:
|
|
|
27
27
|
CLAUDE.md # Agent protocol — Claude Code
|
|
28
28
|
AGENTS.md # Agent protocol — other agents (Codex, Cursor, etc.)
|
|
29
29
|
package.json # Starter deps + scripts (placeholders substituted on init)
|
|
30
|
-
tsconfig.json #
|
|
31
|
-
tsconfig.build.json # Build
|
|
30
|
+
tsconfig.json # Typecheck config — covers src/ and tests/, emits nothing
|
|
31
|
+
tsconfig.build.json # Build config — emits src/ to dist/, tests excluded
|
|
32
32
|
vitest.config.ts # Test runner config
|
|
33
33
|
biome.json # Lint + format config
|
|
34
34
|
devcheck.config.json # Which devcheck steps to run
|
|
@@ -57,6 +57,9 @@ tests/
|
|
|
57
57
|
tools/echo.tool.test.ts # Starter tests (one per echo definition)
|
|
58
58
|
resources/echo.resource.test.ts
|
|
59
59
|
prompts/echo.prompt.test.ts
|
|
60
|
+
smoke/definitions.smoke.test.ts # Every shipped definition executed once
|
|
61
|
+
integration/echo-contract.int.test.ts # The echo tool driven through the production surfaces
|
|
62
|
+
fuzz/echo-tool.fuzz.test.ts # Property-based coverage, via the fast-check dev dep
|
|
60
63
|
```
|
|
61
64
|
|
|
62
65
|
Add these as needed:
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
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
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "0.
|
|
7
|
+
version: "0.3"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -84,7 +84,7 @@ export const getLabel = tool('get_label', {
|
|
|
84
84
|
|
|
85
85
|
- `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.
|
|
86
86
|
- `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.
|
|
87
|
-
- `notice` — custom re-call notice builder
|
|
87
|
+
- `notice` — custom re-call notice builder, called as `(sections, budget)`. The default's worked example names the **largest section that fits the budget**, with its byte size inline; when no single section fits it says so and points at narrowing the request instead of naming a section. Naming the largest sections would hand the agent the most expensive retrieval available — the one most likely to blow the same budget the outline exists to enforce.
|
|
88
88
|
|
|
89
89
|
The flow:
|
|
90
90
|
|
|
@@ -93,6 +93,8 @@ The flow:
|
|
|
93
93
|
3. **Over budget, ≥ 2 sections** → the outline (sections sorted largest-first). The agent re-calls with `sections: [...]`.
|
|
94
94
|
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.
|
|
95
95
|
|
|
96
|
+
The budget bounds the **disclosure**, not the selection. `selectSections` returns whatever the agent named, so a selection over several sections — or one section larger than the budget — comes back whole. That is deliberate: the agent asked for those sections by name, and truncating the answer is the thing this technique exists to avoid. The default notice reports each example's size so the selection can be sized before it is made.
|
|
97
|
+
|
|
96
98
|
## Re-retrieval — why the selection call is stateless
|
|
97
99
|
|
|
98
100
|
The re-call is **self-contained**, so nothing is stored between the outline call and the selection call:
|
package/templates/AGENTS.md
CHANGED
|
@@ -104,7 +104,7 @@ export const itemData = resource('inventory://{itemId}', {
|
|
|
104
104
|
params: z.object({ itemId: z.string().describe('Item identifier') }),
|
|
105
105
|
auth: ['inventory:read'],
|
|
106
106
|
async handler(params, ctx) {
|
|
107
|
-
const item = await ctx.state.get(`item
|
|
107
|
+
const item = await ctx.state.get(`item/${params.itemId}`);
|
|
108
108
|
if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
|
|
109
109
|
return item;
|
|
110
110
|
},
|
package/templates/CLAUDE.md
CHANGED
|
@@ -104,7 +104,7 @@ export const itemData = resource('inventory://{itemId}', {
|
|
|
104
104
|
params: z.object({ itemId: z.string().describe('Item identifier') }),
|
|
105
105
|
auth: ['inventory:read'],
|
|
106
106
|
async handler(params, ctx) {
|
|
107
|
-
const item = await ctx.state.get(`item
|
|
107
|
+
const item = await ctx.state.get(`item/${params.itemId}`);
|
|
108
108
|
if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
|
|
109
109
|
return item;
|
|
110
110
|
},
|
package/templates/Dockerfile
CHANGED
|
@@ -51,8 +51,13 @@ COPY package.json bun.lock ./
|
|
|
51
51
|
|
|
52
52
|
# Install only production dependencies, ignoring any lifecycle scripts (like 'prepare')
|
|
53
53
|
# that are not needed in the final production image.
|
|
54
|
+
# `--omit=peer` drops the framework's optional peer tiers (test runner, service
|
|
55
|
+
# SDKs, parsers) that Bun would otherwise auto-install. Anything this server
|
|
56
|
+
# actually imports belongs in its own `dependencies`, so nothing needed at
|
|
57
|
+
# runtime is lost. The OTEL step below carries the same flag — without it, that
|
|
58
|
+
# install re-resolves the graph and pulls every optional peer back in.
|
|
54
59
|
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
|
55
|
-
bun install --production --frozen-lockfile --ignore-scripts
|
|
60
|
+
bun install --production --omit=peer --frozen-lockfile --ignore-scripts
|
|
56
61
|
|
|
57
62
|
# Conditionally install OpenTelemetry optional peer dependencies (Tier 3).
|
|
58
63
|
# These are not bundled by default to keep the base image lean. Enable at build time
|
|
@@ -60,7 +65,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
|
|
|
60
65
|
ARG OTEL_ENABLED=true
|
|
61
66
|
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
|
62
67
|
if [ "$OTEL_ENABLED" = "true" ]; then \
|
|
63
|
-
bun add --omit=dev --ignore-scripts @hono/otel \
|
|
68
|
+
bun add --omit=dev --omit=peer --ignore-scripts @hono/otel \
|
|
64
69
|
@opentelemetry/instrumentation-http \
|
|
65
70
|
@opentelemetry/exporter-metrics-otlp-http \
|
|
66
71
|
@opentelemetry/exporter-trace-otlp-http \
|
package/templates/_tsconfig.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"extends": "@cyanheads/mcp-ts-core/tsconfig.base.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
|
-
"rootDir": "
|
|
4
|
+
"rootDir": ".",
|
|
5
5
|
"outDir": "dist",
|
|
6
|
+
"noEmit": true,
|
|
7
|
+
"tsBuildInfoFile": ".tsbuildinfo",
|
|
6
8
|
"paths": {
|
|
7
9
|
"@/*": ["./src/*"]
|
|
8
10
|
}
|
|
9
11
|
},
|
|
10
|
-
"include": ["src/**/*"],
|
|
12
|
+
"include": ["src/**/*", "tests/**/*"],
|
|
11
13
|
"exclude": ["node_modules", "dist"]
|
|
12
14
|
}
|
package/templates/package.json
CHANGED
|
@@ -7,9 +7,10 @@ import { describe, expect, it } from 'vitest';
|
|
|
7
7
|
import { echoPrompt } from '@/mcp-server/prompts/definitions/echo.prompt.js';
|
|
8
8
|
|
|
9
9
|
describe('echoPrompt', () => {
|
|
10
|
-
it('generates a user message with the echoed text', () => {
|
|
11
|
-
const args = echoPrompt.args
|
|
12
|
-
|
|
10
|
+
it('generates a user message with the echoed text', async () => {
|
|
11
|
+
const args = echoPrompt.args!.parse({ message: 'hello world' });
|
|
12
|
+
// `generate` may be async — await covers both shapes.
|
|
13
|
+
const messages = await echoPrompt.generate(args);
|
|
13
14
|
expect(messages).toHaveLength(1);
|
|
14
15
|
expect(messages[0]).toMatchObject({
|
|
15
16
|
role: 'user',
|
|
@@ -10,13 +10,21 @@ import { echoResource } from '@/mcp-server/resources/definitions/echo.resource.j
|
|
|
10
10
|
describe('echoResource', () => {
|
|
11
11
|
it('echoes the message from params', async () => {
|
|
12
12
|
const ctx = createMockContext();
|
|
13
|
-
const params = echoResource.params
|
|
13
|
+
const params = echoResource.params!.parse({ message: 'hello world' });
|
|
14
14
|
const result = await echoResource.handler(params, ctx);
|
|
15
15
|
expect(result).toEqual({ message: 'hello world' });
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
it('lists available resources', () => {
|
|
19
|
-
|
|
18
|
+
it('lists available resources', async () => {
|
|
19
|
+
// `list` receives the SDK's request-handler extra, not a Context, and may be
|
|
20
|
+
// async — a minimal literal is enough for a listing that ignores it.
|
|
21
|
+
const extra = {
|
|
22
|
+
signal: new AbortController().signal,
|
|
23
|
+
requestId: 'test',
|
|
24
|
+
sendNotification: () => Promise.resolve(),
|
|
25
|
+
sendRequest: () => Promise.resolve({} as never),
|
|
26
|
+
};
|
|
27
|
+
const listing = await echoResource.list!(extra);
|
|
20
28
|
expect(listing.resources).toHaveLength(1);
|
|
21
29
|
expect(listing.resources[0]).toMatchObject({
|
|
22
30
|
uri: 'echo://hello',
|