@cyanheads/mcp-ts-core 0.7.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +22 -7
- package/README.md +2 -2
- package/changelog/0.8.x/0.8.0.md +31 -0
- package/dist/core/context.d.ts +67 -0
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +46 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/linter/rules/error-contract-rules.d.ts +45 -0
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -0
- package/dist/linter/rules/error-contract-rules.js +321 -0
- package/dist/linter/rules/error-contract-rules.js.map +1 -0
- package/dist/linter/rules/handler-body-rules.d.ts +18 -0
- package/dist/linter/rules/handler-body-rules.d.ts.map +1 -0
- package/dist/linter/rules/handler-body-rules.js +134 -0
- package/dist/linter/rules/handler-body-rules.js.map +1 -0
- package/dist/linter/rules/index.d.ts +2 -0
- package/dist/linter/rules/index.d.ts.map +1 -1
- package/dist/linter/rules/index.js +2 -0
- package/dist/linter/rules/index.js.map +1 -1
- package/dist/linter/rules/resource-rules.d.ts.map +1 -1
- package/dist/linter/rules/resource-rules.js +9 -0
- package/dist/linter/rules/resource-rules.js.map +1 -1
- package/dist/linter/rules/source-text.d.ts +19 -0
- package/dist/linter/rules/source-text.d.ts.map +1 -0
- package/dist/linter/rules/source-text.js +96 -0
- package/dist/linter/rules/source-text.js.map +1 -0
- package/dist/linter/rules/tool-rules.d.ts.map +1 -1
- package/dist/linter/rules/tool-rules.js +9 -0
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/logs/combined.log +4 -4
- package/dist/logs/error.log +4 -4
- package/dist/mcp-server/apps/appBuilders.d.ts +9 -4
- package/dist/mcp-server/apps/appBuilders.d.ts.map +1 -1
- package/dist/mcp-server/apps/appBuilders.js +4 -0
- package/dist/mcp-server/apps/appBuilders.js.map +1 -1
- package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
- package/dist/mcp-server/resources/resource-registration.js +3 -2
- package/dist/mcp-server/resources/resource-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceDefinition.d.ts +13 -5
- package/dist/mcp-server/resources/utils/resourceDefinition.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceDefinition.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -4
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +13 -7
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts +64 -16
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.js +25 -11
- package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +6 -4
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +5 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/types-global/errors.d.ts +82 -0
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/types-global/errors.js +25 -0
- package/dist/types-global/errors.js.map +1 -1
- package/dist/utils/formatting/index.d.ts +1 -0
- package/dist/utils/formatting/index.d.ts.map +1 -1
- package/dist/utils/formatting/index.js +1 -0
- package/dist/utils/formatting/index.js.map +1 -1
- package/dist/utils/formatting/partialResult.d.ts +145 -0
- package/dist/utils/formatting/partialResult.d.ts.map +1 -0
- package/dist/utils/formatting/partialResult.js +145 -0
- package/dist/utils/formatting/partialResult.js.map +1 -0
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/network/httpError.d.ts +112 -0
- package/dist/utils/network/httpError.d.ts.map +1 -0
- package/dist/utils/network/httpError.js +153 -0
- package/dist/utils/network/httpError.js.map +1 -0
- package/dist/utils/network/retry.d.ts.map +1 -1
- package/dist/utils/network/retry.js +0 -1
- package/dist/utils/network/retry.js.map +1 -1
- package/package.json +5 -4
- package/scripts/split-changelog.ts +133 -0
- package/skills/add-app-tool/SKILL.md +12 -0
- package/skills/add-resource/SKILL.md +40 -0
- package/skills/add-service/SKILL.md +47 -0
- package/skills/add-test/SKILL.md +39 -0
- package/skills/add-tool/SKILL.md +39 -4
- package/skills/api-context/SKILL.md +75 -1
- package/skills/api-errors/SKILL.md +162 -4
- package/skills/api-linter/SKILL.md +223 -3
- package/skills/api-testing/SKILL.md +79 -4
- package/skills/api-utils/SKILL.md +4 -2
- package/skills/design-mcp-server/SKILL.md +13 -10
- package/skills/field-test/SKILL.md +8 -2
- package/skills/maintenance/SKILL.md +2 -2
- package/skills/report-issue-framework/SKILL.md +2 -2
- package/skills/security-pass/SKILL.md +6 -5
- package/templates/AGENTS.md +23 -8
- package/templates/CLAUDE.md +23 -8
|
@@ -24,6 +24,7 @@ import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
|
|
|
24
24
|
|
|
25
25
|
createMockContext() // minimal — ctx.state operations throw without tenantId
|
|
26
26
|
createMockContext({ tenantId: 'test-tenant' }) // enables ctx.state (tenant-scoped in-memory storage)
|
|
27
|
+
createMockContext({ errors: myTool.errors }) // attaches typed ctx.fail keyed by the contract reasons
|
|
27
28
|
createMockContext({ sample: vi.fn().mockResolvedValue(...) }) // with MCP sampling
|
|
28
29
|
createMockContext({ elicit: vi.fn().mockResolvedValue(...) }) // with elicitation
|
|
29
30
|
createMockContext({ progress: true }) // with task progress (ctx.progress populated)
|
|
@@ -41,6 +42,7 @@ createMockContext({ uri: new URL('myscheme://item/123') }) // for resource han
|
|
|
41
42
|
interface MockContextOptions {
|
|
42
43
|
auth?: AuthContext;
|
|
43
44
|
elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
|
|
45
|
+
errors?: readonly ErrorContract[];
|
|
44
46
|
notifyResourceListChanged?: () => void;
|
|
45
47
|
notifyResourceUpdated?: (uri: string) => void;
|
|
46
48
|
progress?: boolean;
|
|
@@ -57,6 +59,7 @@ interface MockContextOptions {
|
|
|
57
59
|
| _(none)_ | Minimal context — `ctx.state` operations throw without `tenantId`; `ctx.elicit`/`ctx.sample`/`ctx.progress` are `undefined` |
|
|
58
60
|
| `auth` | Sets `ctx.auth` for scope-checking tests |
|
|
59
61
|
| `elicit` | Assigns a function to `ctx.elicit` for testing elicitation calls |
|
|
62
|
+
| `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly. |
|
|
60
63
|
| `notifyResourceListChanged` | Assigns `ctx.notifyResourceListChanged` for resource notification tests |
|
|
61
64
|
| `notifyResourceUpdated` | Assigns `ctx.notifyResourceUpdated` for resource update notification tests |
|
|
62
65
|
| `progress` | Populates `ctx.progress` with real state-tracking implementation (see below) |
|
|
@@ -90,13 +93,13 @@ expect(progress._messages).toContain('step message');
|
|
|
90
93
|
|
|
91
94
|
### Mock logger
|
|
92
95
|
|
|
93
|
-
`ctx.log` captures all log calls for inspection:
|
|
96
|
+
`ctx.log` captures all log calls for inspection. The mock returns the typed `MockContextLogger` from `@cyanheads/mcp-ts-core/testing` — import that instead of hand-casting:
|
|
94
97
|
|
|
95
98
|
```ts
|
|
99
|
+
import { createMockContext, type MockContextLogger } from '@cyanheads/mcp-ts-core/testing';
|
|
100
|
+
|
|
96
101
|
const ctx = createMockContext();
|
|
97
|
-
const log = ctx.log as
|
|
98
|
-
calls: Array<{ level: string; msg: string; data?: unknown }>;
|
|
99
|
-
};
|
|
102
|
+
const log = ctx.log as MockContextLogger;
|
|
100
103
|
|
|
101
104
|
await myTool.handler(input, ctx);
|
|
102
105
|
expect(log.calls.some(c => c.level === 'info' && c.msg.includes('Processing'))).toBe(true);
|
|
@@ -311,3 +314,75 @@ it('throws NotFound for missing resource', async () => {
|
|
|
311
314
|
```
|
|
312
315
|
|
|
313
316
|
Use `.rejects.toThrow(McpError)` to assert type only. Use `.rejects.toMatchObject({ code: ... })` when the specific error code matters.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Testing handlers with `errors[]` (typed contract)
|
|
321
|
+
|
|
322
|
+
Tools and resources that declare an `errors[]` contract receive a typed `ctx.fail` helper at runtime. Pass the definition's own `errors` to `createMockContext` and the mock wires `fail` the same way the production handler factory does:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
|
|
326
|
+
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
327
|
+
import { fetchItems } from '@/mcp-server/tools/definitions/fetch-items.tool.js';
|
|
328
|
+
|
|
329
|
+
it('throws ctx.fail("no_match") when no items resolve', async () => {
|
|
330
|
+
const ctx = createMockContext({ errors: fetchItems.errors });
|
|
331
|
+
|
|
332
|
+
const input = fetchItems.input.parse({ ids: ['missing'] });
|
|
333
|
+
await expect(fetchItems.handler(input, ctx)).rejects.toMatchObject({
|
|
334
|
+
code: JsonRpcErrorCode.NotFound,
|
|
335
|
+
data: { reason: 'no_match' },
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
For lower-level tests that need the raw `fail` helper without a full mock context (e.g. asserting the reason → code mapping), use `createFail` directly — see [Testing the handler-side `fail` plumbing](#testing-the-handler-side-fail-plumbing) below.
|
|
341
|
+
|
|
342
|
+
### Why test `data.reason` and not just `code`?
|
|
343
|
+
|
|
344
|
+
The contract reason is the stable machine-readable identifier — clients switch on it the same way they would on an HTTP status. A code alone (`NotFound`) doesn't disambiguate between contract entries that share a code (`'no_match'` vs `'withdrawn'` both mapping to `NotFound`). Asserting on `data.reason` locks the test to the specific contract entry.
|
|
345
|
+
|
|
346
|
+
### `data.reason` is overridable-proof
|
|
347
|
+
|
|
348
|
+
The framework spreads caller-supplied data first and writes `reason` last, so a handler that passes `data: { reason: 'something_else' }` cannot override the contract reason. Tests can rely on `data.reason` always equaling the contract entry's reason — write assertions that depend on it without paranoia.
|
|
349
|
+
|
|
350
|
+
### Testing the handler-side `fail` plumbing
|
|
351
|
+
|
|
352
|
+
To verify the definition wires `ctx.fail` correctly without exercising the full handler factory, use the `errors` array directly:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
import { createFail } from '@cyanheads/mcp-ts-core';
|
|
356
|
+
|
|
357
|
+
it('builds an error with the contract code and reason', () => {
|
|
358
|
+
const fail = createFail(myTool.errors!);
|
|
359
|
+
const err = fail('no_match', 'not found', { itemId: '123' });
|
|
360
|
+
expect(err.code).toBe(JsonRpcErrorCode.NotFound);
|
|
361
|
+
expect(err.data).toEqual({ reason: 'no_match', itemId: '123' });
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Fuzz testing
|
|
368
|
+
|
|
369
|
+
For schema-heavy or input-validation-critical handlers, the framework ships fuzz helpers under `@cyanheads/mcp-ts-core/testing/fuzz`. They generate valid + adversarial inputs from your Zod schemas via `fast-check` and assert handler invariants (no crashes, no prototype pollution, no stack-trace leaks).
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { fuzzTool, fuzzResource, fuzzPrompt } from '@cyanheads/mcp-ts-core/testing/fuzz';
|
|
373
|
+
|
|
374
|
+
it('survives fuzz testing', async () => {
|
|
375
|
+
const report = await fuzzTool(myTool, { numRuns: 100, numAdversarial: 30 });
|
|
376
|
+
expect(report.crashes).toHaveLength(0);
|
|
377
|
+
expect(report.leaks).toHaveLength(0);
|
|
378
|
+
expect(report.prototypePollution).toBe(false);
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
| Helper | Purpose |
|
|
383
|
+
|:-------|:--------|
|
|
384
|
+
| `fuzzTool(def, opts)` / `fuzzResource(def, opts)` / `fuzzPrompt(def, opts)` | Drive valid + adversarial inputs through the handler. Returns a `FuzzReport`. |
|
|
385
|
+
| `zodToArbitrary(schema)` | Convert a Zod schema to a `fast-check` `Arbitrary` for custom property-based tests. |
|
|
386
|
+
| `adversarialArbitrary()` / `ADVERSARIAL_STRINGS` | Targeted injection sets (prototype pollution probes, control characters, oversized payloads). |
|
|
387
|
+
|
|
388
|
+
`FuzzOptions`: `numRuns` (default 50), `numAdversarial` (default 30), `seed` (reproducibility), `timeout` (per-call ms, default 5000), `ctx` (`MockContextOptions` for stateful handlers).
|
|
@@ -30,7 +30,9 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
|
|
|
30
30
|
| Export | API | Notes |
|
|
31
31
|
|:-------|:----|:------|
|
|
32
32
|
| `fetchWithTimeout` | `(url, timeoutMs, context: RequestContext, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean` and `signal?: AbortSignal` (external cancellation). SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node; hostname-only on Workers. Manual redirect following (max 5) with per-hop SSRF check. **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
|
-
| `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.
|
|
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
|
+
| `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
|
+
| `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. |
|
|
34
36
|
|
|
35
37
|
---
|
|
36
38
|
|
|
@@ -101,7 +103,7 @@ The `utils` export includes two type guards. The full set of guards lives in the
|
|
|
101
103
|
|
|
102
104
|
| Export | API | Notes |
|
|
103
105
|
|:-------|:----|:------|
|
|
104
|
-
| `ErrorHandler` | `.tryCatch<T>(fn, opts) -> Promise<T>` `.handleError(error, opts) -> Error` `.determineErrorCode(error) -> JsonRpcErrorCode` `.mapError(error, mappings, defaultFactory?) -> T \| Error` `.formatError(error) -> Record<string, unknown>` | Service-level error handling. `tryCatch` wraps async or sync `fn`, logs via `handleError`, and always rethrows. No `.tryCatchSync()`. Use in services, NOT in tool handlers (those throw raw `McpError`).
|
|
106
|
+
| `ErrorHandler` | `.tryCatch<T>(fn, opts) -> Promise<T>` `.handleError(error, opts) -> Error` `.classifyOnly(error) -> { code, message, data? }` `.determineErrorCode(error) -> JsonRpcErrorCode` `.mapError(error, mappings, defaultFactory?) -> T \| Error` `.formatError(error) -> Record<string, unknown>` | Service-level error handling. `tryCatch` wraps async or sync `fn`, logs via `handleError`, and always rethrows. No `.tryCatchSync()`. Use in services, NOT in tool handlers (those throw raw `McpError`). `tryCatch` accepts `Omit<ErrorHandlerOptions, 'rethrow'>` — required: `operation`. Optional: `context`, `errorCode`, `input`, `includeStack`, `critical`, `errorMapper`. `handleError` accepts the full `ErrorHandlerOptions` including `rethrow`. |
|
|
105
107
|
|
|
106
108
|
---
|
|
107
109
|
|
|
@@ -83,7 +83,7 @@ The user-goal list shapes the tool surface; the operation list fills in the gaps
|
|
|
83
83
|
| Primitive | Use when | Examples |
|
|
84
84
|
|:----------|:---------|:--------|
|
|
85
85
|
| **Tool** | The default. Any operation or data access an agent needs to accomplish the server's purpose. | Search, create, update, analyze, fetch-by-ID, list reference data |
|
|
86
|
-
| **App Tool** |
|
|
86
|
+
| **App Tool** | **Rare — default to a standard tool.** Only when a human will actively interact with the result in real time *and* the target client supports MCP Apps. Most clients are tool-only and most agent workflows are read-by-LLM, not viewed-by-human. App tools add an iframe + CSP, `app.ontoolresult`/`callServerTool` plumbing, host-context wiring, and a `format()` text twin that still has to be content-complete (since most clients only see that). Two surfaces to keep in sync, two failure modes per change. | Dense tabular state a human scrubs through; form-based human approval in an MCP Apps-capable client |
|
|
87
87
|
| **Resource** | *Additionally* expose as a resource when the data is addressable by stable URI, read-only, and useful as injectable context. | Config, schemas, status, entity-by-ID lookups |
|
|
88
88
|
| **Prompt** | Reusable message template that structures how the LLM approaches a task | Analysis framework, report template, review checklist |
|
|
89
89
|
| **Neither** | Internal detail, admin-only, not useful to an LLM | Token refresh, webhook setup, migrations |
|
|
@@ -321,7 +321,9 @@ The pattern: name the shortcut for what it does (`text_search`, `name_search`),
|
|
|
321
321
|
|
|
322
322
|
#### Error design
|
|
323
323
|
|
|
324
|
-
Errors are part of the tool's interface — design them during the design phase, not as an afterthought.
|
|
324
|
+
Errors are part of the tool's interface — design them during the design phase, not as an afterthought. Three aspects: **the contract** (which failures are public), **classification** (what error code), and **messaging** (what the LLM reads).
|
|
325
|
+
|
|
326
|
+
**Declare a typed contract for domain failures.** When a tool has known failure modes the agent should plan around (`no_match`, `queue_full`, `vendor_down`), enumerate them as `errors: [{ reason, code, when, retryable? }]` on the definition. The framework publishes the contract under `tools/list` `_meta['mcp-ts-core/errors']` so capable clients can preview failure modes, types `ctx.fail(reason, …)` against the declared reason union (typos become TS errors), and auto-populates `_meta.error.data.reason` on responses for stable observability. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble from anywhere and don't need to be enumerated. See `api-errors` skill for the full pattern.
|
|
325
327
|
|
|
326
328
|
**Classify errors by origin.** Different error sources need different codes and different recovery guidance. Map the failure modes for each tool during design:
|
|
327
329
|
|
|
@@ -333,7 +335,7 @@ Errors are part of the tool's interface — design them during the design phase,
|
|
|
333
335
|
| **Auth/permissions** | Insufficient scopes, expired token | `Forbidden` / `Unauthorized` | Maybe — escalate or re-auth |
|
|
334
336
|
| **Server internal** | Parse failure, missing config, unexpected state | `InternalError` | No — server-side issue |
|
|
335
337
|
|
|
336
|
-
The framework auto-classifies many of these at runtime (HTTP status codes, JS error types, common patterns), but explicit classification in the handler gives better error messages.
|
|
338
|
+
The framework auto-classifies many of these at runtime (HTTP status codes, JS error types, common patterns), but explicit classification in the handler gives better error messages. For declared contract failures, throw via `ctx.fail('reason', …)`. For ad-hoc throws outside the contract, use error factories (`notFound()`, `validationError()`, etc.) when the code matters; plain `throw new Error()` when the framework's auto-classification is good enough.
|
|
337
339
|
|
|
338
340
|
**Write error messages as recovery instructions.** The message is the agent's only signal for what to do next.
|
|
339
341
|
|
|
@@ -354,7 +356,7 @@ throw forbidden(
|
|
|
354
356
|
throw notFound(`Paper '${id}' not found on arXiv. Verify the ID format (e.g., '2401.12345' or '2401.12345v2').`);
|
|
355
357
|
```
|
|
356
358
|
|
|
357
|
-
**During design, list the expected failure modes for each tool
|
|
359
|
+
**During design, list the expected failure modes for each tool** with the reason, code, and when-clause that will land in the contract. Include these in the tool's section of the design doc — they become the literal `errors: [...]` entries during scaffolding and inform recovery messaging. Not every failure needs a contract entry; baseline infrastructure errors (5xx, timeouts, validation) are fine to let bubble.
|
|
358
360
|
|
|
359
361
|
#### Design table
|
|
360
362
|
|
|
@@ -367,7 +369,7 @@ Summarize each tool:
|
|
|
367
369
|
| **Description** | Concrete capability statement. Add operational guidance (prerequisites, constraints, gotchas) when non-obvious. |
|
|
368
370
|
| **Input schema** | `.describe()` on every field. Constrained types (enums, literals, regex). Explain costs/tradeoffs of parameter choices. |
|
|
369
371
|
| **Output schema** | Designed for the LLM's next action. Include chaining IDs. Communicate filtering. Post-write state where useful. |
|
|
370
|
-
| **Error messages
|
|
372
|
+
| **Errors** | Declare domain failure modes as a typed contract (`errors: [{ reason, code, when, retryable? }]`) so `ctx.fail` is type-checked and capable clients can preview failures via `tools/list`. Error messages name what went wrong and what the LLM should do about it. |
|
|
371
373
|
| **Annotations** | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Helps clients auto-approve safely. |
|
|
372
374
|
| **Auth scopes** | `tool:<snake_tool_name>:<verb>` or `resource:<kebab-resource-name>:<verb>` (e.g., `tool:inventory_search:read`, `resource:echo-app-ui:read`). Domain-led `<domain>:<verb>` (e.g., `inventory:read`) is an acceptable alternative — pick one convention per server and stay consistent. Skip for read-only or stdio-only servers. |
|
|
373
375
|
|
|
@@ -399,7 +401,7 @@ Skip for purely data/action-oriented servers.
|
|
|
399
401
|
|
|
400
402
|
**Server-as-service.** When the server IS the source of truth (knowledge graph, in-memory task tracker, local scratchpad, embedded inference wrapper), the resilience table below doesn't apply — there's no upstream to retry. The design questions shift to state management: what's tenant-scoped vs. global, what TTLs apply, what survives a restart, what the storage backend is. Plan persistence via `ctx.state` for tenant-scoped KV (auto-namespaced by `tenantId`), or use a `StorageService` provider directly when data must cross tenants. Service init still happens in `setup()`, accessed via `getMyService()` at request time. Calls within the server are local and synchronous-ish — the API-efficiency table below also doesn't apply.
|
|
401
403
|
|
|
402
|
-
For services wrapping external APIs, plan the resilience layer.
|
|
404
|
+
For services wrapping external APIs, plan the resilience layer.
|
|
403
405
|
|
|
404
406
|
| Concern | Decision |
|
|
405
407
|
|:--------|:---------|
|
|
@@ -507,9 +509,9 @@ Execute the plan using the scaffolding skills:
|
|
|
507
509
|
|
|
508
510
|
1. `add-service` for each service
|
|
509
511
|
2. `add-tool` for each standard tool
|
|
510
|
-
3. `add-
|
|
511
|
-
4. `add-
|
|
512
|
-
5. `add-
|
|
512
|
+
3. `add-resource` for each standalone resource
|
|
513
|
+
4. `add-prompt` for each prompt
|
|
514
|
+
5. `add-app-tool` *only if any app tools survived the design step* (rare — see the App Tool row in Step 3)
|
|
513
515
|
6. `devcheck` after each addition
|
|
514
516
|
|
|
515
517
|
## Checklist
|
|
@@ -529,6 +531,7 @@ Items without an `If …:` prefix apply to every design. Conditional items only
|
|
|
529
531
|
- [ ] Output schemas designed for LLM's next action — chaining IDs, post-write state, filtering communicated
|
|
530
532
|
- [ ] `format()` renders all data the LLM needs — different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data, not just a count or title
|
|
531
533
|
- [ ] Error messages guide recovery — name what went wrong and what to do next
|
|
534
|
+
- [ ] **If a tool has known domain failure modes:** typed error contract declared (`errors: [{ reason, code, when, retryable? }]`) so `ctx.fail` is type-checked and capable clients see failures via `tools/list`
|
|
532
535
|
- [ ] Annotations set correctly (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`)
|
|
533
536
|
- [ ] Design doc written to `docs/design.md`
|
|
534
537
|
- [ ] Design confirmed with user (or user pre-authorized implementation)
|
|
@@ -537,7 +540,7 @@ Items without an `If …:` prefix apply to every design. Conditional items only
|
|
|
537
540
|
- [ ] **If state-aware procedural guidance adds value:** instruction tool considered with `nextToolSuggestions` pre-filled from diagnostics
|
|
538
541
|
- [ ] **If workflow tools have destructive modes:** destructive arm guarded by `ctx.elicit` when available, with `destructiveHint` annotation as fallback for non-interactive clients
|
|
539
542
|
- [ ] **If a parameter determines blast radius:** safe default set (e.g., `mode: 'preview'`, `dryRun: true`, `confirmCount` required)
|
|
540
|
-
- [ ] **If
|
|
543
|
+
- [ ] **App tools default to no.** If one was proposed, verified there's a real human-in-the-loop in an MCP Apps-capable client justifying the iframe/CSP/`format()`-twin maintenance cost — otherwise dropped in favor of a standard tool
|
|
541
544
|
- [ ] **If the server exposes resources:** URIs use `{param}` templates, pagination planned for large lists
|
|
542
545
|
- [ ] **If the server has external deps or shared state:** service layer planned (or explicitly skipped with reasoning)
|
|
543
546
|
- [ ] **If services wrap external APIs:** resilience planned (retry boundary, backoff, parse classification)
|
|
@@ -132,8 +132,8 @@ Runs `initialize`, captures the session id, sends `notifications/initialized`.
|
|
|
132
132
|
|
|
133
133
|
```bash
|
|
134
134
|
. /tmp/mcp-field-test.sh
|
|
135
|
-
mcp_call tools/list | jq '.result.tools[] | {name, description, inputSchema, outputSchema}'
|
|
136
|
-
mcp_call resources/list | jq '.result.resources[] | {uri, name, mimeType}'
|
|
135
|
+
mcp_call tools/list | jq '.result.tools[] | {name, description, inputSchema, outputSchema, errors: ._meta["mcp-ts-core/errors"]}'
|
|
136
|
+
mcp_call resources/list | jq '.result.resources[] | {uri, name, mimeType, errors: ._meta["mcp-ts-core/errors"]}'
|
|
137
137
|
mcp_call prompts/list | jq '.result.prompts[] | {name, description, arguments}'
|
|
138
138
|
```
|
|
139
139
|
|
|
@@ -171,6 +171,8 @@ Treat any hit as a `ux` finding in the report. The authoring rule lives under *T
|
|
|
171
171
|
| Hits external API / live upstream | One call that exercises upstream; note rate-limit / timeout / transient-failure behavior |
|
|
172
172
|
| Chained with other tools (search → detail → act) | Run one representative chain end-to-end; does each step return the IDs/cursors the next needs? |
|
|
173
173
|
| `cursor` / `offset` / `limit` params | Pagination: second page, end-of-list |
|
|
174
|
+
| Tool declared an `errors: [...]` contract | Error contract (tool): trigger ≥1 declared failure mode. Verify `result._meta.error.code` matches the contract entry, `result._meta.error.data.reason` is the declared reason (only present when the handler threw an `McpError` — `ctx.fail` always does, plain `throw new Error(...)` does not), and `content[0].text` is actionable. Reasons declared but unreachable from any input are dead contract entries. |
|
|
175
|
+
| Resource declared an `errors: [...]` contract | Error contract (resource): trigger ≥1 declared failure mode by reading a URI that exercises it. Resources re-throw errors at the JSON-RPC level — verify `error.code` matches the contract entry and `error.data.reason` is the declared reason. (Resources don't use the `result.isError` envelope — they fail the request itself.) |
|
|
174
176
|
|
|
175
177
|
**Resources.** Happy path, not-found URI, `list` if defined, pagination if used.
|
|
176
178
|
**Prompts.** Happy path, defaults omitted, skim message quality.
|
|
@@ -191,6 +193,8 @@ For each call, capture: input sent, response (trim huge payloads to files), whet
|
|
|
191
193
|
**Interpreting responses**
|
|
192
194
|
|
|
193
195
|
- Tool domain errors return `{result: {content: [...], isError: true}}` — they live in `result`, not `error`. Check `isError`, not the JSON-RPC error field.
|
|
196
|
+
- **Tool error code/reason** rides on `result._meta.error.{code, data.reason}` — inspect that, not just the text. `data` is only spread when the handler threw an `McpError` (or `ZodError`); plain `throw new Error(...)` won't populate `data.reason`. Use `ctx.fail`-thrown errors when the contract reason matters.
|
|
197
|
+
- **Resource errors** are JSON-RPC-level — they appear in the top-level `error.{code, data.reason}` field, not inside `result`. Resource handlers re-throw rather than producing an `isError` envelope.
|
|
194
198
|
- JSON-RPC `error` only appears for protocol issues (bad session, malformed envelope, unknown method).
|
|
195
199
|
- `mcp_call` already strips SSE framing. Pipe to `jq` for readability.
|
|
196
200
|
|
|
@@ -253,6 +257,8 @@ End with:
|
|
|
253
257
|
- [ ] Catalog surfaced and presented; descriptions audited for leaks (implementation details, meta-coaching, consumer-aware phrasing)
|
|
254
258
|
- [ ] Universal battery run on every definition
|
|
255
259
|
- [ ] Situational categories applied only when triggered
|
|
260
|
+
- [ ] **If a tool declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; `result._meta.error.code` and `data.reason` verified against the contract entry
|
|
261
|
+
- [ ] **If a resource declared an `errors: [...]` contract:** ≥1 declared failure mode triggered; top-level JSON-RPC `error.code` and `error.data.reason` verified against the contract entry
|
|
256
262
|
- [ ] External-state / auth-gated tools handled explicitly (run, skip, or confirm)
|
|
257
263
|
- [ ] Server stopped; state file removed
|
|
258
264
|
- [ ] Report: summary paragraph → grouped findings → numbered options
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Investigate, adopt, and verify dependency updates — with special handling for `@cyanheads/mcp-ts-core`. Captures what changed, understands why, cross-references against the codebase, adopts framework improvements, syncs project skills, and runs final checks. Supports two entry modes: run the full flow end-to-end, or review updates you already applied.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.8"
|
|
8
8
|
audience: external
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -65,7 +65,7 @@ Scan specifically for:
|
|
|
65
65
|
|
|
66
66
|
| Area | Adoption Check |
|
|
67
67
|
|:-----|:---------------|
|
|
68
|
-
| New
|
|
68
|
+
| New `/errors` surface — factories, typed contracts (`errors[]` + `ctx.fail`), `httpErrorFromResponse` | Replace ad-hoc `new McpError(...)` with factories; declare `errors: [...]` on tools that surface domain-specific failure modes; route declared throws through `ctx.fail(reason, …)` so the conformance lint is happy |
|
|
69
69
|
| New utilities in `/utils` | Identify any that supersede local helper code |
|
|
70
70
|
| New context capabilities | Added `ctx.*` methods worth adopting |
|
|
71
71
|
| Provider/service APIs | Updates to `OpenRouterProvider`, `SpeechService`, `GraphService`, etc. |
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
File a bug or feature request against @cyanheads/mcp-ts-core when you hit a framework issue. Use when a builder, utility, context method, or config behaves contrary to the documented API — not for server-specific application bugs.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.4"
|
|
8
8
|
audience: external
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -141,7 +141,7 @@ Format: `bug(<scope>): concise description`
|
|
|
141
141
|
| `prompt` | Prompt builder, generate, args |
|
|
142
142
|
| `context` | Context, logger, state, progress, elicit, sample |
|
|
143
143
|
| `config` | AppConfig, parseConfig, env parsing |
|
|
144
|
-
| `errors` | McpError, error factories, auto-classification |
|
|
144
|
+
| `errors` | McpError, error factories, typed contracts (`errors[]` / `ctx.fail`), conformance lint, `httpErrorFromResponse`, auto-classification |
|
|
145
145
|
| `auth` | Auth modes, scope checking, JWT/OAuth |
|
|
146
146
|
| `storage` | StorageService, providers |
|
|
147
147
|
| `transport` | stdio/http transport, SSE, session handling |
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Review an MCP server for common security gaps: LLM-facing surfaces as injection vector (tools, resources, prompts, descriptions), scope blast radius, destructive ops without consent, upstream auth shape, input sinks (URL / path / roots / shell / sampling / schema strictness / ReDoS), tenant isolation, leakage through errors and telemetry, unbounded resources, and HTTP-mode deployment surface. Use before a release, after a batch of handler changes, or when the user asks for a security review, audit, or hardening pass. Produces grouped findings and a numbered options list.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.2"
|
|
8
8
|
audience: external
|
|
9
9
|
type: audit
|
|
10
10
|
---
|
|
@@ -203,10 +203,10 @@ grep -rn "^let " src/services/
|
|
|
203
203
|
|
|
204
204
|
What accidentally reaches the LLM, user, or observability sinks.
|
|
205
205
|
|
|
206
|
-
**Look in:** `throw new McpError(...)` sites, `McpError.data` fields, output schemas, and every logging / telemetry surface — not just `ctx.log`.
|
|
206
|
+
**Look in:** `throw new McpError(...)` and `ctx.fail(reason, msg, data)` sites, error factory calls (`notFound`, `httpErrorFromResponse`, …), `McpError.data` fields (the `data` arg flows through both paths), output schemas, and every logging / telemetry surface — not just `ctx.log`.
|
|
207
207
|
|
|
208
208
|
```bash
|
|
209
|
-
grep -
|
|
209
|
+
grep -rnE "new McpError|ctx\.fail\(|httpErrorFromResponse\(" src/
|
|
210
210
|
grep -rnE "\b(ctx\.log|console\.(log|info|warn|error|debug)|logger\.)" src/
|
|
211
211
|
grep -rnE "(Sentry\.|captureException|setTag|setContext|addBreadcrumb)" src/
|
|
212
212
|
grep -rnE "(setAttribute|setAttributes|span\.)" src/ # OpenTelemetry
|
|
@@ -214,7 +214,8 @@ grep -rnE "(setAttribute|setAttributes|span\.)" src/ # OpenTelemetry
|
|
|
214
214
|
|
|
215
215
|
**Check:**
|
|
216
216
|
|
|
217
|
-
- Error `data` fields carry upstream response bodies, auth headers, stack traces?
|
|
217
|
+
- Error `data` fields (whether passed via `ctx.fail(reason, msg, data)`, `new McpError(code, msg, data)`, or factory calls) carry upstream response bodies, auth headers, stack traces?
|
|
218
|
+
- `httpErrorFromResponse` body capture sweeping in too much (default 500-byte cap is fine for most APIs but consider `captureBody: false` when the upstream returns auth-bearing payloads)?
|
|
218
219
|
- Output schemas include token prefixes, internal IDs, session identifiers?
|
|
219
220
|
- `format()` renders fields that shouldn't leave the server?
|
|
220
221
|
- `ctx.log.info(msg, body)` where `body` is the raw request (may contain secrets)?
|
|
@@ -222,7 +223,7 @@ grep -rnE "(setAttribute|setAttributes|span\.)" src/ # OpenTelemetry
|
|
|
222
223
|
- OpenTelemetry span attributes / Sentry breadcrumbs carry tokens, PII, or full request bodies?
|
|
223
224
|
- Secret / token / HMAC comparisons use `===` or `==` instead of constant-time (`timingSafeEqual` / `crypto.timingSafeEqual`) — leaks length and prefix via timing?
|
|
224
225
|
|
|
225
|
-
**Smell:** `throw new McpError(code, upstream.message, { raw: upstream.body })`. Or: `if (apiKey === expected)` on a request-auth path.
|
|
226
|
+
**Smell:** `throw new McpError(code, upstream.message, { raw: upstream.body })` or `throw ctx.fail('upstream_failed', e.message, { raw: e.response.body })`. Or: `if (apiKey === expected)` on a request-auth path.
|
|
226
227
|
|
|
227
228
|
#### Axis 8 — Resource bounds
|
|
228
229
|
|
package/templates/AGENTS.md
CHANGED
|
@@ -165,24 +165,39 @@ Handlers receive a unified `ctx` object. Key properties:
|
|
|
165
165
|
|
|
166
166
|
## Errors
|
|
167
167
|
|
|
168
|
-
Handlers throw — the framework catches, classifies, and formats.
|
|
168
|
+
Handlers throw — the framework catches, classifies, and formats.
|
|
169
|
+
|
|
170
|
+
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, retryable? }]` on `tool()` / `resource()` to advertise the failure surface in `tools/list` (under `_meta['mcp-ts-core/errors']`) and receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
|
|
169
171
|
|
|
170
172
|
```ts
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
errors: [
|
|
174
|
+
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No item matched the query' },
|
|
175
|
+
],
|
|
176
|
+
async handler(input, ctx) {
|
|
177
|
+
const item = await db.find(input.id);
|
|
178
|
+
if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
|
|
179
|
+
return item;
|
|
180
|
+
}
|
|
181
|
+
```
|
|
174
182
|
|
|
175
|
-
|
|
176
|
-
|
|
183
|
+
**Fallback (no contract entry fits):** throw via factories or plain `Error`.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// Error factories — explicit code
|
|
187
|
+
import { notFound, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
|
|
177
188
|
throw notFound('Item not found', { itemId });
|
|
178
189
|
throw serviceUnavailable('API unavailable', { url }, { cause: err });
|
|
179
190
|
|
|
180
|
-
//
|
|
191
|
+
// Plain Error — framework auto-classifies from message patterns
|
|
192
|
+
throw new Error('Item not found'); // → NotFound
|
|
193
|
+
throw new Error('Invalid query format'); // → ValidationError
|
|
194
|
+
|
|
195
|
+
// McpError — when no factory exists for the code
|
|
181
196
|
import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
182
197
|
throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection failed', { pool: 'primary' });
|
|
183
198
|
```
|
|
184
199
|
|
|
185
|
-
|
|
200
|
+
See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all available factories, and the contract reference.
|
|
186
201
|
|
|
187
202
|
---
|
|
188
203
|
|
package/templates/CLAUDE.md
CHANGED
|
@@ -165,24 +165,39 @@ Handlers receive a unified `ctx` object. Key properties:
|
|
|
165
165
|
|
|
166
166
|
## Errors
|
|
167
167
|
|
|
168
|
-
Handlers throw — the framework catches, classifies, and formats.
|
|
168
|
+
Handlers throw — the framework catches, classifies, and formats.
|
|
169
|
+
|
|
170
|
+
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, retryable? }]` on `tool()` / `resource()` to advertise the failure surface in `tools/list` (under `_meta['mcp-ts-core/errors']`) and receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
|
|
169
171
|
|
|
170
172
|
```ts
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
errors: [
|
|
174
|
+
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No item matched the query' },
|
|
175
|
+
],
|
|
176
|
+
async handler(input, ctx) {
|
|
177
|
+
const item = await db.find(input.id);
|
|
178
|
+
if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
|
|
179
|
+
return item;
|
|
180
|
+
}
|
|
181
|
+
```
|
|
174
182
|
|
|
175
|
-
|
|
176
|
-
|
|
183
|
+
**Fallback (no contract entry fits):** throw via factories or plain `Error`.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// Error factories — explicit code
|
|
187
|
+
import { notFound, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
|
|
177
188
|
throw notFound('Item not found', { itemId });
|
|
178
189
|
throw serviceUnavailable('API unavailable', { url }, { cause: err });
|
|
179
190
|
|
|
180
|
-
//
|
|
191
|
+
// Plain Error — framework auto-classifies from message patterns
|
|
192
|
+
throw new Error('Item not found'); // → NotFound
|
|
193
|
+
throw new Error('Invalid query format'); // → ValidationError
|
|
194
|
+
|
|
195
|
+
// McpError — when no factory exists for the code
|
|
181
196
|
import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
182
197
|
throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection failed', { pool: 'primary' });
|
|
183
198
|
```
|
|
184
199
|
|
|
185
|
-
|
|
200
|
+
See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all available factories, and the contract reference.
|
|
186
201
|
|
|
187
202
|
---
|
|
188
203
|
|