UNPKG

limitless-mcp

Version:

MCP server for Limitless API - Connect your Pendant data to Claude and other LLMs

1,023 lines (1,019 loc) 123 kB
#!/usr/bin/env node import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError, ErrorCode, getErrorStatusCode, getErrorMessage, enhanceError } from './utils/errors.js'; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { request } from "undici"; import { z } from "zod"; import NodeCache from "node-cache"; import { initializePlugins } from "./plugins/index.js"; // ────────────────────────────────────────────────────────────────────────────── // Main function that runs the MCP server // ────────────────────────────────────────────────────────────────────────────── async function main() { // Read environment variables for configuration const API_KEY = process.env.LIMITLESS_API_KEY; if (!API_KEY) { console.error("Error: LIMITLESS_API_KEY environment variable is not set"); console.error("Please set it to your Limitless API key"); process.exit(1); } // API configuration with defaults const API_BASE_URL = process.env.LIMITLESS_API_BASE_URL || "https://api.limitless.ai/v1"; const API_TIMEOUT_MS = parseInt(process.env.LIMITLESS_API_TIMEOUT_MS || "120000", 10); // 2 minutes default const API_MAX_RETRIES = parseInt(process.env.LIMITLESS_API_MAX_RETRIES || "3", 10); // Default to 3 retries // Pagination and result limits const MAX_LIFELOG_LIMIT = parseInt(process.env.LIMITLESS_MAX_LIFELOG_LIMIT || "100", 10); // Max 100 results per request const DEFAULT_PAGE_SIZE = parseInt(process.env.LIMITLESS_DEFAULT_PAGE_SIZE || "10", 10); // Default page size const MAX_SEARCH_MULTIPLIER = parseFloat(process.env.LIMITLESS_SEARCH_MULTIPLIER || "3"); // Default search results multiplier // Cache configuration with defaults const CACHE_TTL = parseInt(process.env.LIMITLESS_CACHE_TTL || "300", 10); // 5 minutes default const CACHE_CHECK_PERIOD = parseInt(process.env.LIMITLESS_CACHE_CHECK_PERIOD || "600", 10); // 10 minutes default const CACHE_MAX_KEYS = parseInt(process.env.LIMITLESS_CACHE_MAX_KEYS || "500", 10); // Max 500 entries default // Cache TTL multipliers for different data types const CACHE_TTL_MULTIPLIERS = { METADATA: parseFloat(process.env.CACHE_TTL_METADATA || "3"), // Metadata cached 3x longer by default LISTINGS: parseFloat(process.env.CACHE_TTL_LISTINGS || "2"), // Listings cached 2x longer by default SEARCH: parseFloat(process.env.CACHE_TTL_SEARCH || "1.5"), // Search results cached 1.5x longer by default SUMMARIES: parseFloat(process.env.CACHE_TTL_SUMMARIES || "4") // Summaries cached 4x longer by default (they're expensive to regenerate) }; // Initialize cache const cache = new NodeCache({ stdTTL: CACHE_TTL, checkperiod: CACHE_CHECK_PERIOD, maxKeys: CACHE_MAX_KEYS, useClones: true }); // Log configuration to stderr for debugging console.error(` ====================================== Limitless MCP Server Configuration ====================================== API Base URL: ${API_BASE_URL} API Timeout: ${API_TIMEOUT_MS}ms API Max Retries: ${API_MAX_RETRIES} Max Results: ${MAX_LIFELOG_LIMIT} Default Page Size: ${DEFAULT_PAGE_SIZE} Search Multiplier: ${MAX_SEARCH_MULTIPLIER}x Cache TTL: ${CACHE_TTL}s Cache Check Period: ${CACHE_CHECK_PERIOD}s Cache Max Keys: ${CACHE_MAX_KEYS} Cache TTL Multipliers: - Metadata: ${CACHE_TTL_MULTIPLIERS.METADATA}x - Listings: ${CACHE_TTL_MULTIPLIERS.LISTINGS}x - Search: ${CACHE_TTL_MULTIPLIERS.SEARCH}x - Summaries: ${CACHE_TTL_MULTIPLIERS.SUMMARIES}x ====================================== `); // Cache statistics reporting setInterval(() => { const stats = cache.getStats(); console.error(`Cache stats: ${stats.keys} keys, ${stats.hits} hits, ${stats.misses} misses, Hit rate: ${(stats.hits / (stats.hits + stats.misses) || 0).toFixed(2)}`); }, 300000); // Report every 5 minutes // Function to call the Limitless API with configurable settings const call = async (path, qs = {}, useCache = true) => { // Build cache key based on path and query params const cacheParams = new URLSearchParams(); // Sort keys for consistent cache key generation Object.keys(qs).sort().forEach(key => { const value = qs[key]; if (value !== undefined && value !== null) { cacheParams.append(key, String(value)); } }); const cacheKey = `${path}?${cacheParams.toString()}`; // Check cache if enabled if (useCache) { const cachedData = cache.get(cacheKey); if (cachedData) { console.error(`Cache hit for: ${cacheKey}`); return cachedData; } console.error(`Cache miss for: ${cacheKey}`); } // Convert all query parameter values to strings for API call const params = new URLSearchParams(); Object.entries(qs).forEach(([key, value]) => { if (value !== undefined && value !== null) { params.append(key, String(value)); } }); try { // Apply configured timeout and retry logic const requestOptions = { headers: { "X-API-Key": API_KEY }, bodyTimeout: API_TIMEOUT_MS, headersTimeout: API_TIMEOUT_MS }; // Make the API request with retry logic let response; let retryCount = 0; let lastError; while (retryCount <= API_MAX_RETRIES) { try { if (retryCount > 0) { console.error(`Retry attempt ${retryCount}/${API_MAX_RETRIES} for ${path}`); // Exponential backoff: 1s, 2s, 4s, etc. await new Promise(resolve => setTimeout(resolve, Math.pow(2, retryCount - 1) * 1000)); } response = await request(`${API_BASE_URL}${path}?${params}`, requestOptions); break; // Success - exit retry loop } catch (err) { lastError = err; // Only retry on network errors or 5xx errors const errWithStatus = err; if (errWithStatus.statusCode && errWithStatus.statusCode < 500) { throw err; // Don't retry client errors (4xx) } retryCount++; if (retryCount > API_MAX_RETRIES) { console.error(`All ${API_MAX_RETRIES} retry attempts failed for ${path}`); throw err; } } } // If we're here without response, throw the last error if (!response) { throw lastError || new Error(`API call failed with no response: ${path}`); } // Check if the response is successful (status code 200-299) if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { throw new Error(`HTTP error: ${response.statusCode}`); } const data = await response.body.json(); // Store in cache if enabled if (useCache) { // Calculate custom TTL based on data type // Different data types have different optimal cache durations let ttl = CACHE_TTL; // Single lifelog metadata (cached longer since they rarely change) if (path.includes('/lifelogs/') && !qs.includeMarkdown) { ttl = CACHE_TTL * CACHE_TTL_MULTIPLIERS.METADATA; console.error(`Using metadata TTL multiplier: ${CACHE_TTL_MULTIPLIERS.METADATA}x`); } // Lifelog listings (cached moderately long) else if (path === '/lifelogs' && data.data.lifelogs && data.data.lifelogs.length > 0) { ttl = CACHE_TTL * CACHE_TTL_MULTIPLIERS.LISTINGS; console.error(`Using listings TTL multiplier: ${CACHE_TTL_MULTIPLIERS.LISTINGS}x`); } // Search results (moderate caching) else if (qs.query) { ttl = CACHE_TTL * CACHE_TTL_MULTIPLIERS.SEARCH; console.error(`Using search TTL multiplier: ${CACHE_TTL_MULTIPLIERS.SEARCH}x`); } // Add tags to the cache entry for better management const tags = []; if (path.includes('/lifelogs/')) { tags.push('single_lifelog'); if (qs.includeMarkdown) { tags.push('full_content'); } else { tags.push('metadata_only'); } } else if (path === '/lifelogs') { tags.push('lifelog_listings'); if (qs.date) tags.push(`date:${qs.date}`); } // Store in cache with calculated TTL cache.set(cacheKey, data, ttl); console.error(`Cached data for: ${cacheKey} with TTL ${ttl}s (tags: ${tags.join(', ')})`); } return data; } catch (error) { console.error("API call error:", error); // Add status code to the error object for better error handling const enhancedError = enhanceError(error); throw enhancedError; } }; // ────────────────────────────────────────────────────────────────────────────── // 1. Spin up the server object // ────────────────────────────────────────────────────────────────────────────── const server = new McpServer({ name: "limitless", version: "0.4.0" }); // ────────────────────────────────────────────────────────────────────────────── // 2. Resources (virtual markdown files for Claude to read) // ────────────────────────────────────────────────────────────────────────────── server.resource("lifelogs", new ResourceTemplate("lifelogs://{id}", { list: async () => { const response = await call("/lifelogs", { limit: 25 }); const logs = response.data.lifelogs || []; return { resources: logs.map((l) => ({ name: l.title, uri: `lifelogs://${l.id}`, description: l.title })) }; } }), {}, async (uri) => { const id = uri.host; // lifelogs://<id> const response = await call(`/lifelogs/${id}`); const lifelog = response.data.lifelog; return { contents: [{ uri: uri.href, text: lifelog?.markdown ?? "(empty)" }] }; }); // ────────────────────────────────────────────────────────────────────────────── // 3. Tools (callable functions) // ────────────────────────────────────────────────────────────────────────────── // Tool to get cache information or clear the cache server.tool("manage_cache", { action: z.enum(["stats", "clear", "clear_type", "config"]).default("stats").describe("Action to perform on the cache"), type: z.string().optional().describe("Cache type to clear (for clear_type action)") }, async ({ action, type }) => { // Handle different actions switch (action) { case "clear": const keysCount = cache.keys().length; cache.flushAll(); return { content: [{ type: "text", text: `Cache cleared successfully. ${keysCount} entries removed.` }] }; case "clear_type": if (!type) { throw new McpError(ErrorCode.InvalidParams, "Type parameter is required for clear_type action", { action, type }); } // Get all keys const allKeys = cache.keys(); // Filter keys that match the specified type const keysToDelete = allKeys.filter(key => { if (type === "full_lifelog" && key.includes('/lifelogs/') && key.includes('includeMarkdown=true')) { return true; } else if (type === "metadata" && key.includes('/lifelogs/') && !key.includes('includeMarkdown=true')) { return true; } else if (type === "listings" && key.startsWith('/lifelogs') && !key.includes('/lifelogs/')) { return true; } else if (type === "search" && key.includes('query=')) { return true; } else if (key.includes(type)) { // Generic matching based on substring return true; } return false; }); // Delete the matched keys keysToDelete.forEach(key => cache.del(key)); return { content: [{ type: "text", text: `Selectively cleared ${keysToDelete.length} cache entries of type '${type}'.\n\n` + `Valid types include: full_lifelog, metadata, listings, search, or any custom substring.` }] }; case "config": // Show current cache configuration return { content: [{ type: "text", text: `# Cache Configuration\n\n` + `## API Configuration\n` + `- **API Base URL**: ${API_BASE_URL}\n` + `- **API Timeout**: ${API_TIMEOUT_MS}ms (${API_TIMEOUT_MS / 1000} seconds)\n` + `- **Max Retries**: ${API_MAX_RETRIES}\n\n` + `## Pagination & Limits\n` + `- **Max Results**: ${MAX_LIFELOG_LIMIT}\n` + `- **Default Page Size**: ${DEFAULT_PAGE_SIZE}\n` + `- **Search Multiplier**: ${MAX_SEARCH_MULTIPLIER}x\n\n` + `## Cache Settings\n` + `- **TTL**: ${CACHE_TTL}s (${CACHE_TTL / 60} minutes)\n` + `- **Check Period**: ${CACHE_CHECK_PERIOD}s (${CACHE_CHECK_PERIOD / 60} minutes)\n` + `- **Max Keys**: ${CACHE_MAX_KEYS}\n\n` + `## TTL Multipliers\n` + `- **Metadata**: ${CACHE_TTL_MULTIPLIERS.METADATA}x (${CACHE_TTL * CACHE_TTL_MULTIPLIERS.METADATA}s)\n` + `- **Listings**: ${CACHE_TTL_MULTIPLIERS.LISTINGS}x (${CACHE_TTL * CACHE_TTL_MULTIPLIERS.LISTINGS}s)\n` + `- **Search**: ${CACHE_TTL_MULTIPLIERS.SEARCH}x (${CACHE_TTL * CACHE_TTL_MULTIPLIERS.SEARCH}s)\n` + `- **Summaries**: ${CACHE_TTL_MULTIPLIERS.SUMMARIES}x (${CACHE_TTL * CACHE_TTL_MULTIPLIERS.SUMMARIES}s)\n\n` + `These settings can be configured via the following environment variables:\n` + `- LIMITLESS_API_KEY (required)\n` + `- LIMITLESS_API_BASE_URL\n` + `- LIMITLESS_API_TIMEOUT_MS\n` + `- LIMITLESS_API_MAX_RETRIES\n` + `- LIMITLESS_MAX_LIFELOG_LIMIT\n` + `- LIMITLESS_DEFAULT_PAGE_SIZE\n` + `- LIMITLESS_SEARCH_MULTIPLIER\n` + `- LIMITLESS_CACHE_TTL\n` + `- LIMITLESS_CACHE_CHECK_PERIOD\n` + `- LIMITLESS_CACHE_MAX_KEYS\n` + `- CACHE_TTL_METADATA\n` + `- CACHE_TTL_LISTINGS\n` + `- CACHE_TTL_SEARCH\n` + `- CACHE_TTL_SUMMARIES` }] }; case "stats": default: const stats = cache.getStats(); const keys = cache.keys(); // Enhanced type detection const keysByType = keys.reduce((acc, key) => { let type; if (key.includes('/lifelogs/')) { if (key.includes('includeMarkdown=true')) { type = 'full_lifelog'; } else { type = 'lifelog_metadata'; } } else if (key.startsWith('/lifelogs')) { if (key.includes('date=')) { type = 'date_filtered_listings'; } else { type = 'lifelog_listings'; } } else if (key.includes('query=')) { type = 'search_results'; } else { type = 'other'; } acc[type] = (acc[type] || 0) + 1; return acc; }, {}); // Calculate hit ratio and cache efficiency metrics const hitRatio = stats.hits + stats.misses > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : "0.00"; // Calculate average key age if possible (not directly supported by NodeCache) const keysSample = keys.slice(0, Math.min(keys.length, 10)); const keyAges = keysSample.map(key => { const ttl = cache.getTtl(key); if (ttl) { return Math.round((ttl - Date.now()) / 1000); } return 0; }).filter(age => age > 0); const avgAge = keyAges.length > 0 ? (keyAges.reduce((sum, age) => sum + age, 0) / keyAges.length).toFixed(0) : "unknown"; // Enhanced stats output return { content: [{ type: "text", text: `# Cache Statistics\n\n` + `## Performance Metrics\n` + `- **Total Keys**: ${stats.keys}\n` + `- **Hits**: ${stats.hits}\n` + `- **Misses**: ${stats.misses}\n` + `- **Hit Ratio**: ${hitRatio}%\n` + `- **Avg. TTL Remaining**: ${avgAge !== "unknown" ? `~${avgAge}s` : "unknown"}\n\n` + `## Cache Composition\n` + Object.entries(keysByType) .sort(([_, a], [__, b]) => b - a) // Sort by count (highest first) .map(([type, count]) => { const percentage = ((count / stats.keys) * 100).toFixed(1); return `- **${type}**: ${count} (${percentage}%)`; }) .join('\n') + `\n\n## Available Actions\n` + `- **stats**: Show these statistics\n` + `- **clear**: Clear entire cache\n` + `- **clear_type**: Clear specific type of cached data (requires 'type' parameter)\n` + `- **config**: Show cache configuration settings` }] }; } }); // List lifelogs with enhanced filtering options and selective field retrieval server.tool("list_lifelogs", { limit: z.number().optional(), date: z.string().optional().describe("Date in YYYY-MM-DD format"), timezone: z.string().optional().describe("IANA timezone specifier"), start: z.string().optional().describe("Start date/time in YYYY-MM-DD or YYYY-MM-DD HH:mm:SS format"), end: z.string().optional().describe("End date/time in YYYY-MM-DD or YYYY-MM-DD HH:mm:SS format"), direction: z.enum(["asc", "desc"]).optional().describe("Sort direction: asc or desc"), includeContent: z.boolean().default(false).describe("Whether to include markdown content"), fields: z.array(z.string()).optional().describe("Specific fields to include (title, time, id, etc.)") }, async ({ limit = DEFAULT_PAGE_SIZE, date, timezone, start, end, direction, includeContent, fields }) => { // Set up query parameters with selective field retrieval const queryParams = { limit, date, timezone, start, end, direction, includeMarkdown: includeContent }; // Add specific fields if requested if (fields && fields.length > 0) { queryParams.fields = fields.join(','); } const response = await call("/lifelogs", queryParams); const lifelogs = response.data.lifelogs || []; const nextCursor = response.meta?.lifelogs?.nextCursor; let resultText = lifelogs.map((l) => { let timeInfo = ""; if (l.startTime) { const startDate = new Date(l.startTime); timeInfo = ` (${startDate.toLocaleString()})`; } let result = `${l.id} — ${l.title}${timeInfo}`; // If content was requested, add a snippet if (includeContent && l.markdown) { const snippet = l.markdown.substring(0, 100) + (l.markdown.length > 100 ? '...' : ''); result += `\n${snippet}\n`; } return result; }).join("\n\n"); // Add pagination information if (nextCursor) { resultText += `\n\n[More results available. Use cursor: ${nextCursor} with get_paged_lifelogs]`; } return { content: [{ type: "text", text: lifelogs.length ? resultText : "No lifelogs found for the specified criteria." }] }; }); // Pagination support for lifelogs with selective field retrieval server.tool("get_paged_lifelogs", { cursor: z.string().describe("Pagination cursor from previous results"), limit: z.number().optional(), date: z.string().optional().describe("Date in YYYY-MM-DD format"), timezone: z.string().optional().describe("IANA timezone specifier"), direction: z.enum(["asc", "desc"]).optional().describe("Sort direction: asc or desc"), includeContent: z.boolean().default(false).describe("Whether to include markdown content"), fields: z.array(z.string()).optional().describe("Specific fields to include (title, time, id, etc.)") }, async ({ cursor, limit = DEFAULT_PAGE_SIZE, date, timezone, direction, includeContent, fields }) => { // Set up query parameters with selective field retrieval const queryParams = { cursor, limit, date, timezone, direction, includeMarkdown: includeContent }; // Add specific fields if requested if (fields && fields.length > 0) { queryParams.fields = fields.join(','); } const response = await call("/lifelogs", queryParams); const lifelogs = response.data.lifelogs || []; const nextCursor = response.meta?.lifelogs?.nextCursor; let resultText = lifelogs.map((l) => { let timeInfo = ""; if (l.startTime) { const startDate = new Date(l.startTime); timeInfo = ` (${startDate.toLocaleString()})`; } let result = `${l.id} — ${l.title}${timeInfo}`; // If content was requested, add a snippet if (includeContent && l.markdown) { const snippet = l.markdown.substring(0, 100) + (l.markdown.length > 100 ? '...' : ''); result += `\n${snippet}\n`; } return result; }).join("\n\n"); // Add pagination information if (nextCursor) { resultText += `\n\n[More results available. Use cursor: ${nextCursor}]`; } return { content: [{ type: "text", text: lifelogs.length ? resultText : "No lifelogs found for the specified criteria." }] }; }); // Get a specific lifelog by ID with optional field selection server.tool("get_lifelog", { id: z.string().describe("The ID of the lifelog to retrieve"), includeContent: z.boolean().default(true).describe("Whether to include full content or just metadata"), fields: z.array(z.string()).optional().describe("Specific fields to include (title, time, speakers, etc.)") }, async ({ id, includeContent, fields }) => { try { // Set query parameters based on requested fields const queryParams = { includeMarkdown: includeContent }; // Handle selective field retrieval if (fields && fields.length > 0) { queryParams.fields = fields.join(','); } const response = await call(`/lifelogs/${id}`, queryParams); const lifelog = response.data.lifelog; if (!lifelog) { throw new McpError(ErrorCode.NotFound, `No lifelog found with ID: ${id}`, { id }); } // Process time information let formattedTime = ""; if (lifelog.startTime) { const startDate = new Date(lifelog.startTime); formattedTime = ` (${startDate.toLocaleString()})`; } // Build header with available metadata let header = `# ${lifelog.title}${formattedTime}\n\nID: ${lifelog.id}\n\n`; // Add duration if available if (lifelog.startTime && lifelog.endTime) { const start = new Date(lifelog.startTime).getTime(); const end = new Date(lifelog.endTime).getTime(); const durationMs = end - start; const minutes = Math.floor(durationMs / 60000); const seconds = Math.floor((durationMs % 60000) / 1000); header += `Duration: ${minutes}m ${seconds}s\n\n`; } // Extract speaker information if available if (lifelog.contents && lifelog.contents.length > 0) { const speakers = new Set(); lifelog.contents.forEach(content => { if (content.speakerName) { speakers.add(content.speakerName); } }); if (speakers.size > 0) { header += `Speakers: ${Array.from(speakers).join(', ')}\n\n`; } } // Combine header and content (if requested) const content = includeContent ? (lifelog.markdown || "(No content available)") : "(Content not requested)"; return { content: [{ type: "text", text: header + content }] }; } catch (error) { console.error(`Error fetching lifelog ${id}:`, error); // If it's already an McpError, rethrow it if (error instanceof McpError) { throw error; } // Handle HTTP status errors const statusCode = getErrorStatusCode(error); if (statusCode) { if (statusCode === 404) { throw new McpError(`Lifelog with ID ${id} not found`, ErrorCode.NotFound); } else if (statusCode === 401 || statusCode === 403) { throw new McpError(`Unauthorized access to Limitless API`, ErrorCode.Unauthorized); } else if (statusCode >= 500) { throw new McpError(`Limitless API service error: ${statusCode}`, ErrorCode.ServiceUnavailable); } } // Generic error fallback throw new McpError(`Error retrieving lifelog ${id}: ${getErrorMessage(error)}`, ErrorCode.Internal); } }); // Get only metadata for a lifelog server.tool("get_lifelog_metadata", { id: z.string().describe("The ID of the lifelog to retrieve metadata for") }, async ({ id }) => { try { const response = await call(`/lifelogs/${id}`, { includeMarkdown: false }); const lifelog = response.data.lifelog; if (!lifelog) { throw new McpError(ErrorCode.NotFound, `No lifelog found with ID: ${id}`, { id }); } // Format metadata let metadata = `# Metadata for Lifelog: ${lifelog.title}\n\n`; metadata += `- **ID**: ${lifelog.id}\n`; if (lifelog.startTime) { const startDate = new Date(lifelog.startTime); metadata += `- **Start Time**: ${startDate.toLocaleString()}\n`; } if (lifelog.endTime) { const endDate = new Date(lifelog.endTime); metadata += `- **End Time**: ${endDate.toLocaleString()}\n`; } if (lifelog.startTime && lifelog.endTime) { const start = new Date(lifelog.startTime).getTime(); const end = new Date(lifelog.endTime).getTime(); const durationMs = end - start; const minutes = Math.floor(durationMs / 60000); const seconds = Math.floor((durationMs % 60000) / 1000); metadata += `- **Duration**: ${minutes}m ${seconds}s\n`; } // Add content structure information if available if (lifelog.contents && lifelog.contents.length > 0) { metadata += `- **Content Blocks**: ${lifelog.contents.length}\n`; // Count by type const typeCounts = {}; lifelog.contents.forEach(content => { typeCounts[content.type] = (typeCounts[content.type] || 0) + 1; }); metadata += `- **Content Types**:\n`; Object.entries(typeCounts).forEach(([type, count]) => { metadata += ` - ${type}: ${count}\n`; }); // Speaker information const speakers = new Set(); lifelog.contents.forEach(content => { if (content.speakerName) { speakers.add(content.speakerName); } }); if (speakers.size > 0) { metadata += `- **Speakers**: ${Array.from(speakers).join(', ')}\n`; } } return { content: [{ type: "text", text: metadata }] }; } catch (error) { console.error(`Error fetching lifelog metadata ${id}:`, error); // If it's already an McpError, rethrow it if (error instanceof McpError) { throw error; } // Handle HTTP status errors const statusCode = getErrorStatusCode(error); if (statusCode) { if (statusCode === 404) { throw new McpError(`Lifelog with ID ${id} not found`, ErrorCode.NotFound); } else if (statusCode === 401 || statusCode === 403) { throw new McpError(`Unauthorized access to Limitless API`, ErrorCode.Unauthorized); } else if (statusCode >= 500) { throw new McpError(`Limitless API service error: ${statusCode}`, ErrorCode.ServiceUnavailable); } } // Generic error fallback throw new McpError(`Error retrieving lifelog metadata for ${id}: ${getErrorMessage(error)}`, ErrorCode.Internal); } }); // Filter lifelog contents by various criteria server.tool("filter_lifelog_contents", { id: z.string().describe("The ID of the lifelog to filter content from"), speakerName: z.string().optional().describe("Filter by speaker name"), contentType: z.string().optional().describe("Filter by content type (e.g., heading1, blockquote)"), timeStart: z.string().optional().describe("Filter content after this time (ISO-8601)"), timeEnd: z.string().optional().describe("Filter content before this time (ISO-8601)") }, async ({ id, speakerName, contentType, timeStart, timeEnd }) => { try { const response = await call(`/lifelogs/${id}`); const lifelog = response.data.lifelog; if (!lifelog || !lifelog.contents) { return { content: [{ type: "text", text: `No content found for lifelog with ID: ${id}` }] }; } let filteredContents = lifelog.contents; // Apply filters if (speakerName) { filteredContents = filteredContents.filter(c => c.speakerName && c.speakerName.toLowerCase().includes(speakerName.toLowerCase())); } if (contentType) { filteredContents = filteredContents.filter(c => c.type === contentType); } if (timeStart) { const startTime = new Date(timeStart).getTime(); filteredContents = filteredContents.filter(c => { if (!c.startTime) return true; return new Date(c.startTime).getTime() >= startTime; }); } if (timeEnd) { const endTime = new Date(timeEnd).getTime(); filteredContents = filteredContents.filter(c => { if (!c.endTime) return true; return new Date(c.endTime).getTime() <= endTime; }); } if (filteredContents.length === 0) { return { content: [{ type: "text", text: "No content matched the filter criteria." }] }; } // Format the filtered content let result = `# Filtered Content from "${lifelog.title}"\n\n`; result += `Found ${filteredContents.length} matching content blocks out of ${lifelog.contents.length} total.\n\n`; filteredContents.forEach((content, index) => { let timeInfo = ""; if (content.startTime) { const time = new Date(content.startTime).toLocaleTimeString(); timeInfo = ` (${time})`; } let speaker = content.speakerName ? `**${content.speakerName}**: ` : ""; result += `## Block ${index + 1}${timeInfo}\n${speaker}${content.content}\n\n`; }); return { content: [{ type: "text", text: result }] }; } catch (error) { console.error(`Error filtering lifelog ${id}:`, error); return { content: [{ type: "text", text: `Error filtering lifelog ${id}. Please check if the ID is correct.` }] }; } }); // Generate a formatted transcript from a lifelog server.tool("generate_transcript", { id: z.string().describe("The ID of the lifelog to generate transcript from"), format: z.enum(["simple", "detailed", "dialogue"]).default("dialogue").describe("Transcript format style") }, async ({ id, format }) => { try { const response = await call(`/lifelogs/${id}`); const lifelog = response.data.lifelog; if (!lifelog || !lifelog.contents) { return { content: [{ type: "text", text: `No content found for lifelog with ID: ${id}` }] }; } // Extract and sort content by time if available let contents = [...(lifelog.contents || [])]; contents.sort((a, b) => { if (!a.startTime || !b.startTime) return 0; return new Date(a.startTime).getTime() - new Date(b.startTime).getTime(); }); let transcript = ""; // Generate transcript based on requested format switch (format) { case "simple": transcript = `# ${lifelog.title} - Simple Transcript\n\n`; contents.forEach(content => { transcript += `${content.content}\n\n`; }); break; case "detailed": transcript = `# ${lifelog.title} - Detailed Transcript\n\n`; contents.forEach((content, index) => { let timeInfo = ""; if (content.startTime) { timeInfo = `[${new Date(content.startTime).toLocaleTimeString()}] `; } transcript += `### Block ${index + 1}\n${timeInfo}${content.type}: ${content.content}\n\n`; }); break; case "dialogue": default: transcript = `# ${lifelog.title} - Dialogue Transcript\n\n`; let currentSpeaker = ""; let dialogueBlock = ""; contents.forEach(content => { // If it's a new speaker or a heading if (content.speakerName && content.speakerName !== currentSpeaker) { // Add the previous block if it exists if (dialogueBlock) { transcript += dialogueBlock + "\n\n"; } // Start a new dialogue block currentSpeaker = content.speakerName; dialogueBlock = `**${currentSpeaker}**: ${content.content}`; } else if (content.type.startsWith("heading")) { // Add the previous block if it exists if (dialogueBlock) { transcript += dialogueBlock + "\n\n"; } // Reset speaker and add heading currentSpeaker = ""; dialogueBlock = `## ${content.content}`; } else if (currentSpeaker) { // Continue with the current speaker dialogueBlock += " " + content.content; } else { // No speaker but not a heading, treat as narrative if (dialogueBlock) { transcript += dialogueBlock + "\n\n"; } dialogueBlock = content.content; } }); // Add the last block if (dialogueBlock) { transcript += dialogueBlock; } break; } return { content: [{ type: "text", text: transcript }] }; } catch (error) { console.error(`Error generating transcript for ${id}:`, error); return { content: [{ type: "text", text: `Error generating transcript for lifelog ${id}. Please check if the ID is correct.` }] }; } }); // Get time summary and statistics server.tool("get_time_summary", { date: z.string().optional().describe("Date in YYYY-MM-DD format"), timezone: z.string().optional().describe("IANA timezone specifier"), start: z.string().optional().describe("Start date in YYYY-MM-DD format"), end: z.string().optional().describe("End date in YYYY-MM-DD format"), groupBy: z.enum(["hour", "day", "week"]).default("day").describe("How to group the time statistics") }, async ({ date, timezone = "America/Los_Angeles", start, end, groupBy = "day" }) => { try { // Determine date range let queryParams = { limit: 100, timezone, direction: "asc" }; if (date) { queryParams.date = date; } else if (start && end) { queryParams.start = start; queryParams.end = end; } else if (start) { queryParams.start = start; // Default to 7 days if only start is provided const endDate = new Date(start); endDate.setDate(endDate.getDate() + 7); queryParams.end = endDate.toISOString().split('T')[0]; } else if (end) { queryParams.end = end; // Default to 7 days before if only end is provided const startDate = new Date(end); startDate.setDate(startDate.getDate() - 7); queryParams.start = startDate.toISOString().split('T')[0]; } else { // Default to last 7 days const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - 7); queryParams.start = startDate.toISOString().split('T')[0]; queryParams.end = endDate.toISOString().split('T')[0]; } const response = await call("/lifelogs", queryParams); const lifelogs = response.data.lifelogs || []; if (lifelogs.length === 0) { return { content: [{ type: "text", text: "No lifelogs found for the specified time period." }] }; } const stats = {}; let totalDuration = 0; let countWithDuration = 0; lifelogs.forEach(log => { if (!log.startTime) return; let key = ""; const date = new Date(log.startTime); switch (groupBy) { case "hour": key = `${date.toLocaleDateString()} ${date.getHours()}:00`; break; case "week": // Get the monday of the week const day = date.getDay(); const diff = date.getDate() - day + (day === 0 ? -6 : 1); const monday = new Date(date); monday.setDate(diff); key = `Week of ${monday.toLocaleDateString()}`; break; case "day": default: key = date.toLocaleDateString(); break; } if (!stats[key]) { stats[key] = { count: 0, totalDurationMs: 0, averageDurationMs: 0, key }; } stats[key].count++; if (log.startTime && log.endTime) { const start = new Date(log.startTime).getTime(); const end = new Date(log.endTime).getTime(); const duration = end - start; stats[key].totalDurationMs += duration; totalDuration += duration; countWithDuration++; } }); // Calculate averages Object.values(stats).forEach(stat => { if (stat.count > 0) { stat.averageDurationMs = stat.totalDurationMs / stat.count; } }); // Sort by key for proper chronological display const sortedStats = Object.values(stats).sort((a, b) => a.key.localeCompare(b.key)); // Generate report let summary = `# Time Summary Analysis`; if (date) { summary += ` for ${date}`; } else if (start && end) { summary += ` from ${start} to ${end}`; } summary += `\n\n`; summary += `Total lifelogs: ${lifelogs.length}\n`; if (countWithDuration > 0) { const totalHours = Math.floor(totalDuration / 3600000); const totalMinutes = Math.floor((totalDuration % 3600000) / 60000); summary += `Total recording time: ${totalHours}h ${totalMinutes}m\n`; summary += `Average per recording: ${Math.floor((totalDuration / countWithDuration) / 60000)}m\n\n`; } summary += `## Breakdown by ${groupBy}\n\n`; summary += `| ${groupBy === "hour" ? "Hour" : groupBy === "week" ? "Week" : "Date"} | Count | Total Time | Avg Time |\n`; summary += `| --- | --- | --- | --- |\n`; sortedStats.forEach(stat => { const totalHours = Math.floor(stat.totalDurationMs / 3600000); const totalMinutes = Math.floor((stat.totalDurationMs % 3600000) / 60000); const totalTime = stat.totalDurationMs > 0 ? `${totalHours}h ${totalMinutes}m` : "N/A"; const avgMinutes = Math.floor(stat.averageDurationMs / 60000); const avgSeconds = Math.floor((stat.averageDurationMs % 60000) / 1000); const avgTime = stat.averageDurationMs > 0 ? `${avgMinutes}m ${avgSeconds}s` : "N/A"; summary += `| ${stat.key} | ${stat.count} | ${totalTime} | ${avgTime} |\n`; }); return { content: [{ type: "text", text: summary }] }; } catch (error) { console.error(`Error generating time summary:`, error); return { content: [{ type: "text", text: `Error generating time summary. Please check your date parameters.` }] }; } }); // Enhanced search with relevance scoring server.tool("search_lifelogs", { query: z.string().describe("Text to search for in lifelogs"), limit: z.number().optional(), date: z.string().optional().describe("Date in YYYY-MM-DD format"), timezone: z.string().optional().describe("IANA timezone specifier"), start: z.string().optional().describe("Start date/time in YYYY-MM-DD or YYYY-MM-DD HH:mm:SS format"), end: z.string().optional().describe("End date/time in YYYY-MM-DD or YYYY-MM-DD HH:mm:SS format"), searchMode: z.enum(["basic", "advanced"]).default("advanced").describe("Search mode: basic (simple contains) or advanced (with scoring)"), includeSnippets: z.boolean().default(true).describe("Include matching content snippets in results") }, async ({ query, limit = DEFAULT_PAGE_SIZE, date, timezone, start, end, searchMode, includeSnippets }) => { // First get the lifelogs based on date criteria const response = await call("/lifelogs", { limit: Math.min(limit * MAX_SEARCH_MULTIPLIER, MAX_LIFELOG_LIMIT), // Get more than we need to increase search chances date, timezone, start, end, includeMarkdown: includeSnippets // Only include markdown content if snippets are requested }); const logs = response.data.lifelogs || []; if (logs.length === 0) { return { content: [{ type: "text", text: "No lifelogs found for the specified time criteria." }] };