signupgenius-mcp 1.1.4 → 1.1.6

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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for SignUpGenius — read sign-ups, slot reports, and groups; add group members.",
10
- "version": "1.1.4"
10
+ "version": "1.1.6"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "SignUpGenius",
16
16
  "source": "./",
17
17
  "description": "SignUpGenius MCP server for Claude — sign-ups, slot reports, and groups via natural language. Free or Pro accounts.",
18
- "version": "1.1.4",
18
+ "version": "1.1.6",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "signupgenius-mcp",
3
3
  "displayName": "SignUpGenius",
4
- "version": "1.1.4",
4
+ "version": "1.1.6",
5
5
  "description": "SignUpGenius MCP server for Claude — read sign-ups, slot reports, and groups; add group members. Three auth paths: Pro API key, email/password, or sign in via the fetchproxy browser extension.",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -13,7 +13,11 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
13
13
  throw Error('Dynamic require of "' + x + '" is not supported');
14
14
  });
15
15
  var __commonJS = (cb, mod) => function __require2() {
16
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
16
+ try {
17
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
18
+ } catch (e) {
19
+ throw mod = 0, e;
20
+ }
17
21
  };
18
22
  var __export = (target, all) => {
19
23
  for (var name in all)
@@ -34697,6 +34701,32 @@ var McpToolError = class extends Error {
34697
34701
  Object.setPrototypeOf(this, new.target.prototype);
34698
34702
  }
34699
34703
  };
34704
+ var UnreachableError = class extends McpToolError {
34705
+ /** Upstream HTTP status, when one was observed. */
34706
+ status;
34707
+ constructor(service, status) {
34708
+ const suffix = status !== void 0 ? ` (status ${status})` : "";
34709
+ super(`${service} unreachable${suffix}. The service may be down \u2014 try again later.`, {
34710
+ hint: "The upstream service is temporarily unavailable; retry later."
34711
+ });
34712
+ this.name = "UnreachableError";
34713
+ if (status !== void 0)
34714
+ this.status = status;
34715
+ }
34716
+ };
34717
+ var ModeMismatchError = class extends McpToolError {
34718
+ currentMode;
34719
+ requiredMode;
34720
+ feature;
34721
+ constructor(currentMode, requiredMode, feature) {
34722
+ const hint = `Switch to ${requiredMode} mode to use ${feature}.`;
34723
+ super(`${feature} requires ${requiredMode} mode but the server is running in ${currentMode} mode. ${hint}`, { hint });
34724
+ this.currentMode = currentMode;
34725
+ this.requiredMode = requiredMode;
34726
+ this.feature = feature;
34727
+ this.name = "ModeMismatchError";
34728
+ }
34729
+ };
34700
34730
  function createHelpfulError(message, opts) {
34701
34731
  return new McpToolError(message, opts);
34702
34732
  }
@@ -34714,6 +34744,9 @@ var API_KEY_RE = new RegExp([
34714
34744
  "whsec_[A-Za-z0-9]{16,}\\b"
34715
34745
  // webhook signing secret (Stripe-style)
34716
34746
  ].map((p) => `\\b${p}`).join("|"), "g");
34747
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
34748
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
34749
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
34717
34750
 
34718
34751
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
34719
34752
  function textResult(data) {
@@ -35860,10 +35893,20 @@ async function openEncryptedFrame(sessionKey, frame) {
35860
35893
 
35861
35894
  // node_modules/@fetchproxy/server/dist/election.js
35862
35895
  import { createServer, Server as HttpServer } from "node:http";
35896
+ var DEFAULT_BIND_TIMEOUT_MS = 5e3;
35863
35897
  async function electRole(opts) {
35864
35898
  const server = createServer();
35899
+ const bindTimeoutMs = opts.bindTimeoutMs ?? DEFAULT_BIND_TIMEOUT_MS;
35865
35900
  return new Promise((resolve, reject) => {
35901
+ let timer;
35902
+ const clearBindTimer = () => {
35903
+ if (timer) {
35904
+ clearTimeout(timer);
35905
+ timer = void 0;
35906
+ }
35907
+ };
35866
35908
  const onError = (e) => {
35909
+ clearBindTimer();
35867
35910
  server.removeListener("listening", onListening);
35868
35911
  if (e.code === "EADDRINUSE") {
35869
35912
  try {
@@ -35876,11 +35919,25 @@ async function electRole(opts) {
35876
35919
  }
35877
35920
  };
35878
35921
  const onListening = () => {
35922
+ clearBindTimer();
35879
35923
  server.removeListener("error", onError);
35880
35924
  resolve({ role: "host", server });
35881
35925
  };
35882
35926
  server.once("error", onError);
35883
35927
  server.once("listening", onListening);
35928
+ if (bindTimeoutMs > 0) {
35929
+ timer = setTimeout(() => {
35930
+ server.removeListener("error", onError);
35931
+ server.removeListener("listening", onListening);
35932
+ try {
35933
+ server.close();
35934
+ } catch {
35935
+ }
35936
+ reject(new Error(`fetchproxy: bind to ${opts.host}:${opts.port} timed out after ${bindTimeoutMs}ms (port may be in a bad state)`));
35937
+ }, bindTimeoutMs);
35938
+ if (typeof timer.unref === "function")
35939
+ timer.unref();
35940
+ }
35884
35941
  server.listen(opts.port, opts.host);
35885
35942
  });
35886
35943
  }
@@ -35973,6 +36030,44 @@ var SessionState = class {
35973
36030
  }
35974
36031
  };
35975
36032
 
36033
+ // node_modules/@fetchproxy/server/dist/session-ready.js
36034
+ var SESSION_READY_TIMEOUT_MS = 3e4;
36035
+ var FetchproxySessionNotReadyError = class extends Error {
36036
+ reason;
36037
+ pairCode;
36038
+ mcpId;
36039
+ hint;
36040
+ constructor(info) {
36041
+ const pairing = info.pairCode !== null && info.pairCode !== "";
36042
+ const hint = pairing ? `Open the Transporter extension popup and approve pair code ${info.pairCode} for "${info.mcpId}", then retry.` : `The extension is connected but hasn't confirmed a session for "${info.mcpId}" \u2014 sign in to the target site in that browser (and approve the requested scope if it changed), then retry.`;
36043
+ super(`fetchproxy: ${pairing ? "pairing not yet approved" : "no confirmed browser session"} for "${info.mcpId}". ${hint}`);
36044
+ this.name = "FetchproxySessionNotReadyError";
36045
+ this.reason = pairing ? "pair-required" : "not-ready";
36046
+ this.pairCode = pairing ? info.pairCode : null;
36047
+ this.mcpId = info.mcpId;
36048
+ this.hint = hint;
36049
+ Object.setPrototypeOf(this, new.target.prototype);
36050
+ }
36051
+ };
36052
+ async function awaitSessionReady(ready, opts) {
36053
+ const ms = opts.timeoutMs ?? SESSION_READY_TIMEOUT_MS;
36054
+ if (ms <= 0)
36055
+ return ready;
36056
+ let timer;
36057
+ const timeout = new Promise((_, reject) => {
36058
+ timer = setTimeout(() => {
36059
+ reject(new FetchproxySessionNotReadyError({ mcpId: opts.mcpId, pairCode: opts.pendingPairCode() }));
36060
+ }, ms);
36061
+ timer.unref?.();
36062
+ });
36063
+ try {
36064
+ return await Promise.race([ready, timeout]);
36065
+ } finally {
36066
+ if (timer)
36067
+ clearTimeout(timer);
36068
+ }
36069
+ }
36070
+
35976
36071
  // node_modules/@fetchproxy/server/dist/host.js
35977
36072
  var PUBLIC_ORIGIN_RE = /^https?:\/\/(?!(127\.0\.0\.1|localhost)(:|$))/i;
35978
36073
  var enc2 = new TextEncoder();
@@ -36060,6 +36155,29 @@ async function startHost(opts) {
36060
36155
  return;
36061
36156
  }
36062
36157
  if (frame.type === "hello" && frame.role === "server") {
36158
+ const peerEdPub = fromB64(frame.identityEd25519Pub);
36159
+ const peerSigMsg = concatBytes(enc2.encode(frame.mcpId), fromB64(frame.sessionNonce));
36160
+ const peerSig = fromB64(frame.sessionSig);
36161
+ let peerSigOk = false;
36162
+ try {
36163
+ peerSigOk = await ed25519Verify(peerEdPub, peerSigMsg, peerSig);
36164
+ } catch {
36165
+ peerSigOk = false;
36166
+ }
36167
+ if (!peerSigOk) {
36168
+ console.warn("[fetchproxy] peer hello signature invalid \u2014 refusing registration (possible squatter)");
36169
+ ws.close(1008, "peer hello signature invalid");
36170
+ return;
36171
+ }
36172
+ const existing = peers.get(frame.mcpId);
36173
+ if (existing && existing.ws !== ws) {
36174
+ const existingEdPub = existing.helloFrame.identityEd25519Pub;
36175
+ if (existingEdPub !== frame.identityEd25519Pub) {
36176
+ console.warn("[fetchproxy] peer mcpId already mapped to a different identity \u2014 refusing (mcpId squatting)");
36177
+ ws.close(1008, "mcpId already registered to another identity");
36178
+ return;
36179
+ }
36180
+ }
36063
36181
  identified = "peer";
36064
36182
  peerMcpId = frame.mcpId;
36065
36183
  peers.set(frame.mcpId, { ws, helloFrame: frame });
@@ -36152,8 +36270,10 @@ async function startHost(opts) {
36152
36270
  resetSessionPromise();
36153
36271
  disconnectListeners.forEach((cb) => cb());
36154
36272
  }
36155
- if (identified === "peer" && peerMcpId)
36156
- peers.delete(peerMcpId);
36273
+ if (identified === "peer" && peerMcpId) {
36274
+ if (peers.get(peerMcpId)?.ws === ws)
36275
+ peers.delete(peerMcpId);
36276
+ }
36157
36277
  });
36158
36278
  });
36159
36279
  return {
@@ -36169,7 +36289,10 @@ async function startHost(opts) {
36169
36289
  });
36170
36290
  }),
36171
36291
  sendOwnInner: async (inner) => {
36172
- const session = await ownSessionReady;
36292
+ const session = await awaitSessionReady(ownSessionReady, {
36293
+ mcpId: opts.ownMcpId,
36294
+ pendingPairCode: () => ownPendingPairCode
36295
+ });
36173
36296
  if (!extensionWs)
36174
36297
  throw new Error("host: no extension connected");
36175
36298
  const sealed = await sealInnerFrame(session.sessionKey, opts.ownMcpId, session.nextOutboundSeq(), inner);
@@ -36274,7 +36397,10 @@ async function startPeer(opts) {
36274
36397
  ws,
36275
36398
  session: sessionPromise,
36276
36399
  sendInner: async (inner) => {
36277
- await sessionPromise;
36400
+ await awaitSessionReady(sessionPromise, {
36401
+ mcpId: opts.mcpId,
36402
+ pendingPairCode: () => pendingPairCode
36403
+ });
36278
36404
  const s = session;
36279
36405
  const sealed = await sealInnerFrame(s.sessionKey, opts.mcpId, s.nextOutboundSeq(), inner);
36280
36406
  ws.send(JSON.stringify(sealed));
@@ -36940,6 +37066,35 @@ var FetchproxyServer = class {
36940
37066
  this.keepAliveTimer = null;
36941
37067
  }
36942
37068
  }
37069
+ /**
37070
+ * Send an inner request frame via whichever bridge handle is active. If the
37071
+ * send throws (e.g. `FetchproxySessionNotReadyError` — the session never
37072
+ * confirmed), the frame never reached the bridge, so no reply will arrive:
37073
+ * drop the just-registered pending resolver for this id (it lives in exactly
37074
+ * one of the op maps — request ids are unique) so it doesn't leak until the
37075
+ * server closes, then rethrow.
37076
+ */
37077
+ async sendInnerFrame(inner) {
37078
+ try {
37079
+ if (this.hostHandle) {
37080
+ await this.hostHandle.sendOwnInner(inner);
37081
+ } else if (this.peerHandle) {
37082
+ await this.peerHandle.sendInner(inner);
37083
+ }
37084
+ } catch (err) {
37085
+ if ("id" in inner && typeof inner.id === "number") {
37086
+ const { id } = inner;
37087
+ this.pending.delete(id);
37088
+ this.pendingReadCookies.delete(id);
37089
+ this.pendingStorage.delete(id);
37090
+ this.pendingCapture.delete(id);
37091
+ this.pendingRedirect.delete(id);
37092
+ this.pendingDownload.delete(id);
37093
+ this.pendingIdb.delete(id);
37094
+ }
37095
+ throw err;
37096
+ }
37097
+ }
36943
37098
  /**
36944
37099
  * Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
36945
37100
  * On timeout returns the `{ok:false, kind:'timeout'}` envelope —
@@ -36951,11 +37106,7 @@ var FetchproxyServer = class {
36951
37106
  const pending = new Promise((resolve) => {
36952
37107
  this.pending.set(id, resolve);
36953
37108
  });
36954
- if (this.hostHandle) {
36955
- await this.hostHandle.sendOwnInner(inner);
36956
- } else if (this.peerHandle) {
36957
- await this.peerHandle.sendInner(inner);
36958
- }
37109
+ await this.sendInnerFrame(inner);
36959
37110
  const timeoutMs = this.opts.fetchTimeoutMs;
36960
37111
  if (timeoutMs === void 0 || timeoutMs <= 0)
36961
37112
  return pending;
@@ -36984,6 +37135,51 @@ var FetchproxyServer = class {
36984
37135
  clearTimeout(timer);
36985
37136
  }
36986
37137
  }
37138
+ /**
37139
+ * FP-B2: bound a non-`fetch` verb's reply wait by `fetchTimeoutMs`.
37140
+ *
37141
+ * Before this, only `fetch()` raced its `pending` reply against a timer
37142
+ * (`_fetchOnceWithTimeout`); `readCookies`, the storage reads,
37143
+ * `capture_request_header`, `capture_redirect`, `download`, and
37144
+ * `read_indexed_db` awaited their `pending` promise with no race, so a
37145
+ * wedged extension hung the tool call indefinitely.
37146
+ *
37147
+ * `pending` is the already-registered reply promise. `pendingMap`/`id`
37148
+ * point at the op-specific map entry so we can drop it on expiry exactly
37149
+ * as `_fetchOnceWithTimeout` does — otherwise a late bridge reply would
37150
+ * resolve into a stale resolver / leak. On timeout we reject with a
37151
+ * `FetchproxyTimeoutError` (the same throwable the convenience methods
37152
+ * already surface for fetch timeouts). `0`/unset opts out (unbounded),
37153
+ * matching the fetch path.
37154
+ */
37155
+ async _withVerbTimeout(pending, pendingMap, id, url2) {
37156
+ const timeoutMs = this.opts.fetchTimeoutMs;
37157
+ if (timeoutMs === void 0 || timeoutMs <= 0)
37158
+ return pending;
37159
+ let timer;
37160
+ const start = Date.now();
37161
+ try {
37162
+ return await Promise.race([
37163
+ pending,
37164
+ new Promise((_resolve, reject) => {
37165
+ timer = setTimeout(() => {
37166
+ pendingMap.delete(id);
37167
+ reject(new FetchproxyTimeoutError({
37168
+ url: url2,
37169
+ timeoutMs,
37170
+ role: this.role,
37171
+ port: this.opts.port,
37172
+ elapsedMs: Date.now() - start,
37173
+ retryAttempted: false
37174
+ }));
37175
+ }, timeoutMs);
37176
+ })
37177
+ ]);
37178
+ } finally {
37179
+ if (timer)
37180
+ clearTimeout(timer);
37181
+ }
37182
+ }
36987
37183
  /**
36988
37184
  * Map an `ok:false` fetch result to its typed throwable. Centralizes
36989
37185
  * the kind-to-error-class switch so `request()` and (via the same
@@ -37274,12 +37470,8 @@ var FetchproxyServer = class {
37274
37470
  const pending = new Promise((resolve) => {
37275
37471
  this.pendingReadCookies.set(id, resolve);
37276
37472
  });
37277
- if (this.hostHandle) {
37278
- await this.hostHandle.sendOwnInner(inner);
37279
- } else if (this.peerHandle) {
37280
- await this.peerHandle.sendInner(inner);
37281
- }
37282
- const result = await pending;
37473
+ await this.sendInnerFrame(inner);
37474
+ const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
37283
37475
  if (!result.ok) {
37284
37476
  throw new FetchproxyProtocolError(result.error);
37285
37477
  }
@@ -37345,12 +37537,8 @@ var FetchproxyServer = class {
37345
37537
  const pending = new Promise((resolve, reject) => {
37346
37538
  this.pendingStorage.set(id, { resolve, reject });
37347
37539
  });
37348
- if (this.hostHandle) {
37349
- await this.hostHandle.sendOwnInner(inner);
37350
- } else if (this.peerHandle) {
37351
- await this.peerHandle.sendInner(inner);
37352
- }
37353
- return pending;
37540
+ await this.sendInnerFrame(inner);
37541
+ return this._withVerbTimeout(pending, this.pendingStorage, id, `https://${host}`);
37354
37542
  }
37355
37543
  /**
37356
37544
  * 0.3.0+: snapshot the next outgoing request's named header. Single-
@@ -37454,12 +37642,8 @@ var FetchproxyServer = class {
37454
37642
  const pending = new Promise((resolve, reject) => {
37455
37643
  this.pendingCapture.set(id, { resolve, reject });
37456
37644
  });
37457
- if (this.hostHandle) {
37458
- await this.hostHandle.sendOwnInner(inner);
37459
- } else if (this.peerHandle) {
37460
- await this.peerHandle.sendInner(inner);
37461
- }
37462
- return pending;
37645
+ await this.sendInnerFrame(inner);
37646
+ return this._withVerbTimeout(pending, this.pendingCapture, id, `https://${opts.host}${opts.path ?? "/*"}`);
37463
37647
  }
37464
37648
  /**
37465
37649
  * Snapshot the redirect target URL of the next request the browser
@@ -37543,12 +37727,8 @@ var FetchproxyServer = class {
37543
37727
  const pending = new Promise((resolve, reject) => {
37544
37728
  this.pendingRedirect.set(id, { resolve, reject });
37545
37729
  });
37546
- if (this.hostHandle) {
37547
- await this.hostHandle.sendOwnInner(inner);
37548
- } else if (this.peerHandle) {
37549
- await this.peerHandle.sendInner(inner);
37550
- }
37551
- return pending;
37730
+ await this.sendInnerFrame(inner);
37731
+ return this._withVerbTimeout(pending, this.pendingRedirect, id, `https://${opts.host}${opts.path ?? "/*"}`);
37552
37732
  }
37553
37733
  /**
37554
37734
  * Download `url` through the BROWSER's own network stack via
@@ -37629,12 +37809,8 @@ var FetchproxyServer = class {
37629
37809
  const pending = new Promise((resolve, reject) => {
37630
37810
  this.pendingDownload.set(id, { resolve, reject });
37631
37811
  });
37632
- if (this.hostHandle) {
37633
- await this.hostHandle.sendOwnInner(inner);
37634
- } else if (this.peerHandle) {
37635
- await this.peerHandle.sendInner(inner);
37636
- }
37637
- return pending;
37812
+ await this.sendInnerFrame(inner);
37813
+ return this._withVerbTimeout(pending, this.pendingDownload, id, opts.url);
37638
37814
  }
37639
37815
  /**
37640
37816
  * 0.4.0+: read declared IndexedDB keys from the user's signed-in
@@ -37681,12 +37857,8 @@ var FetchproxyServer = class {
37681
37857
  const pending = new Promise((resolve, reject) => {
37682
37858
  this.pendingIdb.set(id, { resolve, reject });
37683
37859
  });
37684
- if (this.hostHandle) {
37685
- await this.hostHandle.sendOwnInner(inner);
37686
- } else if (this.peerHandle) {
37687
- await this.peerHandle.sendInner(inner);
37688
- }
37689
- return pending;
37860
+ await this.sendInnerFrame(inner);
37861
+ return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37690
37862
  }
37691
37863
  assertScopeSubset(requested, declared, label) {
37692
37864
  const undeclared = undeclaredKeys(requested, declared);
@@ -38154,7 +38326,7 @@ function loadAccount(env = process.env) {
38154
38326
  // package.json
38155
38327
  var package_default = {
38156
38328
  name: "signupgenius-mcp",
38157
- version: "1.1.4",
38329
+ version: "1.1.6",
38158
38330
  mcpName: "io.github.chrischall/signupgenius-mcp",
38159
38331
  description: "SignUpGenius MCP server \u2014 read sign-ups, reports, and groups; add group members.",
38160
38332
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38191,7 +38363,7 @@ var package_default = {
38191
38363
  "test:watch": "vitest"
38192
38364
  },
38193
38365
  dependencies: {
38194
- "@chrischall/mcp-utils": "^0.8.0",
38366
+ "@chrischall/mcp-utils": "^0.12.0",
38195
38367
  "@fetchproxy/bootstrap": "^1.0.0",
38196
38368
  "@fetchproxy/server": "^1.0.0",
38197
38369
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -38199,7 +38371,7 @@ var package_default = {
38199
38371
  zod: "^4.4.3"
38200
38372
  },
38201
38373
  devDependencies: {
38202
- "@types/node": "^25.9.1",
38374
+ "@types/node": "^26.0.0",
38203
38375
  "@vitest/coverage-v8": "^4.1.7",
38204
38376
  esbuild: "^0.28.0",
38205
38377
  typescript: "^6.0.3",
@@ -38291,18 +38463,30 @@ var CookieSessionManager = class {
38291
38463
  inFlight;
38292
38464
  /** A cached *permanent* config error: once set, every `ensure` rethrows it. */
38293
38465
  permanentError;
38466
+ /** When the current session was minted (login) or installed (seed) — for maxAgeMs. */
38467
+ sessionAt = 0;
38294
38468
  loginFn;
38295
38469
  isExpiredFn;
38296
38470
  isPermanentErrorFn;
38471
+ maxAgeMs;
38472
+ now;
38473
+ onReplayLoginErrorFn;
38297
38474
  constructor(opts) {
38298
38475
  this.loginFn = opts.login;
38299
- this.isExpiredFn = opts.isExpired;
38476
+ this.isExpiredFn = opts.isExpired ?? (() => false);
38300
38477
  this.isPermanentErrorFn = opts.isPermanentError ?? (() => false);
38478
+ this.maxAgeMs = opts.maxAgeMs;
38479
+ this.now = opts.now ?? Date.now;
38480
+ this.onReplayLoginErrorFn = opts.onReplayLoginError;
38301
38481
  }
38302
38482
  /** The current session, or `undefined` before the first successful login. */
38303
38483
  get current() {
38304
38484
  return this.session;
38305
38485
  }
38486
+ /** True when {@link CookieSessionManagerOptions.maxAgeMs} says the session is too old. */
38487
+ isStale() {
38488
+ return this.maxAgeMs !== void 0 && this.now() - this.sessionAt >= this.maxAgeMs;
38489
+ }
38306
38490
  /**
38307
38491
  * Return the current session, or single-flight a login if there is none.
38308
38492
  *
@@ -38311,10 +38495,16 @@ var CookieSessionManager = class {
38311
38495
  * the next call retries. A login error classified permanent by
38312
38496
  * {@link CookieSessionManagerOptions.isPermanentError} is cached and rethrown
38313
38497
  * on every later call; a transient error is not cached (next call retries).
38498
+ * With {@link CookieSessionManagerOptions.maxAgeMs} set, a session past its
38499
+ * TTL is invalidated first, so the call falls through to a (single-flight)
38500
+ * re-login.
38314
38501
  */
38315
38502
  async ensure() {
38316
- if (this.session !== void 0)
38317
- return this.session;
38503
+ if (this.session !== void 0) {
38504
+ if (!this.isStale())
38505
+ return this.session;
38506
+ this.invalidate();
38507
+ }
38318
38508
  if (this.permanentError !== void 0)
38319
38509
  throw this.permanentError;
38320
38510
  if (this.inFlight === void 0) {
@@ -38322,6 +38512,21 @@ var CookieSessionManager = class {
38322
38512
  }
38323
38513
  return this.inFlight;
38324
38514
  }
38515
+ /**
38516
+ * Install an externally-minted session (e.g. infinitecampus's CUPS linked-
38517
+ * district discovery, which mints sessions outside {@link
38518
+ * CookieSessionManagerOptions.login}). The seed becomes the current session
38519
+ * with a fresh {@link CookieSessionManagerOptions.maxAgeMs} clock, and any
38520
+ * in-flight login is DETACHED: its waiters still receive its result, but a
38521
+ * late resolution will not overwrite the seed. A cached permanent config
38522
+ * error is left intact — the login path is still misconfigured, and the next
38523
+ * post-seed re-login should keep saying so.
38524
+ */
38525
+ seed(session) {
38526
+ this.session = session;
38527
+ this.sessionAt = this.now();
38528
+ this.inFlight = void 0;
38529
+ }
38325
38530
  /**
38326
38531
  * One login attempt. Self-clears `inFlight` on settle so a rejected login
38327
38532
  * never sticks, and stamps the resulting session only while it's still the
@@ -38333,8 +38538,10 @@ var CookieSessionManager = class {
38333
38538
  holder.p = (async () => {
38334
38539
  try {
38335
38540
  const session = await this.loginFn();
38336
- if (this.inFlight === holder.p)
38541
+ if (this.inFlight === holder.p) {
38337
38542
  this.session = session;
38543
+ this.sessionAt = this.now();
38544
+ }
38338
38545
  return session;
38339
38546
  } catch (err) {
38340
38547
  if (this.isPermanentErrorFn(err))
@@ -38366,6 +38573,10 @@ var CookieSessionManager = class {
38366
38573
  * rather than looping. If the re-login itself fails, the original
38367
38574
  * (expired-looking) response is returned so the caller can surface a clean
38368
38575
  * sign-in error rather than the login failure.
38576
+ *
38577
+ * `call` resolves to `R` (the generic response type, default {@link Response});
38578
+ * the manager passes it untouched to {@link CookieSessionManagerOptions.isExpired}
38579
+ * and returns it untouched, so a custom transport type flows through cleanly.
38369
38580
  */
38370
38581
  async withSession(call) {
38371
38582
  const session = await this.ensure();
@@ -38377,7 +38588,8 @@ var CookieSessionManager = class {
38377
38588
  let fresh;
38378
38589
  try {
38379
38590
  fresh = await this.ensure();
38380
- } catch {
38591
+ } catch (err) {
38592
+ this.onReplayLoginErrorFn?.(err);
38381
38593
  return res;
38382
38594
  }
38383
38595
  return call(fresh);
@@ -38654,7 +38866,7 @@ async function parseEnvelope(res, context, normalize) {
38654
38866
  const msg = parsed?.message.join("; ");
38655
38867
  if (res.status === 401 || res.status === 403) throw new AuthError(res.status, msg);
38656
38868
  if (res.status === 404) throw new Error(`SignUpGenius 404 ${context}`);
38657
- if (res.status >= 500) throw new UnreachableError(res.status);
38869
+ if (res.status >= 500) throw new UnreachableError("SignUpGenius", res.status);
38658
38870
  if (!res.ok) throw new Error(`SignUpGenius ${res.status} ${msg || res.statusText} for ${context}`);
38659
38871
  if (!parsed) {
38660
38872
  throw new Error(`SignUpGenius returned ${text === "" ? "empty" : "non-JSON"} body for ${context}`);
@@ -38672,37 +38884,17 @@ function parseJsonBody(text) {
38672
38884
  return null;
38673
38885
  }
38674
38886
  }
38675
- var AuthError = class extends Error {
38887
+ var AuthError = class _AuthError extends McpToolError {
38676
38888
  constructor(status, detail) {
38677
38889
  super(
38678
- `SignUpGenius rejected the request (${status}). For key mode: check SIGNUPGENIUS_USER_KEY (it may be wrong, revoked, or the account no longer has Pro). For session mode: the session cookie may have been invalidated server-side.` + (detail ? ` (${detail})` : "")
38890
+ `SignUpGenius rejected the request (${status}). ` + _AuthError.HINT + (detail ? ` (${detail})` : ""),
38891
+ { hint: _AuthError.HINT }
38679
38892
  );
38680
38893
  this.status = status;
38681
38894
  this.name = "AuthError";
38682
38895
  }
38683
38896
  status;
38684
- };
38685
- var UnreachableError = class extends Error {
38686
- constructor(status) {
38687
- super(`SignUpGenius unreachable (status ${status})`);
38688
- this.status = status;
38689
- this.name = "UnreachableError";
38690
- }
38691
- status;
38692
- };
38693
- var ModeMismatchError = class extends Error {
38694
- constructor(currentMode, requiredMode, feature) {
38695
- super(
38696
- `${feature} requires ${requiredMode} mode but the server is running in ${currentMode} mode. ` + (requiredMode === "key" ? "Set SIGNUPGENIUS_USER_KEY (Pro subscription required for the documented v2/k API)." : "Set SIGNUPGENIUS_EMAIL + SIGNUPGENIUS_PASSWORD to enable this tool.")
38697
- );
38698
- this.currentMode = currentMode;
38699
- this.requiredMode = requiredMode;
38700
- this.feature = feature;
38701
- this.name = "ModeMismatchError";
38702
- }
38703
- currentMode;
38704
- requiredMode;
38705
- feature;
38897
+ static HINT = "For key mode: check SIGNUPGENIUS_USER_KEY (it may be wrong, revoked, or the account no longer has Pro). For session mode: the session cookie may have been invalidated server-side.";
38706
38898
  };
38707
38899
 
38708
38900
  // src/tools/_shared.ts
@@ -39195,7 +39387,7 @@ bannerLines.push(
39195
39387
  );
39196
39388
  await runMcp({
39197
39389
  name: "signupgenius",
39198
- version: "1.1.4",
39390
+ version: "1.1.6",
39199
39391
  // x-release-please-version
39200
39392
  banner: bannerLines.join("\n"),
39201
39393
  deps: client,
package/dist/client.js CHANGED
@@ -1,4 +1,8 @@
1
+ import { McpToolError, ModeMismatchError, UnreachableError } from '@chrischall/mcp-utils';
1
2
  import { CookieSessionManager } from '@chrischall/mcp-utils/session';
3
+ // Re-exported so tools/tests keep importing the error types from the client
4
+ // module (the shared classes live in @chrischall/mcp-utils since 0.10.x).
5
+ export { ModeMismatchError, UnreachableError };
2
6
  import { sessionLogin as defaultSessionLogin } from './auth-session-login.js';
3
7
  /**
4
8
  * Detect a session that lapsed server-side. SignUpGenius signals expiry two
@@ -245,7 +249,7 @@ async function parseEnvelope(res, context, normalize) {
245
249
  if (res.status === 404)
246
250
  throw new Error(`SignUpGenius 404 ${context}`);
247
251
  if (res.status >= 500)
248
- throw new UnreachableError(res.status);
252
+ throw new UnreachableError('SignUpGenius', res.status);
249
253
  if (!res.ok)
250
254
  throw new Error(`SignUpGenius ${res.status} ${msg || res.statusText} for ${context}`);
251
255
  if (!parsed) {
@@ -266,37 +270,21 @@ function parseJsonBody(text) {
266
270
  return null;
267
271
  }
268
272
  }
269
- export class AuthError extends Error {
273
+ /**
274
+ * SignUpGenius rejected a request (401/403). Kept as a local class (rather
275
+ * than the shared `SessionNotAuthenticatedError`, whose fixed browser-sign-in
276
+ * message can't carry it) because the remediation is mode-dependent SUG
277
+ * guidance — surfaced as the `hint` per the `McpToolError` contract.
278
+ */
279
+ export class AuthError extends McpToolError {
270
280
  status;
281
+ static HINT = 'For key mode: check SIGNUPGENIUS_USER_KEY (it may be wrong, revoked, or the account no longer has Pro). ' +
282
+ 'For session mode: the session cookie may have been invalidated server-side.';
271
283
  constructor(status, detail) {
272
284
  super(`SignUpGenius rejected the request (${status}). ` +
273
- 'For key mode: check SIGNUPGENIUS_USER_KEY (it may be wrong, revoked, or the account no longer has Pro). ' +
274
- 'For session mode: the session cookie may have been invalidated server-side.' +
275
- (detail ? ` (${detail})` : ''));
285
+ AuthError.HINT +
286
+ (detail ? ` (${detail})` : ''), { hint: AuthError.HINT });
276
287
  this.status = status;
277
288
  this.name = 'AuthError';
278
289
  }
279
290
  }
280
- export class UnreachableError extends Error {
281
- status;
282
- constructor(status) {
283
- super(`SignUpGenius unreachable (status ${status})`);
284
- this.status = status;
285
- this.name = 'UnreachableError';
286
- }
287
- }
288
- export class ModeMismatchError extends Error {
289
- currentMode;
290
- requiredMode;
291
- feature;
292
- constructor(currentMode, requiredMode, feature) {
293
- super(`${feature} requires ${requiredMode} mode but the server is running in ${currentMode} mode. ` +
294
- (requiredMode === 'key'
295
- ? 'Set SIGNUPGENIUS_USER_KEY (Pro subscription required for the documented v2/k API).'
296
- : 'Set SIGNUPGENIUS_EMAIL + SIGNUPGENIUS_PASSWORD to enable this tool.'));
297
- this.currentMode = currentMode;
298
- this.requiredMode = requiredMode;
299
- this.feature = feature;
300
- this.name = 'ModeMismatchError';
301
- }
302
- }
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ const bannerLines = account
47
47
  bannerLines.push('[signupgenius-mcp] Developed and maintained by AI (Claude). Use at your own discretion.');
48
48
  await runMcp({
49
49
  name: 'signupgenius',
50
- version: '1.1.4', // x-release-please-version
50
+ version: '1.1.6', // x-release-please-version
51
51
  banner: bannerLines.join('\n'),
52
52
  deps: client,
53
53
  tools: [
@@ -7,8 +7,9 @@ const reportArgs = z.object({
7
7
  * Report endpoints are Pro-only. The session-mode v3 web API has no
8
8
  * equivalent — only the sign-up owner can pull report data, and the v3 paths
9
9
  * for it were not discovered during recon. We still register the tools so
10
- * Claude knows they exist, but in session mode they fail fast with a
11
- * ModeMismatchError pointing the user at SIGNUPGENIUS_USER_KEY.
10
+ * Claude knows they exist, but in session mode they fail fast with the shared
11
+ * ModeMismatchError, whose hint says "Switch to key mode to use {tool}." —
12
+ * the SIGNUPGENIUS_USER_KEY pointer lives in each tool's description below.
12
13
  */
13
14
  export function registerReportTools(server, client) {
14
15
  const register = (toolName, path, blurb) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signupgenius-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "mcpName": "io.github.chrischall/signupgenius-mcp",
5
5
  "description": "SignUpGenius MCP server — read sign-ups, reports, and groups; add group members.",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -37,7 +37,7 @@
37
37
  "test:watch": "vitest"
38
38
  },
39
39
  "dependencies": {
40
- "@chrischall/mcp-utils": "^0.8.0",
40
+ "@chrischall/mcp-utils": "^0.12.0",
41
41
  "@fetchproxy/bootstrap": "^1.0.0",
42
42
  "@fetchproxy/server": "^1.0.0",
43
43
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -45,7 +45,7 @@
45
45
  "zod": "^4.4.3"
46
46
  },
47
47
  "devDependencies": {
48
- "@types/node": "^25.9.1",
48
+ "@types/node": "^26.0.0",
49
49
  "@vitest/coverage-v8": "^4.1.7",
50
50
  "esbuild": "^0.28.0",
51
51
  "typescript": "^6.0.3",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/signupgenius-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.1.4",
9
+ "version": "1.1.6",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "signupgenius-mcp",
14
- "version": "1.1.4",
14
+ "version": "1.1.6",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },