reclaim-mcp-server
Version:
A Model Context Protocol (MCP) server for interacting with Reclaim.ai tasks.
458 lines (457 loc) • 20 kB
JavaScript
/**
* @fileoverview Provides a type-safe client for interacting with the Reclaim.ai REST API.
* Handles API requests, responses, and basic error normalization.
*/
import axios from "axios";
import "dotenv/config";
// Fixed import path with .js extension
import { ReclaimError, } from "./types/reclaim.js";
// --- Configuration ---
const TOKEN = process.env.RECLAIM_API_KEY;
if (!TOKEN) {
// Use console.error for fatal startup issues
console.error("FATAL: RECLAIM_API_KEY environment variable is not set.");
console.error("Please create a .env file in the project root with RECLAIM_API_KEY=your_api_token");
process.exit(1); // Exit if the token is missing, essential for operation
}
// --- Axios Instance ---
/**
* Pre-configured Axios instance for making requests to the Reclaim.ai API.
* Includes base URL and authorization header.
*/
export const reclaim = axios.create({
baseURL: "https://api.app.reclaim.ai/api/",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
Accept: "application/json", // Explicitly accept JSON responses
},
// Optional: Add a timeout for requests
// timeout: 10000, // 10 seconds
});
// --- Helper Functions ---
/**
* Parses a deadline input into an ISO 8601 string suitable for the Reclaim API.
* Handles inputs as number of days from now or a date/datetime string.
* Defaults to 24 hours from the current time if parsing fails or input is invalid/missing.
* Logic ported and refined from `prior-js-implementation.xml`.
*
* @param deadlineInput - The deadline specified as number of days from now,
* an ISO 8601 date/time string, or undefined.
* @returns An ISO 8601 date/time string representing the calculated deadline.
*/
export function parseDeadline(deadlineInput) {
const now = new Date();
try {
if (typeof deadlineInput === "number") {
// Interpret number as days from now
if (deadlineInput <= 0) {
console.warn(`Received non-positive number of days "${deadlineInput}" for deadline/snooze, using current time.`);
// Or perhaps default to 24 hours? Let's default to now to avoid accidental pushing out.
// throw new Error("Number of days must be positive.");
return now.toISOString(); // Defaulting to 'now' might be safer than pushing out
}
const deadline = new Date(now);
deadline.setDate(deadline.getDate() + deadlineInput);
// Keep the current time, just advance the date
return deadline.toISOString();
}
else if (typeof deadlineInput === "string") {
// Attempt to parse as a date/datetime string
const parsed = new Date(deadlineInput);
if (isNaN(parsed.getTime())) {
// Handle potential simple date format like YYYY-MM-DD by assuming start of day UTC
if (/^\d{4}-\d{2}-\d{2}$/.test(deadlineInput)) {
const [year, month, day] = deadlineInput.split("-").map(Number);
// Month is 0-indexed in Date.UTC
const utcDate = new Date(Date.UTC(year, month - 1, day));
if (!isNaN(utcDate.getTime())) {
return utcDate.toISOString();
}
}
throw new Error(`Invalid date format: "${deadlineInput}"`);
}
return parsed.toISOString();
}
// If deadlineInput is undefined or null, fall through to default
}
catch (error) {
// Log the specific error during parsing before defaulting
console.error(`Failed to parse deadline/snooze input "${deadlineInput}", defaulting to 24 hours from now. Error: ${error.message}`);
}
// Default case: 24 hours from now
const defaultDeadline = new Date(now);
defaultDeadline.setDate(defaultDeadline.getDate() + 1); // Add 1 day
return defaultDeadline.toISOString();
}
/**
* Filters an array of Task objects to include only those considered "active".
*
* **Important:** In Reclaim.ai, a task with `status: "COMPLETE"` means its scheduled time allocation
* is finished, but the user may *not* have marked the task itself as done. These tasks
* are considered "active" by this filter unless they are also `ARCHIVED`, `CANCELLED`, or `deleted`.
*
* Active tasks meet these criteria:
* - `deleted` is `false`.
* - `status` is **not** `ARCHIVED`.
* - `status` is **not** `CANCELLED`.
*
* @param tasks - An array of `Task` objects.
* @returns A new array containing only the active `Task` objects.
*/
export function filterActiveTasks(tasks) {
if (!Array.isArray(tasks)) {
console.error("filterActiveTasks received non-array input, returning empty array.");
return [];
}
return tasks.filter((task) => task && // Ensure task object exists
!task.deleted &&
task.status !== "ARCHIVED" &&
task.status !== "CANCELLED");
}
// --- API Methods ---
/**
* Handles errors from Axios API calls, normalizing them into ReclaimError instances.
* Logs the detailed error internally for server-side debugging.
* This function is typed to return 'never' because it *always* throws an error.
*
* @param error - The error object caught from the Axios request (typed as unknown).
* @param context - A string providing context for the API call (e.g., function name, parameters).
* @throws {ReclaimError} Always throws a normalized ReclaimError.
*/
const handleApiError = (error, context) => {
let status;
let detail;
let message;
if (axios.isAxiosError(error)) {
const axiosError = error; // Already checked with isAxiosError
status = axiosError.response?.status;
detail = axiosError.response?.data;
// Try to extract a meaningful message from the response data or fallback to Axios message
const responseData = detail; // Type assertion for easier access
message =
responseData?.message || responseData?.title || axiosError.message;
console.error(`Reclaim API Error (${context}) - Status: ${status ?? "N/A"}`, detail || axiosError.message);
}
else if (error instanceof Error) {
message = error.message;
detail = { stack: error.stack }; // Include stack for non-API errors
console.error(`Error during Reclaim API call (${context})`, error);
}
else {
// Handle cases where something other than an Error was thrown
message = "An unexpected error occurred during API call.";
detail = error; // Preserve the original thrown value
console.error(`Unexpected throw during Reclaim API call (${context})`, error);
}
// Throw a structured error for consistent handling upstream.
// The 'never' return type indicates this function *always* throws.
throw new ReclaimError(`API Call Failed (${context}): ${message}`, status, detail);
};
/**
* Fetches all tasks from the Reclaim API.
*
* **Note on `status: "COMPLETE"`:** See the documentation for `filterActiveTasks` for details.
* This status indicates scheduled time completion, not necessarily user completion.
*
* @returns A promise resolving to an array of Task objects.
* @throws {ReclaimError} If the API request fails.
*/
export async function listTasks() {
const context = "listTasks";
try {
const { data } = await reclaim.get("/tasks");
// It's possible the API returns non-array on error, though Axios usually throws. Add check.
return Array.isArray(data) ? data : [];
}
catch (error) {
// handleApiError always throws, satisfying the return type Promise<Task[]>
return handleApiError(error, context);
}
}
/**
* Fetches a specific task by its unique ID.
*
* **Note on `status: "COMPLETE"`:** See the documentation for `filterActiveTasks` for details.
* This status indicates scheduled time completion, not necessarily user completion.
*
* @param taskId - The numeric ID of the task to fetch.
* @returns A promise resolving to the requested Task object.
* @throws {ReclaimError} If the API request fails (e.g., task not found - 404).
*/
export async function getTask(taskId) {
const context = `getTask(taskId=${taskId})`;
try {
const { data } = await reclaim.get(`/tasks/${taskId}`);
return data;
}
catch (error) {
// handleApiError always throws, satisfying the return type Promise<Task>
return handleApiError(error, context);
}
}
/**
* Creates a new task in Reclaim using the provided data.
* @param taskData - An object containing the properties for the new task. See `TaskInputData`.
* `title` is typically required by the API. `due` will be generated if `deadline` is omitted.
* @returns A promise resolving to the newly created Task object as returned by the API.
* @throws {ReclaimError} If the API request fails (e.g., validation error - 400).
*/
export async function createTask(taskData) {
const context = "createTask";
try {
// API expects 'due', not 'deadline'. parseDeadline handles conversion and default.
const apiPayload = { ...taskData }; // Clone to avoid modifying input object
// Handle deadline/due conversion
if ("deadline" in apiPayload && apiPayload.deadline !== undefined) {
apiPayload.due = parseDeadline(apiPayload.deadline);
delete apiPayload.deadline; // Remove original deadline field
}
else if (!apiPayload.due) {
// Ensure 'due' exists, defaulting if neither 'due' nor 'deadline' provided
apiPayload.due = parseDeadline(undefined); // Defaults to 24h
}
// Handle snoozeUntil conversion
if ("snoozeUntil" in apiPayload && apiPayload.snoozeUntil !== undefined) {
// Use parseDeadline logic for snoozeUntil as well
apiPayload.snoozeUntil = parseDeadline(apiPayload.snoozeUntil);
}
// Clean undefined keys before sending to API
Object.keys(apiPayload).forEach((key) => {
if (apiPayload[key] === undefined) {
delete apiPayload[key];
}
});
const { data } = await reclaim.post("/tasks", apiPayload);
return data;
}
catch (error) {
// handleApiError always throws, satisfying the return type Promise<Task>
return handleApiError(error, context);
}
}
/**
* Updates an existing task with the specified ID using the provided data.
* Only the fields included in `taskData` will be updated (PATCH semantics).
* @param taskId - The numeric ID of the task to update.
* @param taskData - An object containing the properties to update. See `TaskInputData`.
* @returns A promise resolving to the updated Task object as returned by the API.
* @throws {ReclaimError} If the API request fails (e.g., task not found - 404, validation error - 400).
*/
export async function updateTask(taskId, taskData) {
const context = `updateTask(taskId=${taskId})`;
try {
// API expects 'due', not 'deadline'. parseDeadline handles conversion.
const apiPayload = { ...taskData }; // Clone to avoid modifying input object
// Handle deadline/due conversion
if ("deadline" in apiPayload && apiPayload.deadline !== undefined) {
apiPayload.due = parseDeadline(apiPayload.deadline);
delete apiPayload.deadline; // Remove original deadline field
}
// Handle snoozeUntil conversion
if ("snoozeUntil" in apiPayload && apiPayload.snoozeUntil !== undefined) {
apiPayload.snoozeUntil = parseDeadline(apiPayload.snoozeUntil);
}
// Remove undefined keys explicitly for PATCH safety
Object.keys(apiPayload).forEach((key) => {
if (apiPayload[key] === undefined) {
delete apiPayload[key];
}
});
// Ensure we are actually sending some data to update
if (Object.keys(apiPayload).length === 0) {
console.warn(`UpdateTask called for taskId ${taskId} with no fields to update. Skipping API call.`);
// Fetch and return the current task state as PATCH with no data is a no-op
return getTask(taskId);
}
const { data } = await reclaim.patch(`/tasks/${taskId}`, apiPayload);
return data;
}
catch (error) {
// handleApiError always throws, satisfying the return type Promise<Task>
return handleApiError(error, context);
}
}
/**
* Deletes a task by its unique ID.
* Note: This is typically a soft delete in Reclaim unless forced otherwise.
* @param taskId - The numeric ID of the task to delete.
* @returns A promise resolving to void upon successful deletion (API returns 204 No Content).
* @throws {ReclaimError} If the API request fails (e.g., task not found - 404).
*/
export async function deleteTask(taskId) {
const context = `deleteTask(taskId=${taskId})`;
try {
await reclaim.delete(`/tasks/${taskId}`);
// Successful deletion returns 204 No Content, promise resolves void implicitly
}
catch (error) {
// handleApiError always throws. Since the return type is Promise<void>,
// returning 'never' here also satisfies the compiler.
return handleApiError(error, context);
}
}
/**
* Marks a task as complete in the Reclaim planner (user action).
* @param taskId - The numeric ID of the task to mark complete.
* @returns A promise resolving to the API response (often minimal or empty). Use `any` for flexibility or define a specific response type if known.
* @throws {ReclaimError} If the API request fails.
*/
export async function markTaskComplete(taskId) {
const context = `markTaskComplete(taskId=${taskId})`;
try {
// Endpoint might return empty body or a confirmation object
const { data } = await reclaim.post(`/planner/done/task/${taskId}`);
return data ?? { success: true }; // Provide a default success object if body is empty
}
catch (error) {
return handleApiError(error, context);
}
}
/**
* Marks a task as incomplete (e.g., unarchives it).
* @param taskId - The numeric ID of the task to mark incomplete.
* @returns A promise resolving to the API response (often minimal or empty). Use `any` for flexibility.
* @throws {ReclaimError} If the API request fails.
*/
export async function markTaskIncomplete(taskId) {
const context = `markTaskIncomplete(taskId=${taskId})`;
try {
const { data } = await reclaim.post(`/planner/unarchive/task/${taskId}`);
return data ?? { success: true };
}
catch (error) {
return handleApiError(error, context);
}
}
/**
* Adds a specified amount of time to a task's schedule.
* @param taskId - The numeric ID of the task.
* @param minutes - The number of minutes to add (must be positive).
* @returns A promise resolving to the API response. Use `any` for flexibility.
* @throws {ReclaimError} If the API request fails or minutes is invalid.
*/
export async function addTimeToTask(taskId, minutes) {
const context = `addTimeToTask(taskId=${taskId}, minutes=${minutes})`;
if (minutes <= 0) {
// Throw an error immediately for invalid input, handled by wrapApiCall later
throw new Error("Minutes must be positive to add time.");
}
try {
// API expects minutes as a query parameter
const { data } = await reclaim.post(`/planner/add-time/task/${taskId}`, null, {
params: { minutes },
});
return data ?? { success: true };
}
catch (error) {
return handleApiError(error, context);
}
}
/**
* Starts the timer for a specific task.
* @param taskId - The numeric ID of the task to start the timer for.
* @returns A promise resolving to the API response. Use `any` for flexibility.
* @throws {ReclaimError} If the API request fails.
*/
export async function startTaskTimer(taskId) {
const context = `startTaskTimer(taskId=${taskId})`;
try {
const { data } = await reclaim.post(`/planner/start/task/${taskId}`);
return data ?? { success: true };
}
catch (error) {
return handleApiError(error, context);
}
}
/**
* Stops the timer for a specific task.
* @param taskId - The numeric ID of the task to stop the timer for.
* @returns A promise resolving to the API response. Use `any` for flexibility.
* @throws {ReclaimError} If the API request fails.
*/
export async function stopTaskTimer(taskId) {
const context = `stopTaskTimer(taskId=${taskId})`;
try {
const { data } = await reclaim.post(`/planner/stop/task/${taskId}`);
return data ?? { success: true };
}
catch (error) {
return handleApiError(error, context);
}
}
/**
* Logs work (time spent) against a specific task.
* @param taskId - The numeric ID of the task to log work against.
* @param minutes - The number of minutes worked (must be positive).
* @param end - Optional end time of the work session (ISO 8601 string or YYYY-MM-DD). If omitted, Reclaim usually assumes 'now'.
* @returns A promise resolving to the API response. Use `any` for flexibility.
* @throws {ReclaimError} If the API request fails or parameters are invalid.
*/
export async function logWorkForTask(taskId, minutes, end) {
const context = `logWorkForTask(taskId=${taskId}, minutes=${minutes}, end=${end ?? "now"})`;
if (minutes <= 0) {
throw new Error("Minutes must be positive to log work.");
}
// Prepare query parameters, validating 'end' date if provided
const params = { minutes };
if (end) {
try {
// Use parseDeadline to validate and normalize the end date string
// Reclaim API seems to expect ISO string for 'end' param based on prior JS
const parsedEnd = parseDeadline(end);
// Ensure it includes time if only date was given - Reclaim might need time
if (parsedEnd.length === 10) {
// YYYY-MM-DD
params.end = new Date(parsedEnd).toISOString(); // Convert to full ISO string
}
else {
params.end = parsedEnd;
}
}
catch (dateError) {
// Throw a more specific error if parsing fails
const message = dateError instanceof Error ? dateError.message : String(dateError);
throw new Error(`Invalid 'end' date format: "${end}". Error: ${message}. Please use ISO 8601 or YYYY-MM-DD format.`);
}
}
try {
const { data } = await reclaim.post(`/planner/log-work/task/${taskId}`, null, { params });
return data ?? { success: true };
}
catch (error) {
return handleApiError(error, context);
}
}
/**
* Clears any scheduling exceptions associated with a task.
* @param taskId - The numeric ID of the task.
* @returns A promise resolving to the API response. Use `any` for flexibility.
* @throws {ReclaimError} If the API request fails.
*/
export async function clearTaskExceptions(taskId) {
const context = `clearTaskExceptions(taskId=${taskId})`;
try {
const { data } = await reclaim.post(`/planner/clear-exceptions/task/${taskId}`);
return data ?? { success: true };
}
catch (error) {
return handleApiError(error, context);
}
}
/**
* Marks a task for prioritization in the Reclaim planner.
* @param taskId - The numeric ID of the task to prioritize.
* @returns A promise resolving to the API response. Use `any` for flexibility.
* @throws {ReclaimError} If the API request fails.
*/
export async function prioritizeTask(taskId) {
const context = `prioritizeTask(taskId=${taskId})`;
try {
const { data } = await reclaim.post(`/planner/prioritize/task/${taskId}`);
return data ?? { success: true };
}
catch (error) {
return handleApiError(error, context);
}
}