UNPKG

limitless-mcp

Version:

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

283 lines (282 loc) 13.2 kB
import { McpError, ErrorCode, getErrorStatusCode, getErrorMessage } from '../utils/errors.js'; import { z } from "zod"; import callLimitlessApi from "../api/client.js"; import config from "../config.js"; /** * Register lifelog listing and retrieval tools on the MCP server */ export function registerLifelogTools(server) { // 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 = config.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 callLimitlessApi("/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 = config.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 callLimitlessApi("/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 callLimitlessApi(`/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`; } } // Include the content if requested let content = header; if (includeContent && lifelog.markdown) { content += lifelog.markdown; } else { content += "(Content not included. Set includeContent=true to view full content.)"; } return { content: [{ type: "text", text: 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 callLimitlessApi(`/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); } }); }