ofw-mcp 2.3.1 → 2.3.2
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/auth.js +3 -3
- package/dist/bundle.js +572 -111
- package/dist/cache.js +19 -1
- package/dist/client.js +66 -50
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "OurFamilyWizard tools for Claude Code",
|
|
9
|
-
"version": "2.3.
|
|
9
|
+
"version": "2.3.2"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"displayName": "OurFamilyWizard",
|
|
15
15
|
"source": "./",
|
|
16
16
|
"description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
|
|
17
|
-
"version": "2.3.
|
|
17
|
+
"version": "2.3.2",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
package/dist/auth.js
CHANGED
|
@@ -69,9 +69,9 @@ function fetchproxyDisabled() {
|
|
|
69
69
|
* Resolve OFW auth using the three-path priority described at the top of
|
|
70
70
|
* this file. Throws with an actionable error message when no path succeeds.
|
|
71
71
|
*
|
|
72
|
-
* Callers (i.e. `OFWClient
|
|
73
|
-
* credentials — they should not branch on `source`.
|
|
74
|
-
* logging / future cache-keying only.
|
|
72
|
+
* Callers (i.e. the `OFWClient` TokenManager refresh callback) treat the
|
|
73
|
+
* return value as opaque credentials — they should not branch on `source`.
|
|
74
|
+
* The field exists for logging / future cache-keying only.
|
|
75
75
|
*/
|
|
76
76
|
export async function resolveAuth() {
|
|
77
77
|
// ── Path 1: env-var credentials (unchanged from pre-fetchproxy behavior).
|
package/dist/bundle.js
CHANGED
|
@@ -34681,6 +34681,22 @@ async function runMcp(opts) {
|
|
|
34681
34681
|
return server;
|
|
34682
34682
|
}
|
|
34683
34683
|
|
|
34684
|
+
// node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
34685
|
+
var API_KEY_RE = new RegExp([
|
|
34686
|
+
"sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
|
|
34687
|
+
// OpenAI / Anthropic (incl. sk-ant-…)
|
|
34688
|
+
"gh[pousr]_[A-Za-z0-9]{36,}\\b",
|
|
34689
|
+
// GitHub ghp_/gho_/ghu_/ghs_/ghr_
|
|
34690
|
+
"xox[baprs]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])",
|
|
34691
|
+
// Slack
|
|
34692
|
+
"AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
|
|
34693
|
+
// Google API key (39 chars total)
|
|
34694
|
+
"AKIA[0-9A-Z]{16}\\b",
|
|
34695
|
+
// AWS access key id (20 chars total)
|
|
34696
|
+
"whsec_[A-Za-z0-9]{16,}\\b"
|
|
34697
|
+
// webhook signing secret (Stripe-style)
|
|
34698
|
+
].map((p) => `\\b${p}`).join("|"), "g");
|
|
34699
|
+
|
|
34684
34700
|
// node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
34685
34701
|
function textResult(data) {
|
|
34686
34702
|
return {
|
|
@@ -34767,6 +34783,8 @@ var NonNegInt = external_exports.number().int().nonnegative();
|
|
|
34767
34783
|
var NonEmptyString = external_exports.string().min(1);
|
|
34768
34784
|
var IsoDate = external_exports.iso.date();
|
|
34769
34785
|
var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30");
|
|
34786
|
+
var NumericIdString = external_exports.string().regex(/^\d+$/, "must be a numeric id (digits only)").describe("A numeric id string (digits only), safe to interpolate into a URL path.");
|
|
34787
|
+
var SafePathSegment = external_exports.string().min(1).regex(/^[^/?#\s]+$/, 'must not contain "/", "?", "#", or whitespace').refine((v) => !v.includes(".."), 'must not contain ".."').describe('A single URL path segment, safe to interpolate: no "/", "..", "?", "#", or whitespace.');
|
|
34770
34788
|
var schemaOrigin = external_exports.string().optional().describe("Portal origin (e.g. https://<vendor>.example.co) selecting which active session to use. Optional when only one session is active.");
|
|
34771
34789
|
var schemaConfirm = external_exports.boolean().optional().describe("Must be true to proceed. Without this, the tool returns a preview.");
|
|
34772
34790
|
var paginationSchema = {
|
|
@@ -34778,6 +34796,83 @@ var pageSchema = {
|
|
|
34778
34796
|
page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
|
|
34779
34797
|
};
|
|
34780
34798
|
|
|
34799
|
+
// node_modules/@chrischall/mcp-utils/dist/session/index.js
|
|
34800
|
+
var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
34801
|
+
var TokenManager = class {
|
|
34802
|
+
accessToken;
|
|
34803
|
+
refreshToken;
|
|
34804
|
+
expiresAt;
|
|
34805
|
+
refreshFn;
|
|
34806
|
+
skewMs;
|
|
34807
|
+
inFlight;
|
|
34808
|
+
constructor(opts) {
|
|
34809
|
+
this.accessToken = opts.initial.accessToken;
|
|
34810
|
+
this.refreshToken = opts.initial.refreshToken;
|
|
34811
|
+
this.expiresAt = opts.initial.expiresAt;
|
|
34812
|
+
this.refreshFn = opts.refresh;
|
|
34813
|
+
this.skewMs = opts.skewMs ?? TOKEN_REFRESH_SKEW_MS;
|
|
34814
|
+
}
|
|
34815
|
+
/** Whether the token is within the skew window of (or past) expiry. */
|
|
34816
|
+
needsRefresh() {
|
|
34817
|
+
return Date.now() >= this.expiresAt - this.skewMs;
|
|
34818
|
+
}
|
|
34819
|
+
/**
|
|
34820
|
+
* Single-flight refresh. Concurrent callers share one in-flight promise; it is
|
|
34821
|
+
* cleared on settle (success or failure) so a subsequent refresh can proceed.
|
|
34822
|
+
*/
|
|
34823
|
+
refreshNow() {
|
|
34824
|
+
if (!this.inFlight) {
|
|
34825
|
+
const rt = this.refreshToken;
|
|
34826
|
+
if (rt === void 0) {
|
|
34827
|
+
return Promise.reject(new Error("TokenManager: cannot refresh \u2014 no refresh token is available."));
|
|
34828
|
+
}
|
|
34829
|
+
this.inFlight = (async () => {
|
|
34830
|
+
const tok = await this.refreshFn(rt);
|
|
34831
|
+
this.accessToken = tok.accessToken;
|
|
34832
|
+
if (tok.refreshToken !== void 0 && tok.refreshToken !== "") {
|
|
34833
|
+
this.refreshToken = tok.refreshToken;
|
|
34834
|
+
}
|
|
34835
|
+
this.expiresAt = tok.expiresAt;
|
|
34836
|
+
})().finally(() => {
|
|
34837
|
+
this.inFlight = void 0;
|
|
34838
|
+
});
|
|
34839
|
+
}
|
|
34840
|
+
return this.inFlight;
|
|
34841
|
+
}
|
|
34842
|
+
/** Get a valid access token, refreshing proactively inside the skew window. */
|
|
34843
|
+
async getAccessToken() {
|
|
34844
|
+
if (this.needsRefresh())
|
|
34845
|
+
await this.refreshNow();
|
|
34846
|
+
return this.accessToken;
|
|
34847
|
+
}
|
|
34848
|
+
/** Current absolute expiry (epoch ms). */
|
|
34849
|
+
getExpiresAt() {
|
|
34850
|
+
return this.expiresAt;
|
|
34851
|
+
}
|
|
34852
|
+
/**
|
|
34853
|
+
* Run an authenticated request with reactive 401-replay. `call` receives a
|
|
34854
|
+
* valid access token and returns a `Response`. On `401`, the token is
|
|
34855
|
+
* refreshed once and `call` is invoked again exactly once.
|
|
34856
|
+
*
|
|
34857
|
+
* Guarded against double-refresh: if a concurrent caller already rotated the
|
|
34858
|
+
* token while this request was in flight (single-flight settled and cleared),
|
|
34859
|
+
* a late `401` from a request sent under the OLD token does NOT trigger a
|
|
34860
|
+
* second refresh — under refresh-token rotation that would consume and
|
|
34861
|
+
* invalidate the freshly-issued refresh token. It just replays with the
|
|
34862
|
+
* current token.
|
|
34863
|
+
*/
|
|
34864
|
+
async withAuth(call) {
|
|
34865
|
+
const usedToken = await this.getAccessToken();
|
|
34866
|
+
let res = await call(usedToken);
|
|
34867
|
+
if (res.status === 401) {
|
|
34868
|
+
if (this.accessToken === usedToken)
|
|
34869
|
+
await this.refreshNow();
|
|
34870
|
+
res = await call(this.accessToken);
|
|
34871
|
+
}
|
|
34872
|
+
return res;
|
|
34873
|
+
}
|
|
34874
|
+
};
|
|
34875
|
+
|
|
34781
34876
|
// src/client.ts
|
|
34782
34877
|
import { dirname, join as join4 } from "path";
|
|
34783
34878
|
import { fileURLToPath } from "url";
|
|
@@ -34791,7 +34886,9 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
34791
34886
|
"read_local_storage",
|
|
34792
34887
|
"read_session_storage",
|
|
34793
34888
|
"capture_request_header",
|
|
34794
|
-
"
|
|
34889
|
+
"capture_redirect",
|
|
34890
|
+
"read_indexed_db",
|
|
34891
|
+
"download"
|
|
34795
34892
|
]);
|
|
34796
34893
|
|
|
34797
34894
|
// node_modules/@fetchproxy/protocol/dist/mcp-id.js
|
|
@@ -34938,6 +35035,16 @@ function assertScopeKeyArray(value, label) {
|
|
|
34938
35035
|
seen.add(k);
|
|
34939
35036
|
}
|
|
34940
35037
|
}
|
|
35038
|
+
var CAPTURE_PATH_RE = /^\/[A-Za-z0-9._~%\-/]*\*?$/;
|
|
35039
|
+
function hostMatchesAnyDomain(host, domains) {
|
|
35040
|
+
const h = host.toLowerCase();
|
|
35041
|
+
for (const d of domains) {
|
|
35042
|
+
const dom = d.toLowerCase();
|
|
35043
|
+
if (h === dom || h.endsWith("." + dom))
|
|
35044
|
+
return true;
|
|
35045
|
+
}
|
|
35046
|
+
return false;
|
|
35047
|
+
}
|
|
34941
35048
|
function assertCaptureHeadersArray(value, label) {
|
|
34942
35049
|
if (!Array.isArray(value)) {
|
|
34943
35050
|
throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
|
|
@@ -34946,14 +35053,25 @@ function assertCaptureHeadersArray(value, label) {
|
|
|
34946
35053
|
for (let i = 0; i < value.length; i++) {
|
|
34947
35054
|
const entry = value[i];
|
|
34948
35055
|
assertObject(entry, `${label}[${i}]`);
|
|
34949
|
-
if (entry.
|
|
34950
|
-
throw new ProtocolError(`${label}[${i}].
|
|
35056
|
+
if (entry.host === void 0) {
|
|
35057
|
+
throw new ProtocolError(`${label}[${i}].host: missing`);
|
|
34951
35058
|
}
|
|
34952
35059
|
if (entry.headerName === void 0) {
|
|
34953
35060
|
throw new ProtocolError(`${label}[${i}].headerName: missing`);
|
|
34954
35061
|
}
|
|
34955
|
-
if (typeof entry.
|
|
34956
|
-
throw new ProtocolError(`${label}[${i}].
|
|
35062
|
+
if (typeof entry.host !== "string") {
|
|
35063
|
+
throw new ProtocolError(`${label}[${i}].host: expected string, got ${typeof entry.host}`);
|
|
35064
|
+
}
|
|
35065
|
+
if (!HOSTNAME_RE.test(entry.host)) {
|
|
35066
|
+
throw new ProtocolError(`${label}[${i}].host: invalid hostname ${JSON.stringify(entry.host)}`);
|
|
35067
|
+
}
|
|
35068
|
+
if (entry.path !== void 0) {
|
|
35069
|
+
if (typeof entry.path !== "string") {
|
|
35070
|
+
throw new ProtocolError(`${label}[${i}].path: expected string, got ${typeof entry.path}`);
|
|
35071
|
+
}
|
|
35072
|
+
if (!CAPTURE_PATH_RE.test(entry.path)) {
|
|
35073
|
+
throw new ProtocolError(`${label}[${i}].path: must start with '/' ${JSON.stringify(entry.path)}`);
|
|
35074
|
+
}
|
|
34957
35075
|
}
|
|
34958
35076
|
if (typeof entry.headerName !== "string") {
|
|
34959
35077
|
throw new ProtocolError(`${label}[${i}].headerName: expected string, got ${typeof entry.headerName}`);
|
|
@@ -34961,19 +35079,29 @@ function assertCaptureHeadersArray(value, label) {
|
|
|
34961
35079
|
if (!HEADER_NAME_RE.test(entry.headerName)) {
|
|
34962
35080
|
throw new ProtocolError(`${label}[${i}].headerName: invalid name ${JSON.stringify(entry.headerName)}`);
|
|
34963
35081
|
}
|
|
34964
|
-
|
|
34965
|
-
const key = `${entry.
|
|
35082
|
+
const normalizedPath = entry.path ?? "/*";
|
|
35083
|
+
const key = `${entry.host}\0${normalizedPath}\0${entry.headerName}`;
|
|
34966
35084
|
if (seen.has(key)) {
|
|
34967
|
-
throw new ProtocolError(`${label}: duplicate ${JSON.stringify({
|
|
35085
|
+
throw new ProtocolError(`${label}: duplicate ${JSON.stringify({ host: entry.host, path: normalizedPath, headerName: entry.headerName })}`);
|
|
34968
35086
|
}
|
|
34969
35087
|
seen.add(key);
|
|
34970
35088
|
for (const k of Object.keys(entry)) {
|
|
34971
|
-
if (k !== "
|
|
35089
|
+
if (k !== "host" && k !== "path" && k !== "headerName") {
|
|
34972
35090
|
throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
|
|
34973
35091
|
}
|
|
34974
35092
|
}
|
|
34975
35093
|
}
|
|
34976
35094
|
}
|
|
35095
|
+
function validateCaptureHeaderDecls(value, domains, label = "captureHeaders") {
|
|
35096
|
+
assertCaptureHeadersArray(value, label);
|
|
35097
|
+
const arr = value;
|
|
35098
|
+
for (let i = 0; i < arr.length; i++) {
|
|
35099
|
+
const host = arr[i].host;
|
|
35100
|
+
if (!hostMatchesAnyDomain(host, domains)) {
|
|
35101
|
+
throw new ProtocolError(`${label}[${i}].host: ${JSON.stringify(host)} is not a declared domain or subdomain of [${domains.join(", ")}]`);
|
|
35102
|
+
}
|
|
35103
|
+
}
|
|
35104
|
+
}
|
|
34977
35105
|
function assertStoragePointersArray(value, label, declaredKeys) {
|
|
34978
35106
|
if (!Array.isArray(value)) {
|
|
34979
35107
|
throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
|
|
@@ -35061,23 +35189,6 @@ function assertIndexedDbScopesArray(value, label) {
|
|
|
35061
35189
|
}
|
|
35062
35190
|
}
|
|
35063
35191
|
}
|
|
35064
|
-
function assertCaptureUrlPattern(pattern, label) {
|
|
35065
|
-
if (!pattern.startsWith("https://")) {
|
|
35066
|
-
throw new ProtocolError(`${label}: must start with https:// (got ${JSON.stringify(pattern)})`);
|
|
35067
|
-
}
|
|
35068
|
-
const afterScheme = pattern.slice("https://".length);
|
|
35069
|
-
const slash = afterScheme.indexOf("/");
|
|
35070
|
-
const host = slash === -1 ? afterScheme : afterScheme.slice(0, slash);
|
|
35071
|
-
if (host.length === 0) {
|
|
35072
|
-
throw new ProtocolError(`${label}: missing host (got ${JSON.stringify(pattern)})`);
|
|
35073
|
-
}
|
|
35074
|
-
if (host.includes("*")) {
|
|
35075
|
-
throw new ProtocolError(`${label}: wildcards not permitted in host (got ${JSON.stringify(pattern)})`);
|
|
35076
|
-
}
|
|
35077
|
-
if (!HOSTNAME_RE.test(host)) {
|
|
35078
|
-
throw new ProtocolError(`${label}: invalid host ${JSON.stringify(host)} in ${JSON.stringify(pattern)}`);
|
|
35079
|
-
}
|
|
35080
|
-
}
|
|
35081
35192
|
function validateFrame(raw) {
|
|
35082
35193
|
assertObject(raw, "frame");
|
|
35083
35194
|
const t = raw.type;
|
|
@@ -35142,7 +35253,7 @@ function validateHello(raw) {
|
|
|
35142
35253
|
assertScopeKeyArray(raw.sessionStorageKeys, "hello.sessionStorageKeys");
|
|
35143
35254
|
}
|
|
35144
35255
|
if (raw.captureHeaders !== void 0) {
|
|
35145
|
-
|
|
35256
|
+
validateCaptureHeaderDecls(raw.captureHeaders, raw.domains, "hello.captureHeaders");
|
|
35146
35257
|
}
|
|
35147
35258
|
if (raw.indexedDbScopes !== void 0) {
|
|
35148
35259
|
assertIndexedDbScopesArray(raw.indexedDbScopes, "hello.indexedDbScopes");
|
|
@@ -35310,24 +35421,58 @@ function validateInnerRequest(raw) {
|
|
|
35310
35421
|
}
|
|
35311
35422
|
if (raw.op === "capture_request_header") {
|
|
35312
35423
|
assertObject(raw.init, "inner.init");
|
|
35313
|
-
if (raw.init.
|
|
35314
|
-
throw new ProtocolError("inner.init.
|
|
35424
|
+
if (raw.init.host === void 0) {
|
|
35425
|
+
throw new ProtocolError("inner.init.host: missing");
|
|
35315
35426
|
}
|
|
35316
35427
|
if (raw.init.headerName === void 0) {
|
|
35317
35428
|
throw new ProtocolError("inner.init.headerName: missing");
|
|
35318
35429
|
}
|
|
35319
|
-
assertString(raw.init.
|
|
35430
|
+
assertString(raw.init.host, "inner.init.host");
|
|
35431
|
+
if (!HOSTNAME_RE.test(raw.init.host)) {
|
|
35432
|
+
throw new ProtocolError(`inner.init.host: invalid hostname ${JSON.stringify(raw.init.host)}`);
|
|
35433
|
+
}
|
|
35434
|
+
if (raw.init.path !== void 0) {
|
|
35435
|
+
assertString(raw.init.path, "inner.init.path");
|
|
35436
|
+
if (!CAPTURE_PATH_RE.test(raw.init.path)) {
|
|
35437
|
+
throw new ProtocolError(`inner.init.path: must start with '/' ${JSON.stringify(raw.init.path)}`);
|
|
35438
|
+
}
|
|
35439
|
+
}
|
|
35320
35440
|
assertString(raw.init.headerName, "inner.init.headerName");
|
|
35321
35441
|
if (raw.init.timeoutMs !== void 0) {
|
|
35322
35442
|
assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
|
|
35323
35443
|
}
|
|
35324
35444
|
for (const k of Object.keys(raw.init)) {
|
|
35325
|
-
if (k !== "
|
|
35445
|
+
if (k !== "host" && k !== "path" && k !== "headerName" && k !== "timeoutMs") {
|
|
35326
35446
|
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on capture_request_header`);
|
|
35327
35447
|
}
|
|
35328
35448
|
}
|
|
35329
35449
|
return raw;
|
|
35330
35450
|
}
|
|
35451
|
+
if (raw.op === "capture_redirect") {
|
|
35452
|
+
assertObject(raw.init, "inner.init");
|
|
35453
|
+
if (raw.init.host === void 0) {
|
|
35454
|
+
throw new ProtocolError("inner.init.host: missing");
|
|
35455
|
+
}
|
|
35456
|
+
assertString(raw.init.host, "inner.init.host");
|
|
35457
|
+
if (!HOSTNAME_RE.test(raw.init.host)) {
|
|
35458
|
+
throw new ProtocolError(`inner.init.host: invalid hostname ${JSON.stringify(raw.init.host)}`);
|
|
35459
|
+
}
|
|
35460
|
+
if (raw.init.path !== void 0) {
|
|
35461
|
+
assertString(raw.init.path, "inner.init.path");
|
|
35462
|
+
if (!CAPTURE_PATH_RE.test(raw.init.path)) {
|
|
35463
|
+
throw new ProtocolError(`inner.init.path: must start with '/' ${JSON.stringify(raw.init.path)}`);
|
|
35464
|
+
}
|
|
35465
|
+
}
|
|
35466
|
+
if (raw.init.timeoutMs !== void 0) {
|
|
35467
|
+
assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
|
|
35468
|
+
}
|
|
35469
|
+
for (const k of Object.keys(raw.init)) {
|
|
35470
|
+
if (k !== "host" && k !== "path" && k !== "timeoutMs") {
|
|
35471
|
+
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on capture_redirect`);
|
|
35472
|
+
}
|
|
35473
|
+
}
|
|
35474
|
+
return raw;
|
|
35475
|
+
}
|
|
35331
35476
|
if (raw.op === "read_indexed_db") {
|
|
35332
35477
|
assertObject(raw.init, "inner.init");
|
|
35333
35478
|
if (raw.init.origin === void 0)
|
|
@@ -35353,7 +35498,32 @@ function validateInnerRequest(raw) {
|
|
|
35353
35498
|
}
|
|
35354
35499
|
return raw;
|
|
35355
35500
|
}
|
|
35356
|
-
|
|
35501
|
+
if (raw.op === "download") {
|
|
35502
|
+
assertObject(raw.init, "inner.init");
|
|
35503
|
+
if (raw.init.url === void 0) {
|
|
35504
|
+
throw new ProtocolError("inner.init.url: missing");
|
|
35505
|
+
}
|
|
35506
|
+
assertHttpUrl(raw.init.url, "inner.init.url");
|
|
35507
|
+
if (raw.init.filename !== void 0) {
|
|
35508
|
+
assertString(raw.init.filename, "inner.init.filename");
|
|
35509
|
+
if (/^([/\\]|[A-Za-z]:)/.test(raw.init.filename)) {
|
|
35510
|
+
throw new ProtocolError(`inner.init.filename: must be relative ${JSON.stringify(raw.init.filename)}`);
|
|
35511
|
+
}
|
|
35512
|
+
if (raw.init.filename.split(/[/\\]/).includes("..")) {
|
|
35513
|
+
throw new ProtocolError(`inner.init.filename: must not contain '..' ${JSON.stringify(raw.init.filename)}`);
|
|
35514
|
+
}
|
|
35515
|
+
}
|
|
35516
|
+
if (raw.init.timeoutMs !== void 0) {
|
|
35517
|
+
assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
|
|
35518
|
+
}
|
|
35519
|
+
for (const k of Object.keys(raw.init)) {
|
|
35520
|
+
if (k !== "url" && k !== "filename" && k !== "timeoutMs") {
|
|
35521
|
+
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on download`);
|
|
35522
|
+
}
|
|
35523
|
+
}
|
|
35524
|
+
return raw;
|
|
35525
|
+
}
|
|
35526
|
+
throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
|
|
35357
35527
|
}
|
|
35358
35528
|
function assertNonEmptyKeyArray(value, label) {
|
|
35359
35529
|
if (!Array.isArray(value)) {
|
|
@@ -35424,6 +35594,13 @@ function validateInnerResponse(raw) {
|
|
|
35424
35594
|
assertString(raw.value, "inner.value");
|
|
35425
35595
|
return raw;
|
|
35426
35596
|
}
|
|
35597
|
+
if (op === "capture_redirect") {
|
|
35598
|
+
if (raw.value === void 0) {
|
|
35599
|
+
throw new ProtocolError("inner.value: missing on capture_redirect response");
|
|
35600
|
+
}
|
|
35601
|
+
assertString(raw.value, "inner.value");
|
|
35602
|
+
return raw;
|
|
35603
|
+
}
|
|
35427
35604
|
if (op === "read_indexed_db") {
|
|
35428
35605
|
if (raw.values === void 0) {
|
|
35429
35606
|
throw new ProtocolError("inner.values: missing on read_indexed_db response");
|
|
@@ -35431,6 +35608,25 @@ function validateInnerResponse(raw) {
|
|
|
35431
35608
|
assertObject(raw.values, "inner.values");
|
|
35432
35609
|
return raw;
|
|
35433
35610
|
}
|
|
35611
|
+
if (op === "download") {
|
|
35612
|
+
assertObject(raw.value, "inner.value");
|
|
35613
|
+
assertString(raw.value.path, "inner.value.path");
|
|
35614
|
+
if (typeof raw.value.bytes !== "number" || !Number.isInteger(raw.value.bytes) || raw.value.bytes < 0) {
|
|
35615
|
+
throw new ProtocolError("inner.value.bytes: expected non-negative integer");
|
|
35616
|
+
}
|
|
35617
|
+
if (raw.value.mime !== void 0) {
|
|
35618
|
+
assertString(raw.value.mime, "inner.value.mime");
|
|
35619
|
+
}
|
|
35620
|
+
if (raw.value.finalUrl !== void 0) {
|
|
35621
|
+
assertString(raw.value.finalUrl, "inner.value.finalUrl");
|
|
35622
|
+
}
|
|
35623
|
+
for (const k of Object.keys(raw.value)) {
|
|
35624
|
+
if (k !== "path" && k !== "bytes" && k !== "mime" && k !== "finalUrl") {
|
|
35625
|
+
throw new ProtocolError(`inner.value: unexpected field ${JSON.stringify(k)} on download response`);
|
|
35626
|
+
}
|
|
35627
|
+
}
|
|
35628
|
+
return raw;
|
|
35629
|
+
}
|
|
35434
35630
|
throw new ProtocolError(`inner.op: unknown success-response op ${JSON.stringify(raw.op)}`);
|
|
35435
35631
|
}
|
|
35436
35632
|
if (raw.ok === false) {
|
|
@@ -35702,7 +35898,8 @@ async function buildServerHello(opts) {
|
|
|
35702
35898
|
}
|
|
35703
35899
|
if (opts.captureHeaders && opts.captureHeaders.length > 0) {
|
|
35704
35900
|
hello.captureHeaders = opts.captureHeaders.map((d) => ({
|
|
35705
|
-
|
|
35901
|
+
host: d.host,
|
|
35902
|
+
...d.path !== void 0 ? { path: d.path } : {},
|
|
35706
35903
|
headerName: d.headerName
|
|
35707
35904
|
}));
|
|
35708
35905
|
}
|
|
@@ -35940,7 +36137,9 @@ async function startHost(opts) {
|
|
|
35940
36137
|
} catch {
|
|
35941
36138
|
}
|
|
35942
36139
|
}
|
|
35943
|
-
wss.close(() =>
|
|
36140
|
+
wss.close(() => {
|
|
36141
|
+
opts.httpServer.close(() => resolve2());
|
|
36142
|
+
});
|
|
35944
36143
|
}),
|
|
35945
36144
|
sendOwnInner: async (inner) => {
|
|
35946
36145
|
const session = await ownSessionReady;
|
|
@@ -35990,6 +36189,7 @@ async function startPeer(opts) {
|
|
|
35990
36189
|
const innerListeners = [];
|
|
35991
36190
|
const renegotiateListeners = [];
|
|
35992
36191
|
const pendingPairListeners = [];
|
|
36192
|
+
const closeListeners = [];
|
|
35993
36193
|
let session = null;
|
|
35994
36194
|
let pendingPairCode = null;
|
|
35995
36195
|
let resolveFirstReady;
|
|
@@ -36039,6 +36239,7 @@ async function startPeer(opts) {
|
|
|
36039
36239
|
ws.on("message", onMessage);
|
|
36040
36240
|
ws.once("close", () => {
|
|
36041
36241
|
rejectFirstReady(new Error("peer WS closed before ready"));
|
|
36242
|
+
closeListeners.forEach((cb) => cb());
|
|
36042
36243
|
});
|
|
36043
36244
|
sessionPromise.catch(() => {
|
|
36044
36245
|
});
|
|
@@ -36061,6 +36262,9 @@ async function startPeer(opts) {
|
|
|
36061
36262
|
pendingPairListeners.push(cb);
|
|
36062
36263
|
},
|
|
36063
36264
|
pendingPairCode: () => pendingPairCode,
|
|
36265
|
+
onClose: (cb) => {
|
|
36266
|
+
closeListeners.push(cb);
|
|
36267
|
+
},
|
|
36064
36268
|
close: () => ws.close()
|
|
36065
36269
|
};
|
|
36066
36270
|
return handle;
|
|
@@ -36262,6 +36466,12 @@ var FetchproxyServer = class {
|
|
|
36262
36466
|
opts;
|
|
36263
36467
|
hostHandle = null;
|
|
36264
36468
|
peerHandle = null;
|
|
36469
|
+
// 0.13.0+: true from the start of `close()` until the next `doConnect()`.
|
|
36470
|
+
// Distinguishes an intentional shutdown (whose WS close we must ignore)
|
|
36471
|
+
// from the host process dying (which strands a peer and must trigger
|
|
36472
|
+
// re-election). The WS `close` event fires asynchronously, so this stays
|
|
36473
|
+
// latched across `close()` rather than being reset in its tail.
|
|
36474
|
+
closing = false;
|
|
36265
36475
|
nextRequestId = 1;
|
|
36266
36476
|
// 0.8.0+: process-wide freshness counters surfaced via bridgeHealth().
|
|
36267
36477
|
// Replaces the local copies every downstream MCP was rolling on top
|
|
@@ -36287,8 +36497,12 @@ var FetchproxyServer = class {
|
|
|
36287
36497
|
pendingStorage = /* @__PURE__ */ new Map();
|
|
36288
36498
|
// 0.3.0+: capture-header awaiters resolve a single string.
|
|
36289
36499
|
pendingCapture = /* @__PURE__ */ new Map();
|
|
36500
|
+
// capture_redirect awaiters resolve the captured redirect URL string.
|
|
36501
|
+
pendingRedirect = /* @__PURE__ */ new Map();
|
|
36290
36502
|
// 0.4.0+: read_indexed_db awaiters resolve a JSON-typed values map.
|
|
36291
36503
|
pendingIdb = /* @__PURE__ */ new Map();
|
|
36504
|
+
// download awaiters resolve the saved-file metadata (path + size + mime).
|
|
36505
|
+
pendingDownload = /* @__PURE__ */ new Map();
|
|
36292
36506
|
mcpId = null;
|
|
36293
36507
|
identity = null;
|
|
36294
36508
|
// 0.5.3+: in-flight role-election / handle-start promise. Set the
|
|
@@ -36334,6 +36548,14 @@ var FetchproxyServer = class {
|
|
|
36334
36548
|
}
|
|
36335
36549
|
capabilities = [...opts.capabilities];
|
|
36336
36550
|
}
|
|
36551
|
+
if (opts.captureHeaders !== void 0) {
|
|
36552
|
+
try {
|
|
36553
|
+
validateCaptureHeaderDecls(opts.captureHeaders, opts.domains);
|
|
36554
|
+
} catch (err) {
|
|
36555
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
36556
|
+
throw new Error("FetchproxyServer: invalid captureHeaders \u2014 " + message);
|
|
36557
|
+
}
|
|
36558
|
+
}
|
|
36337
36559
|
this.opts = {
|
|
36338
36560
|
port: opts.port ?? 37149,
|
|
36339
36561
|
host: opts.host ?? "127.0.0.1",
|
|
@@ -36345,7 +36567,8 @@ var FetchproxyServer = class {
|
|
|
36345
36567
|
localStorageKeys: [...opts.localStorageKeys ?? []],
|
|
36346
36568
|
sessionStorageKeys: [...opts.sessionStorageKeys ?? []],
|
|
36347
36569
|
captureHeaders: (opts.captureHeaders ?? []).map((d) => ({
|
|
36348
|
-
|
|
36570
|
+
host: d.host,
|
|
36571
|
+
...d.path !== void 0 ? { path: d.path } : {},
|
|
36349
36572
|
headerName: d.headerName
|
|
36350
36573
|
})),
|
|
36351
36574
|
indexedDbScopes: (opts.indexedDbScopes ?? []).map((d) => ({
|
|
@@ -36463,6 +36686,7 @@ var FetchproxyServer = class {
|
|
|
36463
36686
|
async doConnect() {
|
|
36464
36687
|
const identity = this.identity;
|
|
36465
36688
|
const mcpId = this.mcpId;
|
|
36689
|
+
this.closing = false;
|
|
36466
36690
|
const el = await electRole({ host: this.opts.host, port: this.opts.port });
|
|
36467
36691
|
if (el.role === "host") {
|
|
36468
36692
|
this.role = "host";
|
|
@@ -36522,6 +36746,14 @@ var FetchproxyServer = class {
|
|
|
36522
36746
|
const cb = this.opts.onPairCode;
|
|
36523
36747
|
this.peerHandle.onPendingPair((code) => cb(code));
|
|
36524
36748
|
}
|
|
36749
|
+
this.peerHandle.onClose(() => {
|
|
36750
|
+
if (this.closing || this.peerHandle === null)
|
|
36751
|
+
return;
|
|
36752
|
+
this.stopKeepalive();
|
|
36753
|
+
this.rejectAllPending();
|
|
36754
|
+
this.peerHandle = null;
|
|
36755
|
+
this.role = null;
|
|
36756
|
+
});
|
|
36525
36757
|
}
|
|
36526
36758
|
}
|
|
36527
36759
|
pairingErrorMessage(code) {
|
|
@@ -37096,12 +37328,12 @@ var FetchproxyServer = class {
|
|
|
37096
37328
|
/**
|
|
37097
37329
|
* 0.3.0+: snapshot the next outgoing request's named header. Single-
|
|
37098
37330
|
* shot: the extension registers a one-time `webRequest` listener
|
|
37099
|
-
* filtered on `
|
|
37100
|
-
* match, removes itself, and resolves with the
|
|
37101
|
-
* after `timeoutMs` (default 30s on the extension).
|
|
37331
|
+
* filtered on `https://${host}${path ?? '/*'}`, captures the named
|
|
37332
|
+
* header on the first match, removes itself, and resolves with the
|
|
37333
|
+
* value. Times out after `timeoutMs` (default 30s on the extension).
|
|
37102
37334
|
*
|
|
37103
|
-
* `(
|
|
37104
|
-
* `FetchproxyServerOpts.captureHeaders
|
|
37335
|
+
* `(host, path?, headerName)` must match a declared entry in
|
|
37336
|
+
* `FetchproxyServerOpts.captureHeaders` (omitted path ≡ `/*`).
|
|
37105
37337
|
*/
|
|
37106
37338
|
async captureRequestHeader(opts) {
|
|
37107
37339
|
if (!this.opts.capabilities.includes("capture_request_header")) {
|
|
@@ -37110,24 +37342,25 @@ var FetchproxyServer = class {
|
|
|
37110
37342
|
await this.ensureConnected();
|
|
37111
37343
|
this.throwIfPendingPair();
|
|
37112
37344
|
const decls = this.opts.captureHeaders;
|
|
37345
|
+
const normPath = (p) => p ?? "/*";
|
|
37113
37346
|
let resolved;
|
|
37114
|
-
if (opts?.
|
|
37115
|
-
const found = decls.find((d) => d.
|
|
37347
|
+
if (opts?.host !== void 0 && opts?.headerName !== void 0) {
|
|
37348
|
+
const found = decls.find((d) => d.host === opts.host && normPath(d.path) === normPath(opts.path) && d.headerName === opts.headerName);
|
|
37116
37349
|
if (!found) {
|
|
37117
|
-
throw new Error(`FetchproxyServer.captureRequestHeader: (
|
|
37350
|
+
throw new Error(`FetchproxyServer.captureRequestHeader: (host=${JSON.stringify(opts.host)}, path=${JSON.stringify(normPath(opts.path))}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
|
|
37118
37351
|
}
|
|
37119
37352
|
resolved = found;
|
|
37120
|
-
} else if (opts?.
|
|
37353
|
+
} else if (opts?.host === void 0 && opts?.headerName === void 0) {
|
|
37121
37354
|
if (decls.length === 0) {
|
|
37122
|
-
throw new Error("FetchproxyServer.captureRequestHeader: no captureHeaders declared on this server \u2014 declare at least one entry in FetchproxyServerOpts.captureHeaders, or pass {
|
|
37355
|
+
throw new Error("FetchproxyServer.captureRequestHeader: no captureHeaders declared on this server \u2014 declare at least one entry in FetchproxyServerOpts.captureHeaders, or pass {host, headerName} explicitly");
|
|
37123
37356
|
}
|
|
37124
37357
|
if (decls.length > 1) {
|
|
37125
|
-
const list = decls.map((d) => `${JSON.stringify(d.
|
|
37126
|
-
throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {
|
|
37358
|
+
const list = decls.map((d) => `${JSON.stringify(d.host)}${JSON.stringify(normPath(d.path))}/${JSON.stringify(d.headerName)}`).join(", ");
|
|
37359
|
+
throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {host, headerName} to disambiguate`);
|
|
37127
37360
|
}
|
|
37128
37361
|
resolved = decls[0];
|
|
37129
37362
|
} else {
|
|
37130
|
-
throw new Error("FetchproxyServer.captureRequestHeader: pass both
|
|
37363
|
+
throw new Error("FetchproxyServer.captureRequestHeader: pass both host AND headerName, or neither (which defaults to the single declared entry)");
|
|
37131
37364
|
}
|
|
37132
37365
|
const callOpts = { ...resolved, ...opts?.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {} };
|
|
37133
37366
|
try {
|
|
@@ -37161,7 +37394,7 @@ var FetchproxyServer = class {
|
|
|
37161
37394
|
originalError: retryErr.message,
|
|
37162
37395
|
retryAttempted: true,
|
|
37163
37396
|
op: "capture_request_header",
|
|
37164
|
-
url: resolved.
|
|
37397
|
+
url: `https://${resolved.host}${resolved.path ?? "/*"}`,
|
|
37165
37398
|
role: this.role,
|
|
37166
37399
|
port: this.opts.port
|
|
37167
37400
|
});
|
|
@@ -37172,7 +37405,7 @@ var FetchproxyServer = class {
|
|
|
37172
37405
|
originalError: err.message,
|
|
37173
37406
|
retryAttempted: false,
|
|
37174
37407
|
op: "capture_request_header",
|
|
37175
|
-
url: resolved.
|
|
37408
|
+
url: `https://${resolved.host}${resolved.path ?? "/*"}`,
|
|
37176
37409
|
role: this.role,
|
|
37177
37410
|
port: this.opts.port
|
|
37178
37411
|
});
|
|
@@ -37185,7 +37418,8 @@ var FetchproxyServer = class {
|
|
|
37185
37418
|
id,
|
|
37186
37419
|
op: "capture_request_header",
|
|
37187
37420
|
init: {
|
|
37188
|
-
|
|
37421
|
+
host: opts.host,
|
|
37422
|
+
...opts.path !== void 0 ? { path: opts.path } : {},
|
|
37189
37423
|
headerName: opts.headerName,
|
|
37190
37424
|
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
|
|
37191
37425
|
}
|
|
@@ -37200,6 +37434,181 @@ var FetchproxyServer = class {
|
|
|
37200
37434
|
}
|
|
37201
37435
|
return pending;
|
|
37202
37436
|
}
|
|
37437
|
+
/**
|
|
37438
|
+
* Snapshot the redirect target URL of the next request the browser
|
|
37439
|
+
* makes to `(host, path?)`. Single-shot: the extension registers a
|
|
37440
|
+
* one-time `chrome.webRequest.onBeforeRedirect` listener filtered on
|
|
37441
|
+
* `https://${host}${path ?? '/*'}`, captures `details.redirectUrl` on
|
|
37442
|
+
* the first match, removes itself, and resolves with the URL. Times out
|
|
37443
|
+
* after `timeoutMs` (default 30s on the extension).
|
|
37444
|
+
*
|
|
37445
|
+
* Use case: a Cloudflare-walled endpoint that 302-redirects cross-origin
|
|
37446
|
+
* to a presigned URL — a page-level fetch sees only an opaque redirect,
|
|
37447
|
+
* but `onBeforeRedirect` exposes the target. Capture is limited to the
|
|
37448
|
+
* MCP's own declared `domains`; no per-entry declared scope is required.
|
|
37449
|
+
*/
|
|
37450
|
+
async captureRedirect(opts) {
|
|
37451
|
+
if (!this.opts.capabilities.includes("capture_redirect")) {
|
|
37452
|
+
throw new Error('FetchproxyServer.captureRedirect(): MCP did not declare "capture_redirect" in capabilities');
|
|
37453
|
+
}
|
|
37454
|
+
await this.ensureConnected();
|
|
37455
|
+
this.throwIfPendingPair();
|
|
37456
|
+
try {
|
|
37457
|
+
const result = await this._captureRedirectOnce(opts);
|
|
37458
|
+
this.recordSuccess();
|
|
37459
|
+
return result;
|
|
37460
|
+
} catch (err) {
|
|
37461
|
+
const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
|
|
37462
|
+
if (!swDown) {
|
|
37463
|
+
this.recordFailure(`capture_redirect: ${err.message ?? String(err)}`);
|
|
37464
|
+
throw err;
|
|
37465
|
+
}
|
|
37466
|
+
this.lastEvictionDetectedAt = Date.now();
|
|
37467
|
+
const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
|
|
37468
|
+
if (reviveMs > 0) {
|
|
37469
|
+
this.lazyReviveAttempts += 1;
|
|
37470
|
+
await new Promise((r) => setTimeout(r, reviveMs));
|
|
37471
|
+
try {
|
|
37472
|
+
const result = await this._captureRedirectOnce(opts);
|
|
37473
|
+
this.lazyReviveSuccesses += 1;
|
|
37474
|
+
this.recordSuccess();
|
|
37475
|
+
return result;
|
|
37476
|
+
} catch (retryErr) {
|
|
37477
|
+
const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
|
|
37478
|
+
if (!stillDown) {
|
|
37479
|
+
this.recordFailure(`capture_redirect: ${retryErr.message ?? String(retryErr)}`);
|
|
37480
|
+
throw retryErr;
|
|
37481
|
+
}
|
|
37482
|
+
this.recordFailure(`capture_redirect bridge-down: ${retryErr.message}`);
|
|
37483
|
+
throw new FetchproxyBridgeDownError({
|
|
37484
|
+
originalError: retryErr.message,
|
|
37485
|
+
retryAttempted: true,
|
|
37486
|
+
op: "capture_redirect",
|
|
37487
|
+
url: `https://${opts.host}${opts.path ?? "/*"}`,
|
|
37488
|
+
role: this.role,
|
|
37489
|
+
port: this.opts.port
|
|
37490
|
+
});
|
|
37491
|
+
}
|
|
37492
|
+
}
|
|
37493
|
+
this.recordFailure(`capture_redirect bridge-down: ${err.message}`);
|
|
37494
|
+
throw new FetchproxyBridgeDownError({
|
|
37495
|
+
originalError: err.message,
|
|
37496
|
+
retryAttempted: false,
|
|
37497
|
+
op: "capture_redirect",
|
|
37498
|
+
url: `https://${opts.host}${opts.path ?? "/*"}`,
|
|
37499
|
+
role: this.role,
|
|
37500
|
+
port: this.opts.port
|
|
37501
|
+
});
|
|
37502
|
+
}
|
|
37503
|
+
}
|
|
37504
|
+
async _captureRedirectOnce(opts) {
|
|
37505
|
+
const id = this.nextRequestId++;
|
|
37506
|
+
const inner = {
|
|
37507
|
+
type: "request",
|
|
37508
|
+
id,
|
|
37509
|
+
op: "capture_redirect",
|
|
37510
|
+
init: {
|
|
37511
|
+
host: opts.host,
|
|
37512
|
+
...opts.path !== void 0 ? { path: opts.path } : {},
|
|
37513
|
+
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
|
|
37514
|
+
}
|
|
37515
|
+
};
|
|
37516
|
+
const pending = new Promise((resolve2, reject) => {
|
|
37517
|
+
this.pendingRedirect.set(id, { resolve: resolve2, reject });
|
|
37518
|
+
});
|
|
37519
|
+
if (this.hostHandle) {
|
|
37520
|
+
await this.hostHandle.sendOwnInner(inner);
|
|
37521
|
+
} else if (this.peerHandle) {
|
|
37522
|
+
await this.peerHandle.sendInner(inner);
|
|
37523
|
+
}
|
|
37524
|
+
return pending;
|
|
37525
|
+
}
|
|
37526
|
+
/**
|
|
37527
|
+
* Download `url` through the BROWSER's own network stack via
|
|
37528
|
+
* `chrome.downloads` (real cookies + TLS/JA3 fingerprint). Unlike a
|
|
37529
|
+
* page-level `fetch()` (cors mode), this clears a Cloudflare bot-challenge
|
|
37530
|
+
* on the endpoint and follows the cross-origin redirect to the final file.
|
|
37531
|
+
* Resolves the saved local file path + size; the bridge is loopback-only /
|
|
37532
|
+
* single-host, so the MCP reads the bytes from the same disk. Requires
|
|
37533
|
+
* `'download'` in capabilities and the `url` host to be a declared `domain`.
|
|
37534
|
+
*/
|
|
37535
|
+
async download(opts) {
|
|
37536
|
+
if (!this.opts.capabilities.includes("download")) {
|
|
37537
|
+
throw new Error('FetchproxyServer.download(): MCP did not declare "download" in capabilities');
|
|
37538
|
+
}
|
|
37539
|
+
assertUrlInDomains("download url", opts.url, this.opts.domains);
|
|
37540
|
+
await this.ensureConnected();
|
|
37541
|
+
this.throwIfPendingPair();
|
|
37542
|
+
try {
|
|
37543
|
+
const result = await this._downloadOnce(opts);
|
|
37544
|
+
this.recordSuccess();
|
|
37545
|
+
return result;
|
|
37546
|
+
} catch (err) {
|
|
37547
|
+
const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
|
|
37548
|
+
if (!swDown) {
|
|
37549
|
+
this.recordFailure(`download: ${err.message ?? String(err)}`);
|
|
37550
|
+
throw err;
|
|
37551
|
+
}
|
|
37552
|
+
this.lastEvictionDetectedAt = Date.now();
|
|
37553
|
+
const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
|
|
37554
|
+
if (reviveMs > 0) {
|
|
37555
|
+
this.lazyReviveAttempts += 1;
|
|
37556
|
+
await new Promise((r) => setTimeout(r, reviveMs));
|
|
37557
|
+
try {
|
|
37558
|
+
const result = await this._downloadOnce(opts);
|
|
37559
|
+
this.lazyReviveSuccesses += 1;
|
|
37560
|
+
this.recordSuccess();
|
|
37561
|
+
return result;
|
|
37562
|
+
} catch (retryErr) {
|
|
37563
|
+
const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
|
|
37564
|
+
if (!stillDown) {
|
|
37565
|
+
this.recordFailure(`download: ${retryErr.message ?? String(retryErr)}`);
|
|
37566
|
+
throw retryErr;
|
|
37567
|
+
}
|
|
37568
|
+
this.recordFailure(`download bridge-down: ${retryErr.message}`);
|
|
37569
|
+
throw new FetchproxyBridgeDownError({
|
|
37570
|
+
originalError: retryErr.message,
|
|
37571
|
+
retryAttempted: true,
|
|
37572
|
+
op: "download",
|
|
37573
|
+
url: opts.url,
|
|
37574
|
+
role: this.role,
|
|
37575
|
+
port: this.opts.port
|
|
37576
|
+
});
|
|
37577
|
+
}
|
|
37578
|
+
}
|
|
37579
|
+
this.recordFailure(`download bridge-down: ${err.message}`);
|
|
37580
|
+
throw new FetchproxyBridgeDownError({
|
|
37581
|
+
originalError: err.message,
|
|
37582
|
+
retryAttempted: false,
|
|
37583
|
+
op: "download",
|
|
37584
|
+
url: opts.url,
|
|
37585
|
+
role: this.role,
|
|
37586
|
+
port: this.opts.port
|
|
37587
|
+
});
|
|
37588
|
+
}
|
|
37589
|
+
}
|
|
37590
|
+
async _downloadOnce(opts) {
|
|
37591
|
+
const id = this.nextRequestId++;
|
|
37592
|
+
const inner = {
|
|
37593
|
+
type: "request",
|
|
37594
|
+
id,
|
|
37595
|
+
op: "download",
|
|
37596
|
+
init: {
|
|
37597
|
+
url: opts.url,
|
|
37598
|
+
...opts.filename !== void 0 ? { filename: opts.filename } : {},
|
|
37599
|
+
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
|
|
37600
|
+
}
|
|
37601
|
+
};
|
|
37602
|
+
const pending = new Promise((resolve2, reject) => {
|
|
37603
|
+
this.pendingDownload.set(id, { resolve: resolve2, reject });
|
|
37604
|
+
});
|
|
37605
|
+
if (this.hostHandle) {
|
|
37606
|
+
await this.hostHandle.sendOwnInner(inner);
|
|
37607
|
+
} else if (this.peerHandle) {
|
|
37608
|
+
await this.peerHandle.sendInner(inner);
|
|
37609
|
+
}
|
|
37610
|
+
return pending;
|
|
37611
|
+
}
|
|
37203
37612
|
/**
|
|
37204
37613
|
* 0.4.0+: read declared IndexedDB keys from the user's signed-in
|
|
37205
37614
|
* tab. Requires `'read_indexed_db'` in capabilities AND the
|
|
@@ -37347,6 +37756,20 @@ var FetchproxyServer = class {
|
|
|
37347
37756
|
}
|
|
37348
37757
|
return;
|
|
37349
37758
|
}
|
|
37759
|
+
const redirectCb = this.pendingRedirect.get(inner.id);
|
|
37760
|
+
if (redirectCb) {
|
|
37761
|
+
this.pendingRedirect.delete(inner.id);
|
|
37762
|
+
if (inner.ok) {
|
|
37763
|
+
if (inner.op === "capture_redirect" && typeof inner.value === "string") {
|
|
37764
|
+
redirectCb.resolve(inner.value);
|
|
37765
|
+
} else {
|
|
37766
|
+
redirectCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture_redirect awaiter`));
|
|
37767
|
+
}
|
|
37768
|
+
} else {
|
|
37769
|
+
redirectCb.reject(new FetchproxyProtocolError(inner.error));
|
|
37770
|
+
}
|
|
37771
|
+
return;
|
|
37772
|
+
}
|
|
37350
37773
|
const idbCb = this.pendingIdb.get(inner.id);
|
|
37351
37774
|
if (idbCb) {
|
|
37352
37775
|
this.pendingIdb.delete(inner.id);
|
|
@@ -37361,6 +37784,20 @@ var FetchproxyServer = class {
|
|
|
37361
37784
|
}
|
|
37362
37785
|
return;
|
|
37363
37786
|
}
|
|
37787
|
+
const downloadCb = this.pendingDownload.get(inner.id);
|
|
37788
|
+
if (downloadCb) {
|
|
37789
|
+
this.pendingDownload.delete(inner.id);
|
|
37790
|
+
if (inner.ok) {
|
|
37791
|
+
if (inner.op === "download" && inner.value && typeof inner.value === "object") {
|
|
37792
|
+
downloadCb.resolve({ ...inner.value });
|
|
37793
|
+
} else {
|
|
37794
|
+
downloadCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on download awaiter`));
|
|
37795
|
+
}
|
|
37796
|
+
} else {
|
|
37797
|
+
downloadCb.reject(new FetchproxyProtocolError(inner.error));
|
|
37798
|
+
}
|
|
37799
|
+
return;
|
|
37800
|
+
}
|
|
37364
37801
|
const cookiesCb = this.pendingReadCookies.get(inner.id);
|
|
37365
37802
|
if (cookiesCb) {
|
|
37366
37803
|
this.pendingReadCookies.delete(inner.id);
|
|
@@ -37403,9 +37840,15 @@ var FetchproxyServer = class {
|
|
|
37403
37840
|
for (const { reject } of this.pendingCapture.values())
|
|
37404
37841
|
reject(err);
|
|
37405
37842
|
this.pendingCapture.clear();
|
|
37843
|
+
for (const { reject } of this.pendingRedirect.values())
|
|
37844
|
+
reject(err);
|
|
37845
|
+
this.pendingRedirect.clear();
|
|
37406
37846
|
for (const { reject } of this.pendingIdb.values())
|
|
37407
37847
|
reject(err);
|
|
37408
37848
|
this.pendingIdb.clear();
|
|
37849
|
+
for (const { reject } of this.pendingDownload.values())
|
|
37850
|
+
reject(err);
|
|
37851
|
+
this.pendingDownload.clear();
|
|
37409
37852
|
}
|
|
37410
37853
|
/**
|
|
37411
37854
|
* 0.5.2+: read the current pair-pending pair code from whichever handle
|
|
@@ -37440,6 +37883,7 @@ var FetchproxyServer = class {
|
|
|
37440
37883
|
* twice in a row.
|
|
37441
37884
|
*/
|
|
37442
37885
|
async close() {
|
|
37886
|
+
this.closing = true;
|
|
37443
37887
|
this.stopKeepalive();
|
|
37444
37888
|
this.rejectAllPending();
|
|
37445
37889
|
if (this.connectingPromise) {
|
|
@@ -37567,15 +38011,11 @@ async function bootstrap(opts) {
|
|
|
37567
38011
|
const capturedHeaders = {};
|
|
37568
38012
|
for (const h of opts.declare.captureHeaders) {
|
|
37569
38013
|
if (opts.onWaiting) {
|
|
37570
|
-
|
|
37571
|
-
const url2 = new URL(h.urlPattern.replace(/\*+/g, "placeholder"));
|
|
37572
|
-
opts.onWaiting(`waiting for next request to ${url2.host} to capture ${h.headerName} \u2014 interact with the page in your browser`);
|
|
37573
|
-
} catch {
|
|
37574
|
-
opts.onWaiting(`waiting to capture ${h.headerName} \u2014 interact with the page in your browser`);
|
|
37575
|
-
}
|
|
38014
|
+
opts.onWaiting(`waiting for next request to ${h.host} to capture ${h.headerName} \u2014 interact with the page in your browser`);
|
|
37576
38015
|
}
|
|
37577
38016
|
capturedHeaders[h.headerName] = await server.captureRequestHeader({
|
|
37578
|
-
|
|
38017
|
+
host: h.host,
|
|
38018
|
+
...h.path !== void 0 ? { path: h.path } : {},
|
|
37579
38019
|
headerName: h.headerName
|
|
37580
38020
|
});
|
|
37581
38021
|
}
|
|
@@ -37697,7 +38137,7 @@ function getDefaultInlineAttachments() {
|
|
|
37697
38137
|
// package.json
|
|
37698
38138
|
var package_default = {
|
|
37699
38139
|
name: "ofw-mcp",
|
|
37700
|
-
version: "2.3.
|
|
38140
|
+
version: "2.3.2",
|
|
37701
38141
|
mcpName: "io.github.chrischall/ofw-mcp",
|
|
37702
38142
|
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
37703
38143
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -37725,8 +38165,8 @@ var package_default = {
|
|
|
37725
38165
|
"test:watch": "vitest"
|
|
37726
38166
|
},
|
|
37727
38167
|
dependencies: {
|
|
37728
|
-
"@chrischall/mcp-utils": "^0.
|
|
37729
|
-
"@fetchproxy/bootstrap": "^
|
|
38168
|
+
"@chrischall/mcp-utils": "^0.9.0",
|
|
38169
|
+
"@fetchproxy/bootstrap": "^1.3.0",
|
|
37730
38170
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
37731
38171
|
dotenv: "^17.4.2",
|
|
37732
38172
|
zod: "^4.4.3"
|
|
@@ -37835,12 +38275,39 @@ function getRequestTimeoutMs() {
|
|
|
37835
38275
|
const n = Number(raw.trim());
|
|
37836
38276
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
37837
38277
|
}
|
|
38278
|
+
var OFW_REFRESH_SENTINEL = "ofw";
|
|
37838
38279
|
var OFWClient = class {
|
|
37839
|
-
token
|
|
37840
|
-
|
|
38280
|
+
// Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
|
|
38281
|
+
// (proactive refresh inside the skew window, single-flight refresh so a burst
|
|
38282
|
+
// of concurrent callers coalesces onto ONE `resolveAuth()`, and a 401-replay
|
|
38283
|
+
// guarded against double-refresh). It is created lazily, seeded with an
|
|
38284
|
+
// already-expired placeholder token so the first request drives the refresh
|
|
38285
|
+
// callback — i.e. the original "log in on first request" behavior.
|
|
38286
|
+
tokenManager;
|
|
38287
|
+
getTokenManager() {
|
|
38288
|
+
if (!this.tokenManager) {
|
|
38289
|
+
this.tokenManager = new TokenManager({
|
|
38290
|
+
initial: { accessToken: "", refreshToken: OFW_REFRESH_SENTINEL, expiresAt: 0 },
|
|
38291
|
+
skewMs: OFW_TOKEN_EXPIRY_SKEW_MS,
|
|
38292
|
+
// Map OFW's mint/refresh onto the refresh callback. `resolveAuth()`
|
|
38293
|
+
// returns a token and a best-effort expiry; when the fetchproxy path
|
|
38294
|
+
// can't supply one we fall back to the same 6h estimate the password
|
|
38295
|
+
// path uses (the 401-replay covers a wrong guess). We re-arm the
|
|
38296
|
+
// sentinel so the manager can refresh again later.
|
|
38297
|
+
refresh: async () => {
|
|
38298
|
+
const { token, expiresAt } = await resolveAuth();
|
|
38299
|
+
return {
|
|
38300
|
+
accessToken: token,
|
|
38301
|
+
refreshToken: OFW_REFRESH_SENTINEL,
|
|
38302
|
+
expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime()
|
|
38303
|
+
};
|
|
38304
|
+
}
|
|
38305
|
+
});
|
|
38306
|
+
}
|
|
38307
|
+
return this.tokenManager;
|
|
38308
|
+
}
|
|
37841
38309
|
async request(method, path, body) {
|
|
37842
|
-
await this.
|
|
37843
|
-
const response = await this.fetchWithRetry(method, path, body, "application/json", false);
|
|
38310
|
+
const response = await this.fetchAuthed(method, path, body, "application/json");
|
|
37844
38311
|
const text = await response.text();
|
|
37845
38312
|
if (debugLogEnabled()) {
|
|
37846
38313
|
console.error(`[ofw-debug] response body: ${text || "<empty>"}`);
|
|
@@ -37849,23 +38316,45 @@ var OFWClient = class {
|
|
|
37849
38316
|
}
|
|
37850
38317
|
/** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
|
|
37851
38318
|
async requestBinary(method, path) {
|
|
37852
|
-
await this.
|
|
37853
|
-
const response = await this.fetchWithRetry(method, path, void 0, "application/octet-stream", false);
|
|
38319
|
+
const response = await this.fetchAuthed(method, path, void 0, "application/octet-stream");
|
|
37854
38320
|
return {
|
|
37855
38321
|
body: Buffer.from(await response.arrayBuffer()),
|
|
37856
38322
|
contentType: response.headers.get("content-type"),
|
|
37857
38323
|
suggestedFileName: parseContentDispositionFilename(response.headers.get("content-disposition") ?? "")
|
|
37858
38324
|
};
|
|
37859
38325
|
}
|
|
37860
|
-
//
|
|
37861
|
-
//
|
|
37862
|
-
//
|
|
37863
|
-
|
|
38326
|
+
// Authenticated fetch for both JSON and binary callers. Auth (proactive
|
|
38327
|
+
// refresh inside the skew window + one 401-replay, guarded against a
|
|
38328
|
+
// double-refresh under concurrency) is delegated to the shared TokenManager's
|
|
38329
|
+
// `withAuth`. The 429 wait-and-replay and the non-2xx → throw remain here.
|
|
38330
|
+
async fetchAuthed(method, path, body, accept) {
|
|
38331
|
+
let attempt = 0;
|
|
38332
|
+
let response = await this.getTokenManager().withAuth(
|
|
38333
|
+
(token) => this.fetchOnce(method, path, body, accept, token, attempt++ > 0)
|
|
38334
|
+
);
|
|
38335
|
+
if (response.status === 429) {
|
|
38336
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
38337
|
+
response = await this.getTokenManager().withAuth(
|
|
38338
|
+
(token) => this.fetchOnce(method, path, body, accept, token, true)
|
|
38339
|
+
);
|
|
38340
|
+
if (response.status === 429) throw new Error("Rate limited by OFW API");
|
|
38341
|
+
}
|
|
38342
|
+
if (!response.ok) {
|
|
38343
|
+
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
|
|
38344
|
+
}
|
|
38345
|
+
return response;
|
|
38346
|
+
}
|
|
38347
|
+
// A single OFW API fetch with the bearer token supplied by `withAuth`.
|
|
38348
|
+
// Carries the per-request timeout (AbortController + setTimeout so vitest
|
|
38349
|
+
// fake timers can drive it and we attach a clear error message) and the
|
|
38350
|
+
// OFW_DEBUG_LOG instrumentation. Returns the raw Response — 401/429/non-2xx
|
|
38351
|
+
// handling lives in the callers (`withAuth` and `fetchAuthed`).
|
|
38352
|
+
async fetchOnce(method, path, body, accept, token, isRetry = false) {
|
|
37864
38353
|
const isFormData = body instanceof FormData;
|
|
37865
38354
|
const headers = {
|
|
37866
38355
|
...OFW_PROTOCOL_HEADERS,
|
|
37867
38356
|
Accept: accept,
|
|
37868
|
-
Authorization: `Bearer ${
|
|
38357
|
+
Authorization: `Bearer ${token}`
|
|
37869
38358
|
};
|
|
37870
38359
|
if (body !== void 0 && !isFormData) headers["Content-Type"] = "application/json";
|
|
37871
38360
|
const url2 = `${BASE_URL}${path}`;
|
|
@@ -37907,45 +38396,8 @@ var OFWClient = class {
|
|
|
37907
38396
|
if (debugLogEnabled()) {
|
|
37908
38397
|
console.error(`[ofw-debug] \u2190 ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
|
|
37909
38398
|
}
|
|
37910
|
-
if (response.status === 401 && !isRetry) {
|
|
37911
|
-
this.token = null;
|
|
37912
|
-
this.tokenExpiry = null;
|
|
37913
|
-
await this.ensureAuthenticated();
|
|
37914
|
-
return this.fetchWithRetry(method, path, body, accept, true);
|
|
37915
|
-
}
|
|
37916
|
-
if (response.status === 429) {
|
|
37917
|
-
if (!isRetry) {
|
|
37918
|
-
await new Promise((r) => setTimeout(r, 2e3));
|
|
37919
|
-
return this.fetchWithRetry(method, path, body, accept, true);
|
|
37920
|
-
}
|
|
37921
|
-
throw new Error("Rate limited by OFW API");
|
|
37922
|
-
}
|
|
37923
|
-
if (!response.ok) {
|
|
37924
|
-
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
|
|
37925
|
-
}
|
|
37926
38399
|
return response;
|
|
37927
38400
|
}
|
|
37928
|
-
async ensureAuthenticated() {
|
|
37929
|
-
if (!this.isTokenExpiredSoon()) return;
|
|
37930
|
-
await this.login();
|
|
37931
|
-
}
|
|
37932
|
-
// Auth resolution is delegated to `./auth.ts`. This client doesn't care
|
|
37933
|
-
// whether the token came from a password POST or from a one-shot
|
|
37934
|
-
// fetchproxy session-snapshot — it just consumes the result.
|
|
37935
|
-
//
|
|
37936
|
-
// If `expiresAt` is missing (the fetchproxy path on a tab whose
|
|
37937
|
-
// browser didn't persist tokenExpiry), we fall back to the same 6h
|
|
37938
|
-
// estimate the password path uses. The 401-replay path covers us if
|
|
37939
|
-
// the estimate is wrong.
|
|
37940
|
-
async login() {
|
|
37941
|
-
const { token, expiresAt } = await resolveAuth();
|
|
37942
|
-
this.token = token;
|
|
37943
|
-
this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
|
|
37944
|
-
}
|
|
37945
|
-
isTokenExpiredSoon() {
|
|
37946
|
-
if (!this.token || !this.tokenExpiry) return true;
|
|
37947
|
-
return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
|
|
37948
|
-
}
|
|
37949
38401
|
};
|
|
37950
38402
|
var client = new OFWClient();
|
|
37951
38403
|
|
|
@@ -37992,7 +38444,7 @@ function registerUserTools(server, client2) {
|
|
|
37992
38444
|
|
|
37993
38445
|
// src/cache.ts
|
|
37994
38446
|
import { DatabaseSync } from "node:sqlite";
|
|
37995
|
-
import { mkdirSync } from "node:fs";
|
|
38447
|
+
import { mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
37996
38448
|
import { dirname as dirname2 } from "node:path";
|
|
37997
38449
|
var instance = null;
|
|
37998
38450
|
var SCHEMA_V1 = `
|
|
@@ -38055,14 +38507,23 @@ function migrate(db) {
|
|
|
38055
38507
|
"INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
38056
38508
|
).run("schema_version", "2");
|
|
38057
38509
|
}
|
|
38510
|
+
function enforceCachePermissions(dbPath) {
|
|
38511
|
+
chmodSync(dirname2(dbPath), 448);
|
|
38512
|
+
chmodSync(dbPath, 384);
|
|
38513
|
+
for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
38514
|
+
if (existsSync(sibling)) chmodSync(sibling, 384);
|
|
38515
|
+
}
|
|
38516
|
+
}
|
|
38058
38517
|
function openCache() {
|
|
38059
38518
|
if (instance) return instance;
|
|
38060
38519
|
const path = getCacheDbPath();
|
|
38061
38520
|
mkdirSync(dirname2(path), { recursive: true });
|
|
38062
38521
|
const db = new DatabaseSync(path);
|
|
38522
|
+
enforceCachePermissions(path);
|
|
38063
38523
|
db.exec("PRAGMA journal_mode = WAL");
|
|
38064
38524
|
db.exec("PRAGMA foreign_keys = ON");
|
|
38065
38525
|
migrate(db);
|
|
38526
|
+
enforceCachePermissions(path);
|
|
38066
38527
|
instance = { db };
|
|
38067
38528
|
return instance;
|
|
38068
38529
|
}
|
|
@@ -39143,7 +39604,7 @@ process.emit = function(event, ...args) {
|
|
|
39143
39604
|
};
|
|
39144
39605
|
await runMcp({
|
|
39145
39606
|
name: "ofw",
|
|
39146
|
-
version: "2.3.
|
|
39607
|
+
version: "2.3.2",
|
|
39147
39608
|
// x-release-please-version
|
|
39148
39609
|
deps: client,
|
|
39149
39610
|
tools: [
|
package/dist/cache.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, chmodSync, existsSync } from 'node:fs';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { getCacheDbPath } from './config.js';
|
|
5
5
|
let instance = null;
|
|
@@ -62,15 +62,33 @@ function migrate(db) {
|
|
|
62
62
|
db.exec(SCHEMA_V2);
|
|
63
63
|
db.prepare('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run('schema_version', '2');
|
|
64
64
|
}
|
|
65
|
+
// The cache holds full co-parenting message history — keep it private to the
|
|
66
|
+
// owning user. Modes are asserted on every open (not just creation): mkdirSync
|
|
67
|
+
// `mode` and SQLite's default file mode only apply when the path is first
|
|
68
|
+
// created, so a pre-existing dir/db keeps whatever (world-readable) mode it
|
|
69
|
+
// had. The -wal/-shm siblings appear and disappear with WAL checkpoints, hence
|
|
70
|
+
// the existence check.
|
|
71
|
+
function enforceCachePermissions(dbPath) {
|
|
72
|
+
chmodSync(dirname(dbPath), 0o700);
|
|
73
|
+
chmodSync(dbPath, 0o600);
|
|
74
|
+
for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
75
|
+
if (existsSync(sibling))
|
|
76
|
+
chmodSync(sibling, 0o600);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
65
79
|
export function openCache() {
|
|
66
80
|
if (instance)
|
|
67
81
|
return instance;
|
|
68
82
|
const path = getCacheDbPath();
|
|
69
83
|
mkdirSync(dirname(path), { recursive: true });
|
|
70
84
|
const db = new DatabaseSync(path);
|
|
85
|
+
// First pass: lock down dir + db before WAL siblings exist.
|
|
86
|
+
enforceCachePermissions(path);
|
|
71
87
|
db.exec('PRAGMA journal_mode = WAL');
|
|
72
88
|
db.exec('PRAGMA foreign_keys = ON');
|
|
73
89
|
migrate(db);
|
|
90
|
+
// Second pass: the migration writes created -wal/-shm — lock those down too.
|
|
91
|
+
enforceCachePermissions(path);
|
|
74
92
|
instance = { db };
|
|
75
93
|
return instance;
|
|
76
94
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadDotenvSafely } from '@chrischall/mcp-utils';
|
|
2
|
+
import { TokenManager } from '@chrischall/mcp-utils/session';
|
|
2
3
|
import { dirname, join } from 'path';
|
|
3
4
|
import { fileURLToPath } from 'url';
|
|
4
5
|
import { resolveAuth } from './auth.js';
|
|
@@ -51,12 +52,44 @@ function getRequestTimeoutMs() {
|
|
|
51
52
|
const n = Number(raw.trim());
|
|
52
53
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
53
54
|
}
|
|
55
|
+
// Sentinel "refresh token" handed to the shared TokenManager. OFW has no
|
|
56
|
+
// OAuth-style refresh token — every renewal re-runs the full `resolveAuth()`
|
|
57
|
+
// (password POST or fetchproxy snapshot). The TokenManager only refuses to
|
|
58
|
+
// refresh when its refresh token is `undefined`, so a non-empty placeholder
|
|
59
|
+
// keeps the single-flight refresh path live; the refresh callback ignores it.
|
|
60
|
+
const OFW_REFRESH_SENTINEL = 'ofw';
|
|
54
61
|
export class OFWClient {
|
|
55
|
-
token
|
|
56
|
-
|
|
62
|
+
// Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
|
|
63
|
+
// (proactive refresh inside the skew window, single-flight refresh so a burst
|
|
64
|
+
// of concurrent callers coalesces onto ONE `resolveAuth()`, and a 401-replay
|
|
65
|
+
// guarded against double-refresh). It is created lazily, seeded with an
|
|
66
|
+
// already-expired placeholder token so the first request drives the refresh
|
|
67
|
+
// callback — i.e. the original "log in on first request" behavior.
|
|
68
|
+
tokenManager;
|
|
69
|
+
getTokenManager() {
|
|
70
|
+
if (!this.tokenManager) {
|
|
71
|
+
this.tokenManager = new TokenManager({
|
|
72
|
+
initial: { accessToken: '', refreshToken: OFW_REFRESH_SENTINEL, expiresAt: 0 },
|
|
73
|
+
skewMs: OFW_TOKEN_EXPIRY_SKEW_MS,
|
|
74
|
+
// Map OFW's mint/refresh onto the refresh callback. `resolveAuth()`
|
|
75
|
+
// returns a token and a best-effort expiry; when the fetchproxy path
|
|
76
|
+
// can't supply one we fall back to the same 6h estimate the password
|
|
77
|
+
// path uses (the 401-replay covers a wrong guess). We re-arm the
|
|
78
|
+
// sentinel so the manager can refresh again later.
|
|
79
|
+
refresh: async () => {
|
|
80
|
+
const { token, expiresAt } = await resolveAuth();
|
|
81
|
+
return {
|
|
82
|
+
accessToken: token,
|
|
83
|
+
refreshToken: OFW_REFRESH_SENTINEL,
|
|
84
|
+
expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime(),
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return this.tokenManager;
|
|
90
|
+
}
|
|
57
91
|
async request(method, path, body) {
|
|
58
|
-
await this.
|
|
59
|
-
const response = await this.fetchWithRetry(method, path, body, 'application/json', false);
|
|
92
|
+
const response = await this.fetchAuthed(method, path, body, 'application/json');
|
|
60
93
|
const text = await response.text();
|
|
61
94
|
if (debugLogEnabled()) {
|
|
62
95
|
console.error(`[ofw-debug] response body: ${text || '<empty>'}`);
|
|
@@ -65,23 +98,45 @@ export class OFWClient {
|
|
|
65
98
|
}
|
|
66
99
|
/** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
|
|
67
100
|
async requestBinary(method, path) {
|
|
68
|
-
await this.
|
|
69
|
-
const response = await this.fetchWithRetry(method, path, undefined, 'application/octet-stream', false);
|
|
101
|
+
const response = await this.fetchAuthed(method, path, undefined, 'application/octet-stream');
|
|
70
102
|
return {
|
|
71
103
|
body: Buffer.from(await response.arrayBuffer()),
|
|
72
104
|
contentType: response.headers.get('content-type'),
|
|
73
105
|
suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? ''),
|
|
74
106
|
};
|
|
75
107
|
}
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
108
|
+
// Authenticated fetch for both JSON and binary callers. Auth (proactive
|
|
109
|
+
// refresh inside the skew window + one 401-replay, guarded against a
|
|
110
|
+
// double-refresh under concurrency) is delegated to the shared TokenManager's
|
|
111
|
+
// `withAuth`. The 429 wait-and-replay and the non-2xx → throw remain here.
|
|
112
|
+
async fetchAuthed(method, path, body, accept) {
|
|
113
|
+
// `withAuth` invokes `call` once, and again after a refresh on a 401. The
|
|
114
|
+
// second invocation is the replay — mark it `(retry)` in the debug log,
|
|
115
|
+
// preserving the prior bespoke-loop diagnostic.
|
|
116
|
+
let attempt = 0;
|
|
117
|
+
let response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, attempt++ > 0));
|
|
118
|
+
if (response.status === 429) {
|
|
119
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
120
|
+
response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, true));
|
|
121
|
+
if (response.status === 429)
|
|
122
|
+
throw new Error('Rate limited by OFW API');
|
|
123
|
+
}
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
|
|
126
|
+
}
|
|
127
|
+
return response;
|
|
128
|
+
}
|
|
129
|
+
// A single OFW API fetch with the bearer token supplied by `withAuth`.
|
|
130
|
+
// Carries the per-request timeout (AbortController + setTimeout so vitest
|
|
131
|
+
// fake timers can drive it and we attach a clear error message) and the
|
|
132
|
+
// OFW_DEBUG_LOG instrumentation. Returns the raw Response — 401/429/non-2xx
|
|
133
|
+
// handling lives in the callers (`withAuth` and `fetchAuthed`).
|
|
134
|
+
async fetchOnce(method, path, body, accept, token, isRetry = false) {
|
|
80
135
|
const isFormData = body instanceof FormData;
|
|
81
136
|
const headers = {
|
|
82
137
|
...OFW_PROTOCOL_HEADERS,
|
|
83
138
|
Accept: accept,
|
|
84
|
-
Authorization: `Bearer ${
|
|
139
|
+
Authorization: `Bearer ${token}`,
|
|
85
140
|
};
|
|
86
141
|
if (body !== undefined && !isFormData)
|
|
87
142
|
headers['Content-Type'] = 'application/json';
|
|
@@ -131,46 +186,7 @@ export class OFWClient {
|
|
|
131
186
|
if (debugLogEnabled()) {
|
|
132
187
|
console.error(`[ofw-debug] ← ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
|
|
133
188
|
}
|
|
134
|
-
if (response.status === 401 && !isRetry) {
|
|
135
|
-
this.token = null;
|
|
136
|
-
this.tokenExpiry = null;
|
|
137
|
-
await this.ensureAuthenticated();
|
|
138
|
-
return this.fetchWithRetry(method, path, body, accept, true);
|
|
139
|
-
}
|
|
140
|
-
if (response.status === 429) {
|
|
141
|
-
if (!isRetry) {
|
|
142
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
143
|
-
return this.fetchWithRetry(method, path, body, accept, true);
|
|
144
|
-
}
|
|
145
|
-
throw new Error('Rate limited by OFW API');
|
|
146
|
-
}
|
|
147
|
-
if (!response.ok) {
|
|
148
|
-
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
|
|
149
|
-
}
|
|
150
189
|
return response;
|
|
151
190
|
}
|
|
152
|
-
async ensureAuthenticated() {
|
|
153
|
-
if (!this.isTokenExpiredSoon())
|
|
154
|
-
return;
|
|
155
|
-
await this.login();
|
|
156
|
-
}
|
|
157
|
-
// Auth resolution is delegated to `./auth.ts`. This client doesn't care
|
|
158
|
-
// whether the token came from a password POST or from a one-shot
|
|
159
|
-
// fetchproxy session-snapshot — it just consumes the result.
|
|
160
|
-
//
|
|
161
|
-
// If `expiresAt` is missing (the fetchproxy path on a tab whose
|
|
162
|
-
// browser didn't persist tokenExpiry), we fall back to the same 6h
|
|
163
|
-
// estimate the password path uses. The 401-replay path covers us if
|
|
164
|
-
// the estimate is wrong.
|
|
165
|
-
async login() {
|
|
166
|
-
const { token, expiresAt } = await resolveAuth();
|
|
167
|
-
this.token = token;
|
|
168
|
-
this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
|
|
169
|
-
}
|
|
170
|
-
isTokenExpiredSoon() {
|
|
171
|
-
if (!this.token || !this.tokenExpiry)
|
|
172
|
-
return true;
|
|
173
|
-
return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
|
|
174
|
-
}
|
|
175
191
|
}
|
|
176
192
|
export const client = new OFWClient();
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { registerJournalTools } from './tools/journal.js';
|
|
|
24
24
|
// always succeeds before any credential check runs.
|
|
25
25
|
await runMcp({
|
|
26
26
|
name: 'ofw',
|
|
27
|
-
version: '2.3.
|
|
27
|
+
version: '2.3.2', // x-release-please-version
|
|
28
28
|
deps: client,
|
|
29
29
|
tools: [
|
|
30
30
|
registerUserTools,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"mcpName": "io.github.chrischall/ofw-mcp",
|
|
5
5
|
"description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"test:watch": "vitest"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@chrischall/mcp-utils": "^0.
|
|
32
|
-
"@fetchproxy/bootstrap": "^
|
|
31
|
+
"@chrischall/mcp-utils": "^0.9.0",
|
|
32
|
+
"@fetchproxy/bootstrap": "^1.3.0",
|
|
33
33
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
34
34
|
"dotenv": "^17.4.2",
|
|
35
35
|
"zod": "^4.4.3"
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/ofw-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.3.
|
|
9
|
+
"version": "2.3.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.3.
|
|
14
|
+
"version": "2.3.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|