@chrischall/mcp-utils 0.7.0 → 0.9.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 +165 -7
- package/dist/config/index.d.ts +66 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +97 -0
- package/dist/config/index.js.map +1 -1
- package/dist/fetchproxy/index.d.ts +149 -2
- package/dist/fetchproxy/index.d.ts.map +1 -1
- package/dist/fetchproxy/index.js +179 -2
- package/dist/fetchproxy/index.js.map +1 -1
- package/dist/http/index.d.ts +105 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +158 -0
- package/dist/http/index.js.map +1 -1
- package/dist/session/index.d.ts +157 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +186 -3
- package/dist/session/index.js.map +1 -1
- package/dist/zod/index.d.ts +19 -0
- package/dist/zod/index.d.ts.map +1 -1
- package/dist/zod/index.js +27 -0
- package/dist/zod/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ import light:
|
|
|
28
28
|
| Import | Contents |
|
|
29
29
|
| --- | --- |
|
|
30
30
|
| `@chrischall/mcp-utils` | core barrel: `server` + `response` + `errors` + `config` + `fs` + `http` + `zod` + `auth` |
|
|
31
|
-
| `@chrischall/mcp-utils/session` | session registry, session store, token manager |
|
|
31
|
+
| `@chrischall/mcp-utils/session` | session registry, session store, token manager, cookie-session manager |
|
|
32
32
|
| `@chrischall/mcp-utils/fetchproxy` | fetchproxy transport adapter, bot-wall / retry / concurrency helpers |
|
|
33
33
|
| `@chrischall/mcp-utils/html` | opt-in HTML scraping helpers (needs `node-html-parser`) |
|
|
34
34
|
| `@chrischall/mcp-utils/test` | in-memory test harness for tool registration |
|
|
@@ -109,19 +109,46 @@ tool surface can show the user.
|
|
|
109
109
|
|
|
110
110
|
### `config` — hardened env/config
|
|
111
111
|
|
|
112
|
-
`readEnvVar`, `requireEnvVar`, `parseBoolEnv`, `
|
|
112
|
+
`readEnvVar`, `requireEnvVar`, `parseBoolEnv`, `readPortEnv`, `expandPath`,
|
|
113
|
+
`loadDotenvSafely`, `createCachedJsonArrayLoader`.
|
|
113
114
|
|
|
114
115
|
```ts
|
|
115
|
-
import { requireEnvVar, parseBoolEnv, expandPath } from '@chrischall/mcp-utils';
|
|
116
|
+
import { requireEnvVar, parseBoolEnv, readPortEnv, expandPath } from '@chrischall/mcp-utils';
|
|
116
117
|
|
|
117
118
|
const apiKey = requireEnvVar('MY_API_KEY');
|
|
118
119
|
const debug = parseBoolEnv('MY_DEBUG', { default: false });
|
|
120
|
+
const port = readPortEnv('MY_WS_PORT', 37149); // placeholder/NaN/out-of-range → fallback
|
|
119
121
|
const home = expandPath('~/.config/my-mcp');
|
|
120
122
|
```
|
|
121
123
|
|
|
124
|
+
`readPortEnv` parses a TCP port with the same placeholder hardening as
|
|
125
|
+
`readEnvVar`, plus integer + `1..65535` range validation — so an unexpanded
|
|
126
|
+
`${MY_WS_PORT}` or junk falls back to the default instead of handing `NaN` to
|
|
127
|
+
the server.
|
|
128
|
+
|
|
122
129
|
`loadDotenvSafely` is a no-throw `.env` loader (returns `false` instead of
|
|
123
130
|
failing when the file is absent).
|
|
124
131
|
|
|
132
|
+
`createCachedJsonArrayLoader` builds a cached, negative-cached loader for an
|
|
133
|
+
env-named JSON string-array file — the `loadCommunities`/`DEFAULT_COMMUNITIES`
|
|
134
|
+
pattern shared across the realty servers:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { createCachedJsonArrayLoader } from '@chrischall/mcp-utils';
|
|
138
|
+
|
|
139
|
+
const loadCommunities = createCachedJsonArrayLoader({
|
|
140
|
+
envVar: 'REDFIN_COMMUNITIES_FILE', // path to a JSON string-array file
|
|
141
|
+
defaults: DEFAULT_COMMUNITIES, // returned when unset/missing/invalid
|
|
142
|
+
label: 'redfin-mcp',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const communities = loadCommunities(); // parses + caches; re-reads only on path change
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
A successful parse is cached; a missing/unreadable file, invalid JSON, or a
|
|
149
|
+
non-string-array logs one stderr warning and negative-caches (returns defaults
|
|
150
|
+
without re-reading). Pass `readFile` to inject a reader in tests.
|
|
151
|
+
|
|
125
152
|
### `fs` — streaming file helpers (uploads)
|
|
126
153
|
|
|
127
154
|
`fileBlob`, `readFileHead`.
|
|
@@ -145,10 +172,15 @@ constant memory instead of a 20 MB Buffer.
|
|
|
145
172
|
### `http` — bearer API-client kit
|
|
146
173
|
|
|
147
174
|
`createApiClient` plus building blocks: `buildQueryString`, `buildOptionalBody`,
|
|
148
|
-
`formatApiError`, `parseLinkHeader`, `parseCookieJar`,
|
|
149
|
-
(`decodeJwtExp`, `decodeJwtSessionId`,
|
|
175
|
+
`formatApiError`, `parseLinkHeader`, `parseCookieJar`, `parseCookieHeader`,
|
|
176
|
+
`runBoundedBatch`, JWT helpers (`decodeJwtExp`, `decodeJwtSessionId`,
|
|
177
|
+
`decodeJwtClaim`, `validateJwtExpiry`), and the `ApiError` / `UpstreamHttpError` /
|
|
150
178
|
`UnauthorizedError` / `RateLimitedError` / `RequestTimeoutError` classes.
|
|
151
179
|
|
|
180
|
+
`decodeJwtClaim(token, claim)` is the generic single-claim reader — returns the
|
|
181
|
+
raw claim value (`unknown`) or `undefined` for an undecodable token / absent
|
|
182
|
+
claim, so a repo doesn't hand-roll its own `extractXFromJwt`.
|
|
183
|
+
|
|
152
184
|
```ts
|
|
153
185
|
import { createApiClient } from '@chrischall/mcp-utils';
|
|
154
186
|
|
|
@@ -167,6 +199,37 @@ const data = await api.get('/v1/things', { query: { page: 2 } });
|
|
|
167
199
|
`RequestTimeoutError` instead of hanging the tool call. A 429 retry gets a fresh
|
|
168
200
|
timeout. Omit it to keep the previous unbounded behavior.
|
|
169
201
|
|
|
202
|
+
`parseCookieHeader(header)` parses an inbound *request* `Cookie:` header
|
|
203
|
+
(`name=value; name2=value2`) into a `Record<string, string>` (first `=` splits,
|
|
204
|
+
so values may contain `=`; last value wins on a duplicate name). It's the
|
|
205
|
+
counterpart to `parseCookieJar`, which parses *response* `Set-Cookie` headers
|
|
206
|
+
with their attributes and deletion semantics.
|
|
207
|
+
|
|
208
|
+
`UpstreamHttpError(status, message)` is a directly-`throw new`-able,
|
|
209
|
+
status-carrying HTTP error — the manual-throw parallel to `ApiError` (which
|
|
210
|
+
`createApiClient` throws internally). It `extends ApiError`, so both the
|
|
211
|
+
`err instanceof ApiError && err.status === 404` branch and a narrower
|
|
212
|
+
`instanceof UpstreamHttpError` check work. Use it from a transport/bridge code
|
|
213
|
+
path that doesn't route through `createApiClient` but still needs to branch on a
|
|
214
|
+
404.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { runBoundedBatch } from '@chrischall/mcp-utils';
|
|
218
|
+
|
|
219
|
+
const rows = await runBoundedBatch(ids, (id, signal) => fetchRow(id, signal), {
|
|
220
|
+
deadlineMs: 45_000, // overall hard deadline for the whole batch
|
|
221
|
+
concurrency: 4, // optional fan-out cap
|
|
222
|
+
onTimeout: (id, i) => ({ id, pending: true }), // backfill any row the deadline cut off
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`runBoundedBatch(items, worker, opts)` races the whole batch against one overall
|
|
227
|
+
`deadlineMs`; any item still unsettled when it fires is filled by
|
|
228
|
+
`onTimeout(item, index)` (and its worker abandoned + `AbortSignal`-signalled) so
|
|
229
|
+
a single hung row can't wedge the call. It always returns a full-length,
|
|
230
|
+
input-ordered array. `setTimer`/`clearTimer` are injectable for tests. This
|
|
231
|
+
hoists zillow's bulk-tool deadline + `pending`-backfill primitive.
|
|
232
|
+
|
|
170
233
|
### `dates` — date-format converters
|
|
171
234
|
|
|
172
235
|
`isoToDmy`, `dmyToIso`, `isoToCompactTimestamp`. For upstreams that don't speak
|
|
@@ -184,7 +247,8 @@ deepMapStringField(payload, 'eventDate', dmyToIso); // '28-08-2025' → '202
|
|
|
184
247
|
### `zod` — schema atoms
|
|
185
248
|
|
|
186
249
|
Reusable schemas (`PositiveInt`, `NonNegInt`, `NonEmptyString`, `IsoDate`,
|
|
187
|
-
`IsoTime`, `
|
|
250
|
+
`IsoTime`, `NumericIdString`, `SafePathSegment`, `schemaOrigin`,
|
|
251
|
+
`schemaConfirm`), pagination helpers
|
|
188
252
|
(`paginationSchema`, `pageSchema`, `calculateOffset`), tool-annotation builders
|
|
189
253
|
(`toolAnnotations`), and time normalizers (`extractTime`, `normalizeTime`).
|
|
190
254
|
|
|
@@ -196,6 +260,11 @@ const offset = calculateOffset(page, size);
|
|
|
196
260
|
const annotations = toolAnnotations({ readOnly: true });
|
|
197
261
|
```
|
|
198
262
|
|
|
263
|
+
`NumericIdString` (`/^\d+$/`) and `SafePathSegment` (rejects `/`, `..`, `?`,
|
|
264
|
+
`#`, and whitespace) harden caller-supplied ids that get interpolated into
|
|
265
|
+
request paths — defense-in-depth against path traversal and query/fragment
|
|
266
|
+
injection.
|
|
267
|
+
|
|
199
268
|
### `auth` — auth resolver skeletons
|
|
200
269
|
|
|
201
270
|
`createAuthResolver`, `resolveAuthPattern`, `sessionLoginFlow`,
|
|
@@ -209,28 +278,80 @@ const resolver = createAuthResolver({ /* ... */ });
|
|
|
209
278
|
const refresh = createOAuth2Refresher({ /* ... */ });
|
|
210
279
|
```
|
|
211
280
|
|
|
212
|
-
### `session` — session registry &
|
|
281
|
+
### `session` — session registry, token manager & cookie-session manager *(subpath)*
|
|
213
282
|
|
|
214
283
|
```ts
|
|
215
284
|
import {
|
|
216
285
|
createSessionRegistry,
|
|
217
286
|
registerSessionTools,
|
|
218
287
|
TokenManager,
|
|
288
|
+
CookieSessionManager,
|
|
219
289
|
} from '@chrischall/mcp-utils/session';
|
|
220
290
|
|
|
221
291
|
const registry = createSessionRegistry();
|
|
222
292
|
registerSessionTools(server, { registry /* ... */ });
|
|
223
293
|
```
|
|
224
294
|
|
|
295
|
+
The `${prefix}_register_session` tool takes an optional `mark_active`
|
|
296
|
+
(default `false`); passing `mark_active: true` makes the newly-registered
|
|
297
|
+
session active in the same call instead of requiring a follow-up
|
|
298
|
+
`${prefix}_set_active_session`.
|
|
299
|
+
|
|
225
300
|
Includes `SessionStore`, `normalizeOrigin`, `AuthMode`, and `TokenManager`
|
|
226
301
|
(with `TOKEN_REFRESH_SKEW_MS` for proactive refresh).
|
|
227
302
|
|
|
303
|
+
`CookieSessionManager<S, R = Response>` is the cookie-session analog of
|
|
304
|
+
`TokenManager` for sites authenticated by a browser-style cookie session rather
|
|
305
|
+
than a bearer token. It owns *when* to log in (single-flight, so concurrent
|
|
306
|
+
callers coalesce into ONE login), clears the in-flight promise on settle (a
|
|
307
|
+
rejected login never sticks — the next `ensure()` retries), and `withSession()`
|
|
308
|
+
re-logs-in and replays a request **exactly once** on a detected expiry (no
|
|
309
|
+
infinite loop). The injected `isExpired(res)` predicate is the hook for body/URL
|
|
310
|
+
heuristics — so a `200` serving an HTML login page or a redirect away from the
|
|
311
|
+
target is treated as expired, not just `401`/`403`. An optional
|
|
312
|
+
`isPermanentError` caches genuine missing-config errors while leaving transient
|
|
313
|
+
login failures retryable.
|
|
314
|
+
|
|
315
|
+
`isExpired` is **optional** — omit it for ensure-only consumers with no
|
|
316
|
+
per-request expiry path (e.g. Skylight, whose re-auth lives in `TokenManager`);
|
|
317
|
+
it defaults to `() => false`, so `withSession()` simply never replays.
|
|
318
|
+
|
|
319
|
+
The second type param `R` (default `Response`) is the response type
|
|
320
|
+
`withSession`'s `call` resolves to. The manager is response-agnostic — it only
|
|
321
|
+
hands `R` to `isExpired` and returns it untouched — so override `R` for a custom
|
|
322
|
+
or non-fetch transport (e.g. Artsonia's `{ setCookie?, location?, url, body }`).
|
|
323
|
+
Existing adopters writing `CookieSessionManager<MySession>` keep `R = Response`
|
|
324
|
+
with **no call-site changes**.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
const sessions = new CookieSessionManager<{ cookieHeader: string; csrfToken?: string }>({
|
|
328
|
+
login: () => loginWithPassword(), // mints a fresh cookie session
|
|
329
|
+
isExpired: async (res) =>
|
|
330
|
+
res.status === 401 || /<form[^>]*id="login"/i.test(await res.clone().text()),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const res = await sessions.withSession((s) =>
|
|
334
|
+
fetch(url, { headers: { cookie: s.cookieHeader } }),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// Custom non-fetch transport: parameterize R (and isExpired reads R's members).
|
|
338
|
+
const custom = new CookieSessionManager<MySession, MyResponse>({
|
|
339
|
+
login: () => loginWithPassword(),
|
|
340
|
+
isExpired: (res) => /login\.asp/i.test(res.location ?? res.url),
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Replaces the hand-rolled re-login / single-flight / 401-replay code in
|
|
345
|
+
`artsonia-mcp`, `canvas-parent-mcp`, `evite-mcp`, `signupgenius-mcp`, and
|
|
346
|
+
`skylight-mcp`.
|
|
347
|
+
|
|
228
348
|
### `fetchproxy` — transport adapter *(subpath, optional peer)*
|
|
229
349
|
|
|
230
350
|
```ts
|
|
231
351
|
import {
|
|
232
352
|
createFetchproxyTransport,
|
|
233
353
|
createBootstrapOpts,
|
|
354
|
+
registerBridgeHealthcheckTool,
|
|
234
355
|
mapWithConcurrency,
|
|
235
356
|
TokenBucket,
|
|
236
357
|
classifyBotWall,
|
|
@@ -241,6 +362,43 @@ Wraps `@fetchproxy/server` with the fleet's transport, bot-wall classification,
|
|
|
241
362
|
deadline/retry, token-bucket rate limiting, and bounded-concurrency helpers, and
|
|
242
363
|
re-exports the fetchproxy typed-error hierarchy.
|
|
243
364
|
|
|
365
|
+
**Transport verb adapters.** Beyond the `start` / `close` / `status` lifecycle,
|
|
366
|
+
`createFetchproxyTransport` exposes the verb passthroughs redfin / homes /
|
|
367
|
+
compass / musescore had each hand-rolled over the server:
|
|
368
|
+
|
|
369
|
+
- `fetch(init)` → `{ status, body, url }` via `server.request(...)`;
|
|
370
|
+
- `requestJson(method, path, init?)` → `{ data, result }` via
|
|
371
|
+
`server.requestJson(...)` (serialization + header defaults + 204→null +
|
|
372
|
+
`JSON.parse`; the caller keeps its per-site `throwIfNotOk` over `result`);
|
|
373
|
+
- `runProbe(fetchFn, probePath)` → the healthcheck probe loop.
|
|
374
|
+
|
|
375
|
+
The one per-site bit is the subdomain: pass `defaultSubdomain: 'www'` for sites
|
|
376
|
+
served from `www` (redfin/homes/compass); omit it for apex-served sites
|
|
377
|
+
(musescore). A per-call `subdomain` always overrides the default, and absolute
|
|
378
|
+
`http(s)://` paths self-describe their host. Other per-site verbs (e.g.
|
|
379
|
+
musescore's `download` capability) stay caller-supplied — the factory covers the
|
|
380
|
+
common subset, not the long tail.
|
|
381
|
+
|
|
382
|
+
**Bridge-healthcheck tool factory.** `registerBridgeHealthcheckTool({ server,
|
|
383
|
+
prefix, probePath, hostLabel, transport, probeFn })` registers a
|
|
384
|
+
`<prefix>_healthcheck` tool that round-trips `probePath` through the bridge and
|
|
385
|
+
reports bridge role / port / timing plus an actionable hint ladder
|
|
386
|
+
(`bridge_down` → wake the SW, `role === null` → check startup, `timeout` →
|
|
387
|
+
extension not connected, …). The failure hint cites the **actual configured
|
|
388
|
+
bridge port** from `bridgeHealth()`, not a hardcoded `37149` — fixing the bug
|
|
389
|
+
the per-site compass + musescore copies shared.
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
registerBridgeHealthcheckTool({
|
|
393
|
+
server,
|
|
394
|
+
prefix: 'compass',
|
|
395
|
+
probePath: '/robots.txt',
|
|
396
|
+
hostLabel: 'compass.com',
|
|
397
|
+
transport,
|
|
398
|
+
probeFn: (path) => client.fetchHtml(path),
|
|
399
|
+
});
|
|
400
|
+
```
|
|
401
|
+
|
|
244
402
|
### `html` — scraping helpers *(subpath, optional peer)*
|
|
245
403
|
|
|
246
404
|
```ts
|
package/dist/config/index.d.ts
CHANGED
|
@@ -62,6 +62,72 @@ export declare function parseBoolEnv(key: string, opts?: ParseBoolEnvOptions): b
|
|
|
62
62
|
* NOT expanded — only the current user's home is ever substituted.
|
|
63
63
|
*/
|
|
64
64
|
export declare function expandPath(p: string): string;
|
|
65
|
+
/** Options for {@link readPortEnv}. */
|
|
66
|
+
export interface ReadPortEnvOptions {
|
|
67
|
+
/** The source to read from. Defaults to {@link process.env}. */
|
|
68
|
+
env?: EnvSource;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read a TCP port from an environment variable, hardened the way
|
|
72
|
+
* {@link readEnvVar} hardens any var (trim, treat blank / `'undefined'` /
|
|
73
|
+
* `'null'` / unsubstituted `${...}` placeholder as unset) PLUS numeric
|
|
74
|
+
* validation: the value must parse to an integer in the valid port range
|
|
75
|
+
* `1..65535`.
|
|
76
|
+
*
|
|
77
|
+
* Returns `fallback` when the variable is unset, a placeholder, non-numeric, or
|
|
78
|
+
* out of range. Consolidates the bare `Number(process.env.X_WS_PORT)` pattern
|
|
79
|
+
* across compass/redfin/homes/musescore, which yields `NaN` on an unexpanded
|
|
80
|
+
* `${...}` placeholder or junk and then hands `NaN` to the server.
|
|
81
|
+
*
|
|
82
|
+
* @example readPortEnv('REDFIN_WS_PORT', 37149)
|
|
83
|
+
*/
|
|
84
|
+
export declare function readPortEnv(key: string, fallback: number, opts?: ReadPortEnvOptions): number;
|
|
85
|
+
/** A minimal injectable file reader: returns the file's UTF-8 contents. */
|
|
86
|
+
export type ReadFileSyncFn = (path: string) => string;
|
|
87
|
+
/** Options for {@link createCachedJsonArrayLoader}. */
|
|
88
|
+
export interface CachedJsonArrayLoaderOptions {
|
|
89
|
+
/** Name of the env var holding the path to a JSON string-array file. */
|
|
90
|
+
envVar: string;
|
|
91
|
+
/** Returned when the var is unset, or the file is missing/unreadable/invalid. */
|
|
92
|
+
defaults: string[];
|
|
93
|
+
/** The source to read env from. Defaults to {@link process.env}. */
|
|
94
|
+
env?: EnvSource;
|
|
95
|
+
/**
|
|
96
|
+
* Injectable file reader (for tests). Defaults to a `node:fs` reader that
|
|
97
|
+
* throws when the file is missing — a missing file is caught and
|
|
98
|
+
* negative-cached like any other read failure.
|
|
99
|
+
*/
|
|
100
|
+
readFile?: ReadFileSyncFn;
|
|
101
|
+
/**
|
|
102
|
+
* Label woven into the stderr warning on a missing/invalid file (e.g.
|
|
103
|
+
* `'redfin-mcp'`). Defaults to the env-var name.
|
|
104
|
+
*/
|
|
105
|
+
label?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build a cached, negative-cached loader for an env-named JSON string-array
|
|
109
|
+
* file — the `loadCommunities` + `DEFAULT_COMMUNITIES` pattern quadruplicated
|
|
110
|
+
* across redfin/zillow/homes/onehome (only the env var differs:
|
|
111
|
+
* `REDFIN_/ZILLOW_/HOMES_/ONEHOME_COMMUNITIES_FILE`).
|
|
112
|
+
*
|
|
113
|
+
* The returned function:
|
|
114
|
+
* - reads the file path from `envVar` via {@link readEnvVar} (placeholder
|
|
115
|
+
* hardening), returning `defaults` when unset (and clearing any cache),
|
|
116
|
+
* - parses the file as a JSON array of strings; on success caches and returns
|
|
117
|
+
* it (keyed by the env-var value, so a path change re-reads),
|
|
118
|
+
* - on a missing / unreadable file, invalid JSON, or non-string-array,
|
|
119
|
+
* logs a single stderr warning and **negative-caches** — it returns
|
|
120
|
+
* `defaults` without re-reading on subsequent calls for the same path,
|
|
121
|
+
* - never re-reads a successfully-cached file.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* const loadCommunities = createCachedJsonArrayLoader({
|
|
125
|
+
* envVar: 'REDFIN_COMMUNITIES_FILE',
|
|
126
|
+
* defaults: DEFAULT_COMMUNITIES,
|
|
127
|
+
* label: 'redfin-mcp',
|
|
128
|
+
* });
|
|
129
|
+
*/
|
|
130
|
+
export declare function createCachedJsonArrayLoader(opts: CachedJsonArrayLoaderOptions): () => string[];
|
|
65
131
|
/** Options for {@link loadDotenvSafely}. */
|
|
66
132
|
export interface LoadDotenvOptions {
|
|
67
133
|
/** Path to the `.env` file. Defaults to dotenv's own resolution (CWD). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"AAIA,+DAA+D;AAC/D,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAE3D,iDAAiD;AACjD,MAAM,WAAW,cAAc;IAC7B,gEAAgE;IAChE,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAaD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,cAAmB,GAAG,MAAM,GAAG,SAAS,CAerF;AAED,yCAAyC;AACzC,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,8EAA8E;IAC9E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,iBAAsB,GAAG,MAAM,CAO/E;AAED,wCAAwC;AACxC,MAAM,WAAW,mBAAmB;IAClC,gEAAgE;IAChE,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,mBAAwB,GAAG,OAAO,CAQjF;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAQ5C;AAED,uCAAuC;AACvC,MAAM,WAAW,kBAAkB;IACjC,gEAAgE;IAChE,GAAG,CAAC,EAAE,SAAS,CAAC;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,GAAE,kBAAuB,GAAG,MAAM,CAShG;AAED,2EAA2E;AAC3E,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;AAEtD,uDAAuD;AACvD,MAAM,WAAW,4BAA4B;IAC3C,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,oEAAoE;IACpE,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,4BAA4B,GAAG,MAAM,MAAM,EAAE,CAoD9F;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,GAAE,iBAAsB,GAAG,OAAO,CAAC,OAAO,CAAC,CAwBrF"}
|
package/dist/config/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { homedir } from 'node:os';
|
|
2
3
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
4
|
/**
|
|
@@ -93,6 +94,102 @@ export function expandPath(p) {
|
|
|
93
94
|
}
|
|
94
95
|
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
95
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Read a TCP port from an environment variable, hardened the way
|
|
99
|
+
* {@link readEnvVar} hardens any var (trim, treat blank / `'undefined'` /
|
|
100
|
+
* `'null'` / unsubstituted `${...}` placeholder as unset) PLUS numeric
|
|
101
|
+
* validation: the value must parse to an integer in the valid port range
|
|
102
|
+
* `1..65535`.
|
|
103
|
+
*
|
|
104
|
+
* Returns `fallback` when the variable is unset, a placeholder, non-numeric, or
|
|
105
|
+
* out of range. Consolidates the bare `Number(process.env.X_WS_PORT)` pattern
|
|
106
|
+
* across compass/redfin/homes/musescore, which yields `NaN` on an unexpanded
|
|
107
|
+
* `${...}` placeholder or junk and then hands `NaN` to the server.
|
|
108
|
+
*
|
|
109
|
+
* @example readPortEnv('REDFIN_WS_PORT', 37149)
|
|
110
|
+
*/
|
|
111
|
+
export function readPortEnv(key, fallback, opts = {}) {
|
|
112
|
+
const raw = readEnvVar(key, { env: opts.env });
|
|
113
|
+
if (raw === undefined)
|
|
114
|
+
return fallback;
|
|
115
|
+
// Strict integer parse: reject `12abc`, `1.5`, `0x10`, etc. that `Number`
|
|
116
|
+
// would otherwise coerce or accept.
|
|
117
|
+
if (!/^\d+$/.test(raw))
|
|
118
|
+
return fallback;
|
|
119
|
+
const port = Number(raw);
|
|
120
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
121
|
+
return fallback;
|
|
122
|
+
return port;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Build a cached, negative-cached loader for an env-named JSON string-array
|
|
126
|
+
* file — the `loadCommunities` + `DEFAULT_COMMUNITIES` pattern quadruplicated
|
|
127
|
+
* across redfin/zillow/homes/onehome (only the env var differs:
|
|
128
|
+
* `REDFIN_/ZILLOW_/HOMES_/ONEHOME_COMMUNITIES_FILE`).
|
|
129
|
+
*
|
|
130
|
+
* The returned function:
|
|
131
|
+
* - reads the file path from `envVar` via {@link readEnvVar} (placeholder
|
|
132
|
+
* hardening), returning `defaults` when unset (and clearing any cache),
|
|
133
|
+
* - parses the file as a JSON array of strings; on success caches and returns
|
|
134
|
+
* it (keyed by the env-var value, so a path change re-reads),
|
|
135
|
+
* - on a missing / unreadable file, invalid JSON, or non-string-array,
|
|
136
|
+
* logs a single stderr warning and **negative-caches** — it returns
|
|
137
|
+
* `defaults` without re-reading on subsequent calls for the same path,
|
|
138
|
+
* - never re-reads a successfully-cached file.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* const loadCommunities = createCachedJsonArrayLoader({
|
|
142
|
+
* envVar: 'REDFIN_COMMUNITIES_FILE',
|
|
143
|
+
* defaults: DEFAULT_COMMUNITIES,
|
|
144
|
+
* label: 'redfin-mcp',
|
|
145
|
+
* });
|
|
146
|
+
*/
|
|
147
|
+
export function createCachedJsonArrayLoader(opts) {
|
|
148
|
+
const env = opts.env ?? process.env;
|
|
149
|
+
const label = opts.label ?? opts.envVar;
|
|
150
|
+
// Default reader: `node:fs#readFileSync` (utf8). A missing file throws, which
|
|
151
|
+
// the catch below turns into a negative-cached fallback — mirroring redfin's
|
|
152
|
+
// original `existsSync` guard without the extra stat.
|
|
153
|
+
const readFile = opts.readFile ?? ((path) => readFileSync(path, 'utf8'));
|
|
154
|
+
let cached = null;
|
|
155
|
+
// Path the cache (positive OR negative) is keyed to. A negative cache is
|
|
156
|
+
// `cached === null && cachedPath === <path>`: we resolved that path to
|
|
157
|
+
// defaults and won't re-read it.
|
|
158
|
+
let cachedPath = null;
|
|
159
|
+
return function load() {
|
|
160
|
+
const path = readEnvVar(opts.envVar, { env });
|
|
161
|
+
if (!path) {
|
|
162
|
+
// Unset: never cache (the var may be set later) and return defaults.
|
|
163
|
+
cached = null;
|
|
164
|
+
cachedPath = null;
|
|
165
|
+
return opts.defaults;
|
|
166
|
+
}
|
|
167
|
+
if (cachedPath === path) {
|
|
168
|
+
// Same path as last time — positive cache (return it) or negative cache
|
|
169
|
+
// (cached === null → return defaults without re-reading).
|
|
170
|
+
return cached ?? opts.defaults;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const raw = readFile(path);
|
|
174
|
+
const parsed = JSON.parse(raw);
|
|
175
|
+
if (!Array.isArray(parsed) || !parsed.every((s) => typeof s === 'string')) {
|
|
176
|
+
console.error(`[${label}] ${opts.envVar}="${path}" must be a JSON string array — falling back to defaults.`);
|
|
177
|
+
cached = null;
|
|
178
|
+
cachedPath = path; // negative-cache this path
|
|
179
|
+
return opts.defaults;
|
|
180
|
+
}
|
|
181
|
+
cached = parsed;
|
|
182
|
+
cachedPath = path;
|
|
183
|
+
return cached;
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
console.error(`[${label}] failed to load ${opts.envVar}="${path}": ${err instanceof Error ? err.message : String(err)} — falling back to defaults.`);
|
|
187
|
+
cached = null;
|
|
188
|
+
cachedPath = path; // negative-cache this path
|
|
189
|
+
return opts.defaults;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
96
193
|
/**
|
|
97
194
|
* Load a `.env` file for local development, swallowing any failure.
|
|
98
195
|
*
|
package/dist/config/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* - {@link createFetchproxyTransport} wraps a `FetchproxyServer` in the
|
|
11
11
|
* `start` / `close` / `status` lifecycle every MCP's transport interface
|
|
12
|
-
* expects, with optional debug-gated role logging
|
|
12
|
+
* expects, with optional debug-gated role logging, PLUS the opt-in verb
|
|
13
|
+
* passthroughs (`fetch` / `requestJson` / `runProbe`) that redfin / homes /
|
|
14
|
+
* compass / musescore had each hand-rolled over the server.
|
|
15
|
+
* - {@link registerBridgeHealthcheckTool} registers a `<prefix>_healthcheck`
|
|
16
|
+
* tool that round-trips a probe path through the bridge and surfaces the
|
|
17
|
+
* actionable hint ladder compass + musescore had each copied (with drifted
|
|
18
|
+
* internals + a hardcoded-port bug) into `src/tools/healthcheck.ts`.
|
|
13
19
|
* - {@link createBootstrapOpts} assembles a multi-domain / capture-header /
|
|
14
20
|
* storage-pointer declaration fragment of `FetchproxyServerOpts`, deriving
|
|
15
21
|
* the required `capabilities` from the declared bootstrap so callers can't
|
|
@@ -18,10 +24,52 @@
|
|
|
18
24
|
* The adapter shape is identical across 12+ fetchproxy MCPs; collapsing it here
|
|
19
25
|
* keeps the per-row / concurrency / deadline helpers as re-exports rather than
|
|
20
26
|
* re-rolled code.
|
|
27
|
+
*
|
|
28
|
+
* Lazy-import note: this whole subpath is gated behind the OPTIONAL
|
|
29
|
+
* `@fetchproxy/server` peer dep — a consumer only resolves it by importing
|
|
30
|
+
* `@chrischall/mcp-utils/fetchproxy`, never via the core barrel. The eager
|
|
31
|
+
* top-level import below therefore never reaches a `.mcpb` bundle that
|
|
32
|
+
* externalizes `@fetchproxy/server` unless that consumer actually opted into
|
|
33
|
+
* the bridge. Everything added here routes through that same already-imported
|
|
34
|
+
* `FetchproxyServer` instance (no NEW top-level `@fetchproxy/server` import),
|
|
35
|
+
* so the bundle-load smoke posture is unchanged.
|
|
21
36
|
*/
|
|
22
|
-
import { FetchproxyServer, type FetchproxyServerOpts } from '@fetchproxy/server';
|
|
37
|
+
import { FetchproxyServer, type FetchproxyServerOpts, type HttpResponse, type BridgeProbeResult } from '@fetchproxy/server';
|
|
38
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
23
39
|
export { FetchproxyServer, mapWithConcurrency, withDeadline, TokenBucket, classifyBotWall, retryOnceOnTimeout, FetchproxyProtocolError, FetchproxyHttpError, FetchproxyBridgeDownError, FetchproxyTimeoutError, classifyBridgeError, classifyRowError, classifyFetchError, backoffDelayMs, BRIDGE_CONCURRENCY, chunk, sleep, extractGlobalAssign, extractBalancedObject, extractImgTags, lastPathSegment, } from '@fetchproxy/server';
|
|
24
40
|
export type { FetchproxyServerOpts, FetchResult, FetchResultError, HttpResponse, RequestOpts, BodylessRequestOpts, BridgeHealth, BridgeProbeResult, BridgeError, FetchErrorKind, BotWallResult, BotWallVendor, TokenBucketOptions, BackoffOptions, DeadlineOutcome, } from '@fetchproxy/server';
|
|
41
|
+
/**
|
|
42
|
+
* One request to round-trip through the bridge. The path is resolved relative
|
|
43
|
+
* to the transport's declared domain (a `defaultSubdomain` — e.g. `'www'` — is
|
|
44
|
+
* applied unless the caller overrides it per-call). This is the common
|
|
45
|
+
* `FetchInit` redfin / homes / compass / musescore each declared verbatim in
|
|
46
|
+
* their `src/transport.ts`.
|
|
47
|
+
*/
|
|
48
|
+
export interface FetchproxyFetchInit {
|
|
49
|
+
/** Path-and-query relative to the declared domain, e.g. `/robots.txt`. */
|
|
50
|
+
path: string;
|
|
51
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
52
|
+
headers?: Record<string, string>;
|
|
53
|
+
/** Serialized request body. JSON callers stringify before calling. */
|
|
54
|
+
body?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Per-call subdomain override. Defaults to the transport's `defaultSubdomain`
|
|
57
|
+
* (and, absent that, the apex). Absolute `http(s)://` paths self-describe
|
|
58
|
+
* their host, so this is ignored for them.
|
|
59
|
+
*/
|
|
60
|
+
subdomain?: string;
|
|
61
|
+
/** Per-call base-domain selector (required only for multi-domain MCPs). */
|
|
62
|
+
domain?: string;
|
|
63
|
+
}
|
|
64
|
+
/** The success-arm `{status, body, url}` triple every consumer returns. */
|
|
65
|
+
export type FetchproxyFetchResult = HttpResponse;
|
|
66
|
+
/** Options for {@link FetchproxyTransport.requestJson}. */
|
|
67
|
+
export interface FetchproxyRequestJsonInit {
|
|
68
|
+
headers?: Record<string, string>;
|
|
69
|
+
body?: unknown;
|
|
70
|
+
subdomain?: string;
|
|
71
|
+
domain?: string;
|
|
72
|
+
}
|
|
25
73
|
/**
|
|
26
74
|
* The lifecycle surface every per-MCP fetchproxy transport interface exposes.
|
|
27
75
|
* `createFetchproxyTransport` returns this, typed as the caller's `T` so it can
|
|
@@ -48,6 +96,31 @@ export interface FetchproxyTransport {
|
|
|
48
96
|
* verb.
|
|
49
97
|
*/
|
|
50
98
|
readonly server: FetchproxyServer;
|
|
99
|
+
/**
|
|
100
|
+
* Verb passthrough: round-trip one request through `server.request(...)`,
|
|
101
|
+
* applying the transport's `defaultSubdomain`, and return the success-arm
|
|
102
|
+
* `{status, body, url}` triple. Bridge failures throw the typed errors
|
|
103
|
+
* (`FetchproxyBridgeDownError` / `FetchproxyTimeoutError` / …), exactly like
|
|
104
|
+
* `server.request`. This is the `fetch(init)` redfin / homes / compass /
|
|
105
|
+
* musescore each wrote by hand.
|
|
106
|
+
*/
|
|
107
|
+
fetch(init: FetchproxyFetchInit): Promise<FetchproxyFetchResult>;
|
|
108
|
+
/**
|
|
109
|
+
* Verb passthrough over `server.requestJson(...)` (serialization + header
|
|
110
|
+
* defaults + 204→null + JSON.parse). Returns BOTH the parsed `data` and the
|
|
111
|
+
* raw success-arm `result`, so the caller keeps its per-site `throwIfNotOk` /
|
|
112
|
+
* sign-in guards over `result`. `defaultSubdomain` is applied.
|
|
113
|
+
*/
|
|
114
|
+
requestJson<T = unknown>(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, init?: FetchproxyRequestJsonInit): Promise<{
|
|
115
|
+
data: T | null;
|
|
116
|
+
result: FetchproxyFetchResult;
|
|
117
|
+
}>;
|
|
118
|
+
/**
|
|
119
|
+
* Verb passthrough over `server.runProbe(...)` — run one healthcheck probe,
|
|
120
|
+
* measure elapsed ms, classify any thrown error, and project the post-probe
|
|
121
|
+
* `bridgeHealth()`. Powers {@link registerBridgeHealthcheckTool}.
|
|
122
|
+
*/
|
|
123
|
+
runProbe(fetchFn: (path: string) => Promise<unknown>, probePath: string): Promise<BridgeProbeResult>;
|
|
51
124
|
}
|
|
52
125
|
/** Options for {@link createFetchproxyTransport}. */
|
|
53
126
|
export type CreateFetchproxyTransportOptions = FetchproxyServerOpts & {
|
|
@@ -59,6 +132,14 @@ export type CreateFetchproxyTransportOptions = FetchproxyServerOpts & {
|
|
|
59
132
|
debugEnvVar?: string;
|
|
60
133
|
/** Env source for {@link debugEnvVar}. Defaults to `process.env`. */
|
|
61
134
|
env?: NodeJS.ProcessEnv;
|
|
135
|
+
/**
|
|
136
|
+
* Subdomain the verb adapters (`fetch` / `requestJson`) apply per call unless
|
|
137
|
+
* the caller overrides it. This is the ONE per-site bit of the verb surface:
|
|
138
|
+
* redfin / homes / compass pin `'www'`; apex-served sites (musescore) omit it
|
|
139
|
+
* to hit the bare domain. Absolute `http(s)://` paths self-describe their host
|
|
140
|
+
* and ignore this entirely.
|
|
141
|
+
*/
|
|
142
|
+
defaultSubdomain?: string;
|
|
62
143
|
};
|
|
63
144
|
/**
|
|
64
145
|
* Wrap a `FetchproxyServer` in the `start`/`close`/`status` lifecycle the
|
|
@@ -155,4 +236,70 @@ export interface BridgeErrorInfo {
|
|
|
155
236
|
* string kind (drop-in compatible with `@fetchproxy/server`).
|
|
156
237
|
*/
|
|
157
238
|
export declare function bridgeErrorInfo(err: unknown): BridgeErrorInfo;
|
|
239
|
+
/** The diagnostic result a `<prefix>_healthcheck` tool returns. */
|
|
240
|
+
export interface BridgeHealthcheckResult {
|
|
241
|
+
ok: boolean;
|
|
242
|
+
bridge: BridgeProbeResult['bridge'] & {
|
|
243
|
+
last_extension_message_at: number | null;
|
|
244
|
+
};
|
|
245
|
+
probe: {
|
|
246
|
+
url: string;
|
|
247
|
+
elapsed_ms: number;
|
|
248
|
+
status?: number;
|
|
249
|
+
body_length?: number;
|
|
250
|
+
};
|
|
251
|
+
error?: {
|
|
252
|
+
kind: BridgeErrorInfo['type'];
|
|
253
|
+
message: string;
|
|
254
|
+
/** Server-authored next-step hint (`FetchproxyBridgeDownError.hint`), when present. */
|
|
255
|
+
bridge_hint?: string;
|
|
256
|
+
};
|
|
257
|
+
/** Plain-English next-step suggestion derived from the result. */
|
|
258
|
+
hint: string;
|
|
259
|
+
}
|
|
260
|
+
/** Options for {@link registerBridgeHealthcheckTool}. */
|
|
261
|
+
export interface RegisterBridgeHealthcheckToolArgs {
|
|
262
|
+
/** The `McpServer` to register the tool on. */
|
|
263
|
+
server: McpServer;
|
|
264
|
+
/** Tool-name + host-banner prefix, e.g. `'compass'` → `compass_healthcheck`. */
|
|
265
|
+
prefix: string;
|
|
266
|
+
/** Public probe path to round-trip, e.g. `'/robots.txt'`. */
|
|
267
|
+
probePath: string;
|
|
268
|
+
/**
|
|
269
|
+
* Display host for the probe URL + hint copy, e.g. `'compass.com'` or
|
|
270
|
+
* `'www.redfin.com'`. The probe URL is `https://<hostLabel><probePath>`.
|
|
271
|
+
*/
|
|
272
|
+
hostLabel: string;
|
|
273
|
+
/**
|
|
274
|
+
* The bridge transport — supplies `runProbe` (the probe loop + classification
|
|
275
|
+
* + post-probe bridge projection) and `status()` (for the liveness counter the
|
|
276
|
+
* projection omits). Any {@link FetchproxyTransport}-shaped object works.
|
|
277
|
+
*/
|
|
278
|
+
transport: Pick<FetchproxyTransport, 'runProbe' | 'status'>;
|
|
279
|
+
/**
|
|
280
|
+
* Performs the actual probe fetch for `probePath`. Required — most consumers
|
|
281
|
+
* pass `(path) => client.fetchHtml(path)` so the probe exercises the same
|
|
282
|
+
* client path real tools use (sign-in guards and all).
|
|
283
|
+
*/
|
|
284
|
+
probeFn: (path: string) => Promise<string>;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Register a `<prefix>_healthcheck` MCP tool that round-trips `probePath`
|
|
288
|
+
* through the bridge and reports bridge status + role + timing, plus an
|
|
289
|
+
* actionable hint ladder on failure.
|
|
290
|
+
*
|
|
291
|
+
* The probe loop / error classification / post-probe bridge projection all live
|
|
292
|
+
* in `transport.runProbe` (the `@fetchproxy/server` primitive); this factory
|
|
293
|
+
* owns only the tool registration, the result shape, and the hint ladder. The
|
|
294
|
+
* per-site bits (`prefix`, `probePath`, `hostLabel`, the probe `fetchFn`) are
|
|
295
|
+
* options.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* registerBridgeHealthcheckTool({
|
|
299
|
+
* server, prefix: 'compass', probePath: '/robots.txt',
|
|
300
|
+
* hostLabel: 'compass.com', transport,
|
|
301
|
+
* probeFn: (p) => client.fetchHtml(p),
|
|
302
|
+
* });
|
|
303
|
+
*/
|
|
304
|
+
export declare function registerBridgeHealthcheckTool(args: RegisterBridgeHealthcheckToolArgs): void;
|
|
158
305
|
//# sourceMappingURL=index.d.ts.map
|