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.d.ts +23 -0
- package/dist/index.js +812 -0
- package/dist-ui/index.html +242 -0
- package/package.json +53 -0
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
|
+
});
|