@chrischall/mcp-utils 0.23.3 → 0.25.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.
@@ -15,7 +15,11 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
15
  * question each answers: is there a credential at all, did the far side accept
16
16
  * it, and did the round-trip work.
17
17
  */
18
- export type CredentialHealthcheckArm = 'ok' | 'no_credential' | 'credential_rejected' | 'timeout' | 'http' | 'transport' | 'unknown';
18
+ export type CredentialHealthcheckArm = 'ok' | 'no_credential' | 'credential_rejected'
19
+ /** Credentials are fine; no session is live. See {@link sessionProbe}. */
20
+ | 'session_expired'
21
+ /** A second factor is outstanding, so the far side is holding the sign-in. */
22
+ | 'verification_pending' | 'timeout' | 'http' | 'transport' | 'unknown';
19
23
  /** What a consumer's resolver reports. NEVER the credential value itself. */
20
24
  export interface CredentialState {
21
25
  /**
@@ -53,7 +57,11 @@ export interface RegisterCredentialHealthcheckToolArgs {
53
57
  prefix: string;
54
58
  /** Display host for the probe URL and hint copy, e.g. `'api.freshbooks.com'`. */
55
59
  hostLabel: string;
56
- /** Optional path, for display only: the probe URL is `https://<hostLabel><probePath>`. */
60
+ /**
61
+ * Optional path, for display only: the probe URL is
62
+ * `https://<hostLabel>/<probePath>`, with the separator inserted only when
63
+ * `probePath` does not already begin with one.
64
+ */
57
65
  probePath?: string;
58
66
  /**
59
67
  * Resolve the credential the way the real tools do — same cache, same
@@ -68,7 +76,16 @@ export interface RegisterCredentialHealthcheckToolArgs {
68
76
  * are already set.
69
77
  */
70
78
  resolveCredential: () => Promise<CredentialState>;
71
- /** One authenticated round-trip. Only called when a credential resolved. */
79
+ /**
80
+ * One authenticated round-trip. Only called when a credential resolved.
81
+ *
82
+ * **It reports failure only by THROWING.** A probe that resolves is reported
83
+ * healthy, whatever it resolved to — so a 2xx is not, on its own, proof of a
84
+ * session. A cookie-session portal answers a dead session with a login page
85
+ * served 200, or a redirect to one; a probe that hands either back reports
86
+ * `ok: true` and tells the caller to go debug a tool. {@link sessionProbe}
87
+ * builds a compliant probe from the one closure only the consumer can write.
88
+ */
72
89
  probeFn: () => Promise<unknown>;
73
90
  /**
74
91
  * Classify a thrown error into an arm, and optionally override the hint and
@@ -121,21 +138,133 @@ export interface CredentialHealthcheckResult {
121
138
  };
122
139
  hint: string;
123
140
  }
141
+ /** The text result an MCP tool handler returns. */
142
+ export type HealthcheckToolResult = {
143
+ content: {
144
+ type: 'text';
145
+ text: string;
146
+ }[];
147
+ };
148
+ /** The credential arm's one-line description, shared with the adaptive tool. */
149
+ export declare function credentialHealthcheckDescription(hostLabel: string): string;
124
150
  /**
125
- * Register `${prefix}_healthcheck` for a connector whose health is about a
126
- * CREDENTIAL rather than a browser bridge — OAuth connectors, API-key
127
- * connectors, and the fetchproxy MCPs that only BOOTSTRAP a token and then
128
- * talk to an API directly.
151
+ * The credential healthcheck's whole body, without the registration: what a
152
+ * connector reports when its health is about a CREDENTIAL rather than a browser
153
+ * bridge — OAuth connectors, API-key connectors, and the fetchproxy MCPs that
154
+ * only BOOTSTRAP a token and then talk to an API directly.
129
155
  *
130
156
  * It exists because those three failures are indistinguishable today and have
131
157
  * different fixes: nothing minted a credential, something minted one the far
132
158
  * side rejects, and the far side is simply down. The bridge helper
133
- * (`registerBridgeHealthcheckTool`, in `../fetchproxy/`) answers the equivalent question for
134
- * MCPs where every request rides the bridge.
159
+ * ({@link runBridgeHealthcheck}, in `../fetchproxy/`) answers the equivalent
160
+ * question for MCPs where every request rides the bridge.
135
161
  *
136
162
  * The probe is SKIPPED when no credential resolved — probing without one
137
163
  * produces a 401 that reads like a rejected credential and points at the wrong
138
164
  * fix.
165
+ *
166
+ * Split out from the registration so ONE tool can serve a server with two
167
+ * transports and choose between the arms per call
168
+ * (`registerAdaptiveHealthcheckTool`, in `../fetchproxy/`, which is where the
169
+ * bridge arm's optional peer dependency already lives).
170
+ * `registerCredentialHealthcheckTool` is unchanged and still the right choice
171
+ * for a server with only this arm.
172
+ */
173
+ export declare function runCredentialHealthcheck(args: RegisterCredentialHealthcheckToolArgs): Promise<HealthcheckToolResult>;
174
+ /**
175
+ * Register `${prefix}_healthcheck` for a connector whose health is about a
176
+ * CREDENTIAL rather than a browser bridge. The body is
177
+ * {@link runCredentialHealthcheck}.
139
178
  */
140
179
  export declare function registerCredentialHealthcheckTool(args: RegisterCredentialHealthcheckToolArgs): void;
180
+ /**
181
+ * The probe reached the far side and it declined to serve the data — a login
182
+ * page, or a redirect to one. Its own class so a classifier can tell it apart
183
+ * from a network failure.
184
+ */
185
+ export declare class SessionNotLiveError extends Error {
186
+ readonly hostLabel: string;
187
+ readonly detail: string;
188
+ constructor(hostLabel: string, detail: string);
189
+ }
190
+ /** A non-2xx from the far side, carrying the status the healthcheck reports. */
191
+ export declare class ProbeHttpError extends Error {
192
+ readonly hostLabel: string;
193
+ readonly status: number;
194
+ constructor(hostLabel: string, status: number);
195
+ }
196
+ /** The minimum a {@link sessionProbe} request has to report. */
197
+ export interface ProbeResponse {
198
+ status: number;
199
+ body: string;
200
+ }
201
+ export interface SessionProbeOptions {
202
+ /** Make the authenticated request. Must NOT sign in — a diagnostic observes. */
203
+ request: () => Promise<ProbeResponse>;
204
+ /**
205
+ * **The site-specific closure**, and the only part of this that cannot be
206
+ * generalised: does this body mean "not signed in"?
207
+ *
208
+ * It stays with the consumer because getting it wrong is silent and specific.
209
+ * One real portal links to two-factor setup from every signed-in page, so a
210
+ * body-wide match on `twoFactor` reports "signed out" for every request; the
211
+ * fix was to scope it to the `<title>`, which is knowable only next to the
212
+ * markup. A library that guessed this would be wrong in both directions.
213
+ */
214
+ signedOut: (body: string) => boolean;
215
+ /** Used in the thrown messages. Defaults to a neutral phrase. */
216
+ hostLabel?: string;
217
+ }
218
+ /**
219
+ * Build a `probeFn` that JUDGES its response instead of merely completing.
220
+ *
221
+ * `probeFn`'s contract is that it reports failure by throwing, so any probe
222
+ * that can resolve on a signed-out response silently reports healthy. Every
223
+ * connector whose probe rides a client that throws on non-2xx complies by
224
+ * accident — and that accident does not hold against a SOFT wall, where a dead
225
+ * session comes back 200 with a login page.
226
+ *
227
+ * The rules here are the generic ones:
228
+ *
229
+ * - a 3xx is signed out, because under a manual-redirect fetch the bounce to
230
+ * the login page arrives with no body to judge;
231
+ * - any other non-2xx is an upstream error carrying its status;
232
+ * - a 2xx is signed out if the consumer's closure says so.
233
+ *
234
+ * Pair with {@link sessionClassifier} to turn those into arms and remedies.
235
+ */
236
+ export declare function sessionProbe(opts: SessionProbeOptions): () => Promise<string>;
237
+ export interface SessionClassifierOptions {
238
+ /** Tool-name prefix, used to name the remedy tools in the default copy. */
239
+ prefix: string;
240
+ /** Display host named in the default copy, e.g. `'my.atriumhealth.org'`. */
241
+ hostLabel: string;
242
+ /** A second factor is outstanding. Read at classification time, not captured. */
243
+ verificationPending?: () => boolean;
244
+ /** The far side refused this username and password. */
245
+ credentialsRejected?: () => boolean;
246
+ /** Per-kind copy overrides, for a connector whose remedy is not a tool call. */
247
+ hints?: Partial<Record<'session_expired' | 'verification_pending' | 'credential_rejected' | 'http', string>>;
248
+ }
249
+ /**
250
+ * Build a `classifyThrown` that names WHICH signed-out state a
251
+ * {@link sessionProbe} failure is.
252
+ *
253
+ * All three arrive as the same login page and have three different remedies,
254
+ * so reporting them as one sends people to the wrong fix — telling somebody
255
+ * with an outstanding code to check their password sends them to change a
256
+ * credential that is already correct.
257
+ *
258
+ * **A refused credential outranks a pending verification.** Both flags can be
259
+ * set at once, and retrying a code against a password the far side refuses is
260
+ * futile. Two call sites in one repo disagreed about this order, which is the
261
+ * argument for the order living in exactly one place.
262
+ *
263
+ * Returns `undefined` for anything it does not recognise, so the built-in
264
+ * ladder still classifies a network failure rather than being shadowed.
265
+ */
266
+ export declare function sessionClassifier(opts: SessionClassifierOptions): (err: unknown) => {
267
+ kind: string;
268
+ hint?: string;
269
+ } | undefined;
141
270
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/healthcheck/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGzE;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAChC,IAAI,GACJ,eAAe,GACf,qBAAqB,GACrB,SAAS,GACT,MAAM,GACN,WAAW,GACX,SAAS,CAAC;AAEd,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,qCAAqC;IACpD;;;;;;OAMG;IACH,MAAM,EAAE,SAAS,CAAC;IAClB,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,EAAE,MAAM,CAAC;IAClB,0FAA0F;IAC1F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;OAWG;IACH,iBAAiB,EAAE,MAAM,OAAO,CAAC,eAAe,CAAC,CAAC;IAClD,4EAA4E;IAC5E,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAChC;;;;;;;;;;;;;;OAcG;IACH,cAAc,CAAC,EAAE,CACf,GAAG,EAAE,OAAO,KACT;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,SAAS,CAAC;IACnF,8BAA8B;IAC9B,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,wBAAwB,EAAE,MAAM,CAAC,CAAC,CAAC;CAC3D;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,OAAO,CAAC;IACZ,UAAU,EAAE;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC;IAC3F,KAAK,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC;IAC5E,IAAI,EAAE,MAAM,CAAC;CACd;AAiDD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iCAAiC,CAC/C,IAAI,EAAE,qCAAqC,GAC1C,IAAI,CAkJN"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/healthcheck/index.ts"],"names":[],"mappings":"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"}
@@ -11,6 +11,8 @@ const CREDENTIAL_ARMS = new Set([
11
11
  'ok',
12
12
  'no_credential',
13
13
  'credential_rejected',
14
+ 'session_expired',
15
+ 'verification_pending',
14
16
  'timeout',
15
17
  'http',
16
18
  'transport',
@@ -28,6 +30,10 @@ function credentialHint(arm, prefix, hostLabel, source) {
28
30
  return `No credential resolved. Nothing was available to authenticate with — sign in and reconnect the connector so ${prefix} receives a token, or set the documented environment variable.`;
29
31
  case 'credential_rejected':
30
32
  return `${hostLabel} rejected the credential from '${source}'. It is present but no longer valid — most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
33
+ case 'session_expired':
34
+ return `The credential from '${source}' is configured, but no session is live — ${hostLabel} served a sign-in page rather than the data. Sign in again; a cookie-session portal expires these on its own, so this recurs between uses.`;
35
+ case 'verification_pending':
36
+ return `${hostLabel} is holding the sign-in on a second factor rather than refusing it. Supply the verification code the ACCOUNT HOLDER received — the credential itself is not the problem, so changing it will not help.`;
31
37
  case 'timeout':
32
38
  return `The credential from '${source}' resolved, but ${hostLabel} did not answer in time. Usually transient — retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
33
39
  case 'http':
@@ -38,28 +44,166 @@ function credentialHint(arm, prefix, hostLabel, source) {
38
44
  return `Unexpected failure — see error.message.`;
39
45
  }
40
46
  }
47
+ /** The credential arm's one-line description, shared with the adaptive tool. */
48
+ export function credentialHealthcheckDescription(hostLabel) {
49
+ return `Resolves the credential the way real tools do, then makes one authenticated request to ${hostLabel}. Reports which source supplied the credential, whether ${hostLabel} accepted it, the round-trip time, and a plain-English hint distinguishing 'no credential' from 'credential rejected' from 'a ${hostLabel}-side problem'. Read-only; never returns the credential itself.`;
50
+ }
41
51
  /**
42
- * Register `${prefix}_healthcheck` for a connector whose health is about a
43
- * CREDENTIAL rather than a browser bridge — OAuth connectors, API-key
44
- * connectors, and the fetchproxy MCPs that only BOOTSTRAP a token and then
45
- * talk to an API directly.
52
+ * The credential healthcheck's whole body, without the registration: what a
53
+ * connector reports when its health is about a CREDENTIAL rather than a browser
54
+ * bridge — OAuth connectors, API-key connectors, and the fetchproxy MCPs that
55
+ * only BOOTSTRAP a token and then talk to an API directly.
46
56
  *
47
57
  * It exists because those three failures are indistinguishable today and have
48
58
  * different fixes: nothing minted a credential, something minted one the far
49
59
  * side rejects, and the far side is simply down. The bridge helper
50
- * (`registerBridgeHealthcheckTool`, in `../fetchproxy/`) answers the equivalent question for
51
- * MCPs where every request rides the bridge.
60
+ * ({@link runBridgeHealthcheck}, in `../fetchproxy/`) answers the equivalent
61
+ * question for MCPs where every request rides the bridge.
52
62
  *
53
63
  * The probe is SKIPPED when no credential resolved — probing without one
54
64
  * produces a 401 that reads like a rejected credential and points at the wrong
55
65
  * fix.
66
+ *
67
+ * Split out from the registration so ONE tool can serve a server with two
68
+ * transports and choose between the arms per call
69
+ * (`registerAdaptiveHealthcheckTool`, in `../fetchproxy/`, which is where the
70
+ * bridge arm's optional peer dependency already lives).
71
+ * `registerCredentialHealthcheckTool` is unchanged and still the right choice
72
+ * for a server with only this arm.
73
+ */
74
+ export async function runCredentialHealthcheck(args) {
75
+ const { prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
76
+ const probeUrl = probePath
77
+ ? `https://${hostLabel}${probePath.startsWith('/') ? '' : '/'}${probePath}`
78
+ : undefined;
79
+ // Timed from just BEFORE the probe, never from the top: resolving a
80
+ // credential can mint a token or drive the browser bridge, and folding
81
+ // that into `probe.elapsed_ms` reports it as far-side latency.
82
+ let probeStarted = 0;
83
+ let state;
84
+ try {
85
+ state = await resolveCredential();
86
+ }
87
+ catch (e) {
88
+ // A resolver can fail for reasons that are NOT "no credential": a
89
+ // browser bridge that is down, an upstream that rejected a password,
90
+ // a store that will not decrypt. Flattening those into
91
+ // `no_credential` hands out that arm's advice — set the variables —
92
+ // to someone whose variables are already set. So the consumer's
93
+ // classifier is consulted here as it already is for a probe failure;
94
+ // declining it (or not supplying one) keeps the old behaviour exactly.
95
+ const classified = classifyThrown?.(e);
96
+ const result = {
97
+ ok: false,
98
+ // Still false, and still no source: a classification explains WHY
99
+ // nothing resolved, it does not invent a credential that did.
100
+ credential: { source: null, resolved: false },
101
+ // No `url`: nothing was probed, and naming one implies it was tried.
102
+ probe: { elapsed_ms: 0 },
103
+ error: {
104
+ kind: classified?.kind ?? 'no_credential',
105
+ message: truncateErrorMessage(messageOf(e)),
106
+ ...(classified?.detail !== undefined ? { detail: classified.detail } : {}),
107
+ },
108
+ // The hint must follow the KIND beside it. Falling back to
109
+ // `no_credential`'s copy under a classified kind would state a cause
110
+ // the kind contradicts — the same disagreement this path exists to
111
+ // remove. So: an inline hint wins; else the classified arm's own
112
+ // copy (consumer override first); else, for a kind this module has
113
+ // no copy for, the neutral `unknown` text rather than one that
114
+ // asserts a cause; else the unclassified `no_credential` default.
115
+ hint: classified?.hint ??
116
+ (isArm(classified?.kind)
117
+ ? (hints?.[classified.kind] ??
118
+ credentialHint(classified.kind, prefix, hostLabel, null))
119
+ : classified !== undefined
120
+ ? credentialHint('unknown', prefix, hostLabel, null)
121
+ : (hints?.no_credential ??
122
+ credentialHint('no_credential', prefix, hostLabel, null))),
123
+ };
124
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
125
+ }
126
+ const credential = {
127
+ source: state.source,
128
+ resolved: state.source !== null,
129
+ ...(state.detail !== undefined ? { detail: state.detail } : {}),
130
+ };
131
+ // No credential: answer without probing. A probe here 401s and reads as
132
+ // "rejected", which points at re-authenticating a credential that does
133
+ // not exist.
134
+ if (!credential.resolved) {
135
+ const result = {
136
+ ok: false,
137
+ credential,
138
+ probe: { elapsed_ms: 0 },
139
+ error: { kind: 'no_credential', message: 'no credential source resolved' },
140
+ hint: hints?.no_credential ?? credentialHint('no_credential', prefix, hostLabel, null),
141
+ };
142
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
143
+ }
144
+ let arm = 'ok';
145
+ let error;
146
+ let status;
147
+ let customHint;
148
+ probeStarted = Date.now();
149
+ try {
150
+ await probeFn();
151
+ }
152
+ catch (e) {
153
+ status = statusOf(e);
154
+ // `AbortError` is matched on `err.name`, as src/http/index.ts does — a
155
+ // bare AbortController abort carries it there and NOT in the message,
156
+ // so matching the text alone classified those as 'unknown'.
157
+ const aborted = e instanceof Error && e.name === 'AbortError';
158
+ arm =
159
+ status === 401 || status === 403
160
+ ? 'credential_rejected'
161
+ : status !== undefined
162
+ ? 'http'
163
+ : aborted || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e))
164
+ ? 'timeout'
165
+ : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e))
166
+ ? 'transport'
167
+ : 'unknown';
168
+ let kind = arm;
169
+ let detail;
170
+ const custom = classifyThrown?.(e);
171
+ if (custom) {
172
+ kind = custom.kind;
173
+ customHint = custom.hint;
174
+ detail = custom.detail;
175
+ }
176
+ error = {
177
+ kind,
178
+ // Redacted AND bounded before it reaches the result: an upstream
179
+ // failure routinely quotes what it was sent, and a healthcheck is
180
+ // the tool people paste into a chat when something is broken.
181
+ message: truncateErrorMessage(messageOf(e)),
182
+ ...(detail !== undefined ? { detail } : {}),
183
+ };
184
+ }
185
+ const result = {
186
+ ok: error === undefined,
187
+ credential,
188
+ probe: {
189
+ ...(probeUrl ? { url: probeUrl } : {}),
190
+ elapsed_ms: Date.now() - probeStarted,
191
+ ...(status !== undefined ? { status } : {}),
192
+ },
193
+ ...(error ? { error } : {}),
194
+ hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source),
195
+ };
196
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
197
+ }
198
+ /**
199
+ * Register `${prefix}_healthcheck` for a connector whose health is about a
200
+ * CREDENTIAL rather than a browser bridge. The body is
201
+ * {@link runCredentialHealthcheck}.
56
202
  */
57
203
  export function registerCredentialHealthcheckTool(args) {
58
- const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
59
- const probeUrl = probePath ? `https://${hostLabel}${probePath}` : undefined;
60
- server.registerTool(`${prefix}_healthcheck`, {
204
+ args.server.registerTool(`${args.prefix}_healthcheck`, {
61
205
  title: 'Verify credentials and upstream reachability',
62
- description: `Resolves the credential the way real tools do, then makes one authenticated request to ${hostLabel}. Reports which source supplied the credential, whether ${hostLabel} accepted it, the round-trip time, and a plain-English hint distinguishing 'no credential' from 'credential rejected' from 'a ${hostLabel}-side problem'. Call this when a real tool fails and you want to know which hop broke. Read-only; never returns the credential itself.`,
206
+ description: `${credentialHealthcheckDescription(args.hostLabel)} Call this when a real tool fails and you want to know which hop broke.`,
63
207
  annotations: {
64
208
  title: 'Verify credentials and upstream reachability',
65
209
  readOnlyHint: true,
@@ -67,125 +211,121 @@ export function registerCredentialHealthcheckTool(args) {
67
211
  openWorldHint: true,
68
212
  },
69
213
  inputSchema: {},
70
- }, async () => {
71
- // Timed from just BEFORE the probe, never from the top: resolving a
72
- // credential can mint a token or drive the browser bridge, and folding
73
- // that into `probe.elapsed_ms` reports it as far-side latency.
74
- let probeStarted = 0;
75
- let state;
76
- try {
77
- state = await resolveCredential();
214
+ }, async () => runCredentialHealthcheck(args));
215
+ }
216
+ /**
217
+ * The probe reached the far side and it declined to serve the data — a login
218
+ * page, or a redirect to one. Its own class so a classifier can tell it apart
219
+ * from a network failure.
220
+ */
221
+ export class SessionNotLiveError extends Error {
222
+ hostLabel;
223
+ detail;
224
+ constructor(hostLabel, detail) {
225
+ super(`${hostLabel} served a sign-in page rather than the data (${detail}).`);
226
+ this.hostLabel = hostLabel;
227
+ this.detail = detail;
228
+ this.name = 'SessionNotLiveError';
229
+ }
230
+ }
231
+ /** A non-2xx from the far side, carrying the status the healthcheck reports. */
232
+ export class ProbeHttpError extends Error {
233
+ hostLabel;
234
+ status;
235
+ constructor(hostLabel, status) {
236
+ super(`${hostLabel} answered ${status}.`);
237
+ this.hostLabel = hostLabel;
238
+ this.status = status;
239
+ this.name = 'ProbeHttpError';
240
+ }
241
+ }
242
+ /**
243
+ * Build a `probeFn` that JUDGES its response instead of merely completing.
244
+ *
245
+ * `probeFn`'s contract is that it reports failure by throwing, so any probe
246
+ * that can resolve on a signed-out response silently reports healthy. Every
247
+ * connector whose probe rides a client that throws on non-2xx complies by
248
+ * accident — and that accident does not hold against a SOFT wall, where a dead
249
+ * session comes back 200 with a login page.
250
+ *
251
+ * The rules here are the generic ones:
252
+ *
253
+ * - a 3xx is signed out, because under a manual-redirect fetch the bounce to
254
+ * the login page arrives with no body to judge;
255
+ * - any other non-2xx is an upstream error carrying its status;
256
+ * - a 2xx is signed out if the consumer's closure says so.
257
+ *
258
+ * Pair with {@link sessionClassifier} to turn those into arms and remedies.
259
+ */
260
+ export function sessionProbe(opts) {
261
+ const host = opts.hostLabel ?? 'the upstream';
262
+ return async () => {
263
+ const { status, body } = await opts.request();
264
+ if (status >= 300 && status < 400) {
265
+ throw new SessionNotLiveError(host, `redirected with ${status}`);
78
266
  }
79
- catch (e) {
80
- // A resolver can fail for reasons that are NOT "no credential": a
81
- // browser bridge that is down, an upstream that rejected a password,
82
- // a store that will not decrypt. Flattening those into
83
- // `no_credential` hands out that arm's advice — set the variables —
84
- // to someone whose variables are already set. So the consumer's
85
- // classifier is consulted here as it already is for a probe failure;
86
- // declining it (or not supplying one) keeps the old behaviour exactly.
87
- const classified = classifyThrown?.(e);
88
- const result = {
89
- ok: false,
90
- // Still false, and still no source: a classification explains WHY
91
- // nothing resolved, it does not invent a credential that did.
92
- credential: { source: null, resolved: false },
93
- // No `url`: nothing was probed, and naming one implies it was tried.
94
- probe: { elapsed_ms: 0 },
95
- error: {
96
- kind: classified?.kind ?? 'no_credential',
97
- message: truncateErrorMessage(messageOf(e)),
98
- ...(classified?.detail !== undefined ? { detail: classified.detail } : {}),
99
- },
100
- // The hint must follow the KIND beside it. Falling back to
101
- // `no_credential`'s copy under a classified kind would state a cause
102
- // the kind contradicts — the same disagreement this path exists to
103
- // remove. So: an inline hint wins; else the classified arm's own
104
- // copy (consumer override first); else, for a kind this module has
105
- // no copy for, the neutral `unknown` text rather than one that
106
- // asserts a cause; else the unclassified `no_credential` default.
107
- hint: classified?.hint ??
108
- (isArm(classified?.kind)
109
- ? (hints?.[classified.kind] ??
110
- credentialHint(classified.kind, prefix, hostLabel, null))
111
- : classified !== undefined
112
- ? credentialHint('unknown', prefix, hostLabel, null)
113
- : (hints?.no_credential ??
114
- credentialHint('no_credential', prefix, hostLabel, null))),
267
+ if (status < 200 || status >= 300)
268
+ throw new ProbeHttpError(host, status);
269
+ if (opts.signedOut(body))
270
+ throw new SessionNotLiveError(host, 'sign-in or verification page');
271
+ return body;
272
+ };
273
+ }
274
+ /**
275
+ * Build a `classifyThrown` that names WHICH signed-out state a
276
+ * {@link sessionProbe} failure is.
277
+ *
278
+ * All three arrive as the same login page and have three different remedies,
279
+ * so reporting them as one sends people to the wrong fix — telling somebody
280
+ * with an outstanding code to check their password sends them to change a
281
+ * credential that is already correct.
282
+ *
283
+ * **A refused credential outranks a pending verification.** Both flags can be
284
+ * set at once, and retrying a code against a password the far side refuses is
285
+ * futile. Two call sites in one repo disagreed about this order, which is the
286
+ * argument for the order living in exactly one place.
287
+ *
288
+ * Returns `undefined` for anything it does not recognise, so the built-in
289
+ * ladder still classifies a network failure rather than being shadowed.
290
+ */
291
+ export function sessionClassifier(opts) {
292
+ const { prefix, hostLabel, hints } = opts;
293
+ return (err) => {
294
+ if (err instanceof ProbeHttpError) {
295
+ return {
296
+ kind: 'http',
297
+ hint: hints?.http ??
298
+ `${hostLabel} answered ${err.status}. The credential was never judged — that is an ` +
299
+ 'upstream problem, so retry, and if it persists the far side is down.',
115
300
  };
116
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
117
301
  }
118
- const credential = {
119
- source: state.source,
120
- resolved: state.source !== null,
121
- ...(state.detail !== undefined ? { detail: state.detail } : {}),
122
- };
123
- // No credential: answer without probing. A probe here 401s and reads as
124
- // "rejected", which points at re-authenticating a credential that does
125
- // not exist.
126
- if (!credential.resolved) {
127
- const result = {
128
- ok: false,
129
- credential,
130
- probe: { elapsed_ms: 0 },
131
- error: { kind: 'no_credential', message: 'no credential source resolved' },
132
- hint: hints?.no_credential ?? credentialHint('no_credential', prefix, hostLabel, null),
302
+ if (!(err instanceof SessionNotLiveError))
303
+ return undefined;
304
+ if (opts.credentialsRejected?.() === true) {
305
+ return {
306
+ kind: 'credential_rejected',
307
+ hint: hints?.credential_rejected ??
308
+ `${hostLabel} refused this username and password. Correct them, then call ` +
309
+ `${prefix}_sign_in. Nothing retries for you: repeated failures escalate to a ` +
310
+ 'captcha or a lockout.',
133
311
  };
134
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
135
312
  }
136
- let arm = 'ok';
137
- let error;
138
- let status;
139
- let customHint;
140
- probeStarted = Date.now();
141
- try {
142
- await probeFn();
143
- }
144
- catch (e) {
145
- status = statusOf(e);
146
- // `AbortError` is matched on `err.name`, as src/http/index.ts does — a
147
- // bare AbortController abort carries it there and NOT in the message,
148
- // so matching the text alone classified those as 'unknown'.
149
- const aborted = e instanceof Error && e.name === 'AbortError';
150
- arm =
151
- status === 401 || status === 403
152
- ? 'credential_rejected'
153
- : status !== undefined
154
- ? 'http'
155
- : aborted || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e))
156
- ? 'timeout'
157
- : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e))
158
- ? 'transport'
159
- : 'unknown';
160
- let kind = arm;
161
- let detail;
162
- const custom = classifyThrown?.(e);
163
- if (custom) {
164
- kind = custom.kind;
165
- customHint = custom.hint;
166
- detail = custom.detail;
167
- }
168
- error = {
169
- kind,
170
- // Redacted AND bounded before it reaches the result: an upstream
171
- // failure routinely quotes what it was sent, and a healthcheck is
172
- // the tool people paste into a chat when something is broken.
173
- message: truncateErrorMessage(messageOf(e)),
174
- ...(detail !== undefined ? { detail } : {}),
313
+ if (opts.verificationPending?.() === true) {
314
+ return {
315
+ kind: 'verification_pending',
316
+ hint: hints?.verification_pending ??
317
+ `A verification code is outstanding, so ${hostLabel} is holding the sign-in rather ` +
318
+ `than refusing it. Call ${prefix}_send_verification_code, then pass the code the ` +
319
+ `ACCOUNT HOLDER receives to ${prefix}_verify_code.`,
175
320
  };
176
321
  }
177
- const result = {
178
- ok: error === undefined,
179
- credential,
180
- probe: {
181
- ...(probeUrl ? { url: probeUrl } : {}),
182
- elapsed_ms: Date.now() - probeStarted,
183
- ...(status !== undefined ? { status } : {}),
184
- },
185
- ...(error ? { error } : {}),
186
- hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source),
322
+ return {
323
+ kind: 'session_expired',
324
+ hint: hints?.session_expired ??
325
+ `The credentials are configured but no session is live — ${hostLabel} sessions are ` +
326
+ `short-lived, so this recurs between uses. Call ${prefix}_sign_in; expect a ` +
327
+ 'verification code, which goes to the account holder.',
187
328
  };
188
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
189
- });
329
+ };
190
330
  }
191
331
  //# sourceMappingURL=index.js.map