@cyanheads/mcp-ts-core 0.13.3 → 0.13.5

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 (162) hide show
  1. package/AGENTS.md +9 -7
  2. package/CLAUDE.md +9 -7
  3. package/README.md +1 -1
  4. package/changelog/0.13.x/0.13.4.md +65 -0
  5. package/changelog/0.13.x/0.13.5.md +41 -0
  6. package/changelog/template.md +7 -7
  7. package/dist/config/appRoot.d.ts.map +1 -1
  8. package/dist/config/appRoot.js +48 -16
  9. package/dist/config/appRoot.js.map +1 -1
  10. package/dist/core/app.d.ts +20 -0
  11. package/dist/core/app.d.ts.map +1 -1
  12. package/dist/core/app.js +1 -0
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/index.d.ts +1 -0
  15. package/dist/core/index.d.ts.map +1 -1
  16. package/dist/core/index.js.map +1 -1
  17. package/dist/linter/rules/error-contract-rules.d.ts +65 -3
  18. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  19. package/dist/linter/rules/error-contract-rules.js +138 -3
  20. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  21. package/dist/linter/rules/index.d.ts +1 -1
  22. package/dist/linter/rules/index.d.ts.map +1 -1
  23. package/dist/linter/rules/index.js +1 -1
  24. package/dist/linter/rules/index.js.map +1 -1
  25. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  26. package/dist/linter/rules/resource-rules.js +4 -2
  27. package/dist/linter/rules/resource-rules.js.map +1 -1
  28. package/dist/linter/rules/schema-rules.d.ts +19 -0
  29. package/dist/linter/rules/schema-rules.d.ts.map +1 -1
  30. package/dist/linter/rules/schema-rules.js +36 -0
  31. package/dist/linter/rules/schema-rules.js.map +1 -1
  32. package/dist/linter/rules/tool-rules.d.ts +17 -0
  33. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  34. package/dist/linter/rules/tool-rules.js +101 -3
  35. package/dist/linter/rules/tool-rules.js.map +1 -1
  36. package/dist/mcp-server/handlerContext.d.ts +11 -2
  37. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  38. package/dist/mcp-server/handlerContext.js +6 -4
  39. package/dist/mcp-server/handlerContext.js.map +1 -1
  40. package/dist/mcp-server/inputRequired.d.ts +35 -2
  41. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  42. package/dist/mcp-server/inputRequired.js +116 -2
  43. package/dist/mcp-server/inputRequired.js.map +1 -1
  44. package/dist/mcp-server/resources/resource-registration.d.ts +2 -1
  45. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  46. package/dist/mcp-server/resources/resource-registration.js +4 -4
  47. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  48. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +2 -1
  49. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  50. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -2
  51. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  52. package/dist/mcp-server/server.d.ts.map +1 -1
  53. package/dist/mcp-server/server.js +11 -2
  54. package/dist/mcp-server/server.js.map +1 -1
  55. package/dist/mcp-server/tools/tool-registration.d.ts +2 -1
  56. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  57. package/dist/mcp-server/tools/tool-registration.js +4 -4
  58. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  59. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +114 -0
  60. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -0
  61. package/dist/mcp-server/tools/utils/inputPrevalidation.js +428 -0
  62. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -0
  63. package/dist/mcp-server/tools/utils/strictenRecord.d.ts +42 -0
  64. package/dist/mcp-server/tools/utils/strictenRecord.d.ts.map +1 -0
  65. package/dist/mcp-server/tools/utils/strictenRecord.js +48 -0
  66. package/dist/mcp-server/tools/utils/strictenRecord.js.map +1 -0
  67. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +27 -0
  68. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  69. package/dist/mcp-server/tools/utils/toolDefinition.js +35 -6
  70. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  71. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +33 -5
  72. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  73. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +136 -20
  74. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  75. package/dist/services/canvas/core/CanvasInstance.d.ts +4 -1
  76. package/dist/services/canvas/core/CanvasInstance.d.ts.map +1 -1
  77. package/dist/services/canvas/core/CanvasInstance.js +5 -0
  78. package/dist/services/canvas/core/CanvasInstance.js.map +1 -1
  79. package/dist/services/canvas/core/CanvasRegistry.d.ts +52 -1
  80. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  81. package/dist/services/canvas/core/CanvasRegistry.js +79 -3
  82. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  83. package/dist/services/canvas/core/sqlGate.d.ts +14 -1
  84. package/dist/services/canvas/core/sqlGate.d.ts.map +1 -1
  85. package/dist/services/canvas/core/sqlGate.js +69 -7
  86. package/dist/services/canvas/core/sqlGate.js.map +1 -1
  87. package/dist/services/canvas/index.d.ts +2 -2
  88. package/dist/services/canvas/index.d.ts.map +1 -1
  89. package/dist/services/canvas/index.js +2 -2
  90. package/dist/services/canvas/index.js.map +1 -1
  91. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +20 -0
  92. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  93. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +75 -25
  94. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  95. package/dist/services/canvas/types.d.ts +6 -1
  96. package/dist/services/canvas/types.d.ts.map +1 -1
  97. package/dist/types-global/errors.d.ts +30 -0
  98. package/dist/types-global/errors.d.ts.map +1 -1
  99. package/dist/types-global/errors.js +4 -3
  100. package/dist/types-global/errors.js.map +1 -1
  101. package/dist/utils/index.d.ts +3 -2
  102. package/dist/utils/index.d.ts.map +1 -1
  103. package/dist/utils/index.js +3 -2
  104. package/dist/utils/index.js.map +1 -1
  105. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  106. package/dist/utils/internal/error-handler/errorHandler.js +13 -3
  107. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  108. package/dist/utils/internal/error-handler/mappings.d.ts +14 -1
  109. package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
  110. package/dist/utils/internal/error-handler/mappings.js +19 -1
  111. package/dist/utils/internal/error-handler/mappings.js.map +1 -1
  112. package/dist/utils/internal/error-handler/types.d.ts +12 -1
  113. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  114. package/dist/utils/internal/performance.d.ts.map +1 -1
  115. package/dist/utils/internal/performance.js +4 -1
  116. package/dist/utils/internal/performance.js.map +1 -1
  117. package/dist/utils/network/fetchWithTimeout.d.ts +24 -4
  118. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  119. package/dist/utils/network/fetchWithTimeout.js +10 -6
  120. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  121. package/dist/utils/network/httpError.d.ts +56 -6
  122. package/dist/utils/network/httpError.d.ts.map +1 -1
  123. package/dist/utils/network/httpError.js +56 -7
  124. package/dist/utils/network/httpError.js.map +1 -1
  125. package/dist/utils/network/pacer.d.ts +117 -0
  126. package/dist/utils/network/pacer.d.ts.map +1 -0
  127. package/dist/utils/network/pacer.js +304 -0
  128. package/dist/utils/network/pacer.js.map +1 -0
  129. package/dist/utils/network/retry.d.ts +119 -3
  130. package/dist/utils/network/retry.d.ts.map +1 -1
  131. package/dist/utils/network/retry.js +176 -35
  132. package/dist/utils/network/retry.js.map +1 -1
  133. package/dist/utils/security/rateLimiter.d.ts +19 -1
  134. package/dist/utils/security/rateLimiter.d.ts.map +1 -1
  135. package/dist/utils/security/rateLimiter.js +49 -1
  136. package/dist/utils/security/rateLimiter.js.map +1 -1
  137. package/dist/utils/telemetry/attributes.d.ts +27 -0
  138. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  139. package/dist/utils/telemetry/attributes.js +38 -0
  140. package/dist/utils/telemetry/attributes.js.map +1 -1
  141. package/framework-skills/add-tool/SKILL.md +49 -5
  142. package/framework-skills/api-canvas/SKILL.md +37 -16
  143. package/framework-skills/api-config/SKILL.md +4 -4
  144. package/framework-skills/api-context/SKILL.md +4 -2
  145. package/framework-skills/api-errors/SKILL.md +39 -7
  146. package/framework-skills/api-linter/SKILL.md +92 -5
  147. package/framework-skills/api-telemetry/SKILL.md +43 -4
  148. package/framework-skills/api-utils/SKILL.md +9 -5
  149. package/framework-skills/api-utils/references/security.md +2 -2
  150. package/framework-skills/design-mcp-server/SKILL.md +20 -3
  151. package/framework-skills/field-test/SKILL.md +4 -2
  152. package/framework-skills/git-wrapup/SKILL.md +87 -69
  153. package/framework-skills/release-and-publish/SKILL.md +5 -5
  154. package/framework-skills/release-pr-review/SKILL.md +5 -5
  155. package/framework-skills/report-issue-framework/SKILL.md +6 -35
  156. package/framework-skills/report-issue-local/SKILL.md +6 -36
  157. package/package.json +2 -2
  158. package/templates/.github/ISSUE_TEMPLATE/bug_report.yml +2 -2
  159. package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +2 -2
  160. package/templates/AGENTS.md +1 -1
  161. package/templates/CLAUDE.md +1 -1
  162. package/templates/changelog/template.md +7 -7
@@ -10,11 +10,18 @@ import { JsonRpcErrorCode, McpError } from '../../types-global/errors.js';
10
10
  * 4xx/5xx range, with specific mappings for the codes most upstream APIs use to
11
11
  * signal authoritative outcomes (auth failures, conflicts, validation, rate limits).
12
12
  *
13
- * Returns `undefined` when the status is in the 1xx/2xx/3xx range — those are not
13
+ * Returns `undefined` when the status is in the 1xx/2xx range — those are not
14
14
  * errors and the caller should not be invoking error mapping on them.
15
15
  *
16
+ * A 3xx does reach error mapping: under `redirect: 'manual'` the caller gets the
17
+ * redirect back rather than the followed response, and `!response.ok` throws. It
18
+ * maps like an unlisted 4xx — the request as sent cannot be served at this URL —
19
+ * which also keeps it out of `withRetry`'s transient set, since re-issuing it
20
+ * returns the same redirect.
21
+ *
16
22
  * | Status | Code |
17
23
  * |:-------|:-----|
24
+ * | 3xx | `InvalidRequest` |
18
25
  * | 400 | `InvalidParams` |
19
26
  * | 401 | `Unauthorized` |
20
27
  * | 402 | `Forbidden` (payment-required, treated as access denial) |
@@ -57,6 +64,26 @@ export declare function httpStatusToErrorCode(status: number): JsonRpcErrorCode
57
64
  export declare function httpStatusRetryability(status: number): {
58
65
  retryable: false;
59
66
  } | undefined;
67
+ /**
68
+ * The selected response headers as a fragment to spread into an `McpError`'s
69
+ * `data`, mirroring {@link httpStatusRetryability}'s shape: `undefined` when
70
+ * nothing was captured, so the spread adds no `headers` key at all.
71
+ *
72
+ * Selection is case-insensitive and keys are lowercased, so `['X-Request-Id']`
73
+ * and `['x-request-id']` collapse to one `headers['x-request-id']` entry.
74
+ * Presence follows `Headers.has()` — a header carried with an empty value is
75
+ * captured as `''`, one the response does not carry adds no key — and a
76
+ * multi-valued field is captured comma-joined, as `Headers.get()` returns it.
77
+ *
78
+ * Every captured value reaches the MCP client as `structuredContent.error.data`,
79
+ * which is why capture is an explicit allowlist rather than a redacted copy of
80
+ * every header: an arbitrary upstream header name can carry a secret, so no
81
+ * field-name redactor can make the default safe. Both HTTP helpers route through
82
+ * here so the two cannot drift apart.
83
+ */
84
+ export declare function selectErrorHeaders(headers: Headers, selector: readonly string[] | undefined): {
85
+ headers: Record<string, string>;
86
+ } | undefined;
60
87
  /** Configuration for {@link httpErrorFromResponse}. */
61
88
  export interface HttpErrorFromResponseOptions {
62
89
  /**
@@ -82,14 +109,36 @@ export interface HttpErrorFromResponseOptions {
82
109
  codeOverride?: (status: number) => JsonRpcErrorCode | undefined;
83
110
  /**
84
111
  * Additional fields merged into `error.data`. Always includes
85
- * `{ url, status, statusText, body? }` from the response itself, plus the
86
- * legacy aliases `statusCode` (= `status`) and `responseBody` (= `body`) for
112
+ * `{ status, statusText, body? }` from the response itself, plus the legacy
113
+ * aliases `statusCode` (= `status`) and `responseBody` (= `body`) for
87
114
  * consumers reading `fetchWithTimeout`'s shape — slated for consolidation in a
88
115
  * future major. A status whose failure is permanent also carries
89
116
  * `retryable: false` (see {@link httpStatusRetryability}). Fields passed here
90
117
  * override the defaults on key collision.
118
+ *
119
+ * `error.data` is forwarded to the client as `structuredContent.error.data`,
120
+ * so treat everything put here as client-visible.
91
121
  */
92
122
  data?: Record<string, unknown>;
123
+ /**
124
+ * Response headers to copy onto `error.data.headers` under lowercase keys —
125
+ * provider budget headers (`x-ratelimit-remaining-usd`), a quota reset, a
126
+ * request ID. Omitted or empty, no `headers` key is emitted. `set-cookie` is
127
+ * never captured. See {@link selectErrorHeaders} for the selection semantics.
128
+ *
129
+ * Every selected value reaches the client alongside the rest of `error.data`,
130
+ * so never name a header that carries a credential — and note that a
131
+ * `Location` can itself carry a sensitive path, query, or token.
132
+ */
133
+ errorHeaders?: string[];
134
+ /**
135
+ * Put the full `response.url` — path and query string included — on
136
+ * `error.data.url`. Default: `false`, because `error.data` reaches the client
137
+ * and an upstream request URL routinely carries user input, internal
138
+ * identifiers, or an API key in its query string. The error message still
139
+ * names the host either way. No key is added when `response.url` is empty.
140
+ */
141
+ includeUrl?: boolean;
93
142
  /**
94
143
  * Logical service name included in the error message
95
144
  * (e.g., `'NCBI'` → `"NCBI returned HTTP 429"`). When omitted, the message
@@ -104,9 +153,10 @@ export interface HttpErrorFromResponseOptions {
104
153
  * Reads the response body (consuming it) when `captureBody` is true, so callers
105
154
  * must `response.clone()` first if they intend to read the body elsewhere.
106
155
  *
107
- * Always returns an `McpError` even for 1xx/2xx/3xx — the caller is expected to
108
- * have verified `!response.ok` first, but the helper falls back to a sensible
109
- * code (`InternalError`) instead of silently producing nothing.
156
+ * Always returns an `McpError` even for 1xx/2xx — the caller is expected to have
157
+ * verified `!response.ok` first, but the helper falls back to a sensible code
158
+ * (`InternalError`) instead of silently producing nothing. A 3xx is a real
159
+ * outcome here rather than a fallback: see {@link httpStatusToErrorCode}.
110
160
  *
111
161
  * @example
112
162
  * ```ts
@@ -1 +1 @@
1
- {"version":3,"file":"httpError.d.ts","sourceRoot":"","sources":["../../../src/utils/network/httpError.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAGtE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CA+BlF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,SAAS,EAAE,KAAK,CAAA;CAAE,GAAG,SAAS,CAEvF;AAED,uDAAuD;AACvD,MAAM,WAAW,4BAA4B;IAC3C;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,gBAAgB,GAAG,SAAS,CAAC;IAChE;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE,4BAAiC,GACzC,OAAO,CAAC,QAAQ,CAAC,CAmDnB"}
1
+ {"version":3,"file":"httpError.d.ts","sourceRoot":"","sources":["../../../src/utils/network/httpError.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAGtE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAgClF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,SAAS,EAAE,KAAK,CAAA;CAAE,GAAG,SAAS,CAEvF;AAUD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GACtC;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,SAAS,CAWjD;AAED,uDAAuD;AACvD,MAAM,WAAW,4BAA4B;IAC3C;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,gBAAgB,GAAG,SAAS,CAAC;IAChE;;;;;;;;;;;OAWG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE,4BAAiC,GACzC,OAAO,CAAC,QAAQ,CAAC,CAwDnB"}
@@ -11,11 +11,18 @@ import { readBoundedResponseText } from './responseBody.js';
11
11
  * 4xx/5xx range, with specific mappings for the codes most upstream APIs use to
12
12
  * signal authoritative outcomes (auth failures, conflicts, validation, rate limits).
13
13
  *
14
- * Returns `undefined` when the status is in the 1xx/2xx/3xx range — those are not
14
+ * Returns `undefined` when the status is in the 1xx/2xx range — those are not
15
15
  * errors and the caller should not be invoking error mapping on them.
16
16
  *
17
+ * A 3xx does reach error mapping: under `redirect: 'manual'` the caller gets the
18
+ * redirect back rather than the followed response, and `!response.ok` throws. It
19
+ * maps like an unlisted 4xx — the request as sent cannot be served at this URL —
20
+ * which also keeps it out of `withRetry`'s transient set, since re-issuing it
21
+ * returns the same redirect.
22
+ *
17
23
  * | Status | Code |
18
24
  * |:-------|:-----|
25
+ * | 3xx | `InvalidRequest` |
19
26
  * | 400 | `InvalidParams` |
20
27
  * | 401 | `Unauthorized` |
21
28
  * | 402 | `Forbidden` (payment-required, treated as access denial) |
@@ -43,8 +50,10 @@ import { readBoundedResponseText } from './responseBody.js';
43
50
  * non-transient code.
44
51
  */
45
52
  export function httpStatusToErrorCode(status) {
46
- if (status < 400)
53
+ if (status < 300)
47
54
  return;
55
+ if (status < 400)
56
+ return JsonRpcErrorCode.InvalidRequest;
48
57
  switch (status) {
49
58
  case 400:
50
59
  return JsonRpcErrorCode.InvalidParams;
@@ -89,6 +98,42 @@ export function httpStatusToErrorCode(status) {
89
98
  export function httpStatusRetryability(status) {
90
99
  return status === 501 ? { retryable: false } : undefined;
91
100
  }
101
+ /**
102
+ * Response headers no selector can reach. `set-cookie` is credential-bearing,
103
+ * and `Headers.get()` joins its values into a string that is not a valid
104
+ * reconstruction of the field, so capturing it would leak a secret and misreport
105
+ * it at the same time.
106
+ */
107
+ const NEVER_CAPTURED_HEADERS = new Set(['set-cookie']);
108
+ /**
109
+ * The selected response headers as a fragment to spread into an `McpError`'s
110
+ * `data`, mirroring {@link httpStatusRetryability}'s shape: `undefined` when
111
+ * nothing was captured, so the spread adds no `headers` key at all.
112
+ *
113
+ * Selection is case-insensitive and keys are lowercased, so `['X-Request-Id']`
114
+ * and `['x-request-id']` collapse to one `headers['x-request-id']` entry.
115
+ * Presence follows `Headers.has()` — a header carried with an empty value is
116
+ * captured as `''`, one the response does not carry adds no key — and a
117
+ * multi-valued field is captured comma-joined, as `Headers.get()` returns it.
118
+ *
119
+ * Every captured value reaches the MCP client as `structuredContent.error.data`,
120
+ * which is why capture is an explicit allowlist rather than a redacted copy of
121
+ * every header: an arbitrary upstream header name can carry a secret, so no
122
+ * field-name redactor can make the default safe. Both HTTP helpers route through
123
+ * here so the two cannot drift apart.
124
+ */
125
+ export function selectErrorHeaders(headers, selector) {
126
+ if (!selector?.length)
127
+ return;
128
+ const captured = {};
129
+ for (const name of selector) {
130
+ const key = name.toLowerCase();
131
+ if (NEVER_CAPTURED_HEADERS.has(key) || !headers.has(key))
132
+ continue;
133
+ captured[key] = headers.get(key) ?? '';
134
+ }
135
+ return Object.keys(captured).length > 0 ? { headers: captured } : undefined;
136
+ }
92
137
  const DEFAULT_BODY_LIMIT = 500;
93
138
  /**
94
139
  * Builds an `McpError` from an HTTP `Response`, with status-aware classification
@@ -97,9 +142,10 @@ const DEFAULT_BODY_LIMIT = 500;
97
142
  * Reads the response body (consuming it) when `captureBody` is true, so callers
98
143
  * must `response.clone()` first if they intend to read the body elsewhere.
99
144
  *
100
- * Always returns an `McpError` even for 1xx/2xx/3xx — the caller is expected to
101
- * have verified `!response.ok` first, but the helper falls back to a sensible
102
- * code (`InternalError`) instead of silently producing nothing.
145
+ * Always returns an `McpError` even for 1xx/2xx — the caller is expected to have
146
+ * verified `!response.ok` first, but the helper falls back to a sensible code
147
+ * (`InternalError`) instead of silently producing nothing. A 3xx is a real
148
+ * outcome here rather than a fallback: see {@link httpStatusToErrorCode}.
103
149
  *
104
150
  * @example
105
151
  * ```ts
@@ -127,7 +173,7 @@ const DEFAULT_BODY_LIMIT = 500;
127
173
  * ```
128
174
  */
129
175
  export async function httpErrorFromResponse(response, options = {}) {
130
- const { captureBody = true, bodyLimit = DEFAULT_BODY_LIMIT, service, data: extraData, cause, codeOverride, } = options;
176
+ const { captureBody = true, bodyLimit = DEFAULT_BODY_LIMIT, includeUrl = false, service, data: extraData, cause, codeOverride, errorHeaders, } = options;
131
177
  const code = codeOverride?.(response.status) ??
132
178
  httpStatusToErrorCode(response.status) ??
133
179
  JsonRpcErrorCode.InternalError;
@@ -144,7 +190,9 @@ export async function httpErrorFromResponse(response, options = {}) {
144
190
  }
145
191
  const retryAfter = response.headers.get('retry-after') ?? undefined;
146
192
  const data = {
147
- url: response.url || undefined,
193
+ // The full URL is opt-in: `error.data` reaches the client, and a request URL
194
+ // routinely carries user input or an API key in its query string.
195
+ ...(includeUrl && response.url && { url: response.url }),
148
196
  // Canonical (Fetch `Response`-aligned) field names.
149
197
  status: response.status,
150
198
  statusText: response.statusText || undefined,
@@ -156,6 +204,7 @@ export async function httpErrorFromResponse(response, options = {}) {
156
204
  ...(body !== undefined && { responseBody: body }),
157
205
  ...(retryAfter !== undefined && { retryAfter }),
158
206
  ...httpStatusRetryability(response.status),
207
+ ...selectErrorHeaders(response.headers, errorHeaders),
159
208
  ...extraData,
160
209
  };
161
210
  return new McpError(code, `${subject} returned HTTP ${response.status}${statusText}.`, data, cause !== undefined ? { cause } : undefined);
@@ -1 +1 @@
1
- {"version":3,"file":"httpError.js","sourceRoot":"","sources":["../../../src/utils/network/httpError.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"httpError.js","sourceRoot":"","sources":["../../../src/utils/network/httpError.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAE1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAc;IAClD,IAAI,MAAM,GAAG,GAAG;QAAE,OAAO;IACzB,IAAI,MAAM,GAAG,GAAG;QAAE,OAAO,gBAAgB,CAAC,cAAc,CAAC;IAEzD,QAAQ,MAAM,EAAE,CAAC;QACf,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,aAAa,CAAC;QACxC,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,YAAY,CAAC;QACvC,KAAK,GAAG,CAAC;QACT,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,SAAS,CAAC;QACpC,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,QAAQ,CAAC;QACnC,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,OAAO,CAAC;QAClC,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,QAAQ,CAAC;QACnC,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,eAAe,CAAC;QAC1C,KAAK,GAAG,CAAC;QACT,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,QAAQ,CAAC;QACnC,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,OAAO,CAAC;QAClC,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,WAAW,CAAC;QACtC,KAAK,GAAG;YACN,OAAO,gBAAgB,CAAC,OAAO,CAAC;QAClC;YACE,OAAO,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,gBAAgB,CAAC,kBAAkB,CAAC,CAAC,CAAC,gBAAgB,CAAC,cAAc,CAAC;IACjG,CAAC;AACH,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAAc;IACnD,OAAO,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AAC3D,CAAC;AAED;;;;;GAKG;AACH,MAAM,sBAAsB,GAAG,IAAI,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC;AAEvD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,kBAAkB,CAChC,OAAgB,EAChB,QAAuC;IAEvC,IAAI,CAAC,QAAQ,EAAE,MAAM;QAAE,OAAO;IAE9B,MAAM,QAAQ,GAA2B,EAAE,CAAC;IAC5C,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;QAC5B,MAAM,GAAG,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QAC/B,IAAI,sBAAsB,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAS;QACnE,QAAQ,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;IACzC,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AAC9E,CAAC;AAiED,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,QAAkB,EAClB,OAAO,GAAiC,EAAE;IAE1C,MAAM,EACJ,WAAW,GAAG,IAAI,EAClB,SAAS,GAAG,kBAAkB,EAC9B,UAAU,GAAG,KAAK,EAClB,OAAO,EACP,IAAI,EAAE,SAAS,EACf,KAAK,EACL,YAAY,EACZ,YAAY,GACb,GAAG,OAAO,CAAC;IAEZ,MAAM,IAAI,GACR,YAAY,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC;QAC/B,qBAAqB,CAAC,QAAQ,CAAC,MAAM,CAAC;QACtC,gBAAgB,CAAC,aAAa,CAAC;IAEjC,MAAM,OAAO,GAAG,OAAO,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,UAAU,CAAC;IAChE,MAAM,UAAU,GAAG,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAExE,IAAI,IAAwB,CAAC;IAC7B,IAAI,WAAW,EAAE,CAAC;QAChB,IAAI,CAAC;YACH,IAAI,GAAG,MAAM,uBAAuB,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC;QAC5D,CAAC;QAAC,MAAM,CAAC;YACP,kEAAkE;QACpE,CAAC;IACH,CAAC;IAED,MAAM,UAAU,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,IAAI,SAAS,CAAC;IAEpE,MAAM,IAAI,GAA4B;QACpC,6EAA6E;QAC7E,kEAAkE;QAClE,GAAG,CAAC,UAAU,IAAI,QAAQ,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,CAAC;QACxD,oDAAoD;QACpD,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,UAAU,EAAE,QAAQ,CAAC,UAAU,IAAI,SAAS;QAC5C,GAAG,CAAC,IAAI,KAAK,SAAS,IAAI,EAAE,IAAI,EAAE,CAAC;QACnC,8EAA8E;QAC9E,6EAA6E;QAC7E,wDAAwD;QACxD,UAAU,EAAE,QAAQ,CAAC,MAAM;QAC3B,GAAG,CAAC,IAAI,KAAK,SAAS,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC;QACjD,GAAG,CAAC,UAAU,KAAK,SAAS,IAAI,EAAE,UAAU,EAAE,CAAC;QAC/C,GAAG,sBAAsB,CAAC,QAAQ,CAAC,MAAM,CAAC;QAC1C,GAAG,kBAAkB,CAAC,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;QACrD,GAAG,SAAS;KACb,CAAC;IAEF,OAAO,IAAI,QAAQ,CACjB,IAAI,EACJ,GAAG,OAAO,kBAAkB,QAAQ,CAAC,MAAM,GAAG,UAAU,GAAG,EAC3D,IAAI,EACJ,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAC5C,CAAC;AACJ,CAAC;AAED,oFAAoF;AACpF,SAAS,QAAQ,CAAC,GAAW;IAC3B,IAAI,CAAC,GAAG;QAAE,OAAO;IACjB,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO;IACT,CAAC;AACH,CAAC"}
@@ -0,0 +1,117 @@
1
+ /**
2
+ * One sliding start-rate window, e.g. `{ requests: 5, perMs: 60_000 }` for five
3
+ * requests a minute. Several may be declared together (per-minute and per-hour);
4
+ * a start needs every one of them to allow it.
5
+ */
6
+ export interface PacerLimit {
7
+ /** Width of the sliding window in milliseconds. */
8
+ perMs: number;
9
+ /** Starts permitted within the window. */
10
+ requests: number;
11
+ }
12
+ /** Back-off applied to the shared gate when the upstream answers with a rate limit. */
13
+ export interface PacerCooldownOptions {
14
+ /** First cooldown, doubled on each consecutive rate limit. */
15
+ baseMs: number;
16
+ /**
17
+ * Ceiling for both the doubling and an honored `Retry-After`, so a pathological
18
+ * upstream value cannot park the queue.
19
+ */
20
+ maxMs: number;
21
+ }
22
+ /** Configuration for {@link createPacer}. */
23
+ export interface PacerOptions {
24
+ /** Shared 429 gate. Omitted, an upstream rate limit paces nothing by itself. */
25
+ cooldown?: PacerCooldownOptions;
26
+ /** Sliding start-rate windows. Every entry must allow a start. */
27
+ limits?: PacerLimit[];
28
+ /** In-flight ceiling. Unset, only the windows and the start gap bind. */
29
+ maxConcurrent?: number;
30
+ /**
31
+ * Absolute backpressure on queue length, for callers that pass no
32
+ * `maxWaitMs`. An arrival past it is shed at once.
33
+ */
34
+ maxQueueDepth?: number;
35
+ /**
36
+ * Minimum spacing between consecutive starts. Not expressible through
37
+ * {@link PacerOptions.limits} once a window admits more than one request:
38
+ * `{ requests: 10, perMs: 1000 }` permits ten starts in the same millisecond,
39
+ * which is exactly the burst the SEC and NCBI clients space out by hand.
40
+ */
41
+ minStartGapMs?: number;
42
+ /**
43
+ * Author-set label for telemetry attribution. Bounded and never
44
+ * caller-supplied — a metric attribute set lives until process restart.
45
+ */
46
+ name: string;
47
+ }
48
+ /** Per-call options for {@link Pacer.run}. */
49
+ export interface PacerRunOptions {
50
+ /**
51
+ * Ceiling on queue time — not on the task, which bounds itself. Enqueue
52
+ * rejects when the projected wait already exceeds it, and a still-queued entry
53
+ * rejects when it elapses.
54
+ */
55
+ maxWaitMs?: number;
56
+ /**
57
+ * Caller cancellation. While queued, an abort removes the entry and rejects
58
+ * with `signal.reason`; once dispatched the signal is the task's own to honor,
59
+ * and it is what {@link Pacer.run} hands the task.
60
+ */
61
+ signal?: AbortSignal;
62
+ }
63
+ /** A queue in front of one upstream. Process-local; timers and `AbortSignal` only. */
64
+ export interface Pacer extends Disposable {
65
+ /**
66
+ * Clears the dispatch timer and rejects every queued waiter with
67
+ * `RequestCancelled`. In-flight tasks are left to finish. Idempotent; wire it
68
+ * through `createApp({ teardown })`.
69
+ */
70
+ dispose(): void;
71
+ /**
72
+ * Queues `task` and runs it once a slot opens, handing it the caller's signal.
73
+ *
74
+ * @throws {McpError} `RateLimited` with `data.reason: 'pacer_shed'` when no
75
+ * slot fits the caller's wait budget or the queue is at capacity.
76
+ */
77
+ run<T>(task: (signal: AbortSignal) => Promise<T>, options?: PacerRunOptions): Promise<T>;
78
+ }
79
+ /**
80
+ * Creates a FIFO pacer for one upstream.
81
+ *
82
+ * A slot is granted when every {@link PacerOptions.limits} window,
83
+ * {@link PacerOptions.minStartGapMs}, {@link PacerOptions.maxConcurrent}, and the
84
+ * cooldown gate allow it. Each window is a sliding view over recorded *start*
85
+ * times, so a slow response never widens the rate the upstream sees.
86
+ *
87
+ * **Composition with `withRetry`.** Retry outside, pacer inside —
88
+ * `withRetry(({ signal }) => pacer.run(fn, { signal }), { signal, deadlineMs })` —
89
+ * so each attempt re-queues and is re-paced. The cooldown gate is an absolute
90
+ * instant rather than a duration counted from dequeue, so `withRetry`'s
91
+ * `Retry-After` sleep and the gate overlap in wall-clock instead of summing: the
92
+ * caller waits the window the upstream named once, not twice. Pass the retry
93
+ * deadline's signal into `run` and the queue wait is charged to the same budget.
94
+ *
95
+ * **Runtime.** Process-local, so on Workers the limits bind per isolate, OTel is
96
+ * off and the metrics are inert, and `createWorkerHandler` accepts no `teardown`
97
+ * to call {@link Pacer.dispose} from.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * const pacer = createPacer({
102
+ * name: 'courtlistener',
103
+ * limits: [{ requests: 5, perMs: 60_000 }, { requests: 50, perMs: 3_600_000 }],
104
+ * minStartGapMs: 100,
105
+ * maxConcurrent: 2,
106
+ * maxQueueDepth: 100,
107
+ * cooldown: { baseMs: 5_000, maxMs: 60_000 },
108
+ * });
109
+ *
110
+ * const res = await pacer.run(
111
+ * (signal) => fetchWithTimeout(url, 30_000, ctx, { signal }),
112
+ * { signal: ctx.signal, maxWaitMs: 45_000 },
113
+ * );
114
+ * ```
115
+ */
116
+ export declare function createPacer(options: PacerOptions): Pacer;
117
+ //# sourceMappingURL=pacer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pacer.d.ts","sourceRoot":"","sources":["../../../src/utils/network/pacer.ts"],"names":[],"mappings":"AAqBA;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,uFAAuF;AACvF,MAAM,WAAW,oBAAoB;IACnC,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,6CAA6C;AAC7C,MAAM,WAAW,YAAY;IAC3B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC,kEAAkE;IAClE,MAAM,CAAC,EAAE,UAAU,EAAE,CAAC;IACtB,yEAAyE;IACzE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;CACd;AAED,8CAA8C;AAC9C,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,sFAAsF;AACtF,MAAM,WAAW,KAAM,SAAQ,UAAU;IACvC;;;;OAIG;IACH,OAAO,IAAI,IAAI,CAAC;IAChB;;;;;OAKG;IACH,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAC1F;AAgDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,KAAK,CAmQxD"}
@@ -0,0 +1,304 @@
1
+ /**
2
+ * @fileoverview Outbound request pacer — a FIFO queue in front of a rate-limited
3
+ * upstream. Holds work back until every configured window, the minimum start gap,
4
+ * and the concurrency cap allow it; sheds callers whose wait budget cannot be met;
5
+ * and closes a shared cooldown gate when the upstream answers 429.
6
+ *
7
+ * The inbound `RateLimiter` (`utils/security`) is the mirror image: it is keyed
8
+ * per caller and rejects synchronously, so it cannot queue work against an
9
+ * upstream budget.
10
+ * @module src/utils/network/pacer
11
+ */
12
+ import { JsonRpcErrorCode, McpError, rateLimited, requestCancelled, } from '../../types-global/errors.js';
13
+ import { parseRetryAfterMs } from './retry.js';
14
+ import { ATTR_MCP_PACER_NAME } from '../telemetry/attributes.js';
15
+ import { createCounter, createHistogram, createUpDownCounter } from '../telemetry/metrics.js';
16
+ let instruments;
17
+ /**
18
+ * The four pacer instruments, shared across every pacer in the process and told
19
+ * apart by `mcp.pacer.name`. Lazy: a server that never queues emits no series.
20
+ */
21
+ function getPacerMetrics() {
22
+ instruments ??= {
23
+ cooldowns: createCounter('mcp.pacer.cooldowns', 'Cooldown gates closed by an upstream rate limit', '{cooldowns}'),
24
+ queueDepth: createUpDownCounter('mcp.pacer.queue_depth', 'Requests waiting for a pacer slot', '{requests}'),
25
+ sheds: createCounter('mcp.pacer.sheds', 'Requests shed before dispatch because no slot fit the wait budget', '{requests}'),
26
+ wait: createHistogram('mcp.pacer.wait', 'Time a request spent waiting for a pacer slot', 'ms'),
27
+ };
28
+ return instruments;
29
+ }
30
+ /**
31
+ * Creates a FIFO pacer for one upstream.
32
+ *
33
+ * A slot is granted when every {@link PacerOptions.limits} window,
34
+ * {@link PacerOptions.minStartGapMs}, {@link PacerOptions.maxConcurrent}, and the
35
+ * cooldown gate allow it. Each window is a sliding view over recorded *start*
36
+ * times, so a slow response never widens the rate the upstream sees.
37
+ *
38
+ * **Composition with `withRetry`.** Retry outside, pacer inside —
39
+ * `withRetry(({ signal }) => pacer.run(fn, { signal }), { signal, deadlineMs })` —
40
+ * so each attempt re-queues and is re-paced. The cooldown gate is an absolute
41
+ * instant rather than a duration counted from dequeue, so `withRetry`'s
42
+ * `Retry-After` sleep and the gate overlap in wall-clock instead of summing: the
43
+ * caller waits the window the upstream named once, not twice. Pass the retry
44
+ * deadline's signal into `run` and the queue wait is charged to the same budget.
45
+ *
46
+ * **Runtime.** Process-local, so on Workers the limits bind per isolate, OTel is
47
+ * off and the metrics are inert, and `createWorkerHandler` accepts no `teardown`
48
+ * to call {@link Pacer.dispose} from.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * const pacer = createPacer({
53
+ * name: 'courtlistener',
54
+ * limits: [{ requests: 5, perMs: 60_000 }, { requests: 50, perMs: 3_600_000 }],
55
+ * minStartGapMs: 100,
56
+ * maxConcurrent: 2,
57
+ * maxQueueDepth: 100,
58
+ * cooldown: { baseMs: 5_000, maxMs: 60_000 },
59
+ * });
60
+ *
61
+ * const res = await pacer.run(
62
+ * (signal) => fetchWithTimeout(url, 30_000, ctx, { signal }),
63
+ * { signal: ctx.signal, maxWaitMs: 45_000 },
64
+ * );
65
+ * ```
66
+ */
67
+ export function createPacer(options) {
68
+ const { cooldown, limits = [], maxConcurrent, maxQueueDepth, minStartGapMs = 0, name } = options;
69
+ const attributes = { [ATTR_MCP_PACER_NAME]: name };
70
+ /** Recorded start instants, ascending. Pruned to the longest constraint. */
71
+ const starts = [];
72
+ const retentionMs = Math.max(minStartGapMs, ...limits.map((limit) => limit.perMs), 0);
73
+ const queue = [];
74
+ let active = 0;
75
+ let consecutiveRateLimits = 0;
76
+ /** Absolute instant the shared gate reopens. */
77
+ let gateOpensAt = 0;
78
+ let dispatchTimer;
79
+ let disposed = false;
80
+ /**
81
+ * The earliest instant a start is permitted given `history`, evaluated at
82
+ * `at`. Exact over the cooldown gate, the start gap, and every window —
83
+ * concurrency is deliberately not modelled here, since a slot frees on an
84
+ * unknowable completion.
85
+ */
86
+ function earliestStart(history, at) {
87
+ let earliest = gateOpensAt;
88
+ const last = history[history.length - 1];
89
+ if (last !== undefined && minStartGapMs > 0) {
90
+ earliest = Math.max(earliest, last + minStartGapMs);
91
+ }
92
+ for (const { requests, perMs } of limits) {
93
+ if (requests <= 0)
94
+ continue;
95
+ let seen = 0;
96
+ for (let i = history.length - 1; i >= 0; i--) {
97
+ const start = history[i];
98
+ // A start exactly `perMs` old has already left the window.
99
+ if (start <= at - perMs)
100
+ break;
101
+ seen++;
102
+ if (seen === requests) {
103
+ earliest = Math.max(earliest, start + perMs);
104
+ break;
105
+ }
106
+ }
107
+ }
108
+ return earliest;
109
+ }
110
+ /**
111
+ * When an arrival joining behind `ahead` waiters could start, by replaying the
112
+ * windows forward. Exact while only the windows and the gap bind; a lower
113
+ * bound once `maxConcurrent` does — which is what keeps a shed from ever being
114
+ * a false one.
115
+ */
116
+ function projectedStart(now, ahead) {
117
+ const simulated = starts.slice();
118
+ let at = now;
119
+ for (let i = 0; i <= ahead; i++) {
120
+ at = Math.max(at, earliestStart(simulated, at));
121
+ simulated.push(at);
122
+ }
123
+ return at;
124
+ }
125
+ /** Seconds until a slot opens, for the shed error's `retryAfter`. */
126
+ function secondsUntilSlot(now, ahead) {
127
+ return Math.ceil(Math.max(0, projectedStart(now, ahead) - now) / 1000);
128
+ }
129
+ /**
130
+ * The shed error. `rateLimited` with no `retryable: false`: to the calling
131
+ * agent this is an ordinary rate limit — wait `retryAfter`, call again — and
132
+ * that flag would tell them the opposite. `withRetry`'s default predicate
133
+ * reads `reason` instead, so an enclosing retry fails fast rather than
134
+ * sleeping past the deadline the shed exists to enforce.
135
+ */
136
+ function shed(retryAfter, queueDepth) {
137
+ getPacerMetrics().sheds.add(1, attributes);
138
+ return rateLimited(`No ${name} request slot available within the caller's wait budget.`, {
139
+ reason: 'pacer_shed',
140
+ retryAfter,
141
+ queueDepth,
142
+ });
143
+ }
144
+ /**
145
+ * Releases an entry that has left the queue: clears its shed timer and abort
146
+ * listener, and settles the depth gauge. Leaving the line and the gauge are
147
+ * the same event, so every exit — dequeued, removed, disposed — goes through
148
+ * here.
149
+ */
150
+ function detach(entry) {
151
+ if (entry.shedTimer !== undefined) {
152
+ clearTimeout(entry.shedTimer);
153
+ entry.shedTimer = undefined;
154
+ }
155
+ if (entry.onAbort && entry.signal) {
156
+ entry.signal.removeEventListener('abort', entry.onAbort);
157
+ entry.onAbort = undefined;
158
+ }
159
+ getPacerMetrics().queueDepth.add(-1, attributes);
160
+ }
161
+ /** Removes a waiter and settles the depth gauge. `false` if it already left. */
162
+ function remove(entry) {
163
+ const index = queue.indexOf(entry);
164
+ if (index === -1)
165
+ return false;
166
+ queue.splice(index, 1);
167
+ detach(entry);
168
+ return true;
169
+ }
170
+ function clearDispatchTimer() {
171
+ if (dispatchTimer === undefined)
172
+ return;
173
+ clearTimeout(dispatchTimer);
174
+ dispatchTimer = undefined;
175
+ }
176
+ /**
177
+ * Drains as much of the queue as the constraints allow, then either arms the
178
+ * dispatch timer for the next opening or leaves no timer at all. FIFO: only
179
+ * the head is ever considered, so a cheap arrival never overtakes.
180
+ */
181
+ function pump() {
182
+ clearDispatchTimer();
183
+ while (queue.length > 0) {
184
+ if (maxConcurrent !== undefined && active >= maxConcurrent)
185
+ return;
186
+ const now = Date.now();
187
+ const earliest = earliestStart(starts, now);
188
+ if (earliest > now) {
189
+ dispatchTimer = setTimeout(() => {
190
+ dispatchTimer = undefined;
191
+ pump();
192
+ }, earliest - now);
193
+ dispatchTimer.unref?.();
194
+ return;
195
+ }
196
+ const entry = queue.shift();
197
+ if (!entry)
198
+ return;
199
+ detach(entry);
200
+ starts.push(now);
201
+ while (starts.length > 0 && starts[0] <= now - retentionMs)
202
+ starts.shift();
203
+ entry.dispatch(now);
204
+ }
205
+ }
206
+ /**
207
+ * Closes the shared gate on an upstream rate limit:
208
+ * `min(max(baseMs · 2^(consecutive−1), retryAfter), maxMs)`, as an absolute
209
+ * instant. Any other error leaves it open; the first success resets the count.
210
+ */
211
+ function noteRateLimit(error) {
212
+ if (!cooldown)
213
+ return;
214
+ if (!(error instanceof McpError) || error.code !== JsonRpcErrorCode.RateLimited)
215
+ return;
216
+ consecutiveRateLimits++;
217
+ const doubled = cooldown.baseMs * 2 ** (consecutiveRateLimits - 1);
218
+ // An absent or unparseable hint contributes nothing, leaving the doubling.
219
+ const honored = parseRetryAfterMs(error) ?? 0;
220
+ const waitMs = Math.min(Math.max(doubled, honored), cooldown.maxMs);
221
+ gateOpensAt = Math.max(gateOpensAt, Date.now() + waitMs);
222
+ getPacerMetrics().cooldowns.add(1, attributes);
223
+ }
224
+ function run(task, runOptions = {}) {
225
+ const { maxWaitMs, signal } = runOptions;
226
+ if (disposed) {
227
+ return Promise.reject(requestCancelled(`The ${name} pacer has been disposed.`));
228
+ }
229
+ if (signal?.aborted)
230
+ return Promise.reject(signal.reason);
231
+ const now = Date.now();
232
+ const ahead = queue.length;
233
+ // Absolute backpressure first, so it rejects without arming a timer.
234
+ if (maxQueueDepth !== undefined && ahead >= maxQueueDepth) {
235
+ return Promise.reject(shed(secondsUntilSlot(now, ahead), ahead));
236
+ }
237
+ if (maxWaitMs !== undefined && projectedStart(now, ahead) - now > maxWaitMs) {
238
+ return Promise.reject(shed(secondsUntilSlot(now, ahead), ahead));
239
+ }
240
+ return new Promise((resolve, reject) => {
241
+ const entry = {
242
+ enqueuedAt: now,
243
+ reject,
244
+ signal,
245
+ dispatch: (startedAt) => {
246
+ active++;
247
+ getPacerMetrics().wait.record(startedAt - entry.enqueuedAt, attributes);
248
+ // Called inside an async wrapper so a task that throws before returning
249
+ // a promise rejects its own caller instead of escaping into `pump()`.
250
+ void (async () => task(signal ?? new AbortController().signal))().then((value) => {
251
+ consecutiveRateLimits = 0;
252
+ resolve(value);
253
+ active--;
254
+ pump();
255
+ }, (error) => {
256
+ noteRateLimit(error);
257
+ reject(error);
258
+ active--;
259
+ pump();
260
+ });
261
+ },
262
+ };
263
+ if (maxWaitMs !== undefined) {
264
+ entry.shedTimer = setTimeout(() => {
265
+ entry.shedTimer = undefined;
266
+ if (!remove(entry))
267
+ return;
268
+ reject(shed(secondsUntilSlot(Date.now(), 0), queue.length));
269
+ // The shed entry is gone; whoever is behind it should not wait on it.
270
+ pump();
271
+ }, maxWaitMs);
272
+ entry.shedTimer.unref?.();
273
+ }
274
+ if (signal) {
275
+ entry.onAbort = () => {
276
+ if (!remove(entry))
277
+ return;
278
+ reject(signal.reason);
279
+ pump();
280
+ };
281
+ signal.addEventListener('abort', entry.onAbort, { once: true });
282
+ }
283
+ queue.push(entry);
284
+ getPacerMetrics().queueDepth.add(1, attributes);
285
+ pump();
286
+ });
287
+ }
288
+ function dispose() {
289
+ if (disposed)
290
+ return;
291
+ disposed = true;
292
+ clearDispatchTimer();
293
+ for (const entry of queue.splice(0, queue.length)) {
294
+ detach(entry);
295
+ entry.reject(requestCancelled(`The ${name} pacer has been disposed.`));
296
+ }
297
+ }
298
+ return {
299
+ dispose,
300
+ run,
301
+ [Symbol.dispose]: dispose,
302
+ };
303
+ }
304
+ //# sourceMappingURL=pacer.js.map