minutes-mcp 0.5.0 → 0.5.2
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/LICENSE +21 -0
- package/dist/index.js +240 -11
- package/package.json +5 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mat Silverstein
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { readFile } from "fs/promises";
|
|
|
31
31
|
import { dirname, extname, join, resolve } from "path";
|
|
32
32
|
import { fileURLToPath } from "url";
|
|
33
33
|
import { homedir } from "os";
|
|
34
|
+
import * as reader from "minutes-sdk";
|
|
34
35
|
const UI_RESOURCE_URI = "ui://minutes/dashboard";
|
|
35
36
|
const execFileAsync = promisify(execFile);
|
|
36
37
|
// ── QMD semantic search (optional — falls back to CLI) ──────
|
|
@@ -59,16 +60,14 @@ async function isQmdAvailable() {
|
|
|
59
60
|
}
|
|
60
61
|
async function enrichWithFrontmatter(qmdResults) {
|
|
61
62
|
return Promise.all(qmdResults.map(async (r) => {
|
|
63
|
+
const filePath = r.source_path || r.path;
|
|
62
64
|
try {
|
|
63
|
-
const
|
|
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";
|
|
65
|
+
const meeting = await reader.getMeeting(filePath);
|
|
67
66
|
return {
|
|
68
|
-
date,
|
|
69
|
-
title,
|
|
70
|
-
content_type:
|
|
71
|
-
path:
|
|
67
|
+
date: meeting?.frontmatter.date || "",
|
|
68
|
+
title: meeting?.frontmatter.title || "",
|
|
69
|
+
content_type: meeting?.frontmatter.type || "meeting",
|
|
70
|
+
path: filePath,
|
|
72
71
|
snippet: r.snippet || "",
|
|
73
72
|
};
|
|
74
73
|
}
|
|
@@ -77,7 +76,7 @@ async function enrichWithFrontmatter(qmdResults) {
|
|
|
77
76
|
date: "",
|
|
78
77
|
title: "",
|
|
79
78
|
content_type: "meeting",
|
|
80
|
-
path:
|
|
79
|
+
path: filePath,
|
|
81
80
|
snippet: r.snippet || "",
|
|
82
81
|
};
|
|
83
82
|
}
|
|
@@ -141,6 +140,36 @@ function findMinutesBinary() {
|
|
|
141
140
|
return "minutes";
|
|
142
141
|
}
|
|
143
142
|
const MINUTES_BIN = findMinutesBinary();
|
|
143
|
+
// ── CLI availability detection ──────────────────────────────
|
|
144
|
+
// When installed via `npx minutes-mcp`, the Rust CLI may not be present.
|
|
145
|
+
// In that case, read-only tools use the pure-TS reader module.
|
|
146
|
+
let cliAvailable = null;
|
|
147
|
+
let cliCheckedAt = 0;
|
|
148
|
+
const CLI_CACHE_TTL_MS = 5 * 60 * 1000; // re-check every 5 minutes
|
|
149
|
+
async function isCliAvailable() {
|
|
150
|
+
// Cache hit: return true permanently (CLI won't disappear mid-session)
|
|
151
|
+
// Cache miss (false): re-probe after TTL so installing CLI mid-session works
|
|
152
|
+
if (cliAvailable === true)
|
|
153
|
+
return true;
|
|
154
|
+
if (cliAvailable === false && Date.now() - cliCheckedAt < CLI_CACHE_TTL_MS)
|
|
155
|
+
return false;
|
|
156
|
+
try {
|
|
157
|
+
await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000 });
|
|
158
|
+
cliAvailable = true;
|
|
159
|
+
cliCheckedAt = Date.now();
|
|
160
|
+
console.error("[Minutes] CLI found — full mode (all tools enabled)");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
cliAvailable = false;
|
|
164
|
+
cliCheckedAt = Date.now();
|
|
165
|
+
console.error("[Minutes] CLI not found — read-only mode. Install for recording: brew install minutes");
|
|
166
|
+
}
|
|
167
|
+
return cliAvailable;
|
|
168
|
+
}
|
|
169
|
+
const CLI_INSTALL_MSG = "Recording requires the minutes CLI binary. Install it:\n" +
|
|
170
|
+
" macOS: brew tap silverstein/tap && brew install minutes\n" +
|
|
171
|
+
" Any: cargo install minutes-cli\n" +
|
|
172
|
+
" Source: https://github.com/silverstein/minutes";
|
|
144
173
|
// ── Helper: run minutes CLI command (uses execFile, not exec) ──
|
|
145
174
|
async function runMinutes(args, timeoutMs = 30000) {
|
|
146
175
|
try {
|
|
@@ -208,7 +237,7 @@ function validatePathInDirectories(path, roots, allowedExts) {
|
|
|
208
237
|
// ── MCP Server ──────────────────────────────────────────────
|
|
209
238
|
const server = new McpServer({
|
|
210
239
|
name: "minutes",
|
|
211
|
-
version: "0.
|
|
240
|
+
version: "0.5.2",
|
|
212
241
|
});
|
|
213
242
|
// Configurable directories — override via env vars in Claude Desktop extension settings
|
|
214
243
|
const MEETINGS_DIR = process.env.MEETINGS_DIR || join(homedir(), "meetings");
|
|
@@ -234,6 +263,9 @@ server.tool("start_recording", "Start recording audio from the default input dev
|
|
|
234
263
|
.default("meeting")
|
|
235
264
|
.describe("Live capture mode"),
|
|
236
265
|
}, { title: "Start Recording", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ title, mode }) => {
|
|
266
|
+
if (!(await isCliAvailable())) {
|
|
267
|
+
return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
|
|
268
|
+
}
|
|
237
269
|
const { stdout: statusOut } = await runMinutes(["status"]);
|
|
238
270
|
const status = parseJsonOutput(statusOut);
|
|
239
271
|
if (status.recording) {
|
|
@@ -274,6 +306,9 @@ server.tool("start_recording", "Start recording audio from the default input dev
|
|
|
274
306
|
});
|
|
275
307
|
// ── Tool: stop_recording ────────────────────────────────────
|
|
276
308
|
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 () => {
|
|
309
|
+
if (!(await isCliAvailable())) {
|
|
310
|
+
return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
|
|
311
|
+
}
|
|
277
312
|
try {
|
|
278
313
|
const { stdout, stderr } = await runMinutes(["stop"], 180000);
|
|
279
314
|
const result = parseJsonOutput(stdout);
|
|
@@ -293,6 +328,9 @@ server.tool("stop_recording", "Stop the current recording and process it (transc
|
|
|
293
328
|
});
|
|
294
329
|
// ── Tool: get_status ────────────────────────────────────────
|
|
295
330
|
server.tool("get_status", "Check if a recording is currently in progress.", {}, { title: "Recording Status", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async () => {
|
|
331
|
+
if (!(await isCliAvailable())) {
|
|
332
|
+
return { content: [{ type: "text", text: `No recording in progress (read-only mode).\n\n${CLI_INSTALL_MSG}` }] };
|
|
333
|
+
}
|
|
296
334
|
const { stdout } = await runMinutes(["status"]);
|
|
297
335
|
const status = parseJsonOutput(stdout);
|
|
298
336
|
const modeLabel = status.recording_mode === "quick-thought" ? "Quick thought" : "Recording";
|
|
@@ -314,6 +352,36 @@ registerAppTool(server, "list_meetings", {
|
|
|
314
352
|
annotations: { title: "List Meetings", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
315
353
|
_meta: { ui: { resourceUri: UI_RESOURCE_URI } },
|
|
316
354
|
}, async ({ limit, type: contentType }) => {
|
|
355
|
+
// Pure-TS fallback when CLI is not available
|
|
356
|
+
if (!(await isCliAvailable())) {
|
|
357
|
+
const meetings = await reader.listMeetings(MEETINGS_DIR, limit);
|
|
358
|
+
const filtered = contentType
|
|
359
|
+
? meetings.filter((m) => m.frontmatter.type === contentType)
|
|
360
|
+
: meetings;
|
|
361
|
+
const openActions = await reader.findOpenActions(MEETINGS_DIR);
|
|
362
|
+
if (filtered.length === 0) {
|
|
363
|
+
return {
|
|
364
|
+
content: [{ type: "text", text: "No meetings or memos found." }],
|
|
365
|
+
structuredContent: { meetings: [], actions: [], view: "dashboard" },
|
|
366
|
+
_meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const text = filtered
|
|
370
|
+
.map((m) => `${m.frontmatter.date} — ${m.frontmatter.title} [${m.frontmatter.type}]\n ${m.path}`)
|
|
371
|
+
.join("\n\n");
|
|
372
|
+
const meetingsJson = filtered.map((m) => ({
|
|
373
|
+
date: m.frontmatter.date,
|
|
374
|
+
title: m.frontmatter.title,
|
|
375
|
+
content_type: m.frontmatter.type,
|
|
376
|
+
path: m.path,
|
|
377
|
+
duration: m.frontmatter.duration,
|
|
378
|
+
}));
|
|
379
|
+
return {
|
|
380
|
+
content: [{ type: "text", text }],
|
|
381
|
+
structuredContent: { meetings: meetingsJson, actions: openActions.map((a) => a.item), view: "dashboard" },
|
|
382
|
+
_meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
|
|
383
|
+
};
|
|
384
|
+
}
|
|
317
385
|
const args = ["list", "--limit", String(limit)];
|
|
318
386
|
if (contentType)
|
|
319
387
|
args.push("-t", contentType);
|
|
@@ -367,6 +435,40 @@ registerAppTool(server, "search_meetings", {
|
|
|
367
435
|
annotations: { title: "Search Meetings", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
368
436
|
_meta: { ui: { resourceUri: UI_RESOURCE_URI } },
|
|
369
437
|
}, async ({ query, type: contentType, since, limit, intent_kind, owner, intents_only }) => {
|
|
438
|
+
// Pure-TS fallback when CLI is not available
|
|
439
|
+
if (!(await isCliAvailable())) {
|
|
440
|
+
const droppedFilters = [since && "since", intent_kind && "intent_kind", owner && "owner", intents_only && "intents_only"].filter(Boolean);
|
|
441
|
+
const filterWarning = droppedFilters.length > 0
|
|
442
|
+
? `\n\n(Note: ${droppedFilters.join(", ")} filters require the CLI. Install: brew install minutes)`
|
|
443
|
+
: "";
|
|
444
|
+
const results = await reader.searchMeetings(MEETINGS_DIR, query, limit);
|
|
445
|
+
const filtered = contentType
|
|
446
|
+
? results.filter((m) => m.frontmatter.type === contentType)
|
|
447
|
+
: results;
|
|
448
|
+
if (filtered.length === 0) {
|
|
449
|
+
return {
|
|
450
|
+
content: [{ type: "text", text: `No results for "${query}".${filterWarning}` }],
|
|
451
|
+
structuredContent: { results: [], view: "search" },
|
|
452
|
+
_meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "search" },
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const text = filtered
|
|
456
|
+
.map((m) => `${m.frontmatter.date} — ${m.frontmatter.title} [${m.frontmatter.type}]\n ${m.path}`)
|
|
457
|
+
.join("\n\n") + filterWarning;
|
|
458
|
+
return {
|
|
459
|
+
content: [{ type: "text", text }],
|
|
460
|
+
structuredContent: {
|
|
461
|
+
results: filtered.map((m) => ({
|
|
462
|
+
date: m.frontmatter.date,
|
|
463
|
+
title: m.frontmatter.title,
|
|
464
|
+
content_type: m.frontmatter.type,
|
|
465
|
+
path: m.path,
|
|
466
|
+
})),
|
|
467
|
+
view: "search",
|
|
468
|
+
},
|
|
469
|
+
_meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "search" },
|
|
470
|
+
};
|
|
471
|
+
}
|
|
370
472
|
// Intent/metadata queries always use CLI (QMD doesn't index YAML frontmatter fields)
|
|
371
473
|
const useCliOnly = intents_only || intent_kind || owner || since;
|
|
372
474
|
// Try QMD semantic search for text queries
|
|
@@ -436,6 +538,9 @@ registerAppTool(server, "consistency_report", {
|
|
|
436
538
|
annotations: { title: "Consistency Report", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
437
539
|
_meta: { ui: { resourceUri: UI_RESOURCE_URI } },
|
|
438
540
|
}, async ({ owner, stale_after_days }) => {
|
|
541
|
+
if (!(await isCliAvailable())) {
|
|
542
|
+
return { content: [{ type: "text", text: `Consistency reports require the full CLI for structured intent analysis.\n\n${CLI_INSTALL_MSG}` }] };
|
|
543
|
+
}
|
|
439
544
|
const args = ["consistency", "--stale-after-days", String(stale_after_days)];
|
|
440
545
|
if (owner)
|
|
441
546
|
args.push("--owner", owner);
|
|
@@ -485,6 +590,25 @@ registerAppTool(server, "get_person_profile", {
|
|
|
485
590
|
annotations: { title: "Person Profile", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
486
591
|
_meta: { ui: { resourceUri: UI_RESOURCE_URI } },
|
|
487
592
|
}, async ({ name }) => {
|
|
593
|
+
// Pure-TS fallback when CLI is not available
|
|
594
|
+
if (!(await isCliAvailable())) {
|
|
595
|
+
const profile = await reader.getPersonProfile(MEETINGS_DIR, name);
|
|
596
|
+
const sections = [];
|
|
597
|
+
if (profile.topics.length > 0)
|
|
598
|
+
sections.push("Topics: " + profile.topics.join(", "));
|
|
599
|
+
if (profile.meetings.length > 0) {
|
|
600
|
+
sections.push("Meetings:\n" + profile.meetings.map((m) => `- ${m.date} — ${m.title}`).join("\n"));
|
|
601
|
+
}
|
|
602
|
+
if (profile.openActions.length > 0) {
|
|
603
|
+
sections.push("Open actions:\n" + profile.openActions.map((a) => `- ${a.task} (${a.status})`).join("\n"));
|
|
604
|
+
}
|
|
605
|
+
const text = sections.length > 0 ? sections.join("\n\n") : `No profile data found for ${name}.`;
|
|
606
|
+
return {
|
|
607
|
+
content: [{ type: "text", text }],
|
|
608
|
+
structuredContent: { name, top_topics: profile.topics.map((t) => ({ topic: t, count: 1 })), open_intents: profile.openActions, recent_meetings: profile.meetings, view: "person" },
|
|
609
|
+
_meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
|
|
610
|
+
};
|
|
611
|
+
}
|
|
488
612
|
const { stdout, stderr } = await runMinutes(["person", name]);
|
|
489
613
|
const profile = parseJsonOutput(stdout);
|
|
490
614
|
if (!profile || typeof profile !== "object") {
|
|
@@ -537,6 +661,15 @@ server.tool("research_topic", "Research a topic across meetings, decisions, and
|
|
|
537
661
|
since: z.string().optional().describe("Only results after this date (ISO)"),
|
|
538
662
|
attendee: z.string().optional().describe("Filter by attendee / person"),
|
|
539
663
|
}, { title: "Research Topic", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ query, type: contentType, since, attendee }) => {
|
|
664
|
+
if (!(await isCliAvailable())) {
|
|
665
|
+
// Fallback: basic search when CLI is not available
|
|
666
|
+
const results = await reader.searchMeetings(MEETINGS_DIR, query, 20);
|
|
667
|
+
const filtered = contentType ? results.filter((m) => m.frontmatter.type === contentType) : results;
|
|
668
|
+
const text = filtered.length > 0
|
|
669
|
+
? filtered.map((m) => `${m.frontmatter.date} — ${m.frontmatter.title}\n ${m.path}`).join("\n\n")
|
|
670
|
+
: `No results for "${query}". (Note: advanced research features require the CLI.)`;
|
|
671
|
+
return { content: [{ type: "text", text }] };
|
|
672
|
+
}
|
|
540
673
|
const args = ["research", query];
|
|
541
674
|
if (contentType)
|
|
542
675
|
args.push("-t", contentType);
|
|
@@ -629,6 +762,9 @@ server.tool("process_audio", "Process an audio file through the transcription pi
|
|
|
629
762
|
type: z.enum(["meeting", "memo"]).optional().default("memo").describe("Content type"),
|
|
630
763
|
title: z.string().optional().describe("Optional title"),
|
|
631
764
|
}, { title: "Process Audio", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ file_path, type: contentType, title }) => {
|
|
765
|
+
if (!(await isCliAvailable())) {
|
|
766
|
+
return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
|
|
767
|
+
}
|
|
632
768
|
const allowedDirs = [
|
|
633
769
|
join(MINUTES_HOME, "inbox"),
|
|
634
770
|
MEETINGS_DIR,
|
|
@@ -667,6 +803,9 @@ server.tool("add_note", "Add a note to the current recording. Notes are timestam
|
|
|
667
803
|
.optional()
|
|
668
804
|
.describe("Path to an existing meeting file to annotate (for post-meeting notes)"),
|
|
669
805
|
}, { title: "Add Note", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ text, meeting_path }) => {
|
|
806
|
+
if (!(await isCliAvailable())) {
|
|
807
|
+
return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
|
|
808
|
+
}
|
|
670
809
|
try {
|
|
671
810
|
const args = ["note", text];
|
|
672
811
|
if (meeting_path) {
|
|
@@ -775,31 +914,121 @@ server.tool("register_qmd_collection", "Register the Minutes output directory as
|
|
|
775
914
|
});
|
|
776
915
|
// ── Resources ───────────────────────────────────────────────
|
|
777
916
|
server.resource("recent_meetings", "minutes://meetings/recent", { description: "List of recent meetings and memos" }, async () => {
|
|
917
|
+
if (!(await isCliAvailable())) {
|
|
918
|
+
const meetings = await reader.listMeetings(MEETINGS_DIR, 20);
|
|
919
|
+
const json = JSON.stringify(meetings.map((m) => ({
|
|
920
|
+
date: m.frontmatter.date, title: m.frontmatter.title,
|
|
921
|
+
content_type: m.frontmatter.type, path: m.path, duration: m.frontmatter.duration,
|
|
922
|
+
})));
|
|
923
|
+
return { contents: [{ uri: "minutes://meetings/recent", mimeType: "application/json", text: json }] };
|
|
924
|
+
}
|
|
778
925
|
const { stdout } = await runMinutes(["list", "--limit", "20"]);
|
|
779
926
|
return { contents: [{ uri: "minutes://meetings/recent", mimeType: "application/json", text: stdout }] };
|
|
780
927
|
});
|
|
781
928
|
server.resource("recording_status", "minutes://status", { description: "Current recording status" }, async () => {
|
|
929
|
+
if (!(await isCliAvailable())) {
|
|
930
|
+
return { contents: [{ uri: "minutes://status", mimeType: "application/json", text: JSON.stringify({ recording: false, processing: false, note: "Read-only mode (CLI not installed)" }) }] };
|
|
931
|
+
}
|
|
782
932
|
const { stdout } = await runMinutes(["status"]);
|
|
783
933
|
return { contents: [{ uri: "minutes://status", mimeType: "application/json", text: stdout }] };
|
|
784
934
|
});
|
|
785
935
|
server.resource("open_actions", "minutes://actions/open", { description: "All open action items across meetings" }, async () => {
|
|
936
|
+
if (!(await isCliAvailable())) {
|
|
937
|
+
const actions = await reader.findOpenActions(MEETINGS_DIR);
|
|
938
|
+
return { contents: [{ uri: "minutes://actions/open", mimeType: "application/json", text: JSON.stringify(actions) }] };
|
|
939
|
+
}
|
|
786
940
|
const { stdout } = await runMinutes(["search", "", "--intents-only", "--intent-kind", "action-item"]);
|
|
787
941
|
return { contents: [{ uri: "minutes://actions/open", mimeType: "application/json", text: stdout }] };
|
|
788
942
|
});
|
|
789
943
|
server.resource("recent_events", "minutes://events/recent", { description: "Recent pipeline events (recordings, processing, notes)" }, async () => {
|
|
944
|
+
if (!(await isCliAvailable())) {
|
|
945
|
+
return { contents: [{ uri: "minutes://events/recent", mimeType: "application/json", text: "[]" }] };
|
|
946
|
+
}
|
|
790
947
|
const { stdout } = await runMinutes(["events", "--limit", "20"]);
|
|
791
948
|
return { contents: [{ uri: "minutes://events/recent", mimeType: "application/json", text: stdout }] };
|
|
792
949
|
});
|
|
793
950
|
server.resource("meeting", new ResourceTemplate("minutes://meetings/{slug}", { list: undefined }), { description: "Get a specific meeting by its filename slug" }, async (uri, variables) => {
|
|
794
951
|
const slug = String(variables.slug);
|
|
952
|
+
if (!(await isCliAvailable())) {
|
|
953
|
+
// Without CLI resolve, find by filename match
|
|
954
|
+
const meetings = await reader.listMeetings(MEETINGS_DIR, 1000);
|
|
955
|
+
const match = meetings.find((m) => m.path.includes(slug));
|
|
956
|
+
if (match) {
|
|
957
|
+
const content = await readFile(match.path, "utf-8");
|
|
958
|
+
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: content }] };
|
|
959
|
+
}
|
|
960
|
+
return { contents: [{ uri: uri.href, mimeType: "text/plain", text: `Meeting not found: ${slug}` }] };
|
|
961
|
+
}
|
|
795
962
|
const { stdout } = await runMinutes(["resolve", slug]);
|
|
796
963
|
const parsed = parseJsonOutput(stdout);
|
|
797
964
|
if (parsed.path) {
|
|
798
|
-
const
|
|
965
|
+
const validated = validatePathInDirectory(parsed.path, MEETINGS_DIR, [".md"]);
|
|
966
|
+
const content = await readFile(validated, "utf-8");
|
|
799
967
|
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: content }] };
|
|
800
968
|
}
|
|
801
969
|
return { contents: [{ uri: uri.href, mimeType: "text/plain", text: `Meeting not found: ${slug}` }] };
|
|
802
970
|
});
|
|
971
|
+
// ── Tool: start_dictation ──────────────────────────────────
|
|
972
|
+
server.tool("start_dictation", "Start dictation mode. Speak naturally — text goes to clipboard and daily note after each pause. Runs until stop_dictation is called or silence timeout.", {}, { title: "Start Dictation", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async () => {
|
|
973
|
+
if (!(await isCliAvailable())) {
|
|
974
|
+
return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
|
|
975
|
+
}
|
|
976
|
+
const { stdout: statusOut } = await runMinutes(["status"]);
|
|
977
|
+
const status = parseJsonOutput(statusOut);
|
|
978
|
+
if (status.recording) {
|
|
979
|
+
return {
|
|
980
|
+
content: [
|
|
981
|
+
{
|
|
982
|
+
type: "text",
|
|
983
|
+
text: "Recording in progress — stop recording before dictating.",
|
|
984
|
+
},
|
|
985
|
+
],
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
// Spawn detached dictation process
|
|
989
|
+
const child = spawn(MINUTES_BIN, ["dictate"], {
|
|
990
|
+
detached: true,
|
|
991
|
+
stdio: "ignore",
|
|
992
|
+
env: { ...process.env, RUST_LOG: "info" },
|
|
993
|
+
});
|
|
994
|
+
child.unref();
|
|
995
|
+
// Wait briefly for startup
|
|
996
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
997
|
+
return {
|
|
998
|
+
content: [
|
|
999
|
+
{
|
|
1000
|
+
type: "text",
|
|
1001
|
+
text: "Dictation started. Speak naturally — text will be copied to clipboard after each pause. Say \"stop dictation\" when done.",
|
|
1002
|
+
},
|
|
1003
|
+
],
|
|
1004
|
+
};
|
|
1005
|
+
});
|
|
1006
|
+
// ── Tool: stop_dictation ───────────────────────────────────
|
|
1007
|
+
server.tool("stop_dictation", "Stop the current dictation session.", {}, { title: "Stop Dictation", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async () => {
|
|
1008
|
+
// Send stop signal by killing the dictation process via PID file
|
|
1009
|
+
const minutesDir = join(homedir(), ".minutes");
|
|
1010
|
+
const pidPath = join(minutesDir, "dictation.pid");
|
|
1011
|
+
if (existsSync(pidPath)) {
|
|
1012
|
+
try {
|
|
1013
|
+
const pidContent = await readFile(pidPath, "utf-8");
|
|
1014
|
+
const pid = parseInt(pidContent.trim(), 10);
|
|
1015
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
1016
|
+
process.kill(pid, "SIGTERM");
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
catch {
|
|
1020
|
+
// Process already dead or PID file invalid
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return {
|
|
1024
|
+
content: [
|
|
1025
|
+
{
|
|
1026
|
+
type: "text",
|
|
1027
|
+
text: "Dictation stop requested.",
|
|
1028
|
+
},
|
|
1029
|
+
],
|
|
1030
|
+
};
|
|
1031
|
+
});
|
|
803
1032
|
// ── Start server ────────────────────────────────────────────
|
|
804
1033
|
async function main() {
|
|
805
1034
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minutes-mcp",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "MCP server for minutes — conversation memory for AI assistants. Works with Claude Desktop, Cursor, Windsurf, and any MCP client.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist/",
|
|
12
|
-
"dist-ui/"
|
|
12
|
+
"dist-ui/",
|
|
13
|
+
"LICENSE"
|
|
13
14
|
],
|
|
14
15
|
"keywords": [
|
|
15
16
|
"mcp",
|
|
@@ -38,11 +39,11 @@
|
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
41
|
-
"
|
|
42
|
+
"@modelcontextprotocol/ext-apps": "^1.2.2",
|
|
43
|
+
"minutes-sdk": "^0.5.0",
|
|
42
44
|
"yaml": "^2.8.3"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
|
-
"@modelcontextprotocol/ext-apps": "^1.2.2",
|
|
46
47
|
"@types/node": "^22.0.0",
|
|
47
48
|
"tsx": "^4.0.0",
|
|
48
49
|
"typescript": "^5.5.0",
|