minutes-mcp 0.8.0 → 0.8.3

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/index.js CHANGED
@@ -30,12 +30,13 @@ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, EXTENSION_ID,
30
30
  import { z } from "zod";
31
31
  import { execFile, spawn } from "child_process";
32
32
  import { promisify } from "util";
33
- import { existsSync, realpathSync } from "fs";
33
+ import { existsSync } from "fs";
34
34
  import { readFile } from "fs/promises";
35
- import { dirname, extname, join, resolve } from "path";
35
+ import { dirname, join } from "path";
36
36
  import { fileURLToPath } from "url";
37
37
  import { homedir } from "os";
38
38
  import * as reader from "minutes-sdk";
39
+ import { canonicalizeRoot, expandHomeLikePath, validatePathInDirectories, validatePathInDirectory, } from "./paths.js";
39
40
  const UI_RESOURCE_URI = "ui://minutes/dashboard";
40
41
  const execFileAsync = promisify(execFile);
41
42
  // ── QMD semantic search (optional — falls back to CLI) ──────
@@ -143,7 +144,152 @@ function findMinutesBinary() {
143
144
  // Fall back to PATH lookup
144
145
  return "minutes";
145
146
  }
146
- const MINUTES_BIN = findMinutesBinary();
147
+ let MINUTES_BIN = findMinutesBinary();
148
+ // ── Expected CLI version (must match this MCP server release) ──
149
+ const EXPECTED_CLI_VERSION = "0.8.1";
150
+ const RELEASE_TAG = "v0.8.1-live-coach";
151
+ // ── CLI auto-install ────────────────────────────────────────
152
+ // When installed via MCPB or `npx minutes-mcp`, the Rust CLI binary
153
+ // may not be present. We attempt to install it automatically so
154
+ // non-technical users don't hit a "binary not found" dead end.
155
+ let installAttempted = false;
156
+ function getReleaseBinaryName() {
157
+ const platform = process.platform;
158
+ const arch = process.arch;
159
+ if (platform === "darwin" && arch === "arm64")
160
+ return "minutes-macos-arm64";
161
+ if (platform === "darwin" && arch === "x64")
162
+ return "minutes-macos-arm64"; // Rosetta handles it
163
+ if (platform === "linux" && arch === "x64")
164
+ return "minutes-linux-x64";
165
+ if (platform === "win32" && arch === "x64")
166
+ return "minutes-windows-x64.exe";
167
+ return null;
168
+ }
169
+ function getInstallDir() {
170
+ const localBin = join(homedir(), ".local", "bin");
171
+ if (process.platform === "win32") {
172
+ return join(homedir(), ".cargo", "bin"); // common writable dir on Windows
173
+ }
174
+ return localBin;
175
+ }
176
+ async function tryAutoInstall() {
177
+ if (installAttempted)
178
+ return false;
179
+ installAttempted = true;
180
+ console.error("[Minutes] CLI not found — attempting automatic install...");
181
+ // Strategy 1: Download pre-built binary from GitHub release (fastest, no deps)
182
+ const binaryName = getReleaseBinaryName();
183
+ if (binaryName) {
184
+ try {
185
+ const url = `https://github.com/silverstein/minutes/releases/download/${RELEASE_TAG}/${binaryName}`;
186
+ const installDir = getInstallDir();
187
+ const isWindows = process.platform === "win32";
188
+ const targetName = isWindows ? "minutes.exe" : "minutes";
189
+ const targetPath = join(installDir, targetName);
190
+ console.error(`[Minutes] Downloading ${binaryName} from ${RELEASE_TAG} release...`);
191
+ // Ensure install directory exists
192
+ await execFileAsync("mkdir", ["-p", installDir], { timeout: 5000 }).catch(() => { });
193
+ // Download with curl (available on macOS, Linux, and modern Windows)
194
+ await execFileAsync("curl", ["-fSL", "-o", targetPath, url], { timeout: 120000 });
195
+ // Make executable (not needed on Windows)
196
+ if (!isWindows) {
197
+ await execFileAsync("chmod", ["+x", targetPath], { timeout: 5000 });
198
+ }
199
+ console.error(`[Minutes] ✓ Installed to ${targetPath}`);
200
+ MINUTES_BIN = targetPath;
201
+ return true;
202
+ }
203
+ catch (e) {
204
+ console.error(`[Minutes] Binary download failed: ${e.message || e}`);
205
+ }
206
+ }
207
+ // Strategy 2: Homebrew (macOS only)
208
+ if (process.platform === "darwin") {
209
+ try {
210
+ console.error("[Minutes] Trying: brew tap silverstein/tap && brew install minutes");
211
+ await execFileAsync("brew", ["tap", "silverstein/tap"], { timeout: 120000 });
212
+ await execFileAsync("brew", ["install", "minutes"], { timeout: 300000 });
213
+ console.error("[Minutes] ✓ Installed via Homebrew");
214
+ MINUTES_BIN = findMinutesBinary();
215
+ return true;
216
+ }
217
+ catch (e) {
218
+ console.error(`[Minutes] Homebrew install failed: ${e.message || e}`);
219
+ }
220
+ }
221
+ // Strategy 3: Cargo (if Rust is installed)
222
+ try {
223
+ console.error("[Minutes] Trying: cargo install minutes-cli");
224
+ await execFileAsync("cargo", ["install", "minutes-cli"], { timeout: 600000 });
225
+ console.error("[Minutes] ✓ Installed via cargo");
226
+ MINUTES_BIN = findMinutesBinary();
227
+ return true;
228
+ }
229
+ catch (e) {
230
+ console.error(`[Minutes] cargo install failed: ${e.message || e}`);
231
+ }
232
+ console.error("[Minutes] Auto-install failed. Install manually:\n" +
233
+ " macOS: brew tap silverstein/tap && brew install minutes\n" +
234
+ " Any: cargo install minutes-cli\n" +
235
+ " Source: https://github.com/silverstein/minutes");
236
+ return false;
237
+ }
238
+ // ── CLI version check ───────────────────────────────────────
239
+ async function checkCliVersion() {
240
+ try {
241
+ const { stdout } = await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000 });
242
+ // Output is like "minutes 0.8.0" or just "0.8.0"
243
+ const match = stdout.trim().match(/(\d+\.\d+\.\d+)/);
244
+ if (match) {
245
+ const installedVersion = match[1];
246
+ if (installedVersion !== EXPECTED_CLI_VERSION) {
247
+ console.error(`[Minutes] ⚠ CLI version mismatch: installed ${installedVersion}, server expects ${EXPECTED_CLI_VERSION}. ` +
248
+ `Update with: brew upgrade minutes (or cargo install minutes-cli)`);
249
+ }
250
+ else {
251
+ console.error(`[Minutes] CLI v${installedVersion} — up to date`);
252
+ }
253
+ }
254
+ }
255
+ catch {
256
+ // Version check is best-effort — don't block on failure
257
+ }
258
+ }
259
+ // ── Auto-setup: download whisper model if missing ───────────
260
+ // Recording needs a whisper model (~75MB for tiny). If the CLI is
261
+ // available but the model isn't downloaded, trigger setup automatically
262
+ // in the background so the first "start recording" just works.
263
+ let modelCheckDone = false;
264
+ async function ensureWhisperModel() {
265
+ if (modelCheckDone)
266
+ return;
267
+ modelCheckDone = true;
268
+ try {
269
+ // health --json returns an array of { label, state, detail, optional } items.
270
+ // The "Speech model" item has state "ready" when downloaded.
271
+ const { stdout } = await execFileAsync(MINUTES_BIN, ["health", "--json"], { timeout: 10000 });
272
+ const items = JSON.parse(stdout);
273
+ const modelItem = Array.isArray(items) && items.find((i) => i.label === "Speech model");
274
+ if (modelItem && modelItem.state === "ready") {
275
+ console.error("[Minutes] Whisper model ready");
276
+ return;
277
+ }
278
+ }
279
+ catch {
280
+ // health command may not exist in older CLI versions — fall through to setup
281
+ }
282
+ // Model not found — download tiny model in background
283
+ console.error("[Minutes] Whisper model not found — downloading tiny model (~75MB)...");
284
+ try {
285
+ await execFileAsync(MINUTES_BIN, ["setup", "--model", "tiny"], { timeout: 300000 });
286
+ console.error("[Minutes] ✓ Whisper tiny model downloaded — recording is ready");
287
+ }
288
+ catch (e) {
289
+ console.error(`[Minutes] Model download failed: ${e.message || e}. ` +
290
+ `Run manually: minutes setup --model tiny`);
291
+ }
292
+ }
147
293
  // ── CLI availability detection ──────────────────────────────
148
294
  // When installed via `npx minutes-mcp`, the Rust CLI may not be present.
149
295
  // In that case, read-only tools use the pure-TS reader module.
@@ -162,11 +308,32 @@ async function isCliAvailable() {
162
308
  cliAvailable = true;
163
309
  cliCheckedAt = Date.now();
164
310
  console.error("[Minutes] CLI found — full mode (all tools enabled)");
311
+ // Check version and ensure whisper model in background (non-blocking)
312
+ checkCliVersion();
313
+ ensureWhisperModel();
165
314
  }
166
315
  catch {
316
+ // CLI not found — try to install it automatically
317
+ if (!installAttempted) {
318
+ const installed = await tryAutoInstall();
319
+ if (installed) {
320
+ try {
321
+ await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000 });
322
+ cliAvailable = true;
323
+ cliCheckedAt = Date.now();
324
+ console.error("[Minutes] CLI now available after auto-install — full mode");
325
+ checkCliVersion();
326
+ ensureWhisperModel();
327
+ return true;
328
+ }
329
+ catch {
330
+ // Install succeeded but binary still not found — path issue
331
+ }
332
+ }
333
+ }
167
334
  cliAvailable = false;
168
335
  cliCheckedAt = Date.now();
169
- console.error("[Minutes] CLI not found — read-only mode. Install for recording: brew install minutes");
336
+ console.error("[Minutes] CLI not available — read-only mode (search and browse only)");
170
337
  }
171
338
  return cliAvailable;
172
339
  }
@@ -200,48 +367,10 @@ function parseJsonOutput(stdout) {
200
367
  return { raw: stdout };
201
368
  }
202
369
  }
203
- function canonicalizeFilePath(path) {
204
- if (!existsSync(path)) {
205
- throw new Error(`Path does not exist: ${path}`);
206
- }
207
- return realpathSync(path);
208
- }
209
- function canonicalizeRoot(root) {
210
- // Roots may not exist yet (e.g. ~/.minutes/inbox on first run).
211
- // Use realpath if it exists, otherwise lexical resolve.
212
- return existsSync(root) ? realpathSync(root) : resolve(root);
213
- }
214
- function isWithinDirectory(candidate, root) {
215
- // Ensure root ends with separator to prevent prefix attacks (e.g. ~/meetings-evil)
216
- const rootWithSep = root.endsWith("/") ? root : root + "/";
217
- return candidate === root || candidate.startsWith(rootWithSep);
218
- }
219
- function validatePathInDirectory(path, root, allowedExts) {
220
- const canonicalPath = canonicalizeFilePath(path);
221
- const canonicalRoot = canonicalizeRoot(root);
222
- if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
223
- throw new Error(`Access denied: path must be within ${canonicalRoot} and end with ${allowedExts.join(", ")}`);
224
- }
225
- if (!isWithinDirectory(canonicalPath, canonicalRoot)) {
226
- throw new Error(`Access denied: path must be within ${canonicalRoot}`);
227
- }
228
- return canonicalPath;
229
- }
230
- function validatePathInDirectories(path, roots, allowedExts) {
231
- const canonicalPath = canonicalizeFilePath(path);
232
- if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
233
- throw new Error(`Access denied: path must end with one of ${allowedExts.join(", ")}`);
234
- }
235
- const canonicalRoots = roots.map((root) => canonicalizeRoot(root));
236
- if (!canonicalRoots.some((root) => isWithinDirectory(canonicalPath, root))) {
237
- throw new Error(`Access denied: file must be inside one of ${canonicalRoots.join(", ")}`);
238
- }
239
- return canonicalPath;
240
- }
241
370
  // ── MCP Server ──────────────────────────────────────────────
242
371
  const server = new McpServer({
243
372
  name: "minutes",
244
- version: "0.8.0",
373
+ version: "0.8.3",
245
374
  });
246
375
  // Declare MCP Apps extension support so hosts classify this server as interactive.
247
376
  // The `extensions` field is part of the draft MCP spec (SEP-1724) — not yet in the
@@ -250,8 +379,31 @@ server.server.registerCapabilities({
250
379
  extensions: { [EXTENSION_ID]: {} },
251
380
  });
252
381
  // Configurable directories — override via env vars in Claude Desktop extension settings
253
- const MEETINGS_DIR = process.env.MEETINGS_DIR || join(homedir(), "meetings");
254
- const MINUTES_HOME = process.env.MINUTES_HOME || join(homedir(), ".minutes");
382
+ const MEETINGS_DIR = canonicalizeRoot(expandHomeLikePath(process.env.MEETINGS_DIR || join(homedir(), "meetings")));
383
+ const MINUTES_HOME = canonicalizeRoot(expandHomeLikePath(process.env.MINUTES_HOME || join(homedir(), ".minutes")));
384
+ let effectiveMeetingsDirPromise = null;
385
+ async function getEffectiveMeetingsDir() {
386
+ if (effectiveMeetingsDirPromise) {
387
+ return effectiveMeetingsDirPromise;
388
+ }
389
+ effectiveMeetingsDirPromise = (async () => {
390
+ if (!(await isCliAvailable())) {
391
+ return MEETINGS_DIR;
392
+ }
393
+ try {
394
+ const { stdout } = await runMinutes(["paths", "--json"]);
395
+ const parsed = parseJsonOutput(stdout);
396
+ if (parsed && typeof parsed.output_dir === "string" && parsed.output_dir.length > 0) {
397
+ return canonicalizeRoot(parsed.output_dir);
398
+ }
399
+ }
400
+ catch {
401
+ // Fall back to the MCP-configured default when the CLI cannot report paths.
402
+ }
403
+ return MEETINGS_DIR;
404
+ })();
405
+ return effectiveMeetingsDirPromise;
406
+ }
255
407
  // ── UI Resource: MCP App dashboard ──────────────────────────
256
408
  registerAppResource(server, "Minutes Dashboard", UI_RESOURCE_URI, { description: "Interactive meeting dashboard and detail viewer" }, async () => {
257
409
  const htmlPath = join(__dirname, "..", "dist-ui", "index.html");
@@ -762,7 +914,7 @@ registerAppTool(server, "get_meeting", {
762
914
  _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
763
915
  }, async ({ path: filePath }) => {
764
916
  try {
765
- const resolved = validatePathInDirectory(filePath, MEETINGS_DIR, [".md"]);
917
+ const resolved = validatePathInDirectory(filePath, await getEffectiveMeetingsDir(), [".md"]);
766
918
  const content = await readFile(resolved, "utf-8");
767
919
  return {
768
920
  content: [{ type: "text", text: content }],
@@ -787,7 +939,7 @@ server.tool("process_audio", "Process an audio file through the transcription pi
787
939
  }
788
940
  const allowedDirs = [
789
941
  join(MINUTES_HOME, "inbox"),
790
- MEETINGS_DIR,
942
+ await getEffectiveMeetingsDir(),
791
943
  join(homedir(), "Downloads"),
792
944
  ];
793
945
  const audioExts = [".wav", ".m4a", ".mp3", ".ogg", ".webm"];
@@ -829,7 +981,7 @@ server.tool("add_note", "Add a note to the current recording. Notes are timestam
829
981
  try {
830
982
  const args = ["note", text];
831
983
  if (meeting_path) {
832
- const resolved = validatePathInDirectory(meeting_path, MEETINGS_DIR, [".md"]);
984
+ const resolved = validatePathInDirectory(meeting_path, await getEffectiveMeetingsDir(), [".md"]);
833
985
  args.push("--meeting", resolved);
834
986
  }
835
987
  const { stdout, stderr } = await runMinutes(args);
@@ -1083,7 +1235,7 @@ server.resource("meeting", new ResourceTemplate("minutes://meetings/{slug}", { l
1083
1235
  const { stdout } = await runMinutes(["resolve", slug]);
1084
1236
  const parsed = parseJsonOutput(stdout);
1085
1237
  if (parsed.path) {
1086
- const validated = validatePathInDirectory(parsed.path, MEETINGS_DIR, [".md"]);
1238
+ const validated = validatePathInDirectory(parsed.path, await getEffectiveMeetingsDir(), [".md"]);
1087
1239
  const content = await readFile(validated, "utf-8");
1088
1240
  return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: content }] };
1089
1241
  }
@@ -1091,7 +1243,7 @@ server.resource("meeting", new ResourceTemplate("minutes://meetings/{slug}", { l
1091
1243
  });
1092
1244
  // ── Resource: recent_ideas (voice memos from last N days) ──
1093
1245
  server.resource("recent-ideas", "minutes://ideas/recent", { description: "Recent voice memos and ideas captured from any device (last 14 days)" }, async (uri) => {
1094
- const meetings = await reader.listMeetings(MEETINGS_DIR, 200);
1246
+ const meetings = await reader.listMeetings(await getEffectiveMeetingsDir(), 200);
1095
1247
  const cutoff = new Date();
1096
1248
  cutoff.setDate(cutoff.getDate() - 14);
1097
1249
  const memos = meetings.filter((m) => {
@@ -1237,6 +1389,85 @@ server.tool("confirm_speaker", "Confirm or correct a speaker attribution in a me
1237
1389
  };
1238
1390
  }
1239
1391
  });
1392
+ // ── Tool: start_live_transcript ──────────────────────────────
1393
+ server.tool("start_live_transcript", "Start a live transcript session. Records audio and transcribes in real-time, writing utterances to a JSONL file. Use read_live_transcript to read the transcript during the session. Runs until stop is called.", {}, { title: "Start Live Transcript", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async () => {
1394
+ if (!(await isCliAvailable())) {
1395
+ return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
1396
+ }
1397
+ // Pre-flight checks with short timeouts (these are instant file reads)
1398
+ const { stdout: statusOut } = await runMinutes(["status"], 5000);
1399
+ const status = parseJsonOutput(statusOut);
1400
+ if (status.recording) {
1401
+ return {
1402
+ content: [{ type: "text", text: "Recording in progress — stop recording before starting live transcript." }],
1403
+ };
1404
+ }
1405
+ // Check if a live transcript is already running
1406
+ try {
1407
+ const { stdout: ltStatus } = await runMinutes(["transcript", "--status", "--format", "json"], 5000);
1408
+ const ltParsed = parseJsonOutput(ltStatus);
1409
+ if (ltParsed?.active) {
1410
+ return {
1411
+ content: [{ type: "text", text: "Live transcript already running. Use read_live_transcript to read it, or minutes stop to end it." }],
1412
+ };
1413
+ }
1414
+ }
1415
+ catch { /* no active session, proceed */ }
1416
+ // Spawn detached live transcript process
1417
+ const child = spawn(MINUTES_BIN, ["live"], {
1418
+ detached: true,
1419
+ stdio: "ignore",
1420
+ env: { ...process.env, RUST_LOG: "info" },
1421
+ });
1422
+ child.unref();
1423
+ // Verify the session actually started
1424
+ await new Promise((r) => setTimeout(r, 1000));
1425
+ try {
1426
+ const { stdout: verifyOut } = await runMinutes(["transcript", "--status", "--format", "json"], 5000);
1427
+ const verifyStatus = parseJsonOutput(verifyOut);
1428
+ if (verifyStatus?.active) {
1429
+ return {
1430
+ content: [{ type: "text", text: "Live transcript started. Use read_live_transcript to read the transcript. Use minutes stop to end the session." }],
1431
+ };
1432
+ }
1433
+ }
1434
+ catch { /* fall through to error */ }
1435
+ return {
1436
+ content: [{ type: "text", text: "Live transcript may have failed to start. Check minutes health or try again. Common causes: no microphone, whisper model not downloaded, or another session already active." }],
1437
+ isError: true,
1438
+ };
1439
+ });
1440
+ // ── Tool: read_live_transcript ──────────────────────────────
1441
+ server.tool("read_live_transcript", "Read the live transcript. Returns utterances as JSON lines. Use 'since' to get only new lines since a cursor (line number) or time window (e.g., '5m', '30s'). Use 'status' mode to check if a session is active.", {
1442
+ since: z.string().optional().describe("Line number (e.g., '42') or duration (e.g., '5m', '30s'). Omit to get all lines."),
1443
+ status_only: z.boolean().optional().default(false).describe("If true, return session status instead of transcript lines"),
1444
+ }, { title: "Read Live Transcript", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ since, status_only }) => {
1445
+ if (!(await isCliAvailable())) {
1446
+ return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
1447
+ }
1448
+ const args = ["transcript", "--format", "json"];
1449
+ if (status_only) {
1450
+ args.push("--status");
1451
+ }
1452
+ else if (since) {
1453
+ args.push("--since", since);
1454
+ }
1455
+ try {
1456
+ const { stdout } = await runMinutes(args, 10000);
1457
+ // For status queries, a message is helpful. For transcript reads, empty = no new lines.
1458
+ const fallback = status_only ? "No transcript data available." : "";
1459
+ return {
1460
+ content: [{ type: "text", text: stdout || fallback }],
1461
+ };
1462
+ }
1463
+ catch (error) {
1464
+ const msg = error?.stderr || error?.message || String(error);
1465
+ return {
1466
+ content: [{ type: "text", text: `Failed to read transcript: ${msg}` }],
1467
+ isError: true,
1468
+ };
1469
+ }
1470
+ });
1240
1471
  // ── Start server ────────────────────────────────────────────
1241
1472
  async function main() {
1242
1473
  const transport = new StdioServerTransport();
@@ -0,0 +1,6 @@
1
+ export declare function expandHomeLikePath(input: string): string;
2
+ export declare function canonicalizeFilePath(path: string): string;
3
+ export declare function canonicalizeRoot(root: string): string;
4
+ export declare function isWithinDirectory(candidate: string, root: string): boolean;
5
+ export declare function validatePathInDirectory(path: string, root: string, allowedExts: string[]): string;
6
+ export declare function validatePathInDirectories(path: string, roots: string[], allowedExts: string[]): string;
package/dist/paths.js ADDED
@@ -0,0 +1,58 @@
1
+ import { existsSync, realpathSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { extname, join, resolve } from "path";
4
+ export function expandHomeLikePath(input) {
5
+ const home = homedir();
6
+ if (input === "~") {
7
+ return home;
8
+ }
9
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
10
+ return join(home, input.slice(2));
11
+ }
12
+ if (input === "$HOME" || input.startsWith("$HOME/") || input.startsWith("$HOME\\")) {
13
+ return home + input.slice("$HOME".length);
14
+ }
15
+ if (input === "${HOME}" || input.startsWith("${HOME}/") || input.startsWith("${HOME}\\")) {
16
+ return home + input.slice("${HOME}".length);
17
+ }
18
+ return input;
19
+ }
20
+ export function canonicalizeFilePath(path) {
21
+ if (!existsSync(path)) {
22
+ throw new Error(`Path does not exist: ${path}`);
23
+ }
24
+ return realpathSync(path);
25
+ }
26
+ export function canonicalizeRoot(root) {
27
+ const expandedRoot = expandHomeLikePath(root);
28
+ // Roots may not exist yet (e.g. ~/.minutes/inbox on first run).
29
+ // Use realpath if it exists, otherwise lexical resolve.
30
+ return existsSync(expandedRoot) ? realpathSync(expandedRoot) : resolve(expandedRoot);
31
+ }
32
+ export function isWithinDirectory(candidate, root) {
33
+ // Ensure root ends with separator to prevent prefix attacks (e.g. ~/meetings-evil)
34
+ const rootWithSep = root.endsWith("/") ? root : root + "/";
35
+ return candidate === root || candidate.startsWith(rootWithSep);
36
+ }
37
+ export function validatePathInDirectory(path, root, allowedExts) {
38
+ const canonicalPath = canonicalizeFilePath(path);
39
+ const canonicalRoot = canonicalizeRoot(root);
40
+ if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
41
+ throw new Error(`Access denied: path must be within ${canonicalRoot} and end with ${allowedExts.join(", ")}`);
42
+ }
43
+ if (!isWithinDirectory(canonicalPath, canonicalRoot)) {
44
+ throw new Error(`Access denied: path must be within ${canonicalRoot}`);
45
+ }
46
+ return canonicalPath;
47
+ }
48
+ export function validatePathInDirectories(path, roots, allowedExts) {
49
+ const canonicalPath = canonicalizeFilePath(path);
50
+ if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
51
+ throw new Error(`Access denied: path must end with one of ${allowedExts.join(", ")}`);
52
+ }
53
+ const canonicalRoots = roots.map((root) => canonicalizeRoot(root));
54
+ if (!canonicalRoots.some((root) => isWithinDirectory(canonicalPath, root))) {
55
+ throw new Error(`Access denied: file must be inside one of ${canonicalRoots.join(", ")}`);
56
+ }
57
+ return canonicalPath;
58
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "minutes-mcp",
3
- "version": "0.8.0",
4
- "description": "MCP server for minutes — conversation memory for AI assistants. Works with Claude Desktop, Cursor, Windsurf, and any MCP client.",
3
+ "version": "0.8.3",
4
+ "description": "MCP server for minutes — conversation memory for AI assistants. Works with Claude Desktop, Mistral Vibe, Cursor, Windsurf, and any MCP client.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
7
7
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@modelcontextprotocol/ext-apps": "^1.2.2",
42
42
  "@modelcontextprotocol/sdk": "^1.27.1",
43
- "minutes-sdk": "^0.7.0",
43
+ "minutes-sdk": "^0.8.0",
44
44
  "yaml": "^2.8.3"
45
45
  },
46
46
  "devDependencies": {