@novorb/mcp-core 2.1.2 → 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 +77 -163
  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 +277 -42
  44. package/dist/http-client.d.ts.map +1 -1
  45. package/dist/http-client.js +620 -180
  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 +47 -8
  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 -35
  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 +143 -52
  192. package/dist/transport.d.ts.map +1 -1
  193. package/dist/transport.js +292 -123
  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 +130 -17
  237. package/dist/logger.d.ts +0 -80
  238. package/dist/logger.d.ts.map +0 -1
  239. package/dist/logger.js +0 -237
  240. package/dist/logger.js.map +0 -1
@@ -0,0 +1,652 @@
1
+ /**
2
+ * @novorb/mcp-core - Credential health
3
+ *
4
+ * A credential is checked once when the env is loaded (see `credential-checks.ts`); after that its
5
+ * health is learned from use. A key can degrade (quota, rate limit), become invalid or expire while the
6
+ * server runs: that never crashes the server and never turns a tool call into an error on its own.
7
+ * Instead every tool result that touched a credential carries a report of the keys it used and the
8
+ * ones that are degraded, invalid or expired (see `registerTools`).
9
+ *
10
+ * - `CredentialRegistry` (process-wide by default, `processCredentials()`): the status per DECLARED key
11
+ * name, pools (an env `multi` family: `KEY`, `KEY_1`, `KEY_2`, ...), `pick(pool)` rotation that
12
+ * prefers valid over unknown over degraded and keeps invalid and expired keys as the last resort (so
13
+ * no key is lost for good), `record(key, outcome)`, `snapshot()`, and a background re-check of invalid
14
+ * and expired keys (the startup `check`) after `recheckMs`. Transitions are logged once per change.
15
+ * - `CredentialScope`: one tool call's view of the registry. It remembers which credentials the call
16
+ * touched, so the result can report them; the http-client records into the current scope.
17
+ * - `credentialOutcomeFromHttp`: the one HTTP outcome mapping (status-gated, see there).
18
+ *
19
+ * Only declared key NAMES ever appear in logs, results and snapshots: an identifier that is not a
20
+ * registered name is refused with `UnknownCredentialError` (a programming error), so a value passed by
21
+ * mistake is never published. A registry never holds a value.
22
+ */
23
+ import { AsyncLocalStorage } from 'node:async_hooks';
24
+ import { getLogger, maskSecretValues } from './utils/logger.js';
25
+ import { redactStringSecrets } from './utils/redact.js';
26
+ export const CREDENTIAL_STATUSES = ['valid', 'invalid', 'expired', 'degraded', 'unknown'];
27
+ const DEFAULT_RETRY_AFTER_MS = 60_000;
28
+ export const DEFAULT_CREDENTIAL_RECHECK_MS = 300_000;
29
+ const MAX_REASON = 200;
30
+ /** A credential identifier is an env variable name. */
31
+ const KEY_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
32
+ /** A reason is free text from a check or a caller: mask it like client text and bound it. */
33
+ function maskReason(reason) {
34
+ return maskSecretValues(redactStringSecrets(reason))
35
+ .replace(/[\r\n]+/g, ' ')
36
+ .slice(0, MAX_REASON);
37
+ }
38
+ /** No candidate of a pool worked: every one was tried and failed on its credential, or none is configured. */
39
+ export class NoUsableCredentialError extends Error {
40
+ pool;
41
+ keys;
42
+ constructor(pool, keys, detail) {
43
+ super(keys.length === 0
44
+ ? `No credential is configured for ${pool}`
45
+ : `No usable credential for ${pool}: ${detail ?? 'every candidate is invalid or expired'} (${keys.join(', ')})`);
46
+ this.name = 'NoUsableCredentialError';
47
+ this.pool = pool;
48
+ this.keys = keys;
49
+ }
50
+ }
51
+ /**
52
+ * A programming error: an identifier that is not a declared credential name (env key or pool member)
53
+ * was passed where a key NAME belongs (often the VALUE by mistake). The identifier itself is never put
54
+ * in the message.
55
+ */
56
+ export class UnknownCredentialError extends Error {
57
+ constructor() {
58
+ super('Unknown credential name: not a declared env key or pool member. Pass the key NAME (for example SEARCH_API_KEY_2), never its value.');
59
+ this.name = 'UnknownCredentialError';
60
+ }
61
+ }
62
+ function outcomeStatus(outcome) {
63
+ switch (outcome) {
64
+ case 'ok':
65
+ case 'recovered':
66
+ return 'valid';
67
+ case 'degraded':
68
+ case 'invalid':
69
+ case 'expired':
70
+ return outcome;
71
+ default:
72
+ return undefined;
73
+ }
74
+ }
75
+ const UNUSABLE = new Set(['invalid', 'expired']);
76
+ /** The process-wide (or a test's own) credential health registry. */
77
+ export class CredentialRegistry {
78
+ entries = new Map();
79
+ pools = new Map();
80
+ cursors = new Map();
81
+ now;
82
+ logger;
83
+ defaultRetryAfterMs;
84
+ recheckMs;
85
+ constructor(options = {}) {
86
+ this.now = options.now ?? Date.now;
87
+ if (options.logger)
88
+ this.logger = options.logger;
89
+ this.defaultRetryAfterMs = options.defaultRetryAfterMs ?? DEFAULT_RETRY_AFTER_MS;
90
+ this.recheckMs = options.recheckMs ?? DEFAULT_CREDENTIAL_RECHECK_MS;
91
+ }
92
+ log() {
93
+ return this.logger ?? getLogger();
94
+ }
95
+ /** Set the invalid/expired re-check cool-down (startup reads MCP_CREDENTIAL_RECHECK_MS). */
96
+ setRecheckMs(ms) {
97
+ this.recheckMs = Math.max(0, ms);
98
+ }
99
+ /**
100
+ * Declare a credential by its env NAME (status `unknown` until checked or used). Re-registering keeps
101
+ * the status and updates the pool. A pool member is appended to its pool in registration order.
102
+ * Throws `UnknownCredentialError` for anything that is not an env-variable-shaped name.
103
+ */
104
+ register(key, options = {}) {
105
+ if (!KEY_NAME.test(key) || (options.pool !== undefined && !KEY_NAME.test(options.pool))) {
106
+ throw new UnknownCredentialError();
107
+ }
108
+ const existing = this.entries.get(key);
109
+ const entry = existing ?? { key, status: 'unknown' };
110
+ if (options.pool !== undefined && entry.pool !== options.pool) {
111
+ if (entry.pool !== undefined) {
112
+ const old = this.pools.get(entry.pool);
113
+ if (old)
114
+ this.pools.set(entry.pool, old.filter((k) => k !== key));
115
+ }
116
+ entry.pool = options.pool;
117
+ const members = this.pools.get(options.pool) ?? [];
118
+ if (!members.includes(key))
119
+ members.push(key);
120
+ this.pools.set(options.pool, members);
121
+ }
122
+ this.entries.set(key, entry);
123
+ }
124
+ /** Attach the re-check for an invalid or expired key (the startup `check` bound to its value). */
125
+ setProbe(key, probe) {
126
+ this.entry(key).probe = probe;
127
+ }
128
+ /**
129
+ * Declare that `key` stands for the other parts of a multi-part credential (client id + secret, key +
130
+ * secret): every status change of `key` (a check, a recheck, a recorded outcome) is applied to each
131
+ * part too, with the same reason. Both sides must be declared (else `UnknownCredentialError`); `key`
132
+ * itself is ignored if listed.
133
+ */
134
+ cover(key, parts) {
135
+ const entry = this.entry(key);
136
+ const list = parts.filter((p) => p !== key);
137
+ for (const p of list)
138
+ this.entry(p);
139
+ entry.covers = [...new Set([...(entry.covers ?? []), ...list])];
140
+ }
141
+ /** Forget every credential (tests, or a server re-reading its env). */
142
+ clear() {
143
+ this.entries.clear();
144
+ this.pools.clear();
145
+ this.cursors.clear();
146
+ }
147
+ /** Whether `key` is a declared credential name. */
148
+ has(key) {
149
+ return this.entries.has(key);
150
+ }
151
+ /** The declared entry, or `UnknownCredentialError`. */
152
+ entry(key) {
153
+ const entry = this.entries.get(key);
154
+ if (!entry)
155
+ throw new UnknownCredentialError();
156
+ return entry;
157
+ }
158
+ /** A degraded key whose retry-after elapsed goes back to `unknown` (logged once). */
159
+ refresh(entry) {
160
+ if (entry.status === 'degraded' && entry.retryAt !== undefined && entry.retryAt <= this.now()) {
161
+ this.transition(entry.key, 'unknown', { reason: 'retry-after elapsed' }, false);
162
+ }
163
+ }
164
+ /** The current status (`unknown` for a key never registered). */
165
+ status(key) {
166
+ const entry = this.entries.get(key);
167
+ if (!entry)
168
+ return 'unknown';
169
+ this.refresh(entry);
170
+ return entry.status;
171
+ }
172
+ /**
173
+ * The member keys of `pool`: a pool name, a single declared key, or an explicit list of declared keys
174
+ * (an undeclared name in a list throws `UnknownCredentialError`). An unknown pool name has no members.
175
+ */
176
+ members(pool) {
177
+ if (typeof pool !== 'string') {
178
+ for (const key of pool)
179
+ if (!this.entries.has(key))
180
+ throw new UnknownCredentialError();
181
+ return [...pool];
182
+ }
183
+ const members = this.pools.get(pool);
184
+ if (members && members.length > 0)
185
+ return [...members];
186
+ return this.entries.has(pool) ? [pool] : [];
187
+ }
188
+ /**
189
+ * Set a status directly (startup checks, re-checks). `silent` skips the transition log line (the
190
+ * caller logs a summary instead).
191
+ */
192
+ set(key, status, info = {}) {
193
+ this.entry(key);
194
+ this.transition(key, status, info, info.silent === true);
195
+ }
196
+ /** Record what a use of `key` showed. `key` must be a declared name. */
197
+ record(key, outcome, info = {}) {
198
+ const entry = this.entry(key);
199
+ entry.lastUsedAt = this.now();
200
+ const next = outcomeStatus(outcome);
201
+ if (next !== undefined)
202
+ this.transition(key, next, info, false);
203
+ }
204
+ /** Mark a declared key as used (no outcome). */
205
+ touch(key) {
206
+ this.entry(key).lastUsedAt = this.now();
207
+ }
208
+ transition(key, next, info, silent) {
209
+ this.transitionOne(key, next, info, silent);
210
+ for (const part of this.entries.get(key).covers ?? [])
211
+ this.transitionOne(part, next, info, silent);
212
+ }
213
+ transitionOne(key, next, info, silent) {
214
+ const entry = this.entries.get(key);
215
+ const previous = entry.status;
216
+ const changed = previous !== next;
217
+ entry.status = next;
218
+ if (next === 'valid')
219
+ delete entry.reason;
220
+ else if (info.reason !== undefined)
221
+ entry.reason = maskReason(info.reason);
222
+ else if (changed)
223
+ delete entry.reason;
224
+ if (next === 'degraded')
225
+ entry.retryAt = this.now() + Math.max(0, info.retryAfterMs ?? this.defaultRetryAfterMs);
226
+ else
227
+ delete entry.retryAt;
228
+ if (UNUSABLE.has(next))
229
+ entry.recheckAt = this.now() + this.recheckMs;
230
+ else
231
+ delete entry.recheckAt;
232
+ if (!changed)
233
+ return;
234
+ entry.changedAt = this.now();
235
+ if (silent)
236
+ return;
237
+ const detail = entry.reason !== undefined ? ` (${entry.reason})` : '';
238
+ const line = `Credential ${key}: ${previous} -> ${next}${detail}`;
239
+ if (next === 'valid' || next === 'unknown')
240
+ this.log().info(line, 'Credentials');
241
+ else
242
+ this.log().warn(line, 'Credentials');
243
+ }
244
+ /** Rank for `candidates`: valid 0, unknown 1, degraded 2, invalid/expired 3 (the last resort). */
245
+ rank(key) {
246
+ const entry = this.entries.get(key);
247
+ this.refresh(entry);
248
+ switch (entry.status) {
249
+ case 'valid':
250
+ return 0;
251
+ case 'unknown':
252
+ return 1;
253
+ case 'degraded':
254
+ return 2;
255
+ default:
256
+ return 3;
257
+ }
258
+ }
259
+ /**
260
+ * Re-check `key` now with its probe (the startup `check`). Resolves with the new status, or the
261
+ * current one when there is no probe. One probe per key runs at a time; a probe never throws out.
262
+ */
263
+ recheck(key) {
264
+ const entry = this.entry(key);
265
+ if (!entry.probe)
266
+ return Promise.resolve(entry.status);
267
+ entry.probing ??= entry
268
+ .probe()
269
+ .then((result) => {
270
+ this.transition(key, result.status, result, false);
271
+ return result.status;
272
+ }, () => {
273
+ // A failing probe proves nothing: keep the status, wait another cool-down.
274
+ if (UNUSABLE.has(entry.status))
275
+ entry.recheckAt = this.now() + this.recheckMs;
276
+ return entry.status;
277
+ })
278
+ .finally(() => {
279
+ delete entry.probing;
280
+ });
281
+ return entry.probing;
282
+ }
283
+ /** Start the re-check of every invalid/expired member whose cool-down elapsed (not awaited). */
284
+ recheckDue(keys) {
285
+ for (const key of keys) {
286
+ const entry = this.entries.get(key);
287
+ if (UNUSABLE.has(entry.status) && entry.probe && !entry.probing && (entry.recheckAt ?? 0) <= this.now()) {
288
+ void this.recheck(key);
289
+ }
290
+ }
291
+ }
292
+ /**
293
+ * Every member of `pool`, best first: valid, unknown, degraded, then invalid and expired as the last
294
+ * resort (so a pool whose keys all went bad still tries a request, and a success restores the key).
295
+ * Rotated within the best tier so repeated calls spread over equally healthy keys. Invalid and expired
296
+ * members whose re-check cool-down elapsed are re-checked in the background.
297
+ */
298
+ candidates(pool) {
299
+ const members = this.members(pool);
300
+ this.recheckDue(members);
301
+ const ranked = members
302
+ .map((key, index) => ({ key, index, rank: this.rank(key) }))
303
+ .sort((a, b) => a.rank - b.rank || a.index - b.index);
304
+ if (ranked.length === 0)
305
+ return [];
306
+ const best = ranked[0].rank;
307
+ const top = ranked.filter((m) => m.rank === best);
308
+ const id = typeof pool === 'string' ? pool : pool.join(',');
309
+ const cursor = this.cursors.get(id) ?? 0;
310
+ this.cursors.set(id, cursor + 1);
311
+ const offset = cursor % top.length;
312
+ const rotated = [...top.slice(offset), ...top.slice(0, offset)];
313
+ return [...rotated, ...ranked.filter((m) => m.rank !== best)].map((m) => m.key);
314
+ }
315
+ /** The best member of `pool` (see `candidates`), or undefined when the pool has no member. */
316
+ pick(pool) {
317
+ return this.candidates(pool)[0];
318
+ }
319
+ /** One key's state (undefined when never registered). */
320
+ state(key) {
321
+ const entry = this.entries.get(key);
322
+ if (!entry)
323
+ return undefined;
324
+ this.refresh(entry);
325
+ const out = { key, status: entry.status };
326
+ if (entry.pool !== undefined)
327
+ out.pool = entry.pool;
328
+ if (entry.reason !== undefined)
329
+ out.reason = entry.reason;
330
+ if (entry.status === 'degraded' && entry.retryAt !== undefined) {
331
+ out.retryInSeconds = Math.max(1, Math.ceil((entry.retryAt - this.now()) / 1000));
332
+ }
333
+ if (entry.changedAt !== undefined)
334
+ out.changedAt = new Date(entry.changedAt).toISOString();
335
+ if (entry.lastUsedAt !== undefined)
336
+ out.lastUsedAt = new Date(entry.lastUsedAt).toISOString();
337
+ return out;
338
+ }
339
+ /** Every credential's state, in registration order. */
340
+ snapshot() {
341
+ return [...this.entries.keys()].map((key) => this.state(key));
342
+ }
343
+ }
344
+ let processRegistry;
345
+ /** The process-wide registry `runMcpServer` fills and `registerTools` reports from. */
346
+ export function processCredentials() {
347
+ processRegistry ??= new CredentialRegistry();
348
+ return processRegistry;
349
+ }
350
+ /** Replace the process-wide registry (tests, or a host with its own). Undefined resets it. */
351
+ export function setProcessCredentials(registry) {
352
+ processRegistry = registry;
353
+ }
354
+ const USABLE = new Set(['valid', 'unknown']);
355
+ /** A tool call's view of the registry: what it picks and records is reported on its result. */
356
+ export class CredentialScope {
357
+ registry;
358
+ touched = new Map();
359
+ valueFor;
360
+ constructor(registry, valueFor = () => undefined) {
361
+ this.registry = registry;
362
+ this.valueFor = valueFor;
363
+ }
364
+ touch(key, used) {
365
+ const seen = this.touched.get(key);
366
+ if (seen)
367
+ seen.used ||= used;
368
+ else
369
+ this.touched.set(key, { used });
370
+ }
371
+ has(key) {
372
+ return this.registry.has(key);
373
+ }
374
+ /** Consider every member of `pool` (reported when not healthy) and return them, best first. */
375
+ candidates(pool) {
376
+ for (const key of this.registry.members(pool))
377
+ this.touch(key, false);
378
+ return this.registry.candidates(pool);
379
+ }
380
+ /**
381
+ * The best key of `pool` (marked used); an invalid or expired key when nothing better is left (try
382
+ * it and record the outcome). Undefined only when the pool has no member.
383
+ */
384
+ pick(pool) {
385
+ const key = this.candidates(pool)[0];
386
+ if (key !== undefined)
387
+ this.touch(key, true);
388
+ return key;
389
+ }
390
+ /** Like `pick`, but throws `NoUsableCredentialError` when the pool has no member. */
391
+ require(pool) {
392
+ const key = this.pick(pool);
393
+ if (key === undefined)
394
+ throw new NoUsableCredentialError(poolName(pool), this.registry.members(pool));
395
+ return key;
396
+ }
397
+ /** Mark a declared `key` as used by this call without recording an outcome. */
398
+ use(key) {
399
+ this.registry.touch(key);
400
+ this.touch(key, true);
401
+ }
402
+ record(key, outcome, info = {}) {
403
+ this.registry.record(key, outcome, info);
404
+ this.touch(key, true);
405
+ }
406
+ /**
407
+ * Try the members of `pool` in order (best first, invalid and expired last): `fn(key, value)` for each
408
+ * until one succeeds. A failure that is a credential problem (see `credentialOutcomeFromError`) is
409
+ * recorded and the next key is tried; any other error is rethrown. When every member failed on its
410
+ * credential (or the pool is empty) it throws `NoUsableCredentialError`.
411
+ */
412
+ async withFallback(pool, fn) {
413
+ const keys = this.candidates(pool);
414
+ const failures = [];
415
+ for (const key of keys) {
416
+ this.touch(key, true);
417
+ try {
418
+ return await fn(key, this.valueFor(key));
419
+ }
420
+ catch (err) {
421
+ const classified = credentialOutcomeFromError(err);
422
+ if (classified === undefined)
423
+ throw err;
424
+ this.record(key, classified.outcome, classified);
425
+ failures.push(`${key} ${classified.outcome}`);
426
+ }
427
+ }
428
+ const members = this.registry.members(pool);
429
+ throw new NoUsableCredentialError(poolName(pool), members, failures.length > 0 ? `every candidate failed (${failures.join(', ')})` : undefined);
430
+ }
431
+ /** Whether this call touched any credential. */
432
+ get touchedAny() {
433
+ return this.touched.size > 0;
434
+ }
435
+ /**
436
+ * The report entries: every key the call used, plus every considered key that is not healthy
437
+ * (degraded, invalid, expired). Considered-but-unused healthy keys are left out.
438
+ */
439
+ report() {
440
+ const out = [];
441
+ for (const [key, { used }] of this.touched) {
442
+ const state = this.registry.state(key);
443
+ const status = state?.status ?? 'unknown';
444
+ if (!used && USABLE.has(status))
445
+ continue;
446
+ const entry = { key, status, used };
447
+ if (state?.reason !== undefined)
448
+ entry.reason = state.reason;
449
+ if (state?.retryInSeconds !== undefined)
450
+ entry.retryInSeconds = state.retryInSeconds;
451
+ out.push(entry);
452
+ }
453
+ return out;
454
+ }
455
+ }
456
+ function poolName(pool) {
457
+ return typeof pool === 'string' ? pool : pool.join(', ');
458
+ }
459
+ /** The report as one line the model reads: `Credentials: A used; B degraded (quota, retry in 60s); C invalid`. */
460
+ export function formatCredentialReport(entries) {
461
+ const parts = entries.map((e) => {
462
+ if (USABLE.has(e.status))
463
+ return `${e.key} used`;
464
+ const details = [];
465
+ if (e.reason !== undefined)
466
+ details.push(e.reason);
467
+ if (e.retryInSeconds !== undefined && e.retryInSeconds > 0)
468
+ details.push(`retry in ${e.retryInSeconds}s`);
469
+ return `${e.key} ${e.status}${details.length > 0 ? ` (${details.join(', ')})` : ''}`;
470
+ });
471
+ return `Credentials: ${parts.join('; ')}`;
472
+ }
473
+ const scopeStorage = new AsyncLocalStorage();
474
+ /**
475
+ * Run `fn` with `scope` as the current call's credential scope (registerTools does this per call).
476
+ * Outcomes are recorded into the scope active where a request STARTS: a shared in-flight promise
477
+ * (a dedup cache, a coalesced request) reports its key on the call that started it only.
478
+ */
479
+ export function runWithCredentialScope(scope, fn) {
480
+ return scopeStorage.run(scope, fn);
481
+ }
482
+ /** The current tool call's credential scope, when inside one. */
483
+ export function currentCredentialScope() {
484
+ return scopeStorage.getStore();
485
+ }
486
+ const MAX_CHALLENGE_LENGTH = 2048;
487
+ const TOKEN_CHAR = /[!#$%&'*+.^_`|~0-9A-Za-z-]/;
488
+ /**
489
+ * The `error` and `error_description` auth-params of a `WWW-Authenticate` header (first occurrence of
490
+ * each). The header is tokenised left to right: a quoted string (`"..."` with backslash escapes, or
491
+ * `'...'`) is one value and is never searched, so a quoted realm cannot inject a parameter.
492
+ */
493
+ export function parseAuthChallenge(header) {
494
+ const out = {};
495
+ if (!header)
496
+ return out;
497
+ const s = header.slice(0, MAX_CHALLENGE_LENGTH);
498
+ let i = 0;
499
+ const skipSpace = () => {
500
+ while (i < s.length && (s[i] === ' ' || s[i] === '\t' || s[i] === ','))
501
+ i++;
502
+ };
503
+ const readToken = () => {
504
+ const start = i;
505
+ while (i < s.length && TOKEN_CHAR.test(s[i]))
506
+ i++;
507
+ return s.slice(start, i);
508
+ };
509
+ const readQuoted = (quote) => {
510
+ i++;
511
+ let value = '';
512
+ while (i < s.length && s[i] !== quote) {
513
+ if (quote === '"' && s[i] === '\\' && i + 1 < s.length)
514
+ i++;
515
+ value += s[i];
516
+ i++;
517
+ }
518
+ i++;
519
+ return value;
520
+ };
521
+ while (i < s.length) {
522
+ skipSpace();
523
+ if (i >= s.length)
524
+ break;
525
+ if (s[i] === '"') {
526
+ readQuoted('"');
527
+ continue;
528
+ }
529
+ const name = readToken();
530
+ if (name === '') {
531
+ i++;
532
+ continue;
533
+ }
534
+ let j = i;
535
+ while (j < s.length && (s[j] === ' ' || s[j] === '\t'))
536
+ j++;
537
+ if (s[j] !== '=')
538
+ continue;
539
+ i = j + 1;
540
+ while (i < s.length && (s[i] === ' ' || s[i] === '\t'))
541
+ i++;
542
+ let value;
543
+ if (s[i] === '"' || s[i] === "'")
544
+ value = readQuoted(s[i]);
545
+ else {
546
+ const start = i;
547
+ while (i < s.length && s[i] !== ',' && s[i] !== ' ' && s[i] !== '\t')
548
+ i++;
549
+ value = s.slice(start, i);
550
+ }
551
+ const key = name.toLowerCase();
552
+ if (key === 'error' && out.error === undefined)
553
+ out.error = value;
554
+ else if (key === 'error_description' && out.description === undefined)
555
+ out.description = value;
556
+ }
557
+ return out;
558
+ }
559
+ function challengeOf(facts) {
560
+ return facts.challenge ?? parseAuthChallenge(facts.authenticate);
561
+ }
562
+ /** "expired" next to a credential word, or a credential-expired code. Never a bare "expired". */
563
+ const EXPIRED = /\b(?:token|key|credentials?|subscription|password|license|licence)\b[^.;\n]{0,40}\bexpired\b|\bexpired\b[^.;\n]{0,20}\b(?:token|key|credentials?|subscription|password|license|licence)\b|(?:token|key|credentials?)[_-]?expired/i;
564
+ /** A quota or rate-limit signal. Never a bare "exceeded" ("length exceeded" is a bad request). */
565
+ const QUOTA = /quota|rate[ _-]?limit|too many requests|out of credits|insufficient credits|credits? exhausted|run out of|usage limit/i;
566
+ /** A 403 that is about the credential itself, not about access to one resource. */
567
+ const CREDENTIAL_REJECTED = /invalid[ _-]?(?:api[ _-]?)?(?:token|key|credentials?|auth\w*)|bad credentials|\b(?:token|key|credentials?)\b[^.;\n]{0,30}\b(?:revoked|disabled|deactivated|suspended)\b|\brevoked\b|invalid_token|invalid_client|invalid_grant|unauthenticated|not authenticated|authentication (?:failed|required)/i;
568
+ /**
569
+ * Map an HTTP outcome onto a credential outcome, status-gated:
570
+ * - 2xx/3xx -> ok; 5xx, network errors and every other 4xx (400, 404, 409, 413, 422, ...) -> used;
571
+ * - 401 -> invalid, or expired when the code, message, `error_description` or the `WWW-Authenticate`
572
+ * `error` / `error_description` parameters name an expired credential (GitLab: `error="invalid_token",
573
+ * error_description="Token is expired"`);
574
+ * - 402 -> degraded (quota); 429 -> degraded (rate limit, or quota when the body says so);
575
+ * - 403 -> expired / degraded / invalid ONLY when the body (or the challenge's `error="invalid_token"`)
576
+ * says the credential itself expired, hit a quota or rate limit, or was rejected; a plain 403 is an
577
+ * authorization answer about one resource and leaves the key alone (used).
578
+ * Degraded outcomes carry the Retry-After.
579
+ */
580
+ export function credentialOutcomeFromHttp(facts) {
581
+ const { status } = facts;
582
+ if (status === undefined || status >= 500)
583
+ return { outcome: 'used' };
584
+ if (status < 400)
585
+ return { outcome: 'ok' };
586
+ // GitLab-style OAuth answers put a generic `error: "invalid_token"` first and the expiry only in
587
+ // `error_description` and the challenge, so all of them are read.
588
+ // Joined with ` ; ` so no pattern spans two fields (the patterns stop at `;`).
589
+ const challenge = challengeOf(facts);
590
+ const text = [facts.code, facts.message, facts.description, challenge.error, challenge.description]
591
+ .filter((part) => part !== undefined && part !== '')
592
+ .join(' ; ');
593
+ const retry = facts.retryAfterMs !== undefined ? { retryAfterMs: facts.retryAfterMs } : {};
594
+ const quotaReason = /quota|credits|run out|usage limit/i.test(text) ? 'quota' : 'rate limit';
595
+ switch (status) {
596
+ case 401:
597
+ return EXPIRED.test(text)
598
+ ? { outcome: 'expired', reason: 'expired' }
599
+ : { outcome: 'invalid', reason: 'auth rejected' };
600
+ case 402:
601
+ return { outcome: 'degraded', reason: 'quota', ...retry };
602
+ case 429:
603
+ return { outcome: 'degraded', reason: /quota/i.test(text) ? 'quota' : 'rate limit', ...retry };
604
+ case 403:
605
+ if (EXPIRED.test(text))
606
+ return { outcome: 'expired', reason: 'expired' };
607
+ if (QUOTA.test(text))
608
+ return { outcome: 'degraded', reason: quotaReason, ...retry };
609
+ if (CREDENTIAL_REJECTED.test(text) || /invalid_token/i.test(challenge.error ?? '')) {
610
+ return { outcome: 'invalid', reason: 'credential rejected' };
611
+ }
612
+ return { outcome: 'used' };
613
+ default:
614
+ return { outcome: 'used' };
615
+ }
616
+ }
617
+ /**
618
+ * Classify a thrown error as a credential problem, or undefined when it is not one. Reads an
619
+ * `HttpError`-shaped object (`status`, `code`, `message`, `description`, `challenge`, `retryAfterMs`), so
620
+ * it reaches the same verdict the http-client recorded (an expired GitLab token stays expired inside
621
+ * `withFallback` and on a throwing startup check); only invalid, expired and degraded count.
622
+ */
623
+ export function credentialOutcomeFromError(err) {
624
+ if (typeof err !== 'object' || err === null)
625
+ return undefined;
626
+ const e = err;
627
+ if (typeof e.status !== 'number')
628
+ return undefined;
629
+ const facts = { status: e.status };
630
+ if (typeof e.code === 'string')
631
+ facts.code = e.code;
632
+ if (typeof e.message === 'string')
633
+ facts.message = e.message;
634
+ if (typeof e.description === 'string')
635
+ facts.description = e.description;
636
+ if (typeof e.challenge === 'object' && e.challenge !== null) {
637
+ const c = e.challenge;
638
+ const challenge = {};
639
+ if (typeof c.error === 'string')
640
+ challenge.error = c.error;
641
+ if (typeof c.description === 'string')
642
+ challenge.description = c.description;
643
+ facts.challenge = challenge;
644
+ }
645
+ if (typeof e.retryAfterMs === 'number')
646
+ facts.retryAfterMs = e.retryAfterMs;
647
+ const classified = credentialOutcomeFromHttp(facts);
648
+ return classified.outcome === 'invalid' || classified.outcome === 'expired' || classified.outcome === 'degraded'
649
+ ? classified
650
+ : undefined;
651
+ }
652
+ //# sourceMappingURL=credentials.js.map