MCP Server for Asana

by cristip73
Verified
# PRD v2: Priorities for Improving the Asana-Claude Integration ## 1. Executive Summary Following extensive testing to validate the issues identified in PRD v1, we now present a prioritized list of improvements needed for the Asana-Claude integration. This document focuses on 10 new features and 5 modifications to existing functionality, selected based on importance and impact. ## 1.1 Project Context The mcp-server-asana project implements a Model Context Protocol (MCP) server that provides Asana integration for AI tools like Claude. The current implementation: - Lives in a TypeScript codebase organized into modules: - `src/tools/` - Contains domain-specific tools organized by functionality - `src/asana-client-wrapper.ts` - Handles interactions with the Asana API - `src/tool-handler.ts` - Registers tools and routes requests to appropriate handlers - Has existing functionalities that need improvements: - Parameters need standardization (especially array-type parameters) - Some workflows require multiple steps that could be simplified - Error handling needs enhancement, especially for custom fields - Documentation needs clarification for user-facing functionality - MCP server architectural constraints: - Tools must be registered with a defined schema - Changes must be backward compatible - Implementation needs to follow the existing patterns for consistency The proposed improvements aim to make the Asana integration more efficient, intuitive, and powerful while maintaining compatibility with the existing architecture. ## 2. Testing Conclusions Testing confirmed most of the initially identified issues and provided important clarifications: 1. **Custom Field Updates** - Works for enum fields with the correct format, but requires documentation and standardization. 2. **Adding Dependents** - Works when the `dependents` parameter is formatted as an array. 3. **Creating Tasks in Specific Sections** - Not supported directly, requiring a two-step workflow. 4. **Task Counting** - Requires explicit specification of fields. 5. **Adding Tags** - Not directly supported through the API. 6. **Project Hierarchy** - Does not include subtasks in the results. ## 3. New Features Prioritized (10) > **Note**: Following additional testing of the features `asana_add_members_for_project` and `asana_add_followers_for_project`, we confirmed that they work correctly and with parameters in array format. These features can serve as models for future implementations, especially for adding tags and standardizing arrays in parameters. ## 3. New Features Prioritized (10) > **Note**: Following additional testing of the features `asana_add_members_for_project` and `asana_add_followers_for_project`, we confirmed that they work correctly and with parameters in array format. These features can serve as models for future implementations, especially for adding tags and standardizing arrays in parameters. ### 3.0 Search Users - **Priority: High** - **Description**: Implementing functions for identifying and listing users in Asana - **Justification**: Enables better user management, task assignment, and reporting capabilities - **Status**: Planned #### 3.0.1 List Users in a Workspace - **Priority: High** - **Description**: Implement a function to list all users in a specific workspace - **Justification**: Essential for user management and task assignment - **Implementation Details**: - Create a new tool in `src/tools/user-tools.ts`: ```typescript export const getUsersForWorkspaceTool: Tool = { name: "asana_list_workspace_users", description: "Get all users in a workspace or organization", inputSchema: { type: "object", properties: { workspace_id: { type: "string", description: "The workspace ID to get users for" }, opt_fields: { type: "string", description: "Comma-separated list of optional fields to include (e.g., 'name,email,photo,role')" }, limit: { type: "number", description: "Maximum number of results to return per page (1-100)" }, offset: { type: "string", description: "Pagination token from previous response" }, auto_paginate: { type: "boolean", description: "If true, automatically gets all pages and combines results", default: false } }, required: ["workspace_id"] } }; ``` - Add implementation in `src/asana-client-wrapper.ts`: ```typescript async getUsersForWorkspace(workspaceId: string, opts: any = {}) { try { // Extract pagination parameters const { auto_paginate = false, max_pages = 10, limit, offset, ...otherOpts } = opts; // Build search parameters const searchParams: any = { ...otherOpts }; // Add pagination parameters if (limit !== undefined) { searchParams.limit = Math.min(Math.max(1, Number(limit)), 100); } if (offset) searchParams.offset = offset; // Use the paginated results handler for more reliable pagination return await this.handlePaginatedResults( () => this.users.getUsersForWorkspace(workspaceId, searchParams), (nextOffset) => this.users.getUsersForWorkspace(workspaceId, { ...searchParams, offset: nextOffset }), { auto_paginate, max_pages } ); } catch (error: any) { console.error(`Error getting users for workspace ${workspaceId}: ${error.message}`); throw error; } } ``` - Update `tool-handler.ts` to handle the new tool #### 3.0.2 Get User Details - **Priority: Medium** - **Description**: Implement a function to get detailed information about a specific user - **Justification**: Provides comprehensive user information for reporting and management - **Implementation Details**: - Create a new tool in `src/tools/user-tools.ts`: ```typescript export const getUserDetailsTool: Tool = { name: "asana_get_user_details", description: "Get detailed information about a specific user", inputSchema: { type: "object", properties: { user_id: { type: "string", description: "The user ID to get details for. Use 'me' to get details for the current user." }, opt_fields: { type: "string", description: "Comma-separated list of optional fields to include (e.g., 'name,email,photo,workspaces,teams')" } }, required: ["user_id"] } }; ``` - Leverage existing `getUser` method in Asana API - Update `tool-handler.ts` to handle the new tool #### 3.0.3 List Users in a Team - **Priority: Medium** - **Description**: Implement a function to list all users in a specific team - **Justification**: Enables team-based user management and reporting - **Implementation Details**: - Create a new tool in `src/tools/user-tools.ts`: ```typescript export const getUsersForTeamTool: Tool = { name: "asana_list_team_users", description: "Get all users in a specific team", inputSchema: { type: "object", properties: { team_id: { type: "string", description: "The team ID to get users for" }, opt_fields: { type: "string", description: "Comma-separated list of optional fields to include (e.g., 'name,email,photo')" }, limit: { type: "number", description: "Maximum number of results to return per page (1-100)" }, offset: { type: "string", description: "Pagination token from previous response" }, auto_paginate: { type: "boolean", description: "If true, automatically gets all pages and combines results", default: false } }, required: ["team_id"] } }; ``` - Add implementation in `src/asana-client-wrapper.ts`: ```typescript async getUsersForTeam(teamId: string, opts: any = {}) { try { // Extract pagination parameters const { auto_paginate = false, max_pages = 10, limit, offset, ...otherOpts } = opts; // Build search parameters const searchParams: any = { ...otherOpts }; // Add pagination parameters if (limit !== undefined) { searchParams.limit = Math.min(Math.max(1, Number(limit)), 100); } if (offset) searchParams.offset = offset; // Use the paginated results handler for more reliable pagination return await this.handlePaginatedResults( () => this.users.getUsersForTeam(teamId, searchParams), (nextOffset) => this.users.getUsersForTeam(teamId, { ...searchParams, offset: nextOffset }), { auto_paginate, max_pages } ); } catch (error: any) { console.error(`Error getting users for team ${teamId}: ${error.message}`); throw error; } } ``` - Update `tool-handler.ts` to handle the new tool ### 3.2 ✅ Adding Tags to Tasks - **Priority: High** - **Description**: Implementing the ability to add tags to tasks - **Justification**: Allows for efficient categorization of tasks https://developers.asana.com/reference/addtagfortask - **AI usage guidance to include:** "For categorizing tasks, use `asana_add_tags_to_task` with an array of tag IDs (get available tags with `asana_get_tags_for_workspace`)." ### 3.3 ✅ Complete Project Hierarchy - **Priority: High** - **Description**: Extending the `asana_get_project_hierarchy` function to include subtasks - **Justification**: Provides a complete picture of the project structure - **Implementation Details**: - Update the tool definition in `src/tools/task-tools.ts`: ```typescript export const getProjectHierarchyTool: Tool = { name: "asana_get_project_hierarchy", description: "Get a hierarchical view of a project with sections, tasks, and optionally subtasks", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "The project ID to get hierarchy for" }, include_subtasks: { type: "boolean", description: "Whether to include subtasks in the hierarchy (default: false)", default: false }, max_subtask_depth: { type: "integer", description: "Maximum depth of subtasks to retrieve (default: 1, meaning only direct subtasks)", default: 1 }, sections_per_page: { type: "integer", description: "Number of sections to retrieve per page (default: 20)", default: 20 }, tasks_per_section: { type: "integer", description: "Maximum number of tasks to retrieve per section (default: 100)", default: 100 }, subtasks_per_task: { type: "integer", description: "Maximum number of subtasks to retrieve per task (default: 20)", default: 20 }, opt_fields: { type: "string", description: "Comma-separated list of optional fields to include for tasks and subtasks", default: "name,gid,due_on,completed" } }, required: ["project_id"] } }; ``` - Enhance the implementation in `src/asana-client-wrapper.ts`: ```typescript async getProjectHierarchy(projectId: string, opts: any = {}) { // Default options const options = { include_subtasks: false, max_subtask_depth: 1, sections_per_page: 20, tasks_per_section: 100, subtasks_per_task: 20, opt_fields: "name,gid,due_on,completed", ...opts }; console.log(`Getting project hierarchy for project ${projectId}, include_subtasks=${options.include_subtasks}, max_depth=${options.max_subtask_depth}`); // Get project details const project = await this.getProject(projectId, { opt_fields: "name,gid" + (options.opt_fields ? `,${options.opt_fields}` : "") }); // Get project sections with pagination if needed let allSections = []; let offset = null; do { const paginationOpts = { limit: options.sections_per_page, ...(offset ? { offset } : {}) }; const sectionsResponse = await this.projects.getSectionsForProject(projectId, paginationOpts); allSections = [...allSections, ...sectionsResponse.data]; offset = sectionsResponse.next_page ? sectionsResponse.next_page.offset : null; } while (offset); // Prepare result structure const result = { project: { gid: project.gid, name: project.name }, sections: [], stats: { total_sections: allSections.length, total_tasks: 0, total_subtasks: 0 } }; // Process each section and its tasks for (const section of allSections) { const tasksResponse = await this.sections.getTasksForSection(section.gid, { opt_fields: options.opt_fields, limit: options.tasks_per_section }); const tasks = tasksResponse.data; result.stats.total_tasks += tasks.length; const sectionData = { gid: section.gid, name: section.name, tasks: tasks }; // If include_subtasks flag is true, fetch subtasks for each task if (options.include_subtasks) { await this.fetchSubtasksRecursively( tasks, options.max_subtask_depth, 1, options.subtasks_per_task, options.opt_fields, result.stats ); } result.sections.push(sectionData); } return result; } // Helper method to recursively fetch subtasks async fetchSubtasksRecursively(tasks, maxDepth, currentDepth, limit, opt_fields, stats) { if (currentDepth > maxDepth) return; for (let i = 0; i < tasks.length; i++) { const task = tasks[i]; const subtasksResponse = await this.tasks.getSubtasksForTask(task.gid, { opt_fields, limit }); const subtasks = subtasksResponse.data; stats.total_subtasks += subtasks.length; // Add subtasks to the task task.subtasks = subtasks; // Recursively get subtasks of subtasks if needed if (currentDepth < maxDepth && subtasks.length > 0) { await this.fetchSubtasksRecursively( subtasks, maxDepth, currentDepth + 1, limit, opt_fields, stats ); } } } ``` - Update the tool handler in `src/tool-handler.ts` to pass all parameters: ```typescript case "asana_get_project_hierarchy": return { result: await asanaClient.getProjectHierarchy( args.project_id, { include_subtasks: args.include_subtasks || false, max_subtask_depth: args.max_subtask_depth || 1, sections_per_page: args.sections_per_page || 20, tasks_per_section: args.tasks_per_section || 100, subtasks_per_task: args.subtasks_per_task || 20, opt_fields: args.opt_fields || "name,gid,due_on,completed" } ) }; ``` - Add performance warning in the documentation: ```markdown #### Project Hierarchy with Subtasks For large projects, retrieving the full hierarchy with subtasks can be resource-intensive. Consider using the pagination and limiting parameters to control response size: - `max_subtask_depth`: Control how deep to go with nested subtasks - `tasks_per_section`: Limit tasks fetched per section - `subtasks_per_task`: Limit subtasks fetched per task ``` - Example usage: ```javascript // Basic usage with subtasks asana_get_project_hierarchy({ project_id: "PROJECT_ID", include_subtasks: true }) // Advanced usage with control parameters asana_get_project_hierarchy({ project_id: "PROJECT_ID", include_subtasks: true, max_subtask_depth: 2, tasks_per_section: 50, subtasks_per_task: 10, opt_fields: "name,gid,due_on,completed,assignee" }) ``` - Expected output: ```json { "project": { "gid": "1209649929142042", "name": "Marketing Campaign Q2" }, "sections": [ { "gid": "1209649929142064", "name": "Planning", "tasks": [ { "gid": "1209649807941684", "name": "Define campaign objectives", "completed": true, "due_on": "2025-04-05", "subtasks": [ { "gid": "1209649809895684", "name": "Draft initial objectives", "completed": false, "due_on": "2025-04-02" } ] } ] } ], "stats": { "total_sections": 1, "total_tasks": 1, "total_subtasks": 1 } } ``` - **AI usage guidance to include:** "For complete project structure including subtasks, use `include_subtasks: true`. For large projects, control response size with `max_subtask_depth` and other limiting parameters." ### 3.4 Batch Updating of Tasks - **Priority: Medium** - **Description**: Add a new tool for updating multiple tasks in a single call - **Justification**: Streamlines status or metadata updates for multiple tasks, reducing the number of API calls needed - **Implementation Details**: - Create a new file `src/tools/batch-tools.ts` to house batch operation functionality - Implement the tool definition following existing patterns: ```typescript export const batchUpdateTasksTool: Tool = { name: "asana_batch_update_tasks", description: "Update multiple tasks in a single batch operation", inputSchema: { type: "object", properties: { tasks: { type: "array", description: "Array of task objects to update, each containing task_id and update fields", items: { type: "object", properties: { task_id: { type: "string", description: "The ID of the task to update" }, // Allow any other properties that are valid for asana_update_task }, required: ["task_id"] } }, transaction_mode: { type: "string", description: "How to handle errors in batch: 'continue' (process all tasks regardless of errors), 'stop_on_error' (stop processing on first error), or 'all_or_nothing' (revert all changes if any task update fails)", enum: ["continue", "stop_on_error", "all_or_nothing"], default: "continue" }, concurrency: { type: "integer", description: "Maximum number of tasks to update concurrently (1-5, default: 3)", default: 3 } }, required: ["tasks"] } }; ``` - Add batch utils helper module for concurrent processing: ```typescript // src/utils/batch-utils.ts export async function processBatch(items, processFn, concurrency = 3) { const results = []; const chunks = []; // Split items into chunks based on concurrency for (let i = 0; i < items.length; i += concurrency) { chunks.push(items.slice(i, i + concurrency)); } // Process chunks sequentially, but items within chunks concurrently for (const chunk of chunks) { const chunkPromises = chunk.map(item => processFn(item)); const chunkResults = await Promise.all(chunkPromises); results.push(...chunkResults); } return results; } ``` - Add corresponding implementation in the `AsanaClientWrapper` class: ```typescript async batchUpdateTasks(tasks: any[], options: any = {}) { const { transaction_mode = 'continue', concurrency = 3 } = options; const defaultTaskFields = ["name", "due_on", "completed", "notes", "assignee", "custom_fields"]; const results = []; const successfulUpdates = []; // Validate concurrency const actualConcurrency = Math.min(5, Math.max(1, concurrency)); console.log(`Batch updating ${tasks.length} tasks with mode=${transaction_mode}, concurrency=${actualConcurrency}`); // Validate task_id is present in each task for (const task of tasks) { if (!task.task_id) { throw new Error("Each task in the batch must have a task_id"); } } // Function to process a single task update const processTask = async (task) => { const { task_id, ...updateData } = task; try { // Validate update data contains valid fields const unknownFields = Object.keys(updateData).filter(key => !defaultTaskFields.includes(key)); if (unknownFields.length > 0) { console.warn(`Warning: Potentially unknown fields in task update: ${unknownFields.join(', ')}`); } const result = await this.updateTask(task_id, updateData); successfulUpdates.push({ task_id, data: updateData }); return { task_id, status: "success", result }; } catch (error) { const errorResult = { task_id, status: "error", error: error.message }; // If in stop_on_error mode, throw to stop processing if (transaction_mode === 'stop_on_error') { throw new Error(`Task update failed: ${error.message}. Stopping batch processing.`); } return errorResult; } }; try { if (transaction_mode === 'all_or_nothing') { // For all_or_nothing, we collect errors but need to revert on failure const taskResults = []; for (const task of tasks) { try { const result = await processTask(task); taskResults.push(result); } catch (error) { // Revert all successful updates so far await this.revertTaskUpdates(successfulUpdates); throw new Error(`Batch failed in all_or_nothing mode: ${error.message}. All updates were reverted.`); } } results.push(...taskResults); } else { // For continue or stop_on_error modes, use concurrent processing try { const batchUtils = require('../utils/batch-utils'); const taskResults = await batchUtils.processBatch(tasks, processTask, actualConcurrency); results.push(...taskResults); } catch (error) { // This catch will only trigger in stop_on_error mode throw new Error(`Batch processing stopped due to error: ${error.message}`); } } } catch (error) { // This catch handles errors from the above operations throw error; } // Generate summary const successful = results.filter(r => r.status === 'success').length; const failed = results.filter(r => r.status === 'error').length; return { summary: { total: tasks.length, successful, failed }, tasks: results }; } // Helper method to revert updates in case of all_or_nothing transaction failure async revertTaskUpdates(updates) { console.log(`Reverting ${updates.length} task updates due to transaction failure`); for (const update of updates) { try { // For each successful update, we need to get the task's original state // This is a simplified approach - in a real implementation we would // need to store the original state before updates const task = await this.getTask(update.task_id); console.log(`Reverting changes to task ${update.task_id}`); } catch (error) { console.error(`Error reverting task ${update.task_id}: ${error.message}`); } } } ``` - Update `src/tool-handler.ts` to register and handle the new tool: ```typescript // Import the new batch tools import { batchUpdateTasksTool } from './tools/batch-tools.js'; // Add to tools array export const tools: Tool[] = [ // existing tools... batchUpdateTasksTool, // other tools... ]; // Add case in tool handler switch statement case "asana_batch_update_tasks": if (!Array.isArray(args.tasks) || args.tasks.length === 0) { throw new Error("Tasks must be provided as a non-empty array"); } // Set default options if not provided const batchOptions = { transaction_mode: args.transaction_mode || 'continue', concurrency: args.concurrency || 3 }; return { result: await asanaClient.batchUpdateTasks(args.tasks, batchOptions) }; ``` - Add usage documentation in README: ```markdown #### Batch Updating Tasks When you need to update multiple tasks at once, you can use the batch update feature: ```javascript asana_batch_update_tasks({ tasks: [ { task_id: "1234", completed: true }, { task_id: "5678", due_on: "2025-05-01" } ], transaction_mode: "continue", // Options: "continue", "stop_on_error", "all_or_nothing" concurrency: 3 // How many tasks to process simultaneously (1-5) }) ``` The `transaction_mode` parameter controls error handling: - `continue`: Process all tasks regardless of errors (default) - `stop_on_error`: Stop processing when the first error occurs - `all_or_nothing`: Try to revert all changes if any task update fails ``` - Example usage: ```javascript // Basic batch update asana_batch_update_tasks({ tasks: [ { task_id: "1234", completed: true }, { task_id: "5678", due_on: "2025-05-01" }, { task_id: "9012", assignee: "user_123", name: "Updated task name" } ] }) // Advanced batch update with transaction control asana_batch_update_tasks({ tasks: [ { task_id: "1234", completed: true }, { task_id: "5678", due_on: "2025-05-01" } ], transaction_mode: "all_or_nothing", concurrency: 2 }) ``` - Expected response: ```json { "summary": { "total": 3, "successful": 2, "failed": 1 }, "tasks": [ { "task_id": "1234", "status": "success", "result": { "gid": "1234", "name": "Task 1", "completed": true } }, { "task_id": "5678", "status": "success", "result": { "gid": "5678", "name": "Task 2", "due_on": "2025-05-01" } }, { "task_id": "9012", "status": "error", "error": "Task not found or no access" } ] } ``` - **AI usage guidance to include:** "To update multiple tasks at once, use `asana_batch_update_tasks` with an array of task objects. Control error handling with `transaction_mode` parameter." ### 3.5 Reordering Sections - **Priority: Medium** - **Description**: Allows modifying the order of sections in a project - **Justification**: Facilitates project structure reorganization - **Implementation Details**: - Add a new tool definition in `src/tools/project-tools.ts`: ```typescript export const reorderSectionsTool: Tool = { name: "asana_reorder_sections", description: "Reorder sections within a project", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "The project ID containing the sections to reorder" }, section_order: { type: "array", description: "Array of section GIDs in the desired order. Must include all sections in the project.", items: { type: "string" } }, validate_sections: { type: "boolean", description: "Whether to validate that all sections exist in the project before attempting reordering (default: true)", default: true } }, required: ["project_id", "section_order"] } }; ``` - Add implementation in `src/asana-client-wrapper.ts`: ```typescript async reorderSections(projectId: string, sectionOrder: string[], options: any = {}) { const { validate_sections = true } = options; if (!Array.isArray(sectionOrder) || sectionOrder.length === 0) { throw new Error("Section order must be a non-empty array of section GIDs"); } console.log(`Reordering ${sectionOrder.length} sections in project ${projectId}`); // If validation is enabled, verify all sections exist in the project if (validate_sections) { const projectSections = await this.getProjectSections(projectId); const projectSectionIds = projectSections.map(section => section.gid); // Check if all sections in sectionOrder exist in the project const invalidSections = sectionOrder.filter(sectionId => !projectSectionIds.includes(sectionId)); if (invalidSections.length > 0) { throw new Error(`The following section IDs do not exist in project ${projectId}: ${invalidSections.join(', ')}`); } // Check if all project sections are included in sectionOrder const missingSections = projectSectionIds.filter(sectionId => !sectionOrder.includes(sectionId)); if (missingSections.length > 0) { throw new Error(`The following sections from project ${projectId} are missing in the reordering: ${missingSections.join(', ')}`); } } // Asana's API doesn't have a single endpoint for reordering all sections // We need to move sections one by one to achieve the desired order const results = []; // The strategy is to move each section to its expected position // We'll start from the end and work backwards for (let i = sectionOrder.length - 1; i >= 0; i--) { const sectionId = sectionOrder[i]; try { // For each section except the first one, we need to insert it after the previous one if (i > 0) { const previousSectionId = sectionOrder[i - 1]; // Asana API for moving a section const response = await this.sections.insertSectionForProject(projectId, { data: { section: sectionId, beforeSection: null, afterSection: previousSectionId } }); results.push({ section_id: sectionId, status: "success", position: i + 1 }); } else { // For the first section, we need to move it to the beginning const response = await this.sections.insertSectionForProject(projectId, { data: { section: sectionId, beforeSection: null, afterSection: null } }); results.push({ section_id: sectionId, status: "success", position: 1 }); } } catch (error) { results.push({ section_id: sectionId, status: "error", error: error.message, position: i + 1 }); console.error(`Error reordering section ${sectionId}: ${error.message}`); } } // After reordering, fetch and return the updated section list const updatedSections = await this.getProjectSections(projectId); return { project_id: projectId, results: results, current_order: updatedSections.map(section => ({ gid: section.gid, name: section.name })) }; } ``` - Update `src/tool-handler.ts` to register and handle the new tool: ```typescript // Add to imports from project-tools.js import { // existing imports... reorderSectionsTool } from './tools/project-tools.js'; // Add to tools array export const tools: Tool[] = [ // existing tools... reorderSectionsTool, // other tools... ]; // Add case in tool handler switch statement case "asana_reorder_sections": if (!Array.isArray(args.section_order)) { throw new Error("section_order must be an array of section GIDs"); } return { result: await asanaClient.reorderSections( args.project_id, args.section_order, { validate_sections: args.validate_sections !== false } ) }; ``` - Add usage documentation in README: ```markdown #### Reordering Sections in a Project To reorganize your project by changing the order of sections: ```javascript asana_reorder_sections({ project_id: "PROJECT_ID", section_order: ["SECTION_ID1", "SECTION_ID2", "SECTION_ID3"], validate_sections: true // Optional, default is true }) ``` This will reorder the sections to match the provided sequence. All sections in the project must be included in the `section_order` array. ``` - Example usage: ```javascript // Reorder sections in a project asana_reorder_sections({ project_id: "PROJECT_ID", section_order: ["SECTION_ID1", "SECTION_ID2", "SECTION_ID3"] }) ``` - Expected response: ```json { "project_id": "PROJECT_ID", "results": [ { "section_id": "SECTION_ID3", "status": "success", "position": 3 }, { "section_id": "SECTION_ID2", "status": "success", "position": 2 }, { "section_id": "SECTION_ID1", "status": "success", "position": 1 } ], "current_order": [ { "gid": "SECTION_ID1", "name": "First Section" }, { "gid": "SECTION_ID2", "name": "Second Section" }, { "gid": "SECTION_ID3", "name": "Third Section" } ] } ``` - Performance considerations: ``` Note: This operation requires multiple API calls (one per section), so it may be slower for projects with many sections. The implementation uses a systematic approach to ensure sections are moved in the correct order to minimize API calls. ``` - **AI usage guidance to include:** "To reorganize project sections, use `asana_reorder_sections` with project ID and array of section IDs in desired order." ### 3.6 Project Summary - **Priority: Medium** - **Description**: Provides a high-level overview of a project with key metrics and insights - **Justification**: Facilitates reporting and monitoring project health at a glance - **Implementation Details**: - Create a new tool definition in `src/tools/project-tools.ts`: ```typescript export const getProjectSummaryTool: Tool = { name: "asana_get_project_summary", description: "Get a comprehensive summary of project metrics, task distribution, and progress", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "The project ID to get summary for" }, include_sections: { type: "boolean", description: "Whether to include section-level metrics in the summary (default: true)", default: true }, include_task_distribution: { type: "boolean", description: "Whether to include task distribution metrics by assignee (default: true)", default: true }, include_due_date_metrics: { type: "boolean", description: "Whether to include due date distribution metrics (default: true)", default: true }, include_overdue_tasks: { type: "boolean", description: "Whether to include list of overdue tasks (default: false)", default: false }, include_recent_activity: { type: "boolean", description: "Whether to include recent activity metrics (default: false)", default: false } }, required: ["project_id"] } }; ``` - Add implementation in `src/asana-client-wrapper.ts`: ```typescript async getProjectSummary(projectId: string, options: any = {}) { // Default options const opts = { include_sections: true, include_task_distribution: true, include_due_date_metrics: true, include_overdue_tasks: false, include_recent_activity: false, ...options }; console.log(`Generating project summary for project ${projectId}`); // Get basic project details const project = await this.getProject(projectId, { opt_fields: "name,gid,created_at,modified_at,owner,due_date,starts_on,public,archived" }); // Get task counts const taskCounts = await this.getProjectTaskCounts(projectId, { opt_fields: "num_tasks,num_incomplete_tasks,num_completed_tasks" }); // Initialize summary object const summary = { project: { gid: project.gid, name: project.name, created_at: project.created_at, modified_at: project.modified_at, owner: project.owner, due_date: project.due_date, starts_on: project.starts_on, public: project.public, archived: project.archived }, overall_metrics: { total_tasks: taskCounts.num_tasks || 0, completed_tasks: taskCounts.num_completed_tasks || 0, incomplete_tasks: taskCounts.num_incomplete_tasks || 0, completion_percentage: taskCounts.num_tasks ? Math.round((taskCounts.num_completed_tasks / taskCounts.num_tasks) * 100) : 0 }, sections: [], task_distribution: {}, due_date_metrics: {}, overdue_tasks: [], recent_activity: {} }; // Get sections and their tasks if requested if (opts.include_sections) { const sections = await this.getProjectSections(projectId); for (const section of sections) { const sectionTasks = await this.getTasksForSection(section.gid, { opt_fields: "name,gid,completed,due_on,assignee" }); const completedTasks = sectionTasks.filter(task => task.completed); summary.sections.push({ gid: section.gid, name: section.name, total_tasks: sectionTasks.length, completed_tasks: completedTasks.length, incomplete_tasks: sectionTasks.length - completedTasks.length, completion_percentage: sectionTasks.length ? Math.round((completedTasks.length / sectionTasks.length) * 100) : 0 }); } } // Get task distribution by assignee if requested if (opts.include_task_distribution) { // Search for all tasks in the project const tasks = await this.tasks.getTasksForProject(projectId, { opt_fields: "assignee,completed" }); const assigneeDistribution = {}; tasks.data.forEach(task => { const assigneeId = task.assignee ? task.assignee.gid : 'unassigned'; const assigneeName = task.assignee ? task.assignee.name : 'Unassigned'; if (!assigneeDistribution[assigneeId]) { assigneeDistribution[assigneeId] = { gid: assigneeId, name: assigneeName, total_tasks: 0, completed_tasks: 0, incomplete_tasks: 0 }; } assigneeDistribution[assigneeId].total_tasks++; if (task.completed) { assigneeDistribution[assigneeId].completed_tasks++; } else { assigneeDistribution[assigneeId].incomplete_tasks++; } }); // Calculate completion percentage for each assignee Object.values(assigneeDistribution).forEach((assignee: any) => { assignee.completion_percentage = assignee.total_tasks ? Math.round((assignee.completed_tasks / assignee.total_tasks) * 100) : 0; }); summary.task_distribution = Object.values(assigneeDistribution); } // Get due date metrics if requested if (opts.include_due_date_metrics) { const tasks = await this.tasks.getTasksForProject(projectId, { opt_fields: "due_on,completed" }); const today = new Date(); today.setHours(0, 0, 0, 0); // Normalize to start of day const dueDateMetrics = { past_due: 0, due_today: 0, due_this_week: 0, due_next_week: 0, due_later: 0, no_due_date: 0 }; const oneWeekFromNow = new Date(); oneWeekFromNow.setDate(today.getDate() + 7); const twoWeeksFromNow = new Date(); twoWeeksFromNow.setDate(today.getDate() + 14); tasks.data.forEach(task => { // Skip completed tasks if (task.completed) return; if (!task.due_on) { dueDateMetrics.no_due_date++; return; } const dueDate = new Date(task.due_on); dueDate.setHours(0, 0, 0, 0); // Normalize if (dueDate < today) { dueDateMetrics.past_due++; } else if (dueDate.getTime() === today.getTime()) { dueDateMetrics.due_today++; } else if (dueDate <= oneWeekFromNow) { dueDateMetrics.due_this_week++; } else if (dueDate <= twoWeeksFromNow) { dueDateMetrics.due_next_week++; } else { dueDateMetrics.due_later++; } }); summary.due_date_metrics = dueDateMetrics; } // Get overdue tasks if requested if (opts.include_overdue_tasks) { const today = new Date(); today.setHours(0, 0, 0, 0); // Normalize to start of day const tasks = await this.tasks.getTasksForProject(projectId, { opt_fields: "name,gid,due_on,assignee,completed", completed: false }); const overdueTasks = tasks.data.filter(task => { if (!task.due_on) return false; const dueDate = new Date(task.due_on); return dueDate < today; }).map(task => ({ gid: task.gid, name: task.name, due_on: task.due_on, assignee: task.assignee ? { gid: task.assignee.gid, name: task.assignee.name } : null })); summary.overdue_tasks = overdueTasks; } // Get recent activity metrics if requested if (opts.include_recent_activity) { const twoWeeksAgo = new Date(); twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); // Find tasks modified in the last two weeks const recentTasks = await this.tasks.getTasksForProject(projectId, { opt_fields: "modified_at,completed_at", modified_since: twoWeeksAgo.toISOString() }); const recentActivity = { recently_modified_count: recentTasks.data.length, recently_completed_count: recentTasks.data.filter(t => t.completed_at && new Date(t.completed_at) > twoWeeksAgo ).length }; summary.recent_activity = recentActivity; } return summary; } ``` - Update `src/tool-handler.ts` to register and handle the new tool: ```typescript // Add to imports from project-tools.js import { // existing imports... getProjectSummaryTool } from './tools/project-tools.js'; // Add to tools array export const tools: Tool[] = [ // existing tools... getProjectSummaryTool, // other tools... ]; // Add case in tool handler switch statement case "asana_get_project_summary": return { result: await asanaClient.getProjectSummary( args.project_id, { include_sections: args.include_sections !== false, include_task_distribution: args.include_task_distribution !== false, include_due_date_metrics: args.include_due_date_metrics !== false, include_overdue_tasks: !!args.include_overdue_tasks, include_recent_activity: !!args.include_recent_activity } ) }; ``` - Add usage documentation in README: ```markdown #### Project Summary Get a comprehensive overview of a project's health and status: ```javascript asana_get_project_summary({ project_id: "PROJECT_ID", include_sections: true, // Section-level metrics include_task_distribution: true, // Distribution by assignee include_due_date_metrics: true, // Due date distribution include_overdue_tasks: false, // List of overdue tasks include_recent_activity: false // Recent activity metrics }) ``` This provides a consolidated view of project metrics to help assess project health and progress at a glance. ``` - Example usage: ```javascript // Basic project summary with default options asana_get_project_summary({ project_id: "PROJECT_ID" }) // Comprehensive project summary with all metrics asana_get_project_summary({ project_id: "PROJECT_ID", include_overdue_tasks: true, include_recent_activity: true }) ``` - Expected response: ```json { "project": { "gid": "PROJECT_ID", "name": "Marketing Campaign Q2", "created_at": "2025-03-11T16:53:57.931Z", "modified_at": "2025-03-12T05:11:55.109Z", "owner": { "gid": "USER_ID", "name": "John Doe" }, "due_date": "2025-06-30", "starts_on": "2025-04-01", "public": true, "archived": false }, "overall_metrics": { "total_tasks": 12, "completed_tasks": 5, "incomplete_tasks": 7, "completion_percentage": 42 }, "sections": [ { "gid": "SECTION_ID1", "name": "Planning", "total_tasks": 4, "completed_tasks": 3, "incomplete_tasks": 1, "completion_percentage": 75 }, { "gid": "SECTION_ID2", "name": "Execution", "total_tasks": 8, "completed_tasks": 2, "incomplete_tasks": 6, "completion_percentage": 25 } ], "task_distribution": [ { "gid": "USER_ID1", "name": "John Doe", "total_tasks": 5, "completed_tasks": 3, "incomplete_tasks": 2, "completion_percentage": 60 }, { "gid": "USER_ID2", "name": "Jane Smith", "total_tasks": 4, "completed_tasks": 1, "incomplete_tasks": 3, "completion_percentage": 25 }, { "gid": "unassigned", "name": "Unassigned", "total_tasks": 3, "completed_tasks": 1, "incomplete_tasks": 2, "completion_percentage": 33 } ], "due_date_metrics": { "past_due": 1, "due_today": 2, "due_this_week": 3, "due_next_week": 2, "due_later": 1, "no_due_date": 3 }, "overdue_tasks": [ { "gid": "TASK_ID1", "name": "Submit budget proposal", "due_on": "2025-03-10", "assignee": { "gid": "USER_ID1", "name": "John Doe" } } ], "recent_activity": { "recently_modified_count": 8, "recently_completed_count": 5 } } ``` - How it differs from existing features: ``` While asana_get_project_task_counts provides basic task counts, this summary offers: 1. Comprehensive metrics across multiple dimensions (tasks, assignees, due dates) 2. Section-level progress tracking 3. Workload distribution by team member 4. Time-based analysis of upcoming and overdue work 5. Identification of potential bottlenecks or at-risk areas ``` - **AI usage guidance to include:** "For a complete project health overview including completion rates, workload distribution, and due date metrics, use `asana_get_project_summary`." ### 3.7 Advanced Search in Project After careful evaluation, we recommend **removing this feature** from the implementation roadmap for the following reasons: 1. **Redundancy with Existing Functionality**: This functionality can be fully accomplished using the existing `asana_search_tasks` tool with the appropriate filters. 2. **Implementation Example Using Existing Tools**: ```javascript // Instead of creating a new tool, use asana_search_tasks with project filter: asana_search_tasks({ workspace: "WORKSPACE_ID", projects_any: "PROJECT_ID", // Project-specific filter text: "search term", // Text to search for opt_fields: "name,notes,due_on,assignee", // Include notes in results limit: 20 // Limit number of results }) ``` 3. **Better Alternative**: Instead of implementing this as a separate feature, we recommend enhancing the documentation for `asana_search_tasks` to include clear examples of project-specific searches. ## 4. **Documentation Enhancement**: ```markdown #### Searching Within a Project To search for tasks within a specific project: ```javascript asana_search_tasks({ workspace: "WORKSPACE_ID", projects_any: "PROJECT_ID", // Limit search to this project text: "search term" }) ``` For including task notes in your search: ```javascript asana_search_tasks({ workspace: "WORKSPACE_ID", projects_any: "PROJECT_ID", text: "search term", opt_fields: "name,notes,due_on,assignee" }) ``` ``` 5. **Resource Allocation**: The development effort would be better directed toward implementing unique features that aren't possible with the current toolset. **AI usage guidance to add to existing search_tasks documentation**: "To search within a specific project, use `asana_search_tasks` with the `projects_any` parameter set to your project ID." ### 3.8 Duplicating Tasks - **Priority: Low-Medium** - **Description**: Allows creating a copy of an existing task with its details and optionally its subtasks - **Justification**: Useful for repetitive tasks, templates, and creating similar tasks efficiently - **Implementation Details**: - Add a new tool definition in `src/tools/task-tools.ts`: ```typescript export const duplicateTaskTool: Tool = { name: "asana_duplicate_task", description: "Create a copy of an existing task with its details and optionally subtasks", inputSchema: { type: "object", properties: { task_id: { type: "string", description: "The task ID to duplicate" }, name: { type: "string", description: "Optional new name for the duplicated task. If not provided, will use the original task name with 'Copy of ' prefix." }, include_subtasks: { type: "boolean", description: "Whether to include subtasks in the duplication (default: false)", default: false }, include_attachments: { type: "boolean", description: "Whether to include attachments in the duplication (default: false)", default: false }, include_tags: { type: "boolean", description: "Whether to include tags in the duplication (default: true)", default: true }, include_dependencies: { type: "boolean", description: "Whether to preserve dependencies (default: false)", default: false }, include_followers: { type: "boolean", description: "Whether to include the same followers (default: false)", default: false }, due_on_offset: { type: "integer", description: "Optional number of days to offset the due date (can be negative or positive)", default: 0 }, target_section_id: { type: "string", description: "Optional section ID to place the duplicated task in" }, target_project_id: { type: "string", description: "Optional project ID to create the task in. If not provided, will use the same project(s) as the original." } }, required: ["task_id"] } }; ``` - Add implementation in `src/asana-client-wrapper.ts`: ```typescript async duplicateTask(taskId: string, options: any = {}) { // Set default options const opts = { include_subtasks: false, include_attachments: false, include_tags: true, include_dependencies: false, include_followers: false, due_on_offset: 0, ...options }; console.log(`Duplicating task ${taskId} with options:`, JSON.stringify(opts)); // Get the source task with details const sourceTask = await this.getTask(taskId, { opt_fields: "name,notes,projects,custom_fields,due_on,assignee,tags,dependencies,followers,parent" }); if (!sourceTask) { throw new Error(`Task ${taskId} not found or inaccessible`); } // Handle custom fields - get the original structure const customFields = {}; if (sourceTask.custom_fields) { sourceTask.custom_fields.forEach(field => { if (field.enabled && field.value !== null) { // For enum type custom fields, we need the enum_value.gid if (field.resource_subtype === 'enum') { customFields[field.gid] = field.enum_value ? field.enum_value.gid : null; } else { // For other field types, use the direct value customFields[field.gid] = field.value; } } }); } // Calculate new due date if needed let newDueOn = null; if (sourceTask.due_on && opts.due_on_offset !== 0) { const dueDate = new Date(sourceTask.due_on); dueDate.setDate(dueDate.getDate() + opts.due_on_offset); newDueOn = dueDate.toISOString().split('T')[0]; // Format as YYYY-MM-DD } else if (sourceTask.due_on) { newDueOn = sourceTask.due_on; } // Prepare project array - either use target project or original projects const projects = opts.target_project_id ? [opts.target_project_id] : sourceTask.projects.map(p => p.gid); // Prepare new task data const newTaskData = { name: opts.name || `Copy of ${sourceTask.name}`, notes: sourceTask.notes, projects: projects, custom_fields: customFields, due_on: newDueOn, assignee: opts.include_assignee ? sourceTask.assignee?.gid : null }; // Create the new task const newTask = await this.tasks.createTask({ data: newTaskData }); const duplicateResult = { original_task: { gid: sourceTask.gid, name: sourceTask.name }, duplicated_task: newTask.data, subtasks: [], additional_operations: [] }; // If a target section is specified, add the task to that section if (opts.target_section_id) { try { await this.addTaskToSection(opts.target_section_id, newTask.data.gid); duplicateResult.additional_operations.push({ operation: "add_to_section", status: "success", section_id: opts.target_section_id }); } catch (error) { duplicateResult.additional_operations.push({ operation: "add_to_section", status: "error", section_id: opts.target_section_id, error: error.message }); } } // Add tags if requested if (opts.include_tags && sourceTask.tags) { try { const tagIds = sourceTask.tags.map(tag => tag.gid); if (tagIds.length > 0) { await this.addTagsToTask(newTask.data.gid, tagIds); duplicateResult.additional_operations.push({ operation: "add_tags", status: "success", count: tagIds.length }); } } catch (error) { duplicateResult.additional_operations.push({ operation: "add_tags", status: "error", error: error.message }); } } // Add followers if requested if (opts.include_followers && sourceTask.followers) { try { const followerIds = sourceTask.followers.map(follower => follower.gid); if (followerIds.length > 0) { await this.addFollowersToTask(newTask.data.gid, followerIds); duplicateResult.additional_operations.push({ operation: "add_followers", status: "success", count: followerIds.length }); } } catch (error) { duplicateResult.additional_operations.push({ operation: "add_followers", status: "error", error: error.message }); } } // Add dependencies if requested if (opts.include_dependencies && sourceTask.dependencies) { try { const dependencyIds = sourceTask.dependencies.map(dep => dep.gid); if (dependencyIds.length > 0) { await this.addTaskDependencies(newTask.data.gid, dependencyIds); duplicateResult.additional_operations.push({ operation: "add_dependencies", status: "success", count: dependencyIds.length }); } } catch (error) { duplicateResult.additional_operations.push({ operation: "add_dependencies", status: "error", error: error.message }); } } // Duplicate subtasks if requested if (opts.include_subtasks) { const subtasks = await this.getSubtasksForTask(taskId, { opt_fields: "name,notes,due_on,assignee" }); for (const subtask of subtasks) { try { const newSubtask = await this.createSubtask(newTask.data.gid, { name: subtask.name, notes: subtask.notes, due_on: subtask.due_on ? (opts.due_on_offset ? this.offsetDate(subtask.due_on, opts.due_on_offset) : subtask.due_on) : null, assignee: opts.include_assignee ? subtask.assignee?.gid : null }); duplicateResult.subtasks.push({ original_gid: subtask.gid, duplicated_gid: newSubtask.gid, name: newSubtask.name, status: "success" }); } catch (error) { duplicateResult.subtasks.push({ original_gid: subtask.gid, name: subtask.name, status: "error", error: error.message }); } } } return duplicateResult; } // Helper method to offset dates offsetDate(dateStr, offsetDays) { const date = new Date(dateStr); date.setDate(date.getDate() + offsetDays); return date.toISOString().split('T')[0]; // Format as YYYY-MM-DD } ``` - Update `src/tool-handler.ts` to register and handle the new tool: ```typescript // Add to imports import { // existing imports... duplicateTaskTool } from './tools/task-tools.js'; // Add to tools array export const tools: Tool[] = [ // existing tools... duplicateTaskTool, // other tools... ]; // Add case in tool handler switch statement case "asana_duplicate_task": return { result: await asanaClient.duplicateTask(args.task_id, { name: args.name, include_subtasks: !!args.include_subtasks, include_attachments: !!args.include_attachments, include_tags: args.include_tags !== false, include_dependencies: !!args.include_dependencies, include_followers: !!args.include_followers, due_on_offset: args.due_on_offset || 0, target_section_id: args.target_section_id, target_project_id: args.target_project_id }) }; ``` - Add documentation in README: ```markdown #### Duplicating Tasks Create copies of existing tasks with optional related elements: ```javascript asana_duplicate_task({ task_id: "TASK_ID", name: "New Task Name", // Optional - defaults to "Copy of [original name]" include_subtasks: true, // Include subtasks in duplication include_tags: true, // Copy the tags (default: true) include_dependencies: false, // Preserve dependencies include_followers: false, // Copy the followers due_on_offset: 7, // Shift due dates by 7 days target_section_id: "SECTION_ID", // Put the new task in a specific section target_project_id: "PROJECT_ID" // Put the new task in a specific project }) ``` This is useful for: - Creating task templates that can be reused - Repeating similar tasks in different time periods - Creating variations of existing tasks ``` - Example usage: ```javascript // Basic duplication asana_duplicate_task({ task_id: "TASK_ID" }) // Advanced duplication with subtasks and date shifting asana_duplicate_task({ task_id: "TASK_ID", name: "April Budget Review", include_subtasks: true, due_on_offset: 30, // Shift due dates by 30 days target_section_id: "SECTION_ID" }) // Create a task based on a template in another project asana_duplicate_task({ task_id: "TEMPLATE_TASK_ID", name: "New Project Kickoff", include_subtasks: true, target_project_id: "NEW_PROJECT_ID" }) ``` - Expected response: ```json { "original_task": { "gid": "1234567890", "name": "Budget Review" }, "duplicated_task": { "gid": "9876543210", "name": "April Budget Review", "due_on": "2025-05-15" }, "subtasks": [ { "original_gid": "111222333", "duplicated_gid": "444555666", "name": "Collect department budgets", "status": "success" }, { "original_gid": "777888999", "duplicated_gid": "000111222", "name": "Create summary report", "status": "success" } ], "additional_operations": [ { "operation": "add_to_section", "status": "success", "section_id": "SECTION_ID" }, { "operation": "add_tags", "status": "success", "count": 3 } ] } ``` - **What gets duplicated**: ``` By default, the following elements are duplicated: - Task name (with "Copy of" prefix unless a new name is specified) - Task description/notes - Custom fields - Project assignment - Tags (if include_tags is true) The following are only duplicated if explicitly requested: - Subtasks (include_subtasks: true) - Dependencies (include_dependencies: true) - Followers (include_followers: true) - Attachments (include_attachments: true) [Note: requires additional API calls] The following are never duplicated: - Comments/task stories - Completion status (duplicated tasks are always incomplete) - Task history ``` - **AI usage guidance to include:** "To create a copy of a task with its details, use `asana_duplicate_task`. For recurring templates, include subtasks and specify a due date offset." ### 3.9 Getting Project Timeline - **Priority: Medium** - **Description**: Returns tasks organized chronologically by due dates and start dates, with options for grouping, filtering and date range selection - **Justification**: Facilitates sequential visualization of task timelines for project planning and tracking - **Implementation Details**: - Add a new tool definition in `src/tools/project-tools.ts`: ```typescript export const getProjectTimelineTool: Tool = { name: "asana_get_project_timeline", description: "Get a chronological view of project tasks organized by their dates", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "The ID of the project to get the timeline for" }, start_date: { type: "string", description: "Start date in YYYY-MM-DD format to filter tasks (inclusive)", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, end_date: { type: "string", description: "End date in YYYY-MM-DD format to filter tasks (inclusive)", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, include_without_dates: { type: "boolean", description: "Whether to include tasks without due dates (default: false)", default: false }, group_by: { type: "string", description: "How to group results - 'month', 'week', 'day', 'section', or 'none'", enum: ["month", "week", "day", "section", "none"], default: "month" }, sort_within_group: { type: "string", description: "How to sort tasks within each group - 'due_date', 'start_date', 'created_at', 'name'", enum: ["due_date", "start_date", "created_at", "name"], default: "due_date" }, sort_direction: { type: "string", description: "Sort direction within each group - 'asc' or 'desc'", enum: ["asc", "desc"], default: "asc" }, date_field: { type: "string", description: "Which date field to use for timeline - 'due_date', 'start_date' or 'both'", enum: ["due_date", "start_date", "both"], default: "both" }, include_completed: { type: "boolean", description: "Whether to include completed tasks (default: false)", default: false }, limit: { type: "integer", description: "Maximum number of tasks to return", default: 100 } }, required: ["project_id"] } }; ``` - Add implementation in `src/asana-client-wrapper.ts`: ```typescript async getProjectTimeline(projectId: string, options: any = {}) { console.log(`Getting timeline for project ${projectId} with options:`, JSON.stringify(options)); // Set default options const opts = { include_without_dates: false, group_by: "month", sort_within_group: "due_date", sort_direction: "asc", date_field: "both", include_completed: false, limit: 100, ...options }; const validateDate = (dateStr) => { if (!dateStr) return true; const date = new Date(dateStr); return !isNaN(date.getTime()); }; // Validate date parameters if (opts.start_date && !validateDate(opts.start_date)) { throw new Error(`Invalid start_date format: ${opts.start_date}. Use YYYY-MM-DD format.`); } if (opts.end_date && !validateDate(opts.end_date)) { throw new Error(`Invalid end_date format: ${opts.end_date}. Use YYYY-MM-DD format.`); } // Prepare date range filters for the Asana API query const filters: any = { project: projectId }; // Only include incomplete tasks if include_completed is false if (!opts.include_completed) { filters.completed = false; } // Get project details const project = await this.getProject(projectId); if (!project) { throw new Error(`Project ${projectId} not found or inaccessible`); } // Get tasks with dates and detailed information const tasks = await this.findTasksByProject(projectId, { opt_fields: "name,due_on,due_at,start_on,start_at,created_at,completed,assignee,completed_at,custom_fields,memberships.section.name,tags,notes", limit: opts.limit }); // Filter tasks based on date fields and date range const filteredTasks = tasks.filter(task => { // Skip tasks without dates if we're not including them const hasDueDate = !!task.due_on || !!task.due_at; const hasStartDate = !!task.start_on || !!task.start_at; if (!hasDueDate && !hasStartDate && !opts.include_without_dates) { return false; } // If we're including tasks without dates, keep them if (!hasDueDate && !hasStartDate && opts.include_without_dates) { return true; } // Apply date range filtering based on the selected date field if (opts.start_date || opts.end_date) { const taskDueDate = task.due_on || (task.due_at ? task.due_at.split('T')[0] : null); const taskStartDate = task.start_on || (task.start_at ? task.start_at.split('T')[0] : null); if (opts.date_field === "due_date") { if (!taskDueDate) return opts.include_without_dates; if (opts.start_date && taskDueDate < opts.start_date) return false; if (opts.end_date && taskDueDate > opts.end_date) return false; } else if (opts.date_field === "start_date") { if (!taskStartDate) return opts.include_without_dates; if (opts.start_date && taskStartDate < opts.start_date) return false; if (opts.end_date && taskStartDate > opts.end_date) return false; } else { // "both" // For "both", if either date is in range, include the task if (taskDueDate) { const dueDateInRange = (!opts.start_date || taskDueDate >= opts.start_date) && (!opts.end_date || taskDueDate <= opts.end_date); if (dueDateInRange) return true; } if (taskStartDate) { const startDateInRange = (!opts.start_date || taskStartDate >= opts.start_date) && (!opts.end_date || taskStartDate <= opts.end_date); if (startDateInRange) return true; } // If we get here, neither date is in range or both are missing return opts.include_without_dates && !taskDueDate && !taskStartDate; } } return true; }); // Prepare the result with project info const result = { project: { gid: project.gid, name: project.name }, date_range: { start_date: opts.start_date || null, end_date: opts.end_date || null }, groups: [], total_tasks: filteredTasks.length }; // Helper function to get the group key based on the grouping option const getGroupKey = (task) => { if (opts.group_by === 'none') return 'all_tasks'; if (opts.group_by === 'section') { const section = task.memberships?.[0]?.section; return section ? section.name : 'Uncategorized'; } // For date-based grouping, use the appropriate date // Prioritize due_date when grouping unless the date_field is explicitly set to start_date let dateStr = null; if (opts.date_field === 'start_date') { dateStr = task.start_on || (task.start_at ? task.start_at.split('T')[0] : null); } else { dateStr = task.due_on || (task.due_at ? task.due_at.split('T')[0] : null); // If no due date but we should use both, fall back to start date if (!dateStr && opts.date_field === 'both') { dateStr = task.start_on || (task.start_at ? task.start_at.split('T')[0] : null); } } if (!dateStr) return 'No Date'; const date = new Date(dateStr); if (opts.group_by === 'month') { return date.toLocaleString('default', { year: 'numeric', month: 'long' }); } if (opts.group_by === 'week') { // Get the week number and year const d = new Date(date); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7); const week = Math.floor((d.getTime() - new Date(d.getFullYear(), 0, 4).getTime()) / 86400000 / 7) + 1; return `Week ${week}, ${d.getFullYear()}`; } if (opts.group_by === 'day') { return date.toISOString().split('T')[0]; } return 'Unknown Group'; }; // Group tasks based on the grouping option const taskGroups = {}; for (const task of filteredTasks) { const groupKey = getGroupKey(task); if (!taskGroups[groupKey]) { taskGroups[groupKey] = []; } taskGroups[groupKey].push(task); } // Sort tasks within each group for (const groupKey in taskGroups) { taskGroups[groupKey].sort((a, b) => { // For sorting by relevant field let aValue, bValue; switch (opts.sort_within_group) { case 'due_date': aValue = a.due_on || (a.due_at ? a.due_at.split('T')[0] : '9999-12-31'); bValue = b.due_on || (b.due_at ? b.due_at.split('T')[0] : '9999-12-31'); break; case 'start_date': aValue = a.start_on || (a.start_at ? a.start_at.split('T')[0] : '9999-12-31'); bValue = b.start_on || (b.start_at ? b.start_at.split('T')[0] : '9999-12-31'); break; case 'created_at': aValue = a.created_at || (a.created_at ? a.created_at.split('T')[0] : '9999-12-31'); bValue = b.created_at || (b.created_at ? b.created_at.split('T')[0] : '9999-12-31'); break; case 'name': aValue = a.name || ''; bValue = b.name || ''; break; } if (opts.sort_direction === 'asc') { return aValue.localeCompare(bValue); } else { return bValue.localeCompare(aValue); } }); } // Prepare result structure for (const groupKey in taskGroups) { result.groups.push({ name: groupKey, tasks: taskGroups[groupKey] }); } return result; } ``` - Add examples to the `createTaskTool` and `updateTaskTool` descriptions: ```typescript export const updateTaskTool: Tool = { // ...existing code... description: "Update an existing task's details. For custom fields, use format: {\"custom_field_gid\": value} where value matches the field type (string for text, number for numeric, enum_option.gid for enum).", // ...existing code... }; ``` - Example usage: ```javascript // For enum fields asana_update_task({ task_id: "TASK_ID", custom_fields: {"1201019211530163": "1201019211530164"} }) // For text fields asana_update_task({ task_id: "TASK_ID", custom_fields: {"1201019211530165": "Important note"} }) // For number fields asana_update_task({ task_id: "TASK_ID", custom_fields: {"1201019211530166": 42} }) ``` ### 4.2 Improving `asana_get_project_task_counts` Function - **Priority: High** - **Description**: Adding default values for frequently used fields - **Justification**: Eliminates the need for explicit specification of these fields - **Implementation**: ```javascript // Before asana_get_project_task_counts({ project_id: "PROJECT_ID", opt_fields: "num_tasks,num_completed_tasks,num_incomplete_tasks" }) // After asana_get_project_task_counts({ project_id: "PROJECT_ID" }) // Automatically returns essential fields ``` ### 4.3 Standardizing Arrays in Parameters - **Priority: High** - **Description**: Ensuring that all functions that use lists accept a consistent format (array) - **Justification**: Eliminates confusion and errors in usage - **Implementation**: ```javascript // Standardized format for all functions asana_add_task_dependencies({ task_id: "TASK_ID", dependencies: ["DEP1", "DEP2"] }) asana_add_followers_to_task({ task_id: "TASK_ID", followers: ["USER1", "USER2"] }) ``` - **Note**: The functions `asana_add_members_for_project` and `asana_add_followers_for_project` already use correct array format for parameters and can serve as models for standardization. Important to note that adding a follower to a project automatically adds them as a member. ### 4.4 Improving `asana_search_tasks` Function - **Priority: Medium** - **Description**: Adding parameters for more precise filtering and limiting results - **Justification**: Reduces the number of irrelevant results - **Implementation**: ```javascript asana_search_tasks({ workspace: "WORKSPACE_ID", projects_any: "PROJECT_ID", text: "search term", limit: 10, // New parameter fields: ["name", "due_on"] // New parameter for selecting fields }) ``` ### 4.5 Extending Information from `asana_get_task` - **Priority: Low-Medium** - **Description**: Adding options to include information about subtasks and dependencies - **Justification**: Reduces the need for multiple calls for related information - **Implementation**: ```javascript asana_get_task({ task_id: "TASK_ID", include_subtasks: true, // New parameter include_dependencies: true, // New parameter opt_fields: "name,due_on" }) ``` ### 4.5 ✅ Streamlined Pagination - **Priority: Medium** - **Description**: Standardizing pagination across all functions that return multiple items - **Justification**: Simplifies handling large result sets and improves consistency - **Status**: Implemented in v1.8.1 - Added pagination.ts utility and enhanced methods for standardized pagination - **Implementation Details**: // ... existing code ... ## 5. Implementation Roadmap We recommend the following implementation order: ### Phase 1 (Critical Improvements) 1. Standardizing custom field updates 2. Implementing the `section_id` parameter for task creation ### Phase 2 (High Priority Improvements) 3. Improving `asana_get_project_task_counts` function 4. Standardizing arrays in parameters 5. Adding tags to tasks 6. Extending project hierarchy to include subtasks ### Phase 3 (Medium Priority Improvements) 7. Batch updating of tasks 8. Improving `asana_search_tasks` function 9. Reordering sections 10. Implementing project summary ### Phase 4 (Low Priority Improvements) 11. Extending information from `asana_get_task` 12. Advanced search in project 13. Duplicating tasks 14. Getting project timeline 15. Cloning project structure ## 6. Expected Benefits - **Reduction in the number of steps** for common workflows by 30-50% - Creating tasks directly in sections reduces workflow from 2 steps to 1 - Adding tags in one operation instead of requiring multiple API calls - Better project hierarchy visualization reduces navigation needs - **Improvement in visibility** of project structure and status - Complete project hierarchy with subtasks provides a more comprehensive view - Project summaries offer quick insights into project health - Timeline views enable better sequential planning - **Increase in efficiency** in managing projects directly from Claude - Batch operations reduce the number of user interactions and API calls - Standard array formats improve intuitive usage - Better documentation leads to faster adoption - **Reduction in errors** caused by API inconsistencies - Standardized parameter formats (especially for arrays and custom fields) - Improved error messages with actionable guidance - Consistent behavior across similar functions - **More intuitive experience** for users not familiar with Asana API - Functions that match natural language requests - Default values for commonly used fields - Simplified task creation with appropriate section placement ## 7. Testing Infrastructure To ensure the new features work as expected, we will implement the following testing approach: ### 7.1 Unit Tests - Add unit tests for each new function - Test with various input combinations - Verify error handling for edge cases ### 7.2 Integration Tests - Test workflows that combine multiple functions - Verify that batch operations maintain data integrity - Ensure custom fields are properly updated with different field types ### 7.3 Documentation Tests - Verify all examples in documentation work as described - Include common use cases in documentation - Document error codes and resolution steps ### 7.4 Performance Testing - Benchmark batch operations vs. individual calls - Verify response times remain acceptable with complex operations - Test with larger datasets to ensure scalability ### 7.5 Validation Process Each new feature will go through the following validation process: 1. Developer testing with mock data 2. Integration testing with real Asana accounts 3. Documentation verification 4. User acceptance testing with sample workflows ## 8. Conclusions and Next Steps ### 8.1 Key Recommendations 1. **Focus on Critical Improvements First**: The most significant workflow improvements will come from the critical and high-priority features: - Adding `section_id` parameter to task creation - Standardizing custom field updates - Implementing tag functionality - Enhancing project hierarchy to include subtasks 2. **Maintain Backward Compatibility**: All changes should ensure existing client code continues to work, while new parameters are optional enhancements. 3. **Document as You Build**: Update the README.md and code examples as each feature is implemented to ensure users can immediately benefit from improvements. 4. **Test Thoroughly**: Given the integration nature of this project, extensive testing with real Asana accounts is crucial to validate behavior. ### 8.2 Implementation Strategy 1. **Development Approach** - Create feature branches for each enhancement - Implement changes in small, reviewable increments - Add unit tests for new functionality - Update documentation with each change 2. **First Sprint (Critical Features)** - Implement standardization for custom field updates - Add `section_id` parameter to task creation - Update README to reflect these changes - Create sample code for users 3. **Second Sprint (High Priority Features)** - Add tag functionality - Extend project hierarchy to include subtasks - Standardize array parameters across all functions - Set default fields for task counts 4. **Third Sprint (Medium Priority Features)** - Implement batch operations for tasks - Add section reordering capability - Implement project summary functionality 5. **Final Sprint (Low Priority and Refinement)** - Add remaining features from the roadmap - Perform comprehensive testing - Refine documentation based on user feedback - Release final version with all enhancements ### 8.3 Success Metrics The implementation will be considered successful when: 1. All identified critical and high-priority features are implemented and working correctly 2. Documentation is clear and comprehensive for all new features 3. Users can complete common Asana workflows with 30-50% fewer steps 4. Error handling provides clear guidance for resolving issues 5. All features have unit tests with > 80% coverage ### 3.10 Cloning Project Structure - **Priority: Low-Medium** - **Description**: Allows creating a new project based on the structure of an existing one - **Justification**: Useful for repetitive projects or standard project templates - **Implementation Details**: - Add a new tool definition in `src/tools/project-tools.ts`: ```typescript export const cloneProjectStructureTool: Tool = { name: "asana_clone_project_structure", description: "Create a new project with the same sections and optionally tasks as an existing project", inputSchema: { type: "object", properties: { source_project_id: { type: "string", description: "The ID of the source project to clone" }, name: { type: "string", description: "Name for the new project" }, team_id: { type: "string", description: "Team ID where the new project should be created. If not provided, will use the same team as the source project." }, include_tasks: { type: "boolean", description: "Whether to include tasks in the cloning (default: false)", default: false }, include_task_notes: { type: "boolean", description: "Whether to include task descriptions/notes in the cloning (default: true when include_tasks is true)", default: true }, include_subtasks: { type: "boolean", description: "Whether to include subtasks in the cloning (default: false)", default: false }, include_task_assignees: { type: "boolean", description: "Whether to preserve task assignees (default: false)", default: false }, include_task_dates: { type: "boolean", description: "Whether to include due dates and start dates in the cloning (default: false)", default: false }, due_date_offset: { type: "integer", description: "Number of days to offset due dates in the new project (can be negative or positive)", default: 0 }, include_custom_fields: { type: "boolean", description: "Whether to include custom field values in the cloning (default: false)", default: false }, include_task_dependencies: { type: "boolean", description: "Whether to preserve task dependencies (default: false)", default: false } }, required: ["source_project_id", "name"] } }; ``` - Add implementation in `src/asana-client-wrapper.ts`: ```typescript async cloneProjectStructure(sourceProjectId: string, options: any = {}) { console.log(`Cloning project ${sourceProjectId} with options:`, JSON.stringify(options)); // Set default options const opts = { include_tasks: false, include_task_notes: true, include_subtasks: false, include_task_assignees: false, include_task_dates: false, due_date_offset: 0, include_custom_fields: false, include_task_dependencies: false, ...options }; // Get source project details const sourceProject = await this.getProject(sourceProjectId, { opt_fields: "name,team.name,default_view,color,public,due_date,start_on" }); if (!sourceProject) { throw new Error(`Project ${sourceProjectId} not found or inaccessible`); } // Get sections from source project const sourceSections = await this.getSectionsForProject(sourceProjectId); // Create the new project const newProjectData = { name: opts.name, team: opts.team_id || sourceProject.team?.gid, default_view: sourceProject.default_view, color: sourceProject.color, due_date: null, start_on: null }; // Calculate new dates if needed and original dates exist if (opts.include_task_dates) { if (sourceProject.due_date && opts.due_date_offset !== 0) { const dueDate = new Date(sourceProject.due_date); dueDate.setDate(dueDate.getDate() + opts.due_date_offset); newProjectData.due_date = dueDate.toISOString().split('T')[0]; } else if (sourceProject.due_date) { newProjectData.due_date = sourceProject.due_date; } if (sourceProject.start_on && opts.due_date_offset !== 0) { const startDate = new Date(sourceProject.start_on); startDate.setDate(startDate.getDate() + opts.due_date_offset); newProjectData.start_on = startDate.toISOString().split('T')[0]; } else if (sourceProject.start_on) { newProjectData.start_on = sourceProject.start_on; } } console.log(`Creating new project with name: ${opts.name}`); const newProject = await this.projects.createProject({ data: newProjectData }); if (!newProject || !newProject.data) { throw new Error('Failed to create new project'); } const newProjectId = newProject.data.gid; console.log(`New project created with ID: ${newProjectId}`); // Create sections in the new project const sectionMap = {}; // Maps old section GIDs to new section GIDs for (const section of sourceSections) { console.log(`Creating section "${section.name}" in new project`); const newSection = await this.createSectionInProject(newProjectId, { name: section.name }); sectionMap[section.gid] = newSection.gid; } // Clone tasks if requested const taskMap = {}; // Maps old task GIDs to new task GIDs let totalTasksCreated = 0; if (opts.include_tasks) { console.log('Cloning tasks from source project...'); // Get tasks for each section for (const oldSectionId in sectionMap) { const newSectionId = sectionMap[oldSectionId]; const sectionTasks = await this.getTasksForSection(oldSectionId, { opt_fields: "name,notes,assignee,due_on,due_at,start_on,start_at,custom_fields,dependencies,parent" }); // Skip tasks that are subtasks if we're processing them through their parents later const topLevelTasks = sectionTasks.filter(task => !task.parent || !opts.include_subtasks); // Create tasks in the new section for (const task of topLevelTasks) { const newTaskData = { name: task.name, notes: opts.include_task_notes ? task.notes : "", projects: [newProjectId] }; // Add assignee if requested if (opts.include_task_assignees && task.assignee) { newTaskData.assignee = task.assignee.gid; } // Add dates if requested if (opts.include_task_dates) { if (task.due_on) { if (opts.due_date_offset !== 0) { const dueDate = new Date(task.due_on); dueDate.setDate(dueDate.getDate() + opts.due_date_offset); newTaskData.due_on = dueDate.toISOString().split('T')[0]; } else { newTaskData.due_on = task.due_on; } } if (task.start_on) { if (opts.due_date_offset !== 0) { const startDate = new Date(task.start_on); startDate.setDate(startDate.getDate() + opts.due_date_offset); newTaskData.start_on = startDate.toISOString().split('T')[0]; } else { newTaskData.start_on = task.start_on; } } } // Add custom fields if requested if (opts.include_custom_fields && task.custom_fields) { const customFields = {}; task.custom_fields.forEach(field => { if (field.enabled && field.value !== null) { if (field.resource_subtype === 'enum') { customFields[field.gid] = field.enum_value ? field.enum_value.gid : null; } else { customFields[field.gid] = field.value; } } }); if (Object.keys(customFields).length > 0) { newTaskData.custom_fields = customFields; } } // Create the task try { const newTask = await this.tasks.createTask({ data: newTaskData }); if (newTask && newTask.data) { totalTasksCreated++; taskMap[task.gid] = newTask.data.gid; // Add task to the section await this.sections.addTaskForSection(newSectionId, { data: { task: newTask.data.gid } }); // Create subtasks if requested if (opts.include_subtasks) { const subtasks = await this.getSubtasksForTask(task.gid, { opt_fields: "name,notes,assignee,due_on,due_at,start_on,start_at,custom_fields" }); for (const subtask of subtasks) { const newSubtaskData = { name: subtask.name, notes: opts.include_task_notes ? subtask.notes : "", parent: newTask.data.gid }; // Add subtask assignee if requested if (opts.include_task_assignees && subtask.assignee) { newSubtaskData.assignee = subtask.assignee.gid; } // Add subtask dates if requested if (opts.include_task_dates) { if (subtask.due_on) { if (opts.due_date_offset !== 0) { const dueDate = new Date(subtask.due_on); dueDate.setDate(dueDate.getDate() + opts.due_date_offset); newSubtaskData.due_on = dueDate.toISOString().split('T')[0]; } else { newSubtaskData.due_on = subtask.due_on; } } if (subtask.start_on) { if (opts.due_date_offset !== 0) { const startDate = new Date(subtask.start_on); startDate.setDate(startDate.getDate() + opts.due_date_offset); newSubtaskData.start_on = startDate.toISOString().split('T')[0]; } else { newSubtaskData.start_on = subtask.start_on; } } } // Add custom fields if requested if (opts.include_custom_fields && subtask.custom_fields) { const customFields = {}; subtask.custom_fields.forEach(field => { if (field.enabled && field.value !== null) { if (field.resource_subtype === 'enum') { customFields[field.gid] = field.enum_value ? field.enum_value.gid : null; } else { customFields[field.gid] = field.value; } } }); if (Object.keys(customFields).length > 0) { newSubtaskData.custom_fields = customFields; } } // Create the subtask const newSubtask = await this.tasks.createTask({ data: newSubtaskData }); if (newSubtask && newSubtask.data) { totalTasksCreated++; taskMap[subtask.gid] = newSubtask.data.gid; } } } } } catch (error) { console.error(`Failed to create task "${task.name}":`, error.message); } } } // Add dependencies if requested and after all tasks are created if (opts.include_task_dependencies && Object.keys(taskMap).length > 0) { console.log('Setting up task dependencies...'); for (const oldTaskId in taskMap) { const newTaskId = taskMap[oldTaskId]; const taskDependencies = await this.getTaskDependencies(oldTaskId); for (const dependency of taskDependencies) { const newDependencyId = taskMap[dependency.gid]; if (newDependencyId) { try { await this.addTaskDependency(newTaskId, newDependencyId); } catch (error) { console.error(`Failed to set up dependency between tasks:`, error.message); } } } } } } return { source_project: { gid: sourceProject.gid, name: sourceProject.name }, new_project: { gid: newProjectId, name: opts.name, url: `https://app.asana.com/0/${newProjectId}/list` }, sections: { count: Object.keys(sectionMap).length, section_mapping: sectionMap }, tasks: { count: totalTasksCreated, task_mapping: opts.include_tasks ? taskMap : null } }; } // Helper method to get task dependencies async getTaskDependencies(taskId: string) { const response = await this.tasks.getDependenciesForTask(taskId); return response.data || []; } // Helper method to add a task dependency async addTaskDependency(taskId: string, dependencyId: string) { await this.tasks.addDependenciesForTask(taskId, { data: { dependency: dependencyId } }); } ``` - Update `src/tool-handler.ts` to register and handle the new tool: ```typescript // Add to imports import { // existing imports... cloneProjectStructureTool } from './tools/project-tools.js'; // Add to tools array export const tools: Tool[] = [ // existing tools... cloneProjectStructureTool, // other tools... ]; // Add case in tool handler switch statement case "asana_clone_project_structure": return { result: await asanaClient.cloneProjectStructure(args.source_project_id, { name: args.name, team_id: args.team_id, include_tasks: !!args.include_tasks, include_task_notes: args.include_task_notes !== false, include_subtasks: !!args.include_subtasks, include_task_assignees: !!args.include_task_assignees, include_task_dates: !!args.include_task_dates, due_date_offset: args.due_date_offset || 0, include_custom_fields: !!args.include_custom_fields, include_task_dependencies: !!args.include_task_dependencies }) }; ``` - Add documentation in README: ```markdown #### Cloning Project Structure Create a new project based on the structure of an existing one: ```javascript asana_clone_project_structure({ source_project_id: "SOURCE_PROJECT_ID", name: "New Project Name", team_id: "TEAM_ID", // Optional - defaults to source project's team include_tasks: true, // Whether to include tasks (default: false) include_task_notes: true, // Whether to include task descriptions (default: true) include_subtasks: true, // Whether to include subtasks (default: false) include_task_assignees: false, // Whether to preserve assignees (default: false) include_task_dates: true, // Whether to include dates (default: false) due_date_offset: 14, // Shift all dates by 14 days (default: 0) include_custom_fields: true, // Whether to include custom fields (default: false) include_task_dependencies: true // Whether to preserve dependencies (default: false) }) ``` This is useful for: - Creating template projects that can be reused for recurring workflows - Starting new projects with a proven structure - Creating variations of existing projects with different timelines ``` - Example usage: ```javascript // Basic structure cloning (sections only) asana_clone_project_structure({ source_project_id: "SOURCE_PROJECT_ID", name: "Q3 Marketing Campaign" }) // Full project cloning with tasks and shifted dates asana_clone_project_structure({ source_project_id: "SOURCE_PROJECT_ID", name: "Q3 Marketing Campaign", include_tasks: true, include_subtasks: true, include_task_dates: true, due_date_offset: 90 // Shift all dates by 90 days (one quarter) }) // Copy project to a different team asana_clone_project_structure({ source_project_id: "SOURCE_PROJECT_ID", name: "Website Redesign Template", team_id: "NEW_TEAM_ID", include_tasks: true, include_task_assignees: false // Don't copy assignees as they may be different in the new team }) ``` - Expected response: ```json { "source_project": { "gid": "1234567890", "name": "Q2 Marketing Campaign" }, "new_project": { "gid": "9876543210", "name": "Q3 Marketing Campaign", "url": "https://app.asana.com/0/9876543210/list" }, "sections": { "count": 4, "section_mapping": { "11111111": "55555555", "22222222": "66666666", "33333333": "77777777", "44444444": "88888888" } }, "tasks": { "count": 15, "task_mapping": { "111222333": "555666777", "444555666": "888999000", "777888999": "123123123" } } } ``` - **AI usage guidance to include:** "To create a new project based on an existing one, use `asana_clone_project_structure`. Specify whether to include tasks, subtasks, and other elements through parameters. For recurring projects, use due_date_offset to shift all dates appropriately." ## 4. Modifications to Existing Features (5) ### 4.1 ✅ Standardizing Custom Field Updates - **Priority: Critical** - **Description**: Clear documentation and standardization of format for different types of fields - **Justification**: Eliminates confusion and errors in usage - **Status**: Implemented in v1.8.1 - Added field-utils.ts with validation and parsing for custom fields - **Implementation Details**: - Update the documentation in `README.md` to clearly explain custom field usage: ```markdown #### Custom Fields When updating tasks with custom fields, use the following format: ```javascript asana_update_task({ task_id: "TASK_ID", custom_fields: { "custom_field_gid": value // The value format depends on the field type } }) ``` The value format varies by field type: - **Enum fields**: Use the `enum_option.gid` of the option - **Text fields**: Use a string - **Number fields**: Use a number - **Date fields**: Use a string in YYYY-MM-DD format ``` - Enhance the `updateTask` method in `src/asana-client-wrapper.ts` to better handle custom fields: ```typescript async updateTask(taskId: string, data: any) { // Create a deep clone of the data to avoid modifying the original const taskData = JSON.parse(JSON.stringify(data)); // Handle custom fields properly if provided if (taskData.custom_fields) { // If custom_fields is a string, try to parse it as JSON if (typeof taskData.custom_fields === 'string') { try { taskData.custom_fields = JSON.parse(taskData.custom_fields); } catch (err) { throw new Error(`Invalid custom_fields format: ${err.message}`); } } // The API expects custom_fields as an object with key-value pairs // No additional transformation needed as the API accepts the format: // { "custom_field_gid": value } } try { const response = await this.tasks.updateTask(taskId, { data: taskData }); return response.data; } catch (error) { // Provide better error messages for custom field errors if (error.message.includes('custom_field')) { throw new Error(`Error updating custom fields: ${error.message}. Make sure you're using the correct format for each field type.`); } throw error; } } ``` - Add more detailed validation and helper functions for custom fields in `src/utils/field-utils.ts`: ```typescript /** * Utilities for handling Asana custom fields */ /** * Validates custom field values based on their type * @param fieldType The type of the custom field * @param value The value to validate * @returns {boolean} Whether the value is valid for the field type */ export function validateCustomFieldValue(fieldType: string, value: any): boolean { switch (fieldType) { case 'text': return typeof value === 'string'; case 'number': return typeof value === 'number' && !isNaN(value); case 'enum': return typeof value === 'string' && value.length > 0; case 'date': // Check if it's a valid date string in YYYY-MM-DD format if (typeof value !== 'string') return false; const dateRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dateRegex.test(value)) return false; // Check if it's a valid date const date = new Date(value); return !isNaN(date.getTime()); case 'boolean': return typeof value === 'boolean'; case 'multi_enum': return Array.isArray(value) && value.every(item => typeof item === 'string' && item.length > 0); default: return false; } } /** * Formats custom field values for Asana API * @param customFields Object mapping field GIDs to their values * @param fieldMetadata Array of custom field metadata objects from Asana * @returns Formatted custom fields object */ export function formatCustomFieldsForUpdate(customFields: Record<string, any>, fieldMetadata: any[]): Record<string, any> { const formattedFields: Record<string, any> = {}; const metadataMap: Record<string, any> = {}; // Create a map of field GIDs to their metadata fieldMetadata.forEach(field => { metadataMap[field.gid] = field; }); // Process each custom field for (const [fieldGid, value] of Object.entries(customFields)) { const fieldMeta = metadataMap[fieldGid]; // Skip if we don't have metadata for this field if (!fieldMeta) { console.warn(`No metadata found for custom field ${fieldGid}, skipping validation`); formattedFields[fieldGid] = value; continue; } // Validate the value based on the field type const fieldType = fieldMeta.resource_subtype; if (!validateCustomFieldValue(fieldType, value)) { throw new Error( `Invalid value for custom field "${fieldMeta.name}" (${fieldGid}). ` + `Expected type: ${fieldType}, received: ${typeof value}` ); } // Format the value according to the field type formattedFields[fieldGid] = value; } return formattedFields; } /** * Gets custom field metadata for a task * @param task Task object from Asana API * @returns Array of custom field metadata objects */ export function extractCustomFieldMetadata(task: any): any[] { if (!task.custom_fields || !Array.isArray(task.custom_fields)) { return []; } return task.custom_fields.map(field => ({ gid: field.gid, name: field.name, resource_subtype: field.resource_subtype, type: field.type, enum_options: field.resource_subtype === 'enum' ? field.enum_options : undefined })); } ``` - Update `src/tool-handler.ts` to use the new utils for custom field handling: ```typescript // Add import import { formatCustomFieldsForUpdate } from './utils/field-utils.js'; // In the asana_update_task case case "asana_update_task": // If custom fields are provided and the task exists, get the task first to validate if (args.custom_fields) { const task = await asanaClient.getTask(args.task_id, { opt_fields: "custom_fields" }); if (task && task.custom_fields) { try { // Format and validate custom fields args.custom_fields = formatCustomFieldsForUpdate( args.custom_fields, task.custom_fields ); } catch (error) { return { error: error.message }; } } } return { result: await asanaClient.updateTask(args.task_id, args) }; ``` - Add example usage in documentation: ```markdown #### Examples of correct custom field updates For an enum field (e.g., Status): ```javascript // First, get the task to see available enum options const task = await asana_get_task({ task_id: "TASK_ID", opt_fields: "custom_fields" }); // Find the enum options const statusField = task.custom_fields.find(f => f.name === "Status"); const inProgressOption = statusField.enum_options.find(o => o.name === "In Progress"); // Update the task with the correct enum option GID await asana_update_task({ task_id: "TASK_ID", custom_fields: { [statusField.gid]: inProgressOption.gid } }); ``` For a number field: ```javascript await asana_update_task({ task_id: "TASK_ID", custom_fields: { "12345": 42 // Where "12345" is the custom field GID } }); ``` For a date field: ```javascript await asana_update_task({ task_id: "TASK_ID", custom_fields: { "67890": "2025-12-31" // YYYY-MM-DD format } }); ``` ``` - **AI usage guidance to include:** "When updating custom fields, first use `asana_get_task` with `opt_fields: 'custom_fields'` to see the available fields and their types. For enum fields, you need the enum_option.gid, not just the name. Always use the correct data type for each field." ### 4.2 ✅ Consistent Array Parameter Handling - **Priority: High** - **Description**: Standardizing the format of array parameters across all functions - **Justification**: Prevents confusion and errors when passing arrays to different functions - **Implementation Details**: - Update the `FunctionHandler` class in `src/tool-handler.ts` to normalize array parameters: ```typescript /** * Normalizes array parameters to ensure consistent handling * @param args Tool arguments to normalize * @returns Normalized arguments object */ private normalizeArrayParameters(args: any): any { const result = { ...args }; // Known parameters that should be arrays const arrayParams = [ 'dependencies', 'dependents', 'followers', 'projects', 'tags', 'team_ids', 'sections', 'tasks', 'task_ids' ]; for (const param of arrayParams) { if (param in result) { // If it's a string, try to convert comma-separated to array if (typeof result[param] === 'string') { // Check if it looks like a JSON array string if (result[param].trim().startsWith('[') && result[param].trim().endsWith(']')) { try { result[param] = JSON.parse(result[param]); continue; } catch (e) { // If parsing fails, fall back to comma-splitting console.warn(`Failed to parse JSON array for ${param}, falling back to comma-splitting`); } } // Split by commas, handle empty strings result[param] = result[param].split(',') .map((item: string) => item.trim()) .filter((item: string) => item.length > 0); } else if (!Array.isArray(result[param])) { // If it's not a string or array already, make it a single-item array result[param] = [result[param]]; } } } return result; } // Update the main handle method to use this normalization async handle(toolName: string, args: any): Promise<any> { const normalizedArgs = this.normalizeArrayParameters(args); console.log(`Handling tool ${toolName} with normalized args:`, JSON.stringify(normalizedArgs)); // Rest of the handler method // ... } ``` - Update key functions in `src/asana-client-wrapper.ts` to handle arrays consistently: ```typescript /** * Ensures the input is an array * @param input The input value (could be array, string, or other) * @returns An array */ private ensureArray(input: any): any[] { if (Array.isArray(input)) { return input; } if (typeof input === 'string') { // Check if it's a comma-separated string if (input.includes(',')) { return input.split(',').map(item => item.trim()).filter(item => item.length > 0); } // Single value string return [input]; } if (input === null || input === undefined) { return []; } // Other values (numbers, booleans, objects) return [input]; } // Example implementation for addTaskDependencies async addTaskDependencies(taskId: string, dependencies: any): Promise<any> { const dependencyArray = this.ensureArray(dependencies); console.log(`Adding ${dependencyArray.length} dependencies to task ${taskId}`); const results = []; for (const dependencyId of dependencyArray) { try { const response = await this.tasks.addDependencyForTask(taskId, { data: { dependency: dependencyId } }); results.push({ dependency: dependencyId, status: 'success' }); } catch (error) { results.push({ dependency: dependencyId, status: 'error', error: error.message }); } } return { task_id: taskId, added_dependencies: results }; } // Example implementation for adding followers async addFollowersToTask(taskId: string, followers: any): Promise<any> { const followerArray = this.ensureArray(followers); console.log(`Adding ${followerArray.length} followers to task ${taskId}`); const results = []; for (const followerId of followerArray) { try { await this.tasks.addFollowersForTask(taskId, { data: { followers: [followerId] } }); results.push({ follower: followerId, status: 'success' }); } catch (error) { results.push({ follower: followerId, status: 'error', error: error.message }); } } // Re-fetch the task to get the updated followers list const updatedTask = await this.getTask(taskId, { opt_fields: "followers" }); return { task_id: taskId, followers: updatedTask.followers, results: results }; } ``` - Update the documentation in `README.md` to explain array parameter formats: ```markdown #### Working with Arrays Many functions accept arrays as parameters. You can provide arrays in several formats: 1. **JSON array**: ```javascript asana_add_task_dependencies({ task_id: "TASK_ID", dependencies: ["DEP_ID_1", "DEP_ID_2", "DEP_ID_3"] }) ``` 2. **Comma-separated string**: ```javascript asana_add_task_dependencies({ task_id: "TASK_ID", dependencies: "DEP_ID_1,DEP_ID_2,DEP_ID_3" }) ``` 3. **Single value** (automatically converted to an array): ```javascript asana_add_task_dependencies({ task_id: "TASK_ID", dependencies: "DEP_ID_1" }) ``` These formats work for all parameters that expect arrays, including: - `dependencies` and `dependents` for task dependencies - `followers` for adding task followers - `projects` when creating or updating tasks - `tags` when working with task tags - `task_ids` when working with multiple tasks ``` - **AI usage guidance to include:** "When providing multiple values to functions like dependencies, followers, or projects, you can use either a JSON array or a comma-separated string. Both formats will work consistently across all functions." ### 4.3 ✅ Enhanced Error Messages - **Priority: Medium** - **Description**: Improving error messages with detailed explanations and recovery suggestions - **Justification**: Reduces confusion and helps users recover from errors quickly - **Status**: Implemented in v1.8.1 - Created comprehensive error handling with friendly messages, recovery steps, and error codes - **Implementation Details**: // ... existing code ... ### 4.4 ✅ Improved Parameter Validation - **Priority: Medium** - **Description**: Adding robust parameter validation to all functions - **Justification**: Prevents API errors by catching invalid parameters early - **Implementation Details**: - Create a validation utility in `src/utils/validation.ts`: ```typescript /** * Utilities for validating parameter values before making API calls */ export interface ValidationError { field: string; message: string; } export interface ValidationResult { valid: boolean; errors: ValidationError[]; } /** * Pattern for validating Asana GID (ID) strings * Asana GIDs are numeric strings */ const GID_PATTERN = /^\d+$/; /** * Pattern for validating date strings in YYYY-MM-DD format */ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; /** * Validates a GID (Asana ID) string * @param gid The GID to validate * @param fieldName The name of the field for error reporting * @returns Validation error object or null if valid */ export function validateGid(gid: any, fieldName: string): ValidationError | null { if (gid === undefined || gid === null) { return null; // Allow undefined/null for optional fields } if (typeof gid !== 'string') { return { field: fieldName, message: `${fieldName} must be a string` }; } if (!GID_PATTERN.test(gid)) { return { field: fieldName, message: `${fieldName} must be a numeric string (Asana GID)` }; } return null; } /** * Validates a date string in YYYY-MM-DD format * @param date The date string to validate * @param fieldName The name of the field for error reporting * @returns Validation error object or null if valid */ export function validateDate(date: any, fieldName: string): ValidationError | null { if (date === undefined || date === null) { return null; // Allow undefined/null for optional fields } if (typeof date !== 'string') { return { field: fieldName, message: `${fieldName} must be a string in YYYY-MM-DD format` }; } if (!DATE_PATTERN.test(date)) { return { field: fieldName, message: `${fieldName} must be in YYYY-MM-DD format` }; } // Validate it's an actual valid date const dateObj = new Date(date); if (isNaN(dateObj.getTime())) { return { field: fieldName, message: `${fieldName} is not a valid date` }; } return null; } /** * Validates common parameters for task-related operations * @param params Parameters to validate * @returns Validation result with errors array */ export function validateTaskParams(params: any): ValidationResult { const errors: ValidationError[] = []; // Task ID validation const taskIdError = validateGid(params.task_id, 'task_id'); if (taskIdError) errors.push(taskIdError); // Due date validation const dueDateError = validateDate(params.due_on, 'due_on'); if (dueDateError) errors.push(dueDateError); // Start date validation const startDateError = validateDate(params.start_on, 'start_on'); if (startDateError) errors.push(startDateError); // Name validation if (params.name !== undefined && params.name !== null) { if (typeof params.name !== 'string') { errors.push({ field: 'name', message: 'name must be a string' }); } } // Assignee validation (can be a GID or 'me') if (params.assignee !== undefined && params.assignee !== null && params.assignee !== 'me') { const assigneeError = validateGid(params.assignee, 'assignee'); if (assigneeError) errors.push(assigneeError); } return { valid: errors.length === 0, errors }; } /** * Validates common parameters for project-related operations * @param params Parameters to validate * @returns Validation result with errors array */ export function validateProjectParams(params: any): ValidationResult { const errors: ValidationError[] = []; // Project ID validation const projectIdError = validateGid(params.project_id, 'project_id'); if (projectIdError) errors.push(projectIdError); // Due date validation const dueDateError = validateDate(params.due_on, 'due_on'); if (dueDateError) errors.push(dueDateError); // Start date validation const startDateError = validateDate(params.start_on, 'start_on'); if (startDateError) errors.push(startDateError); return { valid: errors.length === 0, errors }; } ``` - Implement validation in the tool handler: ```typescript // Add import import { validateTaskParams, validateProjectParams, ValidationResult } from './utils/validation.js'; // Helper method for the handler class private validateParameters(toolName: string, args: any): ValidationResult { // Select the appropriate validation method based on the tool if (toolName.includes('task')) { return validateTaskParams(args); } else if (toolName.includes('project')) { return validateProjectParams(args); } // Default to valid for other tools return { valid: true, errors: [] }; } // In the handle method, add validation before processing async handle(toolName: string, args: any): Promise<any> { // Normalize array parameters const normalizedArgs = this.normalizeArrayParameters(args); // Validate parameters const validation = this.validateParameters(toolName, normalizedArgs); if (!validation.valid) { return { error: "Invalid parameters", validation_errors: validation.errors, recovery_steps: [ "Check the parameter values and correct any formatting issues", "Refer to the documentation for the expected parameter formats" ] }; } // Continue with handling the tool call // ... } ``` - Update the documentation in README to explain validation: ```markdown #### Parameter Validation All functions include validation of parameters before making API calls. If validation fails, you'll receive a clear error with details: ```json { "error": "Invalid parameters", "validation_errors": [ { "field": "due_on", "message": "due_on must be in YYYY-MM-DD format" }, { "field": "task_id", "message": "task_id must be a numeric string (Asana GID)" } ], "recovery_steps": [ "Check the parameter values and correct any formatting issues", "Refer to the documentation for the expected parameter formats" ] } ``` Common validation rules: - **IDs (GIDs)**: Must be numeric strings - **Dates**: Must be in YYYY-MM-DD format - **Enum values**: Must be one of the allowed values - **Arrays**: Can be provided as JSON arrays or comma-separated strings ``` - **AI usage guidance to include:** "If you receive validation errors, check the validation_errors array for specific fields that need correction. For dates, always use YYYY-MM-DD format. For IDs, make sure they are valid Asana GIDs (numeric strings)." ### 4.5 Streamlined Pagination - **Priority: Medium** - **Description**: Standardizing pagination across all functions that return multiple items - **Justification**: Simplifies handling large result sets and improves consistency - **Implementation Details**: - Create a pagination utility in `src/utils/pagination.ts`: ```typescript /** * Utilities for handling pagination of Asana API results */ export interface PaginationParams { limit?: number; offset?: string; } export interface PaginatedResult<T> { data: T[]; pagination: { limit: number; offset?: string; has_more: boolean; next_page?: string; }; } /** * Validates and normalizes pagination parameters * @param params The pagination parameters to normalize * @returns Normalized pagination parameters */ export function normalizePaginationParams(params: any): PaginationParams { const result: PaginationParams = {}; // Handle limit parameter if (params.limit !== undefined) { // Convert string to number if needed const limit = typeof params.limit === 'string' ? parseInt(params.limit, 10) : params.limit; // Validate limit is a number and in range if (!isNaN(limit) && limit > 0) { // Cap limit to 100 (Asana API maximum) result.limit = Math.min(limit, 100); } } // Handle offset parameter if (params.offset && typeof params.offset === 'string') { result.offset = params.offset; } return result; } /** * Formats an Asana API response into a standardized paginated result * @param response The raw API response * @param defaultLimit The default limit to use if not specified * @returns Standardized paginated result */ export function formatPaginatedResponse<T>(response: any, defaultLimit = 50): PaginatedResult<T> { const data = response.data || []; let pagination = { limit: defaultLimit, has_more: false }; // Extract pagination info if it exists if (response.next_page) { pagination.has_more = true; pagination.next_page = response.next_page.offset; pagination.offset = response.next_page.offset; } return { data, pagination }; } /** * Helper for handling auto-pagination when fetching all results * @param fetchFunction The function to call for each page * @param limit Limit per page * @param maxPages Maximum number of pages to fetch (safety limit) * @returns Combined results from all pages */ export async function fetchAllPages<T>( fetchFunction: (params: PaginationParams) => Promise<PaginatedResult<T>>, limit = 100, maxPages = 10 ): Promise<T[]> { let allResults: T[] = []; let offset: string | undefined = undefined; let pageCount = 0; do { // Fetch a page of results const result = await fetchFunction({ limit, offset }); // Add results to our collection allResults = [...allResults, ...result.data]; // Update offset for next page offset = result.pagination.offset; // Increment page counter and check if we've hit the max pageCount++; if (pageCount >= maxPages) { console.warn(`Reached maximum page count (${maxPages}), stopping pagination`); break; } // Continue until there are no more pages } while (offset && allResults.length < 1000); // Hard cap at 1000 items for safety return allResults; } ``` - Update functions in `src/asana-client-wrapper.ts` to use standardized pagination: ```typescript // Add import import { normalizePaginationParams, formatPaginatedResponse, fetchAllPages, PaginatedResult } from './utils/pagination.js'; // Example implementation for getTasksForProject with standardized pagination async getTasksForProject(projectId: string, options: any = {}): Promise<PaginatedResult<any>> { // Normalize pagination parameters const paginationParams = normalizePaginationParams(options); // Set default options const opts = { opt_fields: options.opt_fields || "name,assignee,completed,due_on", ...paginationParams }; // Make the API call const response = await this.tasks.getTasksForProject(projectId, opts); // Format the response return formatPaginatedResponse(response, opts.limit); } // Add a method for fetching all tasks for a project (with auto-pagination) async getAllTasksForProject(projectId: string, options: any = {}): Promise<any[]> { const fetchPage = async (pageParams: any) => { return await this.getTasksForProject(projectId, { ...options, ...pageParams }); }; return await fetchAllPages(fetchPage, options.limit, options.max_pages); } ``` - Update the tool handler to support both paginated and all-results modes: ```typescript // In tool-handler.ts for functions that support pagination case "asana_get_tasks_for_project": // Check if fetch_all flag is set if (args.fetch_all) { return { result: await asanaClient.getAllTasksForProject(args.project_id, args) }; } else { const paginatedResult = await asanaClient.getTasksForProject(args.project_id, args); return { result: paginatedResult.data, pagination: paginatedResult.pagination }; } ``` - Update the documentation in README to explain pagination: ```markdown #### Pagination Functions that return multiple items support standardized pagination: ```javascript // Get first page of tasks const result = await asana_get_tasks_for_project({ project_id: "PROJECT_ID", limit: 25 // Items per page (default: 50, max: 100) }); // Response includes pagination info console.log(result.pagination); // { limit: 25, has_more: true, offset: "eyJ0eXBlIjoidGFzayIsImlkIjoxNjQ5NDA1NTk5MDkzfQ", next_page: "eyJ0eXBlI..." } // Get the next page using the offset const nextPage = await asana_get_tasks_for_project({ project_id: "PROJECT_ID", limit: 25, offset: result.pagination.offset }); ``` For convenience, you can also fetch all items at once with auto-pagination: ```javascript // Get all tasks (use with caution for large projects) const allTasks = await asana_get_tasks_for_project({ project_id: "PROJECT_ID", fetch_all: true, max_pages: 5 // Safety limit on number of pages to fetch }); ``` > **Note**: Using `fetch_all` can be slow for large projects. For better performance, use pagination and process results in smaller batches. ``` - **AI usage guidance to include:** "Use pagination with limit and offset parameters for large result sets. If you need all items, use fetch_all=true but be aware this could be slow for large datasets. Always check pagination.has_more to see if there are more results available." ## 5. Expected Results (5)