signupgenius-mcp 1.1.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.5"
10
+ "version": "1.2.0"
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.5",
18
+ "version": "1.2.0",
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.5",
4
+ "version": "1.2.0",
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",
@@ -15,5 +15,6 @@
15
15
  "signups",
16
16
  "volunteers",
17
17
  "mcp"
18
- ]
18
+ ],
19
+ "skills": "./skills/"
19
20
  }
package/dist/bundle.js CHANGED
@@ -34701,6 +34701,32 @@ var McpToolError = class extends Error {
34701
34701
  Object.setPrototypeOf(this, new.target.prototype);
34702
34702
  }
34703
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
+ };
34704
34730
  function createHelpfulError(message, opts) {
34705
34731
  return new McpToolError(message, opts);
34706
34732
  }
@@ -34718,6 +34744,9 @@ var API_KEY_RE = new RegExp([
34718
34744
  "whsec_[A-Za-z0-9]{16,}\\b"
34719
34745
  // webhook signing secret (Stripe-style)
34720
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");
34721
34750
 
34722
34751
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
34723
34752
  function textResult(data) {
@@ -34919,6 +34948,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34919
34948
  "capture_request_header",
34920
34949
  "capture_redirect",
34921
34950
  "read_indexed_db",
34951
+ "read_dom",
34922
34952
  "download"
34923
34953
  ]);
34924
34954
 
@@ -35220,6 +35250,44 @@ function assertIndexedDbScopesArray(value, label) {
35220
35250
  }
35221
35251
  }
35222
35252
  }
35253
+ var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35254
+ var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35255
+ function assertDomSelectorsArray(value, label) {
35256
+ if (!Array.isArray(value)) {
35257
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35258
+ }
35259
+ const seen = /* @__PURE__ */ new Set();
35260
+ for (let i = 0; i < value.length; i++) {
35261
+ const entry = value[i];
35262
+ assertObject(entry, `${label}[${i}]`);
35263
+ if (entry.name === void 0) {
35264
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35265
+ }
35266
+ if (entry.selector === void 0) {
35267
+ throw new ProtocolError(`${label}[${i}].selector: missing`);
35268
+ }
35269
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35270
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35271
+ }
35272
+ if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
35273
+ throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
35274
+ }
35275
+ if (entry.attribute !== void 0) {
35276
+ if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
35277
+ throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
35278
+ }
35279
+ }
35280
+ if (seen.has(entry.name)) {
35281
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35282
+ }
35283
+ seen.add(entry.name);
35284
+ for (const k of Object.keys(entry)) {
35285
+ if (k !== "name" && k !== "selector" && k !== "attribute") {
35286
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35287
+ }
35288
+ }
35289
+ }
35290
+ }
35223
35291
  function validateFrame(raw) {
35224
35292
  assertObject(raw, "frame");
35225
35293
  const t = raw.type;
@@ -35295,6 +35363,9 @@ function validateHello(raw) {
35295
35363
  if (raw.sessionStoragePointers !== void 0) {
35296
35364
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35297
35365
  }
35366
+ if (raw.domSelectors !== void 0) {
35367
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35368
+ }
35298
35369
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35299
35370
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35300
35371
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35529,6 +35600,21 @@ function validateInnerRequest(raw) {
35529
35600
  }
35530
35601
  return raw;
35531
35602
  }
35603
+ if (raw.op === "read_dom") {
35604
+ assertObject(raw.init, "inner.init");
35605
+ if (raw.init.origin === void 0)
35606
+ throw new ProtocolError("inner.init.origin: missing");
35607
+ if (raw.init.names === void 0)
35608
+ throw new ProtocolError("inner.init.names: missing");
35609
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35610
+ assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
35611
+ for (const k of Object.keys(raw.init)) {
35612
+ if (k !== "origin" && k !== "names") {
35613
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
35614
+ }
35615
+ }
35616
+ return raw;
35617
+ }
35532
35618
  if (raw.op === "download") {
35533
35619
  assertObject(raw.init, "inner.init");
35534
35620
  if (raw.init.url === void 0) {
@@ -35554,7 +35640,7 @@ function validateInnerRequest(raw) {
35554
35640
  }
35555
35641
  return raw;
35556
35642
  }
35557
- 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)}`);
35643
+ 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", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
35558
35644
  }
35559
35645
  function assertNonEmptyKeyArray(value, label) {
35560
35646
  if (!Array.isArray(value)) {
@@ -35639,6 +35725,13 @@ function validateInnerResponse(raw) {
35639
35725
  assertObject(raw.values, "inner.values");
35640
35726
  return raw;
35641
35727
  }
35728
+ if (op === "read_dom") {
35729
+ if (raw.values === void 0) {
35730
+ throw new ProtocolError("inner.values: missing on read_dom response");
35731
+ }
35732
+ assertStringMap(raw.values, "inner.values");
35733
+ return raw;
35734
+ }
35642
35735
  if (op === "download") {
35643
35736
  assertObject(raw.value, "inner.value");
35644
35737
  assertString(raw.value.path, "inner.value.path");
@@ -35978,6 +36071,13 @@ async function buildServerHello(opts) {
35978
36071
  jsonPointer: d.jsonPointer
35979
36072
  }));
35980
36073
  }
36074
+ if (opts.domSelectors && opts.domSelectors.length > 0) {
36075
+ hello.domSelectors = opts.domSelectors.map((d) => ({
36076
+ name: d.name,
36077
+ selector: d.selector,
36078
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36079
+ }));
36080
+ }
35981
36081
  return hello;
35982
36082
  }
35983
36083
 
@@ -36067,7 +36167,8 @@ async function startHost(opts) {
36067
36167
  captureHeaders: opts.ownCaptureHeaders,
36068
36168
  indexedDbScopes: opts.ownIndexedDbScopes,
36069
36169
  localStoragePointers: opts.ownLocalStoragePointers,
36070
- sessionStoragePointers: opts.ownSessionStoragePointers
36170
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36171
+ domSelectors: opts.ownDomSelectors
36071
36172
  });
36072
36173
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
36073
36174
  let extensionWs = null;
@@ -36302,6 +36403,7 @@ async function startPeer(opts) {
36302
36403
  sessionStorageKeys: opts.sessionStorageKeys,
36303
36404
  captureHeaders: opts.captureHeaders,
36304
36405
  indexedDbScopes: opts.indexedDbScopes,
36406
+ domSelectors: opts.domSelectors,
36305
36407
  localStoragePointers: opts.localStoragePointers,
36306
36408
  sessionStoragePointers: opts.sessionStoragePointers
36307
36409
  });
@@ -36709,6 +36811,11 @@ var FetchproxyServer = class {
36709
36811
  key: d.key,
36710
36812
  jsonPointer: d.jsonPointer
36711
36813
  })),
36814
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
36815
+ name: d.name,
36816
+ selector: d.selector,
36817
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36818
+ })),
36712
36819
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36713
36820
  // adapter was about to set these to the same numbers anyway; the
36714
36821
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36829,6 +36936,7 @@ var FetchproxyServer = class {
36829
36936
  ownIndexedDbScopes: this.opts.indexedDbScopes,
36830
36937
  ownLocalStoragePointers: this.opts.localStoragePointers,
36831
36938
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
36939
+ ownDomSelectors: this.opts.domSelectors,
36832
36940
  onPairCode: this.opts.onPairCode
36833
36941
  });
36834
36942
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -36856,7 +36964,8 @@ var FetchproxyServer = class {
36856
36964
  captureHeaders: this.opts.captureHeaders,
36857
36965
  indexedDbScopes: this.opts.indexedDbScopes,
36858
36966
  localStoragePointers: this.opts.localStoragePointers,
36859
- sessionStoragePointers: this.opts.sessionStoragePointers
36967
+ sessionStoragePointers: this.opts.sessionStoragePointers,
36968
+ domSelectors: this.opts.domSelectors
36860
36969
  });
36861
36970
  this.peerHandle.onInner((inner) => this.onInner(inner));
36862
36971
  this.peerHandle.onRenegotiate(() => {
@@ -37831,6 +37940,46 @@ var FetchproxyServer = class {
37831
37940
  await this.sendInnerFrame(inner);
37832
37941
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37833
37942
  }
37943
+ /**
37944
+ * 1.4.0+: read declared DOM values from the user's signed-in tab.
37945
+ * Requires `'read_dom'` in capabilities AND every requested `name` to
37946
+ * match a declared `domSelectors` entry. The extension reads each
37947
+ * declared selector from the matched tab's DOM (isolated-world
37948
+ * `querySelector`, value or attribute) — no page-JS execution.
37949
+ *
37950
+ * Returns a `Record<string, string>` of `name → value`, with names
37951
+ * whose element (or attribute) was absent omitted. Throws
37952
+ * `FetchproxyProtocolError` on bridge failures and a plain `Error` on
37953
+ * developer mistakes (undeclared capability, undeclared name).
37954
+ */
37955
+ async readDom(opts) {
37956
+ if (!this.opts.capabilities.includes("read_dom")) {
37957
+ throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
37958
+ }
37959
+ await this.ensureConnected();
37960
+ this.throwIfPendingPair();
37961
+ if (!Array.isArray(opts.names) || opts.names.length === 0) {
37962
+ throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
37963
+ }
37964
+ this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
37965
+ if (opts.subdomain !== void 0)
37966
+ assertSubdomainLabel(opts.subdomain);
37967
+ const baseDomain = this.resolveBaseDomain(opts.domain);
37968
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
37969
+ const origin = `https://${host}`;
37970
+ const id = this.nextRequestId++;
37971
+ const inner = {
37972
+ type: "request",
37973
+ id,
37974
+ op: "read_dom",
37975
+ init: { origin, names: [...opts.names] }
37976
+ };
37977
+ const pending = new Promise((resolve, reject) => {
37978
+ this.pendingStorage.set(id, { resolve, reject });
37979
+ });
37980
+ await this.sendInnerFrame(inner);
37981
+ return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
37982
+ }
37834
37983
  assertScopeSubset(requested, declared, label) {
37835
37984
  const undeclared = undeclaredKeys(requested, declared);
37836
37985
  if (undeclared.length > 0) {
@@ -37902,7 +38051,7 @@ var FetchproxyServer = class {
37902
38051
  if (storageCb) {
37903
38052
  this.pendingStorage.delete(inner.id);
37904
38053
  if (inner.ok) {
37905
- if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
38054
+ if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
37906
38055
  storageCb.resolve({ ...inner.values });
37907
38056
  } else {
37908
38057
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
@@ -38297,7 +38446,7 @@ function loadAccount(env = process.env) {
38297
38446
  // package.json
38298
38447
  var package_default = {
38299
38448
  name: "signupgenius-mcp",
38300
- version: "1.1.5",
38449
+ version: "1.2.0",
38301
38450
  mcpName: "io.github.chrischall/signupgenius-mcp",
38302
38451
  description: "SignUpGenius MCP server \u2014 read sign-ups, reports, and groups; add group members.",
38303
38452
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38334,7 +38483,7 @@ var package_default = {
38334
38483
  "test:watch": "vitest"
38335
38484
  },
38336
38485
  dependencies: {
38337
- "@chrischall/mcp-utils": "^0.10.4",
38486
+ "@chrischall/mcp-utils": "^0.13.0",
38338
38487
  "@fetchproxy/bootstrap": "^1.0.0",
38339
38488
  "@fetchproxy/server": "^1.0.0",
38340
38489
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -38345,7 +38494,7 @@ var package_default = {
38345
38494
  "@types/node": "^26.0.0",
38346
38495
  "@vitest/coverage-v8": "^4.1.7",
38347
38496
  esbuild: "^0.28.0",
38348
- typescript: "^6.0.3",
38497
+ typescript: "^7.0.2",
38349
38498
  vitest: "^4.1.7"
38350
38499
  }
38351
38500
  };
@@ -38434,18 +38583,30 @@ var CookieSessionManager = class {
38434
38583
  inFlight;
38435
38584
  /** A cached *permanent* config error: once set, every `ensure` rethrows it. */
38436
38585
  permanentError;
38586
+ /** When the current session was minted (login) or installed (seed) — for maxAgeMs. */
38587
+ sessionAt = 0;
38437
38588
  loginFn;
38438
38589
  isExpiredFn;
38439
38590
  isPermanentErrorFn;
38591
+ maxAgeMs;
38592
+ now;
38593
+ onReplayLoginErrorFn;
38440
38594
  constructor(opts) {
38441
38595
  this.loginFn = opts.login;
38442
38596
  this.isExpiredFn = opts.isExpired ?? (() => false);
38443
38597
  this.isPermanentErrorFn = opts.isPermanentError ?? (() => false);
38598
+ this.maxAgeMs = opts.maxAgeMs;
38599
+ this.now = opts.now ?? Date.now;
38600
+ this.onReplayLoginErrorFn = opts.onReplayLoginError;
38444
38601
  }
38445
38602
  /** The current session, or `undefined` before the first successful login. */
38446
38603
  get current() {
38447
38604
  return this.session;
38448
38605
  }
38606
+ /** True when {@link CookieSessionManagerOptions.maxAgeMs} says the session is too old. */
38607
+ isStale() {
38608
+ return this.maxAgeMs !== void 0 && this.now() - this.sessionAt >= this.maxAgeMs;
38609
+ }
38449
38610
  /**
38450
38611
  * Return the current session, or single-flight a login if there is none.
38451
38612
  *
@@ -38454,10 +38615,16 @@ var CookieSessionManager = class {
38454
38615
  * the next call retries. A login error classified permanent by
38455
38616
  * {@link CookieSessionManagerOptions.isPermanentError} is cached and rethrown
38456
38617
  * on every later call; a transient error is not cached (next call retries).
38618
+ * With {@link CookieSessionManagerOptions.maxAgeMs} set, a session past its
38619
+ * TTL is invalidated first, so the call falls through to a (single-flight)
38620
+ * re-login.
38457
38621
  */
38458
38622
  async ensure() {
38459
- if (this.session !== void 0)
38460
- return this.session;
38623
+ if (this.session !== void 0) {
38624
+ if (!this.isStale())
38625
+ return this.session;
38626
+ this.invalidate();
38627
+ }
38461
38628
  if (this.permanentError !== void 0)
38462
38629
  throw this.permanentError;
38463
38630
  if (this.inFlight === void 0) {
@@ -38465,6 +38632,21 @@ var CookieSessionManager = class {
38465
38632
  }
38466
38633
  return this.inFlight;
38467
38634
  }
38635
+ /**
38636
+ * Install an externally-minted session (e.g. infinitecampus's CUPS linked-
38637
+ * district discovery, which mints sessions outside {@link
38638
+ * CookieSessionManagerOptions.login}). The seed becomes the current session
38639
+ * with a fresh {@link CookieSessionManagerOptions.maxAgeMs} clock, and any
38640
+ * in-flight login is DETACHED: its waiters still receive its result, but a
38641
+ * late resolution will not overwrite the seed. A cached permanent config
38642
+ * error is left intact — the login path is still misconfigured, and the next
38643
+ * post-seed re-login should keep saying so.
38644
+ */
38645
+ seed(session) {
38646
+ this.session = session;
38647
+ this.sessionAt = this.now();
38648
+ this.inFlight = void 0;
38649
+ }
38468
38650
  /**
38469
38651
  * One login attempt. Self-clears `inFlight` on settle so a rejected login
38470
38652
  * never sticks, and stamps the resulting session only while it's still the
@@ -38476,8 +38658,10 @@ var CookieSessionManager = class {
38476
38658
  holder.p = (async () => {
38477
38659
  try {
38478
38660
  const session = await this.loginFn();
38479
- if (this.inFlight === holder.p)
38661
+ if (this.inFlight === holder.p) {
38480
38662
  this.session = session;
38663
+ this.sessionAt = this.now();
38664
+ }
38481
38665
  return session;
38482
38666
  } catch (err) {
38483
38667
  if (this.isPermanentErrorFn(err))
@@ -38524,7 +38708,8 @@ var CookieSessionManager = class {
38524
38708
  let fresh;
38525
38709
  try {
38526
38710
  fresh = await this.ensure();
38527
- } catch {
38711
+ } catch (err) {
38712
+ this.onReplayLoginErrorFn?.(err);
38528
38713
  return res;
38529
38714
  }
38530
38715
  return call(fresh);
@@ -38801,7 +38986,7 @@ async function parseEnvelope(res, context, normalize) {
38801
38986
  const msg = parsed?.message.join("; ");
38802
38987
  if (res.status === 401 || res.status === 403) throw new AuthError(res.status, msg);
38803
38988
  if (res.status === 404) throw new Error(`SignUpGenius 404 ${context}`);
38804
- if (res.status >= 500) throw new UnreachableError(res.status);
38989
+ if (res.status >= 500) throw new UnreachableError("SignUpGenius", res.status);
38805
38990
  if (!res.ok) throw new Error(`SignUpGenius ${res.status} ${msg || res.statusText} for ${context}`);
38806
38991
  if (!parsed) {
38807
38992
  throw new Error(`SignUpGenius returned ${text === "" ? "empty" : "non-JSON"} body for ${context}`);
@@ -38819,37 +39004,17 @@ function parseJsonBody(text) {
38819
39004
  return null;
38820
39005
  }
38821
39006
  }
38822
- var AuthError = class extends Error {
39007
+ var AuthError = class _AuthError extends McpToolError {
38823
39008
  constructor(status, detail) {
38824
39009
  super(
38825
- `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})` : "")
39010
+ `SignUpGenius rejected the request (${status}). ` + _AuthError.HINT + (detail ? ` (${detail})` : ""),
39011
+ { hint: _AuthError.HINT }
38826
39012
  );
38827
39013
  this.status = status;
38828
39014
  this.name = "AuthError";
38829
39015
  }
38830
39016
  status;
38831
- };
38832
- var UnreachableError = class extends Error {
38833
- constructor(status) {
38834
- super(`SignUpGenius unreachable (status ${status})`);
38835
- this.status = status;
38836
- this.name = "UnreachableError";
38837
- }
38838
- status;
38839
- };
38840
- var ModeMismatchError = class extends Error {
38841
- constructor(currentMode, requiredMode, feature) {
38842
- super(
38843
- `${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.")
38844
- );
38845
- this.currentMode = currentMode;
38846
- this.requiredMode = requiredMode;
38847
- this.feature = feature;
38848
- this.name = "ModeMismatchError";
38849
- }
38850
- currentMode;
38851
- requiredMode;
38852
- feature;
39017
+ 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.";
38853
39018
  };
38854
39019
 
38855
39020
  // src/tools/_shared.ts
@@ -39342,7 +39507,7 @@ bannerLines.push(
39342
39507
  );
39343
39508
  await runMcp({
39344
39509
  name: "signupgenius",
39345
- version: "1.1.5",
39510
+ version: "1.2.0",
39346
39511
  // x-release-please-version
39347
39512
  banner: bannerLines.join("\n"),
39348
39513
  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.5', // x-release-please-version
50
+ version: '1.2.0', // 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.5",
3
+ "version": "1.2.0",
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.10.4",
40
+ "@chrischall/mcp-utils": "^0.13.0",
41
41
  "@fetchproxy/bootstrap": "^1.0.0",
42
42
  "@fetchproxy/server": "^1.0.0",
43
43
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -48,7 +48,7 @@
48
48
  "@types/node": "^26.0.0",
49
49
  "@vitest/coverage-v8": "^4.1.7",
50
50
  "esbuild": "^0.28.0",
51
- "typescript": "^6.0.3",
51
+ "typescript": "^7.0.2",
52
52
  "vitest": "^4.1.7"
53
53
  }
54
54
  }
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.5",
9
+ "version": "1.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "signupgenius-mcp",
14
- "version": "1.1.5",
14
+ "version": "1.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -0,0 +1,116 @@
1
+ ---
2
+ name: signupgenius-mcp
3
+ description: Read sign-up sheets, slot reports, and groups on SignUpGenius — and add members to your groups. Triggers on phrases like "check SignUpGenius", "what am I signed up for", "what slots are left for [event]", "available slots", "list my SignUpGenius groups", "add [person] to my [group] group", or any request involving SignUpGenius sign-ups, RSVPs, volunteer slots, potlucks, carpools, classroom helpers, or PTA/HOA/Scout/team sign-ups. Works against your own signed-in account; supports Pro key for full slot reports.
4
+ ---
5
+
6
+ # signupgenius-mcp
7
+
8
+ MCP server for [SignUpGenius](https://www.signupgenius.com) — 14 read tools + 2 write across profile, groups, sign-ups, and reports.
9
+
10
+ - **npm:** [npmjs.com/package/signupgenius-mcp](https://www.npmjs.com/package/signupgenius-mcp)
11
+ - **Source:** [github.com/chrischall/signupgenius-mcp](https://github.com/chrischall/signupgenius-mcp)
12
+
13
+ ## Setup
14
+
15
+ Three auth modes, tried in priority order — first match wins. **You only need one.**
16
+
17
+ ### Mode 1 — fetchproxy fallback (zero env vars, recommended)
18
+
19
+ Install the [fetchproxy extension](https://github.com/chrischall/fetchproxy) once, sign into [signupgenius.com](https://www.signupgenius.com), and add to `.mcp.json` (project) or `~/.claude/mcp.json` (global):
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "signupgenius": {
25
+ "command": "npx",
26
+ "args": ["-y", "signupgenius-mcp"]
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ At startup the MCP reads your `accessToken` / `cfid` / `cftoken` cookies once via the extension, then talks to SignUpGenius directly — the extension is **not** in the request hot path after that. Works with free accounts.
33
+
34
+ ### Mode 2 — session login (email + password)
35
+
36
+ Add an env block with your direct-login credentials (won't work with Google/Apple/Facebook/Microsoft SSO or 2FA):
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "signupgenius": {
42
+ "command": "npx",
43
+ "args": ["-y", "signupgenius-mcp"],
44
+ "env": {
45
+ "SIGNUPGENIUS_EMAIL": "you@example.com",
46
+ "SIGNUPGENIUS_PASSWORD": "your-password"
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ ### Mode 3 — Pro API key (required for slot reports)
54
+
55
+ The three `signupgenius_report_*` tools that list filled / available / all participants for a given sign-up only work against the documented Pro v2 API. Get a key from **Pro Tools → API Management** in your SignUpGenius dashboard (Pro subscription required), then:
56
+
57
+ ```json
58
+ "env": { "SIGNUPGENIUS_USER_KEY": "your-api-key" }
59
+ ```
60
+
61
+ Modes can be combined; Pro key wins where it applies, session/fetchproxy handles everything else.
62
+
63
+ ## Tools
64
+
65
+ ### Profile
66
+
67
+ - **`signupgenius_get_profile`** — Your own profile (name, email, account type).
68
+
69
+ ### Groups
70
+
71
+ - **`signupgenius_list_groups`** — Every group you own or belong to.
72
+ - **`signupgenius_list_group_members`** — Members of one of your groups.
73
+ - **`signupgenius_get_group_member`** — One member's full record.
74
+ - **`signupgenius_add_group_member`** *(write)* — Add a person to one of your groups.
75
+
76
+ ### Sign-ups — created by you
77
+
78
+ - **`signupgenius_list_created_active`** — Sign-ups you've created that are still open.
79
+ - **`signupgenius_list_created_expired`** — Sign-ups you've created that have ended.
80
+ - **`signupgenius_list_created_all`** — Both active and expired in one call.
81
+
82
+ ### Sign-ups — others'
83
+
84
+ - **`signupgenius_list_invited`** — Sign-ups you've been invited to.
85
+ - **`signupgenius_list_signedupfor`** — Sign-ups you've taken a slot on. (Session-mode also includes the bonus `signupgenius_legacy_get_my_signups` which calls the same backend the SignUpGenius web wizard uses and sometimes returns fuller data.)
86
+ - **`signupgenius_legacy_get_my_signups`** *(session only)* — Bonus richer "what am I signed up for" lookup.
87
+
88
+ ### Public sign-up
89
+
90
+ - **`signupgenius_get_public_signup`** — Fetch a public sign-up page by URL or slug. No auth required.
91
+ - **`signupgenius_rsvp`** *(write)* — RSVP to a public sign-up slot.
92
+
93
+ ### Reports — slots for a sign-up (Pro key only)
94
+
95
+ - **`signupgenius_report_all`** — Every slot + participant on a sign-up.
96
+ - **`signupgenius_report_filled`** — Filled slots only.
97
+ - **`signupgenius_report_available`** — Available slots only.
98
+
99
+ Session-mode users hit a fast `ModeMismatchError` on the report tools with a clear instruction to set `SIGNUPGENIUS_USER_KEY`.
100
+
101
+ ## Trigger examples
102
+
103
+ - "Check SignUpGenius — what am I signed up for this week?" → `signupgenius_list_signedupfor` (+ `_legacy_get_my_signups` in session mode)
104
+ - "What slots are still open on the PTA potluck sign-up?" → `signupgenius_report_available` (Pro key)
105
+ - "List my SignUpGenius groups" → `signupgenius_list_groups`
106
+ - "Add Jordan Smith (<jordan@example.com>) to my Scouts group" → `signupgenius_add_group_member`
107
+ - "What sign-ups have I created that are still active?" → `signupgenius_list_created_active`
108
+ - "RSVP me to slot 3 on this SignUpGenius link" → `signupgenius_rsvp`
109
+
110
+ ## Gotchas
111
+
112
+ - **Reports require Pro.** `signupgenius_report_*` only work with `SIGNUPGENIUS_USER_KEY` — session/fetchproxy users get a clear error pointing at the key.
113
+ - **SSO accounts not supported.** Session mode is direct email/password only — no Google/Apple/Facebook/Microsoft SSO, no 2FA. Use fetchproxy mode instead if your account uses SSO.
114
+ - **Session listings collapse.** In session mode the v3 `signups/created` endpoint returns active + expired in one paginated call — the three `list_created_*` tools all hit the same endpoint and filter client-side. Pro key mode has separate endpoints and exposes the real distinction.
115
+ - **Write surface is small.** Only `signupgenius_add_group_member` and `signupgenius_rsvp` mutate; everything else is read-only.
116
+ - **ToS caveat.** SignUpGenius's terms generally prohibit scripted/automated access. Personal-account, personal-scale use is the intended audience; running this against accounts you don't own or at scale is your problem.
@@ -0,0 +1,124 @@
1
+ ---
2
+ name: signupgenius-api
3
+ description: "Access SignUpGenius (sign-ups, groups, RSVPs) from a shell with curl instead of running the signupgenius-mcp server — server-side email/password login to a JWT + cfid/cftoken cookies, then curl the v3 API and legacy /SUGboxAPI.cfm dispatcher directly. Use when you want SignUpGenius data without the MCP, in a script, or on a machine where the MCP isn't installed."
4
+ ---
5
+
6
+ # SignUpGenius via curl (no MCP)
7
+
8
+ SignUpGenius's session mode is a classic ColdFusion login: POST email+password
9
+ to the login form, get back a `accessToken` JWT cookie plus `cfid`/`cftoken`
10
+ session cookies. No browser or extension needed — everything here is a plain
11
+ `curl` call. This is the same auth path `signupgenius-mcp` uses in session
12
+ mode (`src/auth-session-login.ts`); its fetchproxy path (lifting the same
13
+ cookies out of a signed-in browser tab) is only a **fallback** for when you
14
+ don't want to put a password in `.env` — this skill always logs in directly.
15
+
16
+ ## One-time setup
17
+
18
+ ```sh
19
+ export SIGNUPGENIUS_EMAIL="you@example.com"
20
+ export SIGNUPGENIUS_PASSWORD="..."
21
+ # or: export SIGNUPGENIUS_PASSWORD="$(op read 'op://Private/SignUpGenius/password')"
22
+ ```
23
+
24
+ SSO accounts (Google/Apple/Facebook/Microsoft) and 2FA-enabled accounts can't
25
+ use this flow — same limitation as the MCP's session mode.
26
+
27
+ ## Log in: get the JWT + cookies
28
+
29
+ ```sh
30
+ COOKIEJAR=$(mktemp)
31
+
32
+ CSRF=$(curl -s -c "$COOKIEJAR" https://www.signupgenius.com/login \
33
+ | grep -oE 'name="csrfToken"[[:space:]]+value="[^"]+"' \
34
+ | sed -E 's/.*value="([^"]+)".*/\1/')
35
+
36
+ curl -s -D /tmp/sug-login-headers.txt -o /dev/null \
37
+ -b "$COOKIEJAR" -c "$COOKIEJAR" \
38
+ -A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' \
39
+ -X POST 'https://www.signupgenius.com/index.cfm?go=c.Login' \
40
+ --data-urlencode "csrfToken=$CSRF" \
41
+ --data-urlencode "loginemail=$SIGNUPGENIUS_EMAIL" \
42
+ --data-urlencode "pword=$SIGNUPGENIUS_PASSWORD" \
43
+ --data-urlencode "successpage=c.jump&jump=/index.cfm?go=c.MyAccount" \
44
+ --data-urlencode "failpage=c.Register" \
45
+ --data-urlencode "ScreenWidth=2000" \
46
+ --data-urlencode "ScreenHeight=1200" \
47
+ --data-urlencode "formaction=1" \
48
+ --data-urlencode "formName=loginform" \
49
+ --data-urlencode "refererUrl="
50
+
51
+ # Bad credentials 302 to c.Register instead of setting accessToken — check both:
52
+ grep -q 'c.Register' /tmp/sug-login-headers.txt && echo "LOGIN FAILED (bad credentials)" >&2
53
+
54
+ ACCESS_TOKEN=$(awk -F'\t' '$6=="accessToken"{print $7}' "$COOKIEJAR")
55
+ CFID=$(awk -F'\t' '$6=="cfid"{print $7}' "$COOKIEJAR")
56
+ CFTOKEN=$(awk -F'\t' '$6=="cftoken"{print $7}' "$COOKIEJAR")
57
+ [ -z "$ACCESS_TOKEN" ] && echo "LOGIN FAILED (no accessToken cookie set)" >&2
58
+ COOKIE_HEADER="accessToken=${ACCESS_TOKEN}; cfid=${CFID}; cftoken=${CFTOKEN}"
59
+ ```
60
+
61
+ This mirrors `sessionLoginFlow` exactly: GET `/login` for the CSRF token +
62
+ `cfid`/`cftoken`, POST the credentials with the exact same static form fields
63
+ the wizard sends, and read the `accessToken` cookie back out of the jar as
64
+ the success marker.
65
+
66
+ ## Core call pattern
67
+
68
+ Every authenticated call carries **both** headers — a Bearer JWT for the v3
69
+ API and the raw cookie header for the legacy dispatcher (the client sends
70
+ both on every request regardless of which surface it's hitting):
71
+
72
+ ```sh
73
+ curl -s 'https://api.signupgenius.com/v3/member/profile/' \
74
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
75
+ -H "Cookie: $COOKIE_HEADER" | jq .
76
+ ```
77
+
78
+ ```sh
79
+ curl -s -X POST 'https://www.signupgenius.com/SUGboxAPI.cfm?go=t.getMySignups' \
80
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
81
+ -H "Cookie: $COOKIE_HEADER" \
82
+ -H 'Content-Type: application/json' \
83
+ -d '{}' | jq .
84
+ ```
85
+
86
+ Ready-to-run request bodies for every tool endpoint (groups, sign-up
87
+ listings, public sign-up lookup, and the 3-step RSVP flow) are in
88
+ `references/sug-endpoints.md`.
89
+
90
+ ## The two envelope shapes
91
+
92
+ - v3 API (`api.signupgenius.com/v3/...`) returns **lower-case**
93
+ `{data, message, success}`.
94
+ - The legacy dispatcher (`/SUGboxAPI.cfm?go=...`) returns **upper-case**
95
+ `{DATA, MESSAGE, SUCCESS}` — `MESSAGE` may be a bare string instead of an
96
+ array.
97
+
98
+ `jq` recipes in the reference file account for the case difference per
99
+ endpoint.
100
+
101
+ ## Session expiry
102
+
103
+ Two signals mean the session lapsed — re-run the login step and retry:
104
+
105
+ - an HTTP `401` from either surface, or
106
+ - an HTTP `200` whose body is HTML containing `loginform`/`loginemail`/
107
+ `go=c.Login` (the server quietly bounced you to the login page instead of
108
+ returning JSON).
109
+
110
+ A `403` is a Pro-permission failure, not expiry — don't retry the login for
111
+ that one.
112
+
113
+ ## Out of scope for this skill
114
+
115
+ - **Pro API key mode** (`SIGNUPGENIUS_USER_KEY`, `Authorization: <key>` against
116
+ `api.signupgenius.com/v2/k`) is a different auth entirely — it's the only
117
+ mode that can call the slot-report endpoints
118
+ (`/signups/report/{all,filled,available}/{signupId}`), which are not
119
+ reachable in session mode at all (no v3 equivalent exists). Not covered
120
+ here since this skill is the email/password session path.
121
+ - **fetchproxy** (lifting these same cookies out of a signed-in browser tab)
122
+ is the MCP's fallback for when no env credentials are set. This skill
123
+ always logs in directly, so fetchproxy/the Transporter extension is never
124
+ needed.
@@ -0,0 +1,200 @@
1
+ # SignUpGenius session-mode endpoints for curl
2
+
3
+ All calls assume `$ACCESS_TOKEN` and `$COOKIE_HEADER` from the login step in
4
+ `../SKILL.md`. Every call sends both:
5
+
6
+ ```
7
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER"
8
+ ```
9
+
10
+ Paths under `api.signupgenius.com/v3` are the exact paths `signupgenius-mcp`'s
11
+ `client.ts` builds in session mode (it appends a trailing `/` to every v3
12
+ path — included below). Legacy calls POST JSON to
13
+ `https://www.signupgenius.com/SUGboxAPI.cfm?go=<action>` with
14
+ `Content-Type: application/json`.
15
+
16
+ ---
17
+
18
+ ## 1. Profile
19
+
20
+ ```sh
21
+ curl -s 'https://api.signupgenius.com/v3/member/profile/' \
22
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" | jq '.data'
23
+ ```
24
+
25
+ ## 2. Groups
26
+
27
+ List groups (`sort` is optional, `asc`/`desc`):
28
+
29
+ ```sh
30
+ curl -s 'https://api.signupgenius.com/v3/groups/all/?sort=asc' \
31
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" \
32
+ | jq -r '.data[] | "\(.groupid)\t\(.title)"'
33
+ ```
34
+
35
+ List a group's members (`GROUP_ID` from above):
36
+
37
+ ```sh
38
+ curl -s "https://api.signupgenius.com/v3/groups/${GROUP_ID}/members/" \
39
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" \
40
+ | jq -r '.data[] | "\(.communitymemberid)\t\(.emailaddress)"'
41
+ ```
42
+
43
+ Get one member's detail (address/phone — only present if they supplied it on
44
+ a sign-up):
45
+
46
+ ```sh
47
+ curl -s "https://api.signupgenius.com/v3/groups/${GROUP_ID}/members/${MEMBER_ID}/details/" \
48
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" | jq '.data'
49
+ ```
50
+
51
+ Add a member (**write** — confirm with the user first; `firstname`/`lastname`
52
+ are optional):
53
+
54
+ ```sh
55
+ curl -s -X POST "https://api.signupgenius.com/v3/groups/${GROUP_ID}/members/create/" \
56
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" \
57
+ -H 'Content-Type: application/json' \
58
+ -d '{"emailaddress":"new-member@example.com","firstname":"Jane","lastname":"Doe"}' \
59
+ | jq '.success, .message'
60
+ ```
61
+
62
+ ## 3. Sign-up listings
63
+
64
+ In session mode `created`/`invited`/`signedupfor` each have **one** v3 path
65
+ each (unlike key mode's separate `/active`/`/expired`/`/all` paths) — filter
66
+ on `enddate` client-side if you only want active ones:
67
+
68
+ ```sh
69
+ # everything the account created (active + expired together)
70
+ curl -s 'https://api.signupgenius.com/v3/signups/created/' \
71
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" \
72
+ | jq -r '.data[] | "\(.signupid)\t\(.enddate)\t\(.title)"'
73
+
74
+ # sign-ups invited to
75
+ curl -s 'https://api.signupgenius.com/v3/signups/invited/' \
76
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" | jq '.data'
77
+
78
+ # sign-ups personally signed up for
79
+ curl -s 'https://api.signupgenius.com/v3/signups/signedupfor/' \
80
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" | jq '.data'
81
+ ```
82
+
83
+ Legacy dispatcher equivalent — sometimes returns fuller data than v3
84
+ (note the **upper-case** envelope):
85
+
86
+ ```sh
87
+ curl -s -X POST 'https://www.signupgenius.com/SUGboxAPI.cfm?go=t.getMySignups' \
88
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" \
89
+ -H 'Content-Type: application/json' -d '{}' \
90
+ | jq -r '.DATA[] | "\(.signupid)\t\(.title)"'
91
+ ```
92
+
93
+ ## 4. Public sign-up lookup (no auth)
94
+
95
+ Any sign-up's public page is server-rendered HTML at
96
+ `https://www.signupgenius.com/go/<urlid>-<signupid>[-<vanity>]` — no login
97
+ needed, works even without the login step above:
98
+
99
+ ```sh
100
+ curl -s 'https://www.signupgenius.com/go/10C054DA9AF2BA0FEC07-63774883-myers' -o /tmp/sug-page.html
101
+
102
+ grep -oE '<h1 class="SUGHeaderText">[^<]*' /tmp/sug-page.html | sed 's/.*>//' # title
103
+ grep -A2 '<strong>Date' /tmp/sug-page.html | head -1 # date (needs HTML stripping)
104
+ ```
105
+
106
+ There's no JSON surface for this page — `signupgenius-mcp`'s
107
+ `tools/public-signup.ts` regex-scrapes the same landmarks
108
+ (`h1.SUGHeaderText`, `<strong>Date/Time/Location</strong>`, the
109
+ `creator-info` table, and `Yes:/No:/Maybe:` response counts). Reproduce with
110
+ `grep`/`sed` for a quick look, or pull the regexes straight from that file for
111
+ anything more than a spot-check.
112
+
113
+ ## 5. RSVP (write, 3-step flow)
114
+
115
+ RSVP-only (Yes/No/Maybe headcount) sheets. **Confirm with the user before
116
+ running step 3** — it's the real submit. Get `URLID` (the full slug) by
117
+ parsing the public URL as in §4.
118
+
119
+ **Step 1 — PreProcessSignup** (sets a server-side session pointer; without
120
+ this every SUGboxAPI call below 404s with "none to be processed"). Expect
121
+ **301/302**, not 200:
122
+
123
+ ```sh
124
+ curl -s -o /dev/null -w '%{http_code}\n' -D - \
125
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" \
126
+ -H 'Content-Type: application/x-www-form-urlencoded' -H 'Accept: text/html' \
127
+ -X POST "https://www.signupgenius.com/index.cfm?go=s.PreProcessSignup&URLID=${URLID}" \
128
+ --data 'ScreenWidth=2000&ScreenHeight=1200'
129
+ ```
130
+
131
+ **Step 2 — getSignupInfo** (fetch `useRSVP` + `rsvpdetails.slotid`; reject if
132
+ `useRSVP != 1` or `rsvpdetails.rsvpitems` is non-empty — that's an item-based
133
+ sheet, unsupported by this flow):
134
+
135
+ ```sh
136
+ curl -s -X POST 'https://www.signupgenius.com/SUGboxAPI.cfm?go=s.getSignupInfo' \
137
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" \
138
+ -H 'Content-Type: application/json' \
139
+ -d "{\"urlid\":\"${URLID}\"}" | tee /tmp/sug-signupinfo.json | jq '.DATA | {useRSVP, owner, id, title, slotid: .rsvpdetails.slotid}'
140
+ ```
141
+
142
+ **Step 3 — processSignUpFormHandler** (the actual RSVP submit). `RSVPITEMS`
143
+ must always be present as `[]` — the CFML validator throws
144
+ `key [RSVPITEMS] doesn't exist` if it's omitted. `changemembermame` is
145
+ SignUpGenius's own typo — preserve it verbatim. `rsvpresponse` is a single
146
+ letter: `y`/`n`/`m`. A `n` response forces both guest counts to `0`
147
+ regardless of what you pass:
148
+
149
+ ```sh
150
+ OWNER=$(jq -r '.DATA.owner' /tmp/sug-signupinfo.json)
151
+ LISTID=$(jq -r '.DATA.id' /tmp/sug-signupinfo.json)
152
+ TITLE=$(jq -r '.DATA.title' /tmp/sug-signupinfo.json)
153
+ SLOTID=$(jq -r '.DATA.rsvpdetails.slotid' /tmp/sug-signupinfo.json)
154
+
155
+ curl -s -X POST 'https://www.signupgenius.com/SUGboxAPI.cfm?go=s.processSignUpFormHandler' \
156
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Cookie: $COOKIE_HEADER" \
157
+ -H 'Content-Type: application/json' \
158
+ -d @- <<JSON | jq '.SUCCESS, .MESSAGE'
159
+ {
160
+ "listid": ${LISTID},
161
+ "owner": ${OWNER},
162
+ "urlid": "${URLID}",
163
+ "title": "${TITLE}",
164
+ "siid": "",
165
+ "rsvpid": 0,
166
+ "imid": 0,
167
+ "usealternatename": false,
168
+ "changemembermame": false,
169
+ "displayfirstname": "Jane",
170
+ "displaylastname": "Doe",
171
+ "firstname": "Jane",
172
+ "lastname": "Doe",
173
+ "email": "jane@example.com",
174
+ "optInStatus": false,
175
+ "savecontactinfo": false,
176
+ "rsvpresponse": "y",
177
+ "rsvpadult": 1,
178
+ "rsvpchildren": 0,
179
+ "rsvpitems": [],
180
+ "rsvpcomments": "",
181
+ "type": "rsvp",
182
+ "source": "main",
183
+ "slotid": ${SLOTID},
184
+ "isLoggedin": true,
185
+ "payLater": false,
186
+ "customFields": []
187
+ }
188
+ JSON
189
+ ```
190
+
191
+ ## Omitted — not reachable in session mode
192
+
193
+ Slot-report endpoints (`/signups/report/all|filled|available/{signupId}`)
194
+ require `SIGNUPGENIUS_USER_KEY` (Pro API key mode, `Authorization: <key>`
195
+ against `api.signupgenius.com/v2/k`, `user_key` in the query string) — a
196
+ different auth entirely from the session login this skill uses. No v3
197
+ equivalent was found during the MCP's recon (`src/tools/reports.ts`), so
198
+ there's nothing to transcribe for session mode. Slot-based (non-headcount)
199
+ sign-ups also have no submit flow here — the wizard's `s.getSignUpFormItems`
200
+ + per-item payload was never captured.