From a439865ef4f1c13b9330f6bad6f1e97869a6bfba Mon Sep 17 00:00:00 2001 From: George Antonopoulos Date: Sun, 29 Jun 2025 08:22:09 +0100 Subject: [PATCH] Refactor card management in Basecamp client and MCP server CLI - Rename and enhance methods for retrieving card tables and managing cards. - Introduce new methods for completing and uncompleting cards and card steps. - Update MCP server CLI to include new card and card step functionalities. - Revise README to reflect changes in card management tools and usage examples. --- README.md | 15 ++- basecamp_client.py | 142 +++++++++++++++++---- mcp_server_cli.py | 298 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 405 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 98b01b3..3db0f4c 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,16 @@ Once configured, you can use these tools in Cursor: - `get_card` - Get details for a specific card - `create_card` - Create a new card in a column - `update_card` - Update a card -- `move_card` - Move a card to a new column and/or position +- `move_card` - Move a card to a new column +- `complete_card` - Mark a card as complete +- `uncomplete_card` - Mark a card as incomplete +- `get_card_steps` - Get all steps (sub-tasks) for a card +- `create_card_step` - Create a new step (sub-task) for a card +- `get_card_step` - Get details for a specific card step +- `update_card_step` - Update a card step +- `delete_card_step` - Delete a card step +- `complete_card_step` - Mark a card step as complete +- `uncomplete_card_step` - Mark a card step as incomplete ### Example Cursor Usage @@ -111,6 +120,10 @@ Ask Cursor things like: - "Create a new card in the 'In Progress' column" - "Move this card to the 'Done' column" - "Update the color of the 'Urgent' column to red" +- "Mark card as complete" +- "Show me all steps for this card" +- "Create a sub-task for this card" +- "Mark this card step as complete" ## Architecture diff --git a/basecamp_client.py b/basecamp_client.py index 61aa6e3..4bfeb5b 100644 --- a/basecamp_client.py +++ b/basecamp_client.py @@ -351,30 +351,38 @@ class BasecampClient: return response.json() # Card Table methods - def get_card_table(self, project_id): - """Get the card table for a project (Basecamp 3 has one card table per project).""" + def get_card_tables(self, project_id): + """Get all card tables for a project.""" project = self.get_project(project_id) try: - return next(_ for _ in project["dock"] if _["name"] == "card_table") - except (IndexError, TypeError, StopIteration): - raise Exception(f"Failed to get card table for project: {project_id}. Project response: {project}") + return [_ for _ in project["dock"] if _["name"] == "kanban_board"] + except (IndexError, TypeError): + return [] + + def get_card_table(self, project_id): + """Get the first card table for a project (Basecamp 3 can have multiple card tables per project).""" + card_tables = self.get_card_tables(project_id) + if not card_tables: + raise Exception(f"No card tables found for project: {project_id}") + return card_tables[0] # Return the first card table def get_card_table_details(self, project_id, card_table_id): """Get details for a specific card table.""" response = self.get(f'buckets/{project_id}/card_tables/{card_table_id}.json') if response.status_code == 200: return response.json() + elif response.status_code == 204: + # 204 means "No Content" - return an empty structure + return {"lists": [], "id": card_table_id, "status": "empty"} else: raise Exception(f"Failed to get card table: {response.status_code} - {response.text}") # Card Table Column methods def get_columns(self, project_id, card_table_id): """Get all columns in a card table.""" - response = self.get(f'buckets/{project_id}/card_tables/{card_table_id}/columns.json') - if response.status_code == 200: - return response.json() - else: - raise Exception(f"Failed to get columns: {response.status_code} - {response.text}") + # Get the card table details which includes the lists (columns) + card_table_details = self.get_card_table_details(project_id, card_table_id) + return card_table_details.get('lists', []) def get_column(self, project_id, column_id): """Get a specific column.""" @@ -402,10 +410,14 @@ class BasecampClient: else: raise Exception(f"Failed to update column: {response.status_code} - {response.text}") - def move_column(self, project_id, column_id, position): + def move_column(self, project_id, column_id, position, card_table_id): """Move a column to a new position.""" - data = {"position": position} - response = self.post(f'buckets/{project_id}/card_tables/columns/{column_id}/moves.json', data) + data = { + "source_id": column_id, + "target_id": card_table_id, + "position": position + } + response = self.post(f'buckets/{project_id}/card_tables/{card_table_id}/moves.json', data) if response.status_code == 204: return True else: @@ -469,39 +481,125 @@ class BasecampClient: else: raise Exception(f"Failed to get card: {response.status_code} - {response.text}") - def create_card(self, project_id, column_id, title, content=None): + def create_card(self, project_id, column_id, title, content=None, due_on=None, notify=False): """Create a new card in a column.""" data = {"title": title} if content: data["content"] = content + if due_on: + data["due_on"] = due_on + if notify: + data["notify"] = notify response = self.post(f'buckets/{project_id}/card_tables/lists/{column_id}/cards.json', data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create card: {response.status_code} - {response.text}") - def update_card(self, project_id, card_id, title=None, content=None): + def update_card(self, project_id, card_id, title=None, content=None, due_on=None, assignee_ids=None): """Update a card.""" data = {} if title: data["title"] = title if content: data["content"] = content + if due_on: + data["due_on"] = due_on + if assignee_ids: + data["assignee_ids"] = assignee_ids response = self.put(f'buckets/{project_id}/card_tables/cards/{card_id}.json', data) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to update card: {response.status_code} - {response.text}") - def move_card(self, project_id, card_id, column_id=None, position=None): - """Move a card to a new column and/or position.""" - data = {} - if column_id: - data["column_id"] = column_id - if position: - data["position"] = position + def move_card(self, project_id, card_id, column_id): + """Move a card to a new column.""" + data = {"column_id": column_id} response = self.post(f'buckets/{project_id}/card_tables/cards/{card_id}/moves.json', data) if response.status_code == 204: return True else: raise Exception(f"Failed to move card: {response.status_code} - {response.text}") + + def complete_card(self, project_id, card_id): + """Mark a card as complete.""" + response = self.post(f'buckets/{project_id}/todos/{card_id}/completion.json') + if response.status_code == 201: + return response.json() + else: + raise Exception(f"Failed to complete card: {response.status_code} - {response.text}") + + def uncomplete_card(self, project_id, card_id): + """Mark a card as incomplete.""" + response = self.delete(f'buckets/{project_id}/todos/{card_id}/completion.json') + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to uncomplete card: {response.status_code} - {response.text}") + + # Card Steps methods + def get_card_steps(self, project_id, card_id): + """Get all steps (sub-tasks) for a card.""" + card = self.get_card(project_id, card_id) + return card.get('steps', []) + + def create_card_step(self, project_id, card_id, title, due_on=None, assignee_ids=None): + """Create a new step (sub-task) for a card.""" + data = {"title": title} + if due_on: + data["due_on"] = due_on + if assignee_ids: + data["assignee_ids"] = assignee_ids + response = self.post(f'buckets/{project_id}/card_tables/cards/{card_id}/steps.json', data) + if response.status_code == 201: + return response.json() + else: + raise Exception(f"Failed to create card step: {response.status_code} - {response.text}") + + def get_card_step(self, project_id, step_id): + """Get a specific card step.""" + response = self.get(f'buckets/{project_id}/card_tables/steps/{step_id}.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get card step: {response.status_code} - {response.text}") + + def update_card_step(self, project_id, step_id, title=None, due_on=None, assignee_ids=None): + """Update a card step.""" + data = {} + if title: + data["title"] = title + if due_on: + data["due_on"] = due_on + if assignee_ids: + data["assignee_ids"] = assignee_ids + response = self.put(f'buckets/{project_id}/card_tables/steps/{step_id}.json', data) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to update card step: {response.status_code} - {response.text}") + + def delete_card_step(self, project_id, step_id): + """Delete a card step.""" + response = self.delete(f'buckets/{project_id}/card_tables/steps/{step_id}.json') + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to delete card step: {response.status_code} - {response.text}") + + def complete_card_step(self, project_id, step_id): + """Mark a card step as complete.""" + response = self.post(f'buckets/{project_id}/todos/{step_id}/completion.json') + if response.status_code == 201: + return response.json() + else: + raise Exception(f"Failed to complete card step: {response.status_code} - {response.text}") + + def uncomplete_card_step(self, project_id, step_id): + """Mark a card step as incomplete.""" + response = self.delete(f'buckets/{project_id}/todos/{step_id}/completion.json') + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to uncomplete card step: {response.status_code} - {response.text}") diff --git a/mcp_server_cli.py b/mcp_server_cli.py index 8d491e3..71b2f3a 100755 --- a/mcp_server_cli.py +++ b/mcp_server_cli.py @@ -161,6 +161,17 @@ class MCPServer: "required": ["project_id", "question_id"] }, # Card Table tools + { + "name": "get_card_tables", + "description": "Get all card tables for a project", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"} + }, + "required": ["project_id"] + } + }, { "name": "get_card_table", "description": "Get the card table details for a project", @@ -329,7 +340,9 @@ class MCPServer: "project_id": {"type": "string", "description": "The project ID"}, "column_id": {"type": "string", "description": "The column ID"}, "title": {"type": "string", "description": "The card title"}, - "content": {"type": "string", "description": "Optional card content/description"} + "content": {"type": "string", "description": "Optional card content/description"}, + "due_on": {"type": "string", "description": "Optional due date (ISO 8601 format)"}, + "notify": {"type": "boolean", "description": "Whether to notify assignees (default: false)"} }, "required": ["project_id", "column_id", "title"] } @@ -343,24 +356,139 @@ class MCPServer: "project_id": {"type": "string", "description": "The project ID"}, "card_id": {"type": "string", "description": "The card ID"}, "title": {"type": "string", "description": "The new card title"}, - "content": {"type": "string", "description": "The new card content/description"} + "content": {"type": "string", "description": "The new card content/description"}, + "due_on": {"type": "string", "description": "Due date (ISO 8601 format)"}, + "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "Array of person IDs to assign to the card"} }, "required": ["project_id", "card_id"] } }, { "name": "move_card", - "description": "Move a card to a new column and/or position", + "description": "Move a card to a new column", "inputSchema": { "type": "object", "properties": { "project_id": {"type": "string", "description": "The project ID"}, "card_id": {"type": "string", "description": "The card ID"}, - "column_id": {"type": "string", "description": "The destination column ID (optional)"}, - "position": {"type": "integer", "description": "The new 1-based position (optional)"} + "column_id": {"type": "string", "description": "The destination column ID"} + }, + "required": ["project_id", "card_id", "column_id"] + } + }, + { + "name": "complete_card", + "description": "Mark a card as complete", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "card_id": {"type": "string", "description": "The card ID"} }, "required": ["project_id", "card_id"] } + }, + { + "name": "uncomplete_card", + "description": "Mark a card as incomplete", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "card_id": {"type": "string", "description": "The card ID"} + }, + "required": ["project_id", "card_id"] + } + }, + { + "name": "get_card_steps", + "description": "Get all steps (sub-tasks) for a card", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "card_id": {"type": "string", "description": "The card ID"} + }, + "required": ["project_id", "card_id"] + } + }, + { + "name": "create_card_step", + "description": "Create a new step (sub-task) for a card", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "card_id": {"type": "string", "description": "The card ID"}, + "title": {"type": "string", "description": "The step title"}, + "due_on": {"type": "string", "description": "Optional due date (ISO 8601 format)"}, + "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "Array of person IDs to assign to the step"} + }, + "required": ["project_id", "card_id", "title"] + } + }, + { + "name": "get_card_step", + "description": "Get details for a specific card step", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "step_id": {"type": "string", "description": "The step ID"} + }, + "required": ["project_id", "step_id"] + } + }, + { + "name": "update_card_step", + "description": "Update a card step", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "step_id": {"type": "string", "description": "The step ID"}, + "title": {"type": "string", "description": "The step title"}, + "due_on": {"type": "string", "description": "Due date (ISO 8601 format)"}, + "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "Array of person IDs to assign to the step"} + }, + "required": ["project_id", "step_id"] + } + }, + { + "name": "delete_card_step", + "description": "Delete a card step", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "step_id": {"type": "string", "description": "The step ID"} + }, + "required": ["project_id", "step_id"] + } + }, + { + "name": "complete_card_step", + "description": "Mark a card step as complete", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "step_id": {"type": "string", "description": "The step ID"} + }, + "required": ["project_id", "step_id"] + } + }, + { + "name": "uncomplete_card_step", + "description": "Mark a card step as incomplete", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "step_id": {"type": "string", "description": "The step ID"} + }, + "required": ["project_id", "step_id"] + } } ] @@ -401,7 +529,7 @@ class MCPServer: logger.error(f"Error creating Basecamp client: {e}") return None - def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]: + def handle_request(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Handle an MCP request.""" method = request.get("method") # Normalize method name for cursor compatibility @@ -613,7 +741,9 @@ class MCPServer: } elif tool_name == "get_daily_check_ins": project_id = arguments.get("project_id") - page = arguments.get("page") + page = arguments.get("page", 1) + if page is not None and not isinstance(page, int): + page = 1 answers = client.get_daily_check_ins(project_id, page=page) return { "status": "success", @@ -623,7 +753,9 @@ class MCPServer: elif tool_name == "get_question_answers": project_id = arguments.get("project_id") question_id = arguments.get("question_id") - page = arguments.get("page") + page = arguments.get("page", 1) + if page is not None and not isinstance(page, int): + page = 1 answers = client.get_question_answers(project_id, question_id, page=page) return { "status": "success", @@ -632,23 +764,43 @@ class MCPServer: } # Card Table tools implementation + elif tool_name == "get_card_tables": + project_id = arguments.get("project_id") + card_tables = client.get_card_tables(project_id) + return { + "status": "success", + "card_tables": card_tables, + "count": len(card_tables) + } + elif tool_name == "get_card_table": project_id = arguments.get("project_id") try: - card_table = client.get_card_table(project_id) - # Also get the full details + # First, let's see what card tables we find + card_tables = client.get_card_tables(project_id) + if not card_tables: + return { + "status": "error", + "message": "No card tables found in project", + "debug": f"Found {len(card_tables)} card tables" + } + + card_table = card_tables[0] # Get the first card table + + # Get the full details card_table_details = client.get_card_table_details(project_id, card_table['id']) return { "status": "success", - "card_table": card_table_details + "card_table": card_table_details, + "debug": f"Found {len(card_tables)} card tables, using first one with ID {card_table['id']}" } except Exception as e: - if "Failed to get card table" in str(e): - return { - "status": "error", - "message": "No card table found for this project. Make sure the Card Table tool is enabled in the project settings." - } - raise + error_msg = str(e) + return { + "status": "error", + "message": f"Error getting card table: {error_msg}", + "debug": error_msg + } elif tool_name == "get_columns": project_id = arguments.get("project_id") @@ -695,7 +847,10 @@ class MCPServer: project_id = arguments.get("project_id") column_id = arguments.get("column_id") position = arguments.get("position") - client.move_column(project_id, column_id, position) + # Get the card table ID from the project + card_table = client.get_card_table(project_id) + card_table_id = card_table['id'] + client.move_column(project_id, column_id, position, card_table_id) return { "status": "success", "message": f"Column moved to position {position}" @@ -772,7 +927,9 @@ class MCPServer: column_id = arguments.get("column_id") title = arguments.get("title") content = arguments.get("content") - card = client.create_card(project_id, column_id, title, content) + due_on = arguments.get("due_on") + notify = bool(arguments.get("notify", False)) + card = client.create_card(project_id, column_id, title, content, due_on, notify) return { "status": "success", "card": card, @@ -784,7 +941,9 @@ class MCPServer: card_id = arguments.get("card_id") title = arguments.get("title") content = arguments.get("content") - card = client.update_card(project_id, card_id, title, content) + due_on = arguments.get("due_on") + assignee_ids = arguments.get("assignee_ids") + card = client.update_card(project_id, card_id, title, content, due_on, assignee_ids) return { "status": "success", "card": card, @@ -795,20 +954,105 @@ class MCPServer: project_id = arguments.get("project_id") card_id = arguments.get("card_id") column_id = arguments.get("column_id") - position = arguments.get("position") - client.move_card(project_id, card_id, column_id, position) + client.move_card(project_id, card_id, column_id) message = "Card moved" - if column_id and position: - message = f"Card moved to column {column_id} at position {position}" - elif column_id: + if column_id: message = f"Card moved to column {column_id}" - elif position: - message = f"Card moved to position {position}" return { "status": "success", "message": message } + elif tool_name == "complete_card": + project_id = arguments.get("project_id") + card_id = arguments.get("card_id") + client.complete_card(project_id, card_id) + return { + "status": "success", + "message": "Card marked as complete" + } + + elif tool_name == "uncomplete_card": + project_id = arguments.get("project_id") + card_id = arguments.get("card_id") + client.uncomplete_card(project_id, card_id) + return { + "status": "success", + "message": "Card marked as incomplete" + } + + elif tool_name == "get_card_steps": + project_id = arguments.get("project_id") + card_id = arguments.get("card_id") + steps = client.get_card_steps(project_id, card_id) + return { + "status": "success", + "steps": steps, + "count": len(steps) + } + + elif tool_name == "create_card_step": + project_id = arguments.get("project_id") + card_id = arguments.get("card_id") + title = arguments.get("title") + due_on = arguments.get("due_on") + assignee_ids = arguments.get("assignee_ids") + step = client.create_card_step(project_id, card_id, title, due_on, assignee_ids) + return { + "status": "success", + "step": step, + "message": f"Step '{title}' created successfully" + } + + elif tool_name == "get_card_step": + project_id = arguments.get("project_id") + step_id = arguments.get("step_id") + step = client.get_card_step(project_id, step_id) + return { + "status": "success", + "step": step + } + + elif tool_name == "update_card_step": + project_id = arguments.get("project_id") + step_id = arguments.get("step_id") + title = arguments.get("title") + due_on = arguments.get("due_on") + assignee_ids = arguments.get("assignee_ids") + step = client.update_card_step(project_id, step_id, title, due_on, assignee_ids) + return { + "status": "success", + "step": step, + "message": f"Step '{title}' updated successfully" + } + + elif tool_name == "delete_card_step": + project_id = arguments.get("project_id") + step_id = arguments.get("step_id") + client.delete_card_step(project_id, step_id) + return { + "status": "success", + "message": "Step deleted successfully" + } + + elif tool_name == "complete_card_step": + project_id = arguments.get("project_id") + step_id = arguments.get("step_id") + client.complete_card_step(project_id, step_id) + return { + "status": "success", + "message": "Step marked as complete" + } + + elif tool_name == "uncomplete_card_step": + project_id = arguments.get("project_id") + step_id = arguments.get("step_id") + client.uncomplete_card_step(project_id, step_id) + return { + "status": "success", + "message": "Step marked as incomplete" + } + else: return { "error": "Unknown tool",