@cyanheads/mcp-ts-core 0.13.5 → 0.13.7

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.
Files changed (157) hide show
  1. package/AGENTS.md +6 -6
  2. package/CLAUDE.md +6 -6
  3. package/README.md +55 -52
  4. package/biome.json +2 -2
  5. package/changelog/0.13.x/0.13.6.md +49 -0
  6. package/changelog/0.13.x/0.13.7.md +77 -0
  7. package/config/tsconfig.base.json +2 -2
  8. package/dist/config/index.d.ts.map +1 -1
  9. package/dist/config/index.js +42 -11
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/core/app.d.ts.map +1 -1
  12. package/dist/core/app.js +21 -4
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/context.d.ts +9 -1
  15. package/dist/core/context.d.ts.map +1 -1
  16. package/dist/core/context.js +4 -13
  17. package/dist/core/context.js.map +1 -1
  18. package/dist/core/worker.d.ts.map +1 -1
  19. package/dist/core/worker.js +7 -1
  20. package/dist/core/worker.js.map +1 -1
  21. package/dist/linter/rules/enrichment-rules.d.ts +5 -4
  22. package/dist/linter/rules/enrichment-rules.d.ts.map +1 -1
  23. package/dist/linter/rules/enrichment-rules.js +99 -22
  24. package/dist/linter/rules/enrichment-rules.js.map +1 -1
  25. package/dist/linter/rules/error-contract-rules.d.ts +46 -10
  26. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  27. package/dist/linter/rules/error-contract-rules.js +180 -27
  28. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  29. package/dist/linter/rules/format-parity-rules.js +1 -1
  30. package/dist/linter/rules/format-parity-rules.js.map +1 -1
  31. package/dist/linter/rules/index.d.ts +1 -1
  32. package/dist/linter/rules/index.d.ts.map +1 -1
  33. package/dist/linter/rules/index.js +1 -1
  34. package/dist/linter/rules/index.js.map +1 -1
  35. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  36. package/dist/linter/rules/resource-rules.js +2 -1
  37. package/dist/linter/rules/resource-rules.js.map +1 -1
  38. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  39. package/dist/linter/rules/tool-rules.js +2 -1
  40. package/dist/linter/rules/tool-rules.js.map +1 -1
  41. package/dist/mcp-server/handlerContext.d.ts +6 -0
  42. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  43. package/dist/mcp-server/handlerContext.js +3 -0
  44. package/dist/mcp-server/handlerContext.js.map +1 -1
  45. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  46. package/dist/mcp-server/prompts/prompt-registration.js +6 -3
  47. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  48. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  49. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +5 -1
  50. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  51. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  52. package/dist/mcp-server/transports/http/httpErrorHandler.js +15 -5
  53. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  54. package/dist/storage/core/IStorageProvider.d.ts +5 -2
  55. package/dist/storage/core/IStorageProvider.d.ts.map +1 -1
  56. package/dist/storage/core/providerHelpers.d.ts +29 -8
  57. package/dist/storage/core/providerHelpers.d.ts.map +1 -1
  58. package/dist/storage/core/providerHelpers.js +49 -11
  59. package/dist/storage/core/providerHelpers.js.map +1 -1
  60. package/dist/storage/providers/cloudflare/d1Provider.js +4 -4
  61. package/dist/storage/providers/cloudflare/d1Provider.js.map +1 -1
  62. package/dist/storage/providers/cloudflare/kvProvider.d.ts +2 -0
  63. package/dist/storage/providers/cloudflare/kvProvider.d.ts.map +1 -1
  64. package/dist/storage/providers/cloudflare/kvProvider.js +11 -9
  65. package/dist/storage/providers/cloudflare/kvProvider.js.map +1 -1
  66. package/dist/storage/providers/cloudflare/r2Provider.d.ts.map +1 -1
  67. package/dist/storage/providers/cloudflare/r2Provider.js +8 -5
  68. package/dist/storage/providers/cloudflare/r2Provider.js.map +1 -1
  69. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts +1 -0
  70. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts.map +1 -1
  71. package/dist/storage/providers/fileSystem/fileSystemProvider.js +10 -8
  72. package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
  73. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts +5 -0
  74. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts.map +1 -1
  75. package/dist/storage/providers/inMemory/inMemoryProvider.js +9 -5
  76. package/dist/storage/providers/inMemory/inMemoryProvider.js.map +1 -1
  77. package/dist/storage/providers/supabase/supabaseProvider.d.ts.map +1 -1
  78. package/dist/storage/providers/supabase/supabaseProvider.js +5 -1
  79. package/dist/storage/providers/supabase/supabaseProvider.js.map +1 -1
  80. package/dist/testing/index.d.ts +6 -4
  81. package/dist/testing/index.d.ts.map +1 -1
  82. package/dist/testing/index.js +6 -4
  83. package/dist/testing/index.js.map +1 -1
  84. package/dist/types-global/errors.d.ts +18 -0
  85. package/dist/types-global/errors.d.ts.map +1 -1
  86. package/dist/utils/formatting/partialResult.d.ts +28 -2
  87. package/dist/utils/formatting/partialResult.d.ts.map +1 -1
  88. package/dist/utils/formatting/partialResult.js +46 -2
  89. package/dist/utils/formatting/partialResult.js.map +1 -1
  90. package/dist/utils/internal/error-handler/errorHandler.d.ts +8 -2
  91. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  92. package/dist/utils/internal/error-handler/errorHandler.js +27 -16
  93. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  94. package/dist/utils/internal/error-handler/mappings.d.ts +1 -0
  95. package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
  96. package/dist/utils/internal/error-handler/mappings.js +1 -0
  97. package/dist/utils/internal/error-handler/mappings.js.map +1 -1
  98. package/dist/utils/internal/performance.d.ts +5 -1
  99. package/dist/utils/internal/performance.d.ts.map +1 -1
  100. package/dist/utils/internal/performance.js +13 -8
  101. package/dist/utils/internal/performance.js.map +1 -1
  102. package/dist/utils/security/sanitization.d.ts +15 -15
  103. package/dist/utils/security/sanitization.d.ts.map +1 -1
  104. package/dist/utils/security/sanitization.js +108 -88
  105. package/dist/utils/security/sanitization.js.map +1 -1
  106. package/dist/utils/telemetry/instrumentation.d.ts +6 -2
  107. package/dist/utils/telemetry/instrumentation.d.ts.map +1 -1
  108. package/dist/utils/telemetry/instrumentation.js +23 -8
  109. package/dist/utils/telemetry/instrumentation.js.map +1 -1
  110. package/framework-skills/add-app-tool/SKILL.md +12 -18
  111. package/framework-skills/add-prompt/SKILL.md +3 -1
  112. package/framework-skills/add-provider/SKILL.md +14 -4
  113. package/framework-skills/add-resource/SKILL.md +3 -3
  114. package/framework-skills/add-service/SKILL.md +5 -2
  115. package/framework-skills/add-tool/SKILL.md +25 -8
  116. package/framework-skills/api-canvas/SKILL.md +2 -2
  117. package/framework-skills/api-config/SKILL.md +4 -3
  118. package/framework-skills/api-context/SKILL.md +8 -5
  119. package/framework-skills/api-errors/SKILL.md +25 -5
  120. package/framework-skills/api-linter/SKILL.md +95 -22
  121. package/framework-skills/api-telemetry/SKILL.md +9 -4
  122. package/framework-skills/api-testing/SKILL.md +21 -13
  123. package/framework-skills/api-utils/SKILL.md +3 -3
  124. package/framework-skills/api-utils/references/security.md +7 -6
  125. package/framework-skills/code-simplifier/SKILL.md +31 -18
  126. package/framework-skills/design-mcp-server/SKILL.md +62 -35
  127. package/framework-skills/git-wrapup/SKILL.md +16 -10
  128. package/framework-skills/maintenance/SKILL.md +2 -2
  129. package/framework-skills/orchestrations/SKILL.md +1 -1
  130. package/framework-skills/orchestrations/workflows/greenfield-build.md +15 -8
  131. package/framework-skills/polish-docs-meta/SKILL.md +2 -2
  132. package/framework-skills/polish-docs-meta/references/package-meta.md +1 -1
  133. package/framework-skills/polish-docs-meta/references/readme.md +3 -3
  134. package/framework-skills/release-and-publish/SKILL.md +6 -4
  135. package/framework-skills/release-pr-review/SKILL.md +18 -1
  136. package/framework-skills/report-issue-framework/SKILL.md +2 -2
  137. package/framework-skills/report-issue-local/SKILL.md +3 -3
  138. package/framework-skills/security-pass/SKILL.md +11 -3
  139. package/framework-skills/tool-defs-analysis/SKILL.md +3 -3
  140. package/package.json +16 -37
  141. package/scripts/lint-mcp.ts +43 -4
  142. package/templates/.env.example +3 -1
  143. package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +1 -1
  144. package/templates/AGENTS.md +3 -3
  145. package/templates/CLAUDE.md +3 -3
  146. package/templates/Dockerfile +4 -4
  147. package/templates/devcheck.config.json +1 -0
  148. package/templates/package.json +4 -4
  149. package/templates/src/mcp-server/prompts/definitions/echo.prompt.ts +2 -4
  150. package/templates/src/mcp-server/resources/definitions/echo-app-ui.app-resource.ts +51 -14
  151. package/templates/src/mcp-server/resources/definitions/echo.resource.ts +1 -1
  152. package/templates/src/mcp-server/tools/definitions/echo-app.app-tool.ts +2 -3
  153. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +8 -2
  154. package/dist/utils/telemetry/index.d.ts +0 -12
  155. package/dist/utils/telemetry/index.d.ts.map +0 -1
  156. package/dist/utils/telemetry/index.js +0 -12
  157. 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.10"
7
+ version: "1.11"
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`, and `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider`.
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
 
@@ -101,6 +101,15 @@ try {
101
101
 
102
102
  Routes match in registration order. `match` accepts an exact URL, `RegExp`, or request predicate; `respond` accepts a clonable `Response` or response factory. Set `once: true` for one-shot behavior. Unmatched requests throw unless `onUnhandled` is provided.
103
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
+ ```
112
+
104
113
  ---
105
114
 
106
115
  ## Tool conformance with `toolContractSuite`
@@ -188,6 +197,7 @@ interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefine
188
197
  `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
198
 
190
199
  - **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`.
200
+ - **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
201
  - **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
202
  - **`getMany` / `setMany` / `deleteMany` / `list`** validate every key and prefix, and `list` paginates with the same opaque cursors.
193
203
  - **Cancellation** applies: once `ctx.signal` aborts, state operations reject.
@@ -198,6 +208,9 @@ const ctx = createMockContext();
198
208
  await ctx.state.set('cache/v1/abc', { hits: 1 }, { ttl: 30 });
199
209
  await expect(ctx.state.get('cache/v1/abc')).resolves.toEqual({ hits: 1 });
200
210
  await expect(ctx.state.set('cache:v1:abc', {})).rejects.toThrow(McpError);
211
+
212
+ await ctx.state.set('seen/abc', { at: new Date('2026-01-01T00:00:00Z') });
213
+ await expect(ctx.state.get('seen/abc')).resolves.toEqual({ at: '2026-01-01T00:00:00.000Z' });
201
214
  ```
202
215
 
203
216
  Reach for `createInMemoryStorage()` when a service takes a `StorageService` directly — it builds the same pair.
@@ -216,21 +229,16 @@ it('asks for confirmation on the first round', async () => {
216
229
  });
217
230
  ```
218
231
 
219
- To assert on *what* was requested, catch it and read `error.result` — the `input_required` result the handler factory would have returned:
232
+ 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
233
 
221
234
  ```ts
222
- async function requestedInput(input: ToolInput, options: MockContextOptions = {}) {
223
- try {
224
- await myTool.handler(input, createMockContext(options));
225
- } catch (error) {
226
- if (isInputRequiredSignal(error)) return error.result;
227
- throw error;
228
- }
229
- throw new Error('Expected the handler to request input.');
230
- }
235
+ import { createMockContext, expectInputRequired } from '@cyanheads/mcp-ts-core/testing';
236
+
237
+ const asked = await expectInputRequired(() => myTool.handler(input, createMockContext()));
238
+ expect(asked.inputRequests?.confirm?.method).toBe('elicitation/create');
231
239
  ```
232
240
 
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:
241
+ 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
242
 
235
243
  ```ts
236
244
  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.11"
7
+ version: "2.12"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -47,7 +47,7 @@ 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. 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
52
  | `decodeCursor` | `(cursor, context: RequestContext) -> PaginationState` | Decodes opaque base64url cursor. Throws `McpError(InvalidParams)` if malformed. |
53
53
 
@@ -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.
@@ -8,16 +8,16 @@ import { sanitization, RateLimiter, IdGenerator, idGenerator, generateUUID, gene
8
8
 
9
9
  ## `sanitization`
10
10
 
11
- Pre-constructed singleton of `Sanitization`. Tier 3 peers: `sanitize-html`, `validator` (install as needed per method).
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` / `validator` | `(input, options?) -> Promise<string>` |
19
- | `sanitizeUrl` | yes | `validator` | `(input, allowedProtocols?) -> Promise<string>` |
20
- | `sanitizeNumber` | yes | `validator` (string input) | `(input, min?, max?) -> Promise<number>` |
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` |
@@ -59,7 +59,8 @@ 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
@@ -81,7 +82,7 @@ const clean = await sanitization.sanitizeHtml(userHtml, {
81
82
  });
82
83
 
83
84
  // URL validation
84
- const safeUrl = await sanitization.sanitizeUrl(userUrl, ['http', 'https', 'mailto']);
85
+ const safeUrl = await sanitization.sanitizeUrl(userUrl, ['http', 'https', 'ftp']);
85
86
 
86
87
  // Path sanitization (Node-only)
87
88
  const info = sanitization.sanitizePath(userPath, { rootDir: '/app/data', allowAbsolute: false });
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: code-simplifier
3
3
  description: >
4
- Code review and cleanup against a working tree of changes, or against a named path or whole codebase. Analyzes `git diff` (or the named target) to simplify, consolidate, and align code with the existing codebase — modernize syntax, remove unnecessary complexity, consolidate duplicated logic, catch efficiency issues. 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.
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.5"
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 — read new files 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.
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. Find it in `package.json` scripts — `devcheck` if present, else `check`, else the separate `typecheck` / `lint` / `test` scripts; Python projects gate on `uv run ruff check`, `uv run ruff format --check`, and the configured type checker and test runner. 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.
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 `!`, and `@ts-ignore`. 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, and prefer `@ts-expect-error` with a one-line reason over `@ts-ignore`.
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
- - **Comment noise** — Strip comments that restate the code, commented-out 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
+ - **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, and never let a formatting-only hunk into the diff.
94
- 7. **Never stage, commit, tag, or push.** This pass ends with a dirty working tree and a summary; landing the changes is the caller's call.
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, briefly summarize what was fixed, what was deliberately skipped, and any defects or out-of-scope recommendations — or confirm the code was already clean.
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, TS 5.x+)
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 disposal (TS 5.2+) |
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
- | `export { foo } from './foo/index.js'` | Direct imports at call sites | Avoid barrel re-exports inside the package; barrel exports are for public APIs only |
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 preserves Date, Map, Set, and cycles |
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)` | Options object when >3 params |
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 to the shorter input hides bugs |
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 as e: pass` | `except SpecificError as e: log(e)` | Catch specific, never bare except/pass |
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.