@cyanheads/mcp-ts-core 0.9.6 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -3
- package/README.md +12 -8
- package/biome.json +1 -1
- package/changelog/0.9.x/0.9.7.md +18 -0
- package/changelog/0.9.x/0.9.8.md +24 -0
- package/changelog/template.md +26 -0
- package/package.json +3 -4
- package/skills/add-app-tool/SKILL.md +6 -4
- package/skills/add-export/SKILL.md +10 -8
- package/skills/add-prompt/SKILL.md +15 -8
- package/skills/add-provider/SKILL.md +29 -12
- package/skills/add-resource/SKILL.md +20 -11
- package/skills/add-service/SKILL.md +15 -17
- package/skills/add-test/SKILL.md +50 -9
- package/skills/add-tool/SKILL.md +13 -6
- package/skills/api-auth/SKILL.md +3 -2
- package/skills/api-canvas/SKILL.md +43 -6
- package/skills/api-config/SKILL.md +6 -0
- package/skills/api-context/SKILL.md +9 -3
- package/skills/api-errors/SKILL.md +5 -5
- package/skills/api-linter/SKILL.md +32 -9
- package/skills/api-services/SKILL.md +1 -1
- package/skills/api-services/references/graph.md +1 -1
- package/skills/api-services/references/speech.md +1 -1
- package/skills/api-telemetry/SKILL.md +5 -5
- package/skills/api-testing/SKILL.md +9 -1
- package/skills/api-utils/SKILL.md +1 -1
- package/skills/api-workers/SKILL.md +12 -5
- package/skills/design-mcp-server/SKILL.md +20 -8
- package/skills/field-test/SKILL.md +9 -7
- package/skills/git-wrapup/SKILL.md +218 -0
- package/skills/maintenance/SKILL.md +8 -6
- package/skills/migrate-mcp-ts-template/SKILL.md +11 -7
- package/skills/multi-server-orchestration/SKILL.md +17 -5
- package/skills/multi-server-orchestration/references/greenfield-buildout.md +32 -2
- package/skills/multi-server-orchestration/references/maintenance-pass.md +11 -3
- package/skills/multi-server-orchestration/references/release-and-publish-pass.md +14 -25
- package/skills/multi-server-orchestration/references/wrapup-pass.md +15 -36
- package/skills/polish-docs-meta/SKILL.md +3 -1
- package/skills/polish-docs-meta/references/package-meta.md +1 -1
- package/skills/polish-docs-meta/references/readme.md +14 -1
- package/skills/release-and-publish/SKILL.md +20 -7
- package/skills/report-issue-framework/SKILL.md +5 -3
- package/skills/report-issue-local/SKILL.md +10 -5
- package/skills/setup/SKILL.md +13 -8
- package/skills/tool-defs-analysis/SKILL.md +6 -3
- package/templates/AGENTS.md +14 -6
- package/templates/CLAUDE.md +14 -6
- package/templates/changelog/template.md +26 -0
package/skills/add-test/SKILL.md
CHANGED
|
@@ -21,10 +21,10 @@ For the full `createMockContext` API and testing patterns, read:
|
|
|
21
21
|
|
|
22
22
|
1. **Identify the target** — which tool, resource, or service needs tests
|
|
23
23
|
2. **Read the source file** — understand the handler's logic, input/output schemas, error paths, and which `ctx` features it uses
|
|
24
|
-
3. **Create the test file** in the repo's existing test layout
|
|
24
|
+
3. **Create the test file** in the repo's existing test layout — search for existing `*.test.ts` files to confirm whether tests are colocated with source or under a root `tests/` directory
|
|
25
25
|
4. **Write test cases** covering happy path, error paths, and edge cases
|
|
26
26
|
5. **Run `bun run test`** to verify
|
|
27
|
-
6. **Run `bun run devcheck`** to verify types
|
|
27
|
+
6. **Run `bun run devcheck`** to verify lint, types, and MCP definitions
|
|
28
28
|
|
|
29
29
|
## Determining What to Test
|
|
30
30
|
|
|
@@ -41,6 +41,7 @@ Read the handler and identify:
|
|
|
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`. |
|
|
42
42
|
| **`format` function** | Test separately if defined — it's pure, no ctx needed. Verify it renders the IDs and fields the model needs, not just a count or title. For projection-style tools, test non-default field selections. |
|
|
43
43
|
| **Sparse upstream payloads** | For third-party API integrations, build a fixture with omitted fields. Assert normalized output still validates and `format()` preserves unknown values instead of inventing facts. |
|
|
44
|
+
| **Form-client payloads** | If handler has optional fields: test with empty-string inner values (form clients send `""` instead of `undefined`). Assert handler doesn't break or produce invalid output. |
|
|
44
45
|
| **Auth scopes** | Not tested at handler level (framework enforces) — skip |
|
|
45
46
|
|
|
46
47
|
## Templates
|
|
@@ -134,6 +135,8 @@ describe('{{RESOURCE_EXPORT}}', () => {
|
|
|
134
135
|
// expect(err.code).toBe(JsonRpcErrorCode.NotFound);
|
|
135
136
|
// expect(err.data.reason).toBe('no_match');
|
|
136
137
|
|
|
138
|
+
// Include this block only when the resource definition exports a `list` function.
|
|
139
|
+
// Check the source — `list` is optional on resource definitions.
|
|
137
140
|
it('lists available resources', async () => {
|
|
138
141
|
const listing = await {{RESOURCE_EXPORT}}.list!();
|
|
139
142
|
expect(listing.resources).toBeInstanceOf(Array);
|
|
@@ -156,14 +159,16 @@ describe('{{RESOURCE_EXPORT}}', () => {
|
|
|
156
159
|
|
|
157
160
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
158
161
|
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
|
|
162
|
+
import { StorageService } from '@cyanheads/mcp-ts-core/storage';
|
|
159
163
|
import { get{{ServiceClass}}, init{{ServiceClass}} } from '@/services/{{domain}}/{{domain}}-service.js';
|
|
160
164
|
|
|
161
|
-
|
|
165
|
+
// Derive the minimal mock config from src/config/server-config.ts — read
|
|
166
|
+
// the server's Zod schema to see which fields init{{ServiceClass}}() needs.
|
|
167
|
+
const mockConfig = { /* fields from server config schema */ } as AppConfig;
|
|
162
168
|
|
|
163
169
|
describe('{{ServiceClass}}', () => {
|
|
164
|
-
beforeEach(() => {
|
|
165
|
-
|
|
166
|
-
const mockStorage = createInMemoryStorage();
|
|
170
|
+
beforeEach(async () => {
|
|
171
|
+
const mockStorage = await StorageService.create({ type: 'in-memory' });
|
|
167
172
|
init{{ServiceClass}}(mockConfig, mockStorage);
|
|
168
173
|
});
|
|
169
174
|
|
|
@@ -206,8 +211,42 @@ it('respects cancellation', async () => {
|
|
|
206
211
|
setTimeout(() => controller.abort(), 50);
|
|
207
212
|
const result = await {{TOOL_EXPORT}}.handler(input, ctx);
|
|
208
213
|
|
|
209
|
-
// Should have
|
|
210
|
-
|
|
214
|
+
// Should have returned a partial result rather than throwing on cancellation.
|
|
215
|
+
// Assert on a field from the tool's actual output schema.
|
|
216
|
+
expect(result).toBeDefined();
|
|
217
|
+
});
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Prompt test
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
/**
|
|
224
|
+
* @fileoverview Tests for {{PROMPT_NAME}} prompt.
|
|
225
|
+
* @module tests/prompts/{{PROMPT_NAME}}.prompt.test
|
|
226
|
+
*/
|
|
227
|
+
|
|
228
|
+
import { describe, expect, it } from 'vitest';
|
|
229
|
+
import { {{PROMPT_EXPORT}} } from '@/mcp-server/prompts/definitions/{{prompt-name}}.prompt.js';
|
|
230
|
+
|
|
231
|
+
describe('{{PROMPT_EXPORT}}', () => {
|
|
232
|
+
it('generates valid messages for valid args', () => {
|
|
233
|
+
const args = {{PROMPT_EXPORT}}.args!.parse({
|
|
234
|
+
// valid args matching the Zod schema
|
|
235
|
+
});
|
|
236
|
+
const messages = {{PROMPT_EXPORT}}.generate(args);
|
|
237
|
+
expect(messages).toBeInstanceOf(Array);
|
|
238
|
+
expect(messages.length).toBeGreaterThan(0);
|
|
239
|
+
for (const msg of messages) {
|
|
240
|
+
expect(msg).toHaveProperty('role');
|
|
241
|
+
expect(msg).toHaveProperty('content');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Include only when the prompt has no required args (args is optional or all fields optional).
|
|
246
|
+
it('generates messages with no args', () => {
|
|
247
|
+
const messages = {{PROMPT_EXPORT}}.generate({});
|
|
248
|
+
expect(messages.length).toBeGreaterThan(0);
|
|
249
|
+
});
|
|
211
250
|
});
|
|
212
251
|
```
|
|
213
252
|
|
|
@@ -249,6 +288,8 @@ When scaffolding tests for an existing handler, use the Zod schemas to generate
|
|
|
249
288
|
- [ ] `format` function tested if defined
|
|
250
289
|
- [ ] `createMockContext` options match handler's ctx usage (`tenantId`, `progress`, `elicit`, `sample`)
|
|
251
290
|
- [ ] Service re-initialized in `beforeEach` if handler depends on a service singleton
|
|
252
|
-
- [ ] If
|
|
291
|
+
- [ ] If handler has optional fields: tested with empty-string inner values (form-client simulation)
|
|
292
|
+
- [ ] If wrapping external API: sparse-payload case tested — fixture omits at least one optional upstream field; output still validates and `format()` renders uncertainty honestly instead of inventing values
|
|
293
|
+
- [ ] If target is a prompt: `generate()` tested with valid args and (when applicable) no args
|
|
253
294
|
- [ ] `bun run test` passes
|
|
254
295
|
- [ ] `bun run devcheck` passes
|
package/skills/add-tool/SKILL.md
CHANGED
|
@@ -11,18 +11,16 @@ metadata:
|
|
|
11
11
|
|
|
12
12
|
## Context
|
|
13
13
|
|
|
14
|
-
Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in `src/mcp-server/tools/definitions/` with a `.tool.ts` suffix
|
|
15
|
-
|
|
16
|
-
For the full `tool()` API, `Context` interface, and error codes, read `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md`.
|
|
14
|
+
Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in `src/mcp-server/tools/definitions/` with a `.tool.ts` suffix. The standard registration pattern uses a `definitions/index.ts` barrel that collects all tools into an `allToolDefinitions` array for `createApp()`. Fresh scaffolds from `init` start with direct imports in `src/index.ts` — the barrel is introduced as definitions grow. Match the pattern already used by the project you're editing.
|
|
17
15
|
|
|
18
16
|
## Steps
|
|
19
17
|
|
|
20
|
-
1. **
|
|
18
|
+
1. **Gather** the tool's name, purpose, and input/output shape from the user's request — ask only if genuinely absent
|
|
21
19
|
2. **Determine if long-running** — if the tool involves streaming, polling, or
|
|
22
20
|
multi-step async work, it should use `task: true`
|
|
23
21
|
3. **Create the file** at `src/mcp-server/tools/definitions/{{tool-name}}.tool.ts`
|
|
24
22
|
4. **Register** the tool in the project's existing `createApp()` tool list (directly in `src/index.ts` for fresh scaffolds, or via a barrel if the repo already has one)
|
|
25
|
-
5. **Run `bun run devcheck`** to verify
|
|
23
|
+
5. **Run `bun run devcheck`** to verify — if Biome reports formatting issues, run `bun run format` to auto-fix, then re-run devcheck
|
|
26
24
|
6. **Smoke-test** with `bun run rebuild && bun run start:stdio` (or `start:http`)
|
|
27
25
|
|
|
28
26
|
## Naming
|
|
@@ -136,6 +134,7 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
|
|
|
136
134
|
output: z.object({ /* ... */ }),
|
|
137
135
|
|
|
138
136
|
async handler(input, ctx) {
|
|
137
|
+
// ctx.progress is guaranteed non-null when task: true — the ! assertion is safe here.
|
|
139
138
|
await ctx.progress!.setTotal(totalSteps);
|
|
140
139
|
for (const step of steps) {
|
|
141
140
|
if (ctx.signal.aborted) break;
|
|
@@ -528,18 +527,26 @@ Large payloads burn the agent's context window. Default to curated summaries; of
|
|
|
528
527
|
## Checklist
|
|
529
528
|
|
|
530
529
|
- [ ] File created at `src/mcp-server/tools/definitions/{{tool-name}}.tool.ts`
|
|
530
|
+
- [ ] Tool name passed to `tool()` uses snake_case
|
|
531
|
+
- [ ] `title` field set
|
|
532
|
+
- [ ] `annotations` set correctly — `readOnlyHint: false` for write tools, `destructiveHint: true` for delete/overwrite tools
|
|
531
533
|
- [ ] All Zod schema fields have `.describe()` annotations
|
|
532
534
|
- [ ] Numeric `output` fields carry units in the field name (`sizeInBytes`, `durationInMs`, `priceInCents`, `latencyInMs`) — `.describe()` may be summarized away or truncated, but the field name persists into the JSON the agent reads. Exempt: dimensionless counts (`totalCount`, `itemCount`), indices (`index`, `position`)
|
|
533
535
|
- [ ] Schemas use only JSON-Schema-serializable types (no `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`)
|
|
534
536
|
- [ ] JSDoc `@fileoverview` and `@module` header present
|
|
535
537
|
- [ ] Optional nested objects guarded for empty inner values from form-based clients (check `?.field` truthiness, not just object presence)
|
|
536
|
-
- [ ]
|
|
538
|
+
- [ ] No `console` calls — use `ctx.log` for handler logging
|
|
539
|
+
- [ ] `handler(input, ctx)` is pure — throws on failure, no try/catch (exception: batch tools with per-item isolation use try/catch inside the loop — that's intentional, don't remove it)
|
|
537
540
|
- [ ] `format()` renders every field in the output schema — enforced at lint time via sentinel injection, startup fails with `format-parity` errors otherwise. Different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data. Primary fix: render the missing field in `format()` (use `z.discriminatedUnion` for list/detail variants). Escape hatch: if the output schema was over-typed for a genuinely dynamic upstream API, relax it (`z.object({}).passthrough()`) rather than maintaining aspirational typing
|
|
538
541
|
- [ ] If wrapping external API: output schema and `format()` preserve uncertainty from sparse upstream payloads instead of inventing concrete values
|
|
539
542
|
- [ ] `auth` scopes declared if the tool needs authorization
|
|
540
543
|
- [ ] `errors: [...]` contract declared for the tool's domain-specific failure modes — or block deleted if no domain failures apply (baseline codes bubble freely)
|
|
541
544
|
- [ ] Error contract declared inline on this tool — not imported from a shared module, even when other tools have near-identical entries
|
|
542
545
|
- [ ] `task: true` added if the tool is long-running
|
|
546
|
+
- [ ] If `task: true`: handler checks `ctx.signal.aborted` in its loop for cancellation support
|
|
547
|
+
- [ ] If tool returns unbounded arrays: pagination with total count, or `spillover()` / DataCanvas for tabular working sets
|
|
548
|
+
- [ ] If tool is feature-gated: evaluated whether `disabledTool()` wrapper is appropriate (present in manifest but uncallable)
|
|
543
549
|
- [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
|
|
550
|
+
- [ ] Test file created via `add-test` skill, or handler tested directly with `createMockContext()`
|
|
544
551
|
- [ ] `bun run devcheck` passes
|
|
545
552
|
- [ ] Smoke-tested with `bun run rebuild && bun run start:stdio` (or `start:http`)
|
package/skills/api-auth/SKILL.md
CHANGED
|
@@ -141,7 +141,8 @@ A `WARNING`-level log is emitted at startup whenever the flag is active so opera
|
|
|
141
141
|
| `GET /healthz` | No |
|
|
142
142
|
| `GET /mcp` | No |
|
|
143
143
|
| `POST /mcp` | Yes (when auth enabled) |
|
|
144
|
-
| `
|
|
144
|
+
| `DELETE /mcp` | Yes (when auth enabled) — session termination |
|
|
145
|
+
| `OPTIONS /mcp` | No (handled by CORS middleware before auth) |
|
|
145
146
|
|
|
146
147
|
**CORS:** Set `MCP_ALLOWED_ORIGINS` to a comma-separated list of allowed origins, or `*` for open access.
|
|
147
148
|
|
|
@@ -196,7 +197,7 @@ interface AuthContext {
|
|
|
196
197
|
clientId: string; // Required — 'cid' or 'client_id' JWT claim
|
|
197
198
|
scopes: string[]; // Required — union of 'scp', 'scope', and 'mcp_tool_scopes' claims
|
|
198
199
|
sub: string; // Required — 'sub' claim; falls back to clientId when absent
|
|
199
|
-
token
|
|
200
|
+
token?: string; // Optional — raw JWT or OAuth bearer token string (present when transport provides it)
|
|
200
201
|
tenantId?: string; // Optional — 'tid' claim; present only for multi-tenant tokens
|
|
201
202
|
}
|
|
202
203
|
```
|
|
@@ -13,7 +13,7 @@ metadata:
|
|
|
13
13
|
|
|
14
14
|
`DataCanvas` is a primitive for **storage stashes, canvas computes**. The existing `IStorageProvider` is a key/value abstraction — it can stash blobs but exposes no analytical surface. `DataCanvas` is the analytical surface: register tabular data from upstream APIs, run SQL across multiple registered tables, and export results as CSV/Parquet/JSON.
|
|
15
15
|
|
|
16
|
-
**Tier 3** — `@duckdb/node-api` is an optional peer dependency. Servers that don't enable canvas pay zero install cost. Lazy-loaded on first use.
|
|
16
|
+
**Tier 3** — `@duckdb/node-api` is an optional peer dependency (`bun add @duckdb/node-api`). Servers that don't enable canvas pay zero install cost. Lazy-loaded on first use.
|
|
17
17
|
|
|
18
18
|
**Disabled by default.** Set `CANVAS_PROVIDER_TYPE=duckdb` to enable. Otherwise `core.canvas` is `undefined`.
|
|
19
19
|
|
|
@@ -27,7 +27,27 @@ metadata:
|
|
|
27
27
|
import type { DataCanvas, CanvasInstance, ColumnSchema } from '@cyanheads/mcp-ts-core/canvas';
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
The framework wires the optional service onto `CoreServices
|
|
30
|
+
The framework wires the optional service onto `CoreServices`, accessible in the `setup()` callback — **not on `Context`**. Handlers access canvas via a module-level accessor:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// src/services/canvas-accessor.ts
|
|
34
|
+
import type { DataCanvas } from '@cyanheads/mcp-ts-core/canvas';
|
|
35
|
+
|
|
36
|
+
let _canvas: DataCanvas | undefined;
|
|
37
|
+
export const setCanvas = (c: DataCanvas | undefined) => { _canvas = c; };
|
|
38
|
+
export const getCanvas = () => _canvas;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// src/index.ts — wire in setup()
|
|
43
|
+
import { setCanvas } from './services/canvas-accessor.js';
|
|
44
|
+
|
|
45
|
+
await createApp({
|
|
46
|
+
setup(core) {
|
|
47
|
+
setCanvas(core.canvas);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
```
|
|
31
51
|
|
|
32
52
|
```ts
|
|
33
53
|
interface CoreServices {
|
|
@@ -74,7 +94,11 @@ The sweeper runs as an `unref`'d `setInterval` — does not keep the event loop
|
|
|
74
94
|
Resolves an existing canvas or creates a new one. Returns a {@link CanvasInstance} bound to `(canvasId, tenantId)`. Subsequent operations don't repeat them.
|
|
75
95
|
|
|
76
96
|
```ts
|
|
77
|
-
|
|
97
|
+
import { getCanvas } from '@/services/canvas-accessor.js';
|
|
98
|
+
|
|
99
|
+
const canvas = getCanvas();
|
|
100
|
+
if (!canvas) throw new Error('DataCanvas is not enabled. Set CANVAS_PROVIDER_TYPE=duckdb.');
|
|
101
|
+
const instance = await canvas.acquire(input.canvas_id, ctx);
|
|
78
102
|
// instance.canvasId — surface to the agent
|
|
79
103
|
// instance.isNew — true on first call
|
|
80
104
|
// instance.expiresAt — ISO 8601 after sliding extension
|
|
@@ -116,7 +140,7 @@ const joined = await instance.query(`
|
|
|
116
140
|
// joined.tableName === 'g_with_obs'; joined.rows.length === 10; joined.rowCount === <full count>
|
|
117
141
|
```
|
|
118
142
|
|
|
119
|
-
`registerAs` rejects with `
|
|
143
|
+
`registerAs` rejects with `ValidationError` (`data.reason: 'register_as_clash'`) if the target name already exists — drop it first.
|
|
120
144
|
|
|
121
145
|
**Read-only enforcement** (four layers):
|
|
122
146
|
1. Text-level deny-list — pre-parse scan for file/HTTP-reading table functions (`read_csv*`, `read_json*`, `read_parquet*`, `read_text`, `read_blob`, `glob`, `iceberg_scan`, `delta_scan`, `postgres_scan`, `mysql_scan`, `sqlite_scan`, plus pre-staged spatial ones).
|
|
@@ -225,6 +249,7 @@ If your tool surfaces row data via `structuredContent`, the JSON-safe shape flow
|
|
|
225
249
|
|
|
226
250
|
```ts
|
|
227
251
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
252
|
+
import { getCanvas } from '@/services/canvas-accessor.js';
|
|
228
253
|
|
|
229
254
|
export const fetchAndStage = tool('fetch_and_stage_germplasm', {
|
|
230
255
|
description: 'Fetch germplasm matching a query and stage it on a DataCanvas for follow-up SQL.',
|
|
@@ -245,7 +270,7 @@ export const fetchAndStage = tool('fetch_and_stage_germplasm', {
|
|
|
245
270
|
expires_at: z.string().describe('ISO 8601 expiry after sliding 24h window'),
|
|
246
271
|
}),
|
|
247
272
|
async handler(input, ctx) {
|
|
248
|
-
const canvas =
|
|
273
|
+
const canvas = getCanvas();
|
|
249
274
|
if (!canvas) {
|
|
250
275
|
throw new Error('DataCanvas is not enabled. Set CANVAS_PROVIDER_TYPE=duckdb.');
|
|
251
276
|
}
|
|
@@ -334,7 +359,7 @@ When the preview budget is small (single-digit rows) and the sniff window matter
|
|
|
334
359
|
|
|
335
360
|
### Out of scope
|
|
336
361
|
|
|
337
|
-
- **Provenance metadata** (source URI, original query). Caller stores externally —
|
|
362
|
+
- **Provenance metadata** (source URI, original query). Caller stores externally via `ctx.state` or tool output — canvas tables carry data only, not lineage.
|
|
338
363
|
- **Pagination-flavored builder.** A `paginate(fetchPage) → AsyncIterable<Row>` adapter is deferred until a second non-paginated consumer surfaces.
|
|
339
364
|
- **Token-accurate budget.** `previewTokens` (tokenizer-driven) is a future option; characters cover the common case.
|
|
340
365
|
- **`caps.maxBytes`.** Row caps cover the common case without re-doing serialization the canvas appender skips.
|
|
@@ -363,6 +388,18 @@ When the preview budget is small (single-digit rows) and the sniff window matter
|
|
|
363
388
|
|
|
364
389
|
---
|
|
365
390
|
|
|
391
|
+
## Checklist
|
|
392
|
+
|
|
393
|
+
- [ ] `@duckdb/node-api` installed as a peer dependency (`bun add @duckdb/node-api`)
|
|
394
|
+
- [ ] `CANVAS_PROVIDER_TYPE=duckdb` set in `.env`
|
|
395
|
+
- [ ] Canvas accessor module created (`src/services/canvas-accessor.ts` or equivalent)
|
|
396
|
+
- [ ] Accessor wired in `setup()` callback via `setCanvas(core.canvas)`
|
|
397
|
+
- [ ] Handler guards for canvas availability (`if (!canvas) throw ...`)
|
|
398
|
+
- [ ] `canvas_id` accepted as optional input, returned in output
|
|
399
|
+
- [ ] SQL queries are read-only (enforced by the four-layer gate, but don't attempt writes)
|
|
400
|
+
- [ ] Testing: mock the module-level `getCanvas()` accessor with `vi.spyOn` or a test setup that calls `setCanvas(mockCanvas)`
|
|
401
|
+
- [ ] `bun run devcheck` passes
|
|
402
|
+
|
|
366
403
|
## Related skills
|
|
367
404
|
|
|
368
405
|
- `add-tool` — scaffold a new MCP tool definition (use the canvas template above)
|
|
@@ -59,6 +59,10 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod from environment variable
|
|
|
59
59
|
| `MCP_RESPONSE_VERBOSITY` | `mcpResponseVerbosity` | `standard` | `minimal` \| `standard` \| `full` |
|
|
60
60
|
| `MCP_ALLOWED_ORIGINS` | `mcpAllowedOrigins` | — | Comma-separated list; omit to allow all |
|
|
61
61
|
| `MCP_SERVER_RESOURCE_IDENTIFIER` | `mcpServerResourceIdentifier` | — | RFC 8707 resource indicator URL |
|
|
62
|
+
| `MCP_PUBLIC_URL` | `mcpPublicUrl` | — | Public-facing origin for reverse proxies (Cloudflare Tunnel, nginx, ALB) so emitted URLs carry the correct scheme |
|
|
63
|
+
| `MCP_HEARTBEAT_INTERVAL_MS` | `mcpHeartbeatIntervalMs` | `0` (disabled) | Heartbeat ping interval; 0 disables |
|
|
64
|
+
| `MCP_HEARTBEAT_MISS_THRESHOLD` | `mcpHeartbeatMissThreshold` | `3` | Missed heartbeats before session is considered stale |
|
|
65
|
+
| `MCP_GC_PRESSURE_INTERVAL_MS` | `mcpGcPressureIntervalMs` | `0` (disabled) | Bun-only opt-in forced GC loop for HTTP deployments with heap growth |
|
|
62
66
|
|
|
63
67
|
---
|
|
64
68
|
|
|
@@ -75,6 +79,8 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod from environment variable
|
|
|
75
79
|
| `OAUTH_JWKS_COOLDOWN_MS` | `oauthJwksCooldownMs` | `300000` | 5 min; min time between JWKS refetches |
|
|
76
80
|
| `OAUTH_JWKS_TIMEOUT_MS` | `oauthJwksTimeoutMs` | `5000` | JWKS fetch timeout (ms) |
|
|
77
81
|
| `DEV_MCP_AUTH_BYPASS` | `devMcpAuthBypass` | `false` | Skip auth in development; blocked in `production` |
|
|
82
|
+
| `MCP_JWT_EXPECTED_ISSUER` | `mcpJwtExpectedIssuer` | — | Optional issuer validation for JWT mode |
|
|
83
|
+
| `MCP_JWT_EXPECTED_AUDIENCE` | `mcpJwtExpectedAudience` | — | Optional audience validation for JWT mode |
|
|
78
84
|
| `DEV_MCP_CLIENT_ID` | `devMcpClientId` | — | Dev-only: override client ID |
|
|
79
85
|
| `DEV_MCP_SCOPES` | `devMcpScopes` | — | Dev-only: comma-separated scope overrides |
|
|
80
86
|
|
|
@@ -42,9 +42,11 @@ interface Context {
|
|
|
42
42
|
readonly elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
|
|
43
43
|
readonly sample?: (messages: SamplingMessage[], opts?: SamplingOpts) => Promise<CreateMessageResult>;
|
|
44
44
|
|
|
45
|
-
//
|
|
45
|
+
// Notifications — present when transport supports them
|
|
46
46
|
readonly notifyResourceListChanged?: () => void;
|
|
47
47
|
readonly notifyResourceUpdated?: (uri: string) => void;
|
|
48
|
+
readonly notifyPromptListChanged?: () => void;
|
|
49
|
+
readonly notifyToolListChanged?: () => void;
|
|
48
50
|
|
|
49
51
|
// Cancellation
|
|
50
52
|
readonly signal: AbortSignal;
|
|
@@ -415,8 +417,10 @@ Present only when the definition declares an `errors[]` contract. Builds an `Mcp
|
|
|
415
417
|
export const fetchItems = tool('fetch_items', {
|
|
416
418
|
description: 'Fetch items by ID.',
|
|
417
419
|
errors: [
|
|
418
|
-
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No items matched'
|
|
419
|
-
|
|
420
|
+
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No items matched',
|
|
421
|
+
recovery: 'Broaden the query or check the spelling and try again.' },
|
|
422
|
+
{ reason: 'queue_full', code: JsonRpcErrorCode.RateLimited, when: 'Local queue at capacity', retryable: true,
|
|
423
|
+
recovery: 'Wait a few seconds before retrying or reduce batch size.' },
|
|
420
424
|
],
|
|
421
425
|
input: z.object({ ids: z.array(z.string()).describe('Item IDs') }),
|
|
422
426
|
output: z.object({ items: z.array(ItemSchema).describe('Resolved items') }),
|
|
@@ -534,6 +538,8 @@ The `≥5 words` lint rule on contract `recovery` (validated at lint time) makes
|
|
|
534
538
|
| `ctx.sample` | `function \| undefined` | Client supports sampling |
|
|
535
539
|
| `ctx.notifyResourceListChanged` | `function \| undefined` | Transport supports resource notifications |
|
|
536
540
|
| `ctx.notifyResourceUpdated` | `function \| undefined` | Transport supports resource notifications |
|
|
541
|
+
| `ctx.notifyPromptListChanged` | `function \| undefined` | Transport supports prompt notifications |
|
|
542
|
+
| `ctx.notifyToolListChanged` | `function \| undefined` | Transport supports tool notifications |
|
|
537
543
|
| `ctx.progress` | `ContextProgress \| undefined` | Tool defined with `task: true` |
|
|
538
544
|
| `ctx.uri` | `URL \| undefined` | Resource handlers only |
|
|
539
545
|
| `ctx.fail` | `(reason, msg?, data?, opts?) => McpError` | Definition declares `errors[]` contract |
|
|
@@ -11,7 +11,7 @@ metadata:
|
|
|
11
11
|
|
|
12
12
|
## Overview
|
|
13
13
|
|
|
14
|
-
Error handling in `@cyanheads/mcp-ts-core` follows a strict layered pattern: tool and resource handlers throw `McpError` freely (no try/catch), the handler factory catches and normalizes all errors, and services use `ErrorHandler.tryCatch` for
|
|
14
|
+
Error handling in `@cyanheads/mcp-ts-core` follows a strict layered pattern: tool and resource handlers throw `McpError` freely (no try/catch), the handler factory catches and normalizes all errors, and services use `ErrorHandler.tryCatch` for structured logging and wrapping.
|
|
15
15
|
|
|
16
16
|
**Imports:**
|
|
17
17
|
|
|
@@ -64,7 +64,7 @@ export const fetchTool = tool('fetch_articles', {
|
|
|
64
64
|
|:--------|:---------|
|
|
65
65
|
| Compile time | `ctx.fail('typo')` is a TS error. Auto-completes declared reasons. |
|
|
66
66
|
| Runtime | `ctx.fail(reason, msg?, data?, options?)` builds an `McpError(contract.code, msg, { ...data, reason }, options)` — `data.reason` is auto-populated from the contract and cannot be overridden by caller-supplied data (spread first, then `reason` written last), so observers see a stable identifier. `options` accepts `{ cause }` for ES2022 error chaining. |
|
|
67
|
-
| Lint (
|
|
67
|
+
| Lint (devcheck) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. `recovery` validated as non-empty and ≥ 5 words. Build-time only — not invoked at server startup. |
|
|
68
68
|
| Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. |
|
|
69
69
|
|
|
70
70
|
> **`recovery` is opt-in resolution, not auto-population.** The contract `recovery` is required metadata documenting the agent's next move when this failure mode fires (a forcing function for thoughtful guidance — placeholders like "Try again." get flagged by the linter). It does **not** automatically appear in runtime `data.recovery.hint` — the framework never injects it without an explicit signal at the throw site. Authors opt in by spreading `ctx.recoveryFor('reason')` into the `data` argument, the same way `ctx.fail('reason')` opts into resolving the contract `code`. What the author types at the throw site is what flows to the wire, with no hidden transformation; the resolver is just a typed lookup keyed by the same `reason` the author already typed.
|
|
@@ -126,7 +126,7 @@ throw ctx.fail('no_match', `No item ${id}`, {
|
|
|
126
126
|
|
|
127
127
|
### Carrying contract `reason` from services
|
|
128
128
|
|
|
129
|
-
Services don't
|
|
129
|
+
Services don't receive `ctx` automatically (unlike handlers), so they can't call `ctx.fail` directly — though `ctx` can be passed as a parameter when needed. To make a service-thrown failure carry the contract's `reason` on the wire, **pass `data: { reason: 'X' }` to the factory**. The framework's auto-classifier preserves `data` unchanged, so clients see the same `error.data.reason` they'd see from `ctx.fail`:
|
|
130
130
|
|
|
131
131
|
```ts
|
|
132
132
|
// my-service.ts
|
|
@@ -271,7 +271,7 @@ Use factories or `McpError` directly when the code must be exact — auto-classi
|
|
|
271
271
|
The framework applies these steps in order — first match wins:
|
|
272
272
|
|
|
273
273
|
1. **`McpError` instance** — `error.code` is preserved as-is; no classification needed.
|
|
274
|
-
2. **JS constructor name** — matched against a fixed table (e.g. `
|
|
274
|
+
2. **JS constructor name** — matched against a fixed table (e.g. `ZodError` → `ValidationError`, `SyntaxError` → `ValidationError`). Note: `TypeError` is intentionally excluded — runtime TypeErrors are programmer errors, not validation failures.
|
|
275
275
|
3. **Provider-specific patterns** — HTTP status codes, AWS exception names, Supabase, OpenRouter. Checked before common patterns because they are more specific (e.g. `status code 429` beats the generic `rate limit` pattern).
|
|
276
276
|
4. **Common message/name patterns** — broad keyword patterns covering auth, not-found, validation, etc. First match wins; order matters.
|
|
277
277
|
5. **`AbortError` name** — `error.name === 'AbortError'` → `Timeout`.
|
|
@@ -344,7 +344,7 @@ Checked before common patterns. Cover: AWS exception names, HTTP status codes, D
|
|
|
344
344
|
| Tool/resource handlers | Throw `McpError` — no try/catch |
|
|
345
345
|
| Handler factory (tools) | Catches all errors, normalizes to `McpError`, sets `isError: true`, mirrors error across both client surfaces (see [Error-path parity](#error-path-parity)) |
|
|
346
346
|
| Handler factory (resources) | Catches and re-throws to the SDK, which routes through the JSON-RPC error envelope |
|
|
347
|
-
| Services/setup code | `ErrorHandler.tryCatch` for
|
|
347
|
+
| Services/setup code | `ErrorHandler.tryCatch` for structured logging and wrapping (always rethrows — never swallows) |
|
|
348
348
|
|
|
349
349
|
### Error-path parity
|
|
350
350
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api-linter
|
|
3
3
|
description: >
|
|
4
|
-
MCP definition linter rules reference. Use when `bun run lint:mcp
|
|
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
7
|
version: "1.3"
|
|
@@ -11,19 +11,18 @@ metadata:
|
|
|
11
11
|
|
|
12
12
|
## Overview
|
|
13
13
|
|
|
14
|
-
The linter validates tool, resource, and prompt definitions against the MCP spec and framework conventions. It runs in
|
|
14
|
+
The linter validates tool, resource, and prompt definitions against the MCP spec and framework conventions. **It is build-time only — not invoked at server startup.** It runs in two places:
|
|
15
15
|
|
|
16
16
|
| Entry point | When | On failure |
|
|
17
17
|
|:------------|:-----|:-----------|
|
|
18
|
-
| `createApp()` / `createWorkerHandler()` | Every startup | Throws `ConfigurationError`; process exits with a formatted banner. Warnings are logged and startup continues. |
|
|
19
18
|
| `bun run lint:mcp` | Manual or CI | Prints errors + warnings, exits non-zero on errors. |
|
|
20
19
|
| `bun run devcheck` | Pre-commit workflow | Wraps `lint:mcp` alongside typecheck, format, `bun audit`, `bun outdated`. |
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
Both surface the same `LintReport` from `validateDefinitions()` (exported from `@cyanheads/mcp-ts-core/linter`). Each diagnostic has a stable `rule` ID — that's the anchor you land on via the `See: skills/api-linter/SKILL.md#<rule>` breadcrumb appended to every message.
|
|
23
22
|
|
|
24
23
|
**Severity:**
|
|
25
|
-
- **error** — MUST-level spec violation; blocks
|
|
26
|
-
- **warning** — SHOULD-level or quality issue; logged but
|
|
24
|
+
- **error** — MUST-level spec violation; blocks `devcheck`.
|
|
25
|
+
- **warning** — SHOULD-level or quality issue; logged but `devcheck` continues.
|
|
27
26
|
|
|
28
27
|
**Imports (if you need to run the linter programmatically):**
|
|
29
28
|
|
|
@@ -52,7 +51,7 @@ Grouped by family. Jump to any rule ID via its anchor.
|
|
|
52
51
|
| Landing | `landing-*` (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme) | [Landing config rules](#landing-config-rules) |
|
|
53
52
|
| Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
|
|
54
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) |
|
|
55
|
-
| 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 rules](#error-contract-rules) |
|
|
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) |
|
|
56
55
|
| Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail` | [Error contract rules](#error-contract-rules) |
|
|
57
56
|
| server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
|
|
58
57
|
|
|
@@ -515,7 +514,9 @@ Heuristic source-text checks that scan `handler.toString()` for common error-han
|
|
|
515
514
|
|
|
516
515
|
Fires when a handler contains `throw new Error(...)`. Plain `Error` doesn't carry a JSON-RPC code — the framework's auto-classifier degrades to `InternalError`, hiding the actual failure mode.
|
|
517
516
|
|
|
518
|
-
|
|
517
|
+
Plain `Error` is acceptable for "don't care" cases where the specific code doesn't matter (per CLAUDE.md: "plain `Error` for don't-care cases"). This rule targets domain-specific failures that deserve a concrete code — upgrade those to factories or `ctx.fail`, and accept the warning for the rest.
|
|
518
|
+
|
|
519
|
+
**Fix:** use `McpError` or a factory for domain-specific failures:
|
|
519
520
|
|
|
520
521
|
```ts
|
|
521
522
|
// instead of:
|
|
@@ -596,7 +597,7 @@ Fires when `errors: []` is declared. An empty contract is a no-op — nothing to
|
|
|
596
597
|
|
|
597
598
|
**Severity:** error
|
|
598
599
|
|
|
599
|
-
Fires when an entry in `errors[]` isn't an object. Each entry must be `{ code, reason, when }` (and optionally `retryable`).
|
|
600
|
+
Fires when an entry in `errors[]` isn't an object. Each entry must be `{ code, reason, when, recovery }` (and optionally `retryable`).
|
|
600
601
|
|
|
601
602
|
### error-contract-code-type
|
|
602
603
|
|
|
@@ -654,6 +655,28 @@ Fires when an entry's `when` field is missing or empty. `when` is the human-read
|
|
|
654
655
|
|
|
655
656
|
Fires when an entry's optional `retryable` field is present but isn't a boolean. Only `true` or `false` is meaningful — drop the field if you can't commit to either.
|
|
656
657
|
|
|
658
|
+
### error-contract-recovery-required
|
|
659
|
+
|
|
660
|
+
**Severity:** error
|
|
661
|
+
|
|
662
|
+
Fires when an entry's `recovery` field is missing or not a string. `recovery` is the agent's next-move guidance when this failure fires — it flows to the wire via `ctx.recoveryFor`.
|
|
663
|
+
|
|
664
|
+
### error-contract-recovery-empty
|
|
665
|
+
|
|
666
|
+
**Severity:** error
|
|
667
|
+
|
|
668
|
+
Fires when `recovery` is an empty string. A blank recovery is worse than none — it suggests the field was considered and deliberately left empty.
|
|
669
|
+
|
|
670
|
+
**Fix:** write a concrete recovery hint (≥5 words).
|
|
671
|
+
|
|
672
|
+
### error-contract-recovery-min-words
|
|
673
|
+
|
|
674
|
+
**Severity:** warning
|
|
675
|
+
|
|
676
|
+
Fires when `recovery` has fewer than 5 words. Short recoveries like "Try again." are too vague to guide an agent's next action.
|
|
677
|
+
|
|
678
|
+
**Fix:** expand with specifics — what to try, what parameter to change, which tool to call instead.
|
|
679
|
+
|
|
657
680
|
### error-contract-conformance
|
|
658
681
|
|
|
659
682
|
**Severity:** warning
|
|
@@ -11,7 +11,7 @@ metadata:
|
|
|
11
11
|
|
|
12
12
|
## Overview
|
|
13
13
|
|
|
14
|
-
Service
|
|
14
|
+
Service providers are exported from `@cyanheads/mcp-ts-core/services`. These are documented here for servers that use the built-in providers.
|
|
15
15
|
|
|
16
16
|
All services follow the **init/accessor pattern**: initialized in `setup()`, accessed at request time via lazy accessor. See the `add-service` skill for the full pattern.
|
|
17
17
|
|
|
@@ -111,7 +111,7 @@ const path = await graphService.shortestPath('user:alice', 'user:charlie', conte
|
|
|
111
111
|
algorithm: 'bfs',
|
|
112
112
|
maxLength: 4,
|
|
113
113
|
});
|
|
114
|
-
|
|
114
|
+
// path.vertices.length gives the hop count
|
|
115
115
|
|
|
116
116
|
// Check reachability
|
|
117
117
|
const connected = await graphService.pathExists('user:alice', 'user:charlie', context, 3);
|
|
@@ -24,7 +24,7 @@ The provider interface — implemented by ElevenLabs (TTS) and Whisper (STT):
|
|
|
24
24
|
| `.getSTTProvider()` | `ISpeechProvider` | Throws `McpError(InvalidRequest)` if no STT provider configured |
|
|
25
25
|
| `.hasTTS()` | `boolean` | Check if TTS is available |
|
|
26
26
|
| `.hasSTT()` | `boolean` | Check if STT is available |
|
|
27
|
-
| `.healthCheck()` | `Promise<{ tts: boolean; stt: boolean }>` | Checks both providers
|
|
27
|
+
| `.healthCheck()` | `Promise<{ tts: boolean; stt: boolean }>` | Checks both providers sequentially |
|
|
28
28
|
|
|
29
29
|
## Providers
|
|
30
30
|
|
|
@@ -11,7 +11,7 @@ metadata:
|
|
|
11
11
|
|
|
12
12
|
## Overview
|
|
13
13
|
|
|
14
|
-
The framework auto-instruments every tool, resource, prompt, storage, LLM, speech, and graph call — each gets its own span and the standard counters/histograms. HTTP server requests pick up spans from `HttpInstrumentation` (
|
|
14
|
+
The framework auto-instruments every tool, resource, prompt, storage, LLM, speech, and graph call — each gets its own span and the standard counters/histograms. HTTP server requests pick up spans from `HttpInstrumentation` (all Node.js HTTP traffic, skips `/healthz`) plus `httpInstrumentationMiddleware` from `@hono/otel` on the MCP HTTP endpoint when installed (optional Tier 3 peer — `bun add @hono/otel`). On Bun, `HttpInstrumentation` silently no-ops and `@hono/otel` is the only HTTP coverage. Auth checks, session lifecycle, and task lifecycle are tracked as **metrics only** — auth decorates the active HTTP span with attributes, sessions and tasks emit counters.
|
|
15
15
|
|
|
16
16
|
`requestId`, `traceId`, and `tenantId` correlate automatically across spans, metrics, and logs. Pino logs get `trace_id`/`span_id` injected when a span is active.
|
|
17
17
|
|
|
@@ -76,7 +76,7 @@ Trace context propagates across boundaries via W3C `traceparent` headers. See `a
|
|
|
76
76
|
|
|
77
77
|
## Metrics
|
|
78
78
|
|
|
79
|
-
All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` where standard semconv applies). Lazy-initialized on first emission;
|
|
79
|
+
All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` where standard semconv applies). Lazy-initialized on first emission; tool, resource, prompt, `http.client.request.duration`, heartbeat, session, auth, rate-limit, and error metrics are eagerly created at startup so series exist from the first export cycle. LLM, speech, graph, and storage instruments are lazy-initialized on first use.
|
|
80
80
|
|
|
81
81
|
### Tools, resources, prompts
|
|
82
82
|
|
|
@@ -86,17 +86,17 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
|
|
|
86
86
|
| `mcp.tool.duration` | histogram | `ms` | `mcp.tool.name`, `mcp.tool.success` |
|
|
87
87
|
| `mcp.tool.errors` | counter | `{errors}` | `mcp.tool.name`, `mcp.tool.error_category` (`upstream`/`server`/`client`) |
|
|
88
88
|
| `mcp.tool.input_bytes` | histogram | `bytes` | `mcp.tool.name` |
|
|
89
|
-
| `mcp.tool.output_bytes` | histogram | `bytes` | `mcp.tool.name` |
|
|
89
|
+
| `mcp.tool.output_bytes` | histogram | `bytes` | `mcp.tool.name` (success only) |
|
|
90
90
|
| `mcp.tool.param.usage` | counter | `{uses}` | `mcp.tool.name`, `mcp.tool.param` (top-level keys supplied by caller) |
|
|
91
91
|
| `mcp.resource.reads` | counter | `{reads}` | `mcp.resource.name`, `mcp.resource.success` |
|
|
92
92
|
| `mcp.resource.duration` | histogram | `ms` | `mcp.resource.name`, `mcp.resource.success` |
|
|
93
93
|
| `mcp.resource.errors` | counter | `{errors}` | `mcp.resource.name` |
|
|
94
|
-
| `mcp.resource.output_bytes` | histogram | `bytes` | `mcp.resource.name` |
|
|
94
|
+
| `mcp.resource.output_bytes` | histogram | `bytes` | `mcp.resource.name` (success only) |
|
|
95
95
|
| `mcp.prompt.generations` | counter | `{generations}` | `mcp.prompt.name`, `mcp.prompt.success` |
|
|
96
96
|
| `mcp.prompt.duration` | histogram | `ms` | `mcp.prompt.name`, `mcp.prompt.success` |
|
|
97
97
|
| `mcp.prompt.errors` | counter | `{errors}` | `mcp.prompt.name`, `mcp.prompt.error_category` |
|
|
98
98
|
| `mcp.prompt.input_bytes` | histogram | `bytes` | `mcp.prompt.name` |
|
|
99
|
-
| `mcp.prompt.output_bytes` | histogram | `bytes` | `mcp.prompt.name` |
|
|
99
|
+
| `mcp.prompt.output_bytes` | histogram | `bytes` | `mcp.prompt.name` (success only) |
|
|
100
100
|
| `mcp.prompt.message_count` | histogram | `{messages}` | `mcp.prompt.name` |
|
|
101
101
|
| `mcp.requests.active` | up/down counter | `{requests}` | — (in-flight handler executions, all three types) |
|
|
102
102
|
|
|
@@ -13,6 +13,8 @@ metadata:
|
|
|
13
13
|
|
|
14
14
|
Tests target handler behavior directly — call `handler(input, ctx)`, assert on the return value or thrown error. The framework's handler factory (try/catch, formatting, telemetry) is not involved. Use `createMockContext` from `@cyanheads/mcp-ts-core/testing` to construct the `ctx` argument.
|
|
15
15
|
|
|
16
|
+
**Additional exports from `/testing`:** `createMockLogger()` returns a standalone `MockContextLogger` for unit-testing code that accepts a `ContextLogger` directly (services, utilities). `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider` for testing services that take a `StorageService` dependency.
|
|
17
|
+
|
|
16
18
|
**Philosophy:** Test behavior, not implementation. Refactors should not break tests. Match the repo's existing test layout: fresh scaffolds use `tests/`, while colocated `src/**/*.test.ts` files are also supported. Integration tests at I/O boundaries over unit tests of internals.
|
|
17
19
|
|
|
18
20
|
---
|
|
@@ -43,9 +45,12 @@ interface MockContextOptions {
|
|
|
43
45
|
auth?: AuthContext;
|
|
44
46
|
elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
|
|
45
47
|
errors?: readonly ErrorContract[];
|
|
48
|
+
notifyPromptListChanged?: () => void;
|
|
46
49
|
notifyResourceListChanged?: () => void;
|
|
47
50
|
notifyResourceUpdated?: (uri: string) => void;
|
|
51
|
+
notifyToolListChanged?: () => void;
|
|
48
52
|
progress?: boolean;
|
|
53
|
+
sessionId?: string;
|
|
49
54
|
requestId?: string;
|
|
50
55
|
sample?: (messages: SamplingMessage[], opts?: SamplingOpts) => Promise<CreateMessageResult>;
|
|
51
56
|
signal?: AbortSignal;
|
|
@@ -60,8 +65,11 @@ interface MockContextOptions {
|
|
|
60
65
|
| `auth` | Sets `ctx.auth` for scope-checking tests |
|
|
61
66
|
| `elicit` | Assigns a function to `ctx.elicit` for testing elicitation calls |
|
|
62
67
|
| `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly. |
|
|
68
|
+
| `notifyPromptListChanged` | Assigns `ctx.notifyPromptListChanged` for prompt-list change notification tests |
|
|
63
69
|
| `notifyResourceListChanged` | Assigns `ctx.notifyResourceListChanged` for resource notification tests |
|
|
64
70
|
| `notifyResourceUpdated` | Assigns `ctx.notifyResourceUpdated` for resource update notification tests |
|
|
71
|
+
| `notifyToolListChanged` | Assigns `ctx.notifyToolListChanged` for tool-list change notification tests |
|
|
72
|
+
| `sessionId` | Sets `ctx.sessionId` for handlers that branch on session ID |
|
|
65
73
|
| `progress` | Populates `ctx.progress` with real state-tracking implementation (see below) |
|
|
66
74
|
| `requestId` | Overrides `ctx.requestId` (default: `'test-request-id'`) |
|
|
67
75
|
| `sample` | Assigns a function to `ctx.sample` for testing sampling calls |
|
|
@@ -93,7 +101,7 @@ expect(progress._messages).toContain('step message');
|
|
|
93
101
|
|
|
94
102
|
### Mock logger
|
|
95
103
|
|
|
96
|
-
`ctx.log` captures all log calls for inspection.
|
|
104
|
+
`ctx.log` captures all log calls for inspection. Import `MockContextLogger` from `@cyanheads/mcp-ts-core/testing` and cast `ctx.log` to access the `.calls` array (the cast is necessary because `createMockContext` returns `Context`, which types `log` as `ContextLogger`):
|
|
97
105
|
|
|
98
106
|
```ts
|
|
99
107
|
import { createMockContext, type MockContextLogger } from '@cyanheads/mcp-ts-core/testing';
|
|
@@ -29,7 +29,7 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
|
|
|
29
29
|
|
|
30
30
|
| Export | API | Notes |
|
|
31
31
|
|:-------|:----|:------|
|
|
32
|
-
| `fetchWithTimeout` | `(url, timeoutMs, context: RequestContext, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean` and `signal?: AbortSignal` (external cancellation). SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node
|
|
32
|
+
| `fetchWithTimeout` | `(url, timeoutMs, context: RequestContext, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean` and `signal?: AbortSignal` (external cancellation). SSRF 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. |
|
|
33
33
|
| `withRetry` | `<T>(fn: () => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. `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
34
|
| `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`. 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
35
|
| `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. |
|