@novorb/mcp-core 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +82 -161
  2. package/bin/mcp-core-jest.mjs +112 -0
  3. package/dist/auth.d.ts +57 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +74 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/bootstrap.d.ts +129 -0
  8. package/dist/bootstrap.d.ts.map +1 -0
  9. package/dist/bootstrap.js +206 -0
  10. package/dist/bootstrap.js.map +1 -0
  11. package/dist/client-input.d.ts +72 -0
  12. package/dist/client-input.d.ts.map +1 -0
  13. package/dist/client-input.js +109 -0
  14. package/dist/client-input.js.map +1 -0
  15. package/dist/credential-checks.d.ts +80 -0
  16. package/dist/credential-checks.d.ts.map +1 -0
  17. package/dist/credential-checks.js +189 -0
  18. package/dist/credential-checks.js.map +1 -0
  19. package/dist/credential-health.d.ts +12 -0
  20. package/dist/credential-health.d.ts.map +1 -0
  21. package/dist/credential-health.js +12 -0
  22. package/dist/credential-health.js.map +1 -0
  23. package/dist/credential-report.d.ts +46 -0
  24. package/dist/credential-report.d.ts.map +1 -0
  25. package/dist/credential-report.js +135 -0
  26. package/dist/credential-report.js.map +1 -0
  27. package/dist/credential-status-tool.d.ts +20 -0
  28. package/dist/credential-status-tool.d.ts.map +1 -0
  29. package/dist/credential-status-tool.js +33 -0
  30. package/dist/credential-status-tool.js.map +1 -0
  31. package/dist/credentials.d.ts +288 -0
  32. package/dist/credentials.d.ts.map +1 -0
  33. package/dist/credentials.js +652 -0
  34. package/dist/credentials.js.map +1 -0
  35. package/dist/express-peers.d.ts +29 -0
  36. package/dist/express-peers.d.ts.map +1 -0
  37. package/dist/express-peers.js +36 -0
  38. package/dist/express-peers.js.map +1 -0
  39. package/dist/express.d.ts +74 -0
  40. package/dist/express.d.ts.map +1 -0
  41. package/dist/express.js +164 -0
  42. package/dist/express.js.map +1 -0
  43. package/dist/http-client.d.ts +278 -43
  44. package/dist/http-client.d.ts.map +1 -1
  45. package/dist/http-client.js +620 -182
  46. package/dist/http-client.js.map +1 -1
  47. package/dist/index.d.ts +27 -6
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +49 -10
  50. package/dist/index.js.map +1 -1
  51. package/dist/limits.d.ts +28 -0
  52. package/dist/limits.d.ts.map +1 -0
  53. package/dist/limits.js +37 -0
  54. package/dist/limits.js.map +1 -0
  55. package/dist/list-pagination.d.ts +41 -0
  56. package/dist/list-pagination.d.ts.map +1 -0
  57. package/dist/list-pagination.js +104 -0
  58. package/dist/list-pagination.js.map +1 -0
  59. package/dist/net.d.ts +112 -0
  60. package/dist/net.d.ts.map +1 -0
  61. package/dist/net.js +490 -0
  62. package/dist/net.js.map +1 -0
  63. package/dist/notifications.d.ts +78 -0
  64. package/dist/notifications.d.ts.map +1 -0
  65. package/dist/notifications.js +173 -0
  66. package/dist/notifications.js.map +1 -0
  67. package/dist/pagination.d.ts +236 -5
  68. package/dist/pagination.d.ts.map +1 -1
  69. package/dist/pagination.js +393 -6
  70. package/dist/pagination.js.map +1 -1
  71. package/dist/prompts.d.ts +70 -0
  72. package/dist/prompts.d.ts.map +1 -0
  73. package/dist/prompts.js +109 -0
  74. package/dist/prompts.js.map +1 -0
  75. package/dist/protocol-errors.d.ts +15 -0
  76. package/dist/protocol-errors.d.ts.map +1 -0
  77. package/dist/protocol-errors.js +28 -0
  78. package/dist/protocol-errors.js.map +1 -0
  79. package/dist/protocol-logging.d.ts +37 -0
  80. package/dist/protocol-logging.d.ts.map +1 -0
  81. package/dist/protocol-logging.js +77 -0
  82. package/dist/protocol-logging.js.map +1 -0
  83. package/dist/rate-limiter.d.ts +36 -0
  84. package/dist/rate-limiter.d.ts.map +1 -0
  85. package/dist/rate-limiter.js +79 -0
  86. package/dist/rate-limiter.js.map +1 -0
  87. package/dist/request-state.d.ts +74 -0
  88. package/dist/request-state.d.ts.map +1 -0
  89. package/dist/request-state.js +190 -0
  90. package/dist/request-state.js.map +1 -0
  91. package/dist/resources.d.ts +181 -0
  92. package/dist/resources.d.ts.map +1 -0
  93. package/dist/resources.js +302 -0
  94. package/dist/resources.js.map +1 -0
  95. package/dist/result-limits.d.ts +80 -0
  96. package/dist/result-limits.d.ts.map +1 -0
  97. package/dist/result-limits.js +248 -0
  98. package/dist/result-limits.js.map +1 -0
  99. package/dist/retry.d.ts +79 -23
  100. package/dist/retry.d.ts.map +1 -1
  101. package/dist/retry.js +170 -57
  102. package/dist/retry.js.map +1 -1
  103. package/dist/sdk/client-stdio.d.ts +6 -0
  104. package/dist/sdk/client-stdio.d.ts.map +1 -0
  105. package/dist/sdk/client-stdio.js +6 -0
  106. package/dist/sdk/client-stdio.js.map +1 -0
  107. package/dist/sdk/client.d.ts +6 -0
  108. package/dist/sdk/client.d.ts.map +1 -0
  109. package/dist/sdk/client.js +6 -0
  110. package/dist/sdk/client.js.map +1 -0
  111. package/dist/sdk/express.d.ts +3 -0
  112. package/dist/sdk/express.d.ts.map +1 -0
  113. package/dist/sdk/express.js +11 -0
  114. package/dist/sdk/express.js.map +1 -0
  115. package/dist/sdk/node.d.ts +6 -0
  116. package/dist/sdk/node.d.ts.map +1 -0
  117. package/dist/sdk/node.js +6 -0
  118. package/dist/sdk/node.js.map +1 -0
  119. package/dist/sdk/server-stdio.d.ts +6 -0
  120. package/dist/sdk/server-stdio.d.ts.map +1 -0
  121. package/dist/sdk/server-stdio.js +6 -0
  122. package/dist/sdk/server-stdio.js.map +1 -0
  123. package/dist/sdk/server.d.ts +6 -0
  124. package/dist/sdk/server.d.ts.map +1 -0
  125. package/dist/sdk/server.js +6 -0
  126. package/dist/sdk/server.js.map +1 -0
  127. package/dist/server.d.ts +88 -36
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +98 -44
  130. package/dist/server.js.map +1 -1
  131. package/dist/task-request.d.ts +20 -0
  132. package/dist/task-request.d.ts.map +1 -0
  133. package/dist/task-request.js +43 -0
  134. package/dist/task-request.js.map +1 -0
  135. package/dist/task-store.d.ts +230 -0
  136. package/dist/task-store.d.ts.map +1 -0
  137. package/dist/task-store.js +459 -0
  138. package/dist/task-store.js.map +1 -0
  139. package/dist/task-tools.d.ts +115 -0
  140. package/dist/task-tools.d.ts.map +1 -0
  141. package/dist/task-tools.js +397 -0
  142. package/dist/task-tools.js.map +1 -0
  143. package/dist/testing/client.d.ts +25 -0
  144. package/dist/testing/client.d.ts.map +1 -0
  145. package/dist/testing/client.js +11 -0
  146. package/dist/testing/client.js.map +1 -0
  147. package/dist/testing/in-memory.d.ts +26 -0
  148. package/dist/testing/in-memory.d.ts.map +1 -0
  149. package/dist/testing/in-memory.js +41 -0
  150. package/dist/testing/in-memory.js.map +1 -0
  151. package/dist/testing/index.d.ts +20 -0
  152. package/dist/testing/index.d.ts.map +1 -0
  153. package/dist/testing/index.js +20 -0
  154. package/dist/testing/index.js.map +1 -0
  155. package/dist/testing/isolated-dirs.d.ts +27 -0
  156. package/dist/testing/isolated-dirs.d.ts.map +1 -0
  157. package/dist/testing/isolated-dirs.js +67 -0
  158. package/dist/testing/isolated-dirs.js.map +1 -0
  159. package/dist/testing/logs.d.ts +61 -0
  160. package/dist/testing/logs.d.ts.map +1 -0
  161. package/dist/testing/logs.js +113 -0
  162. package/dist/testing/logs.js.map +1 -0
  163. package/dist/testing/raw-http.d.ts +25 -0
  164. package/dist/testing/raw-http.d.ts.map +1 -0
  165. package/dist/testing/raw-http.js +41 -0
  166. package/dist/testing/raw-http.js.map +1 -0
  167. package/dist/testing/raw-peer.d.ts +43 -0
  168. package/dist/testing/raw-peer.d.ts.map +1 -0
  169. package/dist/testing/raw-peer.js +101 -0
  170. package/dist/testing/raw-peer.js.map +1 -0
  171. package/dist/testing/recording-server.d.ts +38 -0
  172. package/dist/testing/recording-server.d.ts.map +1 -0
  173. package/dist/testing/recording-server.js +38 -0
  174. package/dist/testing/recording-server.js.map +1 -0
  175. package/dist/testing/server-process.d.ts +76 -0
  176. package/dist/testing/server-process.d.ts.map +1 -0
  177. package/dist/testing/server-process.js +241 -0
  178. package/dist/testing/server-process.js.map +1 -0
  179. package/dist/testing/tools.d.ts +53 -0
  180. package/dist/testing/tools.d.ts.map +1 -0
  181. package/dist/testing/tools.js +98 -0
  182. package/dist/testing/tools.js.map +1 -0
  183. package/dist/testing/unit.d.ts +52 -0
  184. package/dist/testing/unit.d.ts.map +1 -0
  185. package/dist/testing/unit.js +95 -0
  186. package/dist/testing/unit.js.map +1 -0
  187. package/dist/tools.d.ts +205 -0
  188. package/dist/tools.d.ts.map +1 -0
  189. package/dist/tools.js +370 -0
  190. package/dist/tools.js.map +1 -0
  191. package/dist/transport.d.ts +144 -53
  192. package/dist/transport.d.ts.map +1 -1
  193. package/dist/transport.js +305 -122
  194. package/dist/transport.js.map +1 -1
  195. package/dist/url-path.d.ts +49 -0
  196. package/dist/url-path.d.ts.map +1 -0
  197. package/dist/url-path.js +165 -0
  198. package/dist/url-path.js.map +1 -0
  199. package/dist/utils/dataStore.d.ts +143 -0
  200. package/dist/utils/dataStore.d.ts.map +1 -0
  201. package/dist/utils/dataStore.js +429 -0
  202. package/dist/utils/dataStore.js.map +1 -0
  203. package/dist/utils/envLoader.d.ts +129 -0
  204. package/dist/utils/envLoader.d.ts.map +1 -0
  205. package/dist/utils/envLoader.js +399 -0
  206. package/dist/utils/envLoader.js.map +1 -0
  207. package/dist/utils/logger.d.ts +181 -0
  208. package/dist/utils/logger.d.ts.map +1 -0
  209. package/dist/utils/logger.js +664 -0
  210. package/dist/utils/logger.js.map +1 -0
  211. package/dist/utils/redact.d.ts +50 -0
  212. package/dist/utils/redact.d.ts.map +1 -0
  213. package/dist/utils/redact.js +318 -0
  214. package/dist/utils/redact.js.map +1 -0
  215. package/docs/api-reference.md +29 -0
  216. package/docs/building-a-server.md +63 -0
  217. package/docs/credentials.md +131 -0
  218. package/docs/data-store.md +37 -0
  219. package/docs/env.md +38 -0
  220. package/docs/features.md +28 -0
  221. package/docs/http-client.md +116 -0
  222. package/docs/logger.md +100 -0
  223. package/docs/migrating-to-v3.md +45 -0
  224. package/docs/outbound-urls.md +104 -0
  225. package/docs/pagination.md +45 -0
  226. package/docs/protocol-features.md +62 -0
  227. package/docs/resources-prompts-tasks.md +95 -0
  228. package/docs/sdk.md +33 -0
  229. package/docs/server.md +68 -0
  230. package/docs/testing-harness.md +139 -0
  231. package/docs/testing.md +151 -0
  232. package/docs/tools.md +88 -0
  233. package/docs/transport.md +85 -0
  234. package/examples/testing/tools.integration.test.example.ts +312 -0
  235. package/examples/testing/tools.unit.test.example.ts +149 -0
  236. package/package.json +140 -18
  237. package/dist/logger.d.ts +0 -80
  238. package/dist/logger.d.ts.map +0 -1
  239. package/dist/logger.js +0 -236
  240. package/dist/logger.js.map +0 -1
@@ -1,223 +1,660 @@
1
1
  /**
2
- * @novorb/mcp-core — HTTP Client Factory
2
+ * @novorb/mcp-core - HTTP client
3
3
  *
4
- * Creates pre-configured axios instances with retry logic and logging.
4
+ * One hardened HTTP client for every MCP server, built on axios with one in-house retry loop:
5
+ * - per-request options: headers, query, timeout, signal, responseType (json/text/arraybuffer/stream),
6
+ * response size cap, validateStatus, retry overrides, idempotency key
7
+ * - auth configured once: bearer, basic, custom header, query-param key, async token provider (a 401
8
+ * triggers one forced refresh and one replay)
9
+ * - base URL joining that never doubles a segment; absolute URLs (next-links) are used as-is but only
10
+ * toward the base URL's origin or an explicitly allowed one, so credentials never leave
11
+ * - TLS options through an ESM `node:https` Agent (custom CA, or rejectUnauthorized=false for a
12
+ * self-signed test instance)
13
+ * - `mapError` hook so each service keeps its own error wording; a client-side token bucket
14
+ * - security: maxRedirects 0, request/response size caps (streams included), errors carry only
15
+ * status/statusText/code/method/path (never the raw response, config or headers), secrets masked
16
+ * - logging through the mcp-core logger only: method, host, path template, status, duration, attempt;
17
+ * never query values, headers, bodies or response content
5
18
  */
19
+ import { Agent as HttpsAgent } from 'node:https';
20
+ import { Transform } from 'node:stream';
6
21
  import axios from 'axios';
7
- import axiosRetry from 'axios-retry';
8
- import { getLogger } from './logger.js';
22
+ import { credentialOutcomeFromHttp, currentCredentialScope, parseAuthChallenge, processCredentials, UnknownCredentialError, } from './credentials.js';
23
+ import { paginateRequests } from './pagination.js';
24
+ import { createTokenBucket, TokenBucket } from './rate-limiter.js';
25
+ import { isIdempotentMethod, isRetryableError, parseRetryAfter, retryDelayFor, sleep, throwIfAborted, } from './retry.js';
26
+ import { getLogger } from './utils/logger.js';
27
+ /** The normalized error every failure becomes. Carries no response, config or headers. */
28
+ export class HttpError extends Error {
29
+ status;
30
+ statusText;
31
+ code;
32
+ method;
33
+ path;
34
+ retryAfterMs;
35
+ /** The upstream OAuth `error_description` (masked), for the credential mapping. */
36
+ description;
37
+ /** The parsed `WWW-Authenticate` `error` / `error_description` (masked); never the raw header. */
38
+ challenge;
39
+ constructor(fields) {
40
+ super(fields.message);
41
+ this.name = 'HttpError';
42
+ this.method = fields.method;
43
+ this.path = fields.path;
44
+ if (fields.status !== undefined)
45
+ this.status = fields.status;
46
+ if (fields.statusText !== undefined)
47
+ this.statusText = fields.statusText;
48
+ if (fields.code !== undefined)
49
+ this.code = fields.code;
50
+ if (fields.retryAfterMs !== undefined)
51
+ this.retryAfterMs = fields.retryAfterMs;
52
+ if (fields.description !== undefined)
53
+ this.description = fields.description;
54
+ if (fields.challenge !== undefined)
55
+ this.challenge = fields.challenge;
56
+ }
57
+ }
58
+ const DEFAULT_TIMEOUT_MS = 30000;
59
+ const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
60
+ const MAX_UPSTREAM_MESSAGE = 300;
61
+ /** Provider tokens remembered for masking (the latest ones; older tokens are expired anyway). */
62
+ const MAX_REMEMBERED_TOKENS = 8;
63
+ function asRecord(value) {
64
+ return value !== null && typeof value === 'object' ? value : undefined;
65
+ }
66
+ function isAbsoluteUrl(path) {
67
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(path);
68
+ }
69
+ /**
70
+ * Joins a path onto a base URL exactly once: one slash between them, nothing else touched. An absolute
71
+ * URL is returned unchanged (an OData next-link already holds its `/v1.0` segment).
72
+ */
73
+ export function joinUrl(baseURL, path) {
74
+ if (isAbsoluteUrl(path))
75
+ return path;
76
+ const base = baseURL.replace(/\/+$/, '');
77
+ const rest = path.replace(/^\/+/, '');
78
+ return rest ? `${base}/${rest}` : base;
79
+ }
80
+ /** Serialises query params (null/undefined skipped) without touching an existing query string. */
81
+ export function buildQueryString(query, arrayFormat = 'repeat') {
82
+ if (!query)
83
+ return '';
84
+ const parts = [];
85
+ const enc = encodeURIComponent;
86
+ for (const [key, value] of Object.entries(query)) {
87
+ if (value === null || value === undefined)
88
+ continue;
89
+ if (Array.isArray(value)) {
90
+ const values = value;
91
+ if (arrayFormat === 'comma') {
92
+ parts.push(`${enc(key)}=${values.map((v) => enc(String(v))).join(',')}`);
93
+ }
94
+ else {
95
+ const name = arrayFormat === 'brackets' ? `${key}[]` : key;
96
+ for (const v of values)
97
+ parts.push(`${enc(name)}=${enc(String(v))}`);
98
+ }
99
+ }
100
+ else {
101
+ parts.push(`${enc(key)}=${enc(String(value))}`);
102
+ }
103
+ }
104
+ return parts.join('&');
105
+ }
106
+ function appendQuery(url, queryString) {
107
+ if (!queryString)
108
+ return url;
109
+ return `${url}${url.includes('?') ? '&' : '?'}${queryString}`;
110
+ }
111
+ /** A fixed API word in a CONFIGURED base path: lowercase letters with `_`/`-` separators (`api`, `rest`). */
112
+ const API_WORD = /^[a-z]+(?:[_-][a-z]+)*$/;
113
+ /** An API version segment: `v4`, `v1.0`, or a bare 1-2 digit number (Trello's `/1/`). */
114
+ const API_VERSION = /^(?:v\d+(?:\.\d+)*|\d{1,2})$/;
115
+ /** The only request-path segment logged without a route template: a leading API prefix or version. */
116
+ const API_PREFIX = /^(?:api|rest|v\d+(?:\.\d+)*|\d{1,2})$/;
117
+ function segmentsOf(path) {
118
+ return path.split('/').filter((s) => s !== '');
119
+ }
120
+ function matchRoute(segments, routes) {
121
+ for (const route of routes) {
122
+ const parts = segmentsOf(route);
123
+ if (parts.length !== segments.length)
124
+ continue;
125
+ if (parts.every((part, i) => part.startsWith(':') || part === segments[i]))
126
+ return `/${parts.join('/')}`;
127
+ }
128
+ return undefined;
129
+ }
130
+ /**
131
+ * The route shape of a path for logs and errors, never a dynamic value. Fail closed: without a matching
132
+ * route template every request-path segment becomes `:id` except a leading API prefix or version
133
+ * (`api`, `rest`, `v4`). The configured base path keeps its API words and versions (`/api/v4`); any
134
+ * other base segment (a token in the base URL) is masked too. A `routes` template that matches the
135
+ * path segment for segment (`:name` matches any one segment) is logged instead, with its literal words;
136
+ * a request's own `logPath` overrides everything.
137
+ *
138
+ * templatePath('/api/v4/projects/7/repository/tags/stable', { basePath: '/api/v4' })
139
+ * -> '/api/v4/:id/:id/:id/:id/:id'
140
+ * templatePath('/api/v4/projects/7/repository/tags/stable', {
141
+ * basePath: '/api/v4', routes: ['/projects/:id/repository/tags/:tag'] })
142
+ * -> '/api/v4/projects/:id/repository/tags/:tag'
143
+ */
144
+ export function templatePath(pathname, options = {}) {
145
+ const base = segmentsOf(options.basePath ?? '');
146
+ const all = segmentsOf(pathname);
147
+ const underBase = base.length > 0 && base.every((s, i) => all[i] === s);
148
+ const baseOut = underBase ? base.map((s) => (API_WORD.test(s) || API_VERSION.test(s) ? s : ':id')) : [];
149
+ const rest = underBase ? all.slice(base.length) : all;
150
+ const prefix = baseOut.length > 0 ? `/${baseOut.join('/')}` : '';
151
+ const routed = options.routes ? matchRoute(rest, options.routes) : undefined;
152
+ if (routed !== undefined)
153
+ return `${prefix}${routed}`;
154
+ const masked = rest.map((s, i) => (i === 0 && API_PREFIX.test(s) ? s : ':id'));
155
+ const tail = masked.length > 0 ? `/${masked.join('/')}` : '';
156
+ return `${prefix}${tail}` || '/';
157
+ }
158
+ function plainHeaders(headers) {
159
+ const record = asRecord(headers);
160
+ if (!record)
161
+ return {};
162
+ const toJSON = record.toJSON;
163
+ const source = typeof toJSON === 'function' ? (asRecord(toJSON.call(headers)) ?? {}) : record;
164
+ const out = {};
165
+ for (const [key, value] of Object.entries(source)) {
166
+ if (Array.isArray(value))
167
+ out[key.toLowerCase()] = value.map(String).join(', ');
168
+ else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
169
+ out[key.toLowerCase()] = String(value);
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+ function firstString(value) {
175
+ if (typeof value === 'string' && value.trim())
176
+ return value.trim();
177
+ if (Array.isArray(value)) {
178
+ for (const item of value) {
179
+ const found = firstString(item) ?? firstString(asRecord(item)?.message);
180
+ if (found)
181
+ return found;
182
+ }
183
+ }
184
+ return undefined;
185
+ }
186
+ /** Extracts `{ message, code }` from common upstream error bodies (GitLab, Graph, Jira, Nexus, DT). */
187
+ function upstreamDetails(data, depth = 0) {
188
+ if (depth > 2 || data === undefined || data === null)
189
+ return {};
190
+ if (Buffer.isBuffer(data))
191
+ return upstreamDetails(data.subarray(0, 4096).toString('utf8'), depth + 1);
192
+ if (typeof data === 'string') {
193
+ const text = data.trim();
194
+ if (!text || text.startsWith('<'))
195
+ return {};
196
+ if (text.startsWith('{') || text.startsWith('[')) {
197
+ try {
198
+ return upstreamDetails(JSON.parse(text), depth + 1);
199
+ }
200
+ catch {
201
+ // fall through to the plain text
202
+ }
203
+ }
204
+ return { message: text };
205
+ }
206
+ const body = asRecord(data);
207
+ if (!body)
208
+ return {};
209
+ const error = asRecord(body.error);
210
+ const message = firstString(body.message) ??
211
+ firstString(error?.message) ??
212
+ firstString(body.error) ??
213
+ firstString(body.errorMessages) ??
214
+ firstString(body.errors) ??
215
+ firstString(body.error_description) ??
216
+ firstString(body.detail) ??
217
+ firstString(body.title);
218
+ const code = firstString(error?.code) ?? (typeof body.code === 'string' ? body.code : undefined);
219
+ // OAuth-style bodies (GitLab `{ error: "invalid_token", error_description: "Token is expired" }`): the
220
+ // message stays the `error` code, the description is kept apart for the credential mapping.
221
+ const description = firstString(body.error_description);
222
+ return { message, code, ...(description !== undefined && description !== message ? { description } : {}) };
223
+ }
224
+ /**
225
+ * Caps a response stream and sanitises its errors: a source error (axios emits a raw AxiosError, whose
226
+ * `config` holds the auth headers) is replaced by a normalized one before it reaches the consumer.
227
+ */
228
+ function capStream(source, cap, makeError) {
229
+ let total = 0;
230
+ const tooLarge = () => makeError('ERR_RESPONSE_TOO_LARGE', `Response exceeds ${cap} bytes`);
231
+ const limiter = new Transform({
232
+ transform(chunk, _encoding, callback) {
233
+ total += chunk.length;
234
+ if (total > cap)
235
+ callback(tooLarge());
236
+ else
237
+ callback(null, chunk);
238
+ },
239
+ });
240
+ source.on('error', (err) => {
241
+ if (err.message.includes('maxContentLength'))
242
+ limiter.destroy(tooLarge());
243
+ else {
244
+ const code = asRecord(err)?.code;
245
+ limiter.destroy(makeError(typeof code === 'string' ? code : 'ERR_STREAM', 'Response stream failed'));
246
+ }
247
+ });
248
+ limiter.on('error', () => source.destroy());
249
+ return source.pipe(limiter);
250
+ }
251
+ function isReadable(value) {
252
+ const record = asRecord(value);
253
+ return record !== undefined && typeof record.pipe === 'function' && typeof record.destroy === 'function';
254
+ }
9
255
  /**
10
- * MCP HTTP client wrapping axios with retry, logging, and pagination support.
256
+ * MCP HTTP client. Build one per upstream per process (not per MCP request), so the rate limiter and
257
+ * the connection pool are shared.
11
258
  */
12
259
  export class McpHttpClient {
13
260
  client;
14
261
  logger;
15
262
  logContext;
263
+ config;
264
+ baseOrigin;
265
+ basePath;
266
+ allowedOrigins;
267
+ bucket;
268
+ staticSecrets = [];
269
+ providerTokens = new Set();
16
270
  constructor(config) {
17
- this.logger = config.logger || getLogger();
18
- this.logContext = config.logContext || 'HttpClient';
271
+ this.config = config;
272
+ this.logger = config.logger ?? getLogger();
273
+ this.logContext = config.logContext ?? 'HttpClient';
274
+ this.baseOrigin = new URL(config.baseURL).origin;
275
+ this.basePath = new URL(config.baseURL).pathname;
276
+ this.allowedOrigins = new Set([this.baseOrigin, ...(config.allowedOrigins ?? []).map((o) => new URL(o).origin)]);
277
+ if (config.rateLimit) {
278
+ this.bucket = config.rateLimit instanceof TokenBucket ? config.rateLimit : createTokenBucket(config.rateLimit);
279
+ }
280
+ this.collectSecrets(config.auth);
281
+ const httpsAgent = config.tls
282
+ ? new HttpsAgent({ ca: config.tls.ca, rejectUnauthorized: config.tls.rejectUnauthorized ?? true })
283
+ : undefined;
19
284
  this.client = axios.create({
20
- baseURL: config.baseURL,
21
- timeout: config.timeout || 30000,
22
- // Never follow redirects: the instance carries a static Authorization header, and axios re-sends
23
- // request data to a 3xx Location — a malicious/compromised upstream could harvest the credential.
285
+ timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
286
+ // Never follow redirects: requests carry credentials, and a 3xx Location could harvest them.
24
287
  maxRedirects: 0,
25
- // Cap response/request size (OOM/DoS guard).
26
- maxContentLength: 25 * 1024 * 1024,
27
- maxBodyLength: 25 * 1024 * 1024,
28
- headers: {
29
- 'Content-Type': 'application/json',
30
- ...config.headers,
31
- },
32
- });
33
- axiosRetry(this.client, {
34
- retries: config.retries ?? 3,
35
- retryDelay: axiosRetry.exponentialDelay,
36
- retryCondition: (error) => {
37
- // Network/idempotent-request errors are already method-safe. Only ALSO retry 429/5xx for
38
- // idempotent methods — never replay a non-idempotent POST/PATCH (duplicate side effects).
39
- if (axiosRetry.isNetworkOrIdempotentRequestError(error))
40
- return true;
41
- const method = (error.config?.method ?? 'get').toLowerCase();
42
- const idempotent = ['get', 'head', 'options', 'put', 'delete'].includes(method);
43
- const status = error.response?.status;
44
- const retryableStatus = status === 429 || (status !== undefined && status >= 500);
45
- return idempotent && retryableStatus;
46
- },
288
+ // Size caps (OOM/DoS guard).
289
+ maxContentLength: config.maxResponseBytes ?? DEFAULT_MAX_BYTES,
290
+ maxBodyLength: config.maxRequestBytes ?? DEFAULT_MAX_BYTES,
291
+ headers: { ...config.headers },
292
+ transitional: { clarifyTimeoutError: true, silentJSONParsing: true, forcedJSONParsing: true },
293
+ ...(httpsAgent ? { httpsAgent } : {}),
47
294
  });
48
295
  }
49
- /**
50
- * Perform a GET request.
51
- */
52
- async get(path, params) {
53
- const startTime = Date.now();
54
- this.logger.debug(`GET ${path}`, this.logContext, { params });
55
- try {
56
- const response = await this.client.get(path, { params });
57
- const duration = Date.now() - startTime;
58
- this.logger.debug(`GET ${path} completed`, this.logContext, {
59
- status: response.status,
60
- duration: `${duration}ms`,
61
- });
62
- return response.data;
63
- }
64
- catch (error) {
65
- const duration = Date.now() - startTime;
66
- this.logger.error(`GET ${path} failed`, this.logContext, {
67
- duration: `${duration}ms`,
68
- error: error instanceof Error ? error.message : String(error),
69
- });
70
- throw this.normalizeError(error);
296
+ collectSecrets(auth) {
297
+ if (!auth)
298
+ return;
299
+ const add = (value) => {
300
+ if (value.length >= 4)
301
+ this.staticSecrets.push(value);
302
+ };
303
+ switch (auth.type) {
304
+ case 'bearer':
305
+ add(auth.token);
306
+ break;
307
+ case 'basic':
308
+ add(auth.password);
309
+ add(Buffer.from(`${auth.username}:${auth.password}`).toString('base64'));
310
+ break;
311
+ case 'header':
312
+ add(auth.value);
313
+ break;
314
+ case 'query':
315
+ for (const value of Object.values(auth.params)) {
316
+ add(value);
317
+ add(encodeURIComponent(value));
318
+ }
319
+ break;
320
+ case 'tokenProvider':
321
+ break;
71
322
  }
72
323
  }
73
- /**
74
- * Perform a GET request with pagination.
75
- * Reads total count from the X-Total-Count response header.
76
- */
77
- async getPaginated(path, params) {
78
- const startTime = Date.now();
79
- this.logger.debug(`GET (paginated) ${path}`, this.logContext, { params });
80
- try {
81
- const response = await this.client.get(path, { params });
82
- const totalCount = parseInt(response.headers['x-total-count'] || '0', 10);
83
- const duration = Date.now() - startTime;
84
- this.logger.debug(`GET (paginated) ${path} completed`, this.logContext, {
85
- status: response.status,
86
- totalCount,
87
- resultCount: response.data.length,
88
- duration: `${duration}ms`,
89
- });
90
- return { data: response.data, totalCount };
91
- }
92
- catch (error) {
93
- const duration = Date.now() - startTime;
94
- this.logger.error(`GET (paginated) ${path} failed`, this.logContext, {
95
- duration: `${duration}ms`,
96
- error: error instanceof Error ? error.message : String(error),
97
- });
98
- throw this.normalizeError(error);
324
+ /** Replaces every configured credential value in `text` with `[REDACTED]`. */
325
+ maskSecrets(text) {
326
+ let out = text;
327
+ for (const secret of [...this.staticSecrets, ...this.providerTokens]) {
328
+ out = out.split(secret).join('[REDACTED]');
99
329
  }
330
+ return out;
100
331
  }
101
- /**
102
- * Perform a POST request.
103
- */
104
- async post(path, data, params) {
105
- const startTime = Date.now();
106
- this.logger.debug(`POST ${path}`, this.logContext);
107
- try {
108
- const response = await this.client.post(path, data, { params });
109
- const duration = Date.now() - startTime;
110
- this.logger.debug(`POST ${path} completed`, this.logContext, {
111
- status: response.status,
112
- duration: `${duration}ms`,
113
- });
114
- return response.data;
115
- }
116
- catch (error) {
117
- const duration = Date.now() - startTime;
118
- this.logger.error(`POST ${path} failed`, this.logContext, {
119
- duration: `${duration}ms`,
120
- error: error instanceof Error ? error.message : String(error),
121
- });
122
- throw this.normalizeError(error);
332
+ async authHeaders(signal, forceRefresh) {
333
+ const auth = this.config.auth;
334
+ if (!auth)
335
+ return {};
336
+ switch (auth.type) {
337
+ case 'bearer':
338
+ return { Authorization: `Bearer ${auth.token}` };
339
+ case 'basic':
340
+ return {
341
+ Authorization: `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`,
342
+ };
343
+ case 'header':
344
+ return { [auth.name]: auth.value };
345
+ case 'tokenProvider': {
346
+ const token = await auth.getToken({ forceRefresh, signal });
347
+ if (token.length >= 4) {
348
+ // Keep only the most recent tokens (a refreshing provider must not grow this for ever).
349
+ this.providerTokens.delete(token);
350
+ this.providerTokens.add(token);
351
+ while (this.providerTokens.size > MAX_REMEMBERED_TOKENS) {
352
+ const oldest = this.providerTokens.values().next().value;
353
+ if (oldest === undefined)
354
+ break;
355
+ this.providerTokens.delete(oldest);
356
+ }
357
+ }
358
+ const scheme = auth.scheme ?? 'Bearer';
359
+ return { [auth.headerName ?? 'Authorization']: scheme ? `${scheme} ${token}` : token };
360
+ }
361
+ case 'query':
362
+ return {};
123
363
  }
124
364
  }
125
- /**
126
- * Perform a PUT request.
127
- */
128
- async put(path, data, params) {
129
- const startTime = Date.now();
130
- this.logger.debug(`PUT ${path}`, this.logContext);
365
+ resolve(req, method) {
366
+ const joined = joinUrl(this.config.baseURL, req.path);
367
+ let parsed;
131
368
  try {
132
- const response = await this.client.put(path, data, { params });
133
- const duration = Date.now() - startTime;
134
- this.logger.debug(`PUT ${path} completed`, this.logContext, {
135
- status: response.status,
136
- duration: `${duration}ms`,
137
- });
138
- return response.data;
369
+ parsed = new URL(joined);
370
+ }
371
+ catch {
372
+ throw new HttpError({ message: 'Invalid request URL', method, path: '', code: 'ERR_INVALID_URL' });
139
373
  }
140
- catch (error) {
141
- const duration = Date.now() - startTime;
142
- this.logger.error(`PUT ${path} failed`, this.logContext, {
143
- duration: `${duration}ms`,
144
- error: error instanceof Error ? error.message : String(error),
374
+ const path = req.logPath ?? templatePath(parsed.pathname, { basePath: this.basePath, routes: this.config.routes ?? [] });
375
+ if (!/^https?:$/.test(parsed.protocol) || !this.allowedOrigins.has(parsed.origin)) {
376
+ throw new HttpError({
377
+ message: `Refusing to send credentials to a non-allowed origin (${parsed.host})`,
378
+ method,
379
+ path,
380
+ code: 'ERR_ORIGIN_NOT_ALLOWED',
145
381
  });
146
- throw this.normalizeError(error);
147
382
  }
383
+ const auth = this.config.auth;
384
+ const query = { ...req.query, ...(auth?.type === 'query' ? auth.params : {}) };
385
+ return { url: appendQuery(joined, buildQueryString(query, this.config.arrayFormat)), host: parsed.host, path };
148
386
  }
149
- /**
150
- * Perform a PATCH request.
151
- */
152
- async patch(path, data, params) {
153
- const startTime = Date.now();
154
- this.logger.debug(`PATCH ${path}`, this.logContext);
155
- try {
156
- const response = await this.client.patch(path, data, { params });
157
- const duration = Date.now() - startTime;
158
- this.logger.debug(`PATCH ${path} completed`, this.logContext, {
159
- status: response.status,
160
- duration: `${duration}ms`,
161
- });
162
- return response.data;
387
+ errorInfo(error, method, host, path) {
388
+ const err = asRecord(error) ?? {};
389
+ const response = asRecord(err.response);
390
+ const status = typeof response?.status === 'number' ? response.status : undefined;
391
+ const statusText = typeof response?.statusText === 'string' && response.statusText ? response.statusText : undefined;
392
+ const info = { method, host, path };
393
+ if (status !== undefined) {
394
+ info.status = status;
395
+ if (statusText)
396
+ info.statusText = statusText;
397
+ const headers = plainHeaders(response?.headers);
398
+ const retryAfterMs = parseRetryAfter(headers['retry-after']);
399
+ if (retryAfterMs !== undefined)
400
+ info.retryAfterMs = retryAfterMs;
401
+ const details = isReadable(response?.data) ? {} : upstreamDetails(response?.data);
402
+ if (details.message)
403
+ info.upstreamMessage = this.maskSecrets(details.message).slice(0, MAX_UPSTREAM_MESSAGE);
404
+ if (details.code)
405
+ info.upstreamCode = details.code;
406
+ if (details.description) {
407
+ info.upstreamDescription = this.maskSecrets(details.description).slice(0, MAX_UPSTREAM_MESSAGE);
408
+ }
409
+ const parsed = parseAuthChallenge(headers['www-authenticate']);
410
+ const challenge = {};
411
+ if (parsed.error)
412
+ challenge.error = this.maskSecrets(parsed.error).slice(0, MAX_UPSTREAM_MESSAGE);
413
+ if (parsed.description)
414
+ challenge.description = this.maskSecrets(parsed.description).slice(0, MAX_UPSTREAM_MESSAGE);
415
+ if (challenge.error !== undefined || challenge.description !== undefined)
416
+ info.challenge = challenge;
417
+ info.code = details.code ?? `HTTP_${status}`;
163
418
  }
164
- catch (error) {
165
- const duration = Date.now() - startTime;
166
- this.logger.error(`PATCH ${path} failed`, this.logContext, {
167
- duration: `${duration}ms`,
168
- error: error instanceof Error ? error.message : String(error),
169
- });
170
- throw this.normalizeError(error);
419
+ else {
420
+ const message = typeof err.message === 'string' ? err.message : '';
421
+ info.code = message.includes('maxContentLength')
422
+ ? 'ERR_RESPONSE_TOO_LARGE'
423
+ : message.includes('maxBodyLength')
424
+ ? 'ERR_REQUEST_TOO_LARGE'
425
+ : typeof err.code === 'string'
426
+ ? err.code
427
+ : 'ERR_NETWORK';
171
428
  }
429
+ return info;
430
+ }
431
+ toHttpError(info) {
432
+ const fallback = info.upstreamMessage
433
+ ? info.upstreamMessage
434
+ : info.status !== undefined
435
+ ? `HTTP ${info.status}${info.statusText ? ` ${info.statusText}` : ''}`
436
+ : `Network error (${info.code})`;
437
+ const mapped = this.config.mapError?.(info);
438
+ return new HttpError({
439
+ message: this.maskSecrets(mapped?.message ?? fallback),
440
+ method: info.method,
441
+ path: info.path,
442
+ status: info.status,
443
+ statusText: info.statusText,
444
+ code: mapped?.code ?? info.code,
445
+ retryAfterMs: info.retryAfterMs,
446
+ ...(info.upstreamDescription !== undefined ? { description: info.upstreamDescription } : {}),
447
+ ...(info.challenge !== undefined ? { challenge: info.challenge } : {}),
448
+ });
172
449
  }
173
450
  /**
174
- * Perform a DELETE request.
451
+ * Where a request's `credential` outcome goes: the current tool call's scope, else the configured
452
+ * recorder, else the process registry. The name must be declared there (`UnknownCredentialError`
453
+ * otherwise, before anything is sent: a value passed by mistake is never published).
175
454
  */
176
- async delete(path, params) {
177
- const startTime = Date.now();
178
- this.logger.debug(`DELETE ${path}`, this.logContext);
179
- try {
180
- const response = await this.client.delete(path, { params });
181
- const duration = Date.now() - startTime;
182
- this.logger.debug(`DELETE ${path} completed`, this.logContext, {
183
- status: response.status,
184
- duration: `${duration}ms`,
185
- });
186
- return response.data;
187
- }
188
- catch (error) {
189
- const duration = Date.now() - startTime;
190
- this.logger.error(`DELETE ${path} failed`, this.logContext, {
191
- duration: `${duration}ms`,
192
- error: error instanceof Error ? error.message : String(error),
193
- });
194
- throw this.normalizeError(error);
455
+ credentialRecorder(key) {
456
+ const recorder = currentCredentialScope() ?? this.config.credentials ?? processCredentials();
457
+ if (!recorder.has(key))
458
+ throw new UnknownCredentialError();
459
+ return recorder;
460
+ }
461
+ recordCredential(req, recorder, facts) {
462
+ if (req.credential === undefined || recorder === undefined)
463
+ return;
464
+ const { outcome, ...info } = credentialOutcomeFromHttp(facts);
465
+ recorder.record(req.credential, outcome, info);
466
+ }
467
+ /** Performs a request and returns status, headers and data. */
468
+ async requestRaw(req) {
469
+ const method = req.method ?? 'GET';
470
+ const { url, host, path } = this.resolve(req, method);
471
+ const recorder = req.credential !== undefined ? this.credentialRecorder(req.credential) : undefined;
472
+ const policy = req.retry === false ? { retries: 0 } : { ...this.config.retry, ...req.retry };
473
+ const retries = Math.max(0, policy.retries ?? 3);
474
+ const replayable = isIdempotentMethod(method) || req.idempotencyKey !== undefined || policy.unsafe === true;
475
+ const maxBytes = req.maxResponseBytes ?? this.config.maxResponseBytes ?? DEFAULT_MAX_BYTES;
476
+ const responseType = req.responseType ?? 'json';
477
+ let forceRefresh = false;
478
+ let refreshed = false;
479
+ for (let attempt = 0;;) {
480
+ throwIfAborted(req.signal);
481
+ await this.bucket?.acquire(req.signal);
482
+ let auth;
483
+ try {
484
+ auth = await this.authHeaders(req.signal, forceRefresh);
485
+ }
486
+ catch (error) {
487
+ throwIfAborted(req.signal);
488
+ const detail = error instanceof Error ? error.message : String(error);
489
+ this.logger.warn(`HTTP ${method} ${host}${path} token provider failed`, this.logContext, {
490
+ method,
491
+ host,
492
+ path,
493
+ code: 'ERR_TOKEN_PROVIDER',
494
+ attempt,
495
+ });
496
+ throw new HttpError({
497
+ message: this.maskSecrets(`Token provider failed: ${detail}`).slice(0, MAX_UPSTREAM_MESSAGE),
498
+ method,
499
+ path,
500
+ code: 'ERR_TOKEN_PROVIDER',
501
+ });
502
+ }
503
+ const headers = { ...req.headers, ...auth };
504
+ if (req.idempotencyKey !== undefined) {
505
+ headers[this.config.idempotencyKeyHeader ?? 'Idempotency-Key'] = req.idempotencyKey;
506
+ }
507
+ const started = Date.now();
508
+ try {
509
+ const response = await this.client.request({
510
+ method,
511
+ url,
512
+ data: req.body,
513
+ headers,
514
+ timeout: req.timeout,
515
+ signal: req.signal,
516
+ responseType,
517
+ maxContentLength: maxBytes,
518
+ ...(req.validateStatus ? { validateStatus: req.validateStatus } : {}),
519
+ });
520
+ const durationMs = Date.now() - started;
521
+ this.logger.debug(`HTTP ${method} ${host}${path} ${response.status}`, this.logContext, {
522
+ method,
523
+ host,
524
+ path,
525
+ status: response.status,
526
+ durationMs,
527
+ attempt,
528
+ });
529
+ const plain = plainHeaders(response.headers);
530
+ if (req.credential !== undefined) {
531
+ const facts = { status: response.status };
532
+ if (response.status >= 400) {
533
+ const details = isReadable(response.data) ? {} : upstreamDetails(response.data);
534
+ if (details.code)
535
+ facts.code = details.code;
536
+ if (details.message)
537
+ facts.message = this.maskSecrets(details.message);
538
+ if (details.description)
539
+ facts.description = this.maskSecrets(details.description);
540
+ const retryAfterMs = parseRetryAfter(plain['retry-after']);
541
+ if (retryAfterMs !== undefined)
542
+ facts.retryAfterMs = retryAfterMs;
543
+ if (plain['www-authenticate'])
544
+ facts.authenticate = plain['www-authenticate'];
545
+ }
546
+ this.recordCredential(req, recorder, facts);
547
+ }
548
+ let data = response.data;
549
+ if (responseType === 'stream' && isReadable(data)) {
550
+ const declared = Number(plain['content-length']);
551
+ const streamError = (code, message) => new HttpError({ message, method, path, code });
552
+ if (Number.isFinite(declared) && declared > maxBytes) {
553
+ data.destroy();
554
+ throw streamError('ERR_RESPONSE_TOO_LARGE', `Response exceeds ${maxBytes} bytes`);
555
+ }
556
+ data = capStream(data, maxBytes, streamError);
557
+ }
558
+ return { status: response.status, statusText: response.statusText ?? '', headers: plain, data: data };
559
+ }
560
+ catch (error) {
561
+ if (error instanceof HttpError)
562
+ throw error;
563
+ const durationMs = Date.now() - started;
564
+ const response = asRecord(asRecord(error)?.response);
565
+ if (isReadable(response?.data))
566
+ response.data.destroy();
567
+ throwIfAborted(req.signal);
568
+ const info = this.errorInfo(error, method, host, path);
569
+ if (info.status === 401 && this.config.auth?.type === 'tokenProvider' && !refreshed) {
570
+ refreshed = true;
571
+ forceRefresh = true;
572
+ this.logger.warn(`HTTP ${method} ${host}${path} 401, refreshing token`, this.logContext, {
573
+ method,
574
+ host,
575
+ path,
576
+ status: 401,
577
+ durationMs,
578
+ attempt,
579
+ });
580
+ continue;
581
+ }
582
+ if (replayable && attempt < retries && isRetryableError(error)) {
583
+ const { delayMs, fromRetryAfter } = retryDelayFor(info.retryAfterMs !== undefined ? { retryAfterMs: info.retryAfterMs } : {}, attempt, {
584
+ initialDelayMs: policy.initialDelayMs,
585
+ maxDelayMs: policy.maxDelayMs,
586
+ backoffMultiplier: policy.backoffMultiplier,
587
+ maxRetryAfterMs: policy.maxRetryAfterMs,
588
+ random: policy.random,
589
+ });
590
+ this.logger.warn(`HTTP ${method} ${host}${path} retrying`, this.logContext, {
591
+ method,
592
+ host,
593
+ path,
594
+ status: info.status,
595
+ code: info.code,
596
+ durationMs,
597
+ attempt,
598
+ delayMs,
599
+ });
600
+ policy.onRetry?.({ attempt, delayMs, fromRetryAfter, status: info.status, code: info.code, method, path });
601
+ await sleep(delayMs, req.signal);
602
+ attempt++;
603
+ continue;
604
+ }
605
+ const meta = { method, host, path, status: info.status, code: info.code, durationMs, attempt };
606
+ const line = `HTTP ${method} ${host}${path} failed`;
607
+ if (info.status === undefined || info.status >= 500)
608
+ this.logger.error(line, this.logContext, meta);
609
+ else
610
+ this.logger.warn(line, this.logContext, meta);
611
+ // The same facts the thrown HttpError carries, so a later re-classification agrees.
612
+ this.recordCredential(req, recorder, {
613
+ challenge: info.challenge ?? {},
614
+ ...(info.status !== undefined ? { status: info.status } : {}),
615
+ ...(info.upstreamCode !== undefined ? { code: info.upstreamCode } : {}),
616
+ ...(info.upstreamMessage !== undefined ? { message: info.upstreamMessage } : {}),
617
+ ...(info.upstreamDescription !== undefined ? { description: info.upstreamDescription } : {}),
618
+ ...(info.retryAfterMs !== undefined ? { retryAfterMs: info.retryAfterMs } : {}),
619
+ });
620
+ throw this.toHttpError(info);
621
+ }
195
622
  }
196
623
  }
624
+ /** Performs a request and returns the response data. */
625
+ async request(req) {
626
+ return (await this.requestRaw(req)).data;
627
+ }
628
+ async get(path, config = {}) {
629
+ return this.request({ ...config, method: 'GET', path });
630
+ }
631
+ /** HEAD request; returns the response headers (lower-cased). */
632
+ async head(path, config = {}) {
633
+ return (await this.requestRaw({ ...config, method: 'HEAD', path })).headers;
634
+ }
635
+ async post(path, body, config = {}) {
636
+ return this.request({ ...config, method: 'POST', path, body });
637
+ }
638
+ async put(path, body, config = {}) {
639
+ return this.request({ ...config, method: 'PUT', path, body });
640
+ }
641
+ async patch(path, body, config = {}) {
642
+ return this.request({ ...config, method: 'PATCH', path, body });
643
+ }
644
+ async delete(path, config = {}) {
645
+ return this.request({ ...config, method: 'DELETE', path });
646
+ }
197
647
  /**
198
- * Get the underlying axios instance for advanced use cases.
648
+ * Walks an upstream API's pages with a strategy (see `pagination`). Returns an async iterator of
649
+ * pages plus `items()` and `collect()`; capped by `maxPages` (default 100) and `maxItems`.
199
650
  */
651
+ paginate(request, strategy, options = {}) {
652
+ return paginateRequests((req) => this.requestRaw(req), request, strategy, options);
653
+ }
654
+ /** The underlying axios instance for advanced use cases (bypasses retry, auth and logging). */
200
655
  getAxiosInstance() {
201
656
  return this.client;
202
657
  }
203
- normalizeError(error) {
204
- if (axios.isAxiosError(error)) {
205
- const status = error.response?.status;
206
- const statusText = error.response?.statusText;
207
- const data = error.response?.data;
208
- const message = typeof data === 'object' && data?.message
209
- ? data.message
210
- : `HTTP ${status} ${statusText}`;
211
- const normalized = new Error(message);
212
- // Attach only the status — NOT the raw axios `response`. Its `config` carries the request's
213
- // Authorization header/token, and the upstream response headers may carry set-cookie/auth
214
- // echoes; a consumer logging the thrown error would otherwise leak them.
215
- normalized.status = status;
216
- normalized.statusText = statusText;
217
- return normalized;
218
- }
219
- return error instanceof Error ? error : new Error(String(error));
220
- }
221
658
  }
222
659
  /**
223
660
  * Create a configured HTTP client.
@@ -225,4 +662,5 @@ export class McpHttpClient {
225
662
  export function createHttpClient(config) {
226
663
  return new McpHttpClient(config);
227
664
  }
665
+ export { createTokenBucket, TokenBucket } from './rate-limiter.js';
228
666
  //# sourceMappingURL=http-client.js.map