todoist-sections.ts•13.2 kB
import { z } from 'zod';
import { TodoistApiService } from '../services/todoist-api.js';
import { CacheService } from '../services/cache.js';
import { TokenValidatorSingleton } from '../services/token-validator.js';
import { TodoistSection, APIConfiguration } from '../types/todoist.js';
import { ValidationError } from '../types/errors.js';
import { handleToolError } from '../utils/tool-helpers.js';
/**
* Input schema for the todoist_sections tool
* Flattened for MCP client compatibility
*/
const TodoistSectionsInputSchema = z.object({
action: z.enum(['create', 'get', 'update', 'delete', 'list', 'reorder']),
// Section ID (for get, update, delete)
section_id: z.string().optional(),
// Project ID (for create, list, reorder)
project_id: z.string().optional(),
// Create/Update fields
name: z.string().optional(),
order: z.number().int().optional(),
// Reorder fields
section_orders: z
.array(
z.object({
id: z.string(),
order: z.number().int(),
})
)
.optional(),
});
type TodoistSectionsInput = z.infer<typeof TodoistSectionsInputSchema>;
/**
* Output schema for the todoist_sections tool
*/
interface TodoistSectionsOutput {
success: boolean;
data?: TodoistSection | TodoistSection[] | Record<string, unknown>;
message?: string;
metadata?: {
total_count?: number;
project_name?: string;
operation_time?: number;
rate_limit_remaining?: number;
rate_limit_reset?: string;
};
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
retryable: boolean;
retry_after?: number;
};
}
/**
* TodoistSectionsTool - Section management within Todoist projects
*
* Handles all CRUD operations on sections including:
* - Creating sections within projects
* - Reading individual sections or lists by project
* - Updating section properties
* - Deleting sections
* - Reordering sections within a project
*/
export class TodoistSectionsTool {
private readonly apiService: TodoistApiService;
private readonly cacheService: CacheService;
constructor(
apiConfig: APIConfiguration,
deps: {
apiService?: TodoistApiService;
cacheService?: CacheService;
} = {}
) {
this.apiService = deps.apiService ?? new TodoistApiService(apiConfig);
this.cacheService = deps.cacheService ?? new CacheService();
}
/**
* Get the MCP tool definition
*/
static getToolDefinition() {
return {
name: 'todoist_sections',
description:
'Section management within Todoist projects - create, read, update, delete, and reorder sections for better task organization',
inputSchema: {
type: 'object' as const,
properties: {
action: {
type: 'string',
enum: ['create', 'get', 'update', 'delete', 'list', 'reorder'],
description: 'Action to perform',
},
section_id: {
type: 'string',
description: 'Section ID (required for get/update/delete)',
},
project_id: {
type: 'string',
description: 'Project ID (required for create/list/reorder)',
},
name: { type: 'string', description: 'Section name' },
order: { type: 'number', description: 'Section order' },
section_orders: {
type: 'array',
description: 'Section reordering array (for reorder action)',
items: {
type: 'object',
properties: {
id: { type: 'string' },
order: { type: 'number' },
},
},
},
},
required: ['action'],
},
};
}
/**
* Validate that required fields are present for each action
*/
private validateActionRequirements(input: TodoistSectionsInput): void {
switch (input.action) {
case 'create':
if (!input.name)
throw new ValidationError('name is required for create action');
if (!input.project_id)
throw new ValidationError('project_id is required for create action');
break;
case 'get':
case 'delete':
if (!input.section_id)
throw new ValidationError(
`section_id is required for ${input.action} action`
);
break;
case 'update':
if (!input.section_id)
throw new ValidationError('section_id is required for update action');
break;
case 'list':
if (!input.project_id)
throw new ValidationError('project_id is required for list action');
break;
case 'reorder':
if (!input.project_id)
throw new ValidationError(
'project_id is required for reorder action'
);
if (!input.section_orders || input.section_orders.length === 0)
throw new ValidationError(
'section_orders is required for reorder action'
);
break;
default:
throw new ValidationError('Invalid action specified');
}
}
/**
* Execute the tool with the given input
*/
async execute(input: unknown): Promise<TodoistSectionsOutput> {
const startTime = Date.now();
try {
// Validate API token before processing request
await TokenValidatorSingleton.validateOnce();
// Validate input
const validatedInput = TodoistSectionsInputSchema.parse(input);
// Validate action-specific required fields
this.validateActionRequirements(validatedInput);
let result: TodoistSectionsOutput;
// Route to appropriate handler based on action
switch (validatedInput.action) {
case 'create':
result = await this.handleCreate(validatedInput);
break;
case 'get':
result = await this.handleGet(validatedInput);
break;
case 'update':
result = await this.handleUpdate(validatedInput);
break;
case 'delete':
result = await this.handleDelete(validatedInput);
break;
case 'list':
result = await this.handleList(validatedInput);
break;
case 'reorder':
result = await this.handleReorder(validatedInput);
break;
default:
throw new ValidationError('Invalid action specified');
}
// Add operation metadata
const operationTime = Date.now() - startTime;
const rateLimitStatus = this.apiService.getRateLimitStatus();
result.metadata = {
...result.metadata,
operation_time: operationTime,
rate_limit_remaining: rateLimitStatus.rest.remaining,
rate_limit_reset: new Date(
rateLimitStatus.rest.resetTime
).toISOString(),
};
return result;
} catch (error) {
return this.handleError(error, Date.now() - startTime);
}
}
/**
* Create a new section
*/
private async handleCreate(
input: TodoistSectionsInput
): Promise<TodoistSectionsOutput> {
const projectId = input.project_id;
if (!projectId) {
throw new ValidationError('project_id is required for create action');
}
const sectionData = {
name: input.name,
project_id: projectId,
order: input.order,
};
// Remove undefined properties
const cleanedData = Object.fromEntries(
Object.entries(sectionData).filter(([_, value]) => value !== undefined)
);
const section = await this.apiService.createSection(cleanedData);
// Invalidate sections cache for this project
this.cacheService.invalidateSections(projectId);
return {
success: true,
data: section,
message: 'Section created successfully',
};
}
/**
* Get a specific section by ID
*/
private async handleGet(
input: TodoistSectionsInput
): Promise<TodoistSectionsOutput> {
const sectionId = input.section_id;
if (!sectionId) {
throw new ValidationError('section_id is required for get action');
}
const section = await this.apiService.getSection(sectionId);
// Try to get project name for metadata
let projectName: string | undefined;
try {
const projects = await this.cacheService.getProjects();
const project = projects?.find(p => p.id === section.project_id);
projectName = project?.name;
} catch (error) {
// Ignore errors in getting project name
}
return {
success: true,
data: section,
message: 'Section retrieved successfully',
metadata: {
project_name: projectName,
},
};
}
/**
* Update an existing section
*/
private async handleUpdate(
input: TodoistSectionsInput
): Promise<TodoistSectionsOutput> {
const { section_id, ...updateData } = input;
if (!section_id) {
throw new ValidationError('section_id is required for update action');
}
// Remove undefined properties
const cleanedData = Object.fromEntries(
Object.entries(updateData).filter(([_, value]) => value !== undefined)
);
const section = await this.apiService.updateSection(
section_id,
cleanedData
);
// Invalidate sections cache for this project
this.cacheService.invalidateSections(section.project_id);
return {
success: true,
data: section,
message: 'Section updated successfully',
};
}
/**
* Delete a section
*/
private async handleDelete(
input: TodoistSectionsInput
): Promise<TodoistSectionsOutput> {
// Get section first to know which project cache to invalidate
let projectId: string | undefined;
const sectionId = input.section_id;
if (!sectionId) {
throw new ValidationError('section_id is required for delete action');
}
try {
const section = await this.apiService.getSection(sectionId);
projectId = section.project_id;
} catch (error) {
// Ignore errors - we'll proceed with deletion
}
await this.apiService.deleteSection(sectionId);
// Invalidate sections cache for this project if we know the project ID
if (projectId) {
this.cacheService.invalidateSections(projectId);
}
return {
success: true,
message: 'Section deleted successfully',
};
}
/**
* List sections in a project
*/
private async handleList(
input: TodoistSectionsInput
): Promise<TodoistSectionsOutput> {
const projectId = input.project_id;
if (!projectId) {
throw new ValidationError('project_id is required for list action');
}
const sections = await this.apiService.getSections(projectId);
// Try to get project name for metadata
let projectName: string | undefined;
try {
const projects = await this.cacheService.getProjects();
const project = projects?.find(p => p.id === projectId);
projectName = project?.name;
} catch (error) {
// Ignore errors in getting project name
}
return {
success: true,
data: sections,
message: `Retrieved ${sections.length} section(s)`,
metadata: {
total_count: sections.length,
project_name: projectName,
},
};
}
/**
* Reorder sections within a project
*/
private async handleReorder(
input: TodoistSectionsInput
): Promise<TodoistSectionsOutput> {
const projectId = input.project_id;
if (!projectId) {
throw new ValidationError('project_id is required for reorder action');
}
const sectionOrders = input.section_orders;
if (!sectionOrders || sectionOrders.length === 0) {
throw new ValidationError(
'section_orders is required for reorder action'
);
}
// Validate that all section IDs exist and belong to the specified project
try {
const existingSections = await this.apiService.getSections(projectId);
const existingSectionIds = new Set(existingSections.map(s => s.id));
const invalidSectionIds = sectionOrders
.map(so => so.id)
.filter(id => !existingSectionIds.has(id));
if (invalidSectionIds.length > 0) {
throw new ValidationError(
`Invalid section IDs: ${invalidSectionIds.join(', ')}`
);
}
} catch (error) {
if (error instanceof ValidationError) {
throw error;
}
// If we can't validate, proceed - API will handle invalid IDs
}
// Execute reorder operations sequentially to avoid conflicts
for (const sectionOrder of sectionOrders) {
await this.apiService.updateSection(sectionOrder.id, {
order: sectionOrder.order,
});
}
// Invalidate sections cache for this project
this.cacheService.invalidateSections(projectId);
return {
success: true,
message: 'Sections reordered successfully',
metadata: {
total_count: sectionOrders.length,
},
};
}
/**
* Handle errors and convert them to standardized output format
*/
private handleError(
error: unknown,
operationTime: number
): TodoistSectionsOutput {
return handleToolError(error, operationTime) as TodoistSectionsOutput;
}
}