@mindstone-engineering/mcp-server-fathom 0.1.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/auth.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Fathom authentication module.
3
+ *
4
+ * Manages the API key lifecycle — env var on startup, runtime update via
5
+ * configure tool, and bridge integration for host-app credential management.
6
+ */
7
+ /**
8
+ * Returns the current API key.
9
+ */
10
+ export declare function getApiKey(): string;
11
+ /**
12
+ * Returns true if an API key is configured.
13
+ */
14
+ export declare function isConfigured(): boolean;
15
+ /**
16
+ * Update the API key at runtime (e.g. after configure_fathom_api_key).
17
+ */
18
+ export declare function setApiKey(key: string): void;
19
+ //# sourceMappingURL=auth.d.ts.map
package/dist/auth.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Fathom authentication module.
3
+ *
4
+ * Manages the API key lifecycle — env var on startup, runtime update via
5
+ * configure tool, and bridge integration for host-app credential management.
6
+ */
7
+ let apiKey = process.env.FATHOM_API_KEY || '';
8
+ /**
9
+ * Returns the current API key.
10
+ */
11
+ export function getApiKey() {
12
+ return apiKey;
13
+ }
14
+ /**
15
+ * Returns true if an API key is configured.
16
+ */
17
+ export function isConfigured() {
18
+ return apiKey.length > 0;
19
+ }
20
+ /**
21
+ * Update the API key at runtime (e.g. after configure_fathom_api_key).
22
+ */
23
+ export function setApiKey(key) {
24
+ apiKey = key;
25
+ }
26
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Path to bridge state file, supporting both current and legacy env vars.
3
+ */
4
+ export declare const BRIDGE_STATE_PATH: string;
5
+ /**
6
+ * Send a request to the host app bridge.
7
+ *
8
+ * The bridge is an HTTP server running inside the host app (e.g. Rebel)
9
+ * that handles credential management and other cross-process operations.
10
+ */
11
+ export declare const bridgeRequest: (urlPath: string, body: Record<string, unknown>) => Promise<{
12
+ success: boolean;
13
+ warning?: string;
14
+ error?: string;
15
+ }>;
16
+ //# sourceMappingURL=bridge.d.ts.map
package/dist/bridge.js ADDED
@@ -0,0 +1,43 @@
1
+ import * as fs from 'fs';
2
+ import { REQUEST_TIMEOUT_MS } from './types.js';
3
+ /**
4
+ * Path to bridge state file, supporting both current and legacy env vars.
5
+ */
6
+ export const BRIDGE_STATE_PATH = process.env.MCP_HOST_BRIDGE_STATE || process.env.MINDSTONE_REBEL_BRIDGE_STATE || '';
7
+ const loadBridgeState = () => {
8
+ if (!BRIDGE_STATE_PATH)
9
+ return null;
10
+ try {
11
+ const raw = fs.readFileSync(BRIDGE_STATE_PATH, 'utf8');
12
+ return JSON.parse(raw);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ };
18
+ /**
19
+ * Send a request to the host app bridge.
20
+ *
21
+ * The bridge is an HTTP server running inside the host app (e.g. Rebel)
22
+ * that handles credential management and other cross-process operations.
23
+ */
24
+ export const bridgeRequest = async (urlPath, body) => {
25
+ const bridge = loadBridgeState();
26
+ if (!bridge) {
27
+ return { success: false, error: 'Bridge not available' };
28
+ }
29
+ const response = await fetch(`http://127.0.0.1:${bridge.port}${urlPath}`, {
30
+ method: 'POST',
31
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ Authorization: `Bearer ${bridge.token}`,
35
+ },
36
+ body: JSON.stringify(body),
37
+ });
38
+ if (response.status === 401 || response.status === 403) {
39
+ return { success: false, error: `Bridge returned ${response.status}: unauthorized. Check host app authentication.` };
40
+ }
41
+ return response.json();
42
+ };
43
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Fathom API HTTP client.
3
+ *
4
+ * Centralises X-Api-Key header injection, error handling, and rate-limit
5
+ * messaging for all Fathom API calls.
6
+ */
7
+ /**
8
+ * Make an authenticated request to the Fathom API.
9
+ *
10
+ * @param path API path relative to base, e.g. `/meetings`
11
+ * @param options Additional fetch options
12
+ * @returns Parsed JSON response
13
+ */
14
+ export declare function fathomFetch<T>(path: string, options?: RequestInit): Promise<T>;
15
+ //# sourceMappingURL=client.d.ts.map
package/dist/client.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Fathom API HTTP client.
3
+ *
4
+ * Centralises X-Api-Key header injection, error handling, and rate-limit
5
+ * messaging for all Fathom API calls.
6
+ */
7
+ import { getApiKey } from './auth.js';
8
+ import { FathomError, FATHOM_API_BASE, REQUEST_TIMEOUT_MS } from './types.js';
9
+ /**
10
+ * Make an authenticated request to the Fathom API.
11
+ *
12
+ * @param path API path relative to base, e.g. `/meetings`
13
+ * @param options Additional fetch options
14
+ * @returns Parsed JSON response
15
+ */
16
+ export async function fathomFetch(path, options = {}) {
17
+ const key = getApiKey();
18
+ if (!key) {
19
+ throw new FathomError('Fathom API key not configured', 'AUTH_REQUIRED', 'Use configure_fathom_api_key to set your API key first.');
20
+ }
21
+ const url = `${FATHOM_API_BASE}${path}`;
22
+ let response;
23
+ try {
24
+ response = await fetch(url, {
25
+ ...options,
26
+ signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
27
+ headers: {
28
+ 'X-Api-Key': key,
29
+ 'Content-Type': 'application/json',
30
+ ...options.headers,
31
+ },
32
+ });
33
+ }
34
+ catch (error) {
35
+ if (error instanceof Error && error.name === 'TimeoutError') {
36
+ throw new FathomError('Request to Fathom API timed out', 'TIMEOUT', 'The request took too long. Try again or check if the Fathom API is available.');
37
+ }
38
+ throw error;
39
+ }
40
+ if (response.status === 401 || response.status === 403) {
41
+ throw new FathomError('Authentication failed', 'AUTH_FAILED', 'Your Fathom API key is invalid or revoked. Use configure_fathom_api_key to set a new key.');
42
+ }
43
+ if (response.status === 429) {
44
+ const retryAfter = response.headers.get('Retry-After');
45
+ const waitTime = retryAfter ? `${retryAfter} seconds` : 'a moment';
46
+ throw new FathomError(`Rate limited by Fathom API. Please wait ${waitTime} before retrying.`, 'RATE_LIMITED', `Wait ${waitTime} and try again. Fathom limits API requests to 60 calls per minute.`);
47
+ }
48
+ if (response.status === 404) {
49
+ throw new FathomError('Resource not found', 'NOT_FOUND', 'The requested resource does not exist or you do not have permission to access it.');
50
+ }
51
+ if (!response.ok) {
52
+ const errorText = await response.text().catch(() => 'Unknown error');
53
+ throw new FathomError(`Fathom API error (${response.status}): ${errorText}`, 'API_ERROR', 'Check the request parameters and try again.');
54
+ }
55
+ return response.json();
56
+ }
57
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fathom MCP Server
4
+ *
5
+ * Provides Fathom AI meeting transcription integration via Model Context Protocol.
6
+ *
7
+ * Environment variables:
8
+ * - FATHOM_API_KEY: User's Fathom API key — required for all operations
9
+ * - MCP_HOST_BRIDGE_STATE: Path to host app bridge state file (optional)
10
+ * - MINDSTONE_REBEL_BRIDGE_STATE: Legacy bridge state path (optional)
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fathom MCP Server
4
+ *
5
+ * Provides Fathom AI meeting transcription integration via Model Context Protocol.
6
+ *
7
+ * Environment variables:
8
+ * - FATHOM_API_KEY: User's Fathom API key — required for all operations
9
+ * - MCP_HOST_BRIDGE_STATE: Path to host app bridge state file (optional)
10
+ * - MINDSTONE_REBEL_BRIDGE_STATE: Legacy bridge state path (optional)
11
+ */
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
+ import { createServer } from './server.js';
14
+ async function main() {
15
+ const server = createServer();
16
+ const transport = new StdioServerTransport();
17
+ await server.connect(transport);
18
+ console.error('Fathom MCP server running on stdio');
19
+ }
20
+ main().catch((error) => {
21
+ console.error('Fatal error:', error);
22
+ process.exit(1);
23
+ });
24
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function createServer(): McpServer;
3
+ //# sourceMappingURL=server.d.ts.map
package/dist/server.js ADDED
@@ -0,0 +1,13 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { registerConfigureTools, registerMeetingTools, registerTeamTools, } from './tools/index.js';
3
+ export function createServer() {
4
+ const server = new McpServer({
5
+ name: 'fathom-mcp-server',
6
+ version: '0.1.0',
7
+ });
8
+ registerConfigureTools(server);
9
+ registerMeetingTools(server);
10
+ registerTeamTools(server);
11
+ return server;
12
+ }
13
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerConfigureTools(server: McpServer): void;
3
+ //# sourceMappingURL=configure.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ import { setApiKey } from '../auth.js';
3
+ import { bridgeRequest, BRIDGE_STATE_PATH } from '../bridge.js';
4
+ import { FathomError } from '../types.js';
5
+ import { withErrorHandling } from '../utils.js';
6
+ export function registerConfigureTools(server) {
7
+ server.registerTool('configure_fathom_api_key', {
8
+ description: 'Configure the Fathom API key. Call this tool when the user provides their Fathom API key. ' +
9
+ 'Get your API key from https://fathom.video/customize#api-access-header — ' +
10
+ 'click "Add +", select "Generate API Key", name it "Mindstone Rebel".',
11
+ inputSchema: z.object({
12
+ api_key: z.string().min(1).describe('The Fathom API key'),
13
+ }),
14
+ annotations: { readOnlyHint: false, destructiveHint: false },
15
+ }, withErrorHandling(async (args) => {
16
+ const trimmedKey = args.api_key.trim();
17
+ // If bridge is available, persist via bridge
18
+ if (BRIDGE_STATE_PATH) {
19
+ try {
20
+ const result = await bridgeRequest('/bundled/fathom/configure', { apiKey: trimmedKey });
21
+ if (result.success) {
22
+ setApiKey(trimmedKey);
23
+ const message = result.warning
24
+ ? `Fathom API key configured successfully. Note: ${result.warning}`
25
+ : 'Fathom API key configured successfully! You can now use list_fathom_meetings to access your meeting transcripts.';
26
+ return JSON.stringify({ ok: true, message });
27
+ }
28
+ // Bridge returned failure — surface as error, do NOT fall through
29
+ throw new FathomError(result.error || 'Bridge configuration failed', 'BRIDGE_ERROR', 'The host app bridge rejected the configuration request. Check the host app logs.');
30
+ }
31
+ catch (error) {
32
+ if (error instanceof FathomError)
33
+ throw error;
34
+ // Bridge request failed (network, timeout, etc.) — surface as error
35
+ throw new FathomError(`Bridge request failed: ${error instanceof Error ? error.message : String(error)}`, 'BRIDGE_ERROR', 'Could not reach the host app bridge. Ensure the host app is running.');
36
+ }
37
+ }
38
+ // No bridge configured — configure in-memory only
39
+ setApiKey(trimmedKey);
40
+ return JSON.stringify({
41
+ ok: true,
42
+ message: 'Fathom API key configured successfully! You can now use list_fathom_meetings to access your meeting transcripts.',
43
+ });
44
+ }));
45
+ }
46
+ //# sourceMappingURL=configure.js.map
@@ -0,0 +1,4 @@
1
+ export { registerConfigureTools } from './configure.js';
2
+ export { registerMeetingTools } from './meetings.js';
3
+ export { registerTeamTools } from './teams.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export { registerConfigureTools } from './configure.js';
2
+ export { registerMeetingTools } from './meetings.js';
3
+ export { registerTeamTools } from './teams.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerMeetingTools(server: McpServer): void;
3
+ //# sourceMappingURL=meetings.d.ts.map
@@ -0,0 +1,283 @@
1
+ import { z } from 'zod';
2
+ import { fathomFetch } from '../client.js';
3
+ import { withErrorHandling } from '../utils.js';
4
+ import { isConfigured } from '../auth.js';
5
+ import { FathomError, } from '../types.js';
6
+ function noApiKeyError() {
7
+ return JSON.stringify({
8
+ ok: false,
9
+ error: 'Fathom API key not configured',
10
+ resolution: 'To use Fathom, you need to configure an API key first.',
11
+ next_step: {
12
+ action: 'Ask the user for their Fathom API key, then call configure_fathom_api_key',
13
+ tool_to_call: 'configure_fathom_api_key',
14
+ tool_parameters: { api_key: '<user_provided_key>' },
15
+ get_key_from: 'https://fathom.video/customize#api-access-header',
16
+ },
17
+ });
18
+ }
19
+ /**
20
+ * Find a meeting by its recording ID by scanning through list pages.
21
+ * Fathom API does not have a GET /meetings/{id} endpoint.
22
+ */
23
+ async function findMeetingByRecordingId(recordingId, maxPages = 10) {
24
+ let cursor;
25
+ let pageCount = 0;
26
+ do {
27
+ const path = cursor
28
+ ? `/meetings?cursor=${encodeURIComponent(cursor)}`
29
+ : '/meetings';
30
+ const response = await fathomFetch(path);
31
+ pageCount++;
32
+ const found = response.items.find((item) => item.recording_id === recordingId);
33
+ if (found)
34
+ return found;
35
+ cursor = response.next_cursor || undefined;
36
+ } while (cursor && pageCount < maxPages);
37
+ return null;
38
+ }
39
+ function formatTimestamp(entry) {
40
+ if (entry.timestamp)
41
+ return entry.timestamp;
42
+ if (entry.start_time !== undefined) {
43
+ const totalSeconds = Math.floor(entry.start_time);
44
+ const hours = Math.floor(totalSeconds / 3600);
45
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
46
+ const seconds = totalSeconds % 60;
47
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
48
+ }
49
+ return '00:00:00';
50
+ }
51
+ function getSpeakerName(entry) {
52
+ const speaker = entry.speaker;
53
+ if (!speaker)
54
+ return 'Unknown';
55
+ return speaker.display_name || speaker.name || speaker.matched_calendar_invitee_email || speaker.email || 'Unknown';
56
+ }
57
+ export function registerMeetingTools(server) {
58
+ server.registerTool('list_fathom_meetings', {
59
+ description: `List meetings from Fathom with server-side filtering.
60
+
61
+ Returns meeting metadata including:
62
+ - recording_id: Primary identifier for get_fathom_meeting and get_fathom_transcript
63
+ - title, scheduled_start_time, duration
64
+ - calendar_invitees: Array of attendees with name/email
65
+ - teams: Teams the meeting belongs to
66
+
67
+ Server-side filters (use these to narrow results efficiently):
68
+ - teams: Filter by team names
69
+ - recorded_by: Filter by recorder email addresses
70
+ - calendar_invitees_domains: Filter by attendee email domains (e.g., find all meetings with acme.com)
71
+ - meeting_type: 'internal' (same org) or 'external' (with outsiders)
72
+ - created_after/created_before: Date range filters (ISO format)
73
+
74
+ NOTE: Fathom does NOT have server-side keyword search. To find meetings by keyword, list meetings with filters then examine results yourself, or use get_fathom_transcript to search transcript content.
75
+
76
+ Pagination: Returns up to 'limit' results (default 25). hasMore=true indicates more pages exist.
77
+ Rate limit: Fathom allows ~60 API calls/minute.`,
78
+ inputSchema: z.object({
79
+ teams: z.array(z.string()).optional().describe('Filter by team names (e.g., ["Sales", "Engineering"])'),
80
+ recorded_by: z.array(z.string()).optional().describe('Filter by email addresses of recorders'),
81
+ calendar_invitees_domains: z.array(z.string()).optional().describe('Filter by attendee email domains'),
82
+ meeting_type: z.enum(['all', 'internal', 'external']).optional().describe('Filter by meeting type'),
83
+ created_after: z.string().optional().describe('ISO date string — only return meetings created after this date'),
84
+ created_before: z.string().optional().describe('ISO date string — only return meetings created before this date'),
85
+ limit: z.number().min(1).max(100).default(25).describe('Maximum number of results per page'),
86
+ }),
87
+ annotations: { readOnlyHint: true },
88
+ }, withErrorHandling(async (args) => {
89
+ if (!isConfigured())
90
+ return noApiKeyError();
91
+ const limit = args.limit;
92
+ const meetings = [];
93
+ let stoppedEarly = false;
94
+ // Build query string from filters
95
+ const params = new URLSearchParams();
96
+ if (args.teams)
97
+ args.teams.forEach((t) => params.append('teams[]', t));
98
+ if (args.recorded_by)
99
+ args.recorded_by.forEach((r) => params.append('recorded_by[]', r));
100
+ if (args.calendar_invitees_domains) {
101
+ args.calendar_invitees_domains.forEach((d) => params.append('calendar_invitees_domains[]', d));
102
+ }
103
+ if (args.meeting_type)
104
+ params.set('meeting_type', args.meeting_type);
105
+ if (args.created_after)
106
+ params.set('created_after', args.created_after);
107
+ if (args.created_before)
108
+ params.set('created_before', args.created_before);
109
+ let cursor;
110
+ do {
111
+ if (cursor)
112
+ params.set('cursor', cursor);
113
+ const qs = params.toString();
114
+ const path = qs ? `/meetings?${qs}` : '/meetings';
115
+ const response = await fathomFetch(path);
116
+ meetings.push(...response.items);
117
+ cursor = response.next_cursor || undefined;
118
+ if (meetings.length > limit) {
119
+ stoppedEarly = true;
120
+ break;
121
+ }
122
+ } while (cursor);
123
+ const trimmedMeetings = meetings.slice(0, limit);
124
+ const hasMore = stoppedEarly || meetings.length > limit;
125
+ return JSON.stringify({
126
+ ok: true,
127
+ meetings: trimmedMeetings,
128
+ count: trimmedMeetings.length,
129
+ hasMore,
130
+ ...(hasMore ? { hint: `Showing first ${limit} results. Increase limit parameter for more.` } : {}),
131
+ });
132
+ }));
133
+ server.registerTool('get_fathom_meeting', {
134
+ description: `Get details for a single Fathom meeting by its recording_id.
135
+
136
+ Finds the meeting in your recent history and fetches its AI-generated summary.
137
+
138
+ Returns:
139
+ - Meeting title, scheduled times, duration
140
+ - Recording URL and shareable link
141
+ - Calendar invitees with names/emails
142
+ - AI-generated summary (if available)
143
+
144
+ Note: Searches through up to 10 pages of recent meetings. For older meetings,
145
+ use list_fathom_meetings with created_after/created_before date filters first.
146
+
147
+ For transcript content, use get_fathom_transcript separately.
148
+ Rate limit: May use 1-11 API calls depending on meeting position in history.`,
149
+ inputSchema: z.object({
150
+ recording_id: z.number().int().positive().describe('The recording ID of the meeting (from list_fathom_meetings)'),
151
+ }),
152
+ annotations: { readOnlyHint: true },
153
+ }, withErrorHandling(async (args) => {
154
+ if (!isConfigured())
155
+ return noApiKeyError();
156
+ const recordingId = args.recording_id;
157
+ const meeting = await findMeetingByRecordingId(recordingId);
158
+ if (!meeting) {
159
+ return JSON.stringify({
160
+ ok: false,
161
+ error: 'Meeting not found',
162
+ recordingId,
163
+ resolution: 'The meeting may not exist, may not be accessible, or may be older than the search limit. ' +
164
+ 'Try list_fathom_meetings with created_after/created_before date filters to find older meetings.',
165
+ });
166
+ }
167
+ // Fetch summary via dedicated endpoint
168
+ let summary = meeting.default_summary || null;
169
+ if (!summary) {
170
+ try {
171
+ const summaryResponse = await fathomFetch(`/recordings/${recordingId}/summary`);
172
+ if (summaryResponse?.summary) {
173
+ summary = {
174
+ template_name: summaryResponse.summary.template_name,
175
+ markdown_formatted: summaryResponse.summary.markdown_formatted,
176
+ };
177
+ }
178
+ }
179
+ catch (err) {
180
+ // Re-throw rate limit errors; swallow not-found (summary may not exist yet)
181
+ if (err instanceof FathomError && err.code === 'RATE_LIMITED')
182
+ throw err;
183
+ summary = null;
184
+ }
185
+ }
186
+ return JSON.stringify({
187
+ ok: true,
188
+ meeting: {
189
+ title: meeting.title,
190
+ meeting_title: meeting.meeting_title,
191
+ recording_id: meeting.recording_id,
192
+ url: meeting.url,
193
+ share_url: meeting.share_url,
194
+ created_at: meeting.created_at,
195
+ scheduled_start_time: meeting.scheduled_start_time,
196
+ scheduled_end_time: meeting.scheduled_end_time,
197
+ recording_start_time: meeting.recording_start_time,
198
+ recording_end_time: meeting.recording_end_time,
199
+ calendar_invitees: meeting.calendar_invitees,
200
+ recorded_by: meeting.recorded_by,
201
+ summary,
202
+ },
203
+ });
204
+ }));
205
+ server.registerTool('get_fathom_transcript', {
206
+ description: `Get the transcript for a Fathom meeting by its recording_id.
207
+
208
+ Output formats (use 'format' parameter):
209
+ - "text" (default): Human-readable format: "[HH:MM:SS] Speaker Name: What they said"
210
+ - "json": Compact JSON array with full metadata (speaker object, timestamps, text)
211
+
212
+ Filtering options to reduce output size:
213
+ - search_query: Case-insensitive search — returns only matching lines plus context
214
+ - max_entries: Limit number of transcript entries returned
215
+ - start_entry: Skip first N entries (for pagination)
216
+
217
+ For large transcripts, use search_query to find relevant sections rather than fetching everything.
218
+ Rate limit: Counts as 1 API call (Fathom allows ~60/minute).
219
+ Use list_fathom_meetings first to find the recording_id.`,
220
+ inputSchema: z.object({
221
+ recording_id: z.number().int().positive().describe('The recording ID of the meeting (from list_fathom_meetings)'),
222
+ format: z.enum(['text', 'json']).default('text').describe('Output format: "text" (default) or "json"'),
223
+ search_query: z.string().optional().describe('Case-insensitive search query — returns only matching entries plus context'),
224
+ max_entries: z.number().int().positive().optional().describe('Maximum number of transcript entries to return'),
225
+ start_entry: z.number().int().min(0).default(0).describe('Skip first N entries (0-indexed)'),
226
+ }),
227
+ annotations: { readOnlyHint: true },
228
+ }, withErrorHandling(async (args) => {
229
+ if (!isConfigured())
230
+ return noApiKeyError();
231
+ const response = await fathomFetch(`/recordings/${args.recording_id}/transcript`);
232
+ let entries = response.transcript || [];
233
+ const totalCount = entries.length;
234
+ // Apply search filter if provided
235
+ let matchedIndices = null;
236
+ let directMatchCount = 0;
237
+ if (args.search_query) {
238
+ const query = args.search_query.toLowerCase();
239
+ matchedIndices = new Set();
240
+ entries.forEach((entry, idx) => {
241
+ if (entry.text.toLowerCase().includes(query)) {
242
+ directMatchCount++;
243
+ for (let i = Math.max(0, idx - 2); i <= Math.min(entries.length - 1, idx + 2); i++) {
244
+ matchedIndices.add(i);
245
+ }
246
+ }
247
+ });
248
+ entries = entries.filter((_, idx) => matchedIndices.has(idx));
249
+ }
250
+ // Apply pagination
251
+ if (args.start_entry > 0) {
252
+ entries = entries.slice(args.start_entry);
253
+ }
254
+ if (args.max_entries !== undefined) {
255
+ entries = entries.slice(0, args.max_entries);
256
+ }
257
+ const hasMore = args.start_entry + entries.length < (matchedIndices ? matchedIndices.size : totalCount);
258
+ if (args.format === 'json') {
259
+ return JSON.stringify({
260
+ ok: true,
261
+ transcript: entries,
262
+ count: entries.length,
263
+ totalCount,
264
+ hasMore,
265
+ ...(args.search_query
266
+ ? { searchQuery: args.search_query, directMatches: directMatchCount, entriesWithContext: matchedIndices?.size || 0 }
267
+ : {}),
268
+ });
269
+ }
270
+ // Text format (default)
271
+ const lines = entries.map((entry) => {
272
+ const timestamp = formatTimestamp(entry);
273
+ const speaker = getSpeakerName(entry);
274
+ const text = entry.text.replace(/[\r\n]+/g, ' ');
275
+ return `[${timestamp}] ${speaker}: ${text}`;
276
+ });
277
+ const header = args.search_query
278
+ ? `Transcript: ${directMatchCount} matches for "${args.search_query}" (showing ${entries.length} entries with context, ${totalCount} total in transcript)`
279
+ : `Transcript (${entries.length} of ${totalCount} entries)`;
280
+ return `${header}${hasMore ? ' - more available with start_entry parameter' : ''}\n\n${lines.join('\n')}`;
281
+ }));
282
+ }
283
+ //# sourceMappingURL=meetings.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerTeamTools(server: McpServer): void;
3
+ //# sourceMappingURL=teams.d.ts.map
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+ import { fathomFetch } from '../client.js';
3
+ import { withErrorHandling } from '../utils.js';
4
+ import { isConfigured } from '../auth.js';
5
+ function noApiKeyError() {
6
+ return JSON.stringify({
7
+ ok: false,
8
+ error: 'Fathom API key not configured',
9
+ resolution: 'Use configure_fathom_api_key to set your API key first.',
10
+ });
11
+ }
12
+ export function registerTeamTools(server) {
13
+ server.registerTool('list_fathom_teams', {
14
+ description: `List all teams accessible to the user in Fathom.
15
+
16
+ Returns teams with:
17
+ - name: Team name (use for filtering meetings or listing members)
18
+ - created_at: When the team was created (if available)
19
+
20
+ Use this to find team names for filtering meetings with list_fathom_meetings or listing team members with list_fathom_team_members.`,
21
+ inputSchema: z.object({}),
22
+ annotations: { readOnlyHint: true },
23
+ }, withErrorHandling(async () => {
24
+ if (!isConfigured())
25
+ return noApiKeyError();
26
+ const allTeams = [];
27
+ let cursor;
28
+ do {
29
+ const path = cursor ? `/teams?cursor=${encodeURIComponent(cursor)}` : '/teams';
30
+ const response = await fathomFetch(path);
31
+ const items = response.items || [];
32
+ for (const item of items) {
33
+ if (typeof item === 'string') {
34
+ allTeams.push({ name: item });
35
+ }
36
+ else {
37
+ allTeams.push(item);
38
+ }
39
+ }
40
+ cursor = response.next_cursor || undefined;
41
+ } while (cursor);
42
+ return JSON.stringify({ ok: true, teams: allTeams, count: allTeams.length });
43
+ }));
44
+ server.registerTool('list_fathom_team_members', {
45
+ description: `List members of a specific team in Fathom.
46
+
47
+ Returns team members with:
48
+ - User ID and email
49
+ - Name and role
50
+ - Join date
51
+
52
+ Use list_fathom_teams first to get available team names.`,
53
+ inputSchema: z.object({
54
+ team: z.string().min(1).describe('Team name to list members for (from list_fathom_teams)'),
55
+ }),
56
+ annotations: { readOnlyHint: true },
57
+ }, withErrorHandling(async (args) => {
58
+ if (!isConfigured())
59
+ return noApiKeyError();
60
+ const members = [];
61
+ let cursor;
62
+ do {
63
+ const teamParam = encodeURIComponent(args.team);
64
+ const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '';
65
+ const path = `/team_members?team=${teamParam}${cursorParam}`;
66
+ const response = await fathomFetch(path);
67
+ members.push(...(response.items || []));
68
+ cursor = response.next_cursor || undefined;
69
+ } while (cursor);
70
+ return JSON.stringify({ ok: true, teamMembers: members, count: members.length });
71
+ }));
72
+ }
73
+ //# sourceMappingURL=teams.js.map
@@ -0,0 +1,98 @@
1
+ export declare const REQUEST_TIMEOUT_MS = 30000;
2
+ export declare const FATHOM_API_BASE = "https://api.fathom.ai/external/v1";
3
+ export interface BridgeState {
4
+ port: number;
5
+ token: string;
6
+ }
7
+ export declare class FathomError extends Error {
8
+ readonly code: string;
9
+ readonly resolution: string;
10
+ constructor(message: string, code: string, resolution: string);
11
+ }
12
+ export interface TranscriptEntry {
13
+ speaker: {
14
+ name?: string;
15
+ display_name?: string;
16
+ email?: string;
17
+ matched_calendar_invitee_email?: string;
18
+ };
19
+ start_time?: number;
20
+ end_time?: number;
21
+ timestamp?: string;
22
+ text: string;
23
+ }
24
+ export interface TranscriptResponse {
25
+ transcript: TranscriptEntry[];
26
+ }
27
+ export interface MeetingItem {
28
+ title: string;
29
+ meeting_title: string | null;
30
+ recording_id: number;
31
+ url: string;
32
+ share_url: string;
33
+ created_at: string;
34
+ scheduled_start_time: string;
35
+ scheduled_end_time: string;
36
+ recording_start_time: string;
37
+ recording_end_time: string;
38
+ calendar_invitees_domains_type: string;
39
+ transcript_language: string;
40
+ calendar_invitees: Array<{
41
+ name?: string;
42
+ email: string;
43
+ email_domain?: string;
44
+ is_external?: boolean;
45
+ }>;
46
+ recorded_by: {
47
+ name?: string;
48
+ email: string;
49
+ email_domain?: string;
50
+ team?: string | null;
51
+ };
52
+ default_summary?: {
53
+ template_name?: string;
54
+ markdown_formatted?: string;
55
+ } | null;
56
+ action_items?: Array<{
57
+ description: string;
58
+ user_generated?: boolean;
59
+ completed?: boolean;
60
+ recording_timestamp?: string;
61
+ assignee?: {
62
+ name?: string;
63
+ email?: string;
64
+ };
65
+ }> | null;
66
+ }
67
+ export interface MeetingsListResponse {
68
+ limit: number;
69
+ next_cursor: string | null;
70
+ items: MeetingItem[];
71
+ }
72
+ export interface TeamItem {
73
+ name: string;
74
+ created_at?: string | null;
75
+ }
76
+ export interface TeamsResponse {
77
+ limit: number;
78
+ next_cursor: string | null;
79
+ items: (TeamItem | string)[];
80
+ }
81
+ export interface TeamMembersResponse {
82
+ limit: number;
83
+ next_cursor: string | null;
84
+ items: Array<{
85
+ id?: string;
86
+ email?: string;
87
+ name?: string;
88
+ role?: string;
89
+ joined_at?: string;
90
+ }>;
91
+ }
92
+ export interface SummaryResponse {
93
+ summary: {
94
+ template_name?: string;
95
+ markdown_formatted?: string;
96
+ };
97
+ }
98
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1,13 @@
1
+ export const REQUEST_TIMEOUT_MS = 30_000;
2
+ export const FATHOM_API_BASE = 'https://api.fathom.ai/external/v1';
3
+ export class FathomError extends Error {
4
+ code;
5
+ resolution;
6
+ constructor(message, code, resolution) {
7
+ super(message);
8
+ this.code = code;
9
+ this.resolution = resolution;
10
+ this.name = 'FathomError';
11
+ }
12
+ }
13
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,14 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ type ToolHandler<T> = (args: T, extra: unknown) => Promise<CallToolResult>;
3
+ /**
4
+ * Wraps a tool handler with standard error handling.
5
+ *
6
+ * - On success: returns the string result as a text content block.
7
+ * - On FathomError: returns a structured JSON error with code and resolution.
8
+ * - On unknown error: returns a generic error message.
9
+ *
10
+ * Secrets are never exposed in error messages.
11
+ */
12
+ export declare function withErrorHandling<T>(fn: (args: T, extra: unknown) => Promise<string>): ToolHandler<T>;
13
+ export {};
14
+ //# sourceMappingURL=utils.d.ts.map
package/dist/utils.js ADDED
@@ -0,0 +1,42 @@
1
+ import { FathomError } from './types.js';
2
+ /**
3
+ * Wraps a tool handler with standard error handling.
4
+ *
5
+ * - On success: returns the string result as a text content block.
6
+ * - On FathomError: returns a structured JSON error with code and resolution.
7
+ * - On unknown error: returns a generic error message.
8
+ *
9
+ * Secrets are never exposed in error messages.
10
+ */
11
+ export function withErrorHandling(fn) {
12
+ return async (args, extra) => {
13
+ try {
14
+ const result = await fn(args, extra);
15
+ return { content: [{ type: 'text', text: result }] };
16
+ }
17
+ catch (error) {
18
+ if (error instanceof FathomError) {
19
+ return {
20
+ content: [
21
+ {
22
+ type: 'text',
23
+ text: JSON.stringify({
24
+ ok: false,
25
+ error: error.message,
26
+ code: error.code,
27
+ resolution: error.resolution,
28
+ }),
29
+ },
30
+ ],
31
+ isError: true,
32
+ };
33
+ }
34
+ const errorMessage = error instanceof Error ? error.message : String(error);
35
+ return {
36
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error: errorMessage }) }],
37
+ isError: true,
38
+ };
39
+ }
40
+ };
41
+ }
42
+ //# sourceMappingURL=utils.js.map
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@mindstone-engineering/mcp-server-fathom",
3
+ "version": "0.1.0",
4
+ "description": "Fathom AI meeting transcription MCP server for Model Context Protocol hosts",
5
+ "license": "FSL-1.1-MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "mcp-server-fathom": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "!dist/**/*.map"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/nspr-io/mcp-servers.git",
17
+ "directory": "connectors/fathom"
18
+ },
19
+ "homepage": "https://github.com/nspr-io/mcp-servers/tree/main/connectors/fathom",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc && shx chmod +x dist/index.js",
25
+ "prepare": "npm run build",
26
+ "watch": "tsc --watch",
27
+ "start": "node dist/index.js",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "test:coverage": "vitest run --coverage"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.26.0",
34
+ "fathom-typescript": "^0.0.37",
35
+ "zod": "^3.23.0"
36
+ },
37
+ "devDependencies": {
38
+ "@mindstone-engineering/mcp-test-harness": "file:../../test-harness",
39
+ "@types/node": "^22",
40
+ "@vitest/coverage-v8": "^4.1.3",
41
+ "msw": "^2.13.2",
42
+ "shx": "^0.3.4",
43
+ "typescript": "^5.8.2",
44
+ "vitest": "^4.1.3"
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ }
49
+ }