fellow-mcp 1.0.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,1117 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { FellowDatabase } from "./database.js";
6
+ // Fellow API Client
7
+ class FellowClient {
8
+ apiKey;
9
+ baseUrl;
10
+ constructor(apiKey, subdomain) {
11
+ this.apiKey = apiKey;
12
+ this.baseUrl = `https://${subdomain}.fellow.app/api/v1`;
13
+ }
14
+ async request(method, endpoint, body) {
15
+ const url = `${this.baseUrl}${endpoint}`;
16
+ const options = {
17
+ method,
18
+ headers: {
19
+ "X-API-KEY": this.apiKey,
20
+ "Content-Type": "application/json",
21
+ },
22
+ };
23
+ if (body) {
24
+ options.body = JSON.stringify(body);
25
+ }
26
+ const response = await fetch(url, options);
27
+ if (!response.ok) {
28
+ const errorText = await response.text();
29
+ throw new Error(`Fellow API error (${response.status}): ${errorText}`);
30
+ }
31
+ return response.json();
32
+ }
33
+ async listRecordings(options) {
34
+ const body = {};
35
+ // Build filters
36
+ const filters = {};
37
+ if (options.title)
38
+ filters.title = options.title;
39
+ if (options.created_at_start)
40
+ filters.created_at_start = options.created_at_start;
41
+ if (options.created_at_end)
42
+ filters.created_at_end = options.created_at_end;
43
+ if (options.updated_at_start)
44
+ filters.updated_at_start = options.updated_at_start;
45
+ if (options.updated_at_end)
46
+ filters.updated_at_end = options.updated_at_end;
47
+ if (options.event_guid)
48
+ filters.event_guid = options.event_guid;
49
+ if (options.channel_id)
50
+ filters.channel_id = options.channel_id;
51
+ if (Object.keys(filters).length > 0) {
52
+ body.filters = filters;
53
+ }
54
+ // Build include
55
+ if (options.include_transcript) {
56
+ body.include = { transcript: true };
57
+ }
58
+ // Build pagination
59
+ body.pagination = {
60
+ cursor: options.cursor ?? null,
61
+ page_size: options.page_size ?? 20,
62
+ };
63
+ return this.request("POST", "/recordings", body);
64
+ }
65
+ async getRecording(recordingId) {
66
+ return this.request("GET", `/recording/${recordingId}`);
67
+ }
68
+ async listNotes(options) {
69
+ const body = {};
70
+ // Build filters
71
+ const filters = {};
72
+ if (options.title)
73
+ filters.title = options.title;
74
+ if (options.created_at_start)
75
+ filters.created_at_start = options.created_at_start;
76
+ if (options.created_at_end)
77
+ filters.created_at_end = options.created_at_end;
78
+ if (options.updated_at_start)
79
+ filters.updated_at_start = options.updated_at_start;
80
+ if (options.updated_at_end)
81
+ filters.updated_at_end = options.updated_at_end;
82
+ if (options.event_guid)
83
+ filters.event_guid = options.event_guid;
84
+ if (options.channel_id)
85
+ filters.channel_id = options.channel_id;
86
+ if (Object.keys(filters).length > 0) {
87
+ body.filters = filters;
88
+ }
89
+ // Build include
90
+ const include = {};
91
+ if (options.include_content)
92
+ include.content_markdown = true;
93
+ if (options.include_attendees)
94
+ include.event_attendees = true;
95
+ if (Object.keys(include).length > 0) {
96
+ body.include = include;
97
+ }
98
+ // Build pagination
99
+ body.pagination = {
100
+ cursor: options.cursor ?? null,
101
+ page_size: options.page_size ?? 20,
102
+ };
103
+ return this.request("POST", "/notes", body);
104
+ }
105
+ async getNote(noteId) {
106
+ return this.request("GET", `/note/${noteId}`);
107
+ }
108
+ }
109
+ // Tool definitions
110
+ const tools = [
111
+ {
112
+ name: "search_meetings",
113
+ description: "Search for meetings/recordings in Fellow. Can filter by title, date range, or event ID. Returns a list of meetings with basic metadata.",
114
+ inputSchema: {
115
+ type: "object",
116
+ properties: {
117
+ title: {
118
+ type: "string",
119
+ description: "Filter by meeting title (case-insensitive partial match)",
120
+ },
121
+ created_at_start: {
122
+ type: "string",
123
+ description: "Filter meetings created after this date (ISO format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)",
124
+ },
125
+ created_at_end: {
126
+ type: "string",
127
+ description: "Filter meetings created before this date (ISO format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)",
128
+ },
129
+ limit: {
130
+ type: "number",
131
+ description: "Maximum number of results to return (1-50, default 20)",
132
+ },
133
+ },
134
+ },
135
+ },
136
+ {
137
+ name: "get_meeting_transcript",
138
+ description: "Get the full transcript of a meeting recording. Returns diarized (speaker-labeled) and timestamped transcript segments.",
139
+ inputSchema: {
140
+ type: "object",
141
+ properties: {
142
+ recording_id: {
143
+ type: "string",
144
+ description: "The ID of the recording to get the transcript for",
145
+ },
146
+ meeting_title: {
147
+ type: "string",
148
+ description: "Alternatively, search by meeting title to find and return the transcript",
149
+ },
150
+ },
151
+ },
152
+ },
153
+ {
154
+ name: "get_meeting_summary",
155
+ description: "Get the meeting summary/notes content. Returns the structured notes including agenda items, discussion topics, and decisions made.",
156
+ inputSchema: {
157
+ type: "object",
158
+ properties: {
159
+ note_id: {
160
+ type: "string",
161
+ description: "The ID of the note to get the summary for",
162
+ },
163
+ recording_id: {
164
+ type: "string",
165
+ description: "Alternatively, provide a recording ID to get its associated note/summary",
166
+ },
167
+ meeting_title: {
168
+ type: "string",
169
+ description: "Alternatively, search by meeting title to find and return the summary",
170
+ },
171
+ },
172
+ },
173
+ },
174
+ {
175
+ name: "get_action_items",
176
+ description: "Get action items from a meeting. Extracts action items from the meeting notes content.",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {
180
+ note_id: {
181
+ type: "string",
182
+ description: "The ID of the note to get action items from",
183
+ },
184
+ meeting_title: {
185
+ type: "string",
186
+ description: "Alternatively, search by meeting title to find and return action items",
187
+ },
188
+ },
189
+ },
190
+ },
191
+ {
192
+ name: "get_meeting_participants",
193
+ description: "Get the list of participants/attendees for a meeting. Returns email addresses of people who were invited to the calendar event.",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {
197
+ note_id: {
198
+ type: "string",
199
+ description: "The ID of the note to get participants for",
200
+ },
201
+ meeting_title: {
202
+ type: "string",
203
+ description: "Alternatively, search by meeting title to find and return participants",
204
+ },
205
+ },
206
+ },
207
+ },
208
+ {
209
+ name: "sync_meetings",
210
+ description: "Sync meetings from Fellow API to local database. By default does incremental sync (only new/updated since last sync). Use force=true for full re-sync.",
211
+ inputSchema: {
212
+ type: "object",
213
+ properties: {
214
+ force: {
215
+ type: "boolean",
216
+ description: "If true, performs a full sync clearing and re-fetching all data. Default is false (incremental).",
217
+ },
218
+ include_transcripts: {
219
+ type: "boolean",
220
+ description: "If true, also fetches and stores transcripts. This is slower but enables local transcript search.",
221
+ },
222
+ },
223
+ },
224
+ },
225
+ {
226
+ name: "get_all_action_items",
227
+ description: "Get all action items from the local database. Automatically performs incremental sync first to ensure data is fresh. Can filter by assignee, completion status, or date range.",
228
+ inputSchema: {
229
+ type: "object",
230
+ properties: {
231
+ assignee: {
232
+ type: "string",
233
+ description: "Filter by assignee name (partial match)",
234
+ },
235
+ show_completed: {
236
+ type: "boolean",
237
+ description: "If true, includes completed action items. Default is false (only incomplete).",
238
+ },
239
+ since: {
240
+ type: "string",
241
+ description: "Only return action items from meetings on or after this date (ISO format: YYYY-MM-DD)",
242
+ },
243
+ },
244
+ },
245
+ },
246
+ {
247
+ name: "get_meetings_by_participants",
248
+ description: "Find meetings that included specific participants. Searches the local database.",
249
+ inputSchema: {
250
+ type: "object",
251
+ properties: {
252
+ emails: {
253
+ type: "array",
254
+ items: { type: "string" },
255
+ description: "List of email addresses to search for",
256
+ },
257
+ require_all: {
258
+ type: "boolean",
259
+ description: "If true, only return meetings where ALL specified participants attended. Default is false (any match).",
260
+ },
261
+ },
262
+ required: ["emails"],
263
+ },
264
+ },
265
+ {
266
+ name: "search_cached_notes",
267
+ description: "Full-text search across all cached meeting notes. Searches titles and content.",
268
+ inputSchema: {
269
+ type: "object",
270
+ properties: {
271
+ query: {
272
+ type: "string",
273
+ description: "Search query to find in meeting titles or content",
274
+ },
275
+ },
276
+ required: ["query"],
277
+ },
278
+ },
279
+ {
280
+ name: "get_sync_status",
281
+ description: "Get the current sync status and database statistics.",
282
+ inputSchema: {
283
+ type: "object",
284
+ properties: {},
285
+ },
286
+ },
287
+ ];
288
+ // Initialize server
289
+ const server = new Server({
290
+ name: "fellow-mcp",
291
+ version: "1.0.0",
292
+ }, {
293
+ capabilities: {
294
+ tools: {},
295
+ },
296
+ });
297
+ // Parse command line arguments
298
+ function parseArgs() {
299
+ const args = process.argv.slice(2);
300
+ let apiKey;
301
+ let subdomain;
302
+ for (let i = 0; i < args.length; i++) {
303
+ if (args[i] === "--api-key" && args[i + 1]) {
304
+ apiKey = args[i + 1];
305
+ i++;
306
+ }
307
+ else if (args[i] === "--subdomain" && args[i + 1]) {
308
+ subdomain = args[i + 1];
309
+ i++;
310
+ }
311
+ else if (args[i].startsWith("--api-key=")) {
312
+ apiKey = args[i].split("=")[1];
313
+ }
314
+ else if (args[i].startsWith("--subdomain=")) {
315
+ subdomain = args[i].split("=")[1];
316
+ }
317
+ }
318
+ // Fall back to environment variables
319
+ apiKey = apiKey ?? process.env.FELLOW_API_KEY;
320
+ subdomain = subdomain ?? process.env.FELLOW_SUBDOMAIN;
321
+ if (!apiKey) {
322
+ throw new Error("API key required: use --api-key <key> or set FELLOW_API_KEY env var");
323
+ }
324
+ if (!subdomain) {
325
+ throw new Error("Subdomain required: use --subdomain <subdomain> or set FELLOW_SUBDOMAIN env var");
326
+ }
327
+ return { apiKey, subdomain };
328
+ }
329
+ // Get configuration from args or environment
330
+ let cachedClient = null;
331
+ let cachedDb = null;
332
+ function getClient() {
333
+ if (!cachedClient) {
334
+ const { apiKey, subdomain } = parseArgs();
335
+ cachedClient = new FellowClient(apiKey, subdomain);
336
+ }
337
+ return cachedClient;
338
+ }
339
+ function getDatabase() {
340
+ if (!cachedDb) {
341
+ cachedDb = new FellowDatabase();
342
+ }
343
+ return cachedDb;
344
+ }
345
+ async function syncNotesFromApi(client, db, options = {}) {
346
+ const result = {
347
+ notes_synced: 0,
348
+ recordings_synced: 0,
349
+ action_items_found: 0,
350
+ participants_synced: 0,
351
+ };
352
+ let cursor = null;
353
+ const pageSize = 50;
354
+ // Fetch notes with content and attendees
355
+ do {
356
+ const notesResp = await client.listNotes({
357
+ updated_at_start: options.since,
358
+ include_content: true,
359
+ include_attendees: true,
360
+ cursor: cursor ?? undefined,
361
+ page_size: pageSize,
362
+ });
363
+ for (const note of notesResp.notes.data) {
364
+ // Store note
365
+ db.upsertNote({
366
+ id: note.id,
367
+ title: note.title,
368
+ created_at: note.created_at,
369
+ updated_at: note.updated_at,
370
+ event_start: note.event_start ?? null,
371
+ event_end: note.event_end ?? null,
372
+ event_guid: note.event_guid ?? null,
373
+ call_url: note.call_url ?? null,
374
+ content_markdown: note.content_markdown ?? null,
375
+ });
376
+ result.notes_synced++;
377
+ // Extract and store action items
378
+ if (note.content_markdown) {
379
+ db.clearActionItemsForNote(note.id);
380
+ const actionItems = extractActionItems(note.content_markdown);
381
+ for (const item of actionItems) {
382
+ db.insertActionItem({
383
+ note_id: note.id,
384
+ content: item.content,
385
+ assignee: item.assignee,
386
+ due_date: item.due_date,
387
+ is_completed: item.is_completed,
388
+ created_at: new Date().toISOString(),
389
+ });
390
+ result.action_items_found++;
391
+ }
392
+ }
393
+ // Store participants
394
+ if (note.event_attendees && note.event_attendees.length > 0) {
395
+ db.clearParticipantsForNote(note.id);
396
+ for (const email of note.event_attendees) {
397
+ if (email && typeof email === "string" && email.trim()) {
398
+ db.insertParticipant(note.id, email.trim());
399
+ result.participants_synced++;
400
+ }
401
+ }
402
+ }
403
+ }
404
+ cursor = notesResp.notes.page_info.cursor;
405
+ } while (cursor);
406
+ // Fetch recordings (optionally with transcripts)
407
+ cursor = null;
408
+ do {
409
+ const recordingsResp = await client.listRecordings({
410
+ updated_at_start: options.since,
411
+ include_transcript: options.includeTranscripts ?? false,
412
+ cursor: cursor ?? undefined,
413
+ page_size: pageSize,
414
+ });
415
+ for (const recording of recordingsResp.recordings.data) {
416
+ // Skip if note doesn't exist in DB (can happen with incremental sync)
417
+ if (recording.note_id && !db.getNote(recording.note_id)) {
418
+ continue;
419
+ }
420
+ db.upsertRecording({
421
+ id: recording.id,
422
+ note_id: recording.note_id,
423
+ title: recording.title,
424
+ created_at: recording.created_at,
425
+ updated_at: recording.updated_at,
426
+ event_start: recording.event_start ?? null,
427
+ event_end: recording.event_end ?? null,
428
+ recording_start: recording.recording_start ?? null,
429
+ recording_end: recording.recording_end ?? null,
430
+ event_guid: recording.event_guid ?? null,
431
+ call_url: recording.call_url ?? null,
432
+ transcript_json: recording.transcript ? JSON.stringify(recording.transcript) : null,
433
+ });
434
+ result.recordings_synced++;
435
+ }
436
+ cursor = recordingsResp.recordings.page_info.cursor;
437
+ } while (cursor);
438
+ // Update last sync time
439
+ db.setLastSyncTime(new Date().toISOString());
440
+ return result;
441
+ }
442
+ async function performIncrementalSync(client, db) {
443
+ const lastSync = db.getLastSyncTime();
444
+ if (!lastSync) {
445
+ // No previous sync, do a full sync
446
+ return syncNotesFromApi(client, db);
447
+ }
448
+ // Sync only notes updated since last sync
449
+ return syncNotesFromApi(client, db, { since: lastSync });
450
+ }
451
+ function extractActionItems(content) {
452
+ const actionItems = [];
453
+ const lines = content.split("\n");
454
+ for (const line of lines) {
455
+ // Match checkbox items: - [ ] or - [x] or * [ ] or * [x]
456
+ const checkboxMatch = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.+)/);
457
+ if (checkboxMatch) {
458
+ const isCompleted = checkboxMatch[1].toLowerCase() === "x";
459
+ const itemContent = checkboxMatch[2].trim();
460
+ const { assignee, dueDate } = parseAssigneeAndDueDate(itemContent);
461
+ actionItems.push({
462
+ content: itemContent,
463
+ assignee,
464
+ due_date: dueDate,
465
+ is_completed: isCompleted,
466
+ });
467
+ continue;
468
+ }
469
+ // Match "Action Item:" or "Action:" or "TODO:" patterns
470
+ const actionMatch = line.match(/^\s*[-*]?\s*(?:Action\s*Item|Action|TODO|To-Do|To Do)\s*:\s*(.+)/i);
471
+ if (actionMatch) {
472
+ const itemContent = actionMatch[1].trim();
473
+ const { assignee, dueDate } = parseAssigneeAndDueDate(itemContent);
474
+ actionItems.push({
475
+ content: itemContent,
476
+ assignee,
477
+ due_date: dueDate,
478
+ is_completed: false,
479
+ });
480
+ continue;
481
+ }
482
+ // Match items with @mentions at the start (common Fellow pattern)
483
+ const mentionMatch = line.match(/^\s*[-*]\s*(@\w+[\w\s]*?)\s*[-:]\s*(.+)/);
484
+ if (mentionMatch) {
485
+ const assignee = mentionMatch[1].replace("@", "").trim();
486
+ const itemContent = mentionMatch[2].trim();
487
+ const { dueDate } = parseAssigneeAndDueDate(itemContent);
488
+ actionItems.push({
489
+ content: `@${assignee}: ${itemContent}`,
490
+ assignee,
491
+ due_date: dueDate,
492
+ is_completed: false,
493
+ });
494
+ }
495
+ }
496
+ return actionItems;
497
+ }
498
+ function parseAssigneeAndDueDate(text) {
499
+ let assignee = null;
500
+ let dueDate = null;
501
+ // Extract @mentions for assignee
502
+ const mentionMatch = text.match(/@(\w+)/);
503
+ if (mentionMatch) {
504
+ assignee = mentionMatch[1];
505
+ }
506
+ // Extract due dates in various formats
507
+ // ISO format: 2024-01-15
508
+ const isoDateMatch = text.match(/(?:due|by|deadline)\s*:?\s*(\d{4}-\d{2}-\d{2})/i);
509
+ if (isoDateMatch) {
510
+ dueDate = isoDateMatch[1];
511
+ }
512
+ // US format: 01/15/2024 or 1/15/24
513
+ const usDateMatch = text.match(/(?:due|by|deadline)\s*:?\s*(\d{1,2}\/\d{1,2}\/\d{2,4})/i);
514
+ if (!dueDate && usDateMatch) {
515
+ const parts = usDateMatch[1].split("/");
516
+ const month = parts[0].padStart(2, "0");
517
+ const day = parts[1].padStart(2, "0");
518
+ let year = parts[2];
519
+ if (year.length === 2) {
520
+ year = "20" + year;
521
+ }
522
+ dueDate = `${year}-${month}-${day}`;
523
+ }
524
+ return { assignee, dueDate };
525
+ }
526
+ // Format transcript for output
527
+ function formatTranscript(transcript) {
528
+ if (!transcript.speech_segments || transcript.speech_segments.length === 0) {
529
+ return "No transcript available.";
530
+ }
531
+ let output = `Language: ${transcript.language_code}\n\n`;
532
+ for (const segment of transcript.speech_segments) {
533
+ const startTime = formatTime(segment.start_time);
534
+ const endTime = formatTime(segment.end_time);
535
+ output += `[${startTime} - ${endTime}] ${segment.speaker}: ${segment.text}\n`;
536
+ }
537
+ return output;
538
+ }
539
+ function formatTime(seconds) {
540
+ const mins = Math.floor(seconds / 60);
541
+ const secs = Math.floor(seconds % 60);
542
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
543
+ }
544
+ // Handle tool calls
545
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
546
+ return { tools };
547
+ });
548
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
549
+ const { name, arguments: args } = request.params;
550
+ const client = getClient();
551
+ try {
552
+ switch (name) {
553
+ case "search_meetings": {
554
+ const { title, created_at_start, created_at_end, limit } = args;
555
+ const recordingsResp = await client.listRecordings({
556
+ title,
557
+ created_at_start,
558
+ created_at_end,
559
+ page_size: Math.min(limit ?? 20, 50),
560
+ });
561
+ const results = recordingsResp.recordings.data.map((r) => ({
562
+ id: r.id,
563
+ title: r.title,
564
+ note_id: r.note_id,
565
+ event_start: r.event_start,
566
+ event_end: r.event_end,
567
+ created_at: r.created_at,
568
+ call_url: r.call_url,
569
+ }));
570
+ return {
571
+ content: [
572
+ {
573
+ type: "text",
574
+ text: JSON.stringify({
575
+ total_results: results.length,
576
+ has_more: recordingsResp.recordings.page_info.cursor !== null,
577
+ meetings: results,
578
+ }, null, 2),
579
+ },
580
+ ],
581
+ };
582
+ }
583
+ case "get_meeting_transcript": {
584
+ const { recording_id, meeting_title } = args;
585
+ let recordingWithTranscript = null;
586
+ if (recording_id) {
587
+ // Get the specific recording with transcript
588
+ const recordingsResp = await client.listRecordings({
589
+ include_transcript: true,
590
+ page_size: 50,
591
+ });
592
+ recordingWithTranscript =
593
+ recordingsResp.recordings.data.find((r) => r.id === recording_id) ?? null;
594
+ if (!recordingWithTranscript) {
595
+ // Try fetching all to find it
596
+ const allRecordingsResp = await client.listRecordings({
597
+ include_transcript: true,
598
+ page_size: 50,
599
+ });
600
+ recordingWithTranscript =
601
+ allRecordingsResp.recordings.data.find((r) => r.id === recording_id) ?? null;
602
+ }
603
+ }
604
+ else if (meeting_title) {
605
+ // Search by title and get transcript
606
+ const recordingsResp = await client.listRecordings({
607
+ title: meeting_title,
608
+ include_transcript: true,
609
+ page_size: 1,
610
+ });
611
+ recordingWithTranscript = recordingsResp.recordings.data[0] ?? null;
612
+ }
613
+ if (!recordingWithTranscript) {
614
+ return {
615
+ content: [
616
+ {
617
+ type: "text",
618
+ text: "Recording not found. Please provide a valid recording_id or meeting_title.",
619
+ },
620
+ ],
621
+ };
622
+ }
623
+ const transcriptText = recordingWithTranscript.transcript
624
+ ? formatTranscript(recordingWithTranscript.transcript)
625
+ : "No transcript available for this recording.";
626
+ return {
627
+ content: [
628
+ {
629
+ type: "text",
630
+ text: `# Transcript: ${recordingWithTranscript.title}\n\nRecording ID: ${recordingWithTranscript.id}\nEvent Start: ${recordingWithTranscript.event_start ?? "N/A"}\n\n${transcriptText}`,
631
+ },
632
+ ],
633
+ };
634
+ }
635
+ case "get_meeting_summary": {
636
+ const { note_id, recording_id, meeting_title } = args;
637
+ let noteId = note_id;
638
+ // If recording_id provided, get the associated note_id
639
+ if (!noteId && recording_id) {
640
+ const recordingsResp = await client.listRecordings({ page_size: 50 });
641
+ const recording = recordingsResp.recordings.data.find((r) => r.id === recording_id);
642
+ if (recording) {
643
+ noteId = recording.note_id;
644
+ }
645
+ }
646
+ // If meeting_title provided, search for the note
647
+ if (!noteId && meeting_title) {
648
+ const notesResp = await client.listNotes({
649
+ title: meeting_title,
650
+ include_content: true,
651
+ page_size: 1,
652
+ });
653
+ if (notesResp.notes.data.length > 0) {
654
+ const note = notesResp.notes.data[0];
655
+ return {
656
+ content: [
657
+ {
658
+ type: "text",
659
+ text: `# Meeting Summary: ${note.title}\n\nNote ID: ${note.id}\nEvent Start: ${note.event_start ?? "N/A"}\n\n${note.content_markdown ?? "No content available."}`,
660
+ },
661
+ ],
662
+ };
663
+ }
664
+ }
665
+ if (!noteId) {
666
+ return {
667
+ content: [
668
+ {
669
+ type: "text",
670
+ text: "Note not found. Please provide a valid note_id, recording_id, or meeting_title.",
671
+ },
672
+ ],
673
+ };
674
+ }
675
+ // Get note with content
676
+ const notesResp = await client.listNotes({
677
+ include_content: true,
678
+ page_size: 50,
679
+ });
680
+ const note = notesResp.notes.data.find((n) => n.id === noteId);
681
+ if (!note) {
682
+ return {
683
+ content: [
684
+ {
685
+ type: "text",
686
+ text: `Note with ID ${noteId} not found or not accessible.`,
687
+ },
688
+ ],
689
+ };
690
+ }
691
+ return {
692
+ content: [
693
+ {
694
+ type: "text",
695
+ text: `# Meeting Summary: ${note.title}\n\nNote ID: ${note.id}\nEvent Start: ${note.event_start ?? "N/A"}\n\n${note.content_markdown ?? "No content available."}`,
696
+ },
697
+ ],
698
+ };
699
+ }
700
+ case "get_action_items": {
701
+ const { note_id, meeting_title } = args;
702
+ let note = null;
703
+ if (note_id) {
704
+ const notesResp = await client.listNotes({
705
+ include_content: true,
706
+ page_size: 50,
707
+ });
708
+ note = notesResp.notes.data.find((n) => n.id === note_id) ?? null;
709
+ }
710
+ else if (meeting_title) {
711
+ const notesResp = await client.listNotes({
712
+ title: meeting_title,
713
+ include_content: true,
714
+ page_size: 1,
715
+ });
716
+ note = notesResp.notes.data[0] ?? null;
717
+ }
718
+ if (!note) {
719
+ return {
720
+ content: [
721
+ {
722
+ type: "text",
723
+ text: "Note not found. Please provide a valid note_id or meeting_title.",
724
+ },
725
+ ],
726
+ };
727
+ }
728
+ const actionItems = note.content_markdown
729
+ ? extractActionItems(note.content_markdown)
730
+ : [];
731
+ const formattedItems = actionItems.map((item, i) => {
732
+ let line = `${i + 1}. ${item.is_completed ? "[x]" : "[ ]"} ${item.content}`;
733
+ if (item.assignee)
734
+ line += ` (assignee: @${item.assignee})`;
735
+ if (item.due_date)
736
+ line += ` (due: ${item.due_date})`;
737
+ return line;
738
+ });
739
+ return {
740
+ content: [
741
+ {
742
+ type: "text",
743
+ text: `# Action Items: ${note.title}\n\nNote ID: ${note.id}\nEvent Start: ${note.event_start ?? "N/A"}\n\n${formattedItems.length > 0
744
+ ? formattedItems.join("\n")
745
+ : "No action items found in this meeting."}`,
746
+ },
747
+ ],
748
+ };
749
+ }
750
+ case "get_meeting_participants": {
751
+ const { note_id, meeting_title } = args;
752
+ let note = null;
753
+ if (note_id) {
754
+ const notesResp = await client.listNotes({
755
+ include_attendees: true,
756
+ page_size: 50,
757
+ });
758
+ note = notesResp.notes.data.find((n) => n.id === note_id) ?? null;
759
+ }
760
+ else if (meeting_title) {
761
+ const notesResp = await client.listNotes({
762
+ title: meeting_title,
763
+ include_attendees: true,
764
+ page_size: 1,
765
+ });
766
+ note = notesResp.notes.data[0] ?? null;
767
+ }
768
+ if (!note) {
769
+ return {
770
+ content: [
771
+ {
772
+ type: "text",
773
+ text: "Note not found. Please provide a valid note_id or meeting_title.",
774
+ },
775
+ ],
776
+ };
777
+ }
778
+ const attendees = note.event_attendees ?? [];
779
+ return {
780
+ content: [
781
+ {
782
+ type: "text",
783
+ text: `# Participants: ${note.title}\n\nNote ID: ${note.id}\nEvent Start: ${note.event_start ?? "N/A"}\n\n${attendees.length > 0
784
+ ? `Total participants: ${attendees.length}\n\n${attendees.map((email) => `- ${email}`).join("\n")}`
785
+ : "No participant information available for this meeting."}`,
786
+ },
787
+ ],
788
+ };
789
+ }
790
+ case "sync_meetings": {
791
+ const { force, include_transcripts } = args;
792
+ const db = getDatabase();
793
+ let result;
794
+ if (force) {
795
+ // Full sync - clear existing data first
796
+ // Note: We don't have a clearAll method, but the upserts will update existing records
797
+ // and we clear action items/participants per-note during sync
798
+ result = await syncNotesFromApi(client, db, { includeTranscripts: include_transcripts });
799
+ }
800
+ else {
801
+ // Incremental sync
802
+ const syncResult = await performIncrementalSync(client, db);
803
+ result = syncResult ?? { notes_synced: 0, recordings_synced: 0, action_items_found: 0, participants_synced: 0 };
804
+ }
805
+ const stats = db.getStats();
806
+ return {
807
+ content: [
808
+ {
809
+ type: "text",
810
+ text: `# Sync Complete\n\nMode: ${force ? "Full" : "Incremental"}\n\n## This Sync:\n- Notes synced: ${result.notes_synced}\n- Recordings synced: ${result.recordings_synced}\n- Action items found: ${result.action_items_found}\n- Participants synced: ${result.participants_synced}\n\n## Database Totals:\n- Total notes: ${stats.notes}\n- Total recordings: ${stats.recordings}\n- Total action items: ${stats.action_items}\n- Unique participants: ${stats.participants}\n\nLast sync: ${db.getLastSyncTime()}`,
811
+ },
812
+ ],
813
+ };
814
+ }
815
+ case "get_all_action_items": {
816
+ const { assignee, show_completed, since } = args;
817
+ const db = getDatabase();
818
+ // Perform incremental sync first to ensure fresh data
819
+ let syncError = null;
820
+ let syncResult = null;
821
+ try {
822
+ syncResult = await performIncrementalSync(client, db);
823
+ }
824
+ catch (err) {
825
+ syncError = err instanceof Error ? err.message : String(err);
826
+ console.error("Incremental sync failed:", err);
827
+ }
828
+ const actionItems = db.getAllActionItems({
829
+ assignee,
830
+ is_completed: show_completed ? undefined : false,
831
+ since,
832
+ });
833
+ if (actionItems.length === 0) {
834
+ let msg = "No action items found matching the criteria.";
835
+ if (syncError) {
836
+ msg += `\n\n⚠️ Sync error: ${syncError}`;
837
+ }
838
+ else if (syncResult) {
839
+ msg += `\n\nSync completed: ${syncResult.notes_synced} notes, ${syncResult.action_items_found} action items found.`;
840
+ }
841
+ const stats = db.getStats();
842
+ msg += `\n\nDB stats: ${stats.notes} notes, ${stats.action_items} action items total.`;
843
+ return {
844
+ content: [
845
+ {
846
+ type: "text",
847
+ text: msg,
848
+ },
849
+ ],
850
+ };
851
+ }
852
+ // Group by meeting
853
+ const byMeeting = new Map();
854
+ for (const item of actionItems) {
855
+ const key = item.note_id;
856
+ if (!byMeeting.has(key)) {
857
+ byMeeting.set(key, []);
858
+ }
859
+ byMeeting.get(key).push(item);
860
+ }
861
+ let output = `# All Action Items\n\nTotal: ${actionItems.length} items from ${byMeeting.size} meetings\n`;
862
+ if (assignee)
863
+ output += `Filtered by assignee: ${assignee}\n`;
864
+ if (since)
865
+ output += `Since: ${since}\n`;
866
+ output += `Showing: ${show_completed ? "all" : "incomplete only"}\n\n`;
867
+ for (const [noteId, items] of byMeeting) {
868
+ const firstItem = items[0];
869
+ output += `## ${firstItem.note_title}\n`;
870
+ output += `Date: ${firstItem.event_start ?? "N/A"}\n\n`;
871
+ for (const item of items) {
872
+ output += `- ${item.is_completed ? "[x]" : "[ ]"} ${item.content}`;
873
+ if (item.assignee)
874
+ output += ` (@${item.assignee})`;
875
+ if (item.due_date)
876
+ output += ` [due: ${item.due_date}]`;
877
+ output += "\n";
878
+ }
879
+ output += "\n";
880
+ }
881
+ return {
882
+ content: [
883
+ {
884
+ type: "text",
885
+ text: output,
886
+ },
887
+ ],
888
+ };
889
+ }
890
+ case "get_meetings_by_participants": {
891
+ const { emails, require_all } = args;
892
+ if (!emails || emails.length === 0) {
893
+ return {
894
+ content: [
895
+ {
896
+ type: "text",
897
+ text: "Please provide at least one email address.",
898
+ },
899
+ ],
900
+ };
901
+ }
902
+ const db = getDatabase();
903
+ const meetings = require_all
904
+ ? db.getMeetingsWithAllParticipants(emails)
905
+ : db.getMeetingsByParticipants(emails);
906
+ if (meetings.length === 0) {
907
+ return {
908
+ content: [
909
+ {
910
+ type: "text",
911
+ text: `No meetings found with ${require_all ? "all of" : "any of"}: ${emails.join(", ")}`,
912
+ },
913
+ ],
914
+ };
915
+ }
916
+ let output = `# Meetings with ${require_all ? "all of" : "any of"}: ${emails.join(", ")}\n\n`;
917
+ output += `Found ${meetings.length} meetings:\n\n`;
918
+ for (const meeting of meetings) {
919
+ const participants = db.getParticipantsForNote(meeting.id);
920
+ output += `## ${meeting.title}\n`;
921
+ output += `- Date: ${meeting.event_start ?? "N/A"}\n`;
922
+ output += `- Note ID: ${meeting.id}\n`;
923
+ output += `- Participants: ${participants.length}\n`;
924
+ output += "\n";
925
+ }
926
+ return {
927
+ content: [
928
+ {
929
+ type: "text",
930
+ text: output,
931
+ },
932
+ ],
933
+ };
934
+ }
935
+ case "search_cached_notes": {
936
+ const { query } = args;
937
+ if (!query || query.trim().length === 0) {
938
+ return {
939
+ content: [
940
+ {
941
+ type: "text",
942
+ text: "Please provide a search query.",
943
+ },
944
+ ],
945
+ };
946
+ }
947
+ const db = getDatabase();
948
+ const notes = db.searchNotes(query);
949
+ if (notes.length === 0) {
950
+ return {
951
+ content: [
952
+ {
953
+ type: "text",
954
+ text: `No meetings found matching: "${query}"`,
955
+ },
956
+ ],
957
+ };
958
+ }
959
+ let output = `# Search Results for: "${query}"\n\n`;
960
+ output += `Found ${notes.length} meetings:\n\n`;
961
+ for (const note of notes) {
962
+ output += `## ${note.title}\n`;
963
+ output += `- Date: ${note.event_start ?? "N/A"}\n`;
964
+ output += `- Note ID: ${note.id}\n`;
965
+ // Show a snippet of matching content
966
+ if (note.content_markdown) {
967
+ const lowerContent = note.content_markdown.toLowerCase();
968
+ const lowerQuery = query.toLowerCase();
969
+ const matchIndex = lowerContent.indexOf(lowerQuery);
970
+ if (matchIndex !== -1) {
971
+ const start = Math.max(0, matchIndex - 50);
972
+ const end = Math.min(note.content_markdown.length, matchIndex + query.length + 50);
973
+ let snippet = note.content_markdown.substring(start, end);
974
+ if (start > 0)
975
+ snippet = "..." + snippet;
976
+ if (end < note.content_markdown.length)
977
+ snippet = snippet + "...";
978
+ output += `- Snippet: ${snippet.replace(/\n/g, " ")}\n`;
979
+ }
980
+ }
981
+ output += "\n";
982
+ }
983
+ return {
984
+ content: [
985
+ {
986
+ type: "text",
987
+ text: output,
988
+ },
989
+ ],
990
+ };
991
+ }
992
+ case "get_sync_status": {
993
+ const db = getDatabase();
994
+ const stats = db.getStats();
995
+ const lastSync = db.getLastSyncTime();
996
+ return {
997
+ content: [
998
+ {
999
+ type: "text",
1000
+ text: `# Sync Status\n\nLast sync: ${lastSync ?? "Never"}\n\n## Database Statistics:\n- Total notes: ${stats.notes}\n- Total recordings: ${stats.recordings}\n- Total action items: ${stats.action_items}\n- Unique participants: ${stats.participants}\n\n## Database Location:\n~/.fellow-mcp/fellow.db`,
1001
+ },
1002
+ ],
1003
+ };
1004
+ }
1005
+ default:
1006
+ return {
1007
+ content: [
1008
+ {
1009
+ type: "text",
1010
+ text: `Unknown tool: ${name}`,
1011
+ },
1012
+ ],
1013
+ isError: true,
1014
+ };
1015
+ }
1016
+ }
1017
+ catch (error) {
1018
+ const errorMessage = error instanceof Error ? error.message : String(error);
1019
+ return {
1020
+ content: [
1021
+ {
1022
+ type: "text",
1023
+ text: `Error: ${errorMessage}`,
1024
+ },
1025
+ ],
1026
+ isError: true,
1027
+ };
1028
+ }
1029
+ });
1030
+ // Start server
1031
+ async function main() {
1032
+ // Check for --test flag for CLI debugging
1033
+ if (process.argv.includes("--test")) {
1034
+ await runTest();
1035
+ return;
1036
+ }
1037
+ const transport = new StdioServerTransport();
1038
+ await server.connect(transport);
1039
+ console.error("Fellow MCP server started");
1040
+ }
1041
+ // CLI test mode
1042
+ async function runTest() {
1043
+ console.log("=== Fellow MCP Test Mode ===\n");
1044
+ try {
1045
+ const client = getClient();
1046
+ const db = getDatabase();
1047
+ // 1. Show sync status
1048
+ const lastSync = db.getLastSyncTime();
1049
+ const stats = db.getStats();
1050
+ console.log("Current DB status:");
1051
+ console.log(` Last sync: ${lastSync ?? "Never"}`);
1052
+ console.log(` Notes: ${stats.notes}`);
1053
+ console.log(` Action items: ${stats.action_items}`);
1054
+ console.log("");
1055
+ // 2. Try to sync (but don't fail if API errors)
1056
+ console.log("Syncing notes from API...");
1057
+ try {
1058
+ const syncResult = await syncNotesFromApi(client, db);
1059
+ console.log(` Notes synced: ${syncResult.notes_synced}`);
1060
+ console.log(` Action items found: ${syncResult.action_items_found}`);
1061
+ }
1062
+ catch (syncErr) {
1063
+ console.log(` Sync failed: ${syncErr instanceof Error ? syncErr.message : syncErr}`);
1064
+ console.log(" (continuing with cached data)");
1065
+ }
1066
+ console.log("");
1067
+ // 3. Show raw content from notes with actual content
1068
+ const notes = db.getAllNotes();
1069
+ console.log(`Total notes in DB: ${notes.length}`);
1070
+ // Find a note with substantial content
1071
+ const noteWithContent = notes.find(n => n.content_markdown &&
1072
+ n.content_markdown.length > 200 &&
1073
+ !n.content_markdown.includes("(The things to talk about)"));
1074
+ if (noteWithContent) {
1075
+ console.log(`\n=== Note with content: ${noteWithContent.title} ===`);
1076
+ console.log(`ID: ${noteWithContent.id}`);
1077
+ console.log(`Event: ${noteWithContent.event_start ?? "N/A"}`);
1078
+ console.log("");
1079
+ console.log("--- Raw Markdown Content (first 2000 chars) ---");
1080
+ console.log(noteWithContent.content_markdown?.substring(0, 2000) ?? "(no content)");
1081
+ console.log("--- End Content ---");
1082
+ console.log("");
1083
+ // 4. Test action item extraction
1084
+ if (noteWithContent.content_markdown) {
1085
+ console.log("=== Parsed Action Items ===");
1086
+ const items = extractActionItems(noteWithContent.content_markdown);
1087
+ if (items.length === 0) {
1088
+ console.log("(none found)");
1089
+ }
1090
+ else {
1091
+ for (const item of items) {
1092
+ console.log(`- [${item.is_completed ? "x" : " "}] ${item.content}`);
1093
+ if (item.assignee)
1094
+ console.log(` Assignee: ${item.assignee}`);
1095
+ if (item.due_date)
1096
+ console.log(` Due: ${item.due_date}`);
1097
+ }
1098
+ }
1099
+ }
1100
+ }
1101
+ else if (notes.length > 0) {
1102
+ console.log("No notes with substantial content found.");
1103
+ console.log("First note content:", notes[0].content_markdown);
1104
+ }
1105
+ else {
1106
+ console.log("No notes in database.");
1107
+ }
1108
+ }
1109
+ catch (error) {
1110
+ console.error("Test failed:", error);
1111
+ process.exit(1);
1112
+ }
1113
+ }
1114
+ main().catch((error) => {
1115
+ console.error("Fatal error:", error);
1116
+ process.exit(1);
1117
+ });