@cyanheads/mcp-ts-core 0.13.11 → 0.13.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -8
- package/CLAUDE.md +9 -8
- package/README.md +1 -1
- package/changelog/0.13.x/0.13.12.md +50 -0
- package/changelog/0.13.x/0.13.13.md +93 -0
- package/dist/core/context.d.ts +12 -0
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +59 -14
- package/dist/core/context.js.map +1 -1
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/core/worker.js +21 -8
- package/dist/core/worker.js.map +1 -1
- package/dist/mcp-server/handlerContext.d.ts +14 -8
- package/dist/mcp-server/handlerContext.d.ts.map +1 -1
- package/dist/mcp-server/handlerContext.js +16 -9
- package/dist/mcp-server/handlerContext.js.map +1 -1
- package/dist/mcp-server/inputRequired.d.ts +18 -9
- package/dist/mcp-server/inputRequired.d.ts.map +1 -1
- package/dist/mcp-server/inputRequired.js +29 -15
- package/dist/mcp-server/inputRequired.js.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.js +10 -7
- package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +29 -19
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +8 -2
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/inputPrevalidation.js +18 -7
- package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +7 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +107 -21
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/mcp-server/transports/auth/lib/authUtils.d.ts +22 -0
- package/dist/mcp-server/transports/auth/lib/authUtils.d.ts.map +1 -1
- package/dist/mcp-server/transports/auth/lib/authUtils.js +29 -1
- package/dist/mcp-server/transports/auth/lib/authUtils.js.map +1 -1
- package/dist/mcp-server/transports/auth/lib/checkScopes.d.ts.map +1 -1
- package/dist/mcp-server/transports/auth/lib/checkScopes.js +2 -1
- package/dist/mcp-server/transports/auth/lib/checkScopes.js.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.js +4 -2
- package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
- package/dist/services/mirror/sqlite/handle.d.ts +13 -2
- package/dist/services/mirror/sqlite/handle.d.ts.map +1 -1
- package/dist/services/mirror/sqlite/handle.js +17 -6
- package/dist/services/mirror/sqlite/handle.js.map +1 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts.map +1 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.js +3 -2
- package/dist/services/mirror/sqlite/sqliteMirrorStore.js.map +1 -1
- package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts.map +1 -1
- package/dist/storage/providers/fileSystem/fileSystemProvider.js +48 -21
- package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/types-global/errors.js +31 -16
- package/dist/types-global/errors.js.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.d.ts +29 -11
- package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.js +204 -95
- package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
- package/dist/utils/internal/error-handler/helpers.d.ts +91 -9
- package/dist/utils/internal/error-handler/helpers.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/helpers.js +243 -39
- package/dist/utils/internal/error-handler/helpers.js.map +1 -1
- package/dist/utils/internal/error-handler/types.d.ts +21 -4
- package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
- package/dist/utils/internal/logValue.d.ts +33 -0
- package/dist/utils/internal/logValue.d.ts.map +1 -0
- package/dist/utils/internal/logValue.js +539 -0
- package/dist/utils/internal/logValue.js.map +1 -0
- package/dist/utils/internal/logger.d.ts +38 -9
- package/dist/utils/internal/logger.d.ts.map +1 -1
- package/dist/utils/internal/logger.js +228 -118
- package/dist/utils/internal/logger.js.map +1 -1
- package/dist/utils/internal/observabilityCap.d.ts +35 -0
- package/dist/utils/internal/observabilityCap.d.ts.map +1 -0
- package/dist/utils/internal/observabilityCap.js +43 -0
- package/dist/utils/internal/observabilityCap.js.map +1 -0
- package/dist/utils/internal/performance.d.ts.map +1 -1
- package/dist/utils/internal/performance.js +9 -11
- package/dist/utils/internal/performance.js.map +1 -1
- package/dist/utils/internal/requestContext.d.ts +3 -3
- package/dist/utils/internal/requestContext.js +1 -1
- package/dist/utils/network/fetchWithTimeout.d.ts +20 -10
- package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
- package/dist/utils/network/fetchWithTimeout.js +112 -30
- package/dist/utils/network/fetchWithTimeout.js.map +1 -1
- package/dist/utils/network/httpError.d.ts +8 -6
- package/dist/utils/network/httpError.d.ts.map +1 -1
- package/dist/utils/network/httpError.js +23 -7
- package/dist/utils/network/httpError.js.map +1 -1
- package/dist/utils/network/retry.d.ts +25 -2
- package/dist/utils/network/retry.d.ts.map +1 -1
- package/dist/utils/network/retry.js +47 -5
- package/dist/utils/network/retry.js.map +1 -1
- package/dist/utils/security/sanitization.d.ts +24 -28
- package/dist/utils/security/sanitization.d.ts.map +1 -1
- package/dist/utils/security/sanitization.js +25 -84
- package/dist/utils/security/sanitization.js.map +1 -1
- package/dist/utils/security/sensitiveFields.d.ts +32 -4
- package/dist/utils/security/sensitiveFields.d.ts.map +1 -1
- package/dist/utils/security/sensitiveFields.js +85 -4
- package/dist/utils/security/sensitiveFields.js.map +1 -1
- package/dist/utils/telemetry/trace.d.ts +3 -1
- package/dist/utils/telemetry/trace.d.ts.map +1 -1
- package/dist/utils/telemetry/trace.js +6 -6
- package/dist/utils/telemetry/trace.js.map +1 -1
- package/framework-skills/api-auth/SKILL.md +3 -1
- package/framework-skills/api-canvas/SKILL.md +2 -2
- package/framework-skills/api-config/SKILL.md +2 -1
- package/framework-skills/api-context/SKILL.md +6 -6
- package/framework-skills/api-errors/SKILL.md +19 -17
- package/framework-skills/api-linter/SKILL.md +11 -10
- package/framework-skills/api-mirror/SKILL.md +3 -1
- package/framework-skills/api-telemetry/SKILL.md +14 -8
- package/framework-skills/api-utils/SKILL.md +6 -6
- package/framework-skills/api-utils/references/security.md +4 -2
- package/framework-skills/field-test/SKILL.md +2 -2
- package/framework-skills/git-wrapup/SKILL.md +3 -3
- package/framework-skills/tool-defs-analysis/SKILL.md +2 -2
- package/package.json +10 -9
- package/scripts/check-framework-antipatterns.ts +3 -2
- package/templates/.env.example +2 -0
- package/templates/tests/tools/echo.tool.test.ts +19 -1
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
API reference for all utilities exported from `@cyanheads/mcp-ts-core/utils`. Use when looking up utility method signatures, options, peer dependencies, or usage patterns.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.17"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -31,14 +31,14 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
|
|
|
31
31
|
|
|
32
32
|
| Export | API | Notes |
|
|
33
33
|
|:-------|:----|:------|
|
|
34
|
-
| `fetchWithTimeout` | `(url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `timeoutMs` bounds the **whole exchange**: on a 2xx carrying a body the returned `Response` is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's `.text()`/`.json()` with the same `Timeout` error the header phase raises. `status`, `statusText`, `headers`, `url`, `redirected`, and `type` carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), `errorBodyLimit?: number` (bytes of a non-2xx body kept, default `500`), `errorHeaders?: string[]` (response headers copied onto `error.data.headers` on a non-2xx — same selector as `httpErrorFromResponse` below; `location` is selectable under `redirect: 'manual'` but does **not** compose with `rejectPrivateIPs`, whose per-hop branch
|
|
35
|
-
| `withRetry` | `<T>(fn: (attempt: RetryAttempt) => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. Honors an upstream `Retry-After` on `data.retryAfter` (delta-seconds or HTTP-date) over exponential backoff, capped at `maxDelayMs`; a requested wait beyond the cap fails fast rather than sleeping. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate), `deadlineMs` (total wall-clock budget — see below). |
|
|
34
|
+
| `fetchWithTimeout` | `(url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `timeoutMs` bounds the **whole exchange**: on a 2xx carrying a body the returned `Response` is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's `.text()`/`.json()` with the same `Timeout` error the header phase raises. `status`, `statusText`, `headers`, `url`, `redirected`, and `type` carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), `errorBodyLimit?: number` (bytes of a non-2xx body kept, default `500`), `errorHeaders?: string[]` (response headers copied onto `error.data.headers` on a non-2xx — same selector as `httpErrorFromResponse` below; `location` is selectable under `redirect: 'manual'` but does **not** compose with `rejectPrivateIPs`, whose per-hop branch follows every 3xx carrying a `Location` before the throw path sees it), and `signal?: AbortSignal` (external cancellation — an abort on it throws `RequestCancelled` (-32011), logged at `info` and outside `withRetry`'s transient set, since the caller is gone and no retry can reach them; an abort whose reason is a `TimeoutError` — `AbortSignal.timeout()`, or an `AbortSignal.any` whose timeout member fired — is a deadline instead, and throws `Timeout` (-32004) with `data.errorSource: 'FetchSignalTimeout'`, distinct from the helper's own `timeoutMs` expiry, `'FetchTimeout'`, and likewise outside `withRetry`'s default transient set, since every retry would reuse the fired signal). Validation rejections carry `data.reason` and a `recovery.hint`: `invalid_url` (not an absolute `http:`/`https:` URL, including a redirect target), `private_address_blocked` (the SSRF guard refused the host by name, literal IP, or DNS answer), `too_many_redirects` (past the 5-hop cap, with `data.maxRedirects`). A redirect hop's rejection is thrown with no record from `fetchWithTimeout`, as on the initial URL, so the caller's record — at a tool's declared `severity` — is the only one. On a non-2xx, `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values; consolidating in a future major); a body over `errorBodyLimit` is captured from both ends — 40% head, 60% tail, joined by `…[N bytes elided]…` — so a diagnostic behind a boilerplate preamble survives the cap, while a body still streaming at the 16 KiB scan ceiling stays head-only with a trailing `…`. SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. **Both resolvers are queried** — `resolve4`/`resolve6` (c-ares) and `lookup` (the system resolver, which is what reads `/etc/hosts`, split DNS, and NSS modules) — and a non-global answer from either rejects. Runtimes differ in which resolver the connection uses (Bun 1.4 moved `net.connect()` on Linux to `getaddrinfo` while leaving `dns.resolve*()` on c-ares), so checking one alone leaves a name the other can see unguarded; each probe settles independently, so a resolver absent from the runtime is skipped rather than fatal. Manual redirect following (max 5) with per-hop SSRF check; a 3xx without `Location` (304 included) is not followed and fails as a non-2xx (`InvalidRequest`, not retried), as it does without the option. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** every thrown message and log record names the request by its origin plus elision markers — `https://api.example.com/sk-key/reverse?lat=1` reads `https://api.example.com/…?…`, and a root URL keeps its bare origin — so a credential in the path (`/bot<token>/…`, webhook URLs) or the query (`?api-key=…`, `?api_key=…`) never reaches the client or the logs. A URL the runtime quotes in its own rejection message is reduced the same way. The actual request still uses the full URL. To tell endpoints apart in the logs, label the call through its context: `fetchWithTimeout(url, ms, withExtra(ctx, { endpoint: 'reverse' }))` (`withExtra` from `/utils`) puts `endpoint` on every record for the call. The label is a field, not part of the message, so calls to one origin share one budget under the logger's per-message rate limit (`MCP_LOG_RATE_LIMIT_THRESHOLD` records per `MCP_LOG_RATE_LIMIT_WINDOW_MS`, default 10 a minute) whatever endpoint they name; repeats past it are dropped and reported later as a `Suppressed N` line. A network-level failure — an unparseable redirect `Location` included — throws `ServiceUnavailable` (`data.errorSource: 'FetchNetworkErrorWrapper'`) with the runtime's rejection as `cause`, so its transport code survives — on the rejection itself under Bun (`ConnectionRefused`), on its `cause` under Node (`ECONNREFUSED`) — and the failure record carries the same `causeChain` field as `withRetry`'s retry record. |
|
|
35
|
+
| `withRetry` | `<T>(fn: (attempt: RetryAttempt) => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. Honors an upstream `Retry-After` on `data.retryAfter` (delta-seconds or HTTP-date) over exponential backoff, capped at `maxDelayMs`; a requested wait beyond the cap fails fast rather than sleeping. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. Each `Retry N/M for <operation>: <message> — waiting Xms` debug record adds `causeChain` to the context's `extra` when the retried error has a cause or a string `code` — `{ name, message, code? }` per node, the error itself first, so a transport code (`ECONNRESET`, `ConnectionRefused`) survives a retry that later succeeds; projections only, never a raw `Error`, a stack, or `McpError.data`. An error with neither logs exactly as before. `<message>`, and the exhausted error's message and name, are the error's own read as text: a Symbol reads `Symbol(…)`, a number its digits, and one that throws on read or is an object — or a thrown value that is an object but not an `Error` — `[Unreadable]`, so what was thrown never fails the retry. **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), `deadlineMs` (total wall-clock budget — see below). |
|
|
36
36
|
| `RetryAttempt` | `{ readonly signal: AbortSignal; readonly remainingMs: number }` | What `fn` receives each attempt. `signal` is `AbortSignal.any` over the `deadlineMs` clock and `options.signal`; `remainingMs` is what is left of the total budget as the attempt starts, never negative and `Number.POSITIVE_INFINITY` when no deadline is set — so `Math.min(perAttemptMs, remainingMs)` is correct either way. A zero-argument `fn` stays assignable, so existing callers compile unchanged. |
|
|
37
37
|
| `deadlineMs` | `RetryOptions` field | One wall-clock budget across every attempt, backoff, and honored `Retry-After` — the bound `maxRetries` plus a per-attempt timeout cannot express. Four 30s attempts outlast a client's 60s request timeout, so the caller gets a transport timeout instead of the server's classified error. **Thread `attempt.signal` into the attempt's I/O** (`fetchWithTimeout(url, Math.min(30_000, remainingMs), ctx, { signal })`) or the deadline overshoots by one in-flight request. Clock is `AbortController` + `setTimeout` (never `AbortSignal.timeout()`, per the Bun realm mismatch), cleared on return — no timer outlives the call. Expiry rejects with `Timeout` (-32004) carrying `data: { reason: 'retry_deadline_exceeded', deadlineMs, elapsedMs, retryAttempts }` and the last attempt's error as `cause`; **one shape for every expiry**, including the per-attempt `Timeout` (`errorSource: 'FetchSignalTimeout'`) the clock's abort raises inside `fetchWithTimeout` and the raw abort reason a mid-backoff expiry would otherwise surface. No `retryable` flag (a narrower call can still succeed) and no `attempt` index (`retryAttempts` carries it). A backoff that would outlast the remaining budget fails fast with the expiry instead of sleeping into a certain timeout; an honored `Retry-After` that would outlast it takes the `maxDelayMs` exit instead — the attempt's error unchanged, `data.retryAfter` intact, since "wait the window the upstream named" is still the caller's action. **Three clocks stay distinct:** a caller abort on `options.signal` keeps precedence — mid-attempt it rethrows the attempt's error unchanged, mid-backoff it rejects with `signal.reason` itself (an `AbortError` `DOMException` for a reason-less `abort()`), and the handler factory reports either as `RequestCancelled` when the request signal is the one that fired — a single attempt's timeout is `Timeout` with `errorSource: 'FetchTimeout'` and no `reason`, and the expiry is `Timeout` with the `reason`. Unset, behavior is identical to before — attempt counts, delays, log lines, and the exhausted-error shape untouched. Bounds **one** ladder: a tool making three upstream calls threads its own remaining budget into each. |
|
|
38
38
|
| `defaultIsTransient` | `(error: unknown) -> boolean` | The predicate `withRetry` uses when `isTransient` is omitted: an `McpError` with a transient code (`ServiceUnavailable`, `Timeout`, `RateLimited`) unless it carries `data.retryable === false`, `data.reason === 'pacer_shed'`, or `data.errorSource === 'FetchSignalTimeout'` (a caller-side deadline that already fired); any non-`McpError` throw is assumed transient. Exported so `isTransient` — which **replaces** the default outright — can compose instead of mirroring the transient set, which drifts silently when the framework's classification changes: `isTransient: (error) => !isMyBudgetRefusal(error) && defaultIsTransient(error)`, or the inverse `defaultIsTransient(error) \|\| isMyRetryableShape(error)`. The transient code set itself stays private (a module-level `Set` an exported binding could be mutated into framework-wide retry behavior). |
|
|
39
39
|
| `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` — full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values), so a consumer can classify either helper's error without knowing which raised it. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. **`error.data` is client-facing** — the framework forwards it verbatim as `structuredContent.error.data` — so the full upstream URL is **omitted by default**: a request URL routinely carries user input, internal identifiers, or an API key in its query string. `includeUrl: true` opts into `data.url` carrying the full `response.url`; with an empty `response.url` no key is added either way, and the message still names the host. Response headers are opt-in on the same footing: `errorHeaders: ['x-ratelimit-remaining-usd', 'x-request-id']` copies the named headers onto `data.headers` under **lowercase** keys — selection is case-insensitive and entries differing only in case collapse to one key, presence follows `Headers.has()` (an empty value is captured as `''`, an absent header adds no key), and a multi-valued field is captured comma-joined as `Headers.get()` returns it. Omitted, empty, or matching nothing, no `headers` key is emitted. `set-cookie` is **never** captured whatever the selector says: it is credential-bearing and `Headers.get()` joins its values into a string that is not a valid reconstruction. Every selected value reaches the client, so never name a header that carries a credential — and a selected `Location` can itself carry a sensitive path, query, or token. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `includeUrl?` (default `false`), `errorHeaders?` (default none), `data?` (extra fields merged into `error.data`, overriding defaults on key collision — a caller's own `url` or `headers` still reaches the wire), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` — both classify codes the same way. A 501 also carries `data.retryable: false`, so retry fails it fast instead of re-asking for a method the upstream does not implement. |
|
|
40
40
|
| `createPacer` | `(options: PacerOptions) -> Pacer` | FIFO queue in front of one rate-limited upstream — the outbound counterpart to `RateLimiter` (`utils/security`), which is inbound, per-caller, and reject-only, so it cannot queue work against an upstream budget. `pacer.run(task, { signal?, maxWaitMs? })` holds `task` until every `limits` window, `minStartGapMs`, `maxConcurrent`, and the cooldown gate allow it, then calls it with the caller's signal. `PacerOptions`: `name` (author-set telemetry label), `limits` (`{ requests, perMs }[]` — each a sliding window over recorded **start** times, so a slow response never widens the rate the upstream sees; all must allow a start), `minStartGapMs` (**not** expressible through `limits`: `{ requests: 10, perMs: 1000 }` permits ten starts in the same millisecond), `maxConcurrent`, `maxQueueDepth` (absolute backpressure for callers passing no `maxWaitMs`; rejects without arming a timer; bounds **waiters only** — an arrival whose slot is open that instant starts without queueing, so `0` means "run when a slot is free, never wait"), `cooldown` (`{ baseMs, maxMs }`). **Shed:** `maxWaitMs` bounds queue time only, never the task. The projected wait is exact over the windows and the gap but a lower bound once `maxConcurrent` binds (a slot frees on an unknowable completion), so enqueue rejects only when that lower bound already exceeds `maxWaitMs` — no false sheds — and a still-queued entry rejects when `maxWaitMs` elapses, unless its slot opens that same instant. The shed error is `rateLimited` (-32003) with `data: { reason: 'pacer_shed', shedKind, retryAfter, queueDepth }`. `shedKind` (`PacerShedKind`) is `queue_full` (the call would wait behind `maxQueueDepth` waiters), `wait_projected` (the enqueue projection exceeds `maxWaitMs`), or `wait_elapsed` (`maxWaitMs` ran out while queued), and the message follows the kind — a `queue_full` shed names the full queue, not a wait budget. `retryAfter` is seconds until a caller joining behind every remaining waiter could start; while `maxConcurrent` is saturated — a release the projection cannot see — it is floored at the longest wait of any queued caller, the shed one included, minimum 1. `queueDepth` is the waiters still queued. **No `retryable: false`** — to the calling agent a shed is an ordinary rate limit (wait `retryAfter`, call again) and that flag would say the opposite; `defaultIsTransient` reads the `reason` instead, so an enclosing `withRetry` fails fast rather than sleeping past the deadline the shed enforces. **Cooldown gate:** a `RateLimited` thrown by the task closes the gate for every queued caller until an absolute instant, `min(max(baseMs · 2^(consecutive−1), retryAfter), maxMs)` — `maxMs` caps both the doubling and an honored `Retry-After`, so a pathological upstream value cannot park the queue. Absent or unparseable `retryAfter` leaves the doubling; any other error leaves the gate open and the count untouched, and a shed (`reason: 'pacer_shed'`) from a pacer nested inside the task is local backpressure, never a gate closure. The first success resets the count, and so does a gate that has stood open for `maxMs`: the next rate limit starts over at `baseMs`, while one arriving sooner — the gate still closed included — keeps doubling, so continuous demand under a sustained limit keeps its capped backoff. **`pacer.cooldown`** samples the gate as `PacerCooldownState` `{ remainingMs, consecutive }`: `remainingMs` is the shared gate, not one rate limit's own computation (rate limits landing together close one gate at the later instant), so a task's rejection handler can report it on the server's own error — the pacer never writes to the task's error. Both stay 0 without `cooldown`. **Composition:** `withRetry(({ signal }) => pacer.run(fn, { signal }), { signal, deadlineMs })` — retry outside, pacer inside, so each attempt re-queues and is re-paced. Because the gate is an absolute instant rather than a duration counted from dequeue, retry's `Retry-After` sleep and the gate overlap in wall-clock instead of summing: the window is waited once, not twice. **Lifecycle:** timers and `AbortSignal` only, process-local; the dispatch timer is `unref()`'d where supported; `dispose()` / `[Symbol.dispose]()` clears it and rejects queued waiters with `RequestCancelled` (in-flight tasks are left to finish) — wire it through `createApp({ teardown })`. On Workers state is per-isolate so the limits bind per isolate, OTel is off so the metrics are inert, and `createWorkerHandler` accepts no `teardown`. Metrics: `mcp.pacer.queue_depth`, `mcp.pacer.wait`, `mcp.pacer.sheds`, `mcp.pacer.cooldowns`, attributed by `mcp.pacer.name` only — see `api-telemetry`. |
|
|
41
|
-
| `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx. A 3xx maps to `InvalidRequest` — it reaches error mapping under `redirect: 'manual'`, where the request as sent cannot be served at this URL, and that code is outside `withRetry`'s transient set since re-issuing returns the same redirect. Use when you need just the code without a `Response` object handy. No status maps to `InternalError` — that code means *this* server failed, which a remote status cannot establish; every 5xx is `ServiceUnavailable` (or `Timeout` for 504) and so picks up `withRetry`'s default transient policy. |
|
|
41
|
+
| `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx. A 3xx maps to `InvalidRequest` — it reaches error mapping when it is not followed (under `redirect: 'manual'`, or with no `Location`, a 304 included), where the request as sent cannot be served at this URL, and that code is outside `withRetry`'s transient set since re-issuing returns the same redirect. Use when you need just the code without a `Response` object handy. No status maps to `InternalError` — that code means *this* server failed, which a remote status cannot establish; every 5xx is `ServiceUnavailable` (or `Timeout` for 504) and so picks up `withRetry`'s default transient policy. |
|
|
42
42
|
|
|
43
43
|
---
|
|
44
44
|
|
|
@@ -85,7 +85,7 @@ The `utils` export includes two type guards. The full set of guards lives in the
|
|
|
85
85
|
| Export | API | Notes |
|
|
86
86
|
|:-------|:----|:------|
|
|
87
87
|
| `Logger` | Class | The `Logger` class itself. Use `Logger.getInstance()` if needed; most consumers use the `logger` singleton. |
|
|
88
|
-
| `logger` | `Logger` instance (wraps Pino). `.debug(msg, ctx?)` `.info(msg, ctx?)` `.notice(msg, ctx?)` `.warning(msg, ctx?)` `.error(msg, errorOrCtx, ctx?)` `.crit(msg, errorOrCtx, ctx?)` `.alert(msg, errorOrCtx, ctx?)` `.emerg(msg, errorOrCtx, ctx?)` `.fatal(msg, errorOrCtx, ctx?)` `.isLevelEnabled(level) -> boolean` | Global structured logger. Use `ctx.log` in handlers instead. `logger` is for lifecycle/background contexts (startup, shutdown, `setup()`). Auto-redacts sensitive fields. The context's `extra` bag is flattened into the record, but a canonical field the context carries (`requestId`, `timestamp`, `traceId`, `spanId`, `sessionId`, `tenantId`, `operation`) always wins over an `extra` key of the same name. Records logged before the framework initializes the logger — anything in `setup()` — are held in a 250-record buffer and replayed once the sinks exist, filtered against the level the logger starts with. `isLevelEnabled(level)` is that filter: `true` when a record at `level` would be written, compared on the RFC 5424 order of all eight levels (a `notice` level drops `info` though pino emits both at `info`, a `crit` level drops `error`), and `true` for every level before initialization. The `ctx.log` mirror to the client is gated by the same check. **Note:** `.error()` and higher accept `(msg, Error, ctx?)` or `(msg, ctx?)` — the second arg is overloaded. `.fatal()` is an alias for `.emerg()`. Full RFC 5424 severity set. |
|
|
88
|
+
| `logger` | `Logger` instance (wraps Pino). `.debug(msg, ctx?)` `.info(msg, ctx?)` `.notice(msg, ctx?)` `.warning(msg, ctx?)` `.error(msg, errorOrCtx, ctx?)` `.crit(msg, errorOrCtx, ctx?)` `.alert(msg, errorOrCtx, ctx?)` `.emerg(msg, errorOrCtx, ctx?)` `.fatal(msg, errorOrCtx, ctx?)` `.isLevelEnabled(level) -> boolean` | Global structured logger. Use `ctx.log` in handlers instead. `logger` is for lifecycle/background contexts (startup, shutdown, `setup()`). Auto-redacts sensitive fields at every depth, matching any run of a key's adjacent words against the sensitive names (`x-api-key` and `accessToken` are redacted, `max_tokens` is not). An `Error` anywhere in the record, the `errorOrCtx` argument included, is written as `type`, `message`, `stack`, a string or `McpError` `code`, an `McpError`'s `data`, and `cause`/`errors` in the same shape — no other own property, and a cause's stack only when it differs from its parent's. Objects are kept through 15 levels: `'[MaxDepth]'` 16 levels down, `'[Circular]'` for a reference back to an enclosing object, `'[Truncated]'` where repeated content — an object reached again through a shared reference, or a string of 1,024+ characters written again — passes about 1,000,000 written characters a record, where one walk passes 400,000 reads, one per object, field, and array element, or where it passes 16 MiB (16,777,216 characters) of strings, field names, and primitives written, repeated or not (a 10 MB string is written whole, a 20 MB one is cut), `'[Unreadable]'` for a value whose read throws (so a log call never throws), and a class instance (`AbortSignal`, `Map`) dropped. The context's `extra` bag is flattened into the record, but a canonical field the context carries (`requestId`, `timestamp`, `traceId`, `spanId`, `sessionId`, `tenantId`, `operation`) always wins over an `extra` key of the same name, and is never redacted at the record root. An `extra` key named after a field the logger writes on the line itself (`level`, `time`, `msg`, `env`, `version`, `pid`, `hostname`, and `err` when an error argument is passed) is written as `data_<name>`, so the line keeps one of each, its own, and is routed by its own `level`. A context or `extra` the logger cannot read at all is written as `context: '[Unreadable]'` or `extra: '[Unreadable]'`. Records logged before the framework initializes the logger — anything in `setup()` — are held in a 250-record buffer and replayed once the sinks exist, filtered against the level the logger starts with. `isLevelEnabled(level)` is that filter: `true` when a record at `level` would be written, compared on the RFC 5424 order of all eight levels (a `notice` level drops `info` though pino emits both at `info`, a `crit` level drops `error`), and `true` for every level before initialization. The `ctx.log` mirror to the client is gated by the same check. **Note:** `.error()` and higher accept `(msg, Error, ctx?)` or `(msg, ctx?)` — the second arg is overloaded. `.fatal()` is an alias for `.emerg()`. Full RFC 5424 severity set. |
|
|
89
89
|
| `McpLogLevel` | Type | Log level union type for typing level variables. |
|
|
90
90
|
|
|
91
91
|
---
|
|
@@ -109,7 +109,7 @@ The `utils` export includes two type guards. The full set of guards lives in the
|
|
|
109
109
|
|
|
110
110
|
| Export | API | Notes |
|
|
111
111
|
|:-------|:----|:------|
|
|
112
|
-
| `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`. The returned error's `data` (client-visible once thrown toward a handler) keeps the caught `McpError`'s own `data` plus `originalErrorName`/`originalMessage
|
|
112
|
+
| `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`. The returned error's `data` (client-visible once thrown toward a handler) keeps the caught `McpError`'s own `data` plus `originalErrorName`/`originalMessage`; the cause chain's `rootCause` and `context` go to the log record only. |
|
|
113
113
|
|
|
114
114
|
---
|
|
115
115
|
|
|
@@ -66,13 +66,15 @@ interface SanitizedPathInfo {
|
|
|
66
66
|
- `sanitizeNumber`: `NaN`/`Infinity` always rejected; out-of-range values silently clamped with debug log
|
|
67
67
|
- `sanitizeForLogging`: deep clones via `structuredClone`; returns `'[Log Sanitization Failed]'` on clone error
|
|
68
68
|
- **Rejection reasons.** Every `ValidationError` carries `data.reason`, and a `data.recovery.hint` wherever the caller can change the input: `invalid_url` (`sanitizeUrl`; the hint names the allowed schemes), `invalid_path` / `path_traversal` / `absolute_path_disallowed` (`sanitizePath`), `invalid_json` / `json_too_large` (`sanitizeJson`; the latter names the byte cap), `invalid_number` (`sanitizeNumber`), and `unsupported_sanitize_context` (`sanitizeString`'s `'javascript'` context, which has no hint — it is a server-code choice)
|
|
69
|
-
- `serializeForLogging`: `sanitizeForLogging`, then `JSON.stringify`, then a cut to at most `maxBytes` UTF-8 bytes on a character boundary — redaction first, so a cut never keeps part of a secret. A truncated `text` is a prefix of the whole serialization and no longer valid JSON; `truncated` says so. Returns a string so a
|
|
69
|
+
- `serializeForLogging`: `sanitizeForLogging`, then `JSON.stringify`, then a cut to at most `maxBytes` UTF-8 bytes on a character boundary — redaction first, so a cut never keeps part of a secret. A truncated `text` is a prefix of the whole serialization and no longer valid JSON; `truncated` says so. Returns a string so a payload deeper than the logger's 16-level field depth is written whole, not cut to `'[MaxDepth]'`. A value `JSON.stringify` rejects (a `bigint`) yields `'[Log Serialization Failed]'`. Backs the failed-call payload record (`LOG_TOOL_FAILURE_PAYLOADS`)
|
|
70
70
|
|
|
71
71
|
### Sensitive fields
|
|
72
72
|
|
|
73
73
|
Pre-populated: `password`, `token`, `secret`, `apiKey`, `credential`, `jwt`, `ssn`, `cvv`, `authorization`, `cookie`, `clientsecret`, `client_secret`, `private_key`, `privatekey`.
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
A key is sensitive when some run of its adjacent words, joined, equals one of these names, case and separators ignored. Words are split at every character other than a letter or digit, at a lowercase letter followed by a capital, at the end of a run of capitals, and around each run of digits, so `apiKey`, `API_KEY`, `APIKey`, `x-api-key`, `accessToken`, `upstream_private_key`, and `apiKey2` match, while `max_tokens`, `MAX_TOKENS`, `prompt_tokens`, and `tokenizer` do not. `sanitizeForLogging` and every log sink — the process log, `interactions.log`, the OTLP export, and the `ctx.log` mirror — use this one matcher.
|
|
76
|
+
|
|
77
|
+
Manage with `setSensitiveFields(fields)` (merges, deduped, lowercased; takes effect on every sink at once) and `getSensitiveFields()`. The correlation fields a log record's context supplies at its root — `requestId`, `sessionId`, `tenantId`, `traceId`, `spanId`, `timestamp`, `operation` — are never redacted. An added name matching one (`session_id`, `id`) still redacts a caller's own key of that name, and the same key on `interactions.log`, which carries no record context. `getSensitivePinoFields()` generates 3-depth pino `redact.paths` patterns from the current list, for a pino instance of your own; the framework logger does not use them.
|
|
76
78
|
|
|
77
79
|
### Usage
|
|
78
80
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Exercise tools, resources, and prompts against a live HTTP server via MCP JSON-RPC over curl. Starts the server, surfaces the catalog, runs real and adversarial inputs, measures every call (bytes, token estimate, wall-clock) and weighs the catalog, renders app tools' views in a headless MCP Apps host, and produces a tight report with concrete findings and numbered follow-up options. Use after adding or modifying definitions, or when the user asks to test, try out, or verify their MCP surface.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.20"
|
|
8
8
|
audience: external
|
|
9
9
|
type: debug
|
|
10
10
|
---
|
|
@@ -371,7 +371,7 @@ mcp_call <url> <sid> prompts/list | jq '.result.prompts[] | {name, descripti
|
|
|
371
371
|
mcp_catalog_size <url> <sid> <protocol>
|
|
372
372
|
```
|
|
373
373
|
|
|
374
|
-
**Weigh the catalog.** `mcp_catalog_size` prints the `tools/list` bytes — the context every client loads per session before a single call — and each tool's entry, largest first, split into description / `inputSchema` / `outputSchema`. Record the total alongside the `instructions=` bytes from Step 2; together they are the per-session tax. The split says where a heavy tool's weight lives: an `outputSchema` narrating every field of a 60-field record is the common surprise, an over-long description the obvious one. Hand the outliers to `tool-defs-analysis`
|
|
374
|
+
**Weigh the catalog.** `mcp_catalog_size` prints the `tools/list` bytes — the context every client loads per session before a single call — and each tool's entry, largest first, split into description / `inputSchema` / `outputSchema`. Record the total alongside the `instructions=` bytes from Step 2; together they are the per-session tax. The split says where a heavy tool's weight lives: an `outputSchema` narrating every field of a 60-field record is the common surprise, an over-long description the obvious one. Hand the outliers to `tool-defs-analysis` rather than trimming blind. Its length-outliers pass weighs the output and enrichment field prose as well as the tool description.
|
|
375
375
|
|
|
376
376
|
Present a compact catalog to the user: each definition's name + 1-line description. Flag vague or missing descriptions as you go — those feed into the report. Use this to build the test plan.
|
|
377
377
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Land working-tree changes as logical commits — the work grouped by concern, topped by a release commit (version bump, changelog, regenerated artifacts). The work commits land first, then the version bump, verification, and the release commit on top. Stops at "committed locally on main" — or, when the project releases through a release PR, at "release branch pushed, PR open". No tag, no push to main, no publish: the release-and-publish skill merges, tags, and ships from here. Distilled from the git_wrapup_instructions protocol.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.29"
|
|
8
8
|
audience: external
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -105,9 +105,9 @@ git commit --only <paths-for-this-concern> -m "<subject>" -m "<body>"
|
|
|
105
105
|
|
|
106
106
|
**The file is the atomic boundary:** NEVER split a single file's working-tree changes across commits, regardless of mechanism — not `git add -p`, not an index-only patch (`git apply --cached`), not editing the file between commits to remove-then-re-add a hunk. When one file serves two concerns, it ships whole in the commit of its dominant concern; a later commit may touch the file again only for changes made AFTER the first commit (the version bump applied in step 4).
|
|
107
107
|
|
|
108
|
-
**Every commit builds and passes its tests on its own.** When a concern changes an exported contract — a service method's return type, a shared helper's signature, a renamed export, a changed query or behavior a consumer's tests assert — the files that consume it AND their tests ride in the same commit, even when they also carry other concerns. Grouping the contract change into one commit and each consumer into its own later commit leaves pushed commits that fail typecheck or the suite alone, and pushed history is never rewritten to repair them. A snapshot can typecheck and still be red: before pushing, check out each work commit's tree (`git stash` is not the tool — extract it with `git archive <sha> | tar -x -C <scratch>`, symlink the project's `node_modules` into it) and run the test script there
|
|
108
|
+
**Every commit builds and passes its tests on its own.** When a concern changes an exported contract — a service method's return type, a shared helper's signature, a renamed export, a changed query or behavior a consumer's tests assert — the files that consume it AND their tests ride in the same commit, even when they also carry other concerns. Grouping the contract change into one commit and each consumer into its own later commit leaves pushed commits that fail typecheck or the suite alone, and pushed history is never rewritten to repair them. A snapshot can typecheck and still be red: before pushing, check out each work commit's tree (`git stash` is not the tool — extract it with `git archive <sha> | tar -x -C <scratch>`, symlink the project's `node_modules` into it) and run the test script there. Run it first on a snapshot of the commit the stack starts from: a test that fails in that baseline too, such as one that resolves paths through the symlinked `node_modules`, fails because of the snapshot method, not the group. Merge only groups whose snapshot fails where the baseline passes.
|
|
109
109
|
|
|
110
|
-
**A dependency bump lands before the commits that use it.** When any later commit in the stack uses something the new versions introduce — a new framework export, a new `tool()` option, a changed signature — `chore(deps)` is the first work commit. It builds on its own: `package.json`, the lockfile, and any source change the upgrade itself forces (a renamed import, a removed option) ride in it, so the commits above it compile against the versions they were written for. Ordered the other way, the adopting commit and every commit up to the bump fail typecheck at their own SHA.
|
|
110
|
+
**A dependency bump lands before the commits that use it.** When any later commit in the stack uses something the new versions introduce — a new framework export, a new `tool()` option, a changed signature — `chore(deps)` is the first work commit. It builds on its own: `package.json`, the lockfile, and any source change the upgrade itself forces (a renamed import, a removed option) ride in it, so the commits above it compile against the versions they were written for. Ordered the other way, the adopting commit and every commit up to the bump fail typecheck at their own SHA. The exception is a `package.json` that also carries another concern's entry pointing at files a deps-first commit would not contain, such as a new `exports` subpath or `files` entry. That snapshot fails, so the dependency changes ride the commit that adds those files, and its body says so.
|
|
111
111
|
|
|
112
112
|
**Subject format:** Conventional Commits, no version in the subject — `feat: hosted server endpoint`, `fix: handle empty SPARQL result sets`, `feat(linter): enrichment contract rules`, `docs: document the enrichment block`, `chore(deps): refresh dev dependencies`.
|
|
113
113
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Read-only audit of MCP definition language across an existing surface — tools, resources, prompts, server instructions. Walks every definition file and checks 16 categories the LLM reads to decide whether and how to call: voice & tense, internal leaks, audience leaks, defaults, recovery hints, field descriptions, cross-references, sparsity, examples, structure, mutator observability, unit-bearing numeric names, validator-enforced constraints, annotations truthfulness, single-line strings, exclusive modes in the schema — then a cross-surface pass: naming taxonomy, parameter vocabulary, tool overlap, instructions drift, length outliers. Produces grouped findings with file:line citations and a numbered options list. Use during polish, after a refactor, or before a release. Complements `field-test` (behavior testing) and `security-pass` (security audit).
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.8"
|
|
8
8
|
audience: external
|
|
9
9
|
type: audit
|
|
10
10
|
---
|
|
@@ -219,7 +219,7 @@ The per-file walk misses drift that only shows between files. After it, sweep th
|
|
|
219
219
|
- **Parameter vocabulary** — one name per concept everywhere: `query` vs `q`, `limit` vs `maxResults`, `nctId` vs `nct_id` on sibling tools is a finding.
|
|
220
220
|
- **Tool overlap** — for any pair with adjacent scope, the two descriptions alone must answer "when X vs Y." If an agent can't pick, that's material.
|
|
221
221
|
- **Instructions drift** — if the server sets `instructions`: every tool it names exists, workflow guidance reflects the current surface (new tools that belong in it, renamed or removed ones purged), and nothing contradicts a per-tool description. Shape is a finding too: two to three cohesive sentences in one string literal (no `+`-joined fragments, no one-line-per-tool inventory — the catalog already carries that), written for the calling agent only. Operator configuration (`*_BASE_URL`, API keys, ports) belongs in the README and `.env.example`, not here — the agent cannot act on it.
|
|
222
|
-
- **Length outliers** — a description several times longer than its siblings (attention drag), or a one-liner that underspecifies (selection risk).
|
|
222
|
+
- **Length outliers** — a description several times longer than its siblings (attention drag), or a one-liner that underspecifies (selection risk). Weigh the `output` and `enrichment` field `.describe()` prose per tool as well. It often outweighs the tool description and is loaded on every session. The usual causes are a cross-field rule restated on every field it touches, per-field narration of upstream mechanics, and a subschema emitted at several paths repeating its prose. State a shared rule once on the parent, cut narration down to what a caller needs to read the value, and guard a byte budget with a test so later edits stay under it.
|
|
223
223
|
|
|
224
224
|
Cross-surface findings use the same finding format, cited at the file:line you'd change (the `instructions` string is a citable location).
|
|
225
225
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyanheads/mcp-ts-core",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.13",
|
|
4
4
|
"mcpName": "io.github.cyanheads/mcp-ts-core",
|
|
5
|
-
"description": "Agent-native TypeScript framework for
|
|
5
|
+
"description": "Agent-native TypeScript framework for building MCP servers.",
|
|
6
6
|
"files": [
|
|
7
7
|
"changelog/",
|
|
8
8
|
"dist/",
|
|
@@ -190,6 +190,7 @@
|
|
|
190
190
|
"test:fuzz": "bunx vitest run --project fuzz",
|
|
191
191
|
"test:typecheck": "bunx vitest run --project typecheck",
|
|
192
192
|
"test:compliance": "bunx vitest run --project compliance",
|
|
193
|
+
"test:contract": "bunx vitest run --project contract",
|
|
193
194
|
"test:leak-gate": "bunx vitest run --project leak-gate",
|
|
194
195
|
"test:worker": "bun run scripts/with-node.ts ./node_modules/vitest/vitest.mjs run --config tests/config/vitest.worker.ts && bun run scripts/with-node.ts ./node_modules/vitest/vitest.mjs run --config tests/config/vitest.worker-bundle.ts",
|
|
195
196
|
"test:ui": "bunx vitest --ui",
|
|
@@ -225,13 +226,13 @@
|
|
|
225
226
|
"@socketsecurity/bun-security-scanner": "^1.1.3",
|
|
226
227
|
"@supabase/supabase-js": "^2.117.2",
|
|
227
228
|
"@types/bun": "^1.4.2",
|
|
228
|
-
"@types/node": "26.6.
|
|
229
|
+
"@types/node": "26.6.4",
|
|
229
230
|
"@types/papaparse": "^5.5.2",
|
|
230
231
|
"@types/sanitize-html": "^2.16.2",
|
|
231
232
|
"@vitest/coverage-istanbul": "4.1.11",
|
|
232
233
|
"@vitest/ui": "4.1.11",
|
|
233
234
|
"better-sqlite3": "^13.0.3",
|
|
234
|
-
"chrono-node": "^2.10.
|
|
235
|
+
"chrono-node": "^2.10.2",
|
|
235
236
|
"clipboardy": "^5.3.2",
|
|
236
237
|
"defuddle": "^0.19.4",
|
|
237
238
|
"depcheck": "^1.4.7",
|
|
@@ -239,11 +240,11 @@
|
|
|
239
240
|
"execa": "^10.0.1",
|
|
240
241
|
"fast-check": "^4.10.2",
|
|
241
242
|
"fast-xml-parser": "^5.11.2",
|
|
242
|
-
"ignore": "^7.0.
|
|
243
|
+
"ignore": "^7.0.12",
|
|
243
244
|
"js-yaml": "^5.4.2",
|
|
244
245
|
"linkedom": "^0.18.13",
|
|
245
246
|
"node-cron": "^4.6.0",
|
|
246
|
-
"openai": "^7.
|
|
247
|
+
"openai": "^7.27.0",
|
|
247
248
|
"papaparse": "^5.7.0",
|
|
248
249
|
"partial-json": "^0.1.7",
|
|
249
250
|
"pdf-lib": "^1.17.1",
|
|
@@ -255,7 +256,7 @@
|
|
|
255
256
|
"typescript": "^7.0.2",
|
|
256
257
|
"typescript-v6": "npm:typescript@^6.0.3",
|
|
257
258
|
"unpdf": "^1.8.1",
|
|
258
|
-
"vite": "8.3.
|
|
259
|
+
"vite": "8.3.2",
|
|
259
260
|
"vitest": "^4.1.11"
|
|
260
261
|
},
|
|
261
262
|
"keywords": [
|
|
@@ -300,9 +301,9 @@
|
|
|
300
301
|
"@hono/node-server": "^2.1.3",
|
|
301
302
|
"@modelcontextprotocol/server": "^2.2.0",
|
|
302
303
|
"@opentelemetry/api": "^1.9.1",
|
|
303
|
-
"hono": "^4.13.
|
|
304
|
+
"hono": "^4.13.13",
|
|
304
305
|
"jose": "^6.2.12",
|
|
305
|
-
"pino": "^10.
|
|
306
|
+
"pino": "^10.4.0",
|
|
306
307
|
"zod": "^4.6.5"
|
|
307
308
|
},
|
|
308
309
|
"peerDependencies": {
|
|
@@ -64,13 +64,14 @@ interface Rule {
|
|
|
64
64
|
const RULES: Rule[] = [
|
|
65
65
|
{
|
|
66
66
|
id: 'inputSchema-downgrade',
|
|
67
|
-
pattern:
|
|
67
|
+
pattern:
|
|
68
|
+
'inputSchema:[[:space:]]*z\\.(unknown|any)\\(\\)|inputSchema:[^,]*\\.passthrough\\(\\)',
|
|
68
69
|
pathspec: ['src/mcp-server/tools/', ':!**/*.test.ts'],
|
|
69
70
|
message: 'Framework must not downgrade tool inputSchema — breaks tools/list advertising',
|
|
70
71
|
},
|
|
71
72
|
{
|
|
72
73
|
id: 'inputSchema-mutation',
|
|
73
|
-
pattern: '\\.inputSchema
|
|
74
|
+
pattern: '\\.inputSchema[[:space:]]*=([^=]|$)',
|
|
74
75
|
pathspec: ['src/', ':!src/linter/', ':!**/*.test.ts'],
|
|
75
76
|
message: 'Post-register inputSchema mutation breaks tools/list advertising',
|
|
76
77
|
},
|
package/templates/.env.example
CHANGED
|
@@ -36,6 +36,8 @@ MCP_SESSION_MODE=stateless # stateful | stateless | auto. Set here, not
|
|
|
36
36
|
# LOG_TOOL_FAILURE_PAYLOADS=false # Log failed tool calls' arguments + result (key-name redaction only;
|
|
37
37
|
# secrets in free-form values are kept). Reaches stderr, files, and OTLP
|
|
38
38
|
# LOG_TOOL_FAILURE_PAYLOAD_MAX_BYTES=16384 # Per-payload cap, UTF-8 bytes
|
|
39
|
+
# LOG_LLM_INTERACTIONS=false # Write full OpenRouter request/response bodies to interactions.log
|
|
40
|
+
# instead of metadata (transcripts can carry PII and secrets)
|
|
39
41
|
|
|
40
42
|
# ── Telemetry ─────────────────────────────────────────────────────────
|
|
41
43
|
# OTEL_ENABLED=false # Enable OpenTelemetry (default: false)
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { HandlerContext, ReasonOf } from '@cyanheads/mcp-ts-core';
|
|
7
|
+
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
7
8
|
import { describe, expect, it } from 'vitest';
|
|
8
|
-
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
|
|
9
|
+
import { createMockContext, getEnrichment } from '@cyanheads/mcp-ts-core/testing';
|
|
9
10
|
import { mcpTest } from '@cyanheads/mcp-ts-core/testing/vitest';
|
|
10
11
|
import { echoTool } from '@/mcp-server/tools/definitions/echo.tool.js';
|
|
11
12
|
|
|
@@ -62,6 +63,23 @@ describe('echoTool', () => {
|
|
|
62
63
|
expect(result).toEqual(expect.schemaMatching(echoTool.output));
|
|
63
64
|
});
|
|
64
65
|
|
|
66
|
+
it('enriches the result with the character count', async () => {
|
|
67
|
+
const ctx = echoContext();
|
|
68
|
+
await echoTool.handler(echoTool.input.parse({ message: 'hello world' }), ctx);
|
|
69
|
+
expect(getEnrichment(ctx)).toEqual({ characterCount: 11 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('fails a whitespace-only message with the declared empty_message reason', async () => {
|
|
73
|
+
const ctx = echoContext();
|
|
74
|
+
const input = echoTool.input.parse({ message: ' ' });
|
|
75
|
+
// The async wrapper turns a synchronous throw into a rejection, so this
|
|
76
|
+
// form holds whether the handler is sync or async.
|
|
77
|
+
await expect(async () => echoTool.handler(input, ctx)).rejects.toMatchObject({
|
|
78
|
+
code: JsonRpcErrorCode.InvalidParams,
|
|
79
|
+
data: { reason: 'empty_message' },
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
65
83
|
it('formats output as text content', () => {
|
|
66
84
|
const blocks = echoTool.format!({ message: 'hello world' });
|
|
67
85
|
expect(blocks).toEqual([{ type: 'text', text: 'hello world' }]);
|