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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -3
- package/dist/bundle.js +946 -210
- package/dist/client.js +95 -20
- package/dist/session-store.js +2 -0
- package/dist/tools/collaboration.js +41 -17
- package/dist/tools/comments.js +54 -22
- package/dist/tools/events.js +41 -18
- package/dist/tools/imports.js +15 -7
- package/dist/tools/notifications.js +15 -7
- package/dist/tools/playlists.js +28 -12
- package/dist/tools/questions.js +29 -13
- package/dist/tools/section-edit.js +15 -7
- package/dist/tools/shared.js +25 -11
- package/dist/tools/song-management.js +54 -22
- package/dist/tools/songs.js +28 -12
- package/dist/tools/uploads.js +21 -9
- package/dist/upload-source.js +77 -7
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/vibo-mcp/SKILL.md +15 -7
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
|
-
|
|
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
|
-
|
|
199
|
-
throw
|
|
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({
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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 (
|
|
314
|
-
return
|
|
315
|
-
|
|
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);
|
package/dist/session-store.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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).
|
|
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
|
-
|
|
51
|
+
confirmToken: confirmTokenParam,
|
|
52
52
|
}),
|
|
53
|
-
}, async ({ eventId, type, text, emails,
|
|
53
|
+
}, async ({ eventId, type, text, emails, confirmToken }, ctx) => {
|
|
54
54
|
const variables = { eventId, type, text, emails };
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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
|
-
|
|
75
|
+
confirmToken: confirmTokenParam,
|
|
68
76
|
}),
|
|
69
|
-
}, async ({ eventId, userId, type,
|
|
77
|
+
}, async ({ eventId, userId, type, confirmToken }, ctx) => {
|
|
70
78
|
const variables = { eventId, userId, type };
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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
|
-
|
|
98
|
+
confirmToken: confirmTokenParam,
|
|
83
99
|
}),
|
|
84
|
-
}, async ({ eventId, userId,
|
|
100
|
+
}, async ({ eventId, userId, confirmToken }, ctx) => {
|
|
85
101
|
const variables = { eventId, userId };
|
|
86
|
-
|
|
87
|
-
|
|
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
|
});
|
package/dist/tools/comments.js
CHANGED
|
@@ -1,71 +1,103 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { minifiedResult,
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
14
|
+
confirmToken: confirmTokenParam,
|
|
15
15
|
}),
|
|
16
|
-
}, async ({ eventId, sectionId, songId, message,
|
|
16
|
+
}, async ({ eventId, sectionId, songId, message, confirmToken }, ctx) => {
|
|
17
17
|
const vars = { eventId, sectionId, songId, payload: { message } };
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
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
|
-
|
|
39
|
+
confirmToken: confirmTokenParam,
|
|
32
40
|
}),
|
|
33
|
-
}, async ({ eventId, sectionId, songId, commentId,
|
|
41
|
+
}, async ({ eventId, sectionId, songId, commentId, confirmToken }, ctx) => {
|
|
34
42
|
const vars = { eventId, sectionId, songId, commentId };
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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
|
-
|
|
63
|
+
confirmToken: confirmTokenParam,
|
|
48
64
|
}),
|
|
49
|
-
}, async ({ eventId, sectionId, message,
|
|
65
|
+
}, async ({ eventId, sectionId, message, confirmToken }, ctx) => {
|
|
50
66
|
const vars = { eventId, sectionId, payload: { message } };
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
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
|
-
|
|
87
|
+
confirmToken: confirmTokenParam,
|
|
64
88
|
}),
|
|
65
|
-
}, async ({ eventId, sectionId, commentId,
|
|
89
|
+
}, async ({ eventId, sectionId, commentId, confirmToken }, ctx) => {
|
|
66
90
|
const vars = { eventId, sectionId, commentId };
|
|
67
|
-
|
|
68
|
-
|
|
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
|
});
|
package/dist/tools/events.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { McpToolError, minifiedResult,
|
|
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,
|
|
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.
|
|
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
|
-
|
|
41
|
+
confirmToken: confirmTokenParam,
|
|
42
42
|
}),
|
|
43
|
-
}, async ({ link,
|
|
43
|
+
}, async ({ link, confirmToken }, ctx) => {
|
|
44
44
|
const isUrl = /^https?:\/\//i.test(link);
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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
|
-
|
|
69
|
+
confirmToken: confirmTokenParam,
|
|
63
70
|
}),
|
|
64
|
-
}, async ({ eventId,
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
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
|
-
|
|
96
|
+
confirmToken: confirmTokenParam,
|
|
82
97
|
}),
|
|
83
|
-
}, async ({ eventId, role, email, firstName, lastName, phoneCode, phoneNumber,
|
|
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
|
-
|
|
99
|
-
|
|
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
|
});
|
package/dist/tools/imports.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { minifiedResult,
|
|
2
|
+
import { minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
3
|
import { IMPORT_PLAYLIST_TO_SECTION } from '../gql.js';
|
|
4
|
-
import {
|
|
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.
|
|
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
|
-
|
|
21
|
+
confirmToken: confirmTokenParam,
|
|
22
22
|
}),
|
|
23
|
-
}, async ({ eventId, sectionId, source, playlistId, tracksToAdd, tracksToIgnore,
|
|
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
|
-
|
|
33
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
-
|
|
34
|
+
confirmToken: confirmTokenParam,
|
|
35
35
|
}),
|
|
36
|
-
}, async ({ notificationIds, readAll,
|
|
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
|
-
|
|
48
|
-
|
|
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
|
});
|