Skip to main content
Glama

Limitless MCP Server

by ipvr9
server.ts14 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { McpError, ErrorCode, CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { getLifelogs, getLifelogById, LimitlessApiError, Lifelog, LifelogParams } from "./limitless-client.js"; import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { z } from "zod"; // --- Constants --- const MAX_LIFELOG_LIMIT = 100; const MAX_SEARCH_FETCH_LIMIT = 100; const DEFAULT_SEARCH_FETCH_LIMIT = 20; // --- Environment Variable Checks --- const limitlessApiKey = process.env.LIMITLESS_API_KEY; if (!limitlessApiKey) { console.error("Error: LIMITLESS_API_KEY environment variable not set."); console.error("Ensure the client configuration provides LIMITLESS_API_KEY in the 'env' section."); process.exit(1); } // --- Tool Argument Schemas --- const CommonListArgsSchema = { limit: z.number().int().positive().max(MAX_LIFELOG_LIMIT).optional().describe(`Maximum number of lifelogs to return (Max: ${MAX_LIFELOG_LIMIT}). Fetches in batches from the API if needed.`), timezone: z.string().optional().describe("IANA timezone for date/time parameters (defaults to server's local timezone)."), includeMarkdown: z.boolean().optional().default(true).describe("Include markdown content in the response."), includeHeadings: z.boolean().optional().default(true).describe("Include headings content in the response."), direction: z.enum(["asc", "desc"]).optional().describe("Sort order ('asc' for oldest first, 'desc' for newest first)."), }; const GetByIdArgsSchema = { lifelog_id: z.string().describe("The unique identifier of the lifelog to retrieve."), includeMarkdown: z.boolean().optional().default(true).describe("Include markdown content in the response."), includeHeadings: z.boolean().optional().default(true).describe("Include headings content in the response."), }; const ListByDateArgsSchema = { date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format.").describe("The date to retrieve lifelogs for, in YYYY-MM-DD format."), ...CommonListArgsSchema }; const ListByRangeArgsSchema = { start: z.string().describe("Start datetime filter (YYYY-MM-DD or YYYY-MM-DD HH:mm:SS)."), end: z.string().describe("End datetime filter (YYYY-MM-DD or YYYY-MM-DD HH:mm:SS)."), ...CommonListArgsSchema }; const ListRecentArgsSchema = { limit: z.number().int().positive().max(MAX_LIFELOG_LIMIT).optional().default(10).describe(`Number of recent lifelogs to retrieve (Max: ${MAX_LIFELOG_LIMIT}). Defaults to 10.`), timezone: CommonListArgsSchema.timezone, includeMarkdown: CommonListArgsSchema.includeMarkdown, includeHeadings: CommonListArgsSchema.includeHeadings, }; const SearchArgsSchema = { search_term: z.string().describe("The text to search for within lifelog titles and content."), fetch_limit: z.number().int().positive().max(MAX_SEARCH_FETCH_LIMIT).optional().default(DEFAULT_SEARCH_FETCH_LIMIT).describe(`How many *recent* lifelogs to fetch from the API to search within (Default: ${DEFAULT_SEARCH_FETCH_LIMIT}, Max: ${MAX_SEARCH_FETCH_LIMIT}). This defines the scope of the search, NOT the number of results returned.`), limit: CommonListArgsSchema.limit, timezone: CommonListArgsSchema.timezone, includeMarkdown: CommonListArgsSchema.includeMarkdown, includeHeadings: CommonListArgsSchema.includeHeadings, }; // --- MCP Server Setup --- const server = new McpServer({ name: "LimitlessMCP", version: "0.1.0", }, { capabilities: { tools: {} }, instructions: ` This server connects to the Limitless API (https://limitless.ai) to interact with your lifelogs using specific tools. NOTE: As of March 2025, the Limitless Lifelog API primarily surfaces data recorded via the Limitless Pendant. Queries may return limited or no data if the Pendant is not used. **Tool Usage Strategy:** - To find conceptual information like **summaries, action items, to-dos, key topics, decisions, etc.**, first use a **list tool** (list_by_date, list_by_range, list_recent) to retrieve the relevant log entries. Then, **analyze the returned text content** to extract the required information. - Use the **search tool** (\`limitless_search_lifelogs\`) **ONLY** when looking for logs containing **specific keywords or exact phrases**. Available Tools: 1. **limitless_get_lifelog_by_id**: Retrieves a single lifelog or Pendant recording by its specific ID. - Args: lifelog_id (req), includeMarkdown, includeHeadings 2. **limitless_list_lifelogs_by_date**: Lists logs/recordings for a specific date. Best for getting raw log data which you can then analyze for summaries, action items, topics, etc. - Args: date (req, YYYY-MM-DD), limit (max ${MAX_LIFELOG_LIMIT}), timezone, includeMarkdown, includeHeadings, direction ('asc'/'desc', default 'asc') 3. **limitless_list_lifelogs_by_range**: Lists logs/recordings within a date/time range. Best for getting raw log data which you can then analyze for summaries, action items, topics, etc. - Args: start (req), end (req), limit (max ${MAX_LIFELOG_LIMIT}), timezone, includeMarkdown, includeHeadings, direction ('asc'/'desc', default 'asc') 4. **limitless_list_recent_lifelogs**: Lists the most recent logs/recordings (sorted newest first). Best for getting raw log data which you can then analyze for summaries, action items, topics, etc. - Args: limit (opt, default 10, max ${MAX_LIFELOG_LIMIT}), timezone, includeMarkdown, includeHeadings 5. **limitless_search_lifelogs**: Performs a simple text search for specific keywords/phrases within the title and content of *recent* logs/Pendant recordings. - **USE ONLY FOR KEYWORDS:** Good for finding mentions of "Project X", "Company Name", specific names, etc. - **DO NOT USE FOR CONCEPTS:** Not suitable for finding general concepts like 'action items', 'summaries', 'key decisions', 'to-dos', or 'main topics'. Use a list tool first for those tasks, then analyze the results. - **LIMITATION**: Only searches the 'fetch_limit' most recent logs (default ${DEFAULT_SEARCH_FETCH_LIMIT}, max ${MAX_SEARCH_FETCH_LIMIT}). NOT a full history search. - Args: search_term (req), fetch_limit (opt, default ${DEFAULT_SEARCH_FETCH_LIMIT}, max ${MAX_SEARCH_FETCH_LIMIT}), limit (opt, max ${MAX_LIFELOG_LIMIT} for results), timezone, includeMarkdown, includeHeadings ` }); // --- Tool Implementations --- // Helper to handle common API call errors and format results async function handleToolApiCall<T>(apiCall: () => Promise<T>, requestedLimit?: number): Promise<CallToolResult> { try { const result = await apiCall(); let resultText = ""; if (Array.isArray(result)) { if (result.length === 0) { resultText = "No lifelogs found matching the criteria."; } else if (requestedLimit !== undefined) { // Case 1: A specific limit was requested by the user/LLM if (result.length < requestedLimit) { resultText = `Found ${result.length} lifelogs (requested up to ${requestedLimit}).\n\n${JSON.stringify(result, null, 2)}`; } else { // Found exactly the number requested, or potentially more were available but capped by the limit resultText = `Found ${result.length} lifelogs (limit was ${requestedLimit}).\n\n${JSON.stringify(result, null, 2)}`; } } else { // Case 2: No specific limit was requested (requestedLimit is undefined) // Report the actual number found. Assume getLifelogs fetched all available up to internal limits. resultText = `Found ${result.length} lifelogs matching the criteria.\n\n${JSON.stringify(result, null, 2)}`; } } else if (result) { // Handle single object result (e.g., getById) resultText = JSON.stringify(result, null, 2); } else { resultText = "Operation successful, but no specific data returned."; } return { content: [{ type: "text", text: resultText }] }; } catch (error) { console.error("[Server Tool Error]", error); // Log actual errors to stderr let errorMessage = "Failed to execute tool."; let mcpErrorCode = ErrorCode.InternalError; if (error instanceof LimitlessApiError) { errorMessage = `Limitless API Error (Status ${error.status ?? 'N/A'}): ${error.message}`; if (error.status === 401) mcpErrorCode = ErrorCode.InvalidRequest; if (error.status === 404) mcpErrorCode = ErrorCode.InvalidParams; if (error.status === 504) mcpErrorCode = ErrorCode.InternalError; } else if (error instanceof Error) { errorMessage = error.message; } return { content: [{ type: "text", text: `Error: ${errorMessage}` }], isError: true }; } } // Register tools (Callbacks remain the same) server.tool( "limitless_get_lifelog_by_id", "Retrieves a single lifelog or Pendant recording by its specific ID.", GetByIdArgsSchema, async (args, _extra) => handleToolApiCall(() => getLifelogById(limitlessApiKey, args.lifelog_id, { includeMarkdown: args.includeMarkdown, includeHeadings: args.includeHeadings })) ); server.tool( "limitless_list_lifelogs_by_date", "Lists logs/recordings for a specific date. Best for getting raw log data which you can then analyze for summaries, action items, topics, etc.", ListByDateArgsSchema, async (args, _extra) => { const apiOptions: LifelogParams = { date: args.date, limit: args.limit, timezone: args.timezone, includeMarkdown: args.includeMarkdown, includeHeadings: args.includeHeadings, direction: args.direction ?? 'asc' }; return handleToolApiCall(() => getLifelogs(limitlessApiKey, apiOptions), args.limit); // Pass requestedLimit to helper } ); server.tool( "limitless_list_lifelogs_by_range", "Lists logs/recordings within a date/time range. Best for getting raw log data which you can then analyze for summaries, action items, topics, etc.", ListByRangeArgsSchema, async (args, _extra) => { const apiOptions: LifelogParams = { start: args.start, end: args.end, limit: args.limit, timezone: args.timezone, includeMarkdown: args.includeMarkdown, includeHeadings: args.includeHeadings, direction: args.direction ?? 'asc' }; return handleToolApiCall(() => getLifelogs(limitlessApiKey, apiOptions), args.limit); // Pass requestedLimit to helper } ); server.tool( "limitless_list_recent_lifelogs", "Lists the most recent logs/recordings (sorted newest first). Best for getting raw log data which you can then analyze for summaries, action items, topics, etc.", ListRecentArgsSchema, async (args, _extra) => { const apiOptions: LifelogParams = { limit: args.limit, timezone: args.timezone, includeMarkdown: args.includeMarkdown, includeHeadings: args.includeHeadings, direction: 'desc' }; return handleToolApiCall(() => getLifelogs(limitlessApiKey, apiOptions), args.limit); // Pass requestedLimit to helper } ); server.tool( "limitless_search_lifelogs", "Performs a simple text search for specific keywords/phrases within the title and content of *recent* logs/Pendant recordings. Use ONLY for keywords, NOT for concepts like 'action items' or 'summaries'. Searches only recent logs (limited scope).", SearchArgsSchema, async (args, _extra) => { const fetchLimit = args.fetch_limit ?? DEFAULT_SEARCH_FETCH_LIMIT; console.error(`[Server Tool] Search initiated for term: "${args.search_term}", fetch_limit: ${fetchLimit}`); try { const logsToSearch = await getLifelogs(limitlessApiKey, { limit: fetchLimit, direction: 'desc', timezone: args.timezone, includeMarkdown: true, includeHeadings: args.includeHeadings }); if (logsToSearch.length === 0) return { content: [{ type: "text", text: "No recent lifelogs found to search within." }] }; const searchTermLower = args.search_term.toLowerCase(); const matchingLogs = logsToSearch.filter(log => log.title?.toLowerCase().includes(searchTermLower) || (log.markdown && log.markdown.toLowerCase().includes(searchTermLower))); const finalLimit = args.limit; // This limit applies to the *results* const limitedResults = finalLimit ? matchingLogs.slice(0, finalLimit) : matchingLogs; if (limitedResults.length === 0) return { content: [{ type: "text", text: `No matches found for "${args.search_term}" within the ${logsToSearch.length} most recent lifelogs searched.` }] }; // Report count based on limitedResults length and the requested result limit let resultPrefix = `Found ${limitedResults.length} match(es) for "${args.search_term}" within the ${logsToSearch.length} most recent lifelogs searched`; if (finalLimit !== undefined) { resultPrefix += ` (displaying up to ${finalLimit})`; } resultPrefix += ':\n\n'; const resultText = `${resultPrefix}${JSON.stringify(limitedResults, null, 2)}`; return { content: [{ type: "text", text: resultText }] }; } catch (error) { return handleToolApiCall(() => Promise.reject(error)); } } ); // --- Server Startup --- async function main() { const transport = new StdioServerTransport(); console.error("Limitless MCP Server starting..."); server.server.onclose = () => { console.error("Connection closed."); }; server.server.onerror = (error: Error) => { console.error("MCP Server Error:", error); }; server.server.oninitialized = () => { console.error("Client initialized."); }; try { await server.server.connect(transport); } catch (error) { console.error("Failed to start server:", error); process.exit(1); } } main();

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/ipvr9/mcp-limitless-server'

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