Skip to main content
Glama
index.ts24.1 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, Tool, } from '@modelcontextprotocol/sdk/types.js'; import { config } from 'dotenv'; import { TogglAPI } from './toggl-api.js'; import { CacheManager } from './cache-manager.js'; import { getDateRange, generateDailyReport, generateWeeklyReport, formatReportForDisplay, secondsToHours, groupEntriesByProject, groupEntriesByWorkspace, generateProjectSummary, generateWorkspaceSummary } from './utils.js'; import type { CacheConfig, TimeEntry } from './types.js'; // Version for CLI output and server metadata const VERSION = '1.0.0'; // Basic CLI flags: --help / -h and --version / -v const argv = process.argv.slice(2); if (argv.includes('--version') || argv.includes('-v')) { console.log(`mcp-toggl version ${VERSION}`); process.exit(0); } if (argv.includes('--help') || argv.includes('-h')) { console.log(`mcp-toggl - Toggl MCP Server\n\n` + `Usage:\n` + ` npx @verygoodplugins/mcp-toggl@latest [--help] [--version]\n\n` + `Environment:\n` + ` TOGGL_API_KEY Required Toggl API token\n` + ` TOGGL_DEFAULT_WORKSPACE_ID Optional default workspace id\n` + ` TOGGL_CACHE_TTL Cache TTL in ms (default: 3600000)\n` + ` TOGGL_CACHE_SIZE Max cached entities (default: 1000)\n\n` + `Claude Desktop (claude_desktop_config.json):\n` + ` {\n` + ` "mcpServers": {\n` + ` "mcp-toggl": {\n` + ` "command": "npx @verygoodplugins/mcp-toggl@latest",\n` + ` "env": { "TOGGL_API_KEY": "your_api_key_here" }\n` + ` }\n` + ` }\n` + ` }\n\n` + `Cursor (~/.cursor/mcp.json):\n` + ` {\n` + ` "mcp": {\n` + ` "servers": {\n` + ` "mcp-toggl": {\n` + ` "command": "npx",\n` + ` "args": ["@verygoodplugins/mcp-toggl@latest"],\n` + ` "env": { "TOGGL_API_KEY": "your_api_key_here" }\n` + ` }\n` + ` }\n` + ` }\n` + ` }\n`); process.exit(0); } // Load environment variables config(); // Validate required environment variables // Support a few aliases for convenience/backward-compat const RAW_API_KEY = process.env.TOGGL_API_KEY || process.env.TOGGL_API_TOKEN || process.env.TOGGL_TOKEN; const API_KEY = RAW_API_KEY?.trim(); if (!API_KEY) { console.error('Missing required environment variable: TOGGL_API_KEY'); console.error('Also accepted: TOGGL_API_TOKEN or TOGGL_TOKEN'); process.exit(1); } if (process.env.TOGGL_API_TOKEN || process.env.TOGGL_TOKEN) { console.warn('Using TOGGL_API_TOKEN/TOGGL_TOKEN. Prefer TOGGL_API_KEY going forward.'); } // Initialize configuration const cacheConfig: CacheConfig = { ttl: parseInt(process.env.TOGGL_CACHE_TTL || '3600000'), maxSize: parseInt(process.env.TOGGL_CACHE_SIZE || '1000'), batchSize: parseInt(process.env.TOGGL_BATCH_SIZE || '100') }; const defaultWorkspaceId = process.env.TOGGL_DEFAULT_WORKSPACE_ID ? parseInt(process.env.TOGGL_DEFAULT_WORKSPACE_ID) : undefined; // Initialize API and cache const api = new TogglAPI(API_KEY); const cache = new CacheManager(cacheConfig); cache.setAPI(api); // Track if cache has been warmed let cacheWarmed = false; // Helper to ensure cache is warm async function ensureCache(): Promise<void> { if (!cacheWarmed) { try { await cache.warmCache(defaultWorkspaceId); cacheWarmed = true; } catch (error) { console.error('Failed to warm cache:', error); } } } // Create MCP server const server = new Server( { name: 'mcp-toggl', version: VERSION, }, { capabilities: { tools: {}, }, } ); // Define tool schemas const tools: Tool[] = [ // Health/authentication { name: 'toggl_check_auth', description: 'Verify Toggl API connectivity and authentication is valid', inputSchema: { type: 'object', properties: {}, required: [] }, }, // Time tracking tools { name: 'toggl_get_time_entries', description: 'Get time entries with optional date range filters. Returns hydrated entries with project/workspace names.', inputSchema: { type: 'object', properties: { period: { type: 'string', enum: ['today', 'yesterday', 'week', 'lastWeek', 'month', 'lastMonth'], description: 'Predefined period to fetch entries for' }, start_date: { type: 'string', description: 'Start date (YYYY-MM-DD format)' }, end_date: { type: 'string', description: 'End date (YYYY-MM-DD format)' }, workspace_id: { type: 'number', description: 'Filter by workspace ID' }, project_id: { type: 'number', description: 'Filter by project ID' } } }, }, { name: 'toggl_get_current_entry', description: 'Get the currently running time entry, if any', inputSchema: { type: 'object', properties: {}, required: [] }, }, { name: 'toggl_start_timer', description: 'Start a new time entry timer', inputSchema: { type: 'object', properties: { description: { type: 'string', description: 'Description of the time entry' }, workspace_id: { type: 'number', description: 'Workspace ID (uses default if not provided)' }, project_id: { type: 'number', description: 'Project ID (optional)' }, task_id: { type: 'number', description: 'Task ID (optional)' }, tags: { type: 'array', items: { type: 'string' }, description: 'Tags for the entry' } } }, }, { name: 'toggl_stop_timer', description: 'Stop the currently running timer', inputSchema: { type: 'object', properties: {}, required: [] }, }, // Reporting tools { name: 'toggl_daily_report', description: 'Generate a daily report with hours by project and workspace', inputSchema: { type: 'object', properties: { date: { type: 'string', description: 'Date for report (YYYY-MM-DD format, defaults to today)' }, format: { type: 'string', enum: ['json', 'text'], description: 'Output format (default: json)' } } }, }, { name: 'toggl_weekly_report', description: 'Generate a weekly report with daily breakdown and project summaries', inputSchema: { type: 'object', properties: { week_offset: { type: 'number', description: 'Week offset from current week (0 = this week, -1 = last week)' }, format: { type: 'string', enum: ['json', 'text'], description: 'Output format (default: json)' } } }, }, { name: 'toggl_project_summary', description: 'Get total hours per project for a date range', inputSchema: { type: 'object', properties: { period: { type: 'string', enum: ['week', 'lastWeek', 'month', 'lastMonth'], description: 'Predefined period' }, start_date: { type: 'string', description: 'Start date (YYYY-MM-DD format)' }, end_date: { type: 'string', description: 'End date (YYYY-MM-DD format)' }, workspace_id: { type: 'number', description: 'Filter by workspace ID' } } }, }, { name: 'toggl_workspace_summary', description: 'Get total hours per workspace for a date range', inputSchema: { type: 'object', properties: { period: { type: 'string', enum: ['week', 'lastWeek', 'month', 'lastMonth'], description: 'Predefined period' }, start_date: { type: 'string', description: 'Start date (YYYY-MM-DD format)' }, end_date: { type: 'string', description: 'End date (YYYY-MM-DD format)' } } }, }, // Management tools { name: 'toggl_list_workspaces', description: 'List all available workspaces', inputSchema: { type: 'object', properties: {}, required: [] }, }, { name: 'toggl_list_projects', description: 'List projects for a workspace', inputSchema: { type: 'object', properties: { workspace_id: { type: 'number', description: 'Workspace ID (uses default if not provided)' } } }, }, { name: 'toggl_list_clients', description: 'List clients for a workspace', inputSchema: { type: 'object', properties: { workspace_id: { type: 'number', description: 'Workspace ID (uses default if not provided)' } } }, }, // Cache management { name: 'toggl_warm_cache', description: 'Pre-fetch and cache workspace, project, and client data for better performance', inputSchema: { type: 'object', properties: { workspace_id: { type: 'number', description: 'Specific workspace to warm cache for' } } }, }, { name: 'toggl_cache_stats', description: 'Get cache statistics and performance metrics', inputSchema: { type: 'object', properties: {}, required: [] }, }, { name: 'toggl_clear_cache', description: 'Clear all cached data', inputSchema: { type: 'object', properties: {}, required: [] }, } ]; // Handle tool listing server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { // Health/authentication case 'toggl_check_auth': { const me = await api.getMe(); const workspaces = await api.getWorkspaces(); const maskEmail = (e?: string) => { if (!e) return undefined as unknown as string; const [user, domain] = e.split('@'); if (!domain) return '***'; const u = user.length <= 2 ? '*'.repeat(user.length) : `${user[0]}***${user.slice(-1)}`; return `${u}@${domain}`; }; return { content: [{ type: 'text', text: JSON.stringify({ authenticated: true, user: { id: (me as any).id, email: maskEmail((me as any).email), fullname: (me as any).fullname }, workspaces: workspaces.map(w => ({ id: w.id, name: w.name })), }, null, 2) }] }; } // Time tracking tools case 'toggl_get_time_entries': { await ensureCache(); let entries: TimeEntry[]; if (args?.period) { const range = getDateRange(args.period as any); entries = await api.getTimeEntriesForDateRange(range.start, range.end); } else if (args?.start_date || args?.end_date) { const start = args?.start_date ? new Date(args.start_date as string) : new Date(); const end = args?.end_date ? new Date(args.end_date as string) : new Date(); entries = await api.getTimeEntriesForDateRange(start, end); } else { entries = await api.getTimeEntriesForToday(); } // Filter by workspace/project if specified if (args?.workspace_id) { entries = entries.filter(e => e.workspace_id === args.workspace_id); } if (args?.project_id) { entries = entries.filter(e => e.project_id === args.project_id); } // Hydrate with names const hydrated = await cache.hydrateTimeEntries(entries); return { content: [{ type: 'text', text: JSON.stringify({ count: hydrated.length, entries: hydrated }, null, 2) }] }; } case 'toggl_get_current_entry': { const entry = await api.getCurrentTimeEntry(); if (!entry) { return { content: [{ type: 'text', text: JSON.stringify({ running: false, message: 'No timer currently running' }) }] }; } await ensureCache(); const hydrated = await cache.hydrateTimeEntries([entry]); return { content: [{ type: 'text', text: JSON.stringify({ running: true, entry: hydrated[0] }, null, 2) }] }; } case 'toggl_start_timer': { const workspaceId = args?.workspace_id || defaultWorkspaceId; if (!workspaceId) { throw new Error('Workspace ID required (set TOGGL_DEFAULT_WORKSPACE_ID or provide workspace_id)'); } const entry = await api.startTimer( workspaceId as number, args?.description as string | undefined, args?.project_id as number | undefined, args?.task_id as number | undefined, args?.tags as string[] | undefined ); await ensureCache(); const hydrated = await cache.hydrateTimeEntries([entry]); return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Timer started', entry: hydrated[0] }, null, 2) }] }; } case 'toggl_stop_timer': { const current = await api.getCurrentTimeEntry(); if (!current) { return { content: [{ type: 'text', text: JSON.stringify({ success: false, message: 'No timer currently running' }) }] }; } const stopped = await api.stopTimer(current.workspace_id, current.id); await ensureCache(); const hydrated = await cache.hydrateTimeEntries([stopped]); return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Timer stopped', entry: hydrated[0] }, null, 2) }] }; } // Reporting tools case 'toggl_daily_report': { await ensureCache(); const date = args?.date ? new Date(args.date as string) : new Date(); const nextDay = new Date(date); nextDay.setDate(nextDay.getDate() + 1); const entries = await api.getTimeEntriesForDateRange(date, nextDay); const hydrated = await cache.hydrateTimeEntries(entries); const report = generateDailyReport(date.toISOString().split('T')[0], hydrated); if (args?.format === 'text') { return { content: [{ type: 'text', text: formatReportForDisplay(report) }] }; } return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] }; } case 'toggl_weekly_report': { await ensureCache(); const weekOffset = (args?.week_offset as number) || 0; const entries = await api.getTimeEntriesForWeek(weekOffset); const hydrated = await cache.hydrateTimeEntries(entries); // Calculate week boundaries const today = new Date(); const dayOfWeek = today.getDay(); const diff = today.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); const monday = new Date(today.setDate(diff)); monday.setDate(monday.getDate() + (weekOffset * 7)); const sunday = new Date(monday); sunday.setDate(sunday.getDate() + 6); const report = generateWeeklyReport(monday, sunday, hydrated); if (args?.format === 'text') { return { content: [{ type: 'text', text: formatReportForDisplay(report) }] }; } return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] }; } case 'toggl_project_summary': { await ensureCache(); let entries: TimeEntry[]; if (args?.period) { const range = getDateRange(args.period as any); entries = await api.getTimeEntriesForDateRange(range.start, range.end); } else if (args?.start_date && args?.end_date) { const start = new Date(args.start_date as string); const end = new Date(args.end_date as string); entries = await api.getTimeEntriesForDateRange(start, end); } else { // Default to current week entries = await api.getTimeEntriesForWeek(0); } if (args?.workspace_id) { entries = entries.filter(e => e.workspace_id === args.workspace_id); } const hydrated = await cache.hydrateTimeEntries(entries); const byProject = groupEntriesByProject(hydrated); const summaries: any[] = []; byProject.forEach((projectEntries, projectName) => { summaries.push(generateProjectSummary(projectName, projectEntries)); }); // Sort by total hours descending summaries.sort((a, b) => b.total_seconds - a.total_seconds); return { content: [{ type: 'text', text: JSON.stringify({ project_count: summaries.length, total_hours: secondsToHours(summaries.reduce((t, s) => t + s.total_seconds, 0)), projects: summaries }, null, 2) }] }; } case 'toggl_workspace_summary': { await ensureCache(); let entries: TimeEntry[]; if (args?.period) { const range = getDateRange(args.period as any); entries = await api.getTimeEntriesForDateRange(range.start, range.end); } else if (args?.start_date && args?.end_date) { const start = new Date(args.start_date as string); const end = new Date(args.end_date as string); entries = await api.getTimeEntriesForDateRange(start, end); } else { // Default to current week entries = await api.getTimeEntriesForWeek(0); } const hydrated = await cache.hydrateTimeEntries(entries); const byWorkspace = groupEntriesByWorkspace(hydrated); const summaries: any[] = []; byWorkspace.forEach((wsEntries, wsName) => { const wsId = wsEntries[0]?.workspace_id || 0; summaries.push(generateWorkspaceSummary(wsName, wsId, wsEntries)); }); // Sort by total hours descending summaries.sort((a, b) => b.total_seconds - a.total_seconds); return { content: [{ type: 'text', text: JSON.stringify({ workspace_count: summaries.length, total_hours: secondsToHours(summaries.reduce((t, s) => t + s.total_seconds, 0)), workspaces: summaries }, null, 2) }] }; } // Management tools case 'toggl_list_workspaces': { const workspaces = await api.getWorkspaces(); return { content: [{ type: 'text', text: JSON.stringify({ count: workspaces.length, workspaces: workspaces.map(ws => ({ id: ws.id, name: ws.name, premium: ws.premium, default_currency: ws.default_currency })) }, null, 2) }] }; } case 'toggl_list_projects': { const workspaceId = args?.workspace_id || defaultWorkspaceId; if (!workspaceId) { throw new Error('Workspace ID required (set TOGGL_DEFAULT_WORKSPACE_ID or provide workspace_id)'); } const projects = await api.getProjects(workspaceId as number); return { content: [{ type: 'text', text: JSON.stringify({ workspace_id: workspaceId, count: projects.length, projects: projects.map(p => ({ id: p.id, name: p.name, active: p.active, billable: p.billable, color: p.color, client_id: p.client_id })) }, null, 2) }] }; } case 'toggl_list_clients': { const workspaceId = args?.workspace_id || defaultWorkspaceId; if (!workspaceId) { throw new Error('Workspace ID required (set TOGGL_DEFAULT_WORKSPACE_ID or provide workspace_id)'); } const clients = await api.getClients(workspaceId as number); return { content: [{ type: 'text', text: JSON.stringify({ workspace_id: workspaceId, count: clients.length, clients: clients.map(c => ({ id: c.id, name: c.name, archived: c.archived })) }, null, 2) }] }; } // Cache management case 'toggl_warm_cache': { const workspaceId = (args?.workspace_id as number | undefined) || defaultWorkspaceId; await cache.warmCache(workspaceId); cacheWarmed = true; const stats = cache.getStats(); return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Cache warmed successfully', stats }, null, 2) }] }; } case 'toggl_cache_stats': { const stats = cache.getStats(); const hitRate = stats.hits + stats.misses > 0 ? Math.round((stats.hits / (stats.hits + stats.misses)) * 100) : 0; return { content: [{ type: 'text', text: JSON.stringify({ ...stats, hit_rate: `${hitRate}%`, cache_warmed: cacheWarmed }, null, 2) }] }; } case 'toggl_clear_cache': { cache.clearCache(); cacheWarmed = false; return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Cache cleared successfully' }) }] }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error: any) { return { content: [{ type: 'text', text: JSON.stringify({ error: true, message: error.message || 'An error occurred', details: error.stack }, null, 2) }] }; } }); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('Toggl MCP server running'); } main().catch((error) => { console.error('Server error:', error); 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/verygoodplugins/mcp-toggl'

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