ofw-mcp 2.3.0 → 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/dist/bundle.js CHANGED
@@ -10342,10 +10342,10 @@ var require_websocket_server = __commonJS({
10342
10342
  process.nextTick(emitClose, this);
10343
10343
  }
10344
10344
  } else {
10345
- const server2 = this._server;
10345
+ const server = this._server;
10346
10346
  this._removeListeners();
10347
10347
  this._removeListeners = this._server = null;
10348
- server2.close(() => {
10348
+ server.close(() => {
10349
10349
  emitClose(this);
10350
10350
  });
10351
10351
  }
@@ -10530,17 +10530,17 @@ var require_websocket_server = __commonJS({
10530
10530
  }
10531
10531
  };
10532
10532
  module.exports = WebSocketServer2;
10533
- function addListeners(server2, map2) {
10534
- for (const event of Object.keys(map2)) server2.on(event, map2[event]);
10533
+ function addListeners(server, map2) {
10534
+ for (const event of Object.keys(map2)) server.on(event, map2[event]);
10535
10535
  return function removeListeners() {
10536
10536
  for (const event of Object.keys(map2)) {
10537
- server2.removeListener(event, map2[event]);
10537
+ server.removeListener(event, map2[event]);
10538
10538
  }
10539
10539
  };
10540
10540
  }
10541
- function emitClose(server2) {
10542
- server2._state = CLOSED;
10543
- server2.emit("close");
10541
+ function emitClose(server) {
10542
+ server._state = CLOSED;
10543
+ server.emit("close");
10544
10544
  }
10545
10545
  function socketOnError() {
10546
10546
  this.destroy();
@@ -10559,11 +10559,11 @@ var require_websocket_server = __commonJS({
10559
10559
  ` + Object.keys(headers).map((h) => `${h}: ${headers[h]}`).join("\r\n") + "\r\n\r\n" + message
10560
10560
  );
10561
10561
  }
10562
- function abortHandshakeOrEmitwsClientError(server2, req, socket, code, message, headers) {
10563
- if (server2.listenerCount("wsClientError")) {
10562
+ function abortHandshakeOrEmitwsClientError(server, req, socket, code, message, headers) {
10563
+ if (server.listenerCount("wsClientError")) {
10564
10564
  const err = new Error(message);
10565
10565
  Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
10566
- server2.emit("wsClientError", err, socket, req);
10566
+ server.emit("wsClientError", err, socket, req);
10567
10567
  } else {
10568
10568
  abortHandshake(socket, code, message, headers);
10569
10569
  }
@@ -32189,11 +32189,11 @@ var Protocol = class {
32189
32189
  *
32190
32190
  * The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
32191
32191
  */
32192
- async connect(transport2) {
32192
+ async connect(transport) {
32193
32193
  if (this._transport) {
32194
32194
  throw new Error("Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.");
32195
32195
  }
32196
- this._transport = transport2;
32196
+ this._transport = transport;
32197
32197
  const _onclose = this.transport?.onclose;
32198
32198
  this._transport.onclose = () => {
32199
32199
  _onclose?.();
@@ -33783,8 +33783,8 @@ var McpServer = class {
33783
33783
  *
33784
33784
  * The `server` object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
33785
33785
  */
33786
- async connect(transport2) {
33787
- return await this.server.connect(transport2);
33786
+ async connect(transport) {
33787
+ return await this.server.connect(transport);
33788
33788
  }
33789
33789
  /**
33790
33790
  * Closes the connection.
@@ -34634,8 +34634,247 @@ var StdioServerTransport = class {
34634
34634
  }
34635
34635
  };
34636
34636
 
34637
+ // node_modules/@chrischall/mcp-utils/dist/server/index.js
34638
+ async function createMcpServer(opts) {
34639
+ const server = new McpServer({ name: opts.name, version: opts.version });
34640
+ if (opts.banner !== void 0) {
34641
+ console.error(opts.banner);
34642
+ }
34643
+ const deps = opts.deps;
34644
+ for (const register of opts.tools) {
34645
+ await register(server, deps);
34646
+ }
34647
+ return server;
34648
+ }
34649
+ function withGracefulShutdown(server, opts = {}) {
34650
+ const shouldExit = opts.exit ?? true;
34651
+ let shuttingDown = false;
34652
+ const handler = (signal) => {
34653
+ if (shuttingDown)
34654
+ return;
34655
+ shuttingDown = true;
34656
+ void (async () => {
34657
+ try {
34658
+ if (opts.onSignal)
34659
+ await opts.onSignal(signal);
34660
+ await server.close();
34661
+ } catch (err) {
34662
+ console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
34663
+ } finally {
34664
+ if (shouldExit)
34665
+ process.exit(0);
34666
+ }
34667
+ })();
34668
+ };
34669
+ process.on("SIGINT", () => handler("SIGINT"));
34670
+ process.on("SIGTERM", () => handler("SIGTERM"));
34671
+ }
34672
+ async function runMcp(opts) {
34673
+ const server = await createMcpServer(opts);
34674
+ const shutdown = opts.shutdown ?? true;
34675
+ if (shutdown !== false) {
34676
+ withGracefulShutdown(server, shutdown === true ? {} : shutdown);
34677
+ }
34678
+ const spec = opts.transport ?? "stdio";
34679
+ const transport = spec === "stdio" ? new StdioServerTransport() : spec;
34680
+ await server.connect(transport);
34681
+ return server;
34682
+ }
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
+
34700
+ // node_modules/@chrischall/mcp-utils/dist/response/index.js
34701
+ function textResult(data) {
34702
+ return {
34703
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
34704
+ };
34705
+ }
34706
+ function rawTextResult(text) {
34707
+ return { content: [{ type: "text", text }] };
34708
+ }
34709
+
34710
+ // node_modules/@chrischall/mcp-utils/dist/config/index.js
34711
+ import { homedir } from "node:os";
34712
+ import { isAbsolute, join, resolve } from "node:path";
34713
+ var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
34714
+ function readEnvVar(key, opts = {}) {
34715
+ const env = opts.env ?? process.env;
34716
+ const raw = env[key];
34717
+ if (typeof raw === "string") {
34718
+ const trimmed = raw.trim();
34719
+ if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
34720
+ return trimmed;
34721
+ }
34722
+ }
34723
+ return opts.default;
34724
+ }
34725
+ var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
34726
+ var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
34727
+ function parseBoolEnv(key, opts = {}) {
34728
+ const fallback = opts.default ?? false;
34729
+ const raw = readEnvVar(key, { env: opts.env });
34730
+ if (raw === void 0)
34731
+ return fallback;
34732
+ const token = raw.toLowerCase();
34733
+ if (TRUE_TOKENS.has(token))
34734
+ return true;
34735
+ if (FALSE_TOKENS.has(token))
34736
+ return false;
34737
+ return fallback;
34738
+ }
34739
+ function expandPath(p) {
34740
+ let expanded = p;
34741
+ if (p === "~") {
34742
+ expanded = homedir();
34743
+ } else if (p.startsWith("~/")) {
34744
+ expanded = join(homedir(), p.slice(2));
34745
+ }
34746
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
34747
+ }
34748
+ async function loadDotenvSafely(opts = {}) {
34749
+ try {
34750
+ const mod = await import(
34751
+ /* @vite-ignore */
34752
+ "dotenv"
34753
+ );
34754
+ const result = mod.config({
34755
+ ...opts.path !== void 0 ? { path: opts.path } : {},
34756
+ override: opts.override ?? false,
34757
+ quiet: true
34758
+ });
34759
+ return result.error === void 0;
34760
+ } catch {
34761
+ return false;
34762
+ }
34763
+ }
34764
+
34765
+ // node_modules/@chrischall/mcp-utils/dist/fs/index.js
34766
+ import { openAsBlob } from "node:fs";
34767
+ async function fileBlob(path, opts = {}) {
34768
+ let blob;
34769
+ try {
34770
+ blob = await openAsBlob(path, opts.type !== void 0 ? { type: opts.type } : void 0);
34771
+ } catch {
34772
+ throw new Error(`Cannot read file for upload: ${path}`);
34773
+ }
34774
+ if (opts.maxBytes !== void 0 && blob.size > opts.maxBytes) {
34775
+ throw new Error(`${opts.label ?? "File"} is ${blob.size} bytes, over the ${opts.maxBytes}-byte limit: ${path}`);
34776
+ }
34777
+ return blob;
34778
+ }
34779
+
34780
+ // node_modules/@chrischall/mcp-utils/dist/zod/index.js
34781
+ var PositiveInt = external_exports.number().int().positive();
34782
+ var NonNegInt = external_exports.number().int().nonnegative();
34783
+ var NonEmptyString = external_exports.string().min(1);
34784
+ var IsoDate = external_exports.iso.date();
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.');
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.");
34789
+ var schemaConfirm = external_exports.boolean().optional().describe("Must be true to proceed. Without this, the tool returns a preview.");
34790
+ var paginationSchema = {
34791
+ offset: NonNegInt.default(0).describe("Number of items to skip (0-based)."),
34792
+ limit: external_exports.number().int().min(1).max(200).default(50).describe("Maximum number of items to return (1-200).")
34793
+ };
34794
+ var pageSchema = {
34795
+ page_num: PositiveInt.default(1).describe("1-based page number."),
34796
+ page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
34797
+ };
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
+
34637
34876
  // src/client.ts
34638
- import { dirname, join as join3 } from "path";
34877
+ import { dirname, join as join4 } from "path";
34639
34878
  import { fileURLToPath } from "url";
34640
34879
 
34641
34880
  // node_modules/@fetchproxy/protocol/dist/frames.js
@@ -34647,7 +34886,9 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34647
34886
  "read_local_storage",
34648
34887
  "read_session_storage",
34649
34888
  "capture_request_header",
34650
- "read_indexed_db"
34889
+ "capture_redirect",
34890
+ "read_indexed_db",
34891
+ "download"
34651
34892
  ]);
34652
34893
 
34653
34894
  // node_modules/@fetchproxy/protocol/dist/mcp-id.js
@@ -34794,6 +35035,16 @@ function assertScopeKeyArray(value, label) {
34794
35035
  seen.add(k);
34795
35036
  }
34796
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
+ }
34797
35048
  function assertCaptureHeadersArray(value, label) {
34798
35049
  if (!Array.isArray(value)) {
34799
35050
  throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
@@ -34802,14 +35053,25 @@ function assertCaptureHeadersArray(value, label) {
34802
35053
  for (let i = 0; i < value.length; i++) {
34803
35054
  const entry = value[i];
34804
35055
  assertObject(entry, `${label}[${i}]`);
34805
- if (entry.urlPattern === void 0) {
34806
- throw new ProtocolError(`${label}[${i}].urlPattern: missing`);
35056
+ if (entry.host === void 0) {
35057
+ throw new ProtocolError(`${label}[${i}].host: missing`);
34807
35058
  }
34808
35059
  if (entry.headerName === void 0) {
34809
35060
  throw new ProtocolError(`${label}[${i}].headerName: missing`);
34810
35061
  }
34811
- if (typeof entry.urlPattern !== "string") {
34812
- throw new ProtocolError(`${label}[${i}].urlPattern: expected string, got ${typeof entry.urlPattern}`);
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
+ }
34813
35075
  }
34814
35076
  if (typeof entry.headerName !== "string") {
34815
35077
  throw new ProtocolError(`${label}[${i}].headerName: expected string, got ${typeof entry.headerName}`);
@@ -34817,19 +35079,29 @@ function assertCaptureHeadersArray(value, label) {
34817
35079
  if (!HEADER_NAME_RE.test(entry.headerName)) {
34818
35080
  throw new ProtocolError(`${label}[${i}].headerName: invalid name ${JSON.stringify(entry.headerName)}`);
34819
35081
  }
34820
- assertCaptureUrlPattern(entry.urlPattern, `${label}[${i}].urlPattern`);
34821
- const key = `${entry.urlPattern}\0${entry.headerName}`;
35082
+ const normalizedPath = entry.path ?? "/*";
35083
+ const key = `${entry.host}\0${normalizedPath}\0${entry.headerName}`;
34822
35084
  if (seen.has(key)) {
34823
- throw new ProtocolError(`${label}: duplicate ${JSON.stringify({ urlPattern: entry.urlPattern, headerName: entry.headerName })}`);
35085
+ throw new ProtocolError(`${label}: duplicate ${JSON.stringify({ host: entry.host, path: normalizedPath, headerName: entry.headerName })}`);
34824
35086
  }
34825
35087
  seen.add(key);
34826
35088
  for (const k of Object.keys(entry)) {
34827
- if (k !== "urlPattern" && k !== "headerName") {
35089
+ if (k !== "host" && k !== "path" && k !== "headerName") {
34828
35090
  throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
34829
35091
  }
34830
35092
  }
34831
35093
  }
34832
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
+ }
34833
35105
  function assertStoragePointersArray(value, label, declaredKeys) {
34834
35106
  if (!Array.isArray(value)) {
34835
35107
  throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
@@ -34917,23 +35189,6 @@ function assertIndexedDbScopesArray(value, label) {
34917
35189
  }
34918
35190
  }
34919
35191
  }
34920
- function assertCaptureUrlPattern(pattern, label) {
34921
- if (!pattern.startsWith("https://")) {
34922
- throw new ProtocolError(`${label}: must start with https:// (got ${JSON.stringify(pattern)})`);
34923
- }
34924
- const afterScheme = pattern.slice("https://".length);
34925
- const slash = afterScheme.indexOf("/");
34926
- const host = slash === -1 ? afterScheme : afterScheme.slice(0, slash);
34927
- if (host.length === 0) {
34928
- throw new ProtocolError(`${label}: missing host (got ${JSON.stringify(pattern)})`);
34929
- }
34930
- if (host.includes("*")) {
34931
- throw new ProtocolError(`${label}: wildcards not permitted in host (got ${JSON.stringify(pattern)})`);
34932
- }
34933
- if (!HOSTNAME_RE.test(host)) {
34934
- throw new ProtocolError(`${label}: invalid host ${JSON.stringify(host)} in ${JSON.stringify(pattern)}`);
34935
- }
34936
- }
34937
35192
  function validateFrame(raw) {
34938
35193
  assertObject(raw, "frame");
34939
35194
  const t = raw.type;
@@ -34998,7 +35253,7 @@ function validateHello(raw) {
34998
35253
  assertScopeKeyArray(raw.sessionStorageKeys, "hello.sessionStorageKeys");
34999
35254
  }
35000
35255
  if (raw.captureHeaders !== void 0) {
35001
- assertCaptureHeadersArray(raw.captureHeaders, "hello.captureHeaders");
35256
+ validateCaptureHeaderDecls(raw.captureHeaders, raw.domains, "hello.captureHeaders");
35002
35257
  }
35003
35258
  if (raw.indexedDbScopes !== void 0) {
35004
35259
  assertIndexedDbScopesArray(raw.indexedDbScopes, "hello.indexedDbScopes");
@@ -35166,24 +35421,58 @@ function validateInnerRequest(raw) {
35166
35421
  }
35167
35422
  if (raw.op === "capture_request_header") {
35168
35423
  assertObject(raw.init, "inner.init");
35169
- if (raw.init.urlPattern === void 0) {
35170
- throw new ProtocolError("inner.init.urlPattern: missing");
35424
+ if (raw.init.host === void 0) {
35425
+ throw new ProtocolError("inner.init.host: missing");
35171
35426
  }
35172
35427
  if (raw.init.headerName === void 0) {
35173
35428
  throw new ProtocolError("inner.init.headerName: missing");
35174
35429
  }
35175
- assertString(raw.init.urlPattern, "inner.init.urlPattern");
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
+ }
35176
35440
  assertString(raw.init.headerName, "inner.init.headerName");
35177
35441
  if (raw.init.timeoutMs !== void 0) {
35178
35442
  assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
35179
35443
  }
35180
35444
  for (const k of Object.keys(raw.init)) {
35181
- if (k !== "urlPattern" && k !== "headerName" && k !== "timeoutMs") {
35445
+ if (k !== "host" && k !== "path" && k !== "headerName" && k !== "timeoutMs") {
35182
35446
  throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on capture_request_header`);
35183
35447
  }
35184
35448
  }
35185
35449
  return raw;
35186
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
+ }
35187
35476
  if (raw.op === "read_indexed_db") {
35188
35477
  assertObject(raw.init, "inner.init");
35189
35478
  if (raw.init.origin === void 0)
@@ -35209,7 +35498,32 @@ function validateInnerRequest(raw) {
35209
35498
  }
35210
35499
  return raw;
35211
35500
  }
35212
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "read_indexed_db"; got ${JSON.stringify(raw.op)}`);
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)}`);
35213
35527
  }
35214
35528
  function assertNonEmptyKeyArray(value, label) {
35215
35529
  if (!Array.isArray(value)) {
@@ -35280,6 +35594,13 @@ function validateInnerResponse(raw) {
35280
35594
  assertString(raw.value, "inner.value");
35281
35595
  return raw;
35282
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
+ }
35283
35604
  if (op === "read_indexed_db") {
35284
35605
  if (raw.values === void 0) {
35285
35606
  throw new ProtocolError("inner.values: missing on read_indexed_db response");
@@ -35287,6 +35608,25 @@ function validateInnerResponse(raw) {
35287
35608
  assertObject(raw.values, "inner.values");
35288
35609
  return raw;
35289
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
+ }
35290
35630
  throw new ProtocolError(`inner.op: unknown success-response op ${JSON.stringify(raw.op)}`);
35291
35631
  }
35292
35632
  if (raw.ok === false) {
@@ -35494,13 +35834,13 @@ async function openEncryptedFrame(sessionKey, frame) {
35494
35834
  // node_modules/@fetchproxy/server/dist/election.js
35495
35835
  import { createServer, Server as HttpServer } from "node:http";
35496
35836
  async function electRole(opts) {
35497
- const server2 = createServer();
35837
+ const server = createServer();
35498
35838
  return new Promise((resolve2, reject) => {
35499
35839
  const onError = (e) => {
35500
- server2.removeListener("listening", onListening);
35840
+ server.removeListener("listening", onListening);
35501
35841
  if (e.code === "EADDRINUSE") {
35502
35842
  try {
35503
- server2.close();
35843
+ server.close();
35504
35844
  } catch {
35505
35845
  }
35506
35846
  resolve2({ role: "peer" });
@@ -35509,12 +35849,12 @@ async function electRole(opts) {
35509
35849
  }
35510
35850
  };
35511
35851
  const onListening = () => {
35512
- server2.removeListener("error", onError);
35513
- resolve2({ role: "host", server: server2 });
35852
+ server.removeListener("error", onError);
35853
+ resolve2({ role: "host", server });
35514
35854
  };
35515
- server2.once("error", onError);
35516
- server2.once("listening", onListening);
35517
- server2.listen(opts.port, opts.host);
35855
+ server.once("error", onError);
35856
+ server.once("listening", onListening);
35857
+ server.listen(opts.port, opts.host);
35518
35858
  });
35519
35859
  }
35520
35860
 
@@ -35558,7 +35898,8 @@ async function buildServerHello(opts) {
35558
35898
  }
35559
35899
  if (opts.captureHeaders && opts.captureHeaders.length > 0) {
35560
35900
  hello.captureHeaders = opts.captureHeaders.map((d) => ({
35561
- urlPattern: d.urlPattern,
35901
+ host: d.host,
35902
+ ...d.path !== void 0 ? { path: d.path } : {},
35562
35903
  headerName: d.headerName
35563
35904
  }));
35564
35905
  }
@@ -35796,7 +36137,9 @@ async function startHost(opts) {
35796
36137
  } catch {
35797
36138
  }
35798
36139
  }
35799
- wss.close(() => resolve2());
36140
+ wss.close(() => {
36141
+ opts.httpServer.close(() => resolve2());
36142
+ });
35800
36143
  }),
35801
36144
  sendOwnInner: async (inner) => {
35802
36145
  const session = await ownSessionReady;
@@ -35846,6 +36189,7 @@ async function startPeer(opts) {
35846
36189
  const innerListeners = [];
35847
36190
  const renegotiateListeners = [];
35848
36191
  const pendingPairListeners = [];
36192
+ const closeListeners = [];
35849
36193
  let session = null;
35850
36194
  let pendingPairCode = null;
35851
36195
  let resolveFirstReady;
@@ -35895,6 +36239,7 @@ async function startPeer(opts) {
35895
36239
  ws.on("message", onMessage);
35896
36240
  ws.once("close", () => {
35897
36241
  rejectFirstReady(new Error("peer WS closed before ready"));
36242
+ closeListeners.forEach((cb) => cb());
35898
36243
  });
35899
36244
  sessionPromise.catch(() => {
35900
36245
  });
@@ -35917,6 +36262,9 @@ async function startPeer(opts) {
35917
36262
  pendingPairListeners.push(cb);
35918
36263
  },
35919
36264
  pendingPairCode: () => pendingPairCode,
36265
+ onClose: (cb) => {
36266
+ closeListeners.push(cb);
36267
+ },
35920
36268
  close: () => ws.close()
35921
36269
  };
35922
36270
  return handle;
@@ -35924,19 +36272,19 @@ async function startPeer(opts) {
35924
36272
 
35925
36273
  // node_modules/@fetchproxy/server/dist/identity.js
35926
36274
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
35927
- import { join } from "node:path";
35928
- import { homedir } from "node:os";
36275
+ import { join as join2 } from "node:path";
36276
+ import { homedir as homedir2 } from "node:os";
35929
36277
  var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
35930
36278
  var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
35931
36279
  function defaultIdentityDir() {
35932
- return join(homedir(), ".fetchproxy", "identity");
36280
+ return join2(homedir2(), ".fetchproxy", "identity");
35933
36281
  }
35934
36282
  async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
35935
36283
  if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
35936
36284
  throw new Error(`unsafe serverName for identity file: ${JSON.stringify(serverName)}`);
35937
36285
  }
35938
36286
  const safeFile = serverName.replace(/\//g, "_");
35939
- const path = join(dir, `${safeFile}.json`);
36287
+ const path = join2(dir, `${safeFile}.json`);
35940
36288
  await mkdir(dir, { recursive: true, mode: 448 });
35941
36289
  try {
35942
36290
  const raw = await readFile(path, "utf8");
@@ -36118,6 +36466,12 @@ var FetchproxyServer = class {
36118
36466
  opts;
36119
36467
  hostHandle = null;
36120
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;
36121
36475
  nextRequestId = 1;
36122
36476
  // 0.8.0+: process-wide freshness counters surfaced via bridgeHealth().
36123
36477
  // Replaces the local copies every downstream MCP was rolling on top
@@ -36143,8 +36497,12 @@ var FetchproxyServer = class {
36143
36497
  pendingStorage = /* @__PURE__ */ new Map();
36144
36498
  // 0.3.0+: capture-header awaiters resolve a single string.
36145
36499
  pendingCapture = /* @__PURE__ */ new Map();
36500
+ // capture_redirect awaiters resolve the captured redirect URL string.
36501
+ pendingRedirect = /* @__PURE__ */ new Map();
36146
36502
  // 0.4.0+: read_indexed_db awaiters resolve a JSON-typed values map.
36147
36503
  pendingIdb = /* @__PURE__ */ new Map();
36504
+ // download awaiters resolve the saved-file metadata (path + size + mime).
36505
+ pendingDownload = /* @__PURE__ */ new Map();
36148
36506
  mcpId = null;
36149
36507
  identity = null;
36150
36508
  // 0.5.3+: in-flight role-election / handle-start promise. Set the
@@ -36190,6 +36548,14 @@ var FetchproxyServer = class {
36190
36548
  }
36191
36549
  capabilities = [...opts.capabilities];
36192
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
+ }
36193
36559
  this.opts = {
36194
36560
  port: opts.port ?? 37149,
36195
36561
  host: opts.host ?? "127.0.0.1",
@@ -36201,7 +36567,8 @@ var FetchproxyServer = class {
36201
36567
  localStorageKeys: [...opts.localStorageKeys ?? []],
36202
36568
  sessionStorageKeys: [...opts.sessionStorageKeys ?? []],
36203
36569
  captureHeaders: (opts.captureHeaders ?? []).map((d) => ({
36204
- urlPattern: d.urlPattern,
36570
+ host: d.host,
36571
+ ...d.path !== void 0 ? { path: d.path } : {},
36205
36572
  headerName: d.headerName
36206
36573
  })),
36207
36574
  indexedDbScopes: (opts.indexedDbScopes ?? []).map((d) => ({
@@ -36319,6 +36686,7 @@ var FetchproxyServer = class {
36319
36686
  async doConnect() {
36320
36687
  const identity = this.identity;
36321
36688
  const mcpId = this.mcpId;
36689
+ this.closing = false;
36322
36690
  const el = await electRole({ host: this.opts.host, port: this.opts.port });
36323
36691
  if (el.role === "host") {
36324
36692
  this.role = "host";
@@ -36378,6 +36746,14 @@ var FetchproxyServer = class {
36378
36746
  const cb = this.opts.onPairCode;
36379
36747
  this.peerHandle.onPendingPair((code) => cb(code));
36380
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
+ });
36381
36757
  }
36382
36758
  }
36383
36759
  pairingErrorMessage(code) {
@@ -36952,12 +37328,12 @@ var FetchproxyServer = class {
36952
37328
  /**
36953
37329
  * 0.3.0+: snapshot the next outgoing request's named header. Single-
36954
37330
  * shot: the extension registers a one-time `webRequest` listener
36955
- * filtered on `urlPattern`, captures the named header on the first
36956
- * match, removes itself, and resolves with the value. Times out
36957
- * 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).
36958
37334
  *
36959
- * `(urlPattern, headerName)` must exactly match a declared entry in
36960
- * `FetchproxyServerOpts.captureHeaders`.
37335
+ * `(host, path?, headerName)` must match a declared entry in
37336
+ * `FetchproxyServerOpts.captureHeaders` (omitted path ≡ `/*`).
36961
37337
  */
36962
37338
  async captureRequestHeader(opts) {
36963
37339
  if (!this.opts.capabilities.includes("capture_request_header")) {
@@ -36966,24 +37342,25 @@ var FetchproxyServer = class {
36966
37342
  await this.ensureConnected();
36967
37343
  this.throwIfPendingPair();
36968
37344
  const decls = this.opts.captureHeaders;
37345
+ const normPath = (p) => p ?? "/*";
36969
37346
  let resolved;
36970
- if (opts?.urlPattern !== void 0 && opts?.headerName !== void 0) {
36971
- const found = decls.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
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);
36972
37349
  if (!found) {
36973
- throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
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`);
36974
37351
  }
36975
37352
  resolved = found;
36976
- } else if (opts?.urlPattern === void 0 && opts?.headerName === void 0) {
37353
+ } else if (opts?.host === void 0 && opts?.headerName === void 0) {
36977
37354
  if (decls.length === 0) {
36978
- throw new Error("FetchproxyServer.captureRequestHeader: no captureHeaders declared on this server \u2014 declare at least one entry in FetchproxyServerOpts.captureHeaders, or pass {urlPattern, headerName} explicitly");
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");
36979
37356
  }
36980
37357
  if (decls.length > 1) {
36981
- const list = decls.map((d) => `${JSON.stringify(d.urlPattern)}/${JSON.stringify(d.headerName)}`).join(", ");
36982
- throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {urlPattern, headerName} to disambiguate`);
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`);
36983
37360
  }
36984
37361
  resolved = decls[0];
36985
37362
  } else {
36986
- throw new Error("FetchproxyServer.captureRequestHeader: pass both urlPattern AND headerName, or neither (which defaults to the single declared entry)");
37363
+ throw new Error("FetchproxyServer.captureRequestHeader: pass both host AND headerName, or neither (which defaults to the single declared entry)");
36987
37364
  }
36988
37365
  const callOpts = { ...resolved, ...opts?.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {} };
36989
37366
  try {
@@ -37017,7 +37394,7 @@ var FetchproxyServer = class {
37017
37394
  originalError: retryErr.message,
37018
37395
  retryAttempted: true,
37019
37396
  op: "capture_request_header",
37020
- url: resolved.urlPattern,
37397
+ url: `https://${resolved.host}${resolved.path ?? "/*"}`,
37021
37398
  role: this.role,
37022
37399
  port: this.opts.port
37023
37400
  });
@@ -37028,7 +37405,7 @@ var FetchproxyServer = class {
37028
37405
  originalError: err.message,
37029
37406
  retryAttempted: false,
37030
37407
  op: "capture_request_header",
37031
- url: resolved.urlPattern,
37408
+ url: `https://${resolved.host}${resolved.path ?? "/*"}`,
37032
37409
  role: this.role,
37033
37410
  port: this.opts.port
37034
37411
  });
@@ -37041,7 +37418,8 @@ var FetchproxyServer = class {
37041
37418
  id,
37042
37419
  op: "capture_request_header",
37043
37420
  init: {
37044
- urlPattern: opts.urlPattern,
37421
+ host: opts.host,
37422
+ ...opts.path !== void 0 ? { path: opts.path } : {},
37045
37423
  headerName: opts.headerName,
37046
37424
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
37047
37425
  }
@@ -37056,6 +37434,181 @@ var FetchproxyServer = class {
37056
37434
  }
37057
37435
  return pending;
37058
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
+ }
37059
37612
  /**
37060
37613
  * 0.4.0+: read declared IndexedDB keys from the user's signed-in
37061
37614
  * tab. Requires `'read_indexed_db'` in capabilities AND the
@@ -37203,6 +37756,20 @@ var FetchproxyServer = class {
37203
37756
  }
37204
37757
  return;
37205
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
+ }
37206
37773
  const idbCb = this.pendingIdb.get(inner.id);
37207
37774
  if (idbCb) {
37208
37775
  this.pendingIdb.delete(inner.id);
@@ -37217,6 +37784,20 @@ var FetchproxyServer = class {
37217
37784
  }
37218
37785
  return;
37219
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
+ }
37220
37801
  const cookiesCb = this.pendingReadCookies.get(inner.id);
37221
37802
  if (cookiesCb) {
37222
37803
  this.pendingReadCookies.delete(inner.id);
@@ -37259,9 +37840,15 @@ var FetchproxyServer = class {
37259
37840
  for (const { reject } of this.pendingCapture.values())
37260
37841
  reject(err);
37261
37842
  this.pendingCapture.clear();
37843
+ for (const { reject } of this.pendingRedirect.values())
37844
+ reject(err);
37845
+ this.pendingRedirect.clear();
37262
37846
  for (const { reject } of this.pendingIdb.values())
37263
37847
  reject(err);
37264
37848
  this.pendingIdb.clear();
37849
+ for (const { reject } of this.pendingDownload.values())
37850
+ reject(err);
37851
+ this.pendingDownload.clear();
37265
37852
  }
37266
37853
  /**
37267
37854
  * 0.5.2+: read the current pair-pending pair code from whichever handle
@@ -37296,6 +37883,7 @@ var FetchproxyServer = class {
37296
37883
  * twice in a row.
37297
37884
  */
37298
37885
  async close() {
37886
+ this.closing = true;
37299
37887
  this.stopKeepalive();
37300
37888
  this.rejectAllPending();
37301
37889
  if (this.connectingPromise) {
@@ -37343,7 +37931,7 @@ async function bootstrap(opts) {
37343
37931
  const sessionStorageKeys = new Set(opts.declare.sessionStorage);
37344
37932
  for (const p of sessionStoragePointers)
37345
37933
  sessionStorageKeys.add(p.storageKey);
37346
- const server2 = factory({
37934
+ const server = factory({
37347
37935
  serverName: opts.serverName,
37348
37936
  version: opts.version,
37349
37937
  domains: [...opts.domains],
@@ -37378,10 +37966,10 @@ async function bootstrap(opts) {
37378
37966
  if (opts.storageSubdomain !== void 0)
37379
37967
  storageDomainOpts.subdomain = opts.storageSubdomain;
37380
37968
  try {
37381
- await server2.listen();
37969
+ await server.listen();
37382
37970
  const cookies = {};
37383
37971
  if (opts.declare.cookies.length > 0) {
37384
- const joined = await server2.readCookies({
37972
+ const joined = await server.readCookies({
37385
37973
  keys: opts.declare.cookies,
37386
37974
  ...storageDomainOpts
37387
37975
  });
@@ -37401,7 +37989,7 @@ async function bootstrap(opts) {
37401
37989
  for (const p of localStoragePointers) {
37402
37990
  pointers[p.outputKey] = { storageKey: p.storageKey, jsonPointer: p.jsonPointer };
37403
37991
  }
37404
- localStorage = await server2.readLocalStorage({
37992
+ localStorage = await server.readLocalStorage({
37405
37993
  keys: allKeys,
37406
37994
  ...storageDomainOpts,
37407
37995
  ...localStoragePointers.length > 0 ? { pointers } : {}
@@ -37414,7 +38002,7 @@ async function bootstrap(opts) {
37414
38002
  for (const p of sessionStoragePointers) {
37415
38003
  pointers[p.outputKey] = { storageKey: p.storageKey, jsonPointer: p.jsonPointer };
37416
38004
  }
37417
- sessionStorage = await server2.readSessionStorage({
38005
+ sessionStorage = await server.readSessionStorage({
37418
38006
  keys: allKeys,
37419
38007
  ...storageDomainOpts,
37420
38008
  ...sessionStoragePointers.length > 0 ? { pointers } : {}
@@ -37423,21 +38011,17 @@ async function bootstrap(opts) {
37423
38011
  const capturedHeaders = {};
37424
38012
  for (const h of opts.declare.captureHeaders) {
37425
38013
  if (opts.onWaiting) {
37426
- try {
37427
- const url2 = new URL(h.urlPattern.replace(/\*+/g, "placeholder"));
37428
- opts.onWaiting(`waiting for next request to ${url2.host} to capture ${h.headerName} \u2014 interact with the page in your browser`);
37429
- } catch {
37430
- opts.onWaiting(`waiting to capture ${h.headerName} \u2014 interact with the page in your browser`);
37431
- }
38014
+ opts.onWaiting(`waiting for next request to ${h.host} to capture ${h.headerName} \u2014 interact with the page in your browser`);
37432
38015
  }
37433
- capturedHeaders[h.headerName] = await server2.captureRequestHeader({
37434
- urlPattern: h.urlPattern,
38016
+ capturedHeaders[h.headerName] = await server.captureRequestHeader({
38017
+ host: h.host,
38018
+ ...h.path !== void 0 ? { path: h.path } : {},
37435
38019
  headerName: h.headerName
37436
38020
  });
37437
38021
  }
37438
38022
  const indexedDbBucket = {};
37439
38023
  for (const d of indexedDb) {
37440
- const values = await server2.readIndexedDb({
38024
+ const values = await server.readIndexedDb({
37441
38025
  database: d.database,
37442
38026
  store: d.store,
37443
38027
  keys: [...d.keys],
@@ -37454,7 +38038,7 @@ async function bootstrap(opts) {
37454
38038
  };
37455
38039
  } finally {
37456
38040
  try {
37457
- await server2.close();
38041
+ await server.close();
37458
38042
  } catch {
37459
38043
  }
37460
38044
  }
@@ -37519,8 +38103,8 @@ async function loginWithPassword(username, password) {
37519
38103
 
37520
38104
  // src/config.ts
37521
38105
  import { createHash } from "node:crypto";
37522
- import { homedir as homedir2 } from "node:os";
37523
- import { join as join2 } from "node:path";
38106
+ import { homedir as homedir3 } from "node:os";
38107
+ import { join as join3 } from "node:path";
37524
38108
  function readCacheIdentity() {
37525
38109
  const explicit = process.env.OFW_CACHE_IDENTITY;
37526
38110
  if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
@@ -37531,31 +38115,29 @@ function readCacheIdentity() {
37531
38115
  function getCacheDir() {
37532
38116
  const override = process.env.OFW_CACHE_DIR;
37533
38117
  if (override && override.trim().length > 0) return override.trim();
37534
- return join2(homedir2(), ".cache", "ofw-mcp");
38118
+ return join3(homedir3(), ".cache", "ofw-mcp");
37535
38119
  }
37536
38120
  function getCacheDbPath() {
37537
38121
  const identity = readCacheIdentity();
37538
38122
  const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
37539
- return join2(getCacheDir(), `${hash2}.db`);
38123
+ return join3(getCacheDir(), `${hash2}.db`);
37540
38124
  }
37541
38125
  function getAttachmentsDir() {
37542
38126
  const override = process.env.OFW_ATTACHMENTS_DIR;
37543
38127
  if (override && override.trim().length > 0) return override.trim();
37544
- return join2(homedir2(), "Downloads", "ofw-mcp");
38128
+ return join3(homedir3(), "Downloads", "ofw-mcp");
37545
38129
  }
37546
- function parseBoolEnv(name) {
37547
- const raw = process.env[name];
37548
- if (typeof raw !== "string") return false;
37549
- return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
38130
+ function parseBoolEnv2(name) {
38131
+ return parseBoolEnv(name);
37550
38132
  }
37551
38133
  function getDefaultInlineAttachments() {
37552
- return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
38134
+ return parseBoolEnv2("OFW_INLINE_ATTACHMENTS");
37553
38135
  }
37554
38136
 
37555
38137
  // package.json
37556
38138
  var package_default = {
37557
38139
  name: "ofw-mcp",
37558
- version: "2.3.0",
38140
+ version: "2.3.2",
37559
38141
  mcpName: "io.github.chrischall/ofw-mcp",
37560
38142
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
37561
38143
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -37579,11 +38161,12 @@ var package_default = {
37579
38161
  bundle: `esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from "module"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js`,
37580
38162
  dev: "node --env-file=.env dist/index.js",
37581
38163
  test: "vitest run",
38164
+ "test:coverage": "vitest run --coverage",
37582
38165
  "test:watch": "vitest"
37583
38166
  },
37584
38167
  dependencies: {
37585
- "@fetchproxy/bootstrap": "^0.11.0",
37586
- "@fetchproxy/server": "^0.11.0",
38168
+ "@chrischall/mcp-utils": "^0.9.0",
38169
+ "@fetchproxy/bootstrap": "^1.3.0",
37587
38170
  "@modelcontextprotocol/sdk": "^1.29.0",
37588
38171
  dotenv: "^17.4.2",
37589
38172
  zod: "^4.4.3"
@@ -37599,16 +38182,10 @@ var package_default = {
37599
38182
 
37600
38183
  // src/auth.ts
37601
38184
  function readEnv(key) {
37602
- const raw = process.env[key];
37603
- if (typeof raw !== "string") return void 0;
37604
- const trimmed = raw.trim();
37605
- if (trimmed.length === 0) return void 0;
37606
- if (trimmed === "undefined" || trimmed === "null") return void 0;
37607
- if (/^\$\{[^}]*\}$/.test(trimmed)) return void 0;
37608
- return trimmed;
38185
+ return readEnvVar(key);
37609
38186
  }
37610
38187
  function fetchproxyDisabled() {
37611
- return parseBoolEnv("OFW_DISABLE_FETCHPROXY");
38188
+ return parseBoolEnv2("OFW_DISABLE_FETCHPROXY");
37612
38189
  }
37613
38190
  async function resolveAuth() {
37614
38191
  const username = readEnv("OFW_USERNAME");
@@ -37668,12 +38245,8 @@ async function resolveAuth() {
37668
38245
  }
37669
38246
 
37670
38247
  // src/client.ts
37671
- try {
37672
- const { config: config2 } = await import("dotenv");
37673
- const __dirname = dirname(fileURLToPath(import.meta.url));
37674
- config2({ path: join3(__dirname, "..", ".env"), override: false, quiet: true });
37675
- } catch {
37676
- }
38248
+ var __dirname = dirname(fileURLToPath(import.meta.url));
38249
+ await loadDotenvSafely({ path: join4(__dirname, "..", ".env") });
37677
38250
  function parseContentDispositionFilename(cd) {
37678
38251
  const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
37679
38252
  if (extMatch) {
@@ -37688,7 +38261,7 @@ function parseContentDispositionFilename(cd) {
37688
38261
  return m ? m[1] : null;
37689
38262
  }
37690
38263
  function debugLogEnabled() {
37691
- return parseBoolEnv("OFW_DEBUG_LOG");
38264
+ return parseBoolEnv2("OFW_DEBUG_LOG");
37692
38265
  }
37693
38266
  function redactHeaders(h) {
37694
38267
  const out = { ...h };
@@ -37702,12 +38275,39 @@ function getRequestTimeoutMs() {
37702
38275
  const n = Number(raw.trim());
37703
38276
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
37704
38277
  }
38278
+ var OFW_REFRESH_SENTINEL = "ofw";
37705
38279
  var OFWClient = class {
37706
- token = null;
37707
- tokenExpiry = null;
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
+ }
37708
38309
  async request(method, path, body) {
37709
- await this.ensureAuthenticated();
37710
- const response = await this.fetchWithRetry(method, path, body, "application/json", false);
38310
+ const response = await this.fetchAuthed(method, path, body, "application/json");
37711
38311
  const text = await response.text();
37712
38312
  if (debugLogEnabled()) {
37713
38313
  console.error(`[ofw-debug] response body: ${text || "<empty>"}`);
@@ -37716,23 +38316,45 @@ var OFWClient = class {
37716
38316
  }
37717
38317
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
37718
38318
  async requestBinary(method, path) {
37719
- await this.ensureAuthenticated();
37720
- 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");
37721
38320
  return {
37722
38321
  body: Buffer.from(await response.arrayBuffer()),
37723
38322
  contentType: response.headers.get("content-type"),
37724
38323
  suggestedFileName: parseContentDispositionFilename(response.headers.get("content-disposition") ?? "")
37725
38324
  };
37726
38325
  }
37727
- // Single fetch+retry scaffold for both JSON and binary callers. Handles
37728
- // 401 (re-auth and replay once), 429 (wait 2s and replay once), and
37729
- // turns any other non-2xx into a thrown Error.
37730
- async fetchWithRetry(method, path, body, accept, isRetry) {
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) {
37731
38353
  const isFormData = body instanceof FormData;
37732
38354
  const headers = {
37733
38355
  ...OFW_PROTOCOL_HEADERS,
37734
38356
  Accept: accept,
37735
- Authorization: `Bearer ${this.token}`
38357
+ Authorization: `Bearer ${token}`
37736
38358
  };
37737
38359
  if (body !== void 0 && !isFormData) headers["Content-Type"] = "application/json";
37738
38360
  const url2 = `${BASE_URL}${path}`;
@@ -37774,56 +38396,14 @@ var OFWClient = class {
37774
38396
  if (debugLogEnabled()) {
37775
38397
  console.error(`[ofw-debug] \u2190 ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
37776
38398
  }
37777
- if (response.status === 401 && !isRetry) {
37778
- this.token = null;
37779
- this.tokenExpiry = null;
37780
- await this.ensureAuthenticated();
37781
- return this.fetchWithRetry(method, path, body, accept, true);
37782
- }
37783
- if (response.status === 429) {
37784
- if (!isRetry) {
37785
- await new Promise((r) => setTimeout(r, 2e3));
37786
- return this.fetchWithRetry(method, path, body, accept, true);
37787
- }
37788
- throw new Error("Rate limited by OFW API");
37789
- }
37790
- if (!response.ok) {
37791
- throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
37792
- }
37793
38399
  return response;
37794
38400
  }
37795
- async ensureAuthenticated() {
37796
- if (!this.isTokenExpiredSoon()) return;
37797
- await this.login();
37798
- }
37799
- // Auth resolution is delegated to `./auth.ts`. This client doesn't care
37800
- // whether the token came from a password POST or from a one-shot
37801
- // fetchproxy session-snapshot — it just consumes the result.
37802
- //
37803
- // If `expiresAt` is missing (the fetchproxy path on a tab whose
37804
- // browser didn't persist tokenExpiry), we fall back to the same 6h
37805
- // estimate the password path uses. The 401-replay path covers us if
37806
- // the estimate is wrong.
37807
- async login() {
37808
- const { token, expiresAt } = await resolveAuth();
37809
- this.token = token;
37810
- this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
37811
- }
37812
- isTokenExpiredSoon() {
37813
- if (!this.token || !this.tokenExpiry) return true;
37814
- return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
37815
- }
37816
38401
  };
37817
38402
  var client = new OFWClient();
37818
38403
 
37819
38404
  // src/tools/_shared.ts
37820
- import { isAbsolute, join as join4, resolve } from "node:path";
37821
- function jsonResponse(payload) {
37822
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
37823
- }
37824
- function textResponse(text) {
37825
- return { content: [{ type: "text", text }] };
37826
- }
38405
+ var jsonResponse = textResult;
38406
+ var textResponse = rawTextResult;
37827
38407
  function mapRecipients(items) {
37828
38408
  return (items ?? []).map((r) => ({
37829
38409
  userId: r.user?.id ?? 0,
@@ -37831,10 +38411,7 @@ function mapRecipients(items) {
37831
38411
  viewedAt: r.viewed?.dateTime ?? null
37832
38412
  }));
37833
38413
  }
37834
- function expandPath(p) {
37835
- const expanded = p.startsWith("~/") ? join4(process.env.HOME ?? "", p.slice(2)) : p;
37836
- return isAbsolute(expanded) ? expanded : resolve(expanded);
37837
- }
38414
+ var expandPath2 = expandPath;
37838
38415
  async function postMessageAndRefetch(client2, payload) {
37839
38416
  const raw = await client2.request(
37840
38417
  "POST",
@@ -37848,15 +38425,15 @@ async function postMessageAndRefetch(client2, payload) {
37848
38425
  }
37849
38426
 
37850
38427
  // src/tools/user.ts
37851
- function registerUserTools(server2, client2) {
37852
- server2.registerTool("ofw_get_profile", {
38428
+ function registerUserTools(server, client2) {
38429
+ server.registerTool("ofw_get_profile", {
37853
38430
  description: "Get current user and co-parent profile information from OurFamilyWizard",
37854
38431
  annotations: { readOnlyHint: true }
37855
38432
  }, async () => {
37856
38433
  const data = await client2.request("GET", "/pub/v2/profiles");
37857
38434
  return jsonResponse(data);
37858
38435
  });
37859
- server2.registerTool("ofw_get_notifications", {
38436
+ server.registerTool("ofw_get_notifications", {
37860
38437
  description: "Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.",
37861
38438
  annotations: { readOnlyHint: false }
37862
38439
  }, async () => {
@@ -37867,7 +38444,7 @@ function registerUserTools(server2, client2) {
37867
38444
 
37868
38445
  // src/cache.ts
37869
38446
  import { DatabaseSync } from "node:sqlite";
37870
- import { mkdirSync } from "node:fs";
38447
+ import { mkdirSync, chmodSync, existsSync } from "node:fs";
37871
38448
  import { dirname as dirname2 } from "node:path";
37872
38449
  var instance = null;
37873
38450
  var SCHEMA_V1 = `
@@ -37930,14 +38507,23 @@ function migrate(db) {
37930
38507
  "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
37931
38508
  ).run("schema_version", "2");
37932
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
+ }
37933
38517
  function openCache() {
37934
38518
  if (instance) return instance;
37935
38519
  const path = getCacheDbPath();
37936
38520
  mkdirSync(dirname2(path), { recursive: true });
37937
38521
  const db = new DatabaseSync(path);
38522
+ enforceCachePermissions(path);
37938
38523
  db.exec("PRAGMA journal_mode = WAL");
37939
38524
  db.exec("PRAGMA foreign_keys = ON");
37940
38525
  migrate(db);
38526
+ enforceCachePermissions(path);
37941
38527
  instance = { db };
37942
38528
  return instance;
37943
38529
  }
@@ -38397,15 +38983,15 @@ function listDataHintsAtFiles(listData) {
38397
38983
  if (Array.isArray(ld.files)) return ld.files.length > 0;
38398
38984
  return false;
38399
38985
  }
38400
- function registerMessageTools(server2, client2) {
38401
- server2.registerTool("ofw_list_message_folders", {
38986
+ function registerMessageTools(server, client2) {
38987
+ server.registerTool("ofw_list_message_folders", {
38402
38988
  description: "List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.",
38403
38989
  annotations: { readOnlyHint: true }
38404
38990
  }, async () => {
38405
38991
  const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
38406
38992
  return jsonResponse(data);
38407
38993
  });
38408
- server2.registerTool("ofw_list_messages", {
38994
+ server.registerTool("ofw_list_messages", {
38409
38995
  description: "List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages \u2014 the cache may have 1000+ messages. Call ofw_sync_messages first if the cache is empty or stale.",
38410
38996
  annotations: { readOnlyHint: true },
38411
38997
  inputSchema: {
@@ -38441,7 +39027,7 @@ function registerMessageTools(server2, client2) {
38441
39027
  }
38442
39028
  return jsonResponse(payload);
38443
39029
  });
38444
- server2.registerTool("ofw_get_message", {
39030
+ server.registerTool("ofw_get_message", {
38445
39031
  description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) \u2014 drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
38446
39032
  annotations: { readOnlyHint: false },
38447
39033
  inputSchema: {
@@ -38506,7 +39092,7 @@ function registerMessageTools(server2, client2) {
38506
39092
  const attachments = listAttachmentsForMessage(detail.id);
38507
39093
  return jsonResponse({ ...row, attachments });
38508
39094
  });
38509
- server2.registerTool("ofw_send_message", {
39095
+ server.registerTool("ofw_send_message", {
38510
39096
  description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 subject/body/recipientIds become optional overrides (missing fields default to the draft's cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
38511
39097
  annotations: { destructiveHint: true },
38512
39098
  inputSchema: {
@@ -38616,7 +39202,7 @@ function registerMessageTools(server2, client2) {
38616
39202
 
38617
39203
  ${text}` : text);
38618
39204
  });
38619
- server2.registerTool("ofw_list_drafts", {
39205
+ server.registerTool("ofw_list_drafts", {
38620
39206
  description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
38621
39207
  annotations: { readOnlyHint: true },
38622
39208
  inputSchema: {
@@ -38630,7 +39216,7 @@ ${text}` : text);
38630
39216
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
38631
39217
  return jsonResponse(payload);
38632
39218
  });
38633
- server2.registerTool("ofw_save_draft", {
39219
+ server.registerTool("ofw_save_draft", {
38634
39220
  description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state.",
38635
39221
  annotations: { readOnlyHint: false },
38636
39222
  inputSchema: {
@@ -38692,7 +39278,7 @@ ${text}` : text);
38692
39278
 
38693
39279
  ${text}` : text);
38694
39280
  });
38695
- server2.registerTool("ofw_delete_draft", {
39281
+ server.registerTool("ofw_delete_draft", {
38696
39282
  description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
38697
39283
  annotations: { destructiveHint: true },
38698
39284
  inputSchema: {
@@ -38703,7 +39289,7 @@ ${text}` : text);
38703
39289
  deleteDraft(args.messageId);
38704
39290
  return data ? jsonResponse(data) : textResponse("Draft deleted.");
38705
39291
  });
38706
- server2.registerTool("ofw_get_unread_sent", {
39292
+ server.registerTool("ofw_get_unread_sent", {
38707
39293
  description: "List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.",
38708
39294
  annotations: { readOnlyHint: true },
38709
39295
  inputSchema: {
@@ -38729,7 +39315,7 @@ ${text}` : text);
38729
39315
  }
38730
39316
  return jsonResponse(unread);
38731
39317
  });
38732
- server2.registerTool("ofw_upload_attachment", {
39318
+ server.registerTool("ofw_upload_attachment", {
38733
39319
  description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. Returns the fileId \u2014 pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.`,
38734
39320
  annotations: { destructiveHint: false },
38735
39321
  inputSchema: {
@@ -38739,14 +39325,13 @@ ${text}` : text);
38739
39325
  description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
38740
39326
  }
38741
39327
  }, async (args) => {
38742
- const abs = expandPath(args.path);
39328
+ const abs = expandPath2(args.path);
38743
39329
  const stat = statSync(abs);
38744
39330
  if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
38745
- const buf = readFileSync(abs);
38746
39331
  const fileName = basename(abs);
38747
39332
  const mime = mimeFromName(fileName);
38748
39333
  const form = new FormData();
38749
- form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), fileName);
39334
+ form.append("file", await fileBlob(abs, { type: mime }), fileName);
38750
39335
  form.append("source", "message");
38751
39336
  form.append("description", args.description ?? fileName);
38752
39337
  form.append("label", args.label ?? fileName);
@@ -38758,7 +39343,7 @@ ${text}` : text);
38758
39343
  fileName: meta3.fileName ?? fileName,
38759
39344
  label: meta3.label ?? args.label ?? fileName,
38760
39345
  mimeType: meta3.fileType ?? mime,
38761
- sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : buf.length,
39346
+ sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : stat.size,
38762
39347
  metadata: meta3,
38763
39348
  messageId: 0
38764
39349
  });
@@ -38766,12 +39351,12 @@ ${text}` : text);
38766
39351
  fileId: meta3.fileId,
38767
39352
  fileName: meta3.fileName ?? fileName,
38768
39353
  mimeType: meta3.fileType ?? mime,
38769
- sizeBytes: meta3.sizeInBytes ?? buf.length,
39354
+ sizeBytes: meta3.sizeInBytes ?? stat.size,
38770
39355
  shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
38771
39356
  note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
38772
39357
  });
38773
39358
  });
38774
- server2.registerTool("ofw_download_attachment", {
39359
+ server.registerTool("ofw_download_attachment", {
38775
39360
  description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks \u2014 images come back as ImageContent (the model sees them directly); other files come back as an EmbeddedResource blob. Use inline for small files where you want the model to read content immediately and the host is sandboxed; use disk for large files or when you want a persistent local copy. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var (set to "true" to make inline the default). fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
38776
39361
  annotations: { readOnlyHint: false },
38777
39362
  inputSchema: {
@@ -38825,7 +39410,7 @@ ${text}` : text);
38825
39410
  let dest;
38826
39411
  if (args.saveTo) {
38827
39412
  const isDirArg = args.saveTo.endsWith("/") || args.saveTo.endsWith("\\");
38828
- const abs = expandPath(args.saveTo);
39413
+ const abs = expandPath2(args.saveTo);
38829
39414
  dest = isDirArg ? join5(abs, `${fileId}-${cached2.fileName}`) : abs;
38830
39415
  } else {
38831
39416
  dest = join5(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
@@ -38852,7 +39437,7 @@ ${text}` : text);
38852
39437
  fileName: response.suggestedFileName ?? cached2.fileName
38853
39438
  });
38854
39439
  });
38855
- server2.registerTool("ofw_sync_messages", {
39440
+ server.registerTool("ofw_sync_messages", {
38856
39441
  description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).",
38857
39442
  annotations: { readOnlyHint: false },
38858
39443
  inputSchema: {
@@ -38876,8 +39461,8 @@ async function deleteOFWMessages(client2, ids) {
38876
39461
  }
38877
39462
 
38878
39463
  // src/tools/calendar.ts
38879
- function registerCalendarTools(server2, client2) {
38880
- server2.registerTool("ofw_list_events", {
39464
+ function registerCalendarTools(server, client2) {
39465
+ server.registerTool("ofw_list_events", {
38881
39466
  description: "List OurFamilyWizard calendar events in a date range",
38882
39467
  annotations: { readOnlyHint: true },
38883
39468
  inputSchema: {
@@ -38893,7 +39478,7 @@ function registerCalendarTools(server2, client2) {
38893
39478
  );
38894
39479
  return jsonResponse(data);
38895
39480
  });
38896
- server2.registerTool("ofw_create_event", {
39481
+ server.registerTool("ofw_create_event", {
38897
39482
  description: "Create a calendar event in OurFamilyWizard",
38898
39483
  annotations: { destructiveHint: false },
38899
39484
  inputSchema: {
@@ -38913,7 +39498,7 @@ function registerCalendarTools(server2, client2) {
38913
39498
  const data = await client2.request("POST", "/pub/v1/calendar/events", args);
38914
39499
  return jsonResponse(data);
38915
39500
  });
38916
- server2.registerTool("ofw_update_event", {
39501
+ server.registerTool("ofw_update_event", {
38917
39502
  description: "Update an existing OurFamilyWizard calendar event",
38918
39503
  annotations: { destructiveHint: true },
38919
39504
  inputSchema: {
@@ -38931,7 +39516,7 @@ function registerCalendarTools(server2, client2) {
38931
39516
  const data = await client2.request("PUT", `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
38932
39517
  return jsonResponse(data);
38933
39518
  });
38934
- server2.registerTool("ofw_delete_event", {
39519
+ server.registerTool("ofw_delete_event", {
38935
39520
  description: "Delete an OurFamilyWizard calendar event",
38936
39521
  annotations: { destructiveHint: true },
38937
39522
  inputSchema: {
@@ -38944,15 +39529,15 @@ function registerCalendarTools(server2, client2) {
38944
39529
  }
38945
39530
 
38946
39531
  // src/tools/expenses.ts
38947
- function registerExpenseTools(server2, client2) {
38948
- server2.registerTool("ofw_get_expense_totals", {
39532
+ function registerExpenseTools(server, client2) {
39533
+ server.registerTool("ofw_get_expense_totals", {
38949
39534
  description: "Get OurFamilyWizard expense summary totals (owed/paid)",
38950
39535
  annotations: { readOnlyHint: true }
38951
39536
  }, async () => {
38952
39537
  const data = await client2.request("GET", "/pub/v2/expense/expenses/totals");
38953
39538
  return jsonResponse(data);
38954
39539
  });
38955
- server2.registerTool("ofw_list_expenses", {
39540
+ server.registerTool("ofw_list_expenses", {
38956
39541
  description: "List OurFamilyWizard expenses with pagination",
38957
39542
  annotations: { readOnlyHint: true },
38958
39543
  inputSchema: {
@@ -38965,7 +39550,7 @@ function registerExpenseTools(server2, client2) {
38965
39550
  const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
38966
39551
  return jsonResponse(data);
38967
39552
  });
38968
- server2.registerTool("ofw_create_expense", {
39553
+ server.registerTool("ofw_create_expense", {
38969
39554
  description: "Log a new expense in OurFamilyWizard",
38970
39555
  annotations: { destructiveHint: false },
38971
39556
  inputSchema: {
@@ -38979,8 +39564,8 @@ function registerExpenseTools(server2, client2) {
38979
39564
  }
38980
39565
 
38981
39566
  // src/tools/journal.ts
38982
- function registerJournalTools(server2, client2) {
38983
- server2.registerTool("ofw_list_journal_entries", {
39567
+ function registerJournalTools(server, client2) {
39568
+ server.registerTool("ofw_list_journal_entries", {
38984
39569
  description: "List OurFamilyWizard journal entries",
38985
39570
  annotations: { readOnlyHint: true },
38986
39571
  inputSchema: {
@@ -38993,7 +39578,7 @@ function registerJournalTools(server2, client2) {
38993
39578
  const data = await client2.request("GET", `/pub/v1/journals?start=${start}&max=${max}`);
38994
39579
  return jsonResponse(data);
38995
39580
  });
38996
- server2.registerTool("ofw_create_journal_entry", {
39581
+ server.registerTool("ofw_create_journal_entry", {
38997
39582
  description: "Create a new journal entry in OurFamilyWizard",
38998
39583
  annotations: { destructiveHint: false },
38999
39584
  inputSchema: {
@@ -39017,12 +39602,17 @@ process.emit = function(event, ...args) {
39017
39602
  }
39018
39603
  return originalEmit(event, ...args);
39019
39604
  };
39020
- var server = new McpServer({ name: "ofw", version: "2.3.0" });
39021
- registerUserTools(server, client);
39022
- registerMessageTools(server, client);
39023
- registerCalendarTools(server, client);
39024
- registerExpenseTools(server, client);
39025
- registerJournalTools(server, client);
39026
- console.error("[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.");
39027
- var transport = new StdioServerTransport();
39028
- await server.connect(transport);
39605
+ await runMcp({
39606
+ name: "ofw",
39607
+ version: "2.3.2",
39608
+ // x-release-please-version
39609
+ deps: client,
39610
+ tools: [
39611
+ registerUserTools,
39612
+ registerMessageTools,
39613
+ registerCalendarTools,
39614
+ registerExpenseTools,
39615
+ registerJournalTools
39616
+ ],
39617
+ banner: "[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion."
39618
+ });