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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Itai Liba
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/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # Fellow MCP Server
2
+
3
+ A local MCP (Model Context Protocol) server that wraps the Fellow.ai API, providing tools to access meeting data, transcripts, summaries, action items, and participants.
4
+
5
+ **Features:**
6
+ - Local SQLite database for caching meeting data
7
+ - Automatic incremental sync to keep action items fresh
8
+ - Full-text search across cached notes
9
+ - Find meetings by participant
10
+
11
+ ## Installation
12
+
13
+ ### Option 1: Install from npm (Recommended)
14
+
15
+ ```bash
16
+ npm install -g fellow-mcp
17
+ ```
18
+
19
+ ### Option 2: Install from source
20
+
21
+ ```bash
22
+ git clone https://github.com/liba2k/fellow-mcp.git
23
+ cd fellow-mcp
24
+ npm install
25
+ npm run build
26
+ ```
27
+
28
+ ## Setup
29
+
30
+ ### 1. Get your Fellow API credentials
31
+
32
+ 1. Log into your Fellow account
33
+ 2. Navigate to Developer API settings in your User settings
34
+ 3. Generate a new API key
35
+ 4. Note your workspace subdomain (the part before `.fellow.app` in your URL)
36
+
37
+ ### 2. Configure your MCP client
38
+
39
+ #### If installed via npm:
40
+
41
+ Add the following to your MCP client configuration (e.g., `~/.config/opencode/opencode.json`):
42
+
43
+ ```json
44
+ {
45
+ "mcp": {
46
+ "fellow": {
47
+ "type": "local",
48
+ "command": ["fellow-mcp"],
49
+ "env": {
50
+ "FELLOW_API_KEY": "your-api-key-here",
51
+ "FELLOW_SUBDOMAIN": "your-subdomain"
52
+ },
53
+ "enabled": true
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ #### If installed from source:
60
+
61
+ Option A: Use the run.sh script with .env file:
62
+
63
+ 1. Copy the example env file and fill in your credentials:
64
+ ```bash
65
+ cp .env.example .env
66
+ ```
67
+
68
+ 2. Edit `.env` with your credentials:
69
+ ```
70
+ FELLOW_API_KEY=your-api-key-here
71
+ FELLOW_SUBDOMAIN=your-subdomain
72
+ ```
73
+
74
+ 3. Configure your MCP client:
75
+ ```json
76
+ {
77
+ "mcp": {
78
+ "fellow": {
79
+ "type": "local",
80
+ "command": ["/path/to/fellow-mcp/run.sh"],
81
+ "enabled": true
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ Option B: Pass environment variables directly:
88
+ ```json
89
+ {
90
+ "mcp": {
91
+ "fellow": {
92
+ "type": "local",
93
+ "command": ["node", "/path/to/fellow-mcp/dist/index.js"],
94
+ "env": {
95
+ "FELLOW_API_KEY": "your-api-key-here",
96
+ "FELLOW_SUBDOMAIN": "your-subdomain"
97
+ },
98
+ "enabled": true
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ ## Available Tools
105
+
106
+ ### API Tools (Direct Fellow API calls)
107
+
108
+ #### `search_meetings`
109
+ Search for meetings/recordings in Fellow.
110
+
111
+ **Parameters:**
112
+ - `title` (optional): Filter by meeting title (case-insensitive partial match)
113
+ - `created_at_start` (optional): Filter meetings created after this date (ISO format)
114
+ - `created_at_end` (optional): Filter meetings created before this date (ISO format)
115
+ - `limit` (optional): Maximum number of results (1-50, default 20)
116
+
117
+ #### `get_meeting_transcript`
118
+ Get the full transcript of a meeting recording with speaker labels and timestamps.
119
+
120
+ **Parameters:**
121
+ - `recording_id` (optional): The ID of the recording
122
+ - `meeting_title` (optional): Search by meeting title
123
+
124
+ #### `get_meeting_summary`
125
+ Get the meeting summary/notes content including agenda items, discussion topics, and decisions.
126
+
127
+ **Parameters:**
128
+ - `note_id` (optional): The ID of the note
129
+ - `recording_id` (optional): Get the summary for a recording's associated note
130
+ - `meeting_title` (optional): Search by meeting title
131
+
132
+ #### `get_action_items`
133
+ Extract action items from a single meeting's notes.
134
+
135
+ **Parameters:**
136
+ - `note_id` (optional): The ID of the note
137
+ - `meeting_title` (optional): Search by meeting title
138
+
139
+ #### `get_meeting_participants`
140
+ Get the list of participants/attendees for a meeting.
141
+
142
+ **Parameters:**
143
+ - `note_id` (optional): The ID of the note
144
+ - `meeting_title` (optional): Search by meeting title
145
+
146
+ ### Database Tools (Local SQLite cache)
147
+
148
+ #### `sync_meetings`
149
+ Sync meetings from Fellow API to local database.
150
+
151
+ **Parameters:**
152
+ - `force` (optional, default: false): If true, performs full re-sync. Otherwise does incremental sync (only new/updated since last sync)
153
+ - `include_transcripts` (optional, default: false): If true, also fetches and stores transcripts (slower)
154
+
155
+ #### `get_all_action_items`
156
+ Get all action items from the local database. **Automatically performs incremental sync first** to ensure data is fresh.
157
+
158
+ **Parameters:**
159
+ - `assignee` (optional): Filter by assignee name (partial match)
160
+ - `show_completed` (optional, default: false): If true, includes completed action items
161
+ - `since` (optional): Only return action items from meetings on or after this date (ISO format: YYYY-MM-DD)
162
+
163
+ #### `get_meetings_by_participants`
164
+ Find meetings that included specific participants.
165
+
166
+ **Parameters:**
167
+ - `emails` (required): List of email addresses to search for
168
+ - `require_all` (optional, default: false): If true, only return meetings where ALL specified participants attended
169
+
170
+ #### `search_cached_notes`
171
+ Full-text search across all cached meeting notes (titles and content).
172
+
173
+ **Parameters:**
174
+ - `query` (required): Search query
175
+
176
+ #### `get_sync_status`
177
+ Get the current sync status and database statistics.
178
+
179
+ ## Local Database
180
+
181
+ Meeting data is cached in a local SQLite database at `~/.fellow-mcp/fellow.db`. This enables:
182
+
183
+ - Fast local searches
184
+ - Querying across all action items
185
+ - Finding meetings by participant
186
+ - Offline access to cached data
187
+
188
+ The database stores:
189
+ - Notes (meeting summaries, agendas, content)
190
+ - Recordings (with optional transcripts)
191
+ - Action items (parsed from notes with assignee/due date extraction)
192
+ - Participants (email addresses)
193
+
194
+ ## Environment Variables
195
+
196
+ | Variable | Required | Description |
197
+ |----------|----------|-------------|
198
+ | `FELLOW_API_KEY` | Yes | Your Fellow API key |
199
+ | `FELLOW_SUBDOMAIN` | Yes | Your Fellow workspace subdomain |
200
+
201
+ ## Development
202
+
203
+ ```bash
204
+ # Watch mode for development
205
+ npm run dev
206
+
207
+ # Build
208
+ npm run build
209
+
210
+ # Test the server
211
+ node dist/index.js --test
212
+ ```
213
+
214
+ ## Requirements
215
+
216
+ - Node.js >= 18.0.0
217
+ - A Fellow.ai account with API access
218
+
219
+ ## License
220
+
221
+ MIT
222
+
223
+ ## API Reference
224
+
225
+ This MCP server wraps the [Fellow Developer API](https://developers.fellow.ai/reference/introduction). The API uses:
226
+ - `X-API-KEY` header for authentication
227
+ - POST requests for list operations (with JSON body for filters/pagination)
228
+ - GET requests for retrieving individual resources
@@ -0,0 +1,76 @@
1
+ export interface StoredNote {
2
+ id: string;
3
+ title: string;
4
+ created_at: string;
5
+ updated_at: string;
6
+ event_start: string | null;
7
+ event_end: string | null;
8
+ event_guid: string | null;
9
+ call_url: string | null;
10
+ content_markdown: string | null;
11
+ synced_at: string;
12
+ }
13
+ export interface StoredRecording {
14
+ id: string;
15
+ note_id: string;
16
+ title: string;
17
+ created_at: string;
18
+ updated_at: string;
19
+ event_start: string | null;
20
+ event_end: string | null;
21
+ recording_start: string | null;
22
+ recording_end: string | null;
23
+ event_guid: string | null;
24
+ call_url: string | null;
25
+ transcript_json: string | null;
26
+ synced_at: string;
27
+ }
28
+ export interface StoredActionItem {
29
+ id: number;
30
+ note_id: string;
31
+ content: string;
32
+ assignee: string | null;
33
+ due_date: string | null;
34
+ is_completed: boolean;
35
+ created_at: string;
36
+ }
37
+ export interface StoredParticipant {
38
+ id: number;
39
+ note_id: string;
40
+ email: string;
41
+ }
42
+ export declare class FellowDatabase {
43
+ private db;
44
+ constructor(dbPath?: string);
45
+ private initSchema;
46
+ upsertNote(note: Omit<StoredNote, "synced_at">): void;
47
+ getNote(id: string): StoredNote | null;
48
+ getAllNotes(): StoredNote[];
49
+ searchNotes(query: string): StoredNote[];
50
+ upsertRecording(recording: Omit<StoredRecording, "synced_at">): void;
51
+ getRecording(id: string): StoredRecording | null;
52
+ clearActionItemsForNote(noteId: string): void;
53
+ insertActionItem(item: Omit<StoredActionItem, "id">): void;
54
+ getAllActionItems(filters?: {
55
+ assignee?: string;
56
+ is_completed?: boolean;
57
+ since?: string;
58
+ }): (StoredActionItem & {
59
+ note_title: string;
60
+ event_start: string | null;
61
+ })[];
62
+ clearParticipantsForNote(noteId: string): void;
63
+ insertParticipant(noteId: string, email: string): void;
64
+ getMeetingsByParticipants(emails: string[]): StoredNote[];
65
+ getMeetingsWithAllParticipants(emails: string[]): StoredNote[];
66
+ getParticipantsForNote(noteId: string): string[];
67
+ getLastSyncTime(): string | null;
68
+ setLastSyncTime(time: string): void;
69
+ getStats(): {
70
+ notes: number;
71
+ recordings: number;
72
+ action_items: number;
73
+ participants: number;
74
+ };
75
+ close(): void;
76
+ }
@@ -0,0 +1,244 @@
1
+ import Database from "better-sqlite3";
2
+ import path from "path";
3
+ import os from "os";
4
+ import fs from "fs";
5
+ export class FellowDatabase {
6
+ db;
7
+ constructor(dbPath) {
8
+ const defaultPath = path.join(os.homedir(), ".fellow-mcp", "fellow.db");
9
+ const finalPath = dbPath ?? defaultPath;
10
+ // Ensure directory exists
11
+ const dir = path.dirname(finalPath);
12
+ if (!fs.existsSync(dir)) {
13
+ fs.mkdirSync(dir, { recursive: true });
14
+ }
15
+ this.db = new Database(finalPath);
16
+ this.db.pragma("journal_mode = WAL");
17
+ this.initSchema();
18
+ }
19
+ initSchema() {
20
+ this.db.exec(`
21
+ CREATE TABLE IF NOT EXISTS notes (
22
+ id TEXT PRIMARY KEY,
23
+ title TEXT NOT NULL,
24
+ created_at TEXT NOT NULL,
25
+ updated_at TEXT NOT NULL,
26
+ event_start TEXT,
27
+ event_end TEXT,
28
+ event_guid TEXT,
29
+ call_url TEXT,
30
+ content_markdown TEXT,
31
+ synced_at TEXT NOT NULL
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS recordings (
35
+ id TEXT PRIMARY KEY,
36
+ note_id TEXT,
37
+ title TEXT NOT NULL,
38
+ created_at TEXT NOT NULL,
39
+ updated_at TEXT NOT NULL,
40
+ event_start TEXT,
41
+ event_end TEXT,
42
+ recording_start TEXT,
43
+ recording_end TEXT,
44
+ event_guid TEXT,
45
+ call_url TEXT,
46
+ transcript_json TEXT,
47
+ synced_at TEXT NOT NULL,
48
+ FOREIGN KEY (note_id) REFERENCES notes(id)
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS action_items (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ note_id TEXT NOT NULL,
54
+ content TEXT NOT NULL,
55
+ assignee TEXT,
56
+ due_date TEXT,
57
+ is_completed INTEGER DEFAULT 0,
58
+ created_at TEXT NOT NULL,
59
+ FOREIGN KEY (note_id) REFERENCES notes(id)
60
+ );
61
+
62
+ CREATE TABLE IF NOT EXISTS participants (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ note_id TEXT NOT NULL,
65
+ email TEXT NOT NULL,
66
+ FOREIGN KEY (note_id) REFERENCES notes(id),
67
+ UNIQUE(note_id, email)
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS sync_status (
71
+ key TEXT PRIMARY KEY,
72
+ value TEXT NOT NULL
73
+ );
74
+
75
+ CREATE INDEX IF NOT EXISTS idx_notes_event_start ON notes(event_start);
76
+ CREATE INDEX IF NOT EXISTS idx_notes_title ON notes(title);
77
+ CREATE INDEX IF NOT EXISTS idx_recordings_note_id ON recordings(note_id);
78
+ CREATE INDEX IF NOT EXISTS idx_action_items_note_id ON action_items(note_id);
79
+ CREATE INDEX IF NOT EXISTS idx_action_items_assignee ON action_items(assignee);
80
+ CREATE INDEX IF NOT EXISTS idx_participants_note_id ON participants(note_id);
81
+ CREATE INDEX IF NOT EXISTS idx_participants_email ON participants(email);
82
+ `);
83
+ }
84
+ // Notes
85
+ upsertNote(note) {
86
+ const stmt = this.db.prepare(`
87
+ INSERT INTO notes (id, title, created_at, updated_at, event_start, event_end, event_guid, call_url, content_markdown, synced_at)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
89
+ ON CONFLICT(id) DO UPDATE SET
90
+ title = excluded.title,
91
+ updated_at = excluded.updated_at,
92
+ event_start = excluded.event_start,
93
+ event_end = excluded.event_end,
94
+ event_guid = excluded.event_guid,
95
+ call_url = excluded.call_url,
96
+ content_markdown = COALESCE(excluded.content_markdown, content_markdown),
97
+ synced_at = excluded.synced_at
98
+ `);
99
+ stmt.run(note.id, note.title, note.created_at, note.updated_at, note.event_start, note.event_end, note.event_guid, note.call_url, note.content_markdown, new Date().toISOString());
100
+ }
101
+ getNote(id) {
102
+ const stmt = this.db.prepare("SELECT * FROM notes WHERE id = ?");
103
+ return stmt.get(id);
104
+ }
105
+ getAllNotes() {
106
+ const stmt = this.db.prepare("SELECT * FROM notes ORDER BY event_start DESC");
107
+ return stmt.all();
108
+ }
109
+ searchNotes(query) {
110
+ const stmt = this.db.prepare(`
111
+ SELECT * FROM notes
112
+ WHERE title LIKE ? OR content_markdown LIKE ?
113
+ ORDER BY event_start DESC
114
+ `);
115
+ const pattern = `%${query}%`;
116
+ return stmt.all(pattern, pattern);
117
+ }
118
+ // Recordings
119
+ upsertRecording(recording) {
120
+ const stmt = this.db.prepare(`
121
+ INSERT INTO recordings (id, note_id, title, created_at, updated_at, event_start, event_end, recording_start, recording_end, event_guid, call_url, transcript_json, synced_at)
122
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
123
+ ON CONFLICT(id) DO UPDATE SET
124
+ note_id = excluded.note_id,
125
+ title = excluded.title,
126
+ updated_at = excluded.updated_at,
127
+ event_start = excluded.event_start,
128
+ event_end = excluded.event_end,
129
+ recording_start = excluded.recording_start,
130
+ recording_end = excluded.recording_end,
131
+ event_guid = excluded.event_guid,
132
+ call_url = excluded.call_url,
133
+ transcript_json = COALESCE(excluded.transcript_json, transcript_json),
134
+ synced_at = excluded.synced_at
135
+ `);
136
+ stmt.run(recording.id, recording.note_id, recording.title, recording.created_at, recording.updated_at, recording.event_start, recording.event_end, recording.recording_start, recording.recording_end, recording.event_guid, recording.call_url, recording.transcript_json, new Date().toISOString());
137
+ }
138
+ getRecording(id) {
139
+ const stmt = this.db.prepare("SELECT * FROM recordings WHERE id = ?");
140
+ return stmt.get(id);
141
+ }
142
+ // Action Items
143
+ clearActionItemsForNote(noteId) {
144
+ const stmt = this.db.prepare("DELETE FROM action_items WHERE note_id = ?");
145
+ stmt.run(noteId);
146
+ }
147
+ insertActionItem(item) {
148
+ const stmt = this.db.prepare(`
149
+ INSERT INTO action_items (note_id, content, assignee, due_date, is_completed, created_at)
150
+ VALUES (?, ?, ?, ?, ?, ?)
151
+ `);
152
+ stmt.run(item.note_id, item.content, item.assignee, item.due_date, item.is_completed ? 1 : 0, item.created_at);
153
+ }
154
+ getAllActionItems(filters) {
155
+ let query = `
156
+ SELECT a.*, n.title as note_title, n.event_start
157
+ FROM action_items a
158
+ JOIN notes n ON a.note_id = n.id
159
+ WHERE 1=1
160
+ `;
161
+ const params = [];
162
+ if (filters?.assignee) {
163
+ query += " AND a.assignee LIKE ?";
164
+ params.push(`%${filters.assignee}%`);
165
+ }
166
+ if (filters?.is_completed !== undefined) {
167
+ query += " AND a.is_completed = ?";
168
+ params.push(filters.is_completed ? 1 : 0);
169
+ }
170
+ if (filters?.since) {
171
+ query += " AND n.event_start >= ?";
172
+ params.push(filters.since);
173
+ }
174
+ query += " ORDER BY n.event_start DESC";
175
+ const stmt = this.db.prepare(query);
176
+ return stmt.all(...params);
177
+ }
178
+ // Participants
179
+ clearParticipantsForNote(noteId) {
180
+ const stmt = this.db.prepare("DELETE FROM participants WHERE note_id = ?");
181
+ stmt.run(noteId);
182
+ }
183
+ insertParticipant(noteId, email) {
184
+ const stmt = this.db.prepare(`
185
+ INSERT OR IGNORE INTO participants (note_id, email) VALUES (?, ?)
186
+ `);
187
+ stmt.run(noteId, email);
188
+ }
189
+ getMeetingsByParticipants(emails) {
190
+ if (emails.length === 0)
191
+ return [];
192
+ const placeholders = emails.map(() => "?").join(",");
193
+ const stmt = this.db.prepare(`
194
+ SELECT DISTINCT n.* FROM notes n
195
+ JOIN participants p ON n.id = p.note_id
196
+ WHERE p.email IN (${placeholders})
197
+ ORDER BY n.event_start DESC
198
+ `);
199
+ return stmt.all(...emails);
200
+ }
201
+ getMeetingsWithAllParticipants(emails) {
202
+ if (emails.length === 0)
203
+ return [];
204
+ const placeholders = emails.map(() => "?").join(",");
205
+ const stmt = this.db.prepare(`
206
+ SELECT n.* FROM notes n
207
+ WHERE (
208
+ SELECT COUNT(DISTINCT p.email) FROM participants p
209
+ WHERE p.note_id = n.id AND p.email IN (${placeholders})
210
+ ) = ?
211
+ ORDER BY n.event_start DESC
212
+ `);
213
+ return stmt.all(...emails, emails.length);
214
+ }
215
+ getParticipantsForNote(noteId) {
216
+ const stmt = this.db.prepare("SELECT email FROM participants WHERE note_id = ?");
217
+ const rows = stmt.all(noteId);
218
+ return rows.map((r) => r.email);
219
+ }
220
+ // Sync status
221
+ getLastSyncTime() {
222
+ const stmt = this.db.prepare("SELECT value FROM sync_status WHERE key = 'last_sync'");
223
+ const row = stmt.get();
224
+ return row?.value ?? null;
225
+ }
226
+ setLastSyncTime(time) {
227
+ const stmt = this.db.prepare(`
228
+ INSERT INTO sync_status (key, value) VALUES ('last_sync', ?)
229
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
230
+ `);
231
+ stmt.run(time);
232
+ }
233
+ // Stats
234
+ getStats() {
235
+ const notes = this.db.prepare("SELECT COUNT(*) as count FROM notes").get().count;
236
+ const recordings = this.db.prepare("SELECT COUNT(*) as count FROM recordings").get().count;
237
+ const action_items = this.db.prepare("SELECT COUNT(*) as count FROM action_items").get().count;
238
+ const participants = this.db.prepare("SELECT COUNT(DISTINCT email) as count FROM participants").get().count;
239
+ return { notes, recordings, action_items, participants };
240
+ }
241
+ close() {
242
+ this.db.close();
243
+ }
244
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};