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 +281 -50
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +58 -0
- package/package.json +3 -3
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
|
|
33
|
+
import { existsSync } from "fs";
|
|
34
34
|
import { readFile } from "fs/promises";
|
|
35
|
-
import { dirname,
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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();
|
package/dist/paths.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
43
|
+
"minutes-sdk": "^0.8.0",
|
|
44
44
|
"yaml": "^2.8.3"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|