Skip to main content
Glama
index.ts17 kB
#!/usr/bin/env node /** * Fathom MCP Server * * A Model Context Protocol server for Fathom.video * Provides tools to access meetings, transcripts, summaries, action items, and more * * @author Matthew Bergvinson <operations@vigilanteconsulting.com> * @license MIT * @see https://github.com/matthewbergvinson/fathom-mcp */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import { FathomClient } from './fathom-client.js'; import { formatMeetingToMarkdown, formatMeetingList, formatTranscriptToMarkdown, formatActionItems, generateTranscriptFilename, formatDate, } from './formatters.js'; import * as fs from 'fs/promises'; import * as path from 'path'; // Get configuration from environment const API_KEY = process.env.FATHOM_API_KEY; const OUTPUT_DIR = process.env.FATHOM_OUTPUT_DIR ? `${process.env.FATHOM_OUTPUT_DIR}/transcripts` : `${process.cwd()}/transcripts`; // Validate API key on startup if (!API_KEY) { console.error('Error: FATHOM_API_KEY environment variable is required'); process.exit(1); } // Initialize Fathom client const fathom = new FathomClient(API_KEY); // Create MCP server using McpServer class (same as postmark-mcp) const server = new McpServer({ name: 'fathom-mcp', version: '1.0.0', }); console.error('Initializing Fathom MCP server...'); // ============================================================================ // TOOL: list_meetings // ============================================================================ server.tool( 'list_meetings', { limit: z.number().optional().describe('Maximum number of meetings to return (default: 10, use 0 for all)'), created_after: z.string().optional().describe('ISO 8601 timestamp - only return meetings after this date'), created_before: z.string().optional().describe('ISO 8601 timestamp - only return meetings before this date'), include_external_only: z.boolean().optional().describe('Only include meetings with external participants'), }, async ({ limit, created_after, created_before, include_external_only }) => { console.error('Fetching meetings...'); const response = await fathom.listMeetings({ created_after, created_before, calendar_invitees_domains_type: include_external_only ? 'one_or_more_external' : undefined, }); let meetings = response.items; if (limit && limit > 0) { meetings = meetings.slice(0, limit); } console.error(`Found ${meetings.length} meetings`); const markdown = formatMeetingList(meetings); return { content: [{ type: 'text', text: markdown }], }; } ); // ============================================================================ // TOOL: get_meeting // ============================================================================ server.tool( 'get_meeting', { recording_id: z.number().describe('The recording ID of the meeting to retrieve'), include_transcript: z.boolean().optional().describe('Include the full transcript (default: true)'), include_summary: z.boolean().optional().describe('Include the AI summary (default: true)'), include_action_items: z.boolean().optional().describe('Include action items (default: true)'), include_crm_matches: z.boolean().optional().describe('Include CRM matches (default: true)'), }, async ({ recording_id, include_transcript, include_summary, include_action_items, include_crm_matches }) => { console.error(`Fetching meeting ${recording_id}...`); const response = await fathom.listMeetings({ include_transcript: include_transcript !== false, include_summary: include_summary !== false, include_action_items: include_action_items !== false, include_crm_matches: include_crm_matches !== false, }); const meeting = response.items.find(m => m.recording_id === recording_id); if (!meeting) { return { content: [{ type: 'text', text: `Meeting with recording ID ${recording_id} not found.` }], isError: true, }; } console.error(`Found meeting: ${meeting.title}`); const markdown = formatMeetingToMarkdown(meeting); return { content: [{ type: 'text', text: markdown }], }; } ); // ============================================================================ // TOOL: get_transcript // ============================================================================ server.tool( 'get_transcript', { recording_id: z.number().describe('The recording ID of the meeting'), }, async ({ recording_id }) => { console.error(`Fetching transcript for ${recording_id}...`); const response = await fathom.getTranscript(recording_id); const markdown = formatTranscriptToMarkdown(response.transcript); console.error('Transcript retrieved'); return { content: [{ type: 'text', text: markdown }], }; } ); // ============================================================================ // TOOL: export_meeting // ============================================================================ server.tool( 'export_meeting', { recording_id: z.number().describe('The recording ID of the meeting to export'), output_dir: z.string().optional().describe('Directory to save the file (defaults to workspace/transcripts)'), }, async ({ recording_id, output_dir }) => { const targetDir = output_dir || OUTPUT_DIR; console.error(`Exporting meeting ${recording_id} to ${targetDir}...`); const response = await fathom.listMeetings({ include_transcript: true, include_summary: true, include_action_items: true, include_crm_matches: true, }); const meeting = response.items.find(m => m.recording_id === recording_id); if (!meeting) { return { content: [{ type: 'text', text: `Meeting with recording ID ${recording_id} not found.` }], isError: true, }; } const markdown = formatMeetingToMarkdown(meeting); const filename = generateTranscriptFilename(meeting); const filepath = path.join(targetDir, filename); await fs.mkdir(targetDir, { recursive: true }); await fs.writeFile(filepath, markdown, 'utf-8'); console.error(`Exported: ${filename}`); return { content: [{ type: 'text', text: `Exported meeting to: ${filepath}` }], }; } ); // ============================================================================ // TOOL: export_all_meetings // ============================================================================ server.tool( 'export_all_meetings', { created_after: z.string().optional().describe('ISO 8601 timestamp - only export meetings after this date'), created_before: z.string().optional().describe('ISO 8601 timestamp - only export meetings before this date'), output_dir: z.string().optional().describe('Directory to save files (defaults to workspace/transcripts)'), }, async ({ created_after, created_before, output_dir }) => { const targetDir = output_dir || OUTPUT_DIR; console.error(`Exporting all meetings to ${targetDir}...`); const meetings = await fathom.getAllMeetings({ include_transcript: true, include_summary: true, include_action_items: true, include_crm_matches: true, created_after, created_before, }); await fs.mkdir(targetDir, { recursive: true }); const exported: string[] = []; for (const meeting of meetings) { const markdown = formatMeetingToMarkdown(meeting); const filename = generateTranscriptFilename(meeting); const filepath = path.join(targetDir, filename); await fs.writeFile(filepath, markdown, 'utf-8'); exported.push(filename); console.error(`Exported: ${filename}`); } return { content: [{ type: 'text', text: `Exported ${exported.length} meetings to ${targetDir}:\n${exported.map(f => `- ${f}`).join('\n')}`, }], }; } ); // ============================================================================ // TOOL: search_meetings // ============================================================================ server.tool( 'search_meetings', { participant_emails: z.array(z.string()).optional().describe('Email addresses of participants to search for'), domains: z.array(z.string()).optional().describe('Company domains to search for (e.g., acme.com)'), teams: z.array(z.string()).optional().describe('Team names to filter by'), created_after: z.string().optional().describe('ISO 8601 timestamp'), created_before: z.string().optional().describe('ISO 8601 timestamp'), }, async ({ participant_emails, domains, teams, created_after, created_before }) => { console.error('Searching meetings...'); const response = await fathom.listMeetings({ calendar_invitees: participant_emails, calendar_invitees_domains: domains, teams, created_after, created_before, }); console.error(`Found ${response.items.length} meetings`); const markdown = formatMeetingList(response.items); return { content: [{ type: 'text', text: markdown }], }; } ); // ============================================================================ // TOOL: get_action_items // ============================================================================ server.tool( 'get_action_items', { recording_id: z.number().optional().describe('Get action items from a specific meeting'), include_completed: z.boolean().optional().describe('Include completed action items (default: true)'), limit: z.number().optional().describe('Number of recent meetings to check if no recording_id (default: 10)'), }, async ({ recording_id, include_completed, limit }) => { const meetingLimit = limit || 10; console.error(recording_id ? `Getting action items for ${recording_id}...` : `Getting action items from recent meetings...`); if (recording_id) { const response = await fathom.listMeetings({ include_action_items: true }); const meeting = response.items.find(m => m.recording_id === recording_id); if (!meeting) { return { content: [{ type: 'text', text: `Meeting with recording ID ${recording_id} not found.` }], isError: true, }; } let items = meeting.action_items || []; if (include_completed === false) { items = items.filter(i => !i.completed); } const title = meeting.title || meeting.meeting_title || `Meeting ${meeting.recording_id}`; const markdown = `## Action Items from: ${title}\n\n${formatActionItems(items)}`; return { content: [{ type: 'text', text: markdown }], }; } else { const response = await fathom.listMeetings({ include_action_items: true }); const meetings = response.items.slice(0, meetingLimit); const sections: string[] = []; for (const meeting of meetings) { let items = meeting.action_items || []; if (items.length === 0) continue; if (include_completed === false) { items = items.filter(i => !i.completed); } if (items.length === 0) continue; const title = meeting.title || meeting.meeting_title || `Meeting ${meeting.recording_id}`; const date = formatDate(meeting.recording_start_time); sections.push(`### ${title}\n_${date}_\n\n${formatActionItems(items)}`); } const markdown = sections.length > 0 ? `# Action Items from Recent Meetings\n\n${sections.join('\n\n')}` : 'No action items found in recent meetings.'; return { content: [{ type: 'text', text: markdown }], }; } } ); // ============================================================================ // TOOL: list_teams // ============================================================================ server.tool( 'list_teams', {}, async () => { console.error('Fetching teams...'); const teams = await fathom.getAllTeams(); if (teams.length === 0) { return { content: [{ type: 'text', text: 'No teams found.' }], }; } const markdown = teams.map(t => { const created = formatDate(t.created_at); return `- **${t.name}** (created: ${created})`; }).join('\n'); console.error(`Found ${teams.length} teams`); return { content: [{ type: 'text', text: `# Teams\n\n${markdown}` }], }; } ); // ============================================================================ // TOOL: list_team_members // ============================================================================ server.tool( 'list_team_members', { team: z.string().optional().describe('Filter by team name'), }, async ({ team }) => { console.error(team ? `Fetching members for team: ${team}...` : 'Fetching all team members...'); const members = await fathom.getAllTeamMembers(team); if (members.length === 0) { return { content: [{ type: 'text', text: team ? `No members found in team "${team}".` : 'No team members found.' }], }; } const markdown = members.map(m => `- **${m.name}** <${m.email}>`).join('\n'); const title = team ? `Team Members: ${team}` : 'All Team Members'; console.error(`Found ${members.length} members`); return { content: [{ type: 'text', text: `# ${title}\n\n${markdown}` }], }; } ); // ============================================================================ // TOOL: create_webhook // ============================================================================ server.tool( 'create_webhook', { destination_url: z.string().describe('URL to receive webhook events'), include_transcript: z.boolean().optional().describe('Include transcript in webhook payload'), include_summary: z.boolean().optional().describe('Include summary in webhook payload'), include_action_items: z.boolean().optional().describe('Include action items in webhook payload'), include_crm_matches: z.boolean().optional().describe('Include CRM matches in webhook payload'), triggered_for: z.array(z.enum(['my_recordings', 'shared_external_recordings', 'my_shared_with_team_recordings', 'shared_team_recordings'])).optional().describe('Which recordings trigger the webhook'), }, async ({ destination_url, include_transcript, include_summary, include_action_items, include_crm_matches, triggered_for }) => { console.error(`Creating webhook for ${destination_url}...`); const webhook = await fathom.createWebhook({ destination_url, include_transcript, include_summary, include_action_items, include_crm_matches, triggered_for, }); const markdown = `# Webhook Created Successfully\n\n| Field | Value |\n|-------|-------|\n| **ID** | ${webhook.id} |\n| **URL** | ${webhook.url} |\n| **Secret** | \`${webhook.secret}\` |\n| **Include Transcript** | ${webhook.include_transcript} |\n| **Include Summary** | ${webhook.include_summary} |\n| **Include Action Items** | ${webhook.include_action_items} |\n| **Include CRM Matches** | ${webhook.include_crm_matches} |\n| **Triggered For** | ${webhook.triggered_for.join(', ')} |\n\n**Important:** Save the webhook secret securely - you'll need it to verify incoming webhooks.`; console.error(`Webhook created: ${webhook.id}`); return { content: [{ type: 'text', text: markdown }], }; } ); // ============================================================================ // TOOL: delete_webhook // ============================================================================ server.tool( 'delete_webhook', { webhook_id: z.string().describe('The ID of the webhook to delete'), }, async ({ webhook_id }) => { console.error(`Deleting webhook ${webhook_id}...`); await fathom.deleteWebhook(webhook_id); console.error('Webhook deleted'); return { content: [{ type: 'text', text: `Webhook ${webhook_id} deleted successfully.` }], }; } ); // ============================================================================ // START SERVER // ============================================================================ async function main() { console.error('Connecting to MCP transport...'); const transport = new StdioServerTransport(); await server.connect(transport); console.error('Fathom MCP server is running and ready!'); } // Global error handlers (same as postmark-mcp) process.on('uncaughtException', (error) => { console.error('Uncaught exception:', error.message); process.exit(1); }); process.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason instanceof Error ? reason.message : reason); process.exit(1); }); main().catch((error) => { console.error('Server initialization failed:', error.message); process.exit(1); });

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/matthewbergvinson/fathom-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server