signupgenius-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/bundle.js +31526 -0
- package/dist/client.js +190 -0
- package/dist/config.js +75 -0
- package/dist/index.js +32 -0
- package/dist/session-login.js +126 -0
- package/dist/tools/_shared.js +4 -0
- package/dist/tools/groups.js +67 -0
- package/dist/tools/reports.js +29 -0
- package/dist/tools/signups.js +64 -0
- package/dist/tools/user.js +13 -0
- package/package.json +51 -0
- package/server.json +66 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { sessionLogin as defaultSessionLogin } from './session-login.js';
|
|
2
|
+
export class SignUpGeniusClient {
|
|
3
|
+
account;
|
|
4
|
+
accessToken = null;
|
|
5
|
+
cookieHeader = null;
|
|
6
|
+
refreshInFlight = null;
|
|
7
|
+
sessionLoginFn;
|
|
8
|
+
constructor(account, opts = {}) {
|
|
9
|
+
this.account = account;
|
|
10
|
+
this.sessionLoginFn = opts.sessionLogin ?? defaultSessionLogin;
|
|
11
|
+
}
|
|
12
|
+
describe() {
|
|
13
|
+
return { name: this.account.name, mode: this.account.mode, baseUrl: this.account.baseUrl };
|
|
14
|
+
}
|
|
15
|
+
/** Account mode shorthand for tool registration logic. */
|
|
16
|
+
get mode() {
|
|
17
|
+
return this.account.mode;
|
|
18
|
+
}
|
|
19
|
+
async request(path, opts = {}) {
|
|
20
|
+
if (this.account.mode === 'session' && opts.legacyAction) {
|
|
21
|
+
return this.requestLegacy(opts.legacyAction, opts);
|
|
22
|
+
}
|
|
23
|
+
return this.requestApi(path, opts);
|
|
24
|
+
}
|
|
25
|
+
/** Throws if the current mode can't satisfy the call — e.g. Pro-only reports. */
|
|
26
|
+
requireMode(mode, featureLabel) {
|
|
27
|
+
if (this.account.mode !== mode) {
|
|
28
|
+
throw new ModeMismatchError(this.account.mode, mode, featureLabel);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async requestApi(path, opts) {
|
|
32
|
+
const acct = this.account;
|
|
33
|
+
const normalizedPath = path.endsWith('/') ? path : `${path}/`;
|
|
34
|
+
const params = new URLSearchParams();
|
|
35
|
+
if (acct.mode === 'key')
|
|
36
|
+
params.set('user_key', acct.userKey);
|
|
37
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
38
|
+
if (v !== undefined)
|
|
39
|
+
params.set(k, String(v));
|
|
40
|
+
}
|
|
41
|
+
const qs = params.toString();
|
|
42
|
+
const url = `${acct.baseUrl}${normalizedPath}${qs ? `?${qs}` : ''}`;
|
|
43
|
+
const res = await this.authedFetch(url, {
|
|
44
|
+
method: opts.method ?? 'GET',
|
|
45
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
46
|
+
headers: opts.body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
|
47
|
+
});
|
|
48
|
+
return parseEnvelope(res, path, normalizeKeyShape);
|
|
49
|
+
}
|
|
50
|
+
async requestLegacy(action, opts) {
|
|
51
|
+
// `request()` only routes here in session mode — narrow the union.
|
|
52
|
+
const acct = this.account;
|
|
53
|
+
const url = `${acct.legacyBaseUrl}/SUGboxAPI.cfm?go=${encodeURIComponent(action)}`;
|
|
54
|
+
const res = await this.authedFetch(url, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
body: JSON.stringify(opts.body ?? {}),
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
});
|
|
59
|
+
return parseEnvelope(res, action, normalizeLegacyShape);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Fetch with auth attached. Session mode logs in lazily on first call and
|
|
63
|
+
* re-mints credentials transparently on a 401. Key mode is stateless.
|
|
64
|
+
*/
|
|
65
|
+
async authedFetch(url, init, isRetry = false) {
|
|
66
|
+
await this.ensureAuth();
|
|
67
|
+
const res = await fetch(url, {
|
|
68
|
+
method: init.method,
|
|
69
|
+
headers: { Accept: 'application/json', ...(init.headers ?? {}), ...this.authHeaders() },
|
|
70
|
+
body: init.body,
|
|
71
|
+
});
|
|
72
|
+
if (res.status === 401 && this.account.mode === 'session' && !isRetry) {
|
|
73
|
+
await this.ensureAuth({ force: true });
|
|
74
|
+
return this.authedFetch(url, init, true);
|
|
75
|
+
}
|
|
76
|
+
return res;
|
|
77
|
+
}
|
|
78
|
+
authHeaders() {
|
|
79
|
+
if (this.account.mode === 'session') {
|
|
80
|
+
return { Authorization: `Bearer ${this.accessToken}`, Cookie: this.cookieHeader };
|
|
81
|
+
}
|
|
82
|
+
return {}; // key mode: user_key is in the query string
|
|
83
|
+
}
|
|
84
|
+
async ensureAuth(opts = {}) {
|
|
85
|
+
if (this.account.mode !== 'session')
|
|
86
|
+
return;
|
|
87
|
+
if (!opts.force && this.accessToken && this.cookieHeader)
|
|
88
|
+
return;
|
|
89
|
+
if (this.refreshInFlight)
|
|
90
|
+
return this.refreshInFlight;
|
|
91
|
+
const acct = this.account;
|
|
92
|
+
this.refreshInFlight = (async () => {
|
|
93
|
+
const result = await this.sessionLoginFn({
|
|
94
|
+
loginUrl: acct.loginBaseUrl,
|
|
95
|
+
email: acct.email,
|
|
96
|
+
password: acct.password,
|
|
97
|
+
});
|
|
98
|
+
this.accessToken = result.accessToken;
|
|
99
|
+
this.cookieHeader = result.cookieHeader;
|
|
100
|
+
})();
|
|
101
|
+
try {
|
|
102
|
+
await this.refreshInFlight;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
this.refreshInFlight = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const normalizeKeyShape = (raw) => {
|
|
110
|
+
if (!raw || typeof raw !== 'object')
|
|
111
|
+
return null;
|
|
112
|
+
const r = raw;
|
|
113
|
+
return { data: r.data, message: r.message ?? [], success: r.success ?? false };
|
|
114
|
+
};
|
|
115
|
+
const normalizeLegacyShape = (raw) => {
|
|
116
|
+
if (!raw || typeof raw !== 'object')
|
|
117
|
+
return null;
|
|
118
|
+
const r = raw;
|
|
119
|
+
const m = r.MESSAGE;
|
|
120
|
+
return {
|
|
121
|
+
data: r.DATA,
|
|
122
|
+
message: Array.isArray(m) ? m : m ? [m] : [],
|
|
123
|
+
success: r.SUCCESS ?? false,
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
async function parseEnvelope(res, context, normalize) {
|
|
127
|
+
const text = await res.text();
|
|
128
|
+
const raw = parseJsonBody(text);
|
|
129
|
+
const parsed = raw !== null ? normalize(raw) : null;
|
|
130
|
+
const msg = parsed?.message.join('; ');
|
|
131
|
+
if (res.status === 401 || res.status === 403)
|
|
132
|
+
throw new AuthError(res.status, msg);
|
|
133
|
+
if (res.status === 404)
|
|
134
|
+
throw new Error(`SignUpGenius 404 ${context}`);
|
|
135
|
+
if (res.status >= 500)
|
|
136
|
+
throw new UnreachableError(res.status);
|
|
137
|
+
if (!res.ok)
|
|
138
|
+
throw new Error(`SignUpGenius ${res.status} ${msg || res.statusText} for ${context}`);
|
|
139
|
+
if (!parsed) {
|
|
140
|
+
throw new Error(`SignUpGenius returned ${text === '' ? 'empty' : 'non-JSON'} body for ${context}`);
|
|
141
|
+
}
|
|
142
|
+
if (!parsed.success) {
|
|
143
|
+
throw new Error(`SignUpGenius error: ${msg && msg.length > 0 ? msg : 'unknown'}`);
|
|
144
|
+
}
|
|
145
|
+
return parsed;
|
|
146
|
+
}
|
|
147
|
+
function parseJsonBody(text) {
|
|
148
|
+
if (!text)
|
|
149
|
+
return null;
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(text);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export class AuthError extends Error {
|
|
158
|
+
status;
|
|
159
|
+
constructor(status, detail) {
|
|
160
|
+
super(`SignUpGenius rejected the request (${status}). ` +
|
|
161
|
+
'For key mode: check SIGNUPGENIUS_USER_KEY (it may be wrong, revoked, or the account no longer has Pro). ' +
|
|
162
|
+
'For session mode: the session cookie may have been invalidated server-side.' +
|
|
163
|
+
(detail ? ` (${detail})` : ''));
|
|
164
|
+
this.status = status;
|
|
165
|
+
this.name = 'AuthError';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export class UnreachableError extends Error {
|
|
169
|
+
status;
|
|
170
|
+
constructor(status) {
|
|
171
|
+
super(`SignUpGenius unreachable (status ${status})`);
|
|
172
|
+
this.status = status;
|
|
173
|
+
this.name = 'UnreachableError';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export class ModeMismatchError extends Error {
|
|
177
|
+
currentMode;
|
|
178
|
+
requiredMode;
|
|
179
|
+
feature;
|
|
180
|
+
constructor(currentMode, requiredMode, feature) {
|
|
181
|
+
super(`${feature} requires ${requiredMode} mode but the server is running in ${currentMode} mode. ` +
|
|
182
|
+
(requiredMode === 'key'
|
|
183
|
+
? 'Set SIGNUPGENIUS_USER_KEY (Pro subscription required for the documented v2/k API).'
|
|
184
|
+
: 'Set SIGNUPGENIUS_EMAIL + SIGNUPGENIUS_PASSWORD to enable this tool.'));
|
|
185
|
+
this.currentMode = currentMode;
|
|
186
|
+
this.requiredMode = requiredMode;
|
|
187
|
+
this.feature = feature;
|
|
188
|
+
this.name = 'ModeMismatchError';
|
|
189
|
+
}
|
|
190
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const DEFAULT_KEY_BASE_URL = 'https://api.signupgenius.com/v2/k';
|
|
2
|
+
const DEFAULT_V3_BASE_URL = 'https://api.signupgenius.com/v3';
|
|
3
|
+
const DEFAULT_LEGACY_BASE_URL = 'https://www.signupgenius.com';
|
|
4
|
+
const DEFAULT_LOGIN_BASE_URL = 'https://www.signupgenius.com';
|
|
5
|
+
/**
|
|
6
|
+
* Read an env var and treat empty/placeholder values as unset. Some MCP hosts
|
|
7
|
+
* stringify undefined user_config refs (Claude Desktop emits the literal
|
|
8
|
+
* "undefined"; others leave the `${user_config.foo}` placeholder intact), and
|
|
9
|
+
* a Bearer-style header built from those would silently authenticate as the
|
|
10
|
+
* wrong identity or fail upstream with a confusing 403.
|
|
11
|
+
*/
|
|
12
|
+
function readVar(env, key) {
|
|
13
|
+
const raw = env[key];
|
|
14
|
+
if (typeof raw !== 'string')
|
|
15
|
+
return undefined;
|
|
16
|
+
const trimmed = raw.trim();
|
|
17
|
+
if (trimmed.length === 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (trimmed === 'undefined' || trimmed === 'null')
|
|
20
|
+
return undefined;
|
|
21
|
+
if (/^\$\{[^}]*\}$/.test(trimmed))
|
|
22
|
+
return undefined;
|
|
23
|
+
return trimmed;
|
|
24
|
+
}
|
|
25
|
+
function requireHttps(value, varName) {
|
|
26
|
+
if (!/^https:\/\//.test(value)) {
|
|
27
|
+
throw new Error(`${varName} must be an https URL, got: '${value}'`);
|
|
28
|
+
}
|
|
29
|
+
return value.replace(/\/$/, '');
|
|
30
|
+
}
|
|
31
|
+
export function loadAccount(env = process.env) {
|
|
32
|
+
const userKey = readVar(env, 'SIGNUPGENIUS_USER_KEY');
|
|
33
|
+
const email = readVar(env, 'SIGNUPGENIUS_EMAIL');
|
|
34
|
+
const password = readVar(env, 'SIGNUPGENIUS_PASSWORD');
|
|
35
|
+
const hasFullSession = !!email && !!password;
|
|
36
|
+
const hasPartialSession = (!!email) !== (!!password);
|
|
37
|
+
// Precedence: explicit user_key wins (the documented Pro path). Surface a
|
|
38
|
+
// warning when both modes are configured so the user knows which one is
|
|
39
|
+
// actually being used.
|
|
40
|
+
if (userKey) {
|
|
41
|
+
const baseUrlRaw = readVar(env, 'SIGNUPGENIUS_BASE_URL') ?? DEFAULT_KEY_BASE_URL;
|
|
42
|
+
const baseUrl = requireHttps(baseUrlRaw, 'SIGNUPGENIUS_BASE_URL');
|
|
43
|
+
const name = readVar(env, 'SIGNUPGENIUS_NAME') ?? new URL(baseUrl).host;
|
|
44
|
+
if (hasFullSession) {
|
|
45
|
+
console.error('[signupgenius-mcp] SIGNUPGENIUS_USER_KEY takes precedence over SIGNUPGENIUS_EMAIL/PASSWORD — using key mode. ' +
|
|
46
|
+
'Unset the key to use session mode.');
|
|
47
|
+
}
|
|
48
|
+
else if (hasPartialSession) {
|
|
49
|
+
console.error(`[signupgenius-mcp] Ignoring partial session credentials (only ${email ? 'EMAIL' : 'PASSWORD'} set) — using SIGNUPGENIUS_USER_KEY.`);
|
|
50
|
+
}
|
|
51
|
+
return { mode: 'key', name, baseUrl, userKey };
|
|
52
|
+
}
|
|
53
|
+
if (hasPartialSession) {
|
|
54
|
+
const missing = email ? 'SIGNUPGENIUS_PASSWORD' : 'SIGNUPGENIUS_EMAIL';
|
|
55
|
+
throw new Error(`Incomplete session config — missing: ${missing}. ` +
|
|
56
|
+
'Set both SIGNUPGENIUS_EMAIL and SIGNUPGENIUS_PASSWORD, or use SIGNUPGENIUS_USER_KEY instead.');
|
|
57
|
+
}
|
|
58
|
+
if (hasFullSession) {
|
|
59
|
+
const baseUrl = requireHttps(readVar(env, 'SIGNUPGENIUS_BASE_URL') ?? DEFAULT_V3_BASE_URL, 'SIGNUPGENIUS_BASE_URL');
|
|
60
|
+
const legacyBaseUrl = requireHttps(readVar(env, 'SIGNUPGENIUS_LEGACY_BASE_URL') ?? DEFAULT_LEGACY_BASE_URL, 'SIGNUPGENIUS_LEGACY_BASE_URL');
|
|
61
|
+
const loginBaseUrl = requireHttps(readVar(env, 'SIGNUPGENIUS_LOGIN_URL') ?? DEFAULT_LOGIN_BASE_URL, 'SIGNUPGENIUS_LOGIN_URL');
|
|
62
|
+
const name = readVar(env, 'SIGNUPGENIUS_NAME') ?? email;
|
|
63
|
+
return {
|
|
64
|
+
mode: 'session',
|
|
65
|
+
name,
|
|
66
|
+
baseUrl,
|
|
67
|
+
legacyBaseUrl,
|
|
68
|
+
loginBaseUrl,
|
|
69
|
+
email: email,
|
|
70
|
+
password: password,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
throw new Error('Missing SignUpGenius auth config. Set SIGNUPGENIUS_USER_KEY (Pro API), ' +
|
|
74
|
+
'or SIGNUPGENIUS_EMAIL + SIGNUPGENIUS_PASSWORD (session mode, free accounts).');
|
|
75
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
try {
|
|
5
|
+
const { config } = await import('dotenv');
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
// quiet:true suppresses dotenv's startup banner — MCP uses stdout for
|
|
8
|
+
// JSON-RPC, and any extra output corrupts the stream.
|
|
9
|
+
config({ path: join(__dirname, '..', '.env'), override: false, quiet: true });
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// dotenv not available — rely on process.env
|
|
13
|
+
}
|
|
14
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
15
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
|
+
import { loadAccount } from './config.js';
|
|
17
|
+
import { SignUpGeniusClient } from './client.js';
|
|
18
|
+
import { registerUserTools } from './tools/user.js';
|
|
19
|
+
import { registerGroupTools } from './tools/groups.js';
|
|
20
|
+
import { registerSignUpTools } from './tools/signups.js';
|
|
21
|
+
import { registerReportTools } from './tools/reports.js';
|
|
22
|
+
const account = loadAccount();
|
|
23
|
+
const client = new SignUpGeniusClient(account);
|
|
24
|
+
const server = new McpServer({ name: 'signupgenius', version: '1.0.0' });
|
|
25
|
+
registerUserTools(server, client);
|
|
26
|
+
registerGroupTools(server, client);
|
|
27
|
+
registerSignUpTools(server, client);
|
|
28
|
+
registerReportTools(server, client);
|
|
29
|
+
console.error(`[signupgenius-mcp] SignUpGenius: ${account.name} (${account.baseUrl}) [${account.mode}]`);
|
|
30
|
+
console.error('[signupgenius-mcp] Developed and maintained by AI (Claude). Use at your own discretion.');
|
|
31
|
+
const transport = new StdioServerTransport();
|
|
32
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logs in to signupgenius.com with email+password and returns the JWT access
|
|
3
|
+
* token plus the cookies needed by the legacy SUGboxAPI.cfm dispatcher.
|
|
4
|
+
*
|
|
5
|
+
* Flow (reverse-engineered from the public login page):
|
|
6
|
+
* 1. GET /login — page contains a `csrfToken` hidden input and sets
|
|
7
|
+
* cfid/cftoken cookies.
|
|
8
|
+
* 2. POST /index.cfm?go=c.Login with the form fields the page would submit,
|
|
9
|
+
* including the scraped csrfToken. On success the server 302s and sets
|
|
10
|
+
* an `accessToken` JWT cookie scoped to .signupgenius.com.
|
|
11
|
+
*
|
|
12
|
+
* SSO accounts (Google/Apple/Facebook/Microsoft) and 2FA-enabled accounts are
|
|
13
|
+
* unsupported — same caveat as canvas-parent-mcp's session mode.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_LOGIN_BASE = 'https://www.signupgenius.com';
|
|
16
|
+
export async function sessionLogin(input) {
|
|
17
|
+
const base = input.loginUrl ?? DEFAULT_LOGIN_BASE;
|
|
18
|
+
const jar = new CookieJar();
|
|
19
|
+
// Step 1: fetch login page, extract csrfToken
|
|
20
|
+
const loginPage = await fetch(`${base}/login`, {
|
|
21
|
+
headers: { 'User-Agent': USER_AGENT, Accept: 'text/html' },
|
|
22
|
+
});
|
|
23
|
+
if (!loginPage.ok) {
|
|
24
|
+
throw new LoginFailedError(`login page returned ${loginPage.status}`);
|
|
25
|
+
}
|
|
26
|
+
jar.absorb(loginPage.headers);
|
|
27
|
+
const html = await loginPage.text();
|
|
28
|
+
const csrfMatch = /name="csrfToken"\s+value="([^"]+)"/.exec(html);
|
|
29
|
+
if (!csrfMatch) {
|
|
30
|
+
throw new LoginFailedError('csrfToken not found on login page');
|
|
31
|
+
}
|
|
32
|
+
const csrfToken = csrfMatch[1];
|
|
33
|
+
// Step 2: POST credentials
|
|
34
|
+
const body = new URLSearchParams({
|
|
35
|
+
csrfToken,
|
|
36
|
+
loginemail: input.email,
|
|
37
|
+
pword: input.password,
|
|
38
|
+
successpage: 'c.jump&jump=/index.cfm?go=c.MyAccount',
|
|
39
|
+
failpage: 'c.Register',
|
|
40
|
+
ScreenWidth: '2000',
|
|
41
|
+
ScreenHeight: '1200',
|
|
42
|
+
formaction: '1',
|
|
43
|
+
formName: 'loginform',
|
|
44
|
+
refererUrl: '',
|
|
45
|
+
}).toString();
|
|
46
|
+
const postRes = await fetch(`${base}/index.cfm?go=c.Login`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
redirect: 'manual',
|
|
49
|
+
headers: {
|
|
50
|
+
'User-Agent': USER_AGENT,
|
|
51
|
+
Accept: 'text/html',
|
|
52
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
53
|
+
Referer: `${base}/login`,
|
|
54
|
+
Cookie: jar.header(),
|
|
55
|
+
},
|
|
56
|
+
body,
|
|
57
|
+
});
|
|
58
|
+
jar.absorb(postRes.headers);
|
|
59
|
+
// A successful login redirects to /index.cfm?go=c.MyAccount. A failed login
|
|
60
|
+
// 302s to /index.cfm?go=c.Register (the configured failpage) or returns 200
|
|
61
|
+
// with the login page rerendered. Either way, no accessToken cookie is set.
|
|
62
|
+
const accessToken = jar.get('accessToken');
|
|
63
|
+
if (!accessToken) {
|
|
64
|
+
const location = postRes.headers.get('location') ?? '';
|
|
65
|
+
const detail = location.includes('c.Register')
|
|
66
|
+
? 'login form rejected the credentials'
|
|
67
|
+
: `login did not yield an accessToken (status ${postRes.status})`;
|
|
68
|
+
throw new LoginFailedError(detail);
|
|
69
|
+
}
|
|
70
|
+
return { accessToken, cookieHeader: jar.header() };
|
|
71
|
+
}
|
|
72
|
+
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';
|
|
73
|
+
/** Minimal Netscape-cookie-style jar — only tracks name/value, no domain matching. */
|
|
74
|
+
class CookieJar {
|
|
75
|
+
cookies = new Map();
|
|
76
|
+
absorb(headers) {
|
|
77
|
+
const setCookies = readSetCookieHeaders(headers);
|
|
78
|
+
for (const raw of setCookies) {
|
|
79
|
+
const semi = raw.indexOf(';');
|
|
80
|
+
const pair = semi >= 0 ? raw.slice(0, semi) : raw;
|
|
81
|
+
const eq = pair.indexOf('=');
|
|
82
|
+
if (eq <= 0)
|
|
83
|
+
continue;
|
|
84
|
+
const name = pair.slice(0, eq).trim();
|
|
85
|
+
const value = pair.slice(eq + 1).trim();
|
|
86
|
+
if (!name)
|
|
87
|
+
continue;
|
|
88
|
+
this.cookies.set(name, value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
get(name) {
|
|
92
|
+
return this.cookies.get(name);
|
|
93
|
+
}
|
|
94
|
+
header() {
|
|
95
|
+
return Array.from(this.cookies.entries())
|
|
96
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
97
|
+
.join('; ');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Node's Headers can carry multiple Set-Cookie values; Headers.get joins them
|
|
102
|
+
* with ", " which breaks parsing because cookie attributes also use commas
|
|
103
|
+
* (Expires=Wed, 17 Jun 2026 ...). Headers.getSetCookie() is the spec'd way
|
|
104
|
+
* (Node ≥19, undici ≥5.2) — fall back to the single-string getter for older
|
|
105
|
+
* runtimes by splitting only on commas that precede a `name=` token.
|
|
106
|
+
*/
|
|
107
|
+
function readSetCookieHeaders(headers) {
|
|
108
|
+
const anyHeaders = headers;
|
|
109
|
+
if (typeof anyHeaders.getSetCookie === 'function') {
|
|
110
|
+
return anyHeaders.getSetCookie();
|
|
111
|
+
}
|
|
112
|
+
const raw = headers.get('set-cookie');
|
|
113
|
+
if (!raw)
|
|
114
|
+
return [];
|
|
115
|
+
return raw.split(/,(?=\s*[A-Za-z0-9_-]+=)/);
|
|
116
|
+
}
|
|
117
|
+
export class LoginFailedError extends Error {
|
|
118
|
+
detail;
|
|
119
|
+
constructor(detail) {
|
|
120
|
+
super(`SignUpGenius login failed: ${detail}. ` +
|
|
121
|
+
'Verify SIGNUPGENIUS_EMAIL / SIGNUPGENIUS_PASSWORD. ' +
|
|
122
|
+
'SSO accounts (Google/Apple/Facebook/Microsoft) and 2FA-enabled accounts are not supported in session mode.');
|
|
123
|
+
this.detail = detail;
|
|
124
|
+
this.name = 'LoginFailedError';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textContent } from './_shared.js';
|
|
3
|
+
const listGroupsArgs = z.object({
|
|
4
|
+
sort: z.enum(['asc', 'desc']).optional().describe('Sort order for group results.'),
|
|
5
|
+
});
|
|
6
|
+
const groupIdArgs = z.object({
|
|
7
|
+
groupId: z.number().int().positive().describe('Group ID returned by signupgenius_list_groups.'),
|
|
8
|
+
sort: z.enum(['asc', 'desc']).optional(),
|
|
9
|
+
});
|
|
10
|
+
const memberDetailArgs = z.object({
|
|
11
|
+
groupId: z.number().int().positive(),
|
|
12
|
+
memberId: z.number().int().positive().describe('communitymemberid returned by signupgenius_list_group_members.'),
|
|
13
|
+
});
|
|
14
|
+
const addMemberArgs = z.object({
|
|
15
|
+
groupId: z.number().int().positive(),
|
|
16
|
+
emailaddress: z.string().email().describe('Email of the member to add to the group.'),
|
|
17
|
+
firstname: z.string().optional(),
|
|
18
|
+
lastname: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
export function registerGroupTools(server, client) {
|
|
21
|
+
server.registerTool('signupgenius_list_groups', {
|
|
22
|
+
description: 'List groups created by the authenticated user. Returns groupid, title, and member count for each group.',
|
|
23
|
+
annotations: { readOnlyHint: true },
|
|
24
|
+
inputSchema: listGroupsArgs.shape,
|
|
25
|
+
}, async (raw) => {
|
|
26
|
+
const args = listGroupsArgs.parse(raw);
|
|
27
|
+
const path = client.mode === 'session' ? '/groups/all' : '/groups';
|
|
28
|
+
const data = await client.request(path, { query: { sort: args.sort } });
|
|
29
|
+
return textContent(data);
|
|
30
|
+
});
|
|
31
|
+
server.registerTool('signupgenius_list_group_members', {
|
|
32
|
+
description: 'List members of a SignUpGenius group (basic info: name, email, memberid).',
|
|
33
|
+
annotations: { readOnlyHint: true },
|
|
34
|
+
inputSchema: groupIdArgs.shape,
|
|
35
|
+
}, async (raw) => {
|
|
36
|
+
const args = groupIdArgs.parse(raw);
|
|
37
|
+
const data = await client.request(`/groups/${args.groupId}/members`, { query: { sort: args.sort } });
|
|
38
|
+
return textContent(data);
|
|
39
|
+
});
|
|
40
|
+
server.registerTool('signupgenius_get_group_member', {
|
|
41
|
+
description: 'Get detailed info for a group member (address, phone, email) when the member has provided it via a sign-up.',
|
|
42
|
+
annotations: { readOnlyHint: true },
|
|
43
|
+
inputSchema: memberDetailArgs.shape,
|
|
44
|
+
}, async (raw) => {
|
|
45
|
+
const args = memberDetailArgs.parse(raw);
|
|
46
|
+
const data = await client.request(`/groups/${args.groupId}/members/${args.memberId}/details`);
|
|
47
|
+
return textContent(data);
|
|
48
|
+
});
|
|
49
|
+
server.registerTool('signupgenius_add_group_member', {
|
|
50
|
+
description: 'Add a member to a SignUpGenius group by email address. First/last name are optional. ' +
|
|
51
|
+
'Writes data — confirm with the user before invoking.',
|
|
52
|
+
annotations: { readOnlyHint: false },
|
|
53
|
+
inputSchema: addMemberArgs.shape,
|
|
54
|
+
}, async (raw) => {
|
|
55
|
+
const args = addMemberArgs.parse(raw);
|
|
56
|
+
const body = { emailaddress: args.emailaddress };
|
|
57
|
+
if (args.firstname !== undefined)
|
|
58
|
+
body.firstname = args.firstname;
|
|
59
|
+
if (args.lastname !== undefined)
|
|
60
|
+
body.lastname = args.lastname;
|
|
61
|
+
const data = await client.request(`/groups/${args.groupId}/members/create`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
body,
|
|
64
|
+
});
|
|
65
|
+
return textContent(data);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textContent } from './_shared.js';
|
|
3
|
+
const reportArgs = z.object({
|
|
4
|
+
signupId: z.number().int().positive().describe('Sign-up ID (signupid).'),
|
|
5
|
+
});
|
|
6
|
+
/**
|
|
7
|
+
* Report endpoints are Pro-only. The session-mode v3 web API has no
|
|
8
|
+
* equivalent — only the sign-up owner can pull report data, and the v3 paths
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
export function registerReportTools(server, client) {
|
|
14
|
+
const register = (toolName, path, blurb) => {
|
|
15
|
+
server.registerTool(toolName, {
|
|
16
|
+
description: `${blurb} Requires SIGNUPGENIUS_USER_KEY (Pro subscription) — session mode is not supported for reports.`,
|
|
17
|
+
annotations: { readOnlyHint: true },
|
|
18
|
+
inputSchema: reportArgs.shape,
|
|
19
|
+
}, async (raw) => {
|
|
20
|
+
client.requireMode('key', toolName);
|
|
21
|
+
const args = reportArgs.parse(raw);
|
|
22
|
+
const data = await client.request(`${path}/${args.signupId}`);
|
|
23
|
+
return textContent(data);
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
register('signupgenius_report_all', '/signups/report/all', 'Full report for a sign-up: every slot plus the participant who claimed it (with custom-question answers when present).');
|
|
27
|
+
register('signupgenius_report_filled', '/signups/report/filled', 'Report for a sign-up restricted to slots that have already been filled.');
|
|
28
|
+
register('signupgenius_report_available', '/signups/report/available', 'Report for a sign-up restricted to slots that are still open/empty.');
|
|
29
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { textContent } from './_shared.js';
|
|
2
|
+
const LISTINGS = [
|
|
3
|
+
{
|
|
4
|
+
name: 'signupgenius_list_created_active',
|
|
5
|
+
session: '/signups/created',
|
|
6
|
+
key: '/signups/created/active',
|
|
7
|
+
desc: {
|
|
8
|
+
key: 'List ACTIVE sign-ups created by the API key holder.',
|
|
9
|
+
session: 'List sign-ups created by the authenticated user. In session mode this returns the full list (active + expired) — filter on enddate client-side if you need only active.',
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'signupgenius_list_created_expired',
|
|
14
|
+
session: '/signups/created',
|
|
15
|
+
key: '/signups/created/expired',
|
|
16
|
+
desc: {
|
|
17
|
+
key: 'List EXPIRED sign-ups created by the API key holder.',
|
|
18
|
+
session: 'Alias of signupgenius_list_created_active in session mode (v3 returns active + expired together). Filter on enddate client-side.',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'signupgenius_list_created_all',
|
|
23
|
+
session: '/signups/created',
|
|
24
|
+
key: '/signups/created/all',
|
|
25
|
+
desc: {
|
|
26
|
+
key: 'List ALL sign-ups created by the API key holder (active and expired).',
|
|
27
|
+
session: 'List ALL sign-ups created by the authenticated user (active and expired).',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'signupgenius_list_invited',
|
|
32
|
+
session: '/signups/invited',
|
|
33
|
+
key: '/signups/invited/active',
|
|
34
|
+
desc: {
|
|
35
|
+
key: 'List sign-ups the user has been invited to. Not applicable to sub-admin credentials.',
|
|
36
|
+
session: 'List sign-ups the user has been invited to.',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'signupgenius_list_signedupfor',
|
|
41
|
+
session: '/signups/signedupfor',
|
|
42
|
+
key: '/signups/signedupfor/active',
|
|
43
|
+
desc: {
|
|
44
|
+
key: 'List sign-ups the user has personally signed up for. Not applicable to sub-admin credentials.',
|
|
45
|
+
session: 'List sign-ups the user has personally signed up for.',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
export function registerSignUpTools(server, client) {
|
|
50
|
+
const session = client.mode === 'session';
|
|
51
|
+
for (const l of LISTINGS) {
|
|
52
|
+
server.registerTool(l.name, { description: session ? l.desc.session : l.desc.key, annotations: { readOnlyHint: true } }, async () => textContent(await client.request(session ? l.session : l.key)));
|
|
53
|
+
}
|
|
54
|
+
// Session-only convenience tool — the legacy CF dispatcher is what the
|
|
55
|
+
// signupgenius.com wizard itself uses, sometimes with fuller data than v3.
|
|
56
|
+
if (session) {
|
|
57
|
+
server.registerTool('signupgenius_legacy_get_my_signups', {
|
|
58
|
+
description: 'Session mode only. Returns the same sign-up listing the SignUpGenius wizard sees ' +
|
|
59
|
+
'(via /SUGboxAPI.cfm?go=t.getMySignups). Use when you want fuller data than ' +
|
|
60
|
+
'signupgenius_list_created_* provides.',
|
|
61
|
+
annotations: { readOnlyHint: true },
|
|
62
|
+
}, async () => textContent(await client.request('', { legacyAction: 't.getMySignups' })));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { textContent } from './_shared.js';
|
|
2
|
+
export function registerUserTools(server, client) {
|
|
3
|
+
server.registerTool('signupgenius_get_profile', {
|
|
4
|
+
description: "Get the SignUpGenius profile of the authenticated user (name, email, member ID, " +
|
|
5
|
+
'subscription level). Useful as a first call to confirm credentials. ' +
|
|
6
|
+
'Key mode hits /v2/k/user/profile; session mode hits /v3/member/profile.',
|
|
7
|
+
annotations: { readOnlyHint: true },
|
|
8
|
+
}, async () => {
|
|
9
|
+
const path = client.mode === 'session' ? '/member/profile' : '/user/profile';
|
|
10
|
+
const data = await client.request(path);
|
|
11
|
+
return textContent(data);
|
|
12
|
+
});
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "signupgenius-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"mcpName": "io.github.chrischall/signupgenius-mcp",
|
|
5
|
+
"description": "SignUpGenius MCP server — read sign-ups, reports, and groups; add group members.",
|
|
6
|
+
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chrischall/signupgenius-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"mcp",
|
|
14
|
+
"model-context-protocol",
|
|
15
|
+
"claude",
|
|
16
|
+
"ai",
|
|
17
|
+
"signupgenius",
|
|
18
|
+
"signups",
|
|
19
|
+
"volunteers"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"signupgenius-mcp": "dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
".claude-plugin",
|
|
28
|
+
"skills",
|
|
29
|
+
".mcp.json",
|
|
30
|
+
"server.json"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc && npm run bundle",
|
|
34
|
+
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
|
|
35
|
+
"dev": "node --env-file=.env dist/index.js",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
41
|
+
"dotenv": "^17.4.0",
|
|
42
|
+
"zod": "^4.3.6"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^25.5.2",
|
|
46
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
47
|
+
"esbuild": "^0.28.0",
|
|
48
|
+
"typescript": "^6.0.2",
|
|
49
|
+
"vitest": "^4.1.2"
|
|
50
|
+
}
|
|
51
|
+
}
|