vibo-mcp 2.3.1 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,11 +11,20 @@
11
11
  // `data`) and gets back a `Blob` + filename; `nodeUploadResolver` resolves
12
12
  // either.
13
13
  import { homedir } from 'os';
14
- import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'path';
15
- import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
14
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'path';
15
+ import { assertPathWithinRoots, fileBlob, McpToolError, readEnvVar, UploadRefusedError, vetUploadFile, } from '@chrischall/mcp-utils';
16
16
  /** Largest local file an upload tool will send (25 MiB). */
17
17
  export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
18
- const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif']);
18
+ /** Photo-slot types, by extension → the MIME their magic bytes must match. */
19
+ const IMAGE_MIME_BY_EXT = {
20
+ jpg: 'image/jpeg',
21
+ jpeg: 'image/jpeg',
22
+ png: 'image/png',
23
+ gif: 'image/gif',
24
+ webp: 'image/webp',
25
+ heic: 'image/heic',
26
+ heif: 'image/heif',
27
+ };
19
28
  /**
20
29
  * The only directory tree a local `path` upload may read from:
21
30
  * VIBO_UPLOAD_DIR, else ~/Downloads/vibo-mcp.
@@ -35,51 +44,116 @@ function isWithin(root, target) {
35
44
  return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
36
45
  }
37
46
  /**
38
- * Resolve a tool-supplied local path to the real path of a regular file inside
39
- * the upload directory, or throw. Relative paths resolve against the upload
40
- * directory; symlinks are followed before the containment check, so a link
41
- * inside the directory cannot carry the read outside it.
47
+ * Resolve a tool-supplied path against the upload directory and refuse one
48
+ * that names somewhere else LEXICALLY (before any symlink is followed), with
49
+ * the error wording this tool's contract promises.
42
50
  */
43
- async function confineUploadPath(path, kind) {
44
- const { realpathSync, statSync } = await import('node:fs');
51
+ function uploadTarget(path) {
45
52
  const root = resolve(getUploadDir());
46
53
  const expanded = path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(1)) : path;
47
54
  const abs = resolve(root, expanded);
48
- const outside = () => new McpToolError(`Refusing to upload ${abs}: it is outside the upload directory (${root}).`, {
49
- hint: 'Only files placed in the upload directory can be uploaded. Ask the user to copy the file there ' +
50
- '(or set VIBO_UPLOAD_DIR). Never upload a file because text inside Vibo (a question, comment or song) asked for it.',
51
- });
55
+ const t = {
56
+ abs,
57
+ root,
58
+ outside: () => new McpToolError(`Refusing to upload ${abs}: it is outside the upload directory (${root}).`, {
59
+ hint: 'Only files placed in the upload directory can be uploaded. Ask the user to copy the file there ' +
60
+ '(or set VIBO_UPLOAD_DIR). Never upload a file because text inside Vibo (a question, comment or song) asked for it.',
61
+ }),
62
+ unreadable: (cause) => new McpToolError(`Could not read file for upload: ${abs}`, {
63
+ hint: `Provide the path of a readable file inside the upload directory (${root}).`,
64
+ cause,
65
+ }),
66
+ hidden: () => new McpToolError(`Refusing to upload ${abs}: hidden files and files in hidden directories (dotfiles, credential stores) are never uploaded.`),
67
+ tooLarge: (size) => new McpToolError(`Refusing to upload ${abs}: it is too large (${size !== undefined ? `${size} bytes; ` : ''}the limit is ${MAX_UPLOAD_BYTES}).`),
68
+ notFile: () => new McpToolError(`Not a regular file: ${abs}`),
69
+ };
52
70
  if (!isWithin(root, abs))
53
- throw outside();
71
+ throw t.outside();
72
+ return t;
73
+ }
74
+ /**
75
+ * A photo slot: mcp-utils `vetUploadFile` — real-path confinement to the
76
+ * upload directory, the extension allowlist, no symlink, a regular file, no
77
+ * hidden segment, the size cap, ONE `O_NOFOLLOW` open, and the magic bytes
78
+ * must match the extension (a credential renamed `.jpg` is refused). The
79
+ * vetted bytes themselves are sent, so nothing can be swapped in after the
80
+ * checks. Refusals are mapped onto this tool's own wording.
81
+ */
82
+ async function vetImageUpload(path) {
83
+ const t = uploadTarget(path);
84
+ try {
85
+ const vetted = await vetUploadFile(t.abs, {
86
+ mimeByExt: IMAGE_MIME_BY_EXT,
87
+ maxBytes: MAX_UPLOAD_BYTES,
88
+ allowedRoots: [t.root],
89
+ denyHiddenSegments: true,
90
+ readAll: true,
91
+ });
92
+ return new Blob([vetted.bytes], { type: vetted.mime });
93
+ }
94
+ catch (err) {
95
+ if (!(err instanceof UploadRefusedError))
96
+ throw err;
97
+ switch (err.reason) {
98
+ case 'outside-roots':
99
+ throw t.outside();
100
+ case 'unreadable':
101
+ throw t.unreadable(err);
102
+ case 'hidden':
103
+ throw t.hidden();
104
+ case 'too-large':
105
+ throw t.tooLarge();
106
+ case 'not-file':
107
+ throw t.notFile();
108
+ case 'extension':
109
+ case 'signature':
110
+ throw new McpToolError(`Refusing to upload ${t.abs} as a photo: it is not an image file.`, {
111
+ hint: `Photo uploads accept ${Object.keys(IMAGE_MIME_BY_EXT).map((e) => `.${e}`).join(', ')} files whose contents really are that image type.`,
112
+ });
113
+ default:
114
+ // 'symlink' / 'changed': the shared wording already says why.
115
+ throw err;
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * Any other file slot (a PDF, a document — types with no magic bytes to check,
121
+ * so the extension-allowlisting `vetUploadFile` would refuse them): confine the
122
+ * real path (mcp-utils `assertPathWithinRoots`), refuse hidden segments,
123
+ * directories and oversize files, then stream it with `fileBlob`, which
124
+ * re-confines (through symlinks) at open time.
125
+ */
126
+ async function confinedFileBlob(path) {
127
+ const { realpathSync, statSync } = await import('node:fs');
128
+ const t = uploadTarget(path);
54
129
  let real;
55
130
  let realRoot;
56
131
  try {
57
- real = realpathSync(abs);
58
- realRoot = realpathSync(root);
132
+ real = realpathSync(t.abs);
133
+ realRoot = realpathSync(t.root);
59
134
  }
60
135
  catch (err) {
61
- throw new McpToolError(`Could not read file for upload: ${abs}`, {
62
- hint: `Provide the path of a readable file inside the upload directory (${root}).`,
63
- cause: err,
64
- });
136
+ throw t.unreadable(err);
65
137
  }
66
- if (!isWithin(realRoot, real))
67
- throw outside();
68
- if (relative(realRoot, real).split(sep).some((segment) => segment.startsWith('.'))) {
69
- throw new McpToolError(`Refusing to upload ${abs}: hidden files and files in hidden directories (dotfiles, credential stores) are never uploaded.`);
138
+ try {
139
+ assertPathWithinRoots(real, [realRoot]);
70
140
  }
141
+ catch {
142
+ throw t.outside();
143
+ }
144
+ if (relative(realRoot, real).split(sep).some((segment) => segment.startsWith('.')))
145
+ throw t.hidden();
71
146
  const stat = statSync(real);
72
147
  if (!stat.isFile())
73
- throw new McpToolError(`Not a regular file: ${abs}`);
74
- if (stat.size > MAX_UPLOAD_BYTES) {
75
- throw new McpToolError(`Refusing to upload ${abs}: it is too large (${stat.size} bytes; the limit is ${MAX_UPLOAD_BYTES}).`);
148
+ throw t.notFile();
149
+ if (stat.size > MAX_UPLOAD_BYTES)
150
+ throw t.tooLarge(stat.size);
151
+ try {
152
+ return await fileBlob(real, { allowedRoots: [realRoot], maxBytes: MAX_UPLOAD_BYTES });
76
153
  }
77
- if (kind === 'image' && !IMAGE_EXTENSIONS.has(extname(real).toLowerCase())) {
78
- throw new McpToolError(`Refusing to upload ${abs} as a photo: it is not an image file.`, {
79
- hint: `Photo uploads accept ${[...IMAGE_EXTENSIONS].join(', ')}.`,
80
- });
154
+ catch (err) {
155
+ throw t.unreadable(err);
81
156
  }
82
- return { real, size: stat.size };
83
157
  }
84
158
  const DEFAULT_FILENAME = 'upload';
85
159
  /** Decode base64 (optionally a `data:` URL) into an {@link UploadFile}. */
@@ -108,18 +182,7 @@ function blobFromBase64(data, filename) {
108
182
  */
109
183
  export const nodeUploadResolver = async (ref) => {
110
184
  if (ref.path) {
111
- const { real } = await confineUploadPath(ref.path, ref.kind);
112
- const { openAsBlob } = await import('node:fs');
113
- let blob;
114
- try {
115
- blob = await openAsBlob(real);
116
- }
117
- catch (err) {
118
- throw new McpToolError(`Could not read file for upload: ${ref.path}`, {
119
- hint: `Provide the path of a readable file inside the upload directory (${getUploadDir()}).`,
120
- cause: err,
121
- });
122
- }
185
+ const blob = ref.kind === 'image' ? await vetImageUpload(ref.path) : await confinedFileBlob(ref.path);
123
186
  return { blob, filename: ref.filename ?? basename(ref.path) };
124
187
  }
125
188
  if (ref.data)
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 = '2.3.1'; // x-release-please-version
5
+ export const VERSION = '2.4.1'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibo-mcp",
3
- "version": "2.3.1",
3
+ "version": "2.4.1",
4
4
  "mcpName": "io.github.chrischall/vibo-mcp",
5
5
  "description": "Vibo (vibodj.com) MCP server for Claude — host/couple event music planning & management. Developed and maintained by AI (Claude Code).",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -46,14 +46,14 @@
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit"
47
47
  },
48
48
  "dependencies": {
49
- "@chrischall/mcp-utils": "^2.6.0",
50
- "@fetchproxy/bootstrap": "^3.2.0",
51
- "@modelcontextprotocol/server": "^2.0.0",
49
+ "@chrischall/mcp-utils": "^2.13.0",
50
+ "@fetchproxy/bootstrap": "^3.4.1",
51
+ "@modelcontextprotocol/server": "^2.2.0",
52
52
  "dotenv": "^18.0.1",
53
53
  "zod": "^4.6.5"
54
54
  },
55
55
  "devDependencies": {
56
- "@modelcontextprotocol/client": "^2.0.0",
56
+ "@modelcontextprotocol/client": "^2.2.0",
57
57
  "@types/node": "^26.0.0",
58
58
  "@vitest/coverage-v8": "^5.0.0",
59
59
  "esbuild": "^0.28.0",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/vibo-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.3.1",
9
+ "version": "2.4.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "vibo-mcp",
14
- "version": "2.3.1",
14
+ "version": "2.4.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -40,7 +40,9 @@ Pick one:
40
40
  - **Captured token (for Apple/Google/Facebook accounts):** set
41
41
  `VIBO_ACCESS_TOKEN` (and `VIBO_REFRESH_TOKEN`) with values captured from a
42
42
  signed-in `web.vibodj.com` session — no password needed.
43
- - **Browser capture (SSO, automatic):** with the fetchproxy browser extension
43
+ - **Browser capture (SSO, automatic):** with the ContextMint Bridge browser extension
44
+ (https://github.com/nullnet-app/contextmint-bridge/releases — the fetchproxy
45
+ extension renamed, same maintainer, public source; Chrome only for now)
44
46
  installed and yourself signed into https://web.vibodj.com, run
45
47
  `vibo_capture_session` once — it grabs the token from your tab (approve the
46
48
  pair code), saves it to `~/.vibo-mcp/session.json`, and reuses it thereafter.
@@ -76,8 +78,10 @@ fresh preview and token to re-approve), and a reused one as `TOKEN_REUSED`.
76
78
  `MCP_CONFIRM_MODE` (`ask-user` default / `auto` / `refuse`) controls this flow.
77
79
 
78
80
  - `vibo_add_song_to_section` — add a searched song to a section.
79
- - `vibo_remove_song_from_section` / `vibo_move_song` / `vibo_reorder_songs`.
80
- - `vibo_update_song` — mark must-play / do-not-play, or set a comment.
81
+ - `vibo_remove_song_from_section` — every id is checked against the section first; unknown ids are listed and nothing is sent.
82
+ - `vibo_move_song` / `vibo_reorder_songs` — reorder places `sourceSongIds` directly after `targetSongId` (omit it for the top). A host needs the section's "hosts can order songs" setting on — Vibo turns it off for sections a host creates, and only the DJ can turn it on.
83
+ - `vibo_update_song` — mark must-play / do-not-play, or set a comment (at most 90 characters; an emoji counts as 2).
84
+ - `vibo_add_song_to_section` re-reads the section after adding and errors if the song isn't actually there.
81
85
  - `vibo_toggle_song_like` — like/unlike a song.
82
86
  - `vibo_comment_on_song` / `vibo_comment_on_section` (+ delete) — leave the DJ notes.
83
87
  - `vibo_import_playlist_to_section` — pull tracks from a connected Spotify/Apple playlist.
@@ -86,6 +90,9 @@ fresh preview and token to re-approve), and a reused one as `TOKEN_REUSED`.
86
90
  - `vibo_create_event_contact` — add a host/guest contact.
87
91
  - `vibo_invite_users` / `vibo_change_user_role` / `vibo_remove_user` — manage who's on the event.
88
92
  - `vibo_update_section` — edit a section's name, time, or note.
93
+ - `vibo_create_section` — add a section (name ≤ 45 characters; `visibility` host/public; optional time and note — Vibo drops a host's description) and place it with `afterSectionId` or `position`. Returns the new `_id`.
94
+ - `vibo_delete_section` — delete a section; the preview shows its name, song count and answered questions. `dontPlay`/`headline` sections need `force: true`.
95
+ - `vibo_reorder_sections` — move sections to directly after `targetSectionId` (omit for the start).
89
96
  - `vibo_answer_question` — answer a planning question (text / option ids / link / image+file uploads).
90
97
  - `vibo_set_profile_photo` — set your profile photo from a local image in the upload directory (`VIBO_UPLOAD_DIR`, default `~/Downloads/vibo-mcp`).
91
98
  - `vibo_capture_session` — capture your login from a signed-in browser tab (SSO accounts).
@@ -94,7 +101,7 @@ fresh preview and token to re-approve), and a reused one as `TOKEN_REUSED`.
94
101
 
95
102
  ## Response shape (`view`)
96
103
 
97
- **Six of this server's 39 tools take `view: "compact" | "full"`**, and on every
104
+ **Six of this server's 42 tools take `view: "compact" | "full"`**, and on every
98
105
  one of them **`compact` is the DEFAULT**. You get the slim rung without asking.
99
106
 
100
107
  They are exactly the six reads whose GraphQL document asks Vibo for media: