vibo-mcp 1.3.2 → 1.4.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/dist/bundle.js +202 -118
- package/dist/client.js +57 -59
- package/dist/index.js +5 -0
- package/dist/tools/collaboration.js +1 -2
- package/dist/tools/comments.js +1 -2
- package/dist/tools/events.js +1 -2
- package/dist/tools/ideas.js +1 -2
- package/dist/tools/imports.js +1 -2
- package/dist/tools/notifications.js +1 -2
- package/dist/tools/playlists.js +1 -2
- package/dist/tools/profile.js +1 -2
- package/dist/tools/questions.js +54 -18
- package/dist/tools/section-edit.js +1 -2
- package/dist/tools/sections.js +1 -2
- package/dist/tools/session.js +5 -2
- package/dist/tools/shared.js +9 -0
- package/dist/tools/song-management.js +1 -2
- package/dist/tools/songs.js +1 -2
- package/dist/tools/uploads.js +25 -8
- package/dist/upload-source.js +79 -0
- package/dist/version.js +1 -1
- package/dist/vibo-auth.js +34 -0
- package/package.json +14 -5
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -4,9 +4,17 @@ import { loadDotenvSafely, readEnvVar, McpToolError, SessionNotAuthenticatedErro
|
|
|
4
4
|
import { loadSession, saveSession } from './session-store.js';
|
|
5
5
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. the
|
|
6
6
|
// mcpb bundle, which externalizes dotenv). `override: false` means a
|
|
7
|
-
// host-provided env var always wins over .env.
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// host-provided env var always wins over .env. The try/catch additionally
|
|
8
|
+
// guards the Cloudflare Worker runtime (src/worker.ts): there `import.meta.url`
|
|
9
|
+
// is undefined and `fileURLToPath(undefined)` would otherwise throw at module
|
|
10
|
+
// init (Worker startup validation) — there is no filesystem / .env there anyway.
|
|
11
|
+
try {
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
/* v8 ignore next -- only reached in a non-Node runtime (Workers): no .env to load */
|
|
17
|
+
}
|
|
10
18
|
const DEFAULT_API_URL = 'https://api.vibodj.com/v2/graphql';
|
|
11
19
|
const SERVICE = 'Vibo';
|
|
12
20
|
const SIGN_IN_HOST = 'https://web.vibodj.com';
|
|
@@ -32,48 +40,47 @@ const REFRESH = `
|
|
|
32
40
|
// Error codes Vibo (and conventional GraphQL servers) use for an expired /
|
|
33
41
|
// missing session — these should trigger a token refresh + replay.
|
|
34
42
|
const AUTH_ERROR_CODES = new Set(['UNAUTHORIZED', 'UNAUTHENTICATED', 'FORBIDDEN']);
|
|
35
|
-
/**
|
|
36
|
-
* Thin GraphQL client for the Vibo consumer API.
|
|
37
|
-
*
|
|
38
|
-
* Vibo authenticates with custom `x-token` / `x-refresh-token` headers (not
|
|
39
|
-
* `Authorization: Bearer`), so this is a hand-written client rather than
|
|
40
|
-
* `createApiClient`. Two credential paths are supported:
|
|
41
|
-
*
|
|
42
|
-
* - VIBO_EMAIL + VIBO_PASSWORD → server-side `signIn` mutation (preferred)
|
|
43
|
-
* - VIBO_ACCESS_TOKEN [+ VIBO_REFRESH_TOKEN] → use a captured token directly
|
|
44
|
-
* (for accounts that only sign in via Apple/Google/Facebook SSO)
|
|
45
|
-
*
|
|
46
|
-
* The config error is deferred: the constructor never throws, so the server
|
|
47
|
-
* still boots and answers the host's install-time `tools/list` probe when no
|
|
48
|
-
* credentials are set. The error surfaces on the first tool call.
|
|
49
|
-
*/
|
|
50
43
|
export class ViboClient {
|
|
51
44
|
apiUrl;
|
|
52
45
|
email;
|
|
53
46
|
password;
|
|
54
47
|
// Not readonly: cleared by setTokens() after a browser capture seeds a session.
|
|
55
|
-
configError;
|
|
48
|
+
configError = null;
|
|
56
49
|
accessToken;
|
|
57
50
|
refreshTokenValue;
|
|
51
|
+
// The constructor is PURE — it does no filesystem / homedir / async-I/O /
|
|
52
|
+
// random op — so it is safe to run at Worker global scope (where the
|
|
53
|
+
// module-level `export const client` singleton below is constructed). The
|
|
54
|
+
// saved-session fallback and the config-error determination both touch
|
|
55
|
+
// homedir()/the filesystem via loadSession(), so they are deferred to the
|
|
56
|
+
// first request through `ensureConfigResolved()`. No effect on the stdio path.
|
|
57
|
+
configResolved = false;
|
|
58
58
|
// Single-flight guards so concurrent tool calls never race two logins /
|
|
59
59
|
// refreshes against each other (à la mcp-utils' TokenManager).
|
|
60
60
|
loginInFlight = null;
|
|
61
61
|
reauthInFlight = null;
|
|
62
|
-
constructor() {
|
|
63
|
-
this.apiUrl = readEnvVar('VIBO_API_URL') ?? DEFAULT_API_URL;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
constructor(opts = {}) {
|
|
63
|
+
this.apiUrl = opts.apiUrl ?? readEnvVar('VIBO_API_URL') ?? DEFAULT_API_URL;
|
|
64
|
+
this.email = opts.email ?? readEnvVar('VIBO_EMAIL') ?? null;
|
|
65
|
+
this.password = opts.password ?? readEnvVar('VIBO_PASSWORD') ?? null;
|
|
66
|
+
this.accessToken = opts.accessToken ?? readEnvVar('VIBO_ACCESS_TOKEN') ?? null;
|
|
67
|
+
this.refreshTokenValue = opts.refreshToken ?? readEnvVar('VIBO_REFRESH_TOKEN') ?? null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the saved-session fallback and the deferred config error on first
|
|
71
|
+
* use. Kept out of the constructor so construction is pure (Worker-safe):
|
|
72
|
+
* `loadSession()` reads homedir()/the filesystem, which the Workers runtime
|
|
73
|
+
* forbids at global scope. Runs its body at most once.
|
|
74
|
+
*/
|
|
75
|
+
ensureConfigResolved() {
|
|
76
|
+
if (this.configResolved)
|
|
77
|
+
return;
|
|
78
|
+
this.configResolved = true;
|
|
79
|
+
const haveLogin = Boolean(this.email && this.password);
|
|
73
80
|
// Fall back to a previously browser-captured session (SSO accounts) ONLY
|
|
74
|
-
// when there's no env token AND no email/password. Email/password
|
|
75
|
-
// documented preferred path and must win over a (possibly stale)
|
|
76
|
-
// session — otherwise an old session.json would silently shadow it.
|
|
81
|
+
// when there's no env/injected token AND no email/password. Email/password
|
|
82
|
+
// is the documented preferred path and must win over a (possibly stale)
|
|
83
|
+
// saved session — otherwise an old session.json would silently shadow it.
|
|
77
84
|
if (!this.accessToken && !haveLogin) {
|
|
78
85
|
const saved = loadSession();
|
|
79
86
|
if (saved) {
|
|
@@ -89,9 +96,6 @@ export class ViboClient {
|
|
|
89
96
|
'token from your signed-in web.vibodj.com browser tab (Apple/Google/Facebook accounts).',
|
|
90
97
|
});
|
|
91
98
|
}
|
|
92
|
-
else {
|
|
93
|
-
this.configError = null;
|
|
94
|
-
}
|
|
95
99
|
}
|
|
96
100
|
/**
|
|
97
101
|
* Adopt a browser-captured token pair (from vibo_capture_session) for
|
|
@@ -111,6 +115,7 @@ export class ViboClient {
|
|
|
111
115
|
}
|
|
112
116
|
/** Run a GraphQL operation, transparently authenticating + retrying once on token expiry. */
|
|
113
117
|
async gql(query, variables = {}) {
|
|
118
|
+
this.ensureConfigResolved();
|
|
114
119
|
if (this.configError)
|
|
115
120
|
throw this.configError;
|
|
116
121
|
const token = await this.ensureAccessToken();
|
|
@@ -124,47 +129,40 @@ export class ViboClient {
|
|
|
124
129
|
}
|
|
125
130
|
/**
|
|
126
131
|
* Run a GraphQL operation that uploads one or more files (the `Upload` scalar),
|
|
127
|
-
* using the graphql-multipart-request spec. `
|
|
128
|
-
* path (e.g. "variables.photo" or "variables.payload.answer.images.0") to
|
|
129
|
-
*
|
|
130
|
-
*
|
|
132
|
+
* using the graphql-multipart-request spec. `files` maps a dotted variable
|
|
133
|
+
* path (e.g. "variables.photo" or "variables.payload.answer.images.0") to an
|
|
134
|
+
* in-memory {@link UploadFile} (blob + filename); `variables` must carry
|
|
135
|
+
* `null` at each of those positions. The bytes arrive already resolved (from
|
|
136
|
+
* a local file on stdio, or inline base64 on the hosted connector — see
|
|
137
|
+
* src/upload-source.ts), so this method never touches the filesystem and runs
|
|
138
|
+
* unchanged in the Workers runtime (`FormData`/`Blob`/`fetch` are all
|
|
139
|
+
* available there). Same auth + single-retry-on-expiry behavior as `gql`.
|
|
131
140
|
*/
|
|
132
|
-
async gqlUpload(query, variables,
|
|
141
|
+
async gqlUpload(query, variables, files) {
|
|
142
|
+
this.ensureConfigResolved();
|
|
133
143
|
if (this.configError)
|
|
134
144
|
throw this.configError;
|
|
135
145
|
const token = await this.ensureAccessToken();
|
|
136
|
-
let res = await this.postMultipart(query, variables,
|
|
146
|
+
let res = await this.postMultipart(query, variables, files, token);
|
|
137
147
|
if (this.isAuthError(res.status, res.body.errors)) {
|
|
138
148
|
const fresh = await this.reauthenticate();
|
|
139
|
-
res = await this.postMultipart(query, variables,
|
|
149
|
+
res = await this.postMultipart(query, variables, files, fresh);
|
|
140
150
|
}
|
|
141
151
|
return this.unwrap(res.status, res.body);
|
|
142
152
|
}
|
|
143
|
-
async postMultipart(query, variables,
|
|
144
|
-
const { openAsBlob } = await import('node:fs');
|
|
145
|
-
const { basename } = await import('node:path');
|
|
153
|
+
async postMultipart(query, variables, files, token) {
|
|
146
154
|
const form = new FormData();
|
|
147
155
|
form.append('operations', JSON.stringify({ query, variables }));
|
|
148
156
|
// map: { "0": ["variables.photo"], "1": ["variables.payload.answer.images.0"] }
|
|
149
|
-
const paths = Object.keys(
|
|
157
|
+
const paths = Object.keys(files);
|
|
150
158
|
const map = {};
|
|
151
159
|
paths.forEach((varPath, i) => {
|
|
152
160
|
map[String(i)] = [varPath];
|
|
153
161
|
});
|
|
154
162
|
form.append('map', JSON.stringify(map));
|
|
155
163
|
for (let i = 0; i < paths.length; i++) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
blob = await openAsBlob(filePath);
|
|
160
|
-
}
|
|
161
|
-
catch (err) {
|
|
162
|
-
throw new McpToolError(`Could not read file for upload: ${filePath}`, {
|
|
163
|
-
hint: 'Provide an absolute path to a readable local file.',
|
|
164
|
-
cause: err,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
form.append(String(i), blob, basename(filePath));
|
|
164
|
+
const file = files[paths[i]];
|
|
165
|
+
form.append(String(i), file.blob, file.filename);
|
|
168
166
|
}
|
|
169
167
|
const headers = { 'apollo-require-preflight': 'true' };
|
|
170
168
|
if (token)
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runMcp } from '@chrischall/mcp-utils';
|
|
3
3
|
import { VERSION } from './version.js';
|
|
4
|
+
import { client } from './client.js';
|
|
4
5
|
import { registerProfileTools } from './tools/profile.js';
|
|
5
6
|
import { registerEventTools } from './tools/events.js';
|
|
6
7
|
import { registerSectionTools } from './tools/sections.js';
|
|
@@ -25,6 +26,10 @@ await runMcp({
|
|
|
25
26
|
name: 'vibo-mcp',
|
|
26
27
|
version: VERSION,
|
|
27
28
|
banner: '[vibo-mcp] This project was developed and is maintained by AI (Claude Code). Use at your own discretion.',
|
|
29
|
+
// runMcp invokes each registrar as `register(server, deps)`, threading this
|
|
30
|
+
// singleton so the tool modules import only the ViboClient TYPE. Same client
|
|
31
|
+
// as before — just passed in rather than imported by each module.
|
|
32
|
+
deps: client,
|
|
28
33
|
tools: [
|
|
29
34
|
registerProfileTools,
|
|
30
35
|
registerEventTools,
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { LIST_EVENT_USERS, INVITE_USERS, CHANGE_USER_ROLE, REMOVE_USER } from '../gql.js';
|
|
5
4
|
import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
|
|
6
|
-
export function registerCollaborationTools(server) {
|
|
5
|
+
export function registerCollaborationTools(server, client) {
|
|
7
6
|
server.registerTool('vibo_list_event_users', {
|
|
8
7
|
description: "List the hosts and guests on an event. With no usersType, returns both groups merged ({hosts, guests, hostsCount, guestsCount}) and `limit`/`skip` apply per group; with usersType, returns that one group's page.",
|
|
9
8
|
annotations: toolAnnotations({ title: 'List Vibo event users', readOnly: true }),
|
package/dist/tools/comments.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { CREATE_SONG_COMMENT, DELETE_SONG_COMMENT, CREATE_SECTION_COMMENT, DELETE_SECTION_COMMENT, } from '../gql.js';
|
|
5
4
|
import { previewResult } from './shared.js';
|
|
6
|
-
export function registerCommentTools(server) {
|
|
5
|
+
export function registerCommentTools(server, client) {
|
|
7
6
|
server.registerTool('vibo_comment_on_song', {
|
|
8
7
|
description: 'Leave a comment / note for the DJ on a specific song. Confirm-gated.',
|
|
9
8
|
annotations: toolAnnotations({ title: 'Comment on Vibo song', readOnly: false }),
|
package/dist/tools/events.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
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';
|
|
5
4
|
import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
|
|
6
|
-
export function registerEventTools(server) {
|
|
5
|
+
export function registerEventTools(server, client) {
|
|
7
6
|
server.registerTool('vibo_list_events', {
|
|
8
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.",
|
|
9
8
|
annotations: toolAnnotations({ title: 'List Vibo events', readOnly: true }),
|
package/dist/tools/ideas.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { LIST_SECTION_SONG_IDEAS, LIST_SONG_IDEAS_SONGS } from '../gql.js';
|
|
5
4
|
import { limitSchema, skipSchema, pagination } from './shared.js';
|
|
6
|
-
export function registerIdeasTools(server) {
|
|
5
|
+
export function registerIdeasTools(server, client) {
|
|
7
6
|
server.registerTool('vibo_list_section_song_ideas', {
|
|
8
7
|
description: "List the DJ's suggested song-idea collections for a section (each with a title, songsCount and _id). Use a song-ideas _id with vibo_list_song_ideas_songs to see the suggested songs, then add the ones you like with vibo_add_song_to_section.",
|
|
9
8
|
annotations: toolAnnotations({ title: 'List Vibo section song ideas', readOnly: true }),
|
package/dist/tools/imports.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { IMPORT_PLAYLIST_TO_SECTION } from '../gql.js';
|
|
5
4
|
import { previewResult } from './shared.js';
|
|
6
|
-
export function registerImportTools(server) {
|
|
5
|
+
export function registerImportTools(server, client) {
|
|
7
6
|
server.registerTool('vibo_import_playlist_to_section', {
|
|
8
7
|
description: 'Import selected tracks from a connected Spotify/Apple Music playlist into a section. Returns counts of added/existing/ignored. Confirm-gated.',
|
|
9
8
|
annotations: toolAnnotations({ title: 'Import playlist to section', readOnly: false }),
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { GET_NOTIFICATIONS, GET_NOTIFICATIONS_COUNT, MARK_AS_READ } from '../gql.js';
|
|
5
4
|
import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
|
|
6
|
-
export function registerNotificationTools(server) {
|
|
5
|
+
export function registerNotificationTools(server, client) {
|
|
7
6
|
server.registerTool('vibo_list_notifications', {
|
|
8
7
|
description: 'List your Vibo notifications (song additions, comments, DJ updates, etc.) with read state and linked event/section ids.',
|
|
9
8
|
annotations: toolAnnotations({ title: 'List Vibo notifications', readOnly: true }),
|
package/dist/tools/playlists.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { GET_PLAYLISTS, GET_PLAYLIST_SONGS, EXPORT_EVENT_TO_SPOTIFY, EXPORT_EVENT_TO_APPLE_MUSIC, } from '../gql.js';
|
|
5
4
|
import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
|
|
6
5
|
const sourceSchema = z
|
|
7
6
|
.enum(['spotify', 'appleMusic'])
|
|
8
7
|
.describe('Streaming source — must be connected to your Vibo account.');
|
|
9
|
-
export function registerPlaylistTools(server) {
|
|
8
|
+
export function registerPlaylistTools(server, client) {
|
|
10
9
|
server.registerTool('vibo_get_playlists', {
|
|
11
10
|
description: "List your playlists from a connected streaming service (Spotify or Apple Music) so you can import songs from them. Requires that source to be connected (see vibo_get_me).",
|
|
12
11
|
annotations: toolAnnotations({ title: 'List connected playlists', readOnly: true }),
|
package/dist/tools/profile.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
2
|
-
import { client } from '../client.js';
|
|
3
2
|
import { GET_ME } from '../gql.js';
|
|
4
|
-
export function registerProfileTools(server) {
|
|
3
|
+
export function registerProfileTools(server, client) {
|
|
5
4
|
server.registerTool('vibo_get_me', {
|
|
6
5
|
description: "Get the signed-in Vibo user's profile (id, name, email, phone, locale, and whether Spotify/Apple Music are connected). Use the returned _id to recognize your own songs/contacts.",
|
|
7
6
|
annotations: toolAnnotations({ title: 'Get my Vibo profile', readOnly: true }),
|
package/dist/tools/questions.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { LIST_SECTION_QUESTIONS, ANSWER_SECTION_QUESTION } from '../gql.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
4
|
+
import { nodeUploadResolver } from '../upload-source.js';
|
|
5
|
+
import { previewResult, inlineFileSchema } from './shared.js';
|
|
6
|
+
/**
|
|
7
|
+
* `resolveUpload` is the injectable file-source seam for photo/file answers:
|
|
8
|
+
* stdio uses the default `nodeUploadResolver` (local `*Paths`), the hosted
|
|
9
|
+
* connector passes `inlineUploadResolver` (inline base64 `images`/`files`).
|
|
10
|
+
*/
|
|
11
|
+
export function registerQuestionTools(server, client, resolveUpload = nodeUploadResolver) {
|
|
7
12
|
server.registerTool('vibo_list_section_questions', {
|
|
8
13
|
description: "List the DJ's planning questions for a section, with each question's type (text/radio/checkbox/select/link/header), available options, whether it's answered, the current answer, and overall progress. Use the question _id (and option _ids) with vibo_answer_question.",
|
|
9
14
|
annotations: toolAnnotations({ title: 'List Vibo section questions', readOnly: true }),
|
|
@@ -19,7 +24,7 @@ export function registerQuestionTools(server) {
|
|
|
19
24
|
return textResult(data.getEventSectionQuestionsV2);
|
|
20
25
|
});
|
|
21
26
|
server.registerTool('vibo_answer_question', {
|
|
22
|
-
description: "Answer a section planning question. Provide the field matching the question's type: `text` for a text question, `selectedOptions` (array of option _ids from vibo_list_section_questions) for radio/checkbox/select, or `link` (array of URLs) for a link question. Use `otherOptionTitle` with the question's \"other\" option. For photo/file questions, pass `imagePaths`
|
|
27
|
+
description: "Answer a section planning question. Provide the field matching the question's type: `text` for a text question, `selectedOptions` (array of option _ids from vibo_list_section_questions) for radio/checkbox/select, or `link` (array of URLs) for a link question. Use `otherOptionTitle` with the question's \"other\" option. For photo/file questions, pass local paths (`imagePaths`/`filePaths`) on the stdio server, or inline base64 bytes (`images`/`files`) on the hosted connector. Confirm-gated.",
|
|
23
28
|
annotations: toolAnnotations({ title: 'Answer Vibo question', readOnly: false }),
|
|
24
29
|
inputSchema: {
|
|
25
30
|
eventId: z.string().describe('Event id.'),
|
|
@@ -38,21 +43,40 @@ export function registerQuestionTools(server) {
|
|
|
38
43
|
imagePaths: z
|
|
39
44
|
.array(z.string())
|
|
40
45
|
.optional()
|
|
41
|
-
.describe('Absolute local image file paths, for a photo question.'),
|
|
46
|
+
.describe('Absolute local image file paths, for a photo question (local/stdio server only).'),
|
|
42
47
|
filePaths: z
|
|
43
48
|
.array(z.string())
|
|
44
49
|
.optional()
|
|
45
|
-
.describe('Absolute local file paths, for a file-attachment question.'),
|
|
50
|
+
.describe('Absolute local file paths, for a file-attachment question (local/stdio server only).'),
|
|
51
|
+
images: z
|
|
52
|
+
.array(inlineFileSchema)
|
|
53
|
+
.optional()
|
|
54
|
+
.describe('Inline base64 images, for a photo question (used by the hosted connector, which has no filesystem).'),
|
|
55
|
+
files: z
|
|
56
|
+
.array(inlineFileSchema)
|
|
57
|
+
.optional()
|
|
58
|
+
.describe('Inline base64 files, for a file-attachment question (used by the hosted connector).'),
|
|
46
59
|
confirm: schemaConfirm,
|
|
47
60
|
},
|
|
48
|
-
}, async ({ eventId, sectionId, questionId, text, selectedOptions, link, otherOptionTitle, imagePaths, filePaths, confirm }) => {
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
}, async ({ eventId, sectionId, questionId, text, selectedOptions, link, otherOptionTitle, imagePaths, filePaths, images, files, confirm }) => {
|
|
62
|
+
// Merge local-path and inline-byte file refs (in that order) into one list
|
|
63
|
+
// per slot. stdio supplies paths; the hosted connector supplies inline
|
|
64
|
+
// bytes; the injected resolver turns each ref into an in-memory blob.
|
|
65
|
+
const imageRefs = [
|
|
66
|
+
...(imagePaths ?? []).map((path) => ({ path })),
|
|
67
|
+
...(images ?? []).map((f) => ({ data: f.data, filename: f.filename })),
|
|
68
|
+
];
|
|
69
|
+
const fileRefs = [
|
|
70
|
+
...(filePaths ?? []).map((path) => ({ path })),
|
|
71
|
+
...(files ?? []).map((f) => ({ data: f.data, filename: f.filename })),
|
|
72
|
+
];
|
|
73
|
+
const hasImages = imageRefs.length > 0;
|
|
74
|
+
const hasFiles = fileRefs.length > 0;
|
|
51
75
|
// Require at least one PRIMARY answer field. otherOptionTitle is only a
|
|
52
76
|
// modifier for selectedOptions — on its own it is not a valid answer.
|
|
53
77
|
if (text === undefined && selectedOptions === undefined && link === undefined && !hasImages && !hasFiles) {
|
|
54
|
-
throw new McpToolError('Provide an answer: text, selectedOptions, link, imagePaths, or filePaths.', {
|
|
55
|
-
hint: "Match the question's type — text → `text`, radio/checkbox/select → `selectedOptions`, link → `link`, photo/file → `imagePaths`/`filePaths
|
|
78
|
+
throw new McpToolError('Provide an answer: text, selectedOptions, link, imagePaths/images, or filePaths/files.', {
|
|
79
|
+
hint: "Match the question's type — text → `text`, radio/checkbox/select → `selectedOptions`, link → `link`, photo/file → `imagePaths`/`filePaths` (stdio) or `images`/`files` (hosted connector).",
|
|
56
80
|
});
|
|
57
81
|
}
|
|
58
82
|
const answer = {};
|
|
@@ -67,17 +91,29 @@ export function registerQuestionTools(server) {
|
|
|
67
91
|
// File answers route through a multipart upload (the Upload scalar).
|
|
68
92
|
if (hasImages || hasFiles) {
|
|
69
93
|
if (hasImages)
|
|
70
|
-
answer.images =
|
|
94
|
+
answer.images = imageRefs.map(() => null);
|
|
71
95
|
if (hasFiles)
|
|
72
|
-
answer.files =
|
|
96
|
+
answer.files = fileRefs.map(() => null);
|
|
73
97
|
const payload = { answer };
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
98
|
+
const previewUploads = {};
|
|
99
|
+
imageRefs.forEach((ref, i) => {
|
|
100
|
+
previewUploads[`variables.payload.answer.images.${i}`] = ref.path ?? '(inline bytes)';
|
|
101
|
+
});
|
|
102
|
+
fileRefs.forEach((ref, i) => {
|
|
103
|
+
previewUploads[`variables.payload.answer.files.${i}`] = ref.path ?? '(inline bytes)';
|
|
104
|
+
});
|
|
77
105
|
if (!confirm) {
|
|
78
|
-
return previewResult('answerEventSectionQuestionV2', { eventId, sectionId, questionId, payload, uploads:
|
|
106
|
+
return previewResult('answerEventSectionQuestionV2', { eventId, sectionId, questionId, payload, uploads: previewUploads });
|
|
107
|
+
}
|
|
108
|
+
// Resolve each ref to an in-memory blob keyed by its dotted var path.
|
|
109
|
+
const resolvedFiles = {};
|
|
110
|
+
for (let i = 0; i < imageRefs.length; i++) {
|
|
111
|
+
resolvedFiles[`variables.payload.answer.images.${i}`] = await resolveUpload(imageRefs[i]);
|
|
112
|
+
}
|
|
113
|
+
for (let i = 0; i < fileRefs.length; i++) {
|
|
114
|
+
resolvedFiles[`variables.payload.answer.files.${i}`] = await resolveUpload(fileRefs[i]);
|
|
79
115
|
}
|
|
80
|
-
const data = await client.gqlUpload(ANSWER_SECTION_QUESTION, { eventId, sectionId, questionId, payload },
|
|
116
|
+
const data = await client.gqlUpload(ANSWER_SECTION_QUESTION, { eventId, sectionId, questionId, payload }, resolvedFiles);
|
|
81
117
|
return textResult(data.answerEventSectionQuestionV2);
|
|
82
118
|
}
|
|
83
119
|
const payload = { answer };
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { UPDATE_SECTION } from '../gql.js';
|
|
5
4
|
import { previewResult } from './shared.js';
|
|
6
|
-
export function registerSectionEditTools(server) {
|
|
5
|
+
export function registerSectionEditTools(server, client) {
|
|
7
6
|
server.registerTool('vibo_update_section', {
|
|
8
7
|
description: "Edit a timeline section's name, time, note, or description. Subject to the section's host-edit permissions. Confirm-gated.",
|
|
9
8
|
annotations: toolAnnotations({ title: 'Update Vibo section', readOnly: false }),
|
package/dist/tools/sections.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { LIST_SECTIONS } from '../gql.js';
|
|
5
|
-
export function registerSectionTools(server) {
|
|
4
|
+
export function registerSectionTools(server, client) {
|
|
6
5
|
server.registerTool('vibo_list_sections', {
|
|
7
6
|
description: "List an event's timeline sections (e.g. Ceremony, First Dance, Dinner, Dancing) with each section's id, name, scheduled time, note, song count and progress. Use a section _id with vibo_get_section_songs / vibo_add_song_to_section.",
|
|
8
7
|
annotations: toolAnnotations({ title: 'List Vibo event sections', readOnly: true }),
|
package/dist/tools/session.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
2
|
-
import { client } from '../client.js';
|
|
3
2
|
import { captureViboSession } from '../auth.js';
|
|
4
3
|
import { saveSession } from '../session-store.js';
|
|
5
4
|
import { GET_ME } from '../gql.js';
|
|
6
|
-
|
|
5
|
+
// NB: this registrar is STDIO-ONLY — it is deliberately NOT wired into the
|
|
6
|
+
// hosted Cloudflare connector (src/worker.ts). vibo_capture_session needs the
|
|
7
|
+
// fetchproxy browser bridge + a signed-in browser tab, neither of which exists
|
|
8
|
+
// in the serverless Worker runtime.
|
|
9
|
+
export function registerSessionTools(server, client) {
|
|
7
10
|
server.registerTool('vibo_capture_session', {
|
|
8
11
|
description: "Capture your Vibo login from a signed-in web.vibodj.com browser tab via the fetchproxy bridge — for accounts that sign in with Apple/Google/Facebook (no password). Requires the fetchproxy browser extension installed and you signed into https://web.vibodj.com; approve the pair code shown on first use. The token is saved locally and reused on future calls.",
|
|
9
12
|
annotations: toolAnnotations({ title: 'Capture Vibo session (SSO)', readOnly: false }),
|
package/dist/tools/shared.js
CHANGED
|
@@ -18,6 +18,15 @@ export const skipSchema = z
|
|
|
18
18
|
export function pagination(limit, skip) {
|
|
19
19
|
return { skip: skip ?? 0, limit: limit ?? 20 };
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* One inline file (base64 bytes + optional filename) for the hosted connector,
|
|
23
|
+
* which has no filesystem. Mirrors the local-path upload inputs so a tool can
|
|
24
|
+
* accept either a filesystem path (stdio) or inline bytes (Worker).
|
|
25
|
+
*/
|
|
26
|
+
export const inlineFileSchema = z.object({
|
|
27
|
+
data: z.string().describe('Base64-encoded file bytes (a `data:` URL prefix is allowed).'),
|
|
28
|
+
filename: z.string().optional().describe('Filename to send with this file.'),
|
|
29
|
+
});
|
|
21
30
|
/**
|
|
22
31
|
* Dry-run response for a confirm-gated write. Returned WITHOUT making any
|
|
23
32
|
* network call when `confirm` is not `true`, so the caller can see exactly what
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { REMOVE_SECTION_SONGS, UPDATE_SECTION_SONGS, MOVE_SECTION_SONGS, REORDER_SONGS } from '../gql.js';
|
|
5
4
|
import { previewResult } from './shared.js';
|
|
6
|
-
export function registerSongManagementTools(server) {
|
|
5
|
+
export function registerSongManagementTools(server, client) {
|
|
7
6
|
server.registerTool('vibo_remove_song_from_section', {
|
|
8
7
|
description: 'Remove one or more songs from a section. Confirm-gated.',
|
|
9
8
|
annotations: toolAnnotations({ title: 'Remove songs from Vibo section', readOnly: false }),
|
package/dist/tools/songs.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { GET_SECTION_SONGS, SEARCH_SONGS, ADD_SONG_TO_SECTION, TOGGLE_LIKE } from '../gql.js';
|
|
5
4
|
import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
|
|
6
|
-
export function registerSongTools(server) {
|
|
5
|
+
export function registerSongTools(server, client) {
|
|
7
6
|
server.registerTool('vibo_get_section_songs', {
|
|
8
7
|
description: "List the songs requested in a section, with who added each, like counts, must-play / do-not-play flags, comments, and streaming links. Sort by likesCount, createdAt, or title.",
|
|
9
8
|
annotations: toolAnnotations({ title: 'Get Vibo section songs', readOnly: true }),
|
package/dist/tools/uploads.js
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
2
|
+
import { textResult, toolAnnotations, schemaConfirm, McpToolError } from '@chrischall/mcp-utils';
|
|
4
3
|
import { UPLOAD_USER_PHOTO } from '../gql.js';
|
|
4
|
+
import { nodeUploadResolver } from '../upload-source.js';
|
|
5
5
|
import { previewResult } from './shared.js';
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* `resolveUpload` is the injectable file-source seam: the stdio server uses the
|
|
8
|
+
* default `nodeUploadResolver` (reads a local `path`), while the hosted
|
|
9
|
+
* Cloudflare connector (src/worker.ts) passes `inlineUploadResolver` so the same
|
|
10
|
+
* tool works from base64 `fileData` with no filesystem.
|
|
11
|
+
*/
|
|
12
|
+
export function registerUploadTools(server, client, resolveUpload = nodeUploadResolver) {
|
|
7
13
|
server.registerTool('vibo_set_profile_photo', {
|
|
8
|
-
description: 'Set your Vibo profile photo from a local image
|
|
14
|
+
description: 'Set your Vibo profile photo from an image. On the local (stdio) server pass a local file `path`; on the hosted connector pass the image bytes as base64 `fileData`. Returns the uploaded image URL. Confirm-gated.',
|
|
9
15
|
annotations: toolAnnotations({ title: 'Set Vibo profile photo', readOnly: false }),
|
|
10
16
|
inputSchema: {
|
|
11
|
-
path: z.string().describe('Absolute path to a local image file (jpg/png).'),
|
|
17
|
+
path: z.string().optional().describe('Absolute path to a local image file (jpg/png). Local/stdio server only.'),
|
|
18
|
+
fileData: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Base64-encoded image bytes (a `data:` URL prefix is allowed). Used by the hosted connector, which has no filesystem.'),
|
|
22
|
+
filename: z.string().optional().describe('Filename for the image when using fileData (default "photo.jpg").'),
|
|
12
23
|
confirm: schemaConfirm,
|
|
13
24
|
},
|
|
14
|
-
}, async ({ path, confirm }) => {
|
|
25
|
+
}, async ({ path, fileData, filename, confirm }) => {
|
|
26
|
+
if (!path && !fileData) {
|
|
27
|
+
throw new McpToolError('Provide an image: a local file `path` or inline base64 `fileData`.', {
|
|
28
|
+
hint: 'On the local server pass `path`; on the hosted connector pass `fileData` (base64).',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
15
31
|
if (!confirm)
|
|
16
|
-
return previewResult('uploadUserPhoto', { photo: path });
|
|
17
|
-
const
|
|
32
|
+
return previewResult('uploadUserPhoto', { photo: path ?? '(inline bytes)' });
|
|
33
|
+
const file = await resolveUpload({ path, data: fileData, filename: filename ?? 'photo.jpg' });
|
|
34
|
+
const data = await client.gqlUpload(UPLOAD_USER_PHOTO, { photo: null }, { 'variables.photo': file });
|
|
18
35
|
return textResult(data.uploadUserPhoto);
|
|
19
36
|
});
|
|
20
37
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Injectable file-source boundary for multipart uploads.
|
|
2
|
+
//
|
|
3
|
+
// The Vibo upload tools (`vibo_set_profile_photo`, `vibo_answer_question` with
|
|
4
|
+
// photo/file answers) send local media through the GraphQL `Upload` scalar.
|
|
5
|
+
// The stdio server reads those bytes from a local file path; the hosted
|
|
6
|
+
// Cloudflare Worker has NO filesystem, so it receives the bytes inline as
|
|
7
|
+
// base64 instead. Both paths converge on an in-memory {@link UploadFile} that
|
|
8
|
+
// `ViboClient.gqlUpload` streams into a `FormData` — the client itself never
|
|
9
|
+
// touches `node:fs`, which keeps it loadable in the Workers runtime.
|
|
10
|
+
//
|
|
11
|
+
// A tool hands the resolver a {@link FileRef} (a local `path`, or inline base64
|
|
12
|
+
// `data`) and gets back a `Blob` + filename. `nodeUploadResolver` (stdio)
|
|
13
|
+
// resolves either; `inlineUploadResolver` (Worker) resolves only inline bytes
|
|
14
|
+
// and throws an actionable error for a filesystem path.
|
|
15
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
16
|
+
const DEFAULT_FILENAME = 'upload';
|
|
17
|
+
/** Decode base64 (optionally a `data:` URL) into an {@link UploadFile}. */
|
|
18
|
+
function blobFromBase64(data, filename) {
|
|
19
|
+
// Strip a leading `data:<mime>;base64,` prefix if present.
|
|
20
|
+
const base64 = data.includes(',') && data.startsWith('data:') ? data.slice(data.indexOf(',') + 1) : data;
|
|
21
|
+
let binary;
|
|
22
|
+
try {
|
|
23
|
+
binary = atob(base64.trim());
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
throw new McpToolError('Could not decode inline file data — expected base64.', {
|
|
27
|
+
hint: 'Pass the file bytes as a base64 string in `fileData` (a `data:` URL prefix is allowed).',
|
|
28
|
+
cause: err,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const bytes = new Uint8Array(binary.length);
|
|
32
|
+
for (let i = 0; i < binary.length; i++)
|
|
33
|
+
bytes[i] = binary.charCodeAt(i);
|
|
34
|
+
return { blob: new Blob([bytes]), filename: filename ?? DEFAULT_FILENAME };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* stdio resolver: reads a local file path via `node:fs` `openAsBlob` (streamed,
|
|
38
|
+
* not buffered), or decodes inline base64 if that's what the caller supplied.
|
|
39
|
+
* This is the byte-for-byte-unchanged local-path upload path.
|
|
40
|
+
*/
|
|
41
|
+
export const nodeUploadResolver = async (ref) => {
|
|
42
|
+
if (ref.path) {
|
|
43
|
+
const { openAsBlob } = await import('node:fs');
|
|
44
|
+
const { basename } = await import('node:path');
|
|
45
|
+
let blob;
|
|
46
|
+
try {
|
|
47
|
+
blob = await openAsBlob(ref.path);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
throw new McpToolError(`Could not read file for upload: ${ref.path}`, {
|
|
51
|
+
hint: 'Provide an absolute path to a readable local file.',
|
|
52
|
+
cause: err,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return { blob, filename: ref.filename ?? basename(ref.path) };
|
|
56
|
+
}
|
|
57
|
+
if (ref.data)
|
|
58
|
+
return blobFromBase64(ref.data, ref.filename);
|
|
59
|
+
throw new McpToolError('No file provided for upload.', {
|
|
60
|
+
hint: 'Pass a local file `path` (or inline base64 `fileData`).',
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Hosted-connector resolver: the Worker has no filesystem, so it accepts ONLY
|
|
65
|
+
* inline base64 bytes. A filesystem path draws an actionable error rather than
|
|
66
|
+
* a runtime crash.
|
|
67
|
+
*/
|
|
68
|
+
export const inlineUploadResolver = async (ref) => {
|
|
69
|
+
if (ref.data)
|
|
70
|
+
return blobFromBase64(ref.data, ref.filename);
|
|
71
|
+
if (ref.path) {
|
|
72
|
+
throw new McpToolError('Local file paths are not available on the hosted Vibo connector.', {
|
|
73
|
+
hint: 'The hosted connector has no filesystem — pass the file bytes as base64 in `fileData` instead of a `path`.',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
throw new McpToolError('No file provided for upload.', {
|
|
77
|
+
hint: 'Pass the file bytes as base64 in `fileData`.',
|
|
78
|
+
});
|
|
79
|
+
};
|
package/dist/version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// literal on the line carrying the release marker; every manifest and the MCP
|
|
3
3
|
// server banner import VERSION from here, so there is exactly one place to keep
|
|
4
4
|
// in sync (and one release-please extra-files entry).
|
|
5
|
-
export const VERSION = '1.
|
|
5
|
+
export const VERSION = '1.4.0'; // x-release-please-version
|