vibo-mcp 1.2.0 → 1.3.1

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.
@@ -6,16 +6,16 @@
6
6
  "email": "chris.c.hall@gmail.com"
7
7
  },
8
8
  "metadata": {
9
- "description": "MCP server for Vibo (vibodj.com) — plan event music, song requests, and playlists via natural language",
10
- "version": "1.2.0"
9
+ "description": "MCP server for Vibo (vibodj.com) — plan & manage event music, song requests, ideas, guests, and playlists via natural language",
10
+ "version": "1.3.1"
11
11
  },
12
12
  "plugins": [
13
13
  {
14
14
  "name": "vibo-mcp",
15
15
  "displayName": "Vibo",
16
16
  "source": "./",
17
- "description": "MCP server for Vibo — browse events and timelines, add/like song requests, and export to Spotify/Apple Music",
18
- "version": "1.2.0",
17
+ "description": "MCP server for Vibo — browse & manage events, timeline, songs, the DJ song ideas/questions, guests, and exports to Spotify/Apple Music",
18
+ "version": "1.3.1",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "vibo-mcp",
3
3
  "displayName": "Vibo",
4
- "version": "1.2.0",
5
- "description": "MCP server for Vibo (vibodj.com) — plan event music, song requests, and playlists via natural language",
4
+ "version": "1.3.1",
5
+ "description": "MCP server for Vibo (vibodj.com) — plan & manage event music, song requests, ideas, guests, and playlists via natural language",
6
6
  "author": {
7
7
  "name": "Chris Hall",
8
8
  "email": "chris.c.hall@gmail.com"
package/README.md CHANGED
@@ -33,6 +33,7 @@ Choose one method:
33
33
  |--------|----------|------|
34
34
  | Email + password (recommended) | `VIBO_EMAIL`, `VIBO_PASSWORD` | You sign in to Vibo with an email/password. |
35
35
  | Captured token | `VIBO_ACCESS_TOKEN` (+ `VIBO_REFRESH_TOKEN`) | Your account uses Apple/Google/Facebook sign-in (no password). Capture `x-token`/`x-refresh-token` from a signed-in `web.vibodj.com` session. |
36
+ | Browser capture (SSO) | run `vibo_capture_session` | With the fetchproxy browser extension installed and signed into `web.vibodj.com`, capture the token automatically (saved to `~/.vibo-mcp/session.json`). |
36
37
 
37
38
  The server boots without credentials; the config error only surfaces on the
38
39
  first tool call.
package/SKILL.md CHANGED
@@ -40,6 +40,10 @@ Pick one:
40
40
  - **Captured token (for Apple/Google/Facebook accounts):** set
41
41
  `VIBO_ACCESS_TOKEN` (and `VIBO_REFRESH_TOKEN`) with values captured from a
42
42
  signed-in `web.vibodj.com` session — no password needed.
43
+ - **Browser capture (SSO, automatic):** with the fetchproxy browser extension
44
+ installed and yourself signed into https://web.vibodj.com, run
45
+ `vibo_capture_session` once — it grabs the token from your tab (approve the
46
+ pair code), saves it to `~/.vibo-mcp/session.json`, and reuses it thereafter.
43
47
 
44
48
  The server boots without credentials (so it can be installed and probed); the
45
49
  config error only appears on the first tool call.
@@ -77,6 +81,7 @@ you get a dry-run preview of exactly what would be sent.
77
81
  - `vibo_update_section` — edit a section's name, time, or note.
78
82
  - `vibo_answer_question` — answer a planning question (text / option ids / link / image+file uploads).
79
83
  - `vibo_set_profile_photo` — set your profile photo from a local image.
84
+ - `vibo_capture_session` — capture your login from a signed-in browser tab (SSO accounts).
80
85
  - `vibo_mark_notifications_read`.
81
86
  - `vibo_export_event_to_spotify` / `vibo_export_event_to_apple_music`.
82
87
 
package/dist/auth.js ADDED
@@ -0,0 +1,71 @@
1
+ // SSO browser token auto-capture (fetchproxy bootstrap).
2
+ //
3
+ // Accounts that sign into Vibo only via Apple/Google/Facebook have no password
4
+ // to put in VIBO_PASSWORD. Instead of asking the user to dig the token out of
5
+ // DevTools and paste VIBO_ACCESS_TOKEN, this captures it once from their
6
+ // signed-in web.vibodj.com tab via the fetchproxy browser bridge, then operates
7
+ // from Node thereafter (the bridge touches only the one-time handshake).
8
+ //
9
+ // The Vibo web app stores its tokens as plain localStorage keys `x-token`
10
+ // (access) and `x-refresh-token` on the web.vibodj.com origin (verified live
11
+ // against a signed-in tab). We snapshot those two keys and return them; the caller verifies
12
+ // them (GET_ME) and only then persists via session-store. The client then uses
13
+ // them like any other token pair (with refresh-on-expiry).
14
+ //
15
+ // `@fetchproxy/bootstrap` is imported lazily so the default credential paths
16
+ // never load it — the .mcpb bundle externalizes it, and an eager import would
17
+ // crash the server at load. Tests inject a fake `bootstrap` via `deps`.
18
+ import { McpToolError } from '@chrischall/mcp-utils';
19
+ import { VERSION } from './version.js';
20
+ const SERVER_NAME = 'vibo-mcp';
21
+ /**
22
+ * Capture the signed-in user's Vibo token pair from their browser via the
23
+ * fetchproxy bridge and return it (the caller persists after verifying).
24
+ * Preconditions: the fetchproxy browser
25
+ * extension is installed and the user is signed into https://web.vibodj.com.
26
+ */
27
+ export async function captureViboSession(deps = {}) {
28
+ let bootstrap = deps.bootstrap;
29
+ if (!bootstrap) {
30
+ try {
31
+ const mod = (await import('@fetchproxy/bootstrap'));
32
+ bootstrap = mod.bootstrap;
33
+ }
34
+ catch (err) {
35
+ throw new McpToolError('Browser token capture is unavailable in this build.', {
36
+ hint: 'Run vibo-mcp from npm (npx vibo-mcp) — the packaged .mcpb omits @fetchproxy/bootstrap. Or set VIBO_ACCESS_TOKEN instead.',
37
+ cause: err,
38
+ });
39
+ }
40
+ }
41
+ let session;
42
+ try {
43
+ session = await bootstrap({
44
+ serverName: SERVER_NAME,
45
+ version: VERSION,
46
+ domains: ['vibodj.com'],
47
+ storageSubdomain: 'web', // tokens live on web.vibodj.com
48
+ declare: { cookies: [], localStorage: ['x-token', 'x-refresh-token'], sessionStorage: [], captureHeaders: [] },
49
+ onPairCode: (code) => process.stderr.write(`[vibo-mcp] fetchproxy pair code: ${code}\n`),
50
+ onWaiting: (hint) => process.stderr.write(`[vibo-mcp] ${hint}\n`),
51
+ });
52
+ }
53
+ catch (err) {
54
+ const msg = err instanceof Error ? err.message : String(err);
55
+ throw new McpToolError(`Vibo browser token capture failed: ${msg}`, {
56
+ hint: 'Install the fetchproxy browser extension, sign into https://web.vibodj.com, approve the pair code, then retry.',
57
+ cause: err,
58
+ });
59
+ }
60
+ const accessToken = session.localStorage?.['x-token'];
61
+ const refreshToken = session.localStorage?.['x-refresh-token'] ?? null;
62
+ if (!accessToken) {
63
+ throw new McpToolError('No Vibo token found in the signed-in browser tab.', {
64
+ hint: 'Make sure you are signed into https://web.vibodj.com in the browser with the fetchproxy extension, then retry.',
65
+ });
66
+ }
67
+ // Return the captured pair WITHOUT persisting — the caller verifies it
68
+ // (GET_ME) before writing it to disk, so a stale snapshot never lands in
69
+ // session.json.
70
+ return { accessToken, refreshToken };
71
+ }
package/dist/bundle.js CHANGED
@@ -31040,8 +31040,12 @@ var API_KEY_RE = new RegExp([
31040
31040
  // webhook signing secret (Stripe-style)
31041
31041
  ].map((p) => `\\b${p}`).join("|"), "g");
31042
31042
  var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
31043
+ var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
31044
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
31045
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
31046
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
31043
31047
  function redactSecrets(text) {
31044
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
31048
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
31045
31049
  }
31046
31050
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
31047
31051
  const str = text === null || text === void 0 ? "" : String(text);
@@ -31116,13 +31120,187 @@ function toolAnnotations(opts = {}) {
31116
31120
  }
31117
31121
 
31118
31122
  // src/version.ts
31119
- var VERSION = "1.2.0";
31123
+ var VERSION = "1.3.1";
31120
31124
 
31121
31125
  // src/client.ts
31122
- import { dirname, join } from "path";
31126
+ import { dirname as dirname2, join as join2 } from "path";
31123
31127
  import { fileURLToPath } from "url";
31124
- var __dirname = dirname(fileURLToPath(import.meta.url));
31125
- await loadDotenvSafely({ path: join(__dirname, "..", ".env"), override: false });
31128
+
31129
+ // src/session-store.ts
31130
+ import { homedir } from "os";
31131
+ import { join } from "path";
31132
+
31133
+ // node_modules/@chrischall/mcp-utils/dist/session/index.js
31134
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from "node:fs";
31135
+ import { dirname } from "node:path";
31136
+ function normalizeOrigin(input) {
31137
+ try {
31138
+ return new URL(input).origin.replace(/\/$/, "");
31139
+ } catch {
31140
+ return input.replace(/\/$/, "");
31141
+ }
31142
+ }
31143
+ var SessionStore = class {
31144
+ sessions = /* @__PURE__ */ new Map();
31145
+ mostRecentKey = null;
31146
+ filePath;
31147
+ keyOf;
31148
+ normalizeKey;
31149
+ constructor(opts) {
31150
+ this.filePath = opts.filePath;
31151
+ this.keyOf = opts.keyOf;
31152
+ this.normalizeKey = opts.normalizeKey ?? normalizeOrigin;
31153
+ this.loadFromDisk();
31154
+ }
31155
+ loadFromDisk() {
31156
+ if (!existsSync(this.filePath))
31157
+ return;
31158
+ try {
31159
+ this.sessions = this.deserialize(readFileSync(this.filePath, "utf8"));
31160
+ const keys = Array.from(this.sessions.keys());
31161
+ this.mostRecentKey = keys[keys.length - 1] ?? null;
31162
+ } catch (err) {
31163
+ const backupPath = this.preserveCorruptFile();
31164
+ const detail = err instanceof Error ? err.message : String(err);
31165
+ console.error(`[mcp-utils] SessionStore: failed to parse ${this.filePath} (${detail}); ` + (backupPath !== null ? `preserved the corrupt file at ${backupPath}; ` : "could not preserve the corrupt file; ") + "starting with an empty store.");
31166
+ this.sessions = /* @__PURE__ */ new Map();
31167
+ this.mostRecentKey = null;
31168
+ }
31169
+ }
31170
+ /**
31171
+ * Move a corrupt store file to `<filePath>.corrupt` (or `.corrupt-<n>` if a
31172
+ * previous backup exists) so a subsequent save cannot overwrite the only
31173
+ * copy of the prior credentials. Best-effort: returns the backup path, or
31174
+ * `null` if the rename failed.
31175
+ */
31176
+ preserveCorruptFile() {
31177
+ try {
31178
+ let candidate = `${this.filePath}.corrupt`;
31179
+ for (let n = 1; existsSync(candidate) && n <= 100; n++) {
31180
+ candidate = `${this.filePath}.corrupt-${n}`;
31181
+ }
31182
+ if (existsSync(candidate))
31183
+ return null;
31184
+ renameSync(this.filePath, candidate);
31185
+ return candidate;
31186
+ } catch {
31187
+ return null;
31188
+ }
31189
+ }
31190
+ /** Serialize the store to its on-disk JSON form (array, insertion order). */
31191
+ serialize() {
31192
+ return JSON.stringify(Array.from(this.sessions.values()), null, 2);
31193
+ }
31194
+ /** Parse on-disk JSON back into a keyed `Map`; empty map on invalid input. */
31195
+ deserialize(body) {
31196
+ const map2 = /* @__PURE__ */ new Map();
31197
+ const arr = JSON.parse(body);
31198
+ if (!Array.isArray(arr))
31199
+ return map2;
31200
+ for (const raw of arr) {
31201
+ if (raw && typeof raw === "object") {
31202
+ const s = raw;
31203
+ const key = this.normalizeKey(this.keyOf(s));
31204
+ map2.set(key, s);
31205
+ }
31206
+ }
31207
+ return map2;
31208
+ }
31209
+ saveToDisk() {
31210
+ const dir = dirname(this.filePath);
31211
+ mkdirSync(dir, { recursive: true, mode: 448 });
31212
+ if (existsSync(this.filePath)) {
31213
+ try {
31214
+ chmodSync(this.filePath, 384);
31215
+ } catch {
31216
+ }
31217
+ }
31218
+ writeFileSync(this.filePath, this.serialize(), { mode: 384 });
31219
+ try {
31220
+ chmodSync(this.filePath, 384);
31221
+ chmodSync(dir, 448);
31222
+ } catch {
31223
+ }
31224
+ }
31225
+ /** Insert or replace a record, normalizing its key and marking it active. */
31226
+ add(session) {
31227
+ const key = this.normalizeKey(this.keyOf(session));
31228
+ this.sessions.set(key, session);
31229
+ this.mostRecentKey = key;
31230
+ this.saveToDisk();
31231
+ }
31232
+ /** Look up by key; with no key, returns the active (most-recent) session. */
31233
+ get(key) {
31234
+ if (key !== void 0)
31235
+ return this.sessions.get(this.normalizeKey(key)) ?? null;
31236
+ if (this.mostRecentKey !== null)
31237
+ return this.sessions.get(this.mostRecentKey) ?? null;
31238
+ return null;
31239
+ }
31240
+ /** The most-recently-added session, or `null`. */
31241
+ getActiveSession() {
31242
+ return this.get();
31243
+ }
31244
+ /** All sessions in insertion order. */
31245
+ list() {
31246
+ return Array.from(this.sessions.values());
31247
+ }
31248
+ /** Remove a session; fixes up the active pointer. Returns whether it existed. */
31249
+ remove(key) {
31250
+ const normalized = this.normalizeKey(key);
31251
+ const had = this.sessions.delete(normalized);
31252
+ if (had) {
31253
+ if (this.mostRecentKey === normalized) {
31254
+ const keys = Array.from(this.sessions.keys());
31255
+ this.mostRecentKey = keys[keys.length - 1] ?? null;
31256
+ }
31257
+ this.saveToDisk();
31258
+ }
31259
+ return had;
31260
+ }
31261
+ /** Clear in-memory state without touching disk. Test helper. */
31262
+ resetForTest() {
31263
+ this.sessions.clear();
31264
+ this.mostRecentKey = null;
31265
+ }
31266
+ };
31267
+ var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
31268
+
31269
+ // src/session-store.ts
31270
+ var SESSION_KEY = "vibo";
31271
+ function openStore() {
31272
+ return new SessionStore({
31273
+ filePath: readEnvVar("VIBO_SESSION_FILE") ?? join(homedir(), ".vibo-mcp", "session.json"),
31274
+ keyOf: (s) => typeof s.key === "string" && s.key ? s.key : SESSION_KEY,
31275
+ normalizeKey: (k) => k
31276
+ // fixed single key — no origin normalization
31277
+ });
31278
+ }
31279
+ function loadSession() {
31280
+ try {
31281
+ const rec = openStore().get(SESSION_KEY);
31282
+ if (rec && typeof rec.accessToken === "string" && rec.accessToken) {
31283
+ return {
31284
+ accessToken: rec.accessToken,
31285
+ refreshToken: typeof rec.refreshToken === "string" ? rec.refreshToken : null
31286
+ };
31287
+ }
31288
+ return null;
31289
+ } catch {
31290
+ return null;
31291
+ }
31292
+ }
31293
+ function saveSession(session) {
31294
+ openStore().add({
31295
+ key: SESSION_KEY,
31296
+ accessToken: session.accessToken,
31297
+ refreshToken: session.refreshToken ?? null
31298
+ });
31299
+ }
31300
+
31301
+ // src/client.ts
31302
+ var __dirname = dirname2(fileURLToPath(import.meta.url));
31303
+ await loadDotenvSafely({ path: join2(__dirname, "..", ".env"), override: false });
31126
31304
  var DEFAULT_API_URL = "https://api.vibodj.com/v2/graphql";
31127
31305
  var SERVICE = "Vibo";
31128
31306
  var SIGN_IN_HOST = "https://web.vibodj.com";
@@ -31148,6 +31326,7 @@ var ViboClient = class {
31148
31326
  apiUrl;
31149
31327
  email;
31150
31328
  password;
31329
+ // Not readonly: cleared by setTokens() after a browser capture seeds a session.
31151
31330
  configError;
31152
31331
  accessToken;
31153
31332
  refreshTokenValue;
@@ -31166,18 +31345,41 @@ var ViboClient = class {
31166
31345
  this.accessToken = accessToken ?? null;
31167
31346
  this.refreshTokenValue = refreshToken ?? null;
31168
31347
  const haveLogin = Boolean(email3 && password);
31169
- const haveToken = Boolean(accessToken);
31348
+ if (!this.accessToken && !haveLogin) {
31349
+ const saved = loadSession();
31350
+ if (saved) {
31351
+ this.accessToken = saved.accessToken;
31352
+ this.refreshTokenValue = saved.refreshToken;
31353
+ }
31354
+ }
31355
+ const haveToken = Boolean(this.accessToken);
31170
31356
  if (!haveLogin && !haveToken) {
31171
31357
  this.configError = new McpToolError(
31172
31358
  "Vibo credentials are not configured.",
31173
31359
  {
31174
- hint: "Set VIBO_EMAIL and VIBO_PASSWORD (recommended), or paste a captured VIBO_ACCESS_TOKEN (+ VIBO_REFRESH_TOKEN) if your account uses Apple/Google/Facebook sign-in."
31360
+ hint: "Set VIBO_EMAIL and VIBO_PASSWORD (recommended); paste a captured VIBO_ACCESS_TOKEN (+ VIBO_REFRESH_TOKEN); or run vibo_capture_session to grab the token from your signed-in web.vibodj.com browser tab (Apple/Google/Facebook accounts)."
31175
31361
  }
31176
31362
  );
31177
31363
  } else {
31178
31364
  this.configError = null;
31179
31365
  }
31180
31366
  }
31367
+ /**
31368
+ * Adopt a browser-captured token pair (from vibo_capture_session) for
31369
+ * subsequent calls in this process and clear the config error so an account
31370
+ * that started with no credentials becomes usable. Does NOT persist — the
31371
+ * caller persists only after verifying the token authenticates (GET_ME).
31372
+ */
31373
+ setTokens(accessToken, refreshToken) {
31374
+ this.accessToken = accessToken;
31375
+ this.refreshTokenValue = refreshToken;
31376
+ this.configError = null;
31377
+ }
31378
+ /** True when operating purely from a token (no email/password) — refreshed
31379
+ * tokens should be persisted so they survive a restart. */
31380
+ get tokenOnlyMode() {
31381
+ return !this.email || !this.password;
31382
+ }
31181
31383
  /** Run a GraphQL operation, transparently authenticating + retrying once on token expiry. */
31182
31384
  async gql(query, variables = {}) {
31183
31385
  if (this.configError) throw this.configError;
@@ -31301,6 +31503,9 @@ var ViboClient = class {
31301
31503
  if (data.refreshToken?.accessToken) {
31302
31504
  this.accessToken = data.refreshToken.accessToken;
31303
31505
  this.refreshTokenValue = data.refreshToken.refreshToken;
31506
+ if (this.tokenOnlyMode) {
31507
+ saveSession({ accessToken: this.accessToken, refreshToken: this.refreshTokenValue });
31508
+ }
31304
31509
  return this.accessToken;
31305
31510
  }
31306
31511
  }
@@ -32466,12 +32671,12 @@ function registerCollaborationTools(server) {
32466
32671
  server.registerTool(
32467
32672
  "vibo_list_event_users",
32468
32673
  {
32469
- description: "List the hosts and guests on an event.",
32674
+ description: "List the hosts and guests on an event. With no usersType, returns both groups merged ({hosts, guests, hostsCount, guestsCount}) and `limit`/`skip` apply per group; with usersType, returns that one group's page.",
32470
32675
  annotations: toolAnnotations({ title: "List Vibo event users", readOnly: true }),
32471
32676
  inputSchema: {
32472
32677
  eventId: external_exports.string().describe("Event id."),
32473
32678
  usersType: external_exports.enum(["host", "guest"]).optional().describe("Filter to only hosts or only guests."),
32474
- limit: limitSchema,
32679
+ limit: limitSchema.describe("Max items to return (default 20). Applies per group when usersType is omitted."),
32475
32680
  skip: skipSchema
32476
32681
  }
32477
32682
  },
@@ -32616,6 +32821,75 @@ function registerUploadTools(server) {
32616
32821
  );
32617
32822
  }
32618
32823
 
32824
+ // src/auth.ts
32825
+ var SERVER_NAME = "vibo-mcp";
32826
+ async function captureViboSession(deps = {}) {
32827
+ let bootstrap = deps.bootstrap;
32828
+ if (!bootstrap) {
32829
+ try {
32830
+ const mod = await import("@fetchproxy/bootstrap");
32831
+ bootstrap = mod.bootstrap;
32832
+ } catch (err) {
32833
+ throw new McpToolError("Browser token capture is unavailable in this build.", {
32834
+ hint: "Run vibo-mcp from npm (npx vibo-mcp) \u2014 the packaged .mcpb omits @fetchproxy/bootstrap. Or set VIBO_ACCESS_TOKEN instead.",
32835
+ cause: err
32836
+ });
32837
+ }
32838
+ }
32839
+ let session;
32840
+ try {
32841
+ session = await bootstrap({
32842
+ serverName: SERVER_NAME,
32843
+ version: VERSION,
32844
+ domains: ["vibodj.com"],
32845
+ storageSubdomain: "web",
32846
+ // tokens live on web.vibodj.com
32847
+ declare: { cookies: [], localStorage: ["x-token", "x-refresh-token"], sessionStorage: [], captureHeaders: [] },
32848
+ onPairCode: (code) => process.stderr.write(`[vibo-mcp] fetchproxy pair code: ${code}
32849
+ `),
32850
+ onWaiting: (hint) => process.stderr.write(`[vibo-mcp] ${hint}
32851
+ `)
32852
+ });
32853
+ } catch (err) {
32854
+ const msg = err instanceof Error ? err.message : String(err);
32855
+ throw new McpToolError(`Vibo browser token capture failed: ${msg}`, {
32856
+ hint: "Install the fetchproxy browser extension, sign into https://web.vibodj.com, approve the pair code, then retry.",
32857
+ cause: err
32858
+ });
32859
+ }
32860
+ const accessToken = session.localStorage?.["x-token"];
32861
+ const refreshToken = session.localStorage?.["x-refresh-token"] ?? null;
32862
+ if (!accessToken) {
32863
+ throw new McpToolError("No Vibo token found in the signed-in browser tab.", {
32864
+ hint: "Make sure you are signed into https://web.vibodj.com in the browser with the fetchproxy extension, then retry."
32865
+ });
32866
+ }
32867
+ return { accessToken, refreshToken };
32868
+ }
32869
+
32870
+ // src/tools/session.ts
32871
+ function registerSessionTools(server) {
32872
+ server.registerTool(
32873
+ "vibo_capture_session",
32874
+ {
32875
+ description: "Capture your Vibo login from a signed-in web.vibodj.com browser tab via the fetchproxy bridge \u2014 for accounts that sign in with Apple/Google/Facebook (no password). Requires the fetchproxy browser extension installed and you signed into https://web.vibodj.com; approve the pair code shown on first use. The token is saved locally and reused on future calls.",
32876
+ annotations: toolAnnotations({ title: "Capture Vibo session (SSO)", readOnly: false })
32877
+ },
32878
+ async () => {
32879
+ const { accessToken, refreshToken } = await captureViboSession();
32880
+ client.setTokens(accessToken, refreshToken);
32881
+ const data = await client.gql(GET_ME);
32882
+ saveSession({ accessToken, refreshToken });
32883
+ return textResult({
32884
+ captured: true,
32885
+ hasRefreshToken: Boolean(refreshToken),
32886
+ userId: data.me._id,
32887
+ email: data.me.email
32888
+ });
32889
+ }
32890
+ );
32891
+ }
32892
+
32619
32893
  // src/index.ts
32620
32894
  await runMcp({
32621
32895
  name: "vibo-mcp",
@@ -32635,6 +32909,7 @@ await runMcp({
32635
32909
  registerImportTools,
32636
32910
  registerCollaborationTools,
32637
32911
  registerSectionEditTools,
32638
- registerUploadTools
32912
+ registerUploadTools,
32913
+ registerSessionTools
32639
32914
  ]
32640
32915
  });
package/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { loadDotenvSafely, readEnvVar, McpToolError, SessionNotAuthenticatedError, truncateErrorMessage, } from '@chrischall/mcp-utils';
4
+ import { loadSession, saveSession } from './session-store.js';
4
5
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. the
5
6
  // mcpb bundle, which externalizes dotenv). `override: false` means a
6
7
  // host-provided env var always wins over .env.
@@ -50,6 +51,7 @@ export class ViboClient {
50
51
  apiUrl;
51
52
  email;
52
53
  password;
54
+ // Not readonly: cleared by setTokens() after a browser capture seeds a session.
53
55
  configError;
54
56
  accessToken;
55
57
  refreshTokenValue;
@@ -68,17 +70,45 @@ export class ViboClient {
68
70
  this.accessToken = accessToken ?? null;
69
71
  this.refreshTokenValue = refreshToken ?? null;
70
72
  const haveLogin = Boolean(email && password);
71
- const haveToken = Boolean(accessToken);
73
+ // Fall back to a previously browser-captured session (SSO accounts) ONLY
74
+ // when there's no env token AND no email/password. Email/password is the
75
+ // documented preferred path and must win over a (possibly stale) saved
76
+ // session — otherwise an old session.json would silently shadow it.
77
+ if (!this.accessToken && !haveLogin) {
78
+ const saved = loadSession();
79
+ if (saved) {
80
+ this.accessToken = saved.accessToken;
81
+ this.refreshTokenValue = saved.refreshToken;
82
+ }
83
+ }
84
+ const haveToken = Boolean(this.accessToken);
72
85
  if (!haveLogin && !haveToken) {
73
86
  this.configError = new McpToolError('Vibo credentials are not configured.', {
74
- hint: 'Set VIBO_EMAIL and VIBO_PASSWORD (recommended), or paste a captured ' +
75
- 'VIBO_ACCESS_TOKEN (+ VIBO_REFRESH_TOKEN) if your account uses Apple/Google/Facebook sign-in.',
87
+ hint: 'Set VIBO_EMAIL and VIBO_PASSWORD (recommended); paste a captured ' +
88
+ 'VIBO_ACCESS_TOKEN (+ VIBO_REFRESH_TOKEN); or run vibo_capture_session to grab the ' +
89
+ 'token from your signed-in web.vibodj.com browser tab (Apple/Google/Facebook accounts).',
76
90
  });
77
91
  }
78
92
  else {
79
93
  this.configError = null;
80
94
  }
81
95
  }
96
+ /**
97
+ * Adopt a browser-captured token pair (from vibo_capture_session) for
98
+ * subsequent calls in this process and clear the config error so an account
99
+ * that started with no credentials becomes usable. Does NOT persist — the
100
+ * caller persists only after verifying the token authenticates (GET_ME).
101
+ */
102
+ setTokens(accessToken, refreshToken) {
103
+ this.accessToken = accessToken;
104
+ this.refreshTokenValue = refreshToken;
105
+ this.configError = null;
106
+ }
107
+ /** True when operating purely from a token (no email/password) — refreshed
108
+ * tokens should be persisted so they survive a restart. */
109
+ get tokenOnlyMode() {
110
+ return !this.email || !this.password;
111
+ }
82
112
  /** Run a GraphQL operation, transparently authenticating + retrying once on token expiry. */
83
113
  async gql(query, variables = {}) {
84
114
  if (this.configError)
@@ -205,6 +235,11 @@ export class ViboClient {
205
235
  if (data.refreshToken?.accessToken) {
206
236
  this.accessToken = data.refreshToken.accessToken;
207
237
  this.refreshTokenValue = data.refreshToken.refreshToken;
238
+ // Persist the rotated pair so a captured/pasted session survives
239
+ // a restart (no email/password to re-login with).
240
+ if (this.tokenOnlyMode) {
241
+ saveSession({ accessToken: this.accessToken, refreshToken: this.refreshTokenValue });
242
+ }
208
243
  return this.accessToken;
209
244
  }
210
245
  }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { registerImportTools } from './tools/imports.js';
15
15
  import { registerCollaborationTools } from './tools/collaboration.js';
16
16
  import { registerSectionEditTools } from './tools/section-edit.js';
17
17
  import { registerUploadTools } from './tools/uploads.js';
18
+ import { registerSessionTools } from './tools/session.js';
18
19
  // The ViboClient is a module-level singleton (constructed in client.ts and
19
20
  // imported by each tool module) that defers its config error to the first
20
21
  // request. That preserves the deferred-config-error pattern: the server boots
@@ -39,5 +40,6 @@ await runMcp({
39
40
  registerCollaborationTools,
40
41
  registerSectionEditTools,
41
42
  registerUploadTools,
43
+ registerSessionTools,
42
44
  ],
43
45
  });
@@ -0,0 +1,47 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { readEnvVar } from '@chrischall/mcp-utils';
4
+ import { SessionStore } from '@chrischall/mcp-utils/session';
5
+ const SESSION_KEY = 'vibo';
6
+ // Constructed per call (it re-reads disk) so VIBO_SESSION_FILE is honored
7
+ // dynamically, matching the previous read-env-on-every-call behaviour.
8
+ function openStore() {
9
+ return new SessionStore({
10
+ filePath: readEnvVar('VIBO_SESSION_FILE') ?? join(homedir(), '.vibo-mcp', 'session.json'),
11
+ keyOf: (s) => (typeof s.key === 'string' && s.key ? s.key : SESSION_KEY),
12
+ normalizeKey: (k) => k, // fixed single key — no origin normalization
13
+ });
14
+ }
15
+ /** Load a previously-captured session, or null if none / unreadable. */
16
+ export function loadSession() {
17
+ try {
18
+ const rec = openStore().get(SESSION_KEY);
19
+ if (rec && typeof rec.accessToken === 'string' && rec.accessToken) {
20
+ return {
21
+ accessToken: rec.accessToken,
22
+ refreshToken: typeof rec.refreshToken === 'string' ? rec.refreshToken : null,
23
+ };
24
+ }
25
+ return null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Persist a captured/refreshed token pair (0600 file in a 0700 dir). */
32
+ export function saveSession(session) {
33
+ openStore().add({
34
+ key: SESSION_KEY,
35
+ accessToken: session.accessToken,
36
+ refreshToken: session.refreshToken ?? null,
37
+ });
38
+ }
39
+ /** Remove any persisted session. */
40
+ export function clearSession() {
41
+ try {
42
+ openStore().remove(SESSION_KEY);
43
+ }
44
+ catch {
45
+ // best-effort
46
+ }
47
+ }
@@ -5,12 +5,12 @@ import { LIST_EVENT_USERS, INVITE_USERS, CHANGE_USER_ROLE, REMOVE_USER } from '.
5
5
  import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
6
6
  export function registerCollaborationTools(server) {
7
7
  server.registerTool('vibo_list_event_users', {
8
- description: 'List the hosts and guests on an event.',
8
+ description: "List the hosts and guests on an event. With no usersType, returns both groups merged ({hosts, guests, hostsCount, guestsCount}) and `limit`/`skip` apply per group; with usersType, returns that one group's page.",
9
9
  annotations: toolAnnotations({ title: 'List Vibo event users', readOnly: true }),
10
10
  inputSchema: {
11
11
  eventId: z.string().describe('Event id.'),
12
12
  usersType: z.enum(['host', 'guest']).optional().describe('Filter to only hosts or only guests.'),
13
- limit: limitSchema,
13
+ limit: limitSchema.describe('Max items to return (default 20). Applies per group when usersType is omitted.'),
14
14
  skip: skipSchema,
15
15
  },
16
16
  }, async ({ eventId, usersType, limit, skip }) => {
@@ -0,0 +1,24 @@
1
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { client } from '../client.js';
3
+ import { captureViboSession } from '../auth.js';
4
+ import { saveSession } from '../session-store.js';
5
+ import { GET_ME } from '../gql.js';
6
+ export function registerSessionTools(server) {
7
+ server.registerTool('vibo_capture_session', {
8
+ description: "Capture your Vibo login from a signed-in web.vibodj.com browser tab via the fetchproxy bridge — for accounts that sign in with Apple/Google/Facebook (no password). Requires the fetchproxy browser extension installed and you signed into https://web.vibodj.com; approve the pair code shown on first use. The token is saved locally and reused on future calls.",
9
+ annotations: toolAnnotations({ title: 'Capture Vibo session (SSO)', readOnly: false }),
10
+ }, async () => {
11
+ const { accessToken, refreshToken } = await captureViboSession();
12
+ client.setTokens(accessToken, refreshToken);
13
+ // Confirm the captured token actually authenticates BEFORE persisting it,
14
+ // so a stale snapshot never lands in session.json.
15
+ const data = await client.gql(GET_ME);
16
+ saveSession({ accessToken, refreshToken });
17
+ return textResult({
18
+ captured: true,
19
+ hasRefreshToken: Boolean(refreshToken),
20
+ userId: data.me._id,
21
+ email: data.me.email,
22
+ });
23
+ });
24
+ }
package/dist/version.js CHANGED
@@ -2,4 +2,4 @@
2
2
  // literal on the line carrying the release marker; every manifest and the MCP
3
3
  // server banner import VERSION from here, so there is exactly one place to keep
4
4
  // in sync (and one release-please extra-files entry).
5
- export const VERSION = '1.2.0'; // x-release-please-version
5
+ export const VERSION = '1.3.1'; // x-release-please-version
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "vibo-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "mcpName": "io.github.chrischall/vibo-mcp",
5
- "description": "Vibo (vibodj.com) MCP server for Claude — host/couple event music planning. Developed and maintained by AI (Claude Code).",
5
+ "description": "Vibo (vibodj.com) MCP server for Claude — host/couple event music planning & management. Developed and maintained by AI (Claude Code).",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
7
7
  "repository": {
8
8
  "type": "git",
@@ -37,20 +37,21 @@
37
37
  ],
38
38
  "scripts": {
39
39
  "build": "tsc && npm run bundle",
40
- "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
40
+ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --external:@fetchproxy/bootstrap --outfile=dist/bundle.js",
41
41
  "dev": "node dist/index.js",
42
42
  "test": "vitest run",
43
43
  "test:watch": "vitest",
44
44
  "test:coverage": "vitest run --coverage"
45
45
  },
46
46
  "dependencies": {
47
- "@chrischall/mcp-utils": "^0.10.4",
47
+ "@chrischall/mcp-utils": "^0.12.0",
48
+ "@fetchproxy/bootstrap": "^1.3.4",
48
49
  "@modelcontextprotocol/sdk": "^1.29.0",
49
50
  "dotenv": "^17.4.0",
50
51
  "zod": "^4.4.2"
51
52
  },
52
53
  "devDependencies": {
53
- "@types/node": "^25.5.2",
54
+ "@types/node": "^26.0.0",
54
55
  "@vitest/coverage-v8": "^4.1.2",
55
56
  "esbuild": "^0.28.0",
56
57
  "typescript": "^6.0.2",
package/server.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.chrischall/vibo-mcp",
4
- "description": "Vibo (vibodj.com) MCP — plan event music: events, timeline, song requests, playlist export",
4
+ "description": "Vibo (vibodj.com) MCP — plan & manage event music: timeline, songs, ideas, questions, guests",
5
5
  "repository": {
6
6
  "url": "https://github.com/chrischall/vibo-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.2.0",
9
+ "version": "1.3.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "vibo-mcp",
14
- "version": "1.2.0",
14
+ "version": "1.3.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },