vibo-mcp 2.2.2 → 2.3.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/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'crypto';
1
2
  import { dirname, join } from 'path';
2
3
  import { fileURLToPath } from 'url';
3
4
  import { loadDotenvSafely, readEnvVar, McpToolError, SessionNotAuthenticatedError, truncateErrorMessage, withAmbientCancellation, } from '@chrischall/mcp-utils';
@@ -38,8 +39,18 @@ const REFRESH = `
38
39
  }
39
40
  `;
40
41
  // Error codes Vibo (and conventional GraphQL servers) use for an expired /
41
- // missing session — these should trigger a token refresh + replay.
42
- const AUTH_ERROR_CODES = new Set(['UNAUTHORIZED', 'UNAUTHENTICATED', 'FORBIDDEN']);
42
+ // missing session — these should trigger a token refresh + replay. FORBIDDEN
43
+ // is deliberately NOT here: it is a permission denial (a guest removing a
44
+ // user, a section whose host-edit permission is off), and refreshing cannot
45
+ // fix it — see PERMISSION_ERROR_CODES.
46
+ const AUTH_ERROR_CODES = new Set(['UNAUTHORIZED', 'UNAUTHENTICATED']);
47
+ // Codes for "you are signed in, but not allowed to do this".
48
+ const PERMISSION_ERROR_CODES = new Set(['FORBIDDEN']);
49
+ // Message fallback, consulted ONLY for an error that carries no code. Vibo's
50
+ // expired-session text is "Not authorized. Try to log in"; the patterns are
51
+ // anchored on session/token wording so an unrelated message that merely
52
+ // mentions "login" or "JWT" is not mistaken for an expired session.
53
+ const AUTH_MESSAGE_PATTERN = /not authoriz|unauthoriz|unauthenticated|invalid token|token (has )?expired|expired token|jwt (expired|malformed)|try to log ?in/i;
43
54
  /**
44
55
  * The signal every Vibo request is made with: our timeout, and the caller's
45
56
  * cancellation if there is one (mcp-utils `cancel`).
@@ -59,6 +70,39 @@ const AUTH_ERROR_CODES = new Set(['UNAUTHORIZED', 'UNAUTHENTICATED', 'FORBIDDEN'
59
70
  function requestSignal() {
60
71
  return withAmbientCancellation(AbortSignal.timeout(REQUEST_TIMEOUT_MS));
61
72
  }
73
+ /** Whether a GraphQL document is a mutation (a write with side effects). */
74
+ function isMutation(query) {
75
+ return /^\s*(?:#[^\n]*\n\s*)*mutation\b/.test(query);
76
+ }
77
+ /**
78
+ * The error for a request that never produced a response (timeout, dropped
79
+ * connection, caller abort). For a READ that is safely retryable. For a WRITE
80
+ * the outcome is unknown — Vibo may already have committed it — and a blind
81
+ * retry repeats the side effect (a second round of invitation emails, a
82
+ * second exported playlist, a duplicate comment or import); the confirmation
83
+ * gate cannot stop that, because a fresh preview earns a fresh approval and
84
+ * token. So a write says so, and asks for a state check first.
85
+ */
86
+ function transportError(what, err, isWrite) {
87
+ const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'failed';
88
+ if (isWrite) {
89
+ return new McpToolError(`${what} ${SERVICE} ${reason} — the change may already have been applied (outcome is unknown).`, {
90
+ hint: 'Do not repeat this write blindly — check the current state before retrying: e.g. ' +
91
+ 'vibo_list_event_users after inviting, vibo_get_section_songs after adding, importing or commenting ' +
92
+ 'on songs, the Spotify/Apple Music account after an export. Retry only if the change is not there.',
93
+ cause: err,
94
+ });
95
+ }
96
+ return new McpToolError(`${what} ${SERVICE} ${reason}.`, {
97
+ hint: 'The Vibo API may be unreachable — check your connection and retry.',
98
+ cause: err,
99
+ });
100
+ }
101
+ /** One-way fingerprint of a configured token, so session.json never holds a
102
+ * second copy of the pasted secret just to record where a session came from. */
103
+ function tokenLineage(token) {
104
+ return createHash('sha256').update(token).digest('hex').slice(0, 32);
105
+ }
62
106
  export class ViboClient {
63
107
  apiUrl;
64
108
  email;
@@ -67,6 +111,9 @@ export class ViboClient {
67
111
  configError = null;
68
112
  accessToken;
69
113
  refreshTokenValue;
114
+ // Which configured token pair the current tokens descend from (see
115
+ // ViboSession.lineage); stamped on every persisted rotation.
116
+ sessionLineage = null;
70
117
  // The constructor is PURE — it does no filesystem / homedir / async-I/O /
71
118
  // random op — so it is safe to run at Worker global scope (where the
72
119
  // module-level `export const client` singleton below is constructed). The
@@ -105,6 +152,21 @@ export class ViboClient {
105
152
  if (saved) {
106
153
  this.accessToken = saved.accessToken;
107
154
  this.refreshTokenValue = saved.refreshToken;
155
+ this.sessionLineage = saved.lineage ?? null;
156
+ }
157
+ }
158
+ else if (this.accessToken && !haveLogin) {
159
+ // A configured (pasted) token pair. Vibo rotates the refresh token on
160
+ // every refresh, so after the first refresh the pasted pair is dead and
161
+ // only the persisted rotated pair works. Resume from it when it descends
162
+ // from THIS configured pair; a newly pasted pair (different lineage) or a
163
+ // browser capture (no lineage) never shadows the configured tokens.
164
+ const lineage = tokenLineage(this.refreshTokenValue ?? this.accessToken);
165
+ this.sessionLineage = lineage;
166
+ const saved = loadSession();
167
+ if (saved?.lineage === lineage) {
168
+ this.accessToken = saved.accessToken;
169
+ this.refreshTokenValue = saved.refreshToken;
108
170
  }
109
171
  }
110
172
  const haveToken = Boolean(this.accessToken);
@@ -125,6 +187,7 @@ export class ViboClient {
125
187
  setTokens(accessToken, refreshToken) {
126
188
  this.accessToken = accessToken;
127
189
  this.refreshTokenValue = refreshToken;
190
+ this.sessionLineage = null; // a browser capture descends from no configured pair
128
191
  this.configError = null;
129
192
  }
130
193
  /** True when operating purely from a token (no email/password) — refreshed
@@ -195,11 +258,8 @@ export class ViboClient {
195
258
  });
196
259
  }
197
260
  catch (err) {
198
- const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'failed';
199
- throw new McpToolError(`Upload to ${SERVICE} ${reason}.`, {
200
- hint: 'The Vibo API may be unreachable — check your connection and retry.',
201
- cause: err,
202
- });
261
+ // An upload is always a write (the multipart path only carries mutations).
262
+ throw transportError('Upload to', err, true);
203
263
  }
204
264
  let body;
205
265
  try {
@@ -254,7 +314,11 @@ export class ViboClient {
254
314
  // Persist the rotated pair so a captured/pasted session survives
255
315
  // a restart (no email/password to re-login with).
256
316
  if (this.tokenOnlyMode) {
257
- saveSession({ accessToken: this.accessToken, refreshToken: this.refreshTokenValue });
317
+ saveSession({
318
+ accessToken: this.accessToken,
319
+ refreshToken: this.refreshTokenValue,
320
+ ...(this.sessionLineage ? { lineage: this.sessionLineage } : {}),
321
+ });
258
322
  }
259
323
  return this.accessToken;
260
324
  }
@@ -288,11 +352,9 @@ export class ViboClient {
288
352
  });
289
353
  }
290
354
  catch (err) {
291
- const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'failed';
292
- throw new McpToolError(`Request to ${SERVICE} ${reason}.`, {
293
- hint: 'The Vibo API may be unreachable — check your connection and retry.',
294
- cause: err,
295
- });
355
+ // signIn / refreshToken are mutations too, but repeating them is harmless.
356
+ const isWrite = query !== SIGN_IN && query !== REFRESH && isMutation(query);
357
+ throw transportError('Request to', err, isWrite);
296
358
  }
297
359
  let body;
298
360
  try {
@@ -303,21 +365,34 @@ export class ViboClient {
303
365
  }
304
366
  return { status: response.status, body };
305
367
  }
368
+ /** An expired / missing session — the only case a refresh + replay can fix. */
306
369
  isAuthError(status, errors) {
307
- if (status === 401 || status === 403)
370
+ if (status === 401)
308
371
  return true;
309
372
  if (!errors?.length)
310
373
  return false;
311
374
  return errors.some((e) => {
312
- const code = e.code ?? e.extensions?.code ?? '';
313
- if (AUTH_ERROR_CODES.has(code))
314
- return true;
315
- // Message fallback for servers that omit a code. Vibo's text is
316
- // "Not authorized. Try to log in".
317
- return /not authoriz|unauthor|unauthenticated|invalid token|token expired|expired token|jwt|log ?in/i.test(e.message ?? '');
375
+ const code = e.code ?? e.extensions?.code;
376
+ if (code)
377
+ return AUTH_ERROR_CODES.has(code);
378
+ return AUTH_MESSAGE_PATTERN.test(e.message ?? '');
318
379
  });
319
380
  }
381
+ /** Signed in, but not allowed to do this (HTTP 403 / FORBIDDEN). */
382
+ isPermissionError(status, errors) {
383
+ if (status === 403)
384
+ return true;
385
+ return (errors ?? []).some((e) => PERMISSION_ERROR_CODES.has(e.code ?? e.extensions?.code ?? ''));
386
+ }
320
387
  unwrap(status, body) {
388
+ if (this.isPermissionError(status, body.errors)) {
389
+ const detail = body.errors?.map((e) => e.message).filter(Boolean).join('; ');
390
+ throw new McpToolError(`You don't have permission to do this in this ${SERVICE} event${detail ? `: ${truncateErrorMessage(detail)}` : '.'}`, {
391
+ hint: 'This is a permission denial, not an expired session — signing in again will not help. ' +
392
+ 'Your role in the event (e.g. guest vs. host) or the DJ\'s section settings do not allow this change; ' +
393
+ 'ask the event host or DJ.',
394
+ });
395
+ }
321
396
  if (body.errors?.length) {
322
397
  if (this.isAuthError(status, body.errors)) {
323
398
  throw new SessionNotAuthenticatedError(SERVICE, SIGN_IN_HOST);
@@ -20,6 +20,7 @@ export function loadSession() {
20
20
  return {
21
21
  accessToken: rec.accessToken,
22
22
  refreshToken: typeof rec.refreshToken === 'string' ? rec.refreshToken : null,
23
+ ...(typeof rec.lineage === 'string' && rec.lineage ? { lineage: rec.lineage } : {}),
23
24
  };
24
25
  }
25
26
  return null;
@@ -34,6 +35,7 @@ export function saveSession(session) {
34
35
  key: SESSION_KEY,
35
36
  accessToken: session.accessToken,
36
37
  refreshToken: session.refreshToken ?? null,
38
+ ...(session.lineage ? { lineage: session.lineage } : {}),
37
39
  });
38
40
  }
39
41
  /** Remove any persisted session. */
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, schemaConfirm, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { LIST_EVENT_USERS, INVITE_USERS, CHANGE_USER_ROLE, REMOVE_USER } from '../gql.js';
4
- import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
4
+ import { limitSchema, skipSchema, pagination, confirmWrite, CONFIRM_NOTE } from './shared.js';
5
5
  import { viewArg, viewResponse } from '../view.js';
6
6
  export function registerCollaborationTools(server, client) {
7
7
  server.registerTool('vibo_list_event_users', {
@@ -41,50 +41,74 @@ export function registerCollaborationTools(server, client) {
41
41
  });
42
42
  });
43
43
  server.registerTool('vibo_invite_users', {
44
- description: 'Invite people to an event by email (as host or guest). Confirm-gated.',
44
+ description: 'Invite people to an event by email (as host or guest). ' + CONFIRM_NOTE,
45
45
  annotations: toolAnnotations({ title: 'Invite Vibo event users', readOnly: false, destructive: true }),
46
46
  inputSchema: z.object({
47
47
  eventId: z.string().describe('Event id.'),
48
48
  type: z.enum(['host', 'guest']).describe('Invite as host or guest.'),
49
49
  text: z.string().describe('Personal message included in the invite.'),
50
50
  emails: z.array(z.string().email()).min(1).describe('Email addresses to invite.'),
51
- confirm: schemaConfirm,
51
+ confirmToken: confirmTokenParam,
52
52
  }),
53
- }, async ({ eventId, type, text, emails, confirm }) => {
53
+ }, async ({ eventId, type, text, emails, confirmToken }, ctx) => {
54
54
  const variables = { eventId, type, text, emails };
55
- if (!confirm)
56
- return previewResult('inviteUserViaEmail', variables);
55
+ const gate = await confirmWrite(ctx, {
56
+ tool: 'vibo_invite_users',
57
+ mutation: 'inviteUserViaEmail',
58
+ message: 'Review and confirm sending these invitations:',
59
+ confirmToken,
60
+ target: eventId,
61
+ willSend: variables,
62
+ });
63
+ if (gate)
64
+ return gate;
57
65
  const data = await client.gql(INVITE_USERS, variables);
58
66
  return minifiedResult(data.inviteUserViaEmail);
59
67
  });
60
68
  server.registerTool('vibo_change_user_role', {
61
- description: "Change an event member's role between host and guest. Confirm-gated.",
69
+ description: "Change an event member's role between host and guest. " + CONFIRM_NOTE,
62
70
  annotations: toolAnnotations({ title: 'Change Vibo user role', readOnly: false, destructive: true }),
63
71
  inputSchema: z.object({
64
72
  eventId: z.string().describe('Event id.'),
65
73
  userId: z.string().describe('Id of the member to update.'),
66
74
  type: z.enum(['host', 'guest']).describe('New role for the member.'),
67
- confirm: schemaConfirm,
75
+ confirmToken: confirmTokenParam,
68
76
  }),
69
- }, async ({ eventId, userId, type, confirm }) => {
77
+ }, async ({ eventId, userId, type, confirmToken }, ctx) => {
70
78
  const variables = { eventId, userId, type };
71
- if (!confirm)
72
- return previewResult('changeUserTypeInEvent', variables);
79
+ const gate = await confirmWrite(ctx, {
80
+ tool: 'vibo_change_user_role',
81
+ mutation: 'changeUserTypeInEvent',
82
+ message: 'Review and confirm this role change:',
83
+ confirmToken,
84
+ target: userId,
85
+ willSend: variables,
86
+ });
87
+ if (gate)
88
+ return gate;
73
89
  const data = await client.gql(CHANGE_USER_ROLE, variables);
74
90
  return minifiedResult(data.changeUserTypeInEvent);
75
91
  });
76
92
  server.registerTool('vibo_remove_user', {
77
- description: 'Remove a member from an event. Confirm-gated.',
93
+ description: 'Remove a member from an event. ' + CONFIRM_NOTE,
78
94
  annotations: toolAnnotations({ title: 'Remove Vibo event user', readOnly: false, destructive: true }),
79
95
  inputSchema: z.object({
80
96
  eventId: z.string().describe('Event id.'),
81
97
  userId: z.string().describe('Id of the member to remove.'),
82
- confirm: schemaConfirm,
98
+ confirmToken: confirmTokenParam,
83
99
  }),
84
- }, async ({ eventId, userId, confirm }) => {
100
+ }, async ({ eventId, userId, confirmToken }, ctx) => {
85
101
  const variables = { eventId, userId };
86
- if (!confirm)
87
- return previewResult('removeUserFromEvent', variables);
102
+ const gate = await confirmWrite(ctx, {
103
+ tool: 'vibo_remove_user',
104
+ mutation: 'removeUserFromEvent',
105
+ message: 'Review and confirm removing this member from the event:',
106
+ confirmToken,
107
+ target: userId,
108
+ willSend: variables,
109
+ });
110
+ if (gate)
111
+ return gate;
88
112
  const data = await client.gql(REMOVE_USER, variables);
89
113
  return minifiedResult(data.removeUserFromEvent);
90
114
  });
@@ -1,71 +1,103 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, schemaConfirm, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { CREATE_SONG_COMMENT, DELETE_SONG_COMMENT, CREATE_SECTION_COMMENT, DELETE_SECTION_COMMENT, } from '../gql.js';
4
- import { previewResult } from './shared.js';
4
+ import { confirmWrite, CONFIRM_NOTE } from './shared.js';
5
5
  export function registerCommentTools(server, client) {
6
6
  server.registerTool('vibo_comment_on_song', {
7
- description: 'Leave a comment / note for the DJ on a specific song. Confirm-gated.',
7
+ description: 'Leave a comment / note for the DJ on a specific song. ' + CONFIRM_NOTE,
8
8
  annotations: toolAnnotations({ title: 'Comment on Vibo song', readOnly: false, destructive: false }),
9
9
  inputSchema: z.object({
10
10
  eventId: z.string().describe('Event id.'),
11
11
  sectionId: z.string().describe('Section id.'),
12
12
  songId: z.string().describe('Song _id (from vibo_get_section_songs).'),
13
13
  message: z.string().describe('The comment text.'),
14
- confirm: schemaConfirm,
14
+ confirmToken: confirmTokenParam,
15
15
  }),
16
- }, async ({ eventId, sectionId, songId, message, confirm }) => {
16
+ }, async ({ eventId, sectionId, songId, message, confirmToken }, ctx) => {
17
17
  const vars = { eventId, sectionId, songId, payload: { message } };
18
- if (!confirm)
19
- return previewResult('createSongComment', vars);
18
+ const gate = await confirmWrite(ctx, {
19
+ tool: 'vibo_comment_on_song',
20
+ mutation: 'createSongComment',
21
+ message: 'Review and confirm this song comment:',
22
+ confirmToken,
23
+ target: songId,
24
+ willSend: vars,
25
+ });
26
+ if (gate)
27
+ return gate;
20
28
  const data = await client.gql(CREATE_SONG_COMMENT, vars);
21
29
  return minifiedResult(data.createSongComment);
22
30
  });
23
31
  server.registerTool('vibo_delete_song_comment', {
24
- description: 'Delete a comment on a song. Confirm-gated.',
32
+ description: 'Delete a comment on a song. ' + CONFIRM_NOTE,
25
33
  annotations: toolAnnotations({ title: 'Delete Vibo song comment', readOnly: false, destructive: true }),
26
34
  inputSchema: z.object({
27
35
  eventId: z.string().describe('Event id.'),
28
36
  sectionId: z.string().describe('Section id.'),
29
37
  songId: z.string().describe('Song _id (from vibo_get_section_songs).'),
30
38
  commentId: z.string().describe('Comment _id to delete.'),
31
- confirm: schemaConfirm,
39
+ confirmToken: confirmTokenParam,
32
40
  }),
33
- }, async ({ eventId, sectionId, songId, commentId, confirm }) => {
41
+ }, async ({ eventId, sectionId, songId, commentId, confirmToken }, ctx) => {
34
42
  const vars = { eventId, sectionId, songId, commentId };
35
- if (!confirm)
36
- return previewResult('deleteSongComment', vars);
43
+ const gate = await confirmWrite(ctx, {
44
+ tool: 'vibo_delete_song_comment',
45
+ mutation: 'deleteSongComment',
46
+ message: 'Review and confirm deleting this song comment:',
47
+ confirmToken,
48
+ target: commentId,
49
+ willSend: vars,
50
+ });
51
+ if (gate)
52
+ return gate;
37
53
  const data = await client.gql(DELETE_SONG_COMMENT, vars);
38
54
  return minifiedResult(data.deleteSongComment);
39
55
  });
40
56
  server.registerTool('vibo_comment_on_section', {
41
- description: 'Leave a comment on a timeline section. Confirm-gated.',
57
+ description: 'Leave a comment on a timeline section. ' + CONFIRM_NOTE,
42
58
  annotations: toolAnnotations({ title: 'Comment on Vibo section', readOnly: false, destructive: false }),
43
59
  inputSchema: z.object({
44
60
  eventId: z.string().describe('Event id.'),
45
61
  sectionId: z.string().describe('Section id (from vibo_list_sections).'),
46
62
  message: z.string().describe('The comment text.'),
47
- confirm: schemaConfirm,
63
+ confirmToken: confirmTokenParam,
48
64
  }),
49
- }, async ({ eventId, sectionId, message, confirm }) => {
65
+ }, async ({ eventId, sectionId, message, confirmToken }, ctx) => {
50
66
  const vars = { eventId, sectionId, payload: { message } };
51
- if (!confirm)
52
- return previewResult('createSectionComment', vars);
67
+ const gate = await confirmWrite(ctx, {
68
+ tool: 'vibo_comment_on_section',
69
+ mutation: 'createSectionComment',
70
+ message: 'Review and confirm this section comment:',
71
+ confirmToken,
72
+ target: sectionId,
73
+ willSend: vars,
74
+ });
75
+ if (gate)
76
+ return gate;
53
77
  const data = await client.gql(CREATE_SECTION_COMMENT, vars);
54
78
  return minifiedResult(data.createSectionComment);
55
79
  });
56
80
  server.registerTool('vibo_delete_section_comment', {
57
- description: 'Delete a comment on a timeline section. Confirm-gated.',
81
+ description: 'Delete a comment on a timeline section. ' + CONFIRM_NOTE,
58
82
  annotations: toolAnnotations({ title: 'Delete Vibo section comment', readOnly: false, destructive: true }),
59
83
  inputSchema: z.object({
60
84
  eventId: z.string().describe('Event id.'),
61
85
  sectionId: z.string().describe('Section id (from vibo_list_sections).'),
62
86
  commentId: z.string().describe('Comment _id to delete.'),
63
- confirm: schemaConfirm,
87
+ confirmToken: confirmTokenParam,
64
88
  }),
65
- }, async ({ eventId, sectionId, commentId, confirm }) => {
89
+ }, async ({ eventId, sectionId, commentId, confirmToken }, ctx) => {
66
90
  const vars = { eventId, sectionId, commentId };
67
- if (!confirm)
68
- return previewResult('deleteSectionComment', vars);
91
+ const gate = await confirmWrite(ctx, {
92
+ tool: 'vibo_delete_section_comment',
93
+ mutation: 'deleteSectionComment',
94
+ message: 'Review and confirm deleting this section comment:',
95
+ confirmToken,
96
+ target: commentId,
97
+ willSend: vars,
98
+ });
99
+ if (gate)
100
+ return gate;
69
101
  const data = await client.gql(DELETE_SECTION_COMMENT, vars);
70
102
  return minifiedResult(data.deleteSectionComment);
71
103
  });
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError, minifiedResult, schemaConfirm, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { McpToolError, minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { LIST_UPCOMING_EVENTS, LIST_HISTORY_EVENTS, GET_EVENT, JOIN_EVENT_BY_DEEP_LINK, JOIN_EVENT_BY_HASH, LEAVE_EVENT, CREATE_EVENT_CONTACT, } from '../gql.js';
4
- import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
4
+ import { limitSchema, skipSchema, pagination, confirmWrite, CONFIRM_NOTE } from './shared.js';
5
5
  export function registerEventTools(server, client) {
6
6
  server.registerTool('vibo_list_events', {
7
7
  description: "List the events you're part of (as host or guest). Defaults to upcoming events; pass past:true for events that have already happened. Optionally filter by a search query.",
@@ -32,19 +32,26 @@ export function registerEventTools(server, client) {
32
32
  return minifiedResult(data.event);
33
33
  });
34
34
  server.registerTool('vibo_join_event', {
35
- description: "Join an event you were invited to, via its share link or hash (e.g. a vibodj.app.link/... URL someone sent you). Returns the joined event's id. Confirm-gated.",
35
+ description: "Join an event you were invited to, via its share link or hash (e.g. a vibodj.app.link/... URL someone sent you). Returns the joined event's id. " + CONFIRM_NOTE,
36
36
  annotations: toolAnnotations({ title: 'Join Vibo event', readOnly: false, destructive: false }),
37
37
  inputSchema: z.object({
38
38
  link: z
39
39
  .string()
40
40
  .describe('The full event share URL (vibodj.app.link/... or web.vibodj.com/...) or the bare join hash.'),
41
- confirm: schemaConfirm,
41
+ confirmToken: confirmTokenParam,
42
42
  }),
43
- }, async ({ link, confirm }) => {
43
+ }, async ({ link, confirmToken }, ctx) => {
44
44
  const isUrl = /^https?:\/\//i.test(link);
45
- if (!confirm) {
46
- return previewResult('joinEvent', isUrl ? { deepLink: link } : { hash: link });
47
- }
45
+ const gate = await confirmWrite(ctx, {
46
+ tool: 'vibo_join_event',
47
+ mutation: 'joinEvent',
48
+ message: 'Review and confirm joining this event:',
49
+ confirmToken,
50
+ target: link,
51
+ willSend: isUrl ? { deepLink: link } : { hash: link },
52
+ });
53
+ if (gate)
54
+ return gate;
48
55
  if (isUrl) {
49
56
  const data = await client.gql(JOIN_EVENT_BY_DEEP_LINK, {
50
57
  deepLink: link,
@@ -55,20 +62,28 @@ export function registerEventTools(server, client) {
55
62
  return minifiedResult({ joined: true, eventId: data.joinEventByHash._id });
56
63
  });
57
64
  server.registerTool('vibo_leave_event', {
58
- description: 'Leave an event you previously joined. Confirm-gated.',
65
+ description: 'Leave an event you previously joined. ' + CONFIRM_NOTE,
59
66
  annotations: toolAnnotations({ title: 'Leave Vibo event', readOnly: false, destructive: true }),
60
67
  inputSchema: z.object({
61
68
  eventId: z.string().describe('Event id to leave.'),
62
- confirm: schemaConfirm,
69
+ confirmToken: confirmTokenParam,
63
70
  }),
64
- }, async ({ eventId, confirm }) => {
65
- if (!confirm)
66
- return previewResult('leaveEvent', { eventId });
71
+ }, async ({ eventId, confirmToken }, ctx) => {
72
+ const gate = await confirmWrite(ctx, {
73
+ tool: 'vibo_leave_event',
74
+ mutation: 'leaveEvent',
75
+ message: 'Review and confirm leaving this event:',
76
+ confirmToken,
77
+ target: eventId,
78
+ willSend: { eventId },
79
+ });
80
+ if (gate)
81
+ return gate;
67
82
  const data = await client.gql(LEAVE_EVENT, { eventId });
68
83
  return minifiedResult({ left: true, eventId, result: data.leaveEvent });
69
84
  });
70
85
  server.registerTool('vibo_create_event_contact', {
71
- description: 'Add a contact (host or guest) to an event with their name/email/phone. Confirm-gated.',
86
+ description: 'Add a contact (host or guest) to an event with their name/email/phone. ' + CONFIRM_NOTE,
72
87
  annotations: toolAnnotations({ title: 'Add Vibo event contact', readOnly: false, destructive: false }),
73
88
  inputSchema: z.object({
74
89
  eventId: z.string().describe('Event id.'),
@@ -78,9 +93,9 @@ export function registerEventTools(server, client) {
78
93
  lastName: z.string().optional(),
79
94
  phoneCode: z.string().optional().describe('Country calling code, e.g. "1".'),
80
95
  phoneNumber: z.string().optional(),
81
- confirm: schemaConfirm,
96
+ confirmToken: confirmTokenParam,
82
97
  }),
83
- }, async ({ eventId, role, email, firstName, lastName, phoneCode, phoneNumber, confirm }) => {
98
+ }, async ({ eventId, role, email, firstName, lastName, phoneCode, phoneNumber, confirmToken }, ctx) => {
84
99
  const payload = { role, email };
85
100
  if (firstName !== undefined)
86
101
  payload.firstName = firstName;
@@ -95,8 +110,16 @@ export function registerEventTools(server, client) {
95
110
  hint: 'Pass phoneCode (e.g. "1") alongside phoneNumber.',
96
111
  });
97
112
  }
98
- if (!confirm)
99
- return previewResult('createEventContact', { eventId, payload });
113
+ const gate = await confirmWrite(ctx, {
114
+ tool: 'vibo_create_event_contact',
115
+ mutation: 'createEventContact',
116
+ message: 'Review and confirm adding this event contact:',
117
+ confirmToken,
118
+ target: eventId,
119
+ willSend: { eventId, payload },
120
+ });
121
+ if (gate)
122
+ return gate;
100
123
  const data = await client.gql(CREATE_EVENT_CONTACT, { eventId, payload });
101
124
  return minifiedResult(data.createEventContact);
102
125
  });
@@ -1,10 +1,10 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, schemaConfirm, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { IMPORT_PLAYLIST_TO_SECTION } from '../gql.js';
4
- import { previewResult } from './shared.js';
4
+ import { confirmWrite, CONFIRM_NOTE } from './shared.js';
5
5
  export function registerImportTools(server, client) {
6
6
  server.registerTool('vibo_import_playlist_to_section', {
7
- description: 'Import selected tracks from a connected Spotify/Apple Music playlist into a section. Returns counts of added/existing/ignored. Confirm-gated.',
7
+ description: 'Import selected tracks from a connected Spotify/Apple Music playlist into a section. Returns counts of added/existing/ignored. ' + CONFIRM_NOTE,
8
8
  annotations: toolAnnotations({ title: 'Import playlist to section', readOnly: false, destructive: false }),
9
9
  inputSchema: z.object({
10
10
  eventId: z.string().describe('Event id.'),
@@ -18,9 +18,9 @@ export function registerImportTools(server, client) {
18
18
  .min(1)
19
19
  .describe('Track ids (from vibo_get_playlist_songs) to import.'),
20
20
  tracksToIgnore: z.array(z.string()).optional().describe('Track ids to skip.'),
21
- confirm: schemaConfirm,
21
+ confirmToken: confirmTokenParam,
22
22
  }),
23
- }, async ({ eventId, sectionId, source, playlistId, tracksToAdd, tracksToIgnore, confirm }) => {
23
+ }, async ({ eventId, sectionId, source, playlistId, tracksToAdd, tracksToIgnore, confirmToken }, ctx) => {
24
24
  const vars = {
25
25
  eventId,
26
26
  sectionId,
@@ -29,8 +29,16 @@ export function registerImportTools(server, client) {
29
29
  tracksToAdd,
30
30
  tracksToIgnore: tracksToIgnore ?? [],
31
31
  };
32
- if (!confirm)
33
- return previewResult('importPlaylistToSectionWeb', vars);
32
+ const gate = await confirmWrite(ctx, {
33
+ tool: 'vibo_import_playlist_to_section',
34
+ mutation: 'importPlaylistToSectionWeb',
35
+ message: 'Review and confirm this playlist import:',
36
+ confirmToken,
37
+ target: sectionId,
38
+ willSend: vars,
39
+ });
40
+ if (gate)
41
+ return gate;
34
42
  const data = await client.gql(IMPORT_PLAYLIST_TO_SECTION, vars);
35
43
  return minifiedResult(data.importPlaylistToSectionWeb);
36
44
  });
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError, minifiedResult, schemaConfirm, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { McpToolError, minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { GET_NOTIFICATIONS, GET_NOTIFICATIONS_COUNT, MARK_AS_READ } from '../gql.js';
4
- import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
4
+ import { limitSchema, skipSchema, pagination, confirmWrite, CONFIRM_NOTE } from './shared.js';
5
5
  import { viewArg, viewResponse } from '../view.js';
6
6
  export function registerNotificationTools(server, client) {
7
7
  server.registerTool('vibo_list_notifications', {
@@ -26,14 +26,14 @@ export function registerNotificationTools(server, client) {
26
26
  return minifiedResult(data.getNotificationsCount);
27
27
  });
28
28
  server.registerTool('vibo_mark_notifications_read', {
29
- description: 'Mark notifications as read — pass specific notificationIds, or readAll:true to clear everything. Confirm-gated.',
29
+ description: 'Mark notifications as read — pass specific notificationIds, or readAll:true to clear everything. ' + CONFIRM_NOTE,
30
30
  annotations: toolAnnotations({ title: 'Mark notifications read', readOnly: false, destructive: false }),
31
31
  inputSchema: z.object({
32
32
  notificationIds: z.array(z.string()).optional().describe('Specific notification ids to mark read.'),
33
33
  readAll: z.boolean().optional().describe('Mark every notification as read.'),
34
- confirm: schemaConfirm,
34
+ confirmToken: confirmTokenParam,
35
35
  }),
36
- }, async ({ notificationIds, readAll, confirm }) => {
36
+ }, async ({ notificationIds, readAll, confirmToken }, ctx) => {
37
37
  if (!notificationIds?.length && !readAll) {
38
38
  throw new McpToolError('Provide notificationIds or set readAll:true.', {
39
39
  hint: 'Pass an array of notification ids, or readAll:true to clear all.',
@@ -44,8 +44,16 @@ export function registerNotificationTools(server, client) {
44
44
  variables.notificationIds = notificationIds;
45
45
  if (readAll)
46
46
  variables.readAll = true;
47
- if (!confirm)
48
- return previewResult('markAsRead', variables);
47
+ const gate = await confirmWrite(ctx, {
48
+ tool: 'vibo_mark_notifications_read',
49
+ mutation: 'markAsRead',
50
+ message: 'Review and confirm marking these notifications read:',
51
+ confirmToken,
52
+ target: '',
53
+ willSend: variables,
54
+ });
55
+ if (gate)
56
+ return gate;
49
57
  const data = await client.gql(MARK_AS_READ, variables);
50
58
  return minifiedResult({ marked: true, result: data.markAsRead });
51
59
  });