@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.
- package/README.md +70 -3
- package/dist/auth/es256.js.map +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/dates/index.js.map +1 -1
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js.map +1 -1
- package/dist/fetchproxy/index.d.ts +96 -18
- package/dist/fetchproxy/index.d.ts.map +1 -1
- package/dist/fetchproxy/index.js +235 -155
- package/dist/fetchproxy/index.js.map +1 -1
- package/dist/fs/index.js.map +1 -1
- package/dist/fs/output.js.map +1 -1
- package/dist/healthcheck/index.d.ts +138 -9
- package/dist/healthcheck/index.d.ts.map +1 -1
- package/dist/healthcheck/index.js +262 -122
- package/dist/healthcheck/index.js.map +1 -1
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js.map +1 -1
- package/dist/http/net-atoms.js.map +1 -1
- package/dist/response/media.js.map +1 -1
- package/dist/response/view.d.ts +1 -1
- package/dist/response/view.d.ts.map +1 -1
- package/dist/response/view.js.map +1 -1
- package/dist/scrape/index.js.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js.map +1 -1
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +7 -1
- package/dist/test/index.js.map +1 -1
- package/dist/zod/index.d.ts.map +1 -1
- package/dist/zod/index.js.map +1 -1
- package/package.json +11 -11
|
@@ -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'
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
126
|
-
* CREDENTIAL rather than a browser
|
|
127
|
-
* connectors, and the fetchproxy MCPs that
|
|
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
|
-
* (
|
|
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,
|
|
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;AACvB,0EAA0E;GACxE,iBAAiB;AACnB,8EAA8E;GAC5E,sBAAsB,GACtB,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;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;OAWG;IACH,iBAAiB,EAAE,MAAM,OAAO,CAAC,eAAe,CAAC,CAAC;IAClD;;;;;;;;;OASG;IACH,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;AAuDD,mDAAmD;AACnD,MAAM,MAAM,qBAAqB,GAAG;IAAE,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,CAAC;AAElF,gFAAgF;AAChF,wBAAgB,gCAAgC,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE1E;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,qCAAqC,GAC1C,OAAO,CAAC,qBAAqB,CAAC,CAiIhC;AAED;;;;GAIG;AACH,wBAAgB,iCAAiC,CAC/C,IAAI,EAAE,qCAAqC,GAC1C,IAAI,CAgBN;AAGD;;;;GAIG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM;IAFzB,YACW,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EAIxB;CACF;AAED,gFAAgF;AAChF,qBAAa,cAAe,SAAQ,KAAK;IAErC,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM;IAFzB,YACW,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EAIxB;CACF;AAED,gEAAgE;AAChE,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,OAAO,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,CAAC;IACtC;;;;;;;;;OASG;IACH,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACrC,iEAAiE;IACjE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,mBAAmB,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAW7E;AAED,MAAM,WAAW,wBAAwB;IACvC,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC;IACpC,uDAAuD;IACvD,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC;IACpC,gFAAgF;IAChF,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,iBAAiB,GAAG,sBAAsB,GAAG,qBAAqB,GAAG,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC9G;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,wBAAwB,GAC7B,CAAC,GAAG,EAAE,OAAO,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CA0C/D"}
|
|
@@ -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
|
-
*
|
|
43
|
-
* CREDENTIAL rather than a browser
|
|
44
|
-
* connectors, and the fetchproxy MCPs that
|
|
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
|
-
* (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
189
|
-
});
|
|
329
|
+
};
|
|
190
330
|
}
|
|
191
331
|
//# sourceMappingURL=index.js.map
|