vibo-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js ADDED
@@ -0,0 +1,219 @@
1
+ import { dirname, join } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import { loadDotenvSafely, readEnvVar, McpToolError, SessionNotAuthenticatedError, truncateErrorMessage, } from '@chrischall/mcp-utils';
4
+ // Load .env for local dev; silently skip if dotenv is unavailable (e.g. the
5
+ // mcpb bundle, which externalizes dotenv). `override: false` means a
6
+ // host-provided env var always wins over .env.
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
9
+ const DEFAULT_API_URL = 'https://api.vibodj.com/v2/graphql';
10
+ const SERVICE = 'Vibo';
11
+ const SIGN_IN_HOST = 'https://web.vibodj.com';
12
+ const REQUEST_TIMEOUT_MS = 30_000;
13
+ /** `signIn` exchanges email + password for an access/refresh token pair. */
14
+ const SIGN_IN = `
15
+ mutation signIn($email: String!, $password: String!) {
16
+ signIn(email: $email, password: $password) {
17
+ accessToken
18
+ refreshToken
19
+ }
20
+ }
21
+ `;
22
+ /** `refreshToken` mints a fresh access token from a still-valid refresh token. */
23
+ const REFRESH = `
24
+ mutation refreshToken($refreshToken: String!) {
25
+ refreshToken(refreshToken: $refreshToken) {
26
+ accessToken
27
+ refreshToken
28
+ }
29
+ }
30
+ `;
31
+ // Error codes Vibo (and conventional GraphQL servers) use for an expired /
32
+ // missing session — these should trigger a token refresh + replay.
33
+ const AUTH_ERROR_CODES = new Set(['UNAUTHORIZED', 'UNAUTHENTICATED', 'FORBIDDEN']);
34
+ /**
35
+ * Thin GraphQL client for the Vibo consumer API.
36
+ *
37
+ * Vibo authenticates with custom `x-token` / `x-refresh-token` headers (not
38
+ * `Authorization: Bearer`), so this is a hand-written client rather than
39
+ * `createApiClient`. Two credential paths are supported:
40
+ *
41
+ * - VIBO_EMAIL + VIBO_PASSWORD → server-side `signIn` mutation (preferred)
42
+ * - VIBO_ACCESS_TOKEN [+ VIBO_REFRESH_TOKEN] → use a captured token directly
43
+ * (for accounts that only sign in via Apple/Google/Facebook SSO)
44
+ *
45
+ * The config error is deferred: the constructor never throws, so the server
46
+ * still boots and answers the host's install-time `tools/list` probe when no
47
+ * credentials are set. The error surfaces on the first tool call.
48
+ */
49
+ export class ViboClient {
50
+ apiUrl;
51
+ email;
52
+ password;
53
+ configError;
54
+ accessToken;
55
+ refreshTokenValue;
56
+ // Single-flight guards so concurrent tool calls never race two logins /
57
+ // refreshes against each other (à la mcp-utils' TokenManager).
58
+ loginInFlight = null;
59
+ reauthInFlight = null;
60
+ constructor() {
61
+ this.apiUrl = readEnvVar('VIBO_API_URL') ?? DEFAULT_API_URL;
62
+ const email = readEnvVar('VIBO_EMAIL');
63
+ const password = readEnvVar('VIBO_PASSWORD');
64
+ const accessToken = readEnvVar('VIBO_ACCESS_TOKEN');
65
+ const refreshToken = readEnvVar('VIBO_REFRESH_TOKEN');
66
+ this.email = email ?? null;
67
+ this.password = password ?? null;
68
+ this.accessToken = accessToken ?? null;
69
+ this.refreshTokenValue = refreshToken ?? null;
70
+ const haveLogin = Boolean(email && password);
71
+ const haveToken = Boolean(accessToken);
72
+ if (!haveLogin && !haveToken) {
73
+ 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.',
76
+ });
77
+ }
78
+ else {
79
+ this.configError = null;
80
+ }
81
+ }
82
+ /** Run a GraphQL operation, transparently authenticating + retrying once on token expiry. */
83
+ async gql(query, variables = {}) {
84
+ if (this.configError)
85
+ throw this.configError;
86
+ const token = await this.ensureAccessToken();
87
+ let res = await this.post(query, variables, token);
88
+ if (this.isAuthError(res.status, res.body.errors)) {
89
+ // Token expired/invalid — re-authenticate once and replay exactly once.
90
+ const fresh = await this.reauthenticate();
91
+ res = await this.post(query, variables, fresh);
92
+ }
93
+ return this.unwrap(res.status, res.body);
94
+ }
95
+ /** Returns the current access token, performing a first login if we only have email/password. */
96
+ async ensureAccessToken() {
97
+ if (this.accessToken)
98
+ return this.accessToken;
99
+ return this.login();
100
+ }
101
+ /** Single-flight email/password login. */
102
+ login() {
103
+ if (this.loginInFlight)
104
+ return this.loginInFlight;
105
+ if (!this.email || !this.password) {
106
+ // Only a (now-rejected) token was supplied and there's nothing to log in with.
107
+ throw new SessionNotAuthenticatedError(SERVICE, SIGN_IN_HOST);
108
+ }
109
+ this.loginInFlight = (async () => {
110
+ const res = await this.post(SIGN_IN, { email: this.email, password: this.password }, null);
111
+ const data = this.unwrap(res.status, res.body);
112
+ if (!data.signIn?.accessToken) {
113
+ throw new SessionNotAuthenticatedError(SERVICE, SIGN_IN_HOST);
114
+ }
115
+ this.accessToken = data.signIn.accessToken;
116
+ this.refreshTokenValue = data.signIn.refreshToken;
117
+ return this.accessToken;
118
+ })().finally(() => {
119
+ this.loginInFlight = null;
120
+ });
121
+ return this.loginInFlight;
122
+ }
123
+ /** Single-flight re-auth: try a refresh-token grant first, fall back to a fresh login. */
124
+ reauthenticate() {
125
+ if (this.reauthInFlight)
126
+ return this.reauthInFlight;
127
+ this.reauthInFlight = (async () => {
128
+ if (this.refreshTokenValue) {
129
+ try {
130
+ const res = await this.post(REFRESH, { refreshToken: this.refreshTokenValue }, null);
131
+ if (!this.isAuthError(res.status, res.body.errors)) {
132
+ const data = this.unwrap(res.status, res.body);
133
+ if (data.refreshToken?.accessToken) {
134
+ this.accessToken = data.refreshToken.accessToken;
135
+ this.refreshTokenValue = data.refreshToken.refreshToken;
136
+ return this.accessToken;
137
+ }
138
+ }
139
+ }
140
+ catch {
141
+ // fall through to a full login
142
+ }
143
+ }
144
+ if (this.email && this.password) {
145
+ this.accessToken = null;
146
+ return this.login();
147
+ }
148
+ throw new SessionNotAuthenticatedError(SERVICE, SIGN_IN_HOST);
149
+ })().finally(() => {
150
+ this.reauthInFlight = null;
151
+ });
152
+ return this.reauthInFlight;
153
+ }
154
+ async post(query, variables, token) {
155
+ const headers = { 'content-type': 'application/json' };
156
+ if (token)
157
+ headers['x-token'] = token;
158
+ let response;
159
+ try {
160
+ response = await fetch(this.apiUrl, {
161
+ method: 'POST',
162
+ headers,
163
+ body: JSON.stringify({ query, variables }),
164
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
165
+ });
166
+ }
167
+ catch (err) {
168
+ const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'failed';
169
+ throw new McpToolError(`Request to ${SERVICE} ${reason}.`, {
170
+ hint: 'The Vibo API may be unreachable — check your connection and retry.',
171
+ cause: err,
172
+ });
173
+ }
174
+ let body;
175
+ try {
176
+ body = (await response.json());
177
+ }
178
+ catch {
179
+ body = {};
180
+ }
181
+ return { status: response.status, body };
182
+ }
183
+ isAuthError(status, errors) {
184
+ if (status === 401 || status === 403)
185
+ return true;
186
+ if (!errors?.length)
187
+ return false;
188
+ return errors.some((e) => {
189
+ const code = e.code ?? e.extensions?.code ?? '';
190
+ if (AUTH_ERROR_CODES.has(code))
191
+ return true;
192
+ // Message fallback for servers that omit a code. Vibo's text is
193
+ // "Not authorized. Try to log in".
194
+ return /not authoriz|unauthor|unauthenticated|invalid token|token expired|expired token|jwt|log ?in/i.test(e.message ?? '');
195
+ });
196
+ }
197
+ unwrap(status, body) {
198
+ if (body.errors?.length) {
199
+ if (this.isAuthError(status, body.errors)) {
200
+ throw new SessionNotAuthenticatedError(SERVICE, SIGN_IN_HOST);
201
+ }
202
+ const message = body.errors.map((e) => e.message ?? 'Unknown error').join('; ');
203
+ throw new McpToolError(`${SERVICE} API error: ${truncateErrorMessage(message)}`);
204
+ }
205
+ if (status >= 400) {
206
+ throw new McpToolError(`${SERVICE} API returned HTTP ${status}.`);
207
+ }
208
+ if (body.data === undefined) {
209
+ throw new McpToolError(`${SERVICE} API returned an empty response.`);
210
+ }
211
+ return body.data;
212
+ }
213
+ }
214
+ /**
215
+ * Module-level singleton shared by every tool module. Constructed here (not in
216
+ * index.ts) so the deferred-config-error pattern holds: the server boots and
217
+ * answers the install-time tools/list probe even when credentials are absent.
218
+ */
219
+ export const client = new ViboClient();
package/dist/gql.js ADDED
@@ -0,0 +1,210 @@
1
+ // All GraphQL operation documents for the Vibo consumer API, in one place so
2
+ // the wire surface is reviewable independently of the tool wiring. Field
3
+ // selections were built from live schema introspection of api.vibodj.com — see
4
+ // docs/VIBO-API.md. Tool modules import these and call client.gql(DOC, vars).
5
+ const SONG_LINKS = `links { spotify appleMusic youtube tidal soundcloud deezer }`;
6
+ const THUMBS = `thumbnails { s180x180 original }`;
7
+ const LOCATION = `location { name lat lng }`;
8
+ // ---- Profile ----------------------------------------------------------------
9
+ export const GET_ME = `
10
+ query getMe {
11
+ me {
12
+ _id firstName lastName email phoneCode phoneNumber country lang
13
+ imageUrl role loginType spotifyConnected appleMusicConnected
14
+ ${LOCATION}
15
+ }
16
+ }
17
+ `;
18
+ // ---- Events -----------------------------------------------------------------
19
+ const EVENT_LIST_ITEM = `
20
+ _id title status type date timezone role isPast isLocked playlistSize
21
+ usersCount hostsCount guestsCount sectionsWithSongs sectionsWithSongsTotal
22
+ sectionsWithSongsProgress questionsCount answeredQuestionsCount isPro
23
+ ${LOCATION}
24
+ `;
25
+ export const LIST_UPCOMING_EVENTS = `
26
+ query getUpcomingEvents($filter: EventsFilterInput, $pagination: PaginationInput, $sort: SortInput) {
27
+ upcomingEvents(filter: $filter, pagination: $pagination, sort: $sort) {
28
+ events { ${EVENT_LIST_ITEM} }
29
+ next { skip limit }
30
+ totalCount
31
+ }
32
+ }
33
+ `;
34
+ export const LIST_HISTORY_EVENTS = `
35
+ query getHistoryEvents($filter: EventsFilterInput, $pagination: PaginationInput, $sort: SortInput) {
36
+ historyEvents(filter: $filter, pagination: $pagination, sort: $sort) {
37
+ events { ${EVENT_LIST_ITEM} }
38
+ next { skip limit }
39
+ totalCount
40
+ }
41
+ }
42
+ `;
43
+ export const GET_EVENT = `
44
+ query getEvent($eventId: ID!) {
45
+ event(eventId: $eventId) {
46
+ _id title status type date timezone arrivalTime startTime endTime
47
+ role isPast isLocked lockDate note hostsCount guestsCount usersCount
48
+ playlistSize sectionsCount sectionsWithSongs sectionsWithSongsTotal
49
+ sectionsWithSongsProgress questionsCount answeredQuestionsCount
50
+ dontPlayVisibility hostDeepLink guestDeepLink isPro firstSectionId
51
+ ${LOCATION}
52
+ }
53
+ }
54
+ `;
55
+ export const JOIN_EVENT_BY_DEEP_LINK = `
56
+ mutation joinEventViaDeepLink($deepLink: String!) {
57
+ joinEventViaDeepLink(deepLink: $deepLink) { _id }
58
+ }
59
+ `;
60
+ export const JOIN_EVENT_BY_HASH = `
61
+ mutation joinEventByHash($hash: String!) {
62
+ joinEventByHash(hash: $hash) { _id }
63
+ }
64
+ `;
65
+ export const LEAVE_EVENT = `
66
+ mutation leaveEvent($eventId: ID!) {
67
+ leaveEvent(eventId: $eventId)
68
+ }
69
+ `;
70
+ export const CREATE_EVENT_CONTACT = `
71
+ mutation createEventContact($eventId: ID!, $payload: CreateContactInput!) {
72
+ createEventContact(eventId: $eventId, payload: $payload) {
73
+ _id role email firstName lastName phoneCode phoneNumber
74
+ }
75
+ }
76
+ `;
77
+ // ---- Sections (timeline) ----------------------------------------------------
78
+ export const LIST_SECTIONS = `
79
+ query sections($eventId: ID!, $filter: SectionsFilterInput) {
80
+ sections(eventId: $eventId, filter: $filter) {
81
+ _id name time note description type
82
+ hasNote hasComments hasSongs hasQuestions
83
+ songsCount songsInfo questionsCount answeredCount songIdeasCount
84
+ visibility progress totalProgress
85
+ coverSong { artist title }
86
+ }
87
+ }
88
+ `;
89
+ // ---- Songs ------------------------------------------------------------------
90
+ export const GET_SECTION_SONGS = `
91
+ query getSectionSongs($eventId: ID!, $sectionId: ID!, $filter: SectionSongsFilter, $pagination: PaginationInput, $sort: SongsSortInput) {
92
+ getSectionSongs(eventId: $eventId, sectionId: $sectionId, filter: $filter, pagination: $pagination, sort: $sort) {
93
+ songs {
94
+ _id viboSongId artist title isMustPlay isFlagged comment
95
+ likedByMe likesCount commentsCount isAddedByMe canRemove createdAt
96
+ creator { _id firstName lastName }
97
+ ${THUMBS}
98
+ ${SONG_LINKS}
99
+ }
100
+ next { skip limit }
101
+ totalCount
102
+ }
103
+ }
104
+ `;
105
+ export const SEARCH_SONGS = `
106
+ query getSongs($eventId: ID!, $sectionId: ID!, $filter: SongsFilter!, $limit: Int!) {
107
+ getSongs(eventId: $eventId, sectionId: $sectionId, filter: $filter, limit: $limit) {
108
+ sectionSongId viboSongId songUrl title artist isInSection isDontPlay
109
+ ${THUMBS}
110
+ ${SONG_LINKS}
111
+ }
112
+ }
113
+ `;
114
+ export const ADD_SONG_TO_SECTION = `
115
+ mutation addSongToSection($eventId: ID!, $sectionId: ID!, $payload: AddSongToSectionInput!) {
116
+ addSongToSection(eventId: $eventId, sectionId: $sectionId, payload: $payload) {
117
+ added songId totalCount sectionsWithSongs sectionsWithSongsTotal
118
+ sectionsWithSongsProgress totalProgress songsInfo
119
+ }
120
+ }
121
+ `;
122
+ export const TOGGLE_LIKE = `
123
+ mutation toggleLike($eventId: ID!, $sectionId: ID!, $songId: ID!, $liked: Boolean!) {
124
+ toggleLike(eventId: $eventId, sectionId: $sectionId, songId: $songId, liked: $liked) { liked }
125
+ }
126
+ `;
127
+ // ---- Playlists (connected Spotify / Apple Music) ----------------------------
128
+ export const GET_PLAYLISTS = `
129
+ query getPlaylists($source: MusicImportSource!, $pagination: PaginationInput, $filter: PlaylistsFilter) {
130
+ getPlaylists(source: $source, pagination: $pagination, filter: $filter) {
131
+ playlists { id name total images { url width height } }
132
+ total
133
+ next { skip limit }
134
+ }
135
+ }
136
+ `;
137
+ export const GET_PLAYLIST_SONGS = `
138
+ query getPlaylistSongs($playlistId: ID!, $source: MusicImportSource!, $pagination: PaginationInput) {
139
+ getPlaylistSongs(playlistId: $playlistId, source: $source, pagination: $pagination) {
140
+ tracks { id title artist songUrl images { url width height } }
141
+ total
142
+ next { skip limit }
143
+ }
144
+ }
145
+ `;
146
+ export const EXPORT_EVENT_TO_SPOTIFY = `
147
+ mutation exportEventToSpotify($eventId: ID!, $sectionIds: [ID!]!, $sort: SongsSortInput, $filter: ExportEventFilter, $title: String) {
148
+ exportEventToSpotify(eventId: $eventId, sectionIds: $sectionIds, sort: $sort, filter: $filter, title: $title) {
149
+ playlistUrl exportedCount title
150
+ failedToExport { text viboSongId sectionId }
151
+ }
152
+ }
153
+ `;
154
+ export const EXPORT_EVENT_TO_APPLE_MUSIC = `
155
+ mutation exportEventToAppleMusic($eventId: ID!, $sectionIds: [ID!]!, $sort: SongsSortInput, $filter: ExportEventFilter, $title: String) {
156
+ exportEventToAppleMusic(eventId: $eventId, sectionIds: $sectionIds, sort: $sort, filter: $filter, title: $title) {
157
+ playlistUrl exportedCount title
158
+ failedToExport { text viboSongId sectionId }
159
+ }
160
+ }
161
+ `;
162
+ // ---- Notifications ----------------------------------------------------------
163
+ export const GET_NOTIFICATIONS = `
164
+ query getNotifications($pagination: PaginationInput) {
165
+ getNotifications(pagination: $pagination) {
166
+ notifications {
167
+ _id imageUrl header body isRead notificationType createdAt
168
+ metadata { eventId sectionId questionId }
169
+ }
170
+ next { skip limit }
171
+ totalCount
172
+ }
173
+ }
174
+ `;
175
+ export const GET_NOTIFICATIONS_COUNT = `
176
+ query getNotificationsCount {
177
+ getNotificationsCount { total }
178
+ }
179
+ `;
180
+ export const MARK_AS_READ = `
181
+ mutation markAsRead($notificationIds: [ID!], $readAll: Boolean) {
182
+ markAsRead(notificationIds: $notificationIds, readAll: $readAll)
183
+ }
184
+ `;
185
+ // ---- Section questions (the DJ's planning questions) ------------------------
186
+ export const LIST_SECTION_QUESTIONS = `
187
+ query getEventSectionQuestionsV2($eventId: ID!, $sectionId: ID!) {
188
+ getEventSectionQuestionsV2(eventId: $eventId, sectionId: $sectionId) {
189
+ progress
190
+ questions {
191
+ _id
192
+ isAnswered
193
+ settings { type hasOther optionImagesEnabled notifyMe }
194
+ question {
195
+ title
196
+ options { _id title isOther }
197
+ }
198
+ answer { text selectedOptions link }
199
+ }
200
+ }
201
+ }
202
+ `;
203
+ export const ANSWER_SECTION_QUESTION = `
204
+ mutation answerEventSectionQuestionV2($eventId: ID!, $sectionId: ID!, $questionId: ID!, $payload: AnswerQuestionV2Input!) {
205
+ answerEventSectionQuestionV2(eventId: $eventId, sectionId: $sectionId, questionId: $questionId, payload: $payload) {
206
+ progress
207
+ question { _id isAnswered answer { text selectedOptions link } }
208
+ }
209
+ }
210
+ `;
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { runMcp } from '@chrischall/mcp-utils';
3
+ import { VERSION } from './version.js';
4
+ import { registerProfileTools } from './tools/profile.js';
5
+ import { registerEventTools } from './tools/events.js';
6
+ import { registerSectionTools } from './tools/sections.js';
7
+ import { registerSongTools } from './tools/songs.js';
8
+ import { registerPlaylistTools } from './tools/playlists.js';
9
+ import { registerNotificationTools } from './tools/notifications.js';
10
+ import { registerQuestionTools } from './tools/questions.js';
11
+ // The ViboClient is a module-level singleton (constructed in client.ts and
12
+ // imported by each tool module) that defers its config error to the first
13
+ // request. That preserves the deferred-config-error pattern: the server boots
14
+ // and answers the host's install-time tools/list probe even when no Vibo
15
+ // credentials are set — the error only surfaces on the first tool call.
16
+ await runMcp({
17
+ name: 'vibo-mcp',
18
+ version: VERSION,
19
+ banner: '[vibo-mcp] This project was developed and is maintained by AI (Claude Code). Use at your own discretion.',
20
+ tools: [
21
+ registerProfileTools,
22
+ registerEventTools,
23
+ registerSectionTools,
24
+ registerSongTools,
25
+ registerPlaylistTools,
26
+ registerNotificationTools,
27
+ registerQuestionTools,
28
+ ],
29
+ });
@@ -0,0 +1,104 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations, schemaConfirm, McpToolError } from '@chrischall/mcp-utils';
3
+ import { client } from '../client.js';
4
+ 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';
5
+ import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
6
+ export function registerEventTools(server) {
7
+ server.registerTool('vibo_list_events', {
8
+ 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.",
9
+ annotations: toolAnnotations({ title: 'List Vibo events', readOnly: true }),
10
+ inputSchema: {
11
+ past: z.boolean().optional().describe('Return past events instead of upcoming (default false).'),
12
+ q: z.string().optional().describe('Search events by title.'),
13
+ limit: limitSchema,
14
+ skip: skipSchema,
15
+ },
16
+ }, async ({ past, q, limit, skip }) => {
17
+ const variables = {
18
+ pagination: pagination(limit, skip),
19
+ ...(q ? { filter: { q } } : {}),
20
+ };
21
+ const doc = past ? LIST_HISTORY_EVENTS : LIST_UPCOMING_EVENTS;
22
+ const data = await client.gql(doc, variables);
23
+ return textResult(past ? data.historyEvents : data.upcomingEvents);
24
+ });
25
+ server.registerTool('vibo_get_event', {
26
+ description: 'Get full details for one event: title, date/timezone, location, your role, lock status, playlist size, and section/question progress. Use vibo_list_sections for the timeline.',
27
+ annotations: toolAnnotations({ title: 'Get Vibo event', readOnly: true }),
28
+ inputSchema: {
29
+ eventId: z.string().describe('Event id (the _id from vibo_list_events).'),
30
+ },
31
+ }, async ({ eventId }) => {
32
+ const data = await client.gql(GET_EVENT, { eventId });
33
+ return textResult(data.event);
34
+ });
35
+ server.registerTool('vibo_join_event', {
36
+ 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.",
37
+ annotations: toolAnnotations({ title: 'Join Vibo event', readOnly: false }),
38
+ inputSchema: {
39
+ link: z
40
+ .string()
41
+ .describe('The full event share URL (vibodj.app.link/... or web.vibodj.com/...) or the bare join hash.'),
42
+ confirm: schemaConfirm,
43
+ },
44
+ }, async ({ link, confirm }) => {
45
+ const isUrl = /^https?:\/\//i.test(link);
46
+ if (!confirm) {
47
+ return previewResult('joinEvent', isUrl ? { deepLink: link } : { hash: link });
48
+ }
49
+ if (isUrl) {
50
+ const data = await client.gql(JOIN_EVENT_BY_DEEP_LINK, {
51
+ deepLink: link,
52
+ });
53
+ return textResult({ joined: true, eventId: data.joinEventViaDeepLink._id });
54
+ }
55
+ const data = await client.gql(JOIN_EVENT_BY_HASH, { hash: link });
56
+ return textResult({ joined: true, eventId: data.joinEventByHash._id });
57
+ });
58
+ server.registerTool('vibo_leave_event', {
59
+ description: 'Leave an event you previously joined. Confirm-gated.',
60
+ annotations: toolAnnotations({ title: 'Leave Vibo event', readOnly: false }),
61
+ inputSchema: {
62
+ eventId: z.string().describe('Event id to leave.'),
63
+ confirm: schemaConfirm,
64
+ },
65
+ }, async ({ eventId, confirm }) => {
66
+ if (!confirm)
67
+ return previewResult('leaveEvent', { eventId });
68
+ const data = await client.gql(LEAVE_EVENT, { eventId });
69
+ return textResult({ left: true, eventId, result: data.leaveEvent });
70
+ });
71
+ server.registerTool('vibo_create_event_contact', {
72
+ description: 'Add a contact (host or guest) to an event with their name/email/phone. Confirm-gated.',
73
+ annotations: toolAnnotations({ title: 'Add Vibo event contact', readOnly: false }),
74
+ inputSchema: {
75
+ eventId: z.string().describe('Event id.'),
76
+ role: z.enum(['host', 'guest']).describe("The contact's role in the event."),
77
+ email: z.string().email().describe('Contact email (required by Vibo).'),
78
+ firstName: z.string().optional(),
79
+ lastName: z.string().optional(),
80
+ phoneCode: z.string().optional().describe('Country calling code, e.g. "1".'),
81
+ phoneNumber: z.string().optional(),
82
+ confirm: schemaConfirm,
83
+ },
84
+ }, async ({ eventId, role, email, firstName, lastName, phoneCode, phoneNumber, confirm }) => {
85
+ const payload = { role, email };
86
+ if (firstName !== undefined)
87
+ payload.firstName = firstName;
88
+ if (lastName !== undefined)
89
+ payload.lastName = lastName;
90
+ if (phoneCode !== undefined)
91
+ payload.phoneCode = phoneCode;
92
+ if (phoneNumber !== undefined)
93
+ payload.phoneNumber = phoneNumber;
94
+ if (phoneNumber !== undefined && phoneCode === undefined) {
95
+ throw new McpToolError('phoneCode is required when phoneNumber is provided.', {
96
+ hint: 'Pass phoneCode (e.g. "1") alongside phoneNumber.',
97
+ });
98
+ }
99
+ if (!confirm)
100
+ return previewResult('createEventContact', { eventId, payload });
101
+ const data = await client.gql(CREATE_EVENT_CONTACT, { eventId, payload });
102
+ return textResult(data.createEventContact);
103
+ });
104
+ }
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations, schemaConfirm, McpToolError } from '@chrischall/mcp-utils';
3
+ import { client } from '../client.js';
4
+ import { GET_NOTIFICATIONS, GET_NOTIFICATIONS_COUNT, MARK_AS_READ } from '../gql.js';
5
+ import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
6
+ export function registerNotificationTools(server) {
7
+ server.registerTool('vibo_list_notifications', {
8
+ description: 'List your Vibo notifications (song additions, comments, DJ updates, etc.) with read state and linked event/section ids.',
9
+ annotations: toolAnnotations({ title: 'List Vibo notifications', readOnly: true }),
10
+ inputSchema: {
11
+ limit: limitSchema,
12
+ skip: skipSchema,
13
+ },
14
+ }, async ({ limit, skip }) => {
15
+ const data = await client.gql(GET_NOTIFICATIONS, {
16
+ pagination: pagination(limit, skip),
17
+ });
18
+ return textResult(data.getNotifications);
19
+ });
20
+ server.registerTool('vibo_get_notifications_count', {
21
+ description: 'Get the count of unread Vibo notifications.',
22
+ annotations: toolAnnotations({ title: 'Unread notification count', readOnly: true }),
23
+ }, async () => {
24
+ const data = await client.gql(GET_NOTIFICATIONS_COUNT);
25
+ return textResult(data.getNotificationsCount);
26
+ });
27
+ server.registerTool('vibo_mark_notifications_read', {
28
+ description: 'Mark notifications as read — pass specific notificationIds, or readAll:true to clear everything. Confirm-gated.',
29
+ annotations: toolAnnotations({ title: 'Mark notifications read', readOnly: false }),
30
+ inputSchema: {
31
+ notificationIds: z.array(z.string()).optional().describe('Specific notification ids to mark read.'),
32
+ readAll: z.boolean().optional().describe('Mark every notification as read.'),
33
+ confirm: schemaConfirm,
34
+ },
35
+ }, async ({ notificationIds, readAll, confirm }) => {
36
+ if (!notificationIds?.length && !readAll) {
37
+ throw new McpToolError('Provide notificationIds or set readAll:true.', {
38
+ hint: 'Pass an array of notification ids, or readAll:true to clear all.',
39
+ });
40
+ }
41
+ const variables = {};
42
+ if (notificationIds?.length)
43
+ variables.notificationIds = notificationIds;
44
+ if (readAll)
45
+ variables.readAll = true;
46
+ if (!confirm)
47
+ return previewResult('markAsRead', variables);
48
+ const data = await client.gql(MARK_AS_READ, variables);
49
+ return textResult({ marked: true, result: data.markAsRead });
50
+ });
51
+ }