@cyanheads/mcp-ts-core 0.13.6 → 0.13.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -5
- package/CLAUDE.md +5 -5
- package/README.md +57 -52
- package/biome.json +1 -1
- package/changelog/0.13.x/0.13.7.md +77 -0
- package/changelog/0.13.x/0.13.8.md +101 -0
- package/config/tsconfig.base.json +2 -2
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +61 -11
- package/dist/config/index.js.map +1 -1
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +35 -6
- package/dist/core/app.js.map +1 -1
- package/dist/core/context.d.ts +9 -1
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +17 -16
- package/dist/core/context.js.map +1 -1
- package/dist/core/worker.d.ts +2 -0
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/core/worker.js +9 -1
- package/dist/core/worker.js.map +1 -1
- package/dist/linter/rules/enrichment-rules.d.ts +3 -2
- package/dist/linter/rules/enrichment-rules.d.ts.map +1 -1
- package/dist/linter/rules/enrichment-rules.js +9 -2
- package/dist/linter/rules/enrichment-rules.js.map +1 -1
- package/dist/linter/rules/handler-body-rules.d.ts.map +1 -1
- package/dist/linter/rules/handler-body-rules.js +10 -4
- package/dist/linter/rules/handler-body-rules.js.map +1 -1
- package/dist/linter/rules/schema-rules.d.ts +5 -0
- package/dist/linter/rules/schema-rules.d.ts.map +1 -1
- package/dist/linter/rules/schema-rules.js +44 -17
- package/dist/linter/rules/schema-rules.js.map +1 -1
- package/dist/mcp-server/handlerContext.d.ts +6 -0
- package/dist/mcp-server/handlerContext.d.ts.map +1 -1
- package/dist/mcp-server/handlerContext.js +3 -0
- package/dist/mcp-server/handlerContext.js.map +1 -1
- package/dist/mcp-server/outputContract.d.ts +33 -0
- package/dist/mcp-server/outputContract.d.ts.map +1 -0
- package/dist/mcp-server/outputContract.js +43 -0
- package/dist/mcp-server/outputContract.js.map +1 -0
- package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.js +6 -3
- 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 +10 -2
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +16 -5
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +70 -14
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/mcp-server/transports/auth/lib/authUtils.js +4 -1
- package/dist/mcp-server/transports/auth/lib/authUtils.js.map +1 -1
- package/dist/mcp-server/transports/auth/strategies/jwtStrategy.d.ts.map +1 -1
- package/dist/mcp-server/transports/auth/strategies/jwtStrategy.js +1 -1
- package/dist/mcp-server/transports/auth/strategies/jwtStrategy.js.map +1 -1
- package/dist/mcp-server/transports/auth/strategies/oauthStrategy.d.ts.map +1 -1
- package/dist/mcp-server/transports/auth/strategies/oauthStrategy.js +2 -5
- package/dist/mcp-server/transports/auth/strategies/oauthStrategy.js.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.js +15 -5
- package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
- package/dist/mcp-server/transports/http/sessionStore.d.ts.map +1 -1
- package/dist/mcp-server/transports/http/sessionStore.js +2 -2
- package/dist/mcp-server/transports/http/sessionStore.js.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.js +1 -1
- package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
- package/dist/services/canvas/core/DataCanvas.d.ts.map +1 -1
- package/dist/services/canvas/core/DataCanvas.js +7 -5
- package/dist/services/canvas/core/DataCanvas.js.map +1 -1
- package/dist/services/canvas/core/canvasFactory.d.ts.map +1 -1
- package/dist/services/canvas/core/canvasFactory.js +2 -2
- package/dist/services/canvas/core/canvasFactory.js.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +25 -16
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
- package/dist/services/llm/providers/openrouter.provider.js +1 -1
- package/dist/services/llm/providers/openrouter.provider.js.map +1 -1
- package/dist/services/speech/providers/elevenlabs.provider.js +3 -3
- package/dist/services/speech/providers/elevenlabs.provider.js.map +1 -1
- package/dist/services/speech/providers/whisper.provider.d.ts.map +1 -1
- package/dist/services/speech/providers/whisper.provider.js +5 -5
- package/dist/services/speech/providers/whisper.provider.js.map +1 -1
- package/dist/storage/core/IStorageProvider.d.ts +5 -2
- package/dist/storage/core/IStorageProvider.d.ts.map +1 -1
- package/dist/storage/core/StorageService.d.ts.map +1 -1
- package/dist/storage/core/StorageService.js +3 -6
- package/dist/storage/core/StorageService.js.map +1 -1
- package/dist/storage/core/providerHelpers.d.ts +29 -8
- package/dist/storage/core/providerHelpers.d.ts.map +1 -1
- package/dist/storage/core/providerHelpers.js +49 -11
- package/dist/storage/core/providerHelpers.js.map +1 -1
- package/dist/storage/core/storageFactory.d.ts.map +1 -1
- package/dist/storage/core/storageFactory.js +12 -15
- package/dist/storage/core/storageFactory.js.map +1 -1
- package/dist/storage/core/storageValidation.d.ts +13 -13
- package/dist/storage/core/storageValidation.d.ts.map +1 -1
- package/dist/storage/core/storageValidation.js +49 -125
- package/dist/storage/core/storageValidation.js.map +1 -1
- package/dist/storage/providers/cloudflare/d1Provider.d.ts.map +1 -1
- package/dist/storage/providers/cloudflare/d1Provider.js +9 -7
- package/dist/storage/providers/cloudflare/d1Provider.js.map +1 -1
- package/dist/storage/providers/cloudflare/kvProvider.d.ts +2 -0
- package/dist/storage/providers/cloudflare/kvProvider.d.ts.map +1 -1
- package/dist/storage/providers/cloudflare/kvProvider.js +12 -10
- package/dist/storage/providers/cloudflare/kvProvider.js.map +1 -1
- package/dist/storage/providers/cloudflare/r2Provider.d.ts.map +1 -1
- package/dist/storage/providers/cloudflare/r2Provider.js +11 -8
- package/dist/storage/providers/cloudflare/r2Provider.js.map +1 -1
- package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts +1 -0
- package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts.map +1 -1
- package/dist/storage/providers/fileSystem/fileSystemProvider.js +14 -12
- package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
- package/dist/storage/providers/inMemory/inMemoryProvider.d.ts +6 -1
- package/dist/storage/providers/inMemory/inMemoryProvider.d.ts.map +1 -1
- package/dist/storage/providers/inMemory/inMemoryProvider.js +15 -10
- package/dist/storage/providers/inMemory/inMemoryProvider.js.map +1 -1
- package/dist/storage/providers/supabase/supabaseProvider.d.ts.map +1 -1
- package/dist/storage/providers/supabase/supabaseProvider.js +5 -1
- package/dist/storage/providers/supabase/supabaseProvider.js.map +1 -1
- package/dist/testing/fuzz.d.ts.map +1 -1
- package/dist/testing/fuzz.js +7 -1
- package/dist/testing/fuzz.js.map +1 -1
- package/dist/testing/index.d.ts +21 -6
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +57 -10
- package/dist/testing/index.js.map +1 -1
- package/dist/types-global/errors.d.ts +7 -4
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/types-global/errors.js.map +1 -1
- package/dist/utils/formatting/codeSpan.d.ts +27 -0
- package/dist/utils/formatting/codeSpan.d.ts.map +1 -0
- package/dist/utils/formatting/codeSpan.js +42 -0
- package/dist/utils/formatting/codeSpan.js.map +1 -0
- package/dist/utils/formatting/diffFormatter.d.ts.map +1 -1
- package/dist/utils/formatting/diffFormatter.js +7 -15
- package/dist/utils/formatting/diffFormatter.js.map +1 -1
- package/dist/utils/formatting/markdownBuilder.d.ts +12 -5
- package/dist/utils/formatting/markdownBuilder.d.ts.map +1 -1
- package/dist/utils/formatting/markdownBuilder.js +14 -2
- package/dist/utils/formatting/markdownBuilder.js.map +1 -1
- package/dist/utils/formatting/partialResult.d.ts +28 -2
- package/dist/utils/formatting/partialResult.d.ts.map +1 -1
- package/dist/utils/formatting/partialResult.js +46 -2
- package/dist/utils/formatting/partialResult.js.map +1 -1
- package/dist/utils/formatting/tableFormatter.d.ts.map +1 -1
- package/dist/utils/formatting/tableFormatter.js +5 -9
- package/dist/utils/formatting/tableFormatter.js.map +1 -1
- package/dist/utils/formatting/treeFormatter.d.ts.map +1 -1
- package/dist/utils/formatting/treeFormatter.js +5 -9
- package/dist/utils/formatting/treeFormatter.js.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.d.ts +21 -8
- package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.js +70 -38
- package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
- package/dist/utils/internal/error-handler/mappings.d.ts +18 -1
- package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/mappings.js +23 -1
- package/dist/utils/internal/error-handler/mappings.js.map +1 -1
- package/dist/utils/internal/error-handler/types.d.ts +2 -0
- package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
- package/dist/utils/internal/logger.d.ts +75 -3
- package/dist/utils/internal/logger.d.ts.map +1 -1
- package/dist/utils/internal/logger.js +181 -52
- package/dist/utils/internal/logger.js.map +1 -1
- package/dist/utils/internal/performance.d.ts +16 -1
- package/dist/utils/internal/performance.d.ts.map +1 -1
- package/dist/utils/internal/performance.js +59 -20
- package/dist/utils/internal/performance.js.map +1 -1
- package/dist/utils/network/fetchWithTimeout.d.ts +11 -5
- package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
- package/dist/utils/network/fetchWithTimeout.js +50 -23
- package/dist/utils/network/fetchWithTimeout.js.map +1 -1
- package/dist/utils/network/retry.d.ts +16 -8
- package/dist/utils/network/retry.d.ts.map +1 -1
- package/dist/utils/network/retry.js +19 -8
- package/dist/utils/network/retry.js.map +1 -1
- package/dist/utils/overflow/outlineOnOverflow.d.ts +18 -2
- package/dist/utils/overflow/outlineOnOverflow.d.ts.map +1 -1
- package/dist/utils/overflow/outlineOnOverflow.js +28 -3
- package/dist/utils/overflow/outlineOnOverflow.js.map +1 -1
- package/dist/utils/pagination/pagination.d.ts +3 -1
- package/dist/utils/pagination/pagination.d.ts.map +1 -1
- package/dist/utils/pagination/pagination.js +10 -2
- package/dist/utils/pagination/pagination.js.map +1 -1
- package/dist/utils/parsing/csvParser.d.ts.map +1 -1
- package/dist/utils/parsing/csvParser.js +4 -2
- package/dist/utils/parsing/csvParser.js.map +1 -1
- package/dist/utils/parsing/htmlExtractor.js +1 -1
- package/dist/utils/parsing/htmlExtractor.js.map +1 -1
- package/dist/utils/parsing/jsonParser.d.ts.map +1 -1
- package/dist/utils/parsing/jsonParser.js +3 -1
- package/dist/utils/parsing/jsonParser.js.map +1 -1
- package/dist/utils/parsing/xmlParser.d.ts.map +1 -1
- package/dist/utils/parsing/xmlParser.js +3 -1
- package/dist/utils/parsing/xmlParser.js.map +1 -1
- package/dist/utils/parsing/yamlParser.d.ts.map +1 -1
- package/dist/utils/parsing/yamlParser.js +3 -1
- package/dist/utils/parsing/yamlParser.js.map +1 -1
- package/dist/utils/security/idGenerator.d.ts.map +1 -1
- package/dist/utils/security/idGenerator.js +20 -4
- package/dist/utils/security/idGenerator.js.map +1 -1
- package/dist/utils/security/sanitization.d.ts +46 -15
- package/dist/utils/security/sanitization.d.ts.map +1 -1
- package/dist/utils/security/sanitization.js +203 -96
- package/dist/utils/security/sanitization.js.map +1 -1
- package/dist/utils/telemetry/attributes.d.ts +16 -1
- package/dist/utils/telemetry/attributes.d.ts.map +1 -1
- package/dist/utils/telemetry/attributes.js +16 -1
- package/dist/utils/telemetry/attributes.js.map +1 -1
- package/dist/utils/telemetry/instrumentation.d.ts +13 -3
- package/dist/utils/telemetry/instrumentation.d.ts.map +1 -1
- package/dist/utils/telemetry/instrumentation.js +104 -17
- package/dist/utils/telemetry/instrumentation.js.map +1 -1
- package/framework-skills/add-app-tool/SKILL.md +12 -18
- package/framework-skills/add-prompt/SKILL.md +3 -1
- package/framework-skills/add-provider/SKILL.md +14 -4
- package/framework-skills/add-resource/SKILL.md +3 -3
- package/framework-skills/add-tool/SKILL.md +22 -7
- package/framework-skills/api-auth/SKILL.md +3 -1
- package/framework-skills/api-canvas/SKILL.md +4 -4
- package/framework-skills/api-config/SKILL.md +9 -5
- package/framework-skills/api-context/SKILL.md +10 -7
- package/framework-skills/api-errors/SKILL.md +20 -11
- package/framework-skills/api-linter/SKILL.md +14 -10
- package/framework-skills/api-telemetry/SKILL.md +38 -13
- package/framework-skills/api-testing/SKILL.md +25 -15
- package/framework-skills/api-utils/SKILL.md +10 -10
- package/framework-skills/api-utils/references/formatting.md +1 -1
- package/framework-skills/api-utils/references/parsing.md +2 -2
- package/framework-skills/api-utils/references/security.md +13 -10
- package/framework-skills/code-simplifier/SKILL.md +31 -18
- package/framework-skills/design-mcp-server/SKILL.md +62 -35
- package/framework-skills/git-wrapup/SKILL.md +19 -10
- package/framework-skills/maintenance/SKILL.md +3 -3
- package/framework-skills/orchestrations/SKILL.md +1 -1
- package/framework-skills/orchestrations/workflows/greenfield-build.md +15 -8
- package/framework-skills/polish-docs-meta/SKILL.md +2 -2
- package/framework-skills/polish-docs-meta/references/package-meta.md +1 -1
- package/framework-skills/polish-docs-meta/references/readme.md +4 -3
- package/framework-skills/release-and-publish/SKILL.md +7 -5
- package/framework-skills/release-pr-review/SKILL.md +18 -1
- package/framework-skills/report-issue-framework/SKILL.md +2 -2
- package/framework-skills/report-issue-local/SKILL.md +3 -3
- package/framework-skills/security-pass/SKILL.md +11 -3
- package/framework-skills/techniques/SKILL.md +1 -1
- package/framework-skills/techniques/references/outline-on-overflow.md +12 -7
- package/framework-skills/tool-defs-analysis/SKILL.md +3 -3
- package/package.json +30 -36
- package/scripts/check-skill-versions.ts +103 -22
- package/scripts/devcheck.ts +4 -3
- package/scripts/lint-packaging.ts +38 -1
- package/templates/.env.example +7 -1
- package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +1 -1
- package/templates/AGENTS.md +2 -2
- package/templates/CLAUDE.md +2 -2
- package/templates/Dockerfile +30 -10
- package/templates/package.json +4 -3
- package/templates/src/mcp-server/prompts/definitions/echo.prompt.ts +2 -4
- package/templates/src/mcp-server/resources/definitions/echo-app-ui.app-resource.ts +51 -14
- package/templates/src/mcp-server/resources/definitions/echo.resource.ts +1 -1
- package/templates/src/mcp-server/tools/definitions/echo-app.app-tool.ts +2 -3
- package/templates/src/mcp-server/tools/definitions/echo.tool.ts +1 -1
- package/dist/utils/telemetry/index.d.ts +0 -12
- package/dist/utils/telemetry/index.d.ts.map +0 -1
- package/dist/utils/telemetry/index.js +0 -12
- package/dist/utils/telemetry/index.js.map +0 -1
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.12"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -13,7 +13,7 @@ metadata:
|
|
|
13
13
|
|
|
14
14
|
Tests target handler behavior directly — call `handler(input, ctx)`, assert on the return value or thrown error. The framework's handler factory (try/catch, formatting, telemetry) is not involved. Use `createMockContext` from `@cyanheads/mcp-ts-core/testing` to construct the `ctx` argument.
|
|
15
15
|
|
|
16
|
-
**Additional exports from `/testing`:** `createMockSession()` binds a mock handler context to an HTTP session; `createFetchMock()` provides a strict upstream HTTP fake; `runToolContract()` executes a definition through schema, handler, formatting, enrichment/content, and production-shaped error-envelope checks. `createMockLogger()` returns a standalone `MockContextLogger`,
|
|
16
|
+
**Additional exports from `/testing`:** `createMockSession()` binds a mock handler context to an HTTP session; `createFetchMock()` provides a strict upstream HTTP fake; `runToolContract()` executes a definition through schema, handler, formatting, enrichment/content, and production-shaped error-envelope checks. `createMockLogger()` returns a standalone `MockContextLogger`, `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider`, and `expectInputRequired(run)` returns the `input_required` result a multi-round-trip handler asked for (see [Mock inputs](#mock-inputs)).
|
|
17
17
|
|
|
18
18
|
**Philosophy:** Test behavior, not implementation. Refactors should not break tests. Match the repo's existing test layout: fresh scaffolds use `tests/`, while colocated `src/**/*.test.ts` files are also supported. Integration tests at I/O boundaries over unit tests of internals.
|
|
19
19
|
|
|
@@ -99,7 +99,16 @@ try {
|
|
|
99
99
|
}
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
Routes match in registration order. `match` accepts an exact URL, `RegExp`, or request predicate; `respond` accepts a
|
|
102
|
+
Routes match in registration order. `match` accepts an exact URL, `RegExp`, or request predicate; `respond` accepts a static `Response` or a response factory. A static response's body is read once, on the route's first match, and every call is served a fresh `Response` over those bytes with the same `status`, `statusText`, and headers — so a consumer that cancels the body, or an error-body reader like `httpErrorFromResponse` that stops past its cap, settles on Node as on Bun. Set `once: true` for one-shot behavior. Unmatched requests throw unless `onUnhandled` is provided.
|
|
103
|
+
|
|
104
|
+
**A request predicate routes on the URL's origin, never a prefix.** `req.url.startsWith(BASE_URL)` also matches a lookalike host (`https://api.example.test.evil.com/...`), which CodeQL reports as high-severity incomplete URL substring sanitization — it scans test files as readily as `src/`, so a suite that is green locally still fails the security check on a pull request. Parse the URL and compare origins, matching the path separately:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
match: (req) => {
|
|
108
|
+
const url = new URL(req.url);
|
|
109
|
+
return url.origin === new URL(BASE_URL).origin && url.pathname.startsWith('/items/');
|
|
110
|
+
},
|
|
111
|
+
```
|
|
103
112
|
|
|
104
113
|
---
|
|
105
114
|
|
|
@@ -124,7 +133,9 @@ toolContractSuite(searchTool, {
|
|
|
124
133
|
|
|
125
134
|
Use `runToolContract(definition, input, { context })` from `/testing` when a custom test runner or an imperative assertion is a better fit. It intentionally skips transport auth and telemetry; those belong in transport/integration tests.
|
|
126
135
|
|
|
127
|
-
Arguments that fail the `input` schema are rejected the way the production handler factory rejects them: `InvalidParams` (`-32602`), with a message naming the tool and every failing field. That is the code a client sees on the wire, so assert it — not `ValidationError` (`-32007`), which stays the classification for a `ZodError` a handler throws itself
|
|
136
|
+
Arguments that fail the `input` schema are rejected the way the production handler factory rejects them: `InvalidParams` (`-32602`), with a message naming the tool and every failing field. That is the code a client sees on the wire, so assert it — not `ValidationError` (`-32007`), which stays the classification for a `ZodError` a handler throws itself. A result that breaks the tool's own `output` or `enrichment` schema is the definition's bug, so it returns `InternalError` (`-32603`) with a message naming that contract, exactly as in production.
|
|
137
|
+
|
|
138
|
+
Cancellation settles as it does in production. Pass `context: { signal }` and abort it: once the signal has fired, whatever the handler — or the output validation, `format()`, and enrichment after it — throws comes back as `RequestCancelled` (`-32011`), whether that is the signal's `AbortError`, its reason string, a `withRetry` backoff that stopped, or an `McpError` of the handler's own. A throw while the signal is still live keeps its own classification, and argument parsing stays outside the settle, so schema-invalid arguments on an aborted signal still return `InvalidParams`. A `toolContractSuite` error case with an aborted `context.signal` asserts `code: JsonRpcErrorCode.RequestCancelled` the same way.
|
|
128
139
|
|
|
129
140
|
---
|
|
130
141
|
|
|
@@ -188,6 +199,7 @@ interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefine
|
|
|
188
199
|
`ctx.state` is a real `StorageService` over an `InMemoryProvider` — the production storage path, not a `Map`. A test therefore sees the same rules a deployed server enforces:
|
|
189
200
|
|
|
190
201
|
- **Keys** match `^[a-zA-Z0-9_.\-/]+$` and may not contain `..`. Colons are rejected, so `cache:v1:abc` throws `McpError(ValidationError)` in the test exactly as it would in a deployment; use `cache/v1/abc`.
|
|
202
|
+
- **Values** round-trip as JSON, as on every persistent provider. A read returns a fresh object in its JSON form — a `Date` reads back as its ISO string — so a test cannot pass on identity or on a `Date`/`Map` surviving storage. A value JSON cannot encode (`bigint`, a cyclic reference, a top-level `undefined`, function, or symbol) rejects with `McpError(SerializationError)`.
|
|
191
203
|
- **TTL** is honored. An entry written with `{ ttl: 30 }` reads back as `null` once 30 seconds elapse — drive the clock with `vi.useFakeTimers()` to assert expiry.
|
|
192
204
|
- **`getMany` / `setMany` / `deleteMany` / `list`** validate every key and prefix, and `list` paginates with the same opaque cursors.
|
|
193
205
|
- **Cancellation** applies: once `ctx.signal` aborts, state operations reject.
|
|
@@ -198,6 +210,9 @@ const ctx = createMockContext();
|
|
|
198
210
|
await ctx.state.set('cache/v1/abc', { hits: 1 }, { ttl: 30 });
|
|
199
211
|
await expect(ctx.state.get('cache/v1/abc')).resolves.toEqual({ hits: 1 });
|
|
200
212
|
await expect(ctx.state.set('cache:v1:abc', {})).rejects.toThrow(McpError);
|
|
213
|
+
|
|
214
|
+
await ctx.state.set('seen/abc', { at: new Date('2026-01-01T00:00:00Z') });
|
|
215
|
+
await expect(ctx.state.get('seen/abc')).resolves.toEqual({ at: '2026-01-01T00:00:00.000Z' });
|
|
201
216
|
```
|
|
202
217
|
|
|
203
218
|
Reach for `createInMemoryStorage()` when a service takes a `StorageService` directly — it builds the same pair.
|
|
@@ -216,21 +231,16 @@ it('asks for confirmation on the first round', async () => {
|
|
|
216
231
|
});
|
|
217
232
|
```
|
|
218
233
|
|
|
219
|
-
To assert on *what* was requested,
|
|
234
|
+
To assert on *what* was requested, use `expectInputRequired` from `/testing`. It runs the handler and returns the `input_required` result the handler factory would have returned; it throws when the handler returns normally, and any other error propagates untouched:
|
|
220
235
|
|
|
221
236
|
```ts
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (isInputRequiredSignal(error)) return error.result;
|
|
227
|
-
throw error;
|
|
228
|
-
}
|
|
229
|
-
throw new Error('Expected the handler to request input.');
|
|
230
|
-
}
|
|
237
|
+
import { createMockContext, expectInputRequired } from '@cyanheads/mcp-ts-core/testing';
|
|
238
|
+
|
|
239
|
+
const asked = await expectInputRequired(() => myTool.handler(input, createMockContext()));
|
|
240
|
+
expect(asked.inputRequests?.confirm?.method).toBe('elicitation/create');
|
|
231
241
|
```
|
|
232
242
|
|
|
233
|
-
`inputResponses` drives the second round. `ctx.inputs.accepted(key, schema)` and `.view(key)` read it with the same helpers production uses, so a wrong response shape fails in the test:
|
|
243
|
+
Pass `asked.requestState` back as `createMockContext({ requestState })` when the handler reads state from the prior round. `inputResponses` drives the second round. `ctx.inputs.accepted(key, schema)` and `.view(key)` read it with the same helpers production uses, so a wrong response shape fails in the test:
|
|
234
244
|
|
|
235
245
|
```ts
|
|
236
246
|
it('proceeds once the user accepts', async () => {
|
|
@@ -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.13"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -31,11 +31,11 @@ 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 consumes the 3xx 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). 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. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
|
|
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 consumes the 3xx 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`). 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. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
|
|
35
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). |
|
|
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
|
-
| `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 `
|
|
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` or `data.
|
|
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
|
+
| `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), `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. The shed error is `rateLimited` (-32003) with `data: { reason: 'pacer_shed', retryAfter, queueDepth }` and **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; the first success resets the count. **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
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. |
|
|
@@ -47,9 +47,9 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
|
|
|
47
47
|
| Export | API | Notes |
|
|
48
48
|
|:-------|:----|:------|
|
|
49
49
|
| `extractCursor` | `(params?) -> string \| undefined` | Extracts opaque cursor string from MCP request params. Checks `params.cursor` then `params._meta.cursor`. Returns `undefined` when no cursor is present. Does not decode. |
|
|
50
|
-
| `paginateArray` | `<T>(items, cursorStr, defaultPageSize, maxPageSize, context: RequestContext) -> PaginatedResult<T>` | Decodes cursor, slices array, returns `{ items, nextCursor?, totalCount }`. `nextCursor` omitted on last page. Throws `McpError(InvalidParams)` on invalid cursor. |
|
|
50
|
+
| `paginateArray` | `<T>(items, cursorStr, defaultPageSize, maxPageSize, context: RequestContext) -> PaginatedResult<T>` | Decodes cursor, slices array, returns `{ items, nextCursor?, totalCount }`. `nextCursor` omitted on last page. Throws `McpError(InvalidParams)` on invalid cursor, inherited from `decodeCursor` (`data.reason: 'invalid_cursor'`). On a continued call the page size comes from the cursor, not `defaultPageSize` — a tool with a caller-facing `limit` input must slice on `decodeCursor(...).offset` itself to honor `limit` past page 1. |
|
|
51
51
|
| `encodeCursor` | `(state: PaginationState) -> string` | Encodes `{ offset, limit, ...extra }` to opaque base64url string. |
|
|
52
|
-
| `decodeCursor` | `(cursor, context: RequestContext) -> PaginationState` | Decodes opaque base64url cursor. Throws `McpError(InvalidParams)` if malformed. |
|
|
52
|
+
| `decodeCursor` | `(cursor, context: RequestContext) -> PaginationState` | Decodes opaque base64url cursor. Throws `McpError(InvalidParams)` if malformed, with `data: { cursor, reason: 'invalid_cursor', recovery: { hint } }` — the hint tells the caller to omit `cursor` or pass the previous `nextCursor` unchanged, so the rejection renders the `Recovery:` line and `(reason invalid_cursor)` trailer like any other classified failure. |
|
|
53
53
|
|
|
54
54
|
---
|
|
55
55
|
|
|
@@ -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?)` | Global structured logger. Use `ctx.log` in handlers instead. `logger` is for lifecycle/background contexts (startup, shutdown, `setup()`). Auto-redacts sensitive fields. 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. **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?)` | 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. **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`. |
|
|
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`/`rootCause`; `context` goes to the log record only. |
|
|
113
113
|
|
|
114
114
|
---
|
|
115
115
|
|
|
@@ -148,7 +148,7 @@ Helper API only. For the catalog of what the framework auto-emits (span names, m
|
|
|
148
148
|
|
|
149
149
|
| Export | Signature | Notes |
|
|
150
150
|
|:-------|:----------|:------|
|
|
151
|
-
| `initializeOpenTelemetry` | `() -> Promise<void>` | Idempotent. Initializes `NodeSDK` with OTLP trace + metrics exporters, `TraceIdRatioBasedSampler`, HTTP instrumentation, and
|
|
151
|
+
| `initializeOpenTelemetry` | `() -> Promise<void>` | Idempotent. Initializes `NodeSDK` with OTLP trace + metrics exporters, `TraceIdRatioBasedSampler`, and HTTP instrumentation, and attaches the framework logger's OTLP log sink when `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is set. Framework log records carry `traceId`/`spanId` from the request context; `PinoInstrumentation` is registered but patches only a `pino` loaded after the SDK starts, never the framework logger's. No-ops when `OTEL_ENABLED=false` or in Worker/Edge runtimes where `NodeSDK` is unavailable. Safe to call multiple times. |
|
|
152
152
|
| `shutdownOpenTelemetry` | `(timeoutMs?: number) -> Promise<void>` | Gracefully flushes and shuts down the SDK. `timeoutMs` defaults to `5000`. Resets internal state so the next `initializeOpenTelemetry()` call can reinitialize. No-op when SDK was never started. |
|
|
153
153
|
| `sdk` | `NodeSDK \| null` | The live SDK instance, or `null` when telemetry is disabled, in a Worker runtime, or after shutdown. |
|
|
154
154
|
|
|
@@ -177,6 +177,6 @@ Helper API only. For the catalog of what the framework auto-emits (span names, m
|
|
|
177
177
|
|
|
178
178
|
MCP-specific `ATTR_*` constant exports for span and metric attributes. Covers: code execution (`code.function.name`, `code.namespace`), MCP tool execution (name, input/output bytes, duration, success, error code, error category, partial success, batch succeeded/failed counts), MCP resource (URI, name, MIME type, size, duration, success, error code), MCP request context (tenant ID, client ID), MCP session events, MCP storage, GenAI semantic conventions, speech, graph, auth, task, and error classification attributes.
|
|
179
179
|
|
|
180
|
-
Batch/partial success attributes (`mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count`, `mcp.tool.batch.failed_count`) are set automatically by the framework when a tool handler returns a result containing a non-empty `failed` array — matching the batch response pattern from the design skill.
|
|
180
|
+
Batch/partial success attributes (`mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count`, `mcp.tool.batch.failed_count`) are set automatically by the framework when a tool handler returns a result containing a non-empty `failed` array — matching the batch response pattern from the design skill. A tool whose `output` is built with `partialResultSchema()` is read under its `failedKey`/`succeededKey` instead, including after `.extend()`, `.pick()`, `.omit()`, or a `.shape` spread.
|
|
181
181
|
|
|
182
182
|
Standard OTel semantic conventions (HTTP, cloud, service, network, etc.) are NOT re-exported — import those directly from `@opentelemetry/semantic-conventions` if needed.
|
|
@@ -22,7 +22,7 @@ import { markdown, MarkdownBuilder, diffFormatter, tableFormatter, treeFormatter
|
|
|
22
22
|
| `keyValuePlain` | `(key, value) -> this` | `key: value` (no bold) |
|
|
23
23
|
| `list` | `(items, ordered?) -> this` | `ordered` defaults to `false`; empty arrays silently ignored |
|
|
24
24
|
| `codeBlock` | `(content, language?) -> this` | Fenced block; `language` defaults to `''`. The fence outgrows the longest backtick run in `content`, so a payload the tool did not author (upstream text, a file excerpt) cannot break out of the block; content is emitted byte-for-byte |
|
|
25
|
-
| `inlineCode` | `(code) -> this` |
|
|
25
|
+
| `inlineCode` | `(code) -> this` | Code span; no trailing newline. The backtick delimiter outgrows the longest backtick run in `code`, space-padded when a backtick touches either end or the value both begins and ends with a space, so a value the tool did not author reads back byte-identical as one span and cannot break out into live markdown. A value with no backtick that does not both begin and end with a space renders as `` `code` `` |
|
|
26
26
|
| `paragraph` | `(text) -> this` | Text + `\n\n` |
|
|
27
27
|
| `blockquote` | `(text) -> this` | Each line prefixed with `>` + space |
|
|
28
28
|
| `hr` | `() -> this` | `---` |
|
|
@@ -12,7 +12,7 @@ All parsers are **Tier 3** — lazy-load their peer dependency on first call. Al
|
|
|
12
12
|
- `<think>...</think>` blocks at the start of input are automatically stripped and logged at `debug` level (except `dateParser` and `pdfParser`)
|
|
13
13
|
- Every `context?` parameter is optional (synthetic context created if omitted) and accepts the handler `Context` as well as a `RequestContext` bag
|
|
14
14
|
- Input budgets are opt-in: a parser is unbounded unless the caller passes `maxBytes`, which then rejects an over-budget input with `ValidationError` (`reason: 'parser_input_too_large'`). `DEFAULT_TEXT_PARSER_MAX_BYTES` (1 MiB) and `DEFAULT_BINARY_PARSER_MAX_BYTES` (25 MiB) are exported as starting points, not applied defaults
|
|
15
|
-
- Errors throw `McpError` — never return error values. The message is `<summary>: <library message>`, so it carries the underlying parser's diagnostic; `data` carries only `{ reason }
|
|
15
|
+
- Errors throw `McpError` — never return error values. The message is `<summary>: <library message>`, so it carries the underlying parser's diagnostic; `data` carries only `{ reason }` (`csvParser` adds Papa's `errors` list and a content sample), and the input sample and stack stay on `cause`. Input that is empty after `<think>` stripping and trimming rejects with `ValidationError` (`reason: 'parser_input_empty'`). The `context` you pass is for log correlation only — it never becomes error `data`, which reaches the client
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
@@ -56,7 +56,7 @@ const data = await xmlParser.parse<FeedResponse>(xmlString);
|
|
|
56
56
|
|:-------|:----------|
|
|
57
57
|
| `parse` | `<T = unknown>(csvString, options?, context?) -> Promise<Papa.ParseResult<T>>` |
|
|
58
58
|
|
|
59
|
-
`options` is `Papa.ParseConfig` forwarded verbatim — key options: `header`, `delimiter`, `dynamicTyping`. Returns `{ data: T[], errors: ParseError[], meta: ParseMeta }`. Throws `ValidationError` if `result.errors` is non-empty
|
|
59
|
+
`options` is `Papa.ParseConfig` forwarded verbatim — key options: `header`, `delimiter`, `dynamicTyping`. Returns `{ data: T[], errors: ParseError[], meta: ParseMeta }`. Throws `ValidationError` if `result.errors` is non-empty, with `data: { reason: 'csv_parse_failed', errors, originalContentSample }`.
|
|
60
60
|
|
|
61
61
|
```ts
|
|
62
62
|
const result = await csvParser.parse<Row>(csvString, { header: true, dynamicTyping: true });
|
|
@@ -8,20 +8,20 @@ import { sanitization, RateLimiter, IdGenerator, idGenerator, generateUUID, gene
|
|
|
8
8
|
|
|
9
9
|
## `sanitization`
|
|
10
10
|
|
|
11
|
-
Pre-constructed singleton of `Sanitization`. Tier 3
|
|
11
|
+
Pre-constructed singleton of `Sanitization`. Tier 3 peer: `sanitize-html` (HTML handling only); URL and number validation are built in.
|
|
12
12
|
|
|
13
13
|
### Methods
|
|
14
14
|
|
|
15
15
|
| Method | Async | Peer dep | Signature |
|
|
16
16
|
|:-------|:------|:---------|:----------|
|
|
17
17
|
| `sanitizeHtml` | yes | `sanitize-html` | `(input, config?) -> Promise<string>` |
|
|
18
|
-
| `sanitizeString` | yes | `sanitize-html`
|
|
19
|
-
| `sanitizeUrl` | yes |
|
|
20
|
-
| `sanitizeNumber` | yes |
|
|
18
|
+
| `sanitizeString` | yes | `sanitize-html` (`'text'`, `'html'`, `'attribute'` contexts) | `(input, options?) -> Promise<string>` |
|
|
19
|
+
| `sanitizeUrl` | yes | none | `(input, allowedProtocols?) -> Promise<string>` |
|
|
20
|
+
| `sanitizeNumber` | yes | none | `(input, min?, max?) -> Promise<number>` |
|
|
21
21
|
| `sanitizePath` | **no** | Node.js only | `(input, options?) -> SanitizedPathInfo` |
|
|
22
22
|
| `sanitizeJson` | **no** | none | `<T>(input, maxSize?) -> T` |
|
|
23
23
|
| `sanitizeForLogging` | **no** | none | `(input) -> unknown` |
|
|
24
|
-
| `
|
|
24
|
+
| `serializeForLogging` | **no** | none | `(value, maxBytes) -> { text: string; truncated: boolean }` |
|
|
25
25
|
| `getSensitivePinoFields` | **no** | none | `() -> string[]` |
|
|
26
26
|
|
|
27
27
|
### Option types
|
|
@@ -59,11 +59,14 @@ interface SanitizedPathInfo {
|
|
|
59
59
|
|
|
60
60
|
- `sanitizeHtml`: returns `''` for falsy input; `<a>` tags get `rel="noopener noreferrer"` by default
|
|
61
61
|
- `sanitizeString`: `'javascript'` context always throws `McpError(ValidationError)` — no JavaScript allowed
|
|
62
|
-
- `sanitizeUrl`: default protocols `['http', 'https']`; always blocks `javascript:`, `data:`, `vbscript:`
|
|
62
|
+
- `sanitizeUrl`: default protocols `['http', 'https']`; requires a host (domain, single-label name such as `localhost`, IPv4 dotted quad, or bracketed IPv6), so host-less schemes like `mailto:` never pass; rejects whitespace, `<`, `>`, and URLs over 2084 characters; always blocks `javascript:`, `data:`, `vbscript:`
|
|
63
|
+
- `sanitizeNumber`: string input must be a plain decimal — optional sign and fraction, no exponent (`1e5`) or separators (`1,000`)
|
|
63
64
|
- `sanitizePath`: **Node-only** — throws `McpError(InternalError)` in Workers. Throws `McpError(ValidationError)` on path traversal or null bytes.
|
|
64
65
|
- `sanitizeJson`: `maxSize` is bytes (UTF-8); uses `Buffer.byteLength` / `TextEncoder` / `string.length` fallback chain
|
|
65
66
|
- `sanitizeNumber`: `NaN`/`Infinity` always rejected; out-of-range values silently clamped with debug log
|
|
66
67
|
- `sanitizeForLogging`: deep clones via `structuredClone`; returns `'[Log Sanitization Failed]'` on clone error
|
|
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 deep payload survives the logger's four-level field depth. A value `JSON.stringify` rejects (a `bigint`) yields `'[Log Serialization Failed]'`. Backs the failed-call payload record (`LOG_TOOL_FAILURE_PAYLOADS`)
|
|
67
70
|
|
|
68
71
|
### Sensitive fields
|
|
69
72
|
|
|
@@ -81,7 +84,7 @@ const clean = await sanitization.sanitizeHtml(userHtml, {
|
|
|
81
84
|
});
|
|
82
85
|
|
|
83
86
|
// URL validation
|
|
84
|
-
const safeUrl = await sanitization.sanitizeUrl(userUrl, ['http', 'https', '
|
|
87
|
+
const safeUrl = await sanitization.sanitizeUrl(userUrl, ['http', 'https', 'ftp']);
|
|
85
88
|
|
|
86
89
|
// Path sanitization (Node-only)
|
|
87
90
|
const info = sanitization.sanitizePath(userPath, { rootDir: '/app/data', allowAbsolute: false });
|
|
@@ -190,10 +193,10 @@ interface IdGenerationOptions {
|
|
|
190
193
|
| Method | Signature | Notes |
|
|
191
194
|
|:-------|:----------|:------|
|
|
192
195
|
| `generate` | `(prefix?, options?) -> string` | `PREFIX_XXXXXX` or just `XXXXXX` if no prefix |
|
|
193
|
-
| `generateForEntity` | `(entityType, options?) -> string` | Uses registered prefix; throws `McpError(ValidationError)` if type unknown |
|
|
194
|
-
| `generateRandomString` | `(length?, charset?) -> string` | Raw random string; defaults: length 6, charset `A-Z0-9` |
|
|
196
|
+
| `generateForEntity` | `(entityType, options?) -> string` | Uses registered prefix; throws `McpError(ValidationError)` with `data.reason: 'unknown_entity_type'` if type unknown |
|
|
197
|
+
| `generateRandomString` | `(length?, charset?) -> string` | Raw random string; defaults: length 6, charset `A-Z0-9`. A charset outside 1–256 characters throws `ValidationError` with `data.reason: 'invalid_charset'` |
|
|
195
198
|
| `isValid` | `(id, entityType, options?) -> boolean` | Regex-validates format against prefix + separator + charset{length} |
|
|
196
|
-
| `getEntityType` | `(id, separator?) -> string` | Resolves entity type from prefix; throws `McpError(ValidationError)`
|
|
199
|
+
| `getEntityType` | `(id, separator?) -> string` | Resolves entity type from prefix; throws `McpError(ValidationError)` — `data.reason: 'invalid_id_format'` when the ID has no `PREFIX<sep>` part, `'unknown_entity_type'` when the prefix is unregistered, each with a `recovery.hint` (the latter lists the registered prefixes) |
|
|
197
200
|
| `normalize` | `(id, separator?) -> string` | Canonical prefix casing + uppercase random part |
|
|
198
201
|
| `stripPrefix` | `(id, separator?) -> string` | Returns random part; returns original if separator not found |
|
|
199
202
|
| `setEntityPrefixes` | `(config) -> void` | Replaces all prefixes and rebuilds reverse lookup |
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: code-simplifier
|
|
3
3
|
description: >
|
|
4
|
-
|
|
4
|
+
Cleanup pass that edits the working tree — over a session's uncommitted changes, or a named path or whole codebase. Reads `git diff` (or the named target) and simplifies, consolidates, and aligns code with the existing codebase — modernize syntax, cut unnecessary complexity and slop, consolidate duplicated logic, catch efficiency issues. Not a bug hunt: defects are reported, not fixed. Use after a substantive working session, or when asked to clean up, simplify, reduce slop, consolidate, modernize, tighten up, de-slop, or scan a codebase. For `@cyanheads/mcp-ts-core` projects, includes specific transformations for tool/resource/prompt definitions, the ctx pattern, error factories, and framework idioms.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.6"
|
|
8
8
|
audience: external
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -23,7 +23,7 @@ Cleanup pass over a session's changes or a named target. Reviews the code in sco
|
|
|
23
23
|
|
|
24
24
|
Two scopes; the caller's wording picks one, and the diff is the default.
|
|
25
25
|
|
|
26
|
-
- **Diff** (nothing named): run `git status` to see the shape of the working tree, then `git diff HEAD` for all uncommitted changes (staged and unstaged). Untracked files never appear in the diff —
|
|
26
|
+
- **Diff** (nothing named): run `git status` to see the shape of the working tree, then `git diff HEAD` for all uncommitted changes (staged and unstaged). Untracked files never appear in the diff — list them with `git ls-files --others --exclude-standard` (`git status` collapses a new directory to one line) and read them directly. If the diff is empty and there are no untracked files, review the last commit (`git diff HEAD~1 HEAD`); if that is also empty, say the tree is clean and stop. Don't go hunting through the codebase for files to improve.
|
|
27
27
|
- **Target** (a named path, module, or "the whole codebase"): the named files are the scope, whatever their git state. Work one module or directory at a time and re-run the gate after each, so a large scan never becomes one unverifiable diff. Take the target as named — don't rank or narrow it by commit history.
|
|
28
28
|
|
|
29
29
|
### Phase 2: Understand the surrounding codebase
|
|
@@ -33,7 +33,7 @@ Don't review changes in isolation. Before any modifications:
|
|
|
33
33
|
1. **Read the full files** containing changes — not just the diff hunks. Understand imports, surrounding logic, module structure.
|
|
34
34
|
2. **Identify the project language(s)** and select the relevant transformation rules. Discard inapplicable rules.
|
|
35
35
|
3. **Survey adjacent code** — shared utilities, sibling modules, common patterns. You need to know what already exists before deciding something is missing.
|
|
36
|
-
4. **Run the project's gate once before editing** to establish a baseline.
|
|
36
|
+
4. **Run the project's gate once before editing** to establish a baseline. Use the gate the project's `CLAUDE.md` / `AGENTS.md` names; absent one, take it from `package.json` scripts — `devcheck` if present, else `check`, else the separate `typecheck` / `lint` scripts — or a `Makefile` `check` target. Python projects gate on `uv run ruff check`, `uv run ruff format --check`, and the configured type checker. Add the test suite when the gate doesn't run it — read the script rather than assume (a `devcheck` often stops at lint and typecheck); without tests, nothing shows behavior survived the pass. In a Bun project that tests with Vitest, run `bun run test` — bare `bun test` bypasses the script and runs Bun's own runner. If the gate is already red, say so in the summary and don't attribute the failure to your changes.
|
|
37
37
|
|
|
38
38
|
### Phase 3: Review
|
|
39
39
|
|
|
@@ -50,13 +50,15 @@ Evaluate the changes across these dimensions. Not every dimension applies to eve
|
|
|
50
50
|
|
|
51
51
|
- **Redundant state** — State that duplicates existing state, cached values that could be derived.
|
|
52
52
|
- **Unnecessary complexity** — Deep nesting that could be guard clauses, premature abstractions, over-engineered solutions to simple problems.
|
|
53
|
+
- **Speculative generality** — Options, parameters, config flags, generic type parameters, and branches that no caller exercises. Flexibility for a hypothetical caller is cost paid now: remove it, and let the first real use add it back. On a published package's public surface it is API — note it instead (see Dead code).
|
|
53
54
|
- **Pass-through layers** — Apply the deletion test to a wrapper, helper, or module: if deleting it and inlining its body makes the complexity vanish, it was a pass-through — inline it. If the same logic would reappear across several callers, it earns its keep. An interface, port, or injected dependency with a single implementation and no test double is a hypothetical seam, not a real one — collapse it until something actually varies across it.
|
|
54
55
|
- **Test-only reach** — A function extracted or exported only so a test can get at it is a shape problem, not a cleanup: name it in the summary with the module it belongs to. Don't restructure it here — the tests would have to move with it.
|
|
55
|
-
- **Dead code** — Unreachable branches, unused variables, commented-out code. An export nothing imports is dead in an application or a package-internal module; on a published package's public surface it is API — leave it and note it in the summary.
|
|
56
|
+
- **Dead code** — Unreachable branches, unused variables, commented-out code, and debug leftovers from the session (`console.log`, `print`, `debugger`) that aren't the program's real output or the project's logger. An export nothing imports is dead in an application or a package-internal module; on a published package's public surface it is API — leave it and note it in the summary.
|
|
56
57
|
- **Defensive code for impossible states** — Guards for cases the type system or upstream validation already prevents. Drop them.
|
|
57
|
-
- **Type escapes** — `any`, `as` casts that paper over a mismatch, non-null `!`,
|
|
58
|
+
- **Type escapes** — `any`, `as` casts that paper over a mismatch, non-null `!`, `@ts-ignore`, and Python's `# type: ignore` / `cast()`. Each is a claim the compiler couldn't check: replace with a narrowed type, a type guard, or a parse at the boundary. Keep the ones documenting a genuine type-system or third-party-types limitation — confirm the limitation is gone before removing one — and prefer `@ts-expect-error` with a one-line reason over `@ts-ignore`.
|
|
58
59
|
- **Swallowed errors** — Empty `catch {}`, `catch { return null }`, and `try` blocks that log and continue. A fallback that hides a failure is worse than the crash it prevents: rethrow or let it propagate. When wrapping, preserve the chain (`new Error(msg, { cause })`, `raise X from err`).
|
|
59
|
-
- **
|
|
60
|
+
- **Masking defaults** — `?? ''`, `|| []`, `?? 0`, `.get(key, {})` standing in for a value that must exist. The default turns a missing config key or a broken upstream into quietly wrong output further down. When the type already rules out absence, the default is dead — drop it; when the value is optional in the type but required in fact, replace the default with an error that names what's missing, where the value is read. Keep defaults only where absence is a legitimate, expected state.
|
|
61
|
+
- **Comment noise** — Strip comments that restate the code and comments describing behavior the diff removed. Keep file headers, export JSDoc, and any comment carrying a *why* — a constraint, a workaround, an upstream bug reference.
|
|
60
62
|
- **Outdated patterns** — Verbose or legacy syntax where modern equivalents exist. See the transformation tables below.
|
|
61
63
|
|
|
62
64
|
#### Efficiency
|
|
@@ -90,24 +92,31 @@ Evaluate the changes across these dimensions. Not every dimension applies to eve
|
|
|
90
92
|
3. **Correctness bugs are not this pass's job.** A real defect doesn't get folded into a cleanup diff — name it in the summary with file and line so it can be handled as its own change.
|
|
91
93
|
4. **Transform incrementally** — one category of change at a time (modernize syntax, then reduce nesting, then consolidate).
|
|
92
94
|
5. **Verify equivalence** — all functionality, types, and public interfaces must remain unchanged. Re-run the gate from Phase 2 after transforming; a simplification that breaks the build is worse than the verbosity it removed.
|
|
93
|
-
6. **Keep the diff minimal.** Only touch lines that have a real reason to change. Don't reformat untouched code, add comments to code you didn't modify, or "improve" things that are already fine. Formatting belongs to the formatter (Biome, ruff): never hand-adjust whitespace, quotes, or import order
|
|
94
|
-
7. **Never stage, commit, tag, or
|
|
95
|
+
6. **Keep the diff minimal.** Only touch lines that have a real reason to change. Don't reformat untouched code, add comments to code you didn't modify, or "improve" things that are already fine. Formatting belongs to the formatter (Biome, ruff): never hand-adjust whitespace, quotes, or import order. Hunks the project's formatter writes during a gate run stay, even outside the scope — reverting them only fights the next run; mention them in the summary.
|
|
96
|
+
7. **Never stage, commit, tag, push, or stash.** This pass ends with a dirty working tree and a summary; landing the changes is the caller's call. A stash hides the very changes under review — compare against the baseline with `git diff`, never by setting work aside.
|
|
95
97
|
|
|
96
|
-
When done,
|
|
98
|
+
When done, report:
|
|
99
|
+
|
|
100
|
+
- **Gate** — the result before and after the pass, so a failure that predates the cleanup isn't pinned on it.
|
|
101
|
+
- **Fixed** — what changed, grouped by category.
|
|
102
|
+
- **Skipped** — findings deliberately left, each with its reason.
|
|
103
|
+
- **Defects and recommendations** — correctness bugs and out-of-scope changes, each with `file:line`.
|
|
104
|
+
|
|
105
|
+
When nothing earned a change, say the code was already clean.
|
|
97
106
|
|
|
98
107
|
## Common transformations
|
|
99
108
|
|
|
100
109
|
The tables below cover TypeScript and Python. For other languages, apply analogous principles: prefer modern idioms, reduce nesting, eliminate dead code, follow project conventions. Check the project's language floor (`tsconfig` target/lib, `pyproject` `requires-python`) before applying a version-gated row.
|
|
101
110
|
|
|
102
|
-
### TypeScript (modern ESM
|
|
111
|
+
### TypeScript (modern ESM)
|
|
103
112
|
|
|
104
113
|
| Before | After | Why |
|
|
105
114
|
| --- | --- | --- |
|
|
106
115
|
| `const x: Foo = { ... } as Foo` | `const x = { ... } satisfies Foo` | Type-checked without assertion |
|
|
107
|
-
| `let resource = acquire(); try { ... } finally { release(resource) }` | `using resource = acquire()` | Explicit resource
|
|
116
|
+
| `let resource = acquire(); try { ... } finally { release(resource) }` | `using resource = acquire()` | Explicit resource management (TS 5.2+) — only when the resource implements `Symbol.dispose` (`await using` for `Symbol.asyncDispose`); otherwise the `try`/`finally` stays |
|
|
108
117
|
| `if (x !== null && x !== undefined)` | `if (x != null)` | Idiomatic null/undefined check |
|
|
109
118
|
| `arr.filter(x => x !== null) as T[]` | `arr.filter(x => x != null)` | TS 5.5+ infers the type predicate — no cast; on older TS use an explicit `(x): x is T` predicate |
|
|
110
|
-
| `
|
|
119
|
+
| `import { foo } from './index.js'` (a module importing through its own barrel) | `import { foo } from './foo.js'` | Inside a module, import siblings directly — routing through the module's own barrel invites import cycles. Across modules, the public barrel is the right entry point; leave those imports alone |
|
|
111
120
|
| `import { readFile } from 'fs/promises'` | `import { readFile } from 'node:fs/promises'` | `node:` protocol — unambiguous, lint-enforced in Biome |
|
|
112
121
|
| `async function f() { const a = await x(); const b = await y(); }` | `const [a, b] = await Promise.all([x(), y()])` | Parallel when independent |
|
|
113
122
|
| `value \|\| fallback` | `value ?? fallback` | `\|\|` also swallows `0`, `''`, and `false` — use `??` unless every falsy value really should take the fallback |
|
|
@@ -116,10 +125,14 @@ The tables below cover TypeScript and Python. For other languages, apply analogo
|
|
|
116
125
|
| `try { risky() } catch (e: any) { ... }` | `try { risky() } catch (e) { ... }` | Under `strict` the catch binding is already `unknown`; narrow with a type guard before use |
|
|
117
126
|
| `catch (err) { throw new Error('load failed') }` | `throw new Error('load failed', { cause: err })` | Preserve the cause chain |
|
|
118
127
|
| `[...arr].sort(cmp)` / `arr.slice().sort(cmp)` | `arr.toSorted(cmp)` | Non-mutating array methods (ES2023) — also `toReversed`, `toSpliced`, `with` |
|
|
128
|
+
| `arr[arr.length - 1]` | `arr.at(-1)` | ES2022 — typed `T \| undefined`: equivalent under `noUncheckedIndexedAccess`, a new `undefined` to handle otherwise |
|
|
129
|
+
| `arr.reduce((acc, x) => { (acc[key(x)] ??= []).push(x); return acc }, {})` | `Object.groupBy(arr, key)` | ES2024 — returns a null-prototype object whose values are typed `T[] \| undefined`; `Map.groupBy` for non-string keys |
|
|
130
|
+
| `let resolve!: (v: T) => void; const p = new Promise<T>((r) => { resolve = r })` | `const { promise, resolve, reject } = Promise.withResolvers<T>()` | ES2024 — the deferred without the captured-variable dance |
|
|
131
|
+
| `new Set([...a].filter((x) => b.has(x)))` | `a.intersection(b)` | ES2025 `Set` methods — also `union`, `difference`, `symmetricDifference`, `isSubsetOf`; the receiver must be a `Set` |
|
|
119
132
|
| `const c = new AbortController(); setTimeout(() => c.abort(), ms)` | `AbortSignal.timeout(ms)` | Built-in timeout signal; combine with a caller's signal via `AbortSignal.any([...])` |
|
|
120
|
-
| `JSON.parse(JSON.stringify(x))` | `structuredClone(x)` | Deep clone that
|
|
133
|
+
| `JSON.parse(JSON.stringify(x))` | `structuredClone(x)` | Deep clone that keeps Date, Map, Set, and cycles. Not a drop-in: it throws on functions and strips class prototypes, and Dates stay Dates instead of becoming strings |
|
|
121
134
|
| `enum Status { A, B, C }` | `const Status = { A: 'A', B: 'B', C: 'C' } as const` | `enum`, `namespace`, and constructor parameter properties are non-erasable syntax rejected by TS 5.8 `erasableSyntaxOnly` and Node type-stripping — but switching numeric values to strings changes serialized output; keep values stable if they're persisted |
|
|
122
|
-
| `function f(a: string, b: string, c: string, d?: string)` | `function f(opts: FnOptions)` |
|
|
135
|
+
| `function f(a: string, b: string, c: string, d?: string)` | `function f(opts: FnOptions)` | Internal functions whose same-typed positional params can be swapped and still type-check. An exported signature is API — leave it |
|
|
123
136
|
| `throw new Error('Bad input')` (in a tool handler) | `throw validationError('Bad input', { field: 'x' })` | Use framework error factories so the framework can classify and instrument |
|
|
124
137
|
| `const ATTR_KEY = 'mcp.tool.name'` | `import { ATTR_MCP_TOOL_NAME } from '@cyanheads/mcp-ts-core/utils'` | Use framework attribute constants |
|
|
125
138
|
|
|
@@ -134,13 +147,14 @@ The tables below cover TypeScript and Python. For other languages, apply analogo
|
|
|
134
147
|
| `if isinstance(x, Foo): a = x.a; b = x.b` | `match x: case Foo(a=a, b=b): ...` | Structural pattern matching (3.10+) where it destructures — not as a replacement for a flat equality `if/elif` chain |
|
|
135
148
|
| `class Config: def __init__(self, a, b, c): self.a = a ...` | `@dataclass(slots=True) class Config: a: str; b: int; c: float` | Less boilerplate, built-in eq/repr; `frozen=True` when instances shouldn't mutate |
|
|
136
149
|
| `results = []; for item in items: results.append(transform(item))` | `results = [transform(item) for item in items]` | Idiomatic comprehension |
|
|
150
|
+
| `[items[i:i + n] for i in range(0, len(items), n)]` | `itertools.batched(items, n)` | 3.12+ — works on any iterable, not just sequences; yields tuples, not lists |
|
|
137
151
|
| `f = open('x'); try: ... finally: f.close()` | `with open('x') as f: ...` | Context manager for resources |
|
|
138
152
|
| `os.path.join(d, n)`, `os.path.exists(p)`, `open(p).read()` | `Path(d) / n`, `p.exists()`, `p.read_text()` | `pathlib` over `os.path` string juggling |
|
|
139
153
|
| `datetime.utcnow()` / `datetime.utcfromtimestamp(t)` | `datetime.now(UTC)` / `datetime.fromtimestamp(t, UTC)` | Deprecated in 3.12 — the old calls return naive datetimes that compare wrong against aware ones |
|
|
140
|
-
| `zip(a, b)` | `zip(a, b, strict=True)` | 3.10+ — silently truncating
|
|
154
|
+
| `zip(a, b)` where the inputs must match in length | `zip(a, b, strict=True)` | 3.10+ — a mismatch raises instead of silently truncating; plain `zip` stays where truncation is intended |
|
|
141
155
|
| `m = pattern.match(s)` then `if m: use(m)` | `if (m := pattern.match(s)): use(m)` | Walrus operator where it removes a throwaway assignment |
|
|
142
156
|
| `"Hello " + name + "!"` | `f"Hello {name}!"` | f-string over concatenation |
|
|
143
|
-
| `except Exception
|
|
157
|
+
| `except Exception: pass` / `except Exception as e: log(e)` | `except SpecificError:` with real handling, or no `try` at all | Catch only what you can handle; everything else propagates (see Swallowed errors) |
|
|
144
158
|
| `from module import *` | `from module import specific_name` | Explicit imports only |
|
|
145
159
|
| Sequential `await` for independent I/O | `async with asyncio.TaskGroup() as tg: tg.create_task(a()); tg.create_task(b())` | Structured concurrency (3.11+) — cancels siblings on failure and raises an `ExceptionGroup`; `asyncio.gather(..., return_exceptions=True)` stays correct when every result is wanted regardless of failures |
|
|
146
160
|
|
|
@@ -154,7 +168,6 @@ Leave code alone when:
|
|
|
154
168
|
- **Performance-critical paths.** A less readable version may exist for measured performance reasons — check before simplifying.
|
|
155
169
|
- **API compatibility.** Don't change public function signatures, export shapes, or return types that callers depend on.
|
|
156
170
|
- **Tests.** Don't DRY up test code aggressively — test readability and isolation matter more than deduplication.
|
|
157
|
-
- **Type workarounds.** Sometimes an `as` cast or `# type: ignore` exists because of a genuine type system limitation — verify before removing.
|
|
158
171
|
- **The abstraction isn't proven.** Don't create a shared utility for two similar blocks of code. Wait until there are three, and even then only if the abstraction is genuinely simpler than the duplication.
|
|
159
172
|
- **`return await` inside `try` / `finally`.** Collapsing it to `return` is not equivalent — the promise settles outside the block, so `catch` never fires and `finally` runs early. Only strip `await` from a `return` in plain function-body position.
|
|
160
173
|
- **Lazy logging arguments.** `logger.info("loaded %s in %sms", name, ms)` defers formatting until the record is emitted — don't turn it into an f-string.
|