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
JavaScript
#!/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."
}]
};