@novorb/mcp-core 2.1.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -163
- package/bin/mcp-core-jest.mjs +112 -0
- package/dist/auth.d.ts +57 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +74 -0
- package/dist/auth.js.map +1 -0
- package/dist/bootstrap.d.ts +129 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +206 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/client-input.d.ts +72 -0
- package/dist/client-input.d.ts.map +1 -0
- package/dist/client-input.js +109 -0
- package/dist/client-input.js.map +1 -0
- package/dist/credential-checks.d.ts +80 -0
- package/dist/credential-checks.d.ts.map +1 -0
- package/dist/credential-checks.js +189 -0
- package/dist/credential-checks.js.map +1 -0
- package/dist/credential-health.d.ts +12 -0
- package/dist/credential-health.d.ts.map +1 -0
- package/dist/credential-health.js +12 -0
- package/dist/credential-health.js.map +1 -0
- package/dist/credential-report.d.ts +46 -0
- package/dist/credential-report.d.ts.map +1 -0
- package/dist/credential-report.js +135 -0
- package/dist/credential-report.js.map +1 -0
- package/dist/credential-status-tool.d.ts +20 -0
- package/dist/credential-status-tool.d.ts.map +1 -0
- package/dist/credential-status-tool.js +33 -0
- package/dist/credential-status-tool.js.map +1 -0
- package/dist/credentials.d.ts +288 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +652 -0
- package/dist/credentials.js.map +1 -0
- package/dist/express-peers.d.ts +29 -0
- package/dist/express-peers.d.ts.map +1 -0
- package/dist/express-peers.js +36 -0
- package/dist/express-peers.js.map +1 -0
- package/dist/express.d.ts +74 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +164 -0
- package/dist/express.js.map +1 -0
- package/dist/http-client.d.ts +277 -42
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +620 -180
- package/dist/http-client.js.map +1 -1
- package/dist/index.d.ts +27 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -8
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +28 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +37 -0
- package/dist/limits.js.map +1 -0
- package/dist/list-pagination.d.ts +41 -0
- package/dist/list-pagination.d.ts.map +1 -0
- package/dist/list-pagination.js +104 -0
- package/dist/list-pagination.js.map +1 -0
- package/dist/net.d.ts +112 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +490 -0
- package/dist/net.js.map +1 -0
- package/dist/notifications.d.ts +78 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +173 -0
- package/dist/notifications.js.map +1 -0
- package/dist/pagination.d.ts +236 -5
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +393 -6
- package/dist/pagination.js.map +1 -1
- package/dist/prompts.d.ts +70 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +109 -0
- package/dist/prompts.js.map +1 -0
- package/dist/protocol-errors.d.ts +15 -0
- package/dist/protocol-errors.d.ts.map +1 -0
- package/dist/protocol-errors.js +28 -0
- package/dist/protocol-errors.js.map +1 -0
- package/dist/protocol-logging.d.ts +37 -0
- package/dist/protocol-logging.d.ts.map +1 -0
- package/dist/protocol-logging.js +77 -0
- package/dist/protocol-logging.js.map +1 -0
- package/dist/rate-limiter.d.ts +36 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +79 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/request-state.d.ts +74 -0
- package/dist/request-state.d.ts.map +1 -0
- package/dist/request-state.js +190 -0
- package/dist/request-state.js.map +1 -0
- package/dist/resources.d.ts +181 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +302 -0
- package/dist/resources.js.map +1 -0
- package/dist/result-limits.d.ts +80 -0
- package/dist/result-limits.d.ts.map +1 -0
- package/dist/result-limits.js +248 -0
- package/dist/result-limits.js.map +1 -0
- package/dist/retry.d.ts +79 -23
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +170 -57
- package/dist/retry.js.map +1 -1
- package/dist/sdk/client-stdio.d.ts +6 -0
- package/dist/sdk/client-stdio.d.ts.map +1 -0
- package/dist/sdk/client-stdio.js +6 -0
- package/dist/sdk/client-stdio.js.map +1 -0
- package/dist/sdk/client.d.ts +6 -0
- package/dist/sdk/client.d.ts.map +1 -0
- package/dist/sdk/client.js +6 -0
- package/dist/sdk/client.js.map +1 -0
- package/dist/sdk/express.d.ts +3 -0
- package/dist/sdk/express.d.ts.map +1 -0
- package/dist/sdk/express.js +11 -0
- package/dist/sdk/express.js.map +1 -0
- package/dist/sdk/node.d.ts +6 -0
- package/dist/sdk/node.d.ts.map +1 -0
- package/dist/sdk/node.js +6 -0
- package/dist/sdk/node.js.map +1 -0
- package/dist/sdk/server-stdio.d.ts +6 -0
- package/dist/sdk/server-stdio.d.ts.map +1 -0
- package/dist/sdk/server-stdio.js +6 -0
- package/dist/sdk/server-stdio.js.map +1 -0
- package/dist/sdk/server.d.ts +6 -0
- package/dist/sdk/server.d.ts.map +1 -0
- package/dist/sdk/server.js +6 -0
- package/dist/sdk/server.js.map +1 -0
- package/dist/server.d.ts +88 -35
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +98 -44
- package/dist/server.js.map +1 -1
- package/dist/task-request.d.ts +20 -0
- package/dist/task-request.d.ts.map +1 -0
- package/dist/task-request.js +43 -0
- package/dist/task-request.js.map +1 -0
- package/dist/task-store.d.ts +230 -0
- package/dist/task-store.d.ts.map +1 -0
- package/dist/task-store.js +459 -0
- package/dist/task-store.js.map +1 -0
- package/dist/task-tools.d.ts +115 -0
- package/dist/task-tools.d.ts.map +1 -0
- package/dist/task-tools.js +397 -0
- package/dist/task-tools.js.map +1 -0
- package/dist/testing/client.d.ts +25 -0
- package/dist/testing/client.d.ts.map +1 -0
- package/dist/testing/client.js +11 -0
- package/dist/testing/client.js.map +1 -0
- package/dist/testing/in-memory.d.ts +26 -0
- package/dist/testing/in-memory.d.ts.map +1 -0
- package/dist/testing/in-memory.js +41 -0
- package/dist/testing/in-memory.js.map +1 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +20 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/isolated-dirs.d.ts +27 -0
- package/dist/testing/isolated-dirs.d.ts.map +1 -0
- package/dist/testing/isolated-dirs.js +67 -0
- package/dist/testing/isolated-dirs.js.map +1 -0
- package/dist/testing/logs.d.ts +61 -0
- package/dist/testing/logs.d.ts.map +1 -0
- package/dist/testing/logs.js +113 -0
- package/dist/testing/logs.js.map +1 -0
- package/dist/testing/raw-http.d.ts +25 -0
- package/dist/testing/raw-http.d.ts.map +1 -0
- package/dist/testing/raw-http.js +41 -0
- package/dist/testing/raw-http.js.map +1 -0
- package/dist/testing/raw-peer.d.ts +43 -0
- package/dist/testing/raw-peer.d.ts.map +1 -0
- package/dist/testing/raw-peer.js +101 -0
- package/dist/testing/raw-peer.js.map +1 -0
- package/dist/testing/recording-server.d.ts +38 -0
- package/dist/testing/recording-server.d.ts.map +1 -0
- package/dist/testing/recording-server.js +38 -0
- package/dist/testing/recording-server.js.map +1 -0
- package/dist/testing/server-process.d.ts +76 -0
- package/dist/testing/server-process.d.ts.map +1 -0
- package/dist/testing/server-process.js +241 -0
- package/dist/testing/server-process.js.map +1 -0
- package/dist/testing/tools.d.ts +53 -0
- package/dist/testing/tools.d.ts.map +1 -0
- package/dist/testing/tools.js +98 -0
- package/dist/testing/tools.js.map +1 -0
- package/dist/testing/unit.d.ts +52 -0
- package/dist/testing/unit.d.ts.map +1 -0
- package/dist/testing/unit.js +95 -0
- package/dist/testing/unit.js.map +1 -0
- package/dist/tools.d.ts +205 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +370 -0
- package/dist/tools.js.map +1 -0
- package/dist/transport.d.ts +143 -52
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +292 -123
- package/dist/transport.js.map +1 -1
- package/dist/url-path.d.ts +49 -0
- package/dist/url-path.d.ts.map +1 -0
- package/dist/url-path.js +165 -0
- package/dist/url-path.js.map +1 -0
- package/dist/utils/dataStore.d.ts +143 -0
- package/dist/utils/dataStore.d.ts.map +1 -0
- package/dist/utils/dataStore.js +429 -0
- package/dist/utils/dataStore.js.map +1 -0
- package/dist/utils/envLoader.d.ts +129 -0
- package/dist/utils/envLoader.d.ts.map +1 -0
- package/dist/utils/envLoader.js +399 -0
- package/dist/utils/envLoader.js.map +1 -0
- package/dist/utils/logger.d.ts +181 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +664 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/redact.d.ts +50 -0
- package/dist/utils/redact.d.ts.map +1 -0
- package/dist/utils/redact.js +318 -0
- package/dist/utils/redact.js.map +1 -0
- package/docs/api-reference.md +29 -0
- package/docs/building-a-server.md +63 -0
- package/docs/credentials.md +131 -0
- package/docs/data-store.md +37 -0
- package/docs/env.md +38 -0
- package/docs/features.md +28 -0
- package/docs/http-client.md +116 -0
- package/docs/logger.md +100 -0
- package/docs/migrating-to-v3.md +45 -0
- package/docs/outbound-urls.md +104 -0
- package/docs/pagination.md +45 -0
- package/docs/protocol-features.md +62 -0
- package/docs/resources-prompts-tasks.md +95 -0
- package/docs/sdk.md +33 -0
- package/docs/server.md +68 -0
- package/docs/testing-harness.md +139 -0
- package/docs/testing.md +151 -0
- package/docs/tools.md +88 -0
- package/docs/transport.md +85 -0
- package/examples/testing/tools.integration.test.example.ts +312 -0
- package/examples/testing/tools.unit.test.example.ts +149 -0
- package/package.json +130 -17
- package/dist/logger.d.ts +0 -80
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -237
- package/dist/logger.js.map +0 -1
package/dist/http-client.js
CHANGED
|
@@ -1,221 +1,660 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @novorb/mcp-core
|
|
2
|
+
* @novorb/mcp-core - HTTP client
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
8
|
-
import {
|
|
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
|
|
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.
|
|
18
|
-
this.
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
26
|
-
maxContentLength:
|
|
27
|
-
maxBodyLength:
|
|
28
|
-
headers: {
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
*
|
|
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 ? data.message : `HTTP ${status} ${statusText}`;
|
|
209
|
-
const normalized = new Error(message);
|
|
210
|
-
// Attach only the status — NOT the raw axios `response`. Its `config` carries the request's
|
|
211
|
-
// Authorization header/token, and the upstream response headers may carry set-cookie/auth
|
|
212
|
-
// echoes; a consumer logging the thrown error would otherwise leak them.
|
|
213
|
-
normalized.status = status;
|
|
214
|
-
normalized.statusText = statusText;
|
|
215
|
-
return normalized;
|
|
216
|
-
}
|
|
217
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
218
|
-
}
|
|
219
658
|
}
|
|
220
659
|
/**
|
|
221
660
|
* Create a configured HTTP client.
|
|
@@ -223,4 +662,5 @@ export class McpHttpClient {
|
|
|
223
662
|
export function createHttpClient(config) {
|
|
224
663
|
return new McpHttpClient(config);
|
|
225
664
|
}
|
|
665
|
+
export { createTokenBucket, TokenBucket } from './rate-limiter.js';
|
|
226
666
|
//# sourceMappingURL=http-client.js.map
|