minutes-mcp 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,812 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minutes MCP Server
4
+ *
5
+ * MCP tools for Claude Desktop / Cowork / Dispatch:
6
+ * - start_recording: Start recording audio from the default input device
7
+ * - stop_recording: Stop recording and process through the pipeline
8
+ * - get_status: Check if a recording is in progress
9
+ * - list_meetings: List recent meetings and voice memos
10
+ * - search_meetings: Search meeting transcripts
11
+ * - get_meeting: Get full transcript of a specific meeting
12
+ * - process_audio: Process an audio file through the pipeline
13
+ * - add_note: Add a timestamped note to a recording or meeting
14
+ * - consistency_report: Flag conflicting decisions and stale commitments
15
+ * - get_person_profile: Build a profile for a person across meetings
16
+ * - research_topic: Cross-meeting topic research
17
+ * - qmd_collection_status: Check QMD collection registration
18
+ * - register_qmd_collection: Register Minutes output as QMD collection
19
+ *
20
+ * All tools use execFile (not exec) to shell out to the `minutes` CLI binary.
21
+ * No shell interpolation — safe from injection.
22
+ */
23
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
24
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
+ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
26
+ import { z } from "zod";
27
+ import { execFile, spawn } from "child_process";
28
+ import { promisify } from "util";
29
+ import { existsSync, realpathSync } from "fs";
30
+ import { readFile } from "fs/promises";
31
+ import { dirname, extname, join, resolve } from "path";
32
+ import { fileURLToPath } from "url";
33
+ import { homedir } from "os";
34
+ const UI_RESOURCE_URI = "ui://minutes/dashboard";
35
+ const execFileAsync = promisify(execFile);
36
+ // ── QMD semantic search (optional — falls back to CLI) ──────
37
+ let qmdAvailable = null;
38
+ async function runQmd(args, timeoutMs = 15000) {
39
+ try {
40
+ const { stdout, stderr } = await execFileAsync("qmd", args, {
41
+ timeout: timeoutMs,
42
+ env: { ...process.env },
43
+ });
44
+ return { stdout: stdout.trim(), stderr: stderr.trim() };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ async function isQmdAvailable() {
51
+ if (qmdAvailable !== null)
52
+ return qmdAvailable;
53
+ const result = await runQmd(["collection", "show", "minutes"]);
54
+ qmdAvailable = result !== null && !result.stderr.includes("not found") && !result.stderr.includes("No collection");
55
+ if (qmdAvailable) {
56
+ console.error("[Minutes] QMD available — semantic search enabled for minutes collection");
57
+ }
58
+ return qmdAvailable;
59
+ }
60
+ async function enrichWithFrontmatter(qmdResults) {
61
+ return Promise.all(qmdResults.map(async (r) => {
62
+ try {
63
+ const head = (await readFile(r.source_path || r.path, "utf-8")).slice(0, 600);
64
+ const title = head.match(/^title:\s*(.+)$/m)?.[1]?.trim() || "";
65
+ const date = head.match(/^date:\s*(.+)$/m)?.[1]?.trim() || "";
66
+ const contentType = head.match(/^type:\s*(.+)$/m)?.[1]?.trim() || "meeting";
67
+ return {
68
+ date,
69
+ title,
70
+ content_type: contentType,
71
+ path: r.source_path || r.path,
72
+ snippet: r.snippet || "",
73
+ };
74
+ }
75
+ catch {
76
+ return {
77
+ date: "",
78
+ title: "",
79
+ content_type: "meeting",
80
+ path: r.source_path || r.path,
81
+ snippet: r.snippet || "",
82
+ };
83
+ }
84
+ }));
85
+ }
86
+ async function searchViaQmd(query, limit, contentType) {
87
+ if (!(await isQmdAvailable()))
88
+ return null;
89
+ const args = ["search", query, "-c", "minutes", "-n", String(limit), "--json"];
90
+ const result = await runQmd(args);
91
+ if (!result)
92
+ return null;
93
+ try {
94
+ const parsed = JSON.parse(result.stdout);
95
+ const results = Array.isArray(parsed) ? parsed : parsed.results || [];
96
+ if (results.length === 0)
97
+ return null;
98
+ const enriched = await enrichWithFrontmatter(results);
99
+ // Apply content type filter if specified
100
+ if (contentType) {
101
+ const filtered = enriched.filter((r) => r.content_type === contentType);
102
+ return filtered.length > 0 ? filtered : null;
103
+ }
104
+ return enriched;
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ async function triggerQmdIndex() {
111
+ if (!(await isQmdAvailable()))
112
+ return;
113
+ // Fire-and-forget — don't block the response
114
+ execFileAsync("qmd", ["update", "-c", "minutes"]).catch(() => { });
115
+ }
116
+ // ESM-compatible __dirname
117
+ const __filename = fileURLToPath(import.meta.url);
118
+ const __dirname = dirname(__filename);
119
+ // ── Find the minutes binary ─────────────────────────────────
120
+ function findMinutesBinary() {
121
+ const isWindows = process.platform === "win32";
122
+ const ext = isWindows ? ".exe" : "";
123
+ const candidates = [
124
+ join(__dirname, "..", "..", "..", "target", "release", `minutes${ext}`),
125
+ join(__dirname, "..", "..", "..", "target", "debug", `minutes${ext}`),
126
+ join(homedir(), ".cargo", "bin", `minutes${ext}`),
127
+ ...(isWindows
128
+ ? []
129
+ : [
130
+ join(homedir(), ".local", "bin", "minutes"),
131
+ "/opt/homebrew/bin/minutes",
132
+ "/usr/local/bin/minutes",
133
+ ]),
134
+ ];
135
+ for (const candidate of candidates) {
136
+ if (existsSync(candidate)) {
137
+ return candidate;
138
+ }
139
+ }
140
+ // Fall back to PATH lookup
141
+ return "minutes";
142
+ }
143
+ const MINUTES_BIN = findMinutesBinary();
144
+ // ── Helper: run minutes CLI command (uses execFile, not exec) ──
145
+ async function runMinutes(args, timeoutMs = 30000) {
146
+ try {
147
+ const { stdout, stderr } = await execFileAsync(MINUTES_BIN, args, {
148
+ timeout: timeoutMs,
149
+ env: { ...process.env, RUST_LOG: "info" },
150
+ });
151
+ return { stdout: stdout.trim(), stderr: stderr.trim() };
152
+ }
153
+ catch (error) {
154
+ if (error.killed) {
155
+ throw new Error(`Command timed out after ${timeoutMs}ms`);
156
+ }
157
+ const stderr = error.stderr?.trim() || "";
158
+ const stdout = error.stdout?.trim() || "";
159
+ throw new Error(stderr || stdout || error.message);
160
+ }
161
+ }
162
+ function parseJsonOutput(stdout) {
163
+ try {
164
+ return JSON.parse(stdout);
165
+ }
166
+ catch {
167
+ return { raw: stdout };
168
+ }
169
+ }
170
+ function canonicalizeFilePath(path) {
171
+ if (!existsSync(path)) {
172
+ throw new Error(`Path does not exist: ${path}`);
173
+ }
174
+ return realpathSync(path);
175
+ }
176
+ function canonicalizeRoot(root) {
177
+ // Roots may not exist yet (e.g. ~/.minutes/inbox on first run).
178
+ // Use realpath if it exists, otherwise lexical resolve.
179
+ return existsSync(root) ? realpathSync(root) : resolve(root);
180
+ }
181
+ function isWithinDirectory(candidate, root) {
182
+ // Ensure root ends with separator to prevent prefix attacks (e.g. ~/meetings-evil)
183
+ const rootWithSep = root.endsWith("/") ? root : root + "/";
184
+ return candidate === root || candidate.startsWith(rootWithSep);
185
+ }
186
+ function validatePathInDirectory(path, root, allowedExts) {
187
+ const canonicalPath = canonicalizeFilePath(path);
188
+ const canonicalRoot = canonicalizeRoot(root);
189
+ if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
190
+ throw new Error(`Access denied: path must be within ${canonicalRoot} and end with ${allowedExts.join(", ")}`);
191
+ }
192
+ if (!isWithinDirectory(canonicalPath, canonicalRoot)) {
193
+ throw new Error(`Access denied: path must be within ${canonicalRoot}`);
194
+ }
195
+ return canonicalPath;
196
+ }
197
+ function validatePathInDirectories(path, roots, allowedExts) {
198
+ const canonicalPath = canonicalizeFilePath(path);
199
+ if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
200
+ throw new Error(`Access denied: path must end with one of ${allowedExts.join(", ")}`);
201
+ }
202
+ const canonicalRoots = roots.map((root) => canonicalizeRoot(root));
203
+ if (!canonicalRoots.some((root) => isWithinDirectory(canonicalPath, root))) {
204
+ throw new Error(`Access denied: file must be inside one of ${canonicalRoots.join(", ")}`);
205
+ }
206
+ return canonicalPath;
207
+ }
208
+ // ── MCP Server ──────────────────────────────────────────────
209
+ const server = new McpServer({
210
+ name: "minutes",
211
+ version: "0.3.0",
212
+ });
213
+ // Configurable directories — override via env vars in Claude Desktop extension settings
214
+ const MEETINGS_DIR = process.env.MEETINGS_DIR || join(homedir(), "meetings");
215
+ const MINUTES_HOME = process.env.MINUTES_HOME || join(homedir(), ".minutes");
216
+ // ── UI Resource: MCP App dashboard ──────────────────────────
217
+ registerAppResource(server, "Minutes Dashboard", UI_RESOURCE_URI, { description: "Interactive meeting dashboard and detail viewer" }, async () => {
218
+ const htmlPath = join(__dirname, "..", "dist-ui", "index.html");
219
+ const html = await readFile(htmlPath, "utf-8");
220
+ return {
221
+ contents: [{
222
+ uri: UI_RESOURCE_URI,
223
+ mimeType: RESOURCE_MIME_TYPE,
224
+ text: html,
225
+ }],
226
+ };
227
+ });
228
+ // ── Tool: start_recording ───────────────────────────────────
229
+ server.tool("start_recording", "Start recording audio from the default input device. The recording runs until stop_recording is called.", {
230
+ title: z.string().optional().describe("Optional title for this recording"),
231
+ mode: z
232
+ .enum(["meeting", "quick-thought"])
233
+ .optional()
234
+ .default("meeting")
235
+ .describe("Live capture mode"),
236
+ }, { title: "Start Recording", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ title, mode }) => {
237
+ const { stdout: statusOut } = await runMinutes(["status"]);
238
+ const status = parseJsonOutput(statusOut);
239
+ if (status.recording) {
240
+ return {
241
+ content: [
242
+ {
243
+ type: "text",
244
+ text: `Already recording (PID: ${status.pid}). Run stop_recording first.`,
245
+ },
246
+ ],
247
+ };
248
+ }
249
+ // Spawn detached — recording is a foreground process that blocks,
250
+ // so we spawn it and let it run independently
251
+ const args = ["record", "--mode", mode];
252
+ if (title)
253
+ args.push("--title", title);
254
+ const child = spawn(MINUTES_BIN, args, {
255
+ detached: true,
256
+ stdio: "ignore",
257
+ env: { ...process.env, RUST_LOG: "info" },
258
+ });
259
+ child.unref();
260
+ // Wait for PID file to appear
261
+ await new Promise((r) => setTimeout(r, 1000));
262
+ const { stdout: newStatus } = await runMinutes(["status"]);
263
+ const result = parseJsonOutput(newStatus);
264
+ return {
265
+ content: [
266
+ {
267
+ type: "text",
268
+ text: result.recording
269
+ ? `${result.recording_mode === "quick-thought" ? "Quick thought" : "Recording"} started (PID: ${result.pid}). Say "stop recording" when done.`
270
+ : "Recording failed to start. Check `minutes logs` for details.",
271
+ },
272
+ ],
273
+ };
274
+ });
275
+ // ── Tool: stop_recording ────────────────────────────────────
276
+ server.tool("stop_recording", "Stop the current recording and process it (transcribe, diarize, summarize).", {}, { title: "Stop Recording", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async () => {
277
+ try {
278
+ const { stdout, stderr } = await runMinutes(["stop"], 180000);
279
+ const result = parseJsonOutput(stdout);
280
+ const message = result.file
281
+ ? `Recording saved: ${result.file}\nTitle: ${result.title}\nWords: ${result.words}`
282
+ : stderr || "Recording stopped.";
283
+ // Trigger QMD re-index so new meeting is immediately searchable
284
+ if (result.file)
285
+ triggerQmdIndex();
286
+ return { content: [{ type: "text", text: message }] };
287
+ }
288
+ catch (error) {
289
+ return {
290
+ content: [{ type: "text", text: `Stop failed: ${error.message}` }],
291
+ };
292
+ }
293
+ });
294
+ // ── Tool: get_status ────────────────────────────────────────
295
+ server.tool("get_status", "Check if a recording is currently in progress.", {}, { title: "Recording Status", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async () => {
296
+ const { stdout } = await runMinutes(["status"]);
297
+ const status = parseJsonOutput(stdout);
298
+ const modeLabel = status.recording_mode === "quick-thought" ? "Quick thought" : "Recording";
299
+ const processingLabel = status.recording_mode === "quick-thought" ? "Quick thought processing" : "Processing";
300
+ const text = status.recording
301
+ ? `${modeLabel} in progress (PID: ${status.pid})`
302
+ : status.processing
303
+ ? `${processingLabel}${status.processing_stage ? `: ${status.processing_stage}` : "."}`
304
+ : "No recording in progress.";
305
+ return { content: [{ type: "text", text }] };
306
+ });
307
+ // ── Tool: list_meetings ─────────────────────────────────────
308
+ registerAppTool(server, "list_meetings", {
309
+ description: "List recent meetings and voice memos.",
310
+ inputSchema: {
311
+ limit: z.number().optional().default(10).describe("Maximum results"),
312
+ type: z.enum(["meeting", "memo"]).optional().describe("Filter by type"),
313
+ },
314
+ annotations: { title: "List Meetings", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
315
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
316
+ }, async ({ limit, type: contentType }) => {
317
+ const args = ["list", "--limit", String(limit)];
318
+ if (contentType)
319
+ args.push("-t", contentType);
320
+ // Fetch meetings and action items in parallel
321
+ const [meetingsResult, actionsResult] = await Promise.all([
322
+ runMinutes(args),
323
+ runMinutes(["search", "", "--intents-only", "--intent-kind", "action-item", "--limit", "20"]).catch(() => ({ stdout: "[]", stderr: "" })),
324
+ ]);
325
+ const meetings = parseJsonOutput(meetingsResult.stdout);
326
+ let actions = [];
327
+ const parsedActions = parseJsonOutput(actionsResult.stdout);
328
+ if (Array.isArray(parsedActions))
329
+ actions = parsedActions;
330
+ if (Array.isArray(meetings) && meetings.length === 0) {
331
+ return {
332
+ content: [{ type: "text", text: "No meetings or memos found." }],
333
+ structuredContent: { meetings: [], actions, view: "dashboard" },
334
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
335
+ };
336
+ }
337
+ const text = Array.isArray(meetings)
338
+ ? meetings
339
+ .map((m) => `${m.date} — ${m.title} [${m.content_type}]\n ${m.path}`)
340
+ .join("\n\n")
341
+ : (meetingsResult.stderr || meetingsResult.stdout);
342
+ return {
343
+ content: [{ type: "text", text }],
344
+ structuredContent: { meetings: Array.isArray(meetings) ? meetings : [], actions, view: "dashboard" },
345
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
346
+ };
347
+ });
348
+ // ── Tool: search_meetings ───────────────────────────────────
349
+ registerAppTool(server, "search_meetings", {
350
+ description: "Search meeting transcripts and voice memos.",
351
+ inputSchema: {
352
+ query: z.string().describe("Text to search for"),
353
+ type: z.enum(["meeting", "memo"]).optional().describe("Filter by type"),
354
+ since: z.string().optional().describe("Only results after this date (ISO)"),
355
+ limit: z.number().optional().default(10).describe("Maximum results"),
356
+ intent_kind: z
357
+ .enum(["action-item", "decision", "open-question", "commitment"])
358
+ .optional()
359
+ .describe("Filter structured intents by kind"),
360
+ owner: z.string().optional().describe("Filter structured intents by owner / person"),
361
+ intents_only: z
362
+ .boolean()
363
+ .optional()
364
+ .default(false)
365
+ .describe("Return structured intent records instead of transcript snippets"),
366
+ },
367
+ annotations: { title: "Search Meetings", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
368
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
369
+ }, async ({ query, type: contentType, since, limit, intent_kind, owner, intents_only }) => {
370
+ // Intent/metadata queries always use CLI (QMD doesn't index YAML frontmatter fields)
371
+ const useCliOnly = intents_only || intent_kind || owner || since;
372
+ // Try QMD semantic search for text queries
373
+ let results = null;
374
+ let usedQmd = false;
375
+ if (!useCliOnly) {
376
+ results = await searchViaQmd(query, limit, contentType);
377
+ if (results)
378
+ usedQmd = true;
379
+ }
380
+ // Fall back to CLI regex search
381
+ if (!results) {
382
+ const args = ["search", query, "--limit", String(limit)];
383
+ if (contentType)
384
+ args.push("-t", contentType);
385
+ if (since)
386
+ args.push("--since", since);
387
+ if (intent_kind)
388
+ args.push("--intent-kind", intent_kind);
389
+ if (owner)
390
+ args.push("--owner", owner);
391
+ if (intents_only)
392
+ args.push("--intents-only");
393
+ const { stdout, stderr } = await runMinutes(args);
394
+ const parsed = parseJsonOutput(stdout);
395
+ results = Array.isArray(parsed) ? parsed : [];
396
+ }
397
+ if (results.length === 0) {
398
+ return {
399
+ content: [{ type: "text", text: `No results found for "${query}".` }],
400
+ structuredContent: { meetings: [], actions: [], view: "dashboard" },
401
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
402
+ };
403
+ }
404
+ const text = intents_only
405
+ ? results
406
+ .map((r) => `${r.date} — ${r.title} [${r.content_type}]\n ${r.kind}: ${r.what}${r.who ? ` (@${r.who})` : ""}${r.by_date ? ` by ${r.by_date}` : ""}\n ${r.path}`)
407
+ .join("\n\n")
408
+ : results
409
+ .map((r) => `${r.date} — ${r.title} [${r.content_type}]\n ${r.snippet}\n ${r.path}`)
410
+ .join("\n\n");
411
+ // Map search results to meeting-like objects for the dashboard view
412
+ const meetings = results.map((r) => ({
413
+ date: r.date,
414
+ title: r.title,
415
+ content_type: r.content_type,
416
+ path: r.path,
417
+ snippet: r.snippet || (intents_only ? `${r.kind}: ${r.what}` : undefined),
418
+ }));
419
+ return {
420
+ content: [{ type: "text", text }],
421
+ structuredContent: { meetings, actions: [], view: "dashboard" },
422
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
423
+ };
424
+ });
425
+ // ── Tool: consistency_report ───────────────────────────────
426
+ registerAppTool(server, "consistency_report", {
427
+ description: "Flag conflicting decisions and stale commitments across meetings using structured intent data.",
428
+ inputSchema: {
429
+ owner: z.string().optional().describe("Filter stale commitments by owner / person"),
430
+ stale_after_days: z
431
+ .number()
432
+ .optional()
433
+ .default(7)
434
+ .describe("Flag commitments this many days old or older"),
435
+ },
436
+ annotations: { title: "Consistency Report", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
437
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
438
+ }, async ({ owner, stale_after_days }) => {
439
+ const args = ["consistency", "--stale-after-days", String(stale_after_days)];
440
+ if (owner)
441
+ args.push("--owner", owner);
442
+ const { stdout, stderr } = await runMinutes(args);
443
+ const report = parseJsonOutput(stdout);
444
+ if (!report || typeof report !== "object") {
445
+ return { content: [{ type: "text", text: stderr || stdout }] };
446
+ }
447
+ const decisionConflicts = Array.isArray(report.decision_conflicts)
448
+ ? report.decision_conflicts
449
+ : [];
450
+ const staleCommitments = Array.isArray(report.stale_commitments)
451
+ ? report.stale_commitments
452
+ : [];
453
+ if (decisionConflicts.length === 0 && staleCommitments.length === 0) {
454
+ return {
455
+ content: [{ type: "text", text: "No consistency issues found." }],
456
+ structuredContent: { decision_conflicts: [], stale_commitments: [], view: "report" },
457
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "report" },
458
+ };
459
+ }
460
+ const sections = [];
461
+ if (decisionConflicts.length > 0) {
462
+ sections.push("Decision conflicts:\n" +
463
+ decisionConflicts
464
+ .map((conflict) => `- ${conflict.topic}: latest "${conflict.latest.what}" (${conflict.latest.title})`)
465
+ .join("\n"));
466
+ }
467
+ if (staleCommitments.length > 0) {
468
+ sections.push("Stale commitments:\n" +
469
+ staleCommitments
470
+ .map((stale) => `- ${stale.kind}: ${stale.entry.what}${stale.entry.who ? ` (@${stale.entry.who})` : ""} — ${Array.isArray(stale.reasons) ? stale.reasons.join(", ") : `${stale.age_days} days old`}${stale.latest_follow_up ? `; latest follow-up: ${stale.latest_follow_up.title}` : ""}`)
471
+ .join("\n"));
472
+ }
473
+ return {
474
+ content: [{ type: "text", text: sections.join("\n\n") }],
475
+ structuredContent: { decision_conflicts: decisionConflicts, stale_commitments: staleCommitments, view: "report" },
476
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "report" },
477
+ };
478
+ });
479
+ // ── Tool: get_person_profile ───────────────────────────────
480
+ registerAppTool(server, "get_person_profile", {
481
+ description: "Build a first-pass profile for a person across meetings using structured intent data.",
482
+ inputSchema: {
483
+ name: z.string().describe("Person / attendee name to profile"),
484
+ },
485
+ annotations: { title: "Person Profile", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
486
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
487
+ }, async ({ name }) => {
488
+ const { stdout, stderr } = await runMinutes(["person", name]);
489
+ const profile = parseJsonOutput(stdout);
490
+ if (!profile || typeof profile !== "object") {
491
+ return { content: [{ type: "text", text: stderr || stdout }] };
492
+ }
493
+ const topics = Array.isArray(profile.top_topics) ? profile.top_topics : [];
494
+ const openIntents = Array.isArray(profile.open_intents) ? profile.open_intents : [];
495
+ const recentMeetings = Array.isArray(profile.recent_meetings)
496
+ ? profile.recent_meetings
497
+ : [];
498
+ if (topics.length === 0 && openIntents.length === 0 && recentMeetings.length === 0) {
499
+ return {
500
+ content: [{ type: "text", text: `No profile data found for ${name}.` }],
501
+ structuredContent: { name, top_topics: [], open_intents: [], recent_meetings: [], view: "person" },
502
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
503
+ };
504
+ }
505
+ const sections = [];
506
+ if (topics.length > 0) {
507
+ sections.push("Top topics:\n" +
508
+ topics.map((topic) => `- ${topic.topic} (${topic.count})`).join("\n"));
509
+ }
510
+ if (openIntents.length > 0) {
511
+ sections.push("Open commitments/actions:\n" +
512
+ openIntents
513
+ .map((intent) => `- ${intent.kind}: ${intent.what}${intent.by_date ? ` by ${intent.by_date}` : ""}`)
514
+ .join("\n"));
515
+ }
516
+ if (recentMeetings.length > 0) {
517
+ sections.push("Recent meetings:\n" +
518
+ recentMeetings
519
+ .map((meeting) => `- ${meeting.date} — ${meeting.title}`)
520
+ .join("\n"));
521
+ }
522
+ return {
523
+ content: [
524
+ {
525
+ type: "text",
526
+ text: `Profile for ${profile.name}:\n\n${sections.join("\n\n")}`,
527
+ },
528
+ ],
529
+ structuredContent: { ...profile, view: "person" },
530
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
531
+ };
532
+ });
533
+ // ── Tool: research_topic ────────────────────────────────────
534
+ server.tool("research_topic", "Research a topic across meetings, decisions, and open follow-ups.", {
535
+ query: z.string().describe("Topic or question to investigate across meetings"),
536
+ type: z.enum(["meeting", "memo"]).optional().describe("Filter by type"),
537
+ since: z.string().optional().describe("Only results after this date (ISO)"),
538
+ attendee: z.string().optional().describe("Filter by attendee / person"),
539
+ }, { title: "Research Topic", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ query, type: contentType, since, attendee }) => {
540
+ const args = ["research", query];
541
+ if (contentType)
542
+ args.push("-t", contentType);
543
+ if (since)
544
+ args.push("--since", since);
545
+ if (attendee)
546
+ args.push("--attendee", attendee);
547
+ const { stdout, stderr } = await runMinutes(args);
548
+ const report = parseJsonOutput(stdout);
549
+ if (!report || typeof report !== "object") {
550
+ return { content: [{ type: "text", text: stderr || stdout }] };
551
+ }
552
+ const decisions = Array.isArray(report.related_decisions) ? report.related_decisions : [];
553
+ const openIntents = Array.isArray(report.related_open_intents)
554
+ ? report.related_open_intents
555
+ : [];
556
+ const recentMeetings = Array.isArray(report.recent_meetings)
557
+ ? report.recent_meetings
558
+ : [];
559
+ const topics = Array.isArray(report.related_topics) ? report.related_topics : [];
560
+ if (decisions.length === 0 && openIntents.length === 0 && recentMeetings.length === 0) {
561
+ return {
562
+ content: [
563
+ {
564
+ type: "text",
565
+ text: `No cross-meeting results found for ${query}.`,
566
+ },
567
+ ],
568
+ };
569
+ }
570
+ const sections = [];
571
+ if (topics.length > 0) {
572
+ sections.push("Related topics:\n" +
573
+ topics.map((topic) => `- ${topic.topic} (${topic.count})`).join("\n"));
574
+ }
575
+ if (decisions.length > 0) {
576
+ sections.push("Recent decisions:\n" +
577
+ decisions
578
+ .map((decision) => `- ${decision.date} — ${decision.what} (${decision.title})`)
579
+ .join("\n"));
580
+ }
581
+ if (openIntents.length > 0) {
582
+ sections.push("Open follow-ups:\n" +
583
+ openIntents
584
+ .map((intent) => `- ${intent.kind}: ${intent.what}${intent.who ? ` (@${intent.who})` : ""}${intent.by_date ? ` by ${intent.by_date}` : ""}`)
585
+ .join("\n"));
586
+ }
587
+ if (recentMeetings.length > 0) {
588
+ sections.push("Matching meetings:\n" +
589
+ recentMeetings
590
+ .map((meeting) => `- ${meeting.date} — ${meeting.title}`)
591
+ .join("\n"));
592
+ }
593
+ return {
594
+ content: [
595
+ {
596
+ type: "text",
597
+ text: `Cross-meeting research for ${query}:\n\n${sections.join("\n\n")}`,
598
+ },
599
+ ],
600
+ };
601
+ });
602
+ // ── Tool: get_meeting ───────────────────────────────────────
603
+ registerAppTool(server, "get_meeting", {
604
+ description: "Get the full transcript and details of a specific meeting or memo.",
605
+ inputSchema: {
606
+ path: z.string().describe("Path to the meeting markdown file"),
607
+ },
608
+ annotations: { title: "View Meeting", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
609
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
610
+ }, async ({ path: filePath }) => {
611
+ try {
612
+ const resolved = validatePathInDirectory(filePath, MEETINGS_DIR, [".md"]);
613
+ const content = await readFile(resolved, "utf-8");
614
+ return {
615
+ content: [{ type: "text", text: content }],
616
+ structuredContent: { path: resolved, view: "detail" },
617
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "detail", path: resolved },
618
+ };
619
+ }
620
+ catch (error) {
621
+ return {
622
+ content: [{ type: "text", text: `Could not read: ${error.message}` }],
623
+ };
624
+ }
625
+ });
626
+ // ── Tool: process_audio ─────────────────────────────────────
627
+ server.tool("process_audio", "Process an audio file through the transcription pipeline.", {
628
+ file_path: z.string().describe("Path to audio file (.wav, .m4a, .mp3)"),
629
+ type: z.enum(["meeting", "memo"]).optional().default("memo").describe("Content type"),
630
+ title: z.string().optional().describe("Optional title"),
631
+ }, { title: "Process Audio", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ file_path, type: contentType, title }) => {
632
+ const allowedDirs = [
633
+ join(MINUTES_HOME, "inbox"),
634
+ MEETINGS_DIR,
635
+ join(homedir(), "Downloads"),
636
+ ];
637
+ const audioExts = [".wav", ".m4a", ".mp3", ".ogg", ".webm"];
638
+ try {
639
+ const resolved = validatePathInDirectories(file_path, allowedDirs, audioExts);
640
+ const args = ["process", resolved, "-t", contentType];
641
+ if (title)
642
+ args.push("--title", title);
643
+ const { stdout } = await runMinutes(args, 300000);
644
+ const result = parseJsonOutput(stdout);
645
+ return {
646
+ content: [
647
+ {
648
+ type: "text",
649
+ text: result.file
650
+ ? `Processed: ${result.file}\nTitle: ${result.title}\nWords: ${result.words}`
651
+ : stdout,
652
+ },
653
+ ],
654
+ };
655
+ }
656
+ catch (error) {
657
+ return {
658
+ content: [{ type: "text", text: `Failed: ${error.message}` }],
659
+ };
660
+ }
661
+ });
662
+ // ── Tool: add_note ───────────────────────────────────────────
663
+ server.tool("add_note", "Add a note to the current recording. Notes are timestamped and included in the meeting summary. If no recording is active, annotate an existing meeting file with --meeting.", {
664
+ text: z.string().describe("The note text (plain text, no markdown needed)"),
665
+ meeting_path: z
666
+ .string()
667
+ .optional()
668
+ .describe("Path to an existing meeting file to annotate (for post-meeting notes)"),
669
+ }, { title: "Add Note", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ text, meeting_path }) => {
670
+ try {
671
+ const args = ["note", text];
672
+ if (meeting_path) {
673
+ const resolved = validatePathInDirectory(meeting_path, MEETINGS_DIR, [".md"]);
674
+ args.push("--meeting", resolved);
675
+ }
676
+ const { stdout, stderr } = await runMinutes(args);
677
+ return {
678
+ content: [{ type: "text", text: stderr || stdout || "Note added." }],
679
+ };
680
+ }
681
+ catch (error) {
682
+ return {
683
+ content: [{ type: "text", text: `Note failed: ${error.message}` }],
684
+ };
685
+ }
686
+ });
687
+ // ── Tool: qmd_collection_status ─────────────────────────────
688
+ server.tool("qmd_collection_status", "Check whether the Minutes output directory is already registered as a QMD collection.", {
689
+ collection: z
690
+ .string()
691
+ .optional()
692
+ .default("minutes")
693
+ .describe("QMD collection name to check"),
694
+ }, { title: "QMD Status", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ collection }) => {
695
+ const { stdout, stderr } = await runMinutes([
696
+ "qmd",
697
+ "status",
698
+ "--collection",
699
+ collection,
700
+ ]);
701
+ const report = parseJsonOutput(stdout);
702
+ if (!report || typeof report !== "object") {
703
+ return { content: [{ type: "text", text: stderr || stdout }] };
704
+ }
705
+ if (!report.qmd_available) {
706
+ return {
707
+ content: [
708
+ {
709
+ type: "text",
710
+ text: `QMD is not installed or not on PATH. Install qmd, then run register_qmd_collection for "${collection}".`,
711
+ },
712
+ ],
713
+ };
714
+ }
715
+ if (report.registered) {
716
+ return {
717
+ content: [
718
+ {
719
+ type: "text",
720
+ text: `QMD collection "${collection}" already indexes ${report.output_dir}.`,
721
+ },
722
+ ],
723
+ };
724
+ }
725
+ const aliases = Array.isArray(report.matching_collections)
726
+ ? report.matching_collections.map((candidate) => candidate.name)
727
+ : [];
728
+ return {
729
+ content: [
730
+ {
731
+ type: "text",
732
+ text: aliases.length > 0
733
+ ? `${report.output_dir} is already indexed in QMD under: ${aliases.join(", ")}.`
734
+ : `${report.output_dir} is not indexed in QMD yet.`,
735
+ },
736
+ ],
737
+ };
738
+ });
739
+ // ── Tool: register_qmd_collection ───────────────────────────
740
+ server.tool("register_qmd_collection", "Register the Minutes output directory as a QMD collection.", {
741
+ collection: z
742
+ .string()
743
+ .optional()
744
+ .default("minutes")
745
+ .describe("QMD collection name to register"),
746
+ }, { title: "Register QMD", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ collection }) => {
747
+ const { stdout, stderr } = await runMinutes([
748
+ "qmd",
749
+ "register",
750
+ "--collection",
751
+ collection,
752
+ ]);
753
+ const report = parseJsonOutput(stdout);
754
+ if (!report || typeof report !== "object") {
755
+ return { content: [{ type: "text", text: stderr || stdout }] };
756
+ }
757
+ if (!report.registered) {
758
+ return {
759
+ content: [
760
+ {
761
+ type: "text",
762
+ text: stderr || stdout || `Failed to register QMD collection "${collection}".`,
763
+ },
764
+ ],
765
+ };
766
+ }
767
+ return {
768
+ content: [
769
+ {
770
+ type: "text",
771
+ text: `Registered ${report.output_dir} as QMD collection "${collection}".`,
772
+ },
773
+ ],
774
+ };
775
+ });
776
+ // ── Resources ───────────────────────────────────────────────
777
+ server.resource("recent_meetings", "minutes://meetings/recent", { description: "List of recent meetings and memos" }, async () => {
778
+ const { stdout } = await runMinutes(["list", "--limit", "20"]);
779
+ return { contents: [{ uri: "minutes://meetings/recent", mimeType: "application/json", text: stdout }] };
780
+ });
781
+ server.resource("recording_status", "minutes://status", { description: "Current recording status" }, async () => {
782
+ const { stdout } = await runMinutes(["status"]);
783
+ return { contents: [{ uri: "minutes://status", mimeType: "application/json", text: stdout }] };
784
+ });
785
+ server.resource("open_actions", "minutes://actions/open", { description: "All open action items across meetings" }, async () => {
786
+ const { stdout } = await runMinutes(["search", "", "--intents-only", "--intent-kind", "action-item"]);
787
+ return { contents: [{ uri: "minutes://actions/open", mimeType: "application/json", text: stdout }] };
788
+ });
789
+ server.resource("recent_events", "minutes://events/recent", { description: "Recent pipeline events (recordings, processing, notes)" }, async () => {
790
+ const { stdout } = await runMinutes(["events", "--limit", "20"]);
791
+ return { contents: [{ uri: "minutes://events/recent", mimeType: "application/json", text: stdout }] };
792
+ });
793
+ server.resource("meeting", new ResourceTemplate("minutes://meetings/{slug}", { list: undefined }), { description: "Get a specific meeting by its filename slug" }, async (uri, variables) => {
794
+ const slug = String(variables.slug);
795
+ const { stdout } = await runMinutes(["resolve", slug]);
796
+ const parsed = parseJsonOutput(stdout);
797
+ if (parsed.path) {
798
+ const content = await readFile(parsed.path, "utf-8");
799
+ return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: content }] };
800
+ }
801
+ return { contents: [{ uri: uri.href, mimeType: "text/plain", text: `Meeting not found: ${slug}` }] };
802
+ });
803
+ // ── Start server ────────────────────────────────────────────
804
+ async function main() {
805
+ const transport = new StdioServerTransport();
806
+ await server.connect(transport);
807
+ console.error("Minutes MCP server running on stdio");
808
+ }
809
+ main().catch((error) => {
810
+ console.error("Fatal error:", error);
811
+ process.exit(1);
812
+ });