diff --git a/.gitignore b/.gitignore index 61ffe89..4eda87d 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,5 @@ composio_client_example.py .composio/ .env +card_table_implementation.md +card-tables-docs.md diff --git a/README.md b/README.md index 7639af9..3db0f4c 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,37 @@ Once configured, you can use these tools in Cursor: - `get_todos` - Get todos from a todo list - `search_basecamp` - Search across projects, todos, and messages - `get_comments` - Get comments for a Basecamp item +- `get_campfire_lines` - Get recent messages from a Basecamp campfire +- `get_daily_check_ins` - Get project's daily check-in questions +- `get_question_answers` - Get answers to daily check-in questions + +### Card Table Tools + +- `get_card_table` - Get the card table details for a project +- `get_columns` - Get all columns in a card table +- `get_column` - Get details for a specific column +- `create_column` - Create a new column in a card table +- `update_column` - Update a column title +- `move_column` - Move a column to a new position +- `update_column_color` - Update a column color +- `put_column_on_hold` - Put a column on hold (freeze work) +- `remove_column_hold` - Remove hold from a column (unfreeze work) +- `watch_column` - Subscribe to notifications for changes in a column +- `unwatch_column` - Unsubscribe from notifications for a column +- `get_cards` - Get all cards in a column +- `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 +- `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 @@ -85,6 +116,14 @@ Ask Cursor things like: - "What todos are in project X?" - "Search for messages containing 'deadline'" - "Get details for the Technology project" +- "Show me the card table for project X" +- "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 7ef5070..4bfeb5b 100644 --- a/basecamp_client.py +++ b/basecamp_client.py @@ -90,6 +90,11 @@ class BasecampClient: url = f"{self.base_url}/{endpoint}" return requests.delete(url, auth=self.auth, headers=self.headers) + def patch(self, endpoint, data=None): + """Make a PATCH request to the Basecamp API.""" + url = f"{self.base_url}/{endpoint}" + return requests.patch(url, auth=self.auth, headers=self.headers, json=data) + # Project methods def get_projects(self): """Get all projects.""" @@ -344,3 +349,257 @@ class BasecampClient: if response.status_code != 200: raise Exception("Failed to read question answers") return response.json() + + # Card Table methods + def get_card_tables(self, project_id): + """Get all card tables for a project.""" + project = self.get_project(project_id) + try: + 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.""" + # 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.""" + response = self.get(f'buckets/{project_id}/card_tables/columns/{column_id}.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get column: {response.status_code} - {response.text}") + + def create_column(self, project_id, card_table_id, title): + """Create a new column in a card table.""" + data = {"title": title} + response = self.post(f'buckets/{project_id}/card_tables/{card_table_id}/columns.json', data) + if response.status_code == 201: + return response.json() + else: + raise Exception(f"Failed to create column: {response.status_code} - {response.text}") + + def update_column(self, project_id, column_id, title): + """Update a column title.""" + data = {"title": title} + response = self.put(f'buckets/{project_id}/card_tables/columns/{column_id}.json', data) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to update column: {response.status_code} - {response.text}") + + def move_column(self, project_id, column_id, position, card_table_id): + """Move a column to a new position.""" + 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: + raise Exception(f"Failed to move column: {response.status_code} - {response.text}") + + def update_column_color(self, project_id, column_id, color): + """Update a column color.""" + data = {"color": color} + response = self.patch(f'buckets/{project_id}/card_tables/columns/{column_id}/color.json', data) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to update column color: {response.status_code} - {response.text}") + + def put_column_on_hold(self, project_id, column_id): + """Put a column on hold.""" + response = self.post(f'buckets/{project_id}/card_tables/columns/{column_id}/on_hold.json') + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to put column on hold: {response.status_code} - {response.text}") + + def remove_column_hold(self, project_id, column_id): + """Remove hold from a column.""" + response = self.delete(f'buckets/{project_id}/card_tables/columns/{column_id}/on_hold.json') + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to remove column hold: {response.status_code} - {response.text}") + + def watch_column(self, project_id, column_id): + """Subscribe to column notifications.""" + response = self.post(f'buckets/{project_id}/card_tables/lists/{column_id}/subscription.json') + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to watch column: {response.status_code} - {response.text}") + + def unwatch_column(self, project_id, column_id): + """Unsubscribe from column notifications.""" + response = self.delete(f'buckets/{project_id}/card_tables/lists/{column_id}/subscription.json') + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to unwatch column: {response.status_code} - {response.text}") + + # Card Table Card methods + def get_cards(self, project_id, column_id): + """Get all cards in a column.""" + response = self.get(f'buckets/{project_id}/card_tables/lists/{column_id}/cards.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get cards: {response.status_code} - {response.text}") + + def get_card(self, project_id, card_id): + """Get a specific card.""" + response = self.get(f'buckets/{project_id}/card_tables/cards/{card_id}.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get card: {response.status_code} - {response.text}") + + 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, 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): + """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/examples/card_table_example.py b/examples/card_table_example.py new file mode 100644 index 0000000..1f108af --- /dev/null +++ b/examples/card_table_example.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +Example of using the Basecamp Card Table API through the MCP integration. + +This example demonstrates how to: +1. Get a card table for a project +2. List columns +3. Create a new column +4. Create cards in columns +5. Move cards between columns +6. Update column properties +""" + +import json +import subprocess +import sys + +def send_mcp_request(method, params=None): + """Send a request to the MCP server and return the response.""" + request = { + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params or {} + } + + # Run the MCP server with the request + result = subprocess.run( + [sys.executable, "mcp_server_cli.py"], + input=json.dumps(request), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + if result.returncode != 0: + print(f"Error: {result.stderr}") + return None + + stdout = result.stdout + + try: + return json.loads(stdout) + except json.JSONDecodeError as e: + print(f"Failed to parse response: {e}") + print(f"Response: {stdout}") + return None + +def main(): + """Demonstrate card table functionality.""" + + # Example project ID - replace with your actual project ID + project_id = "123456" + + print("Basecamp Card Table Example") + print("=" * 50) + + # 1. Get the card table for a project + print("\n1. Getting card table for project...") + response = send_mcp_request("tools/call", { + "name": "get_card_table", + "arguments": {"project_id": project_id} + }) + + if response and "result" in response: + result = json.loads(response["result"]["content"][0]["text"]) + if result.get("status") == "success": + card_table = result["card_table"] + card_table_id = card_table["id"] + print(f"Card table found: {card_table['title']} (ID: {card_table_id})") + else: + print("No card table found. Make sure the Card Table tool is enabled in your project.") + return + + # 2. List existing columns + print("\n2. Listing columns...") + response = send_mcp_request("tools/call", { + "name": "get_columns", + "arguments": { + "project_id": project_id, + "card_table_id": card_table_id + } + }) + + if response and "result" in response: + result = json.loads(response["result"]["content"][0]["text"]) + columns = result.get("columns", []) + print(f"Found {len(columns)} columns:") + for col in columns: + print(f" - {col['title']} (ID: {col['id']})") + + # 3. Create a new column + print("\n3. Creating a new column...") + response = send_mcp_request("tools/call", { + "name": "create_column", + "arguments": { + "project_id": project_id, + "card_table_id": card_table_id, + "title": "Testing" + } + }) + + if response and "result" in response: + result = json.loads(response["result"]["content"][0]["text"]) + if result.get("status") == "success": + new_column = result["column"] + print(f"Created column: {new_column['title']} (ID: {new_column['id']})") + + # 4. Create a card in the first column + if columns: + first_column_id = columns[0]['id'] + print(f"\n4. Creating a card in column '{columns[0]['title']}'...") + + response = send_mcp_request("tools/call", { + "name": "create_card", + "arguments": { + "project_id": project_id, + "column_id": first_column_id, + "title": "Test Card", + "content": "This is a test card created via the MCP API" + } + }) + + if response and "result" in response: + result = json.loads(response["result"]["content"][0]["text"]) + if result.get("status") == "success": + new_card = result["card"] + print(f"Created card: {new_card['title']} (ID: {new_card['id']})") + + # 5. Update column color + if columns: + print(f"\n5. Updating color of column '{columns[0]['title']}'...") + + response = send_mcp_request("tools/call", { + "name": "update_column_color", + "arguments": { + "project_id": project_id, + "column_id": columns[0]['id'], + "color": "#FF0000" # Red + } + }) + + if response and "result" in response: + result = json.loads(response["result"]["content"][0]["text"]) + if result.get("status") == "success": + print(f"Updated column color to red") + + print("\n" + "=" * 50) + print("Example completed!") + print("\nNote: Replace the project_id with your actual Basecamp project ID to run this example.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mcp_server_cli.py b/mcp_server_cli.py index 6f6ce90..357607d 100755 --- a/mcp_server_cli.py +++ b/mcp_server_cli.py @@ -159,6 +159,337 @@ 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", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"} + }, + "required": ["project_id"] + } + }, + { + "name": "get_columns", + "description": "Get all columns in a card table", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "card_table_id": {"type": "string", "description": "The card table ID"} + }, + "required": ["project_id", "card_table_id"] + } + }, + { + "name": "get_column", + "description": "Get details for a specific column", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "column_id": {"type": "string", "description": "The column ID"} + }, + "required": ["project_id", "column_id"] + } + }, + { + "name": "create_column", + "description": "Create a new column in a card table", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "card_table_id": {"type": "string", "description": "The card table ID"}, + "title": {"type": "string", "description": "The column title"} + }, + "required": ["project_id", "card_table_id", "title"] + } + }, + { + "name": "update_column", + "description": "Update a column title", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "column_id": {"type": "string", "description": "The column ID"}, + "title": {"type": "string", "description": "The new column title"} + }, + "required": ["project_id", "column_id", "title"] + } + }, + { + "name": "move_column", + "description": "Move a column to a new position", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "card_table_id": {"type": "string", "description": "The card table ID"}, + "column_id": {"type": "string", "description": "The column ID"}, + "position": {"type": "integer", "description": "The new 1-based position"} + }, + "required": ["project_id", "card_table_id", "column_id", "position"] + } + }, + { + "name": "update_column_color", + "description": "Update a column color", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "column_id": {"type": "string", "description": "The column ID"}, + "color": {"type": "string", "description": "The hex color code (e.g., #FF0000)"} + }, + "required": ["project_id", "column_id", "color"] + } + }, + { + "name": "put_column_on_hold", + "description": "Put a column on hold (freeze work)", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "column_id": {"type": "string", "description": "The column ID"} + }, + "required": ["project_id", "column_id"] + } + }, + { + "name": "remove_column_hold", + "description": "Remove hold from a column (unfreeze work)", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "column_id": {"type": "string", "description": "The column ID"} + }, + "required": ["project_id", "column_id"] + } + }, + { + "name": "watch_column", + "description": "Subscribe to notifications for changes in a column", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "column_id": {"type": "string", "description": "The column ID"} + }, + "required": ["project_id", "column_id"] + } + }, + { + "name": "unwatch_column", + "description": "Unsubscribe from notifications for a column", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "column_id": {"type": "string", "description": "The column ID"} + }, + "required": ["project_id", "column_id"] + } + }, + { + "name": "get_cards", + "description": "Get all cards in a column", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The project ID"}, + "column_id": {"type": "string", "description": "The column ID"} + }, + "required": ["project_id", "column_id"] + } + }, + { + "name": "get_card", + "description": "Get details for a specific 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", + "description": "Create a new card in a column", + "inputSchema": { + "type": "object", + "properties": { + "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"}, + "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"] + } + }, + { + "name": "update_card", + "description": "Update 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 new card title"}, + "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", + "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"} + }, + "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"] + } } ] @@ -199,7 +530,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 @@ -411,7 +742,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", @@ -421,7 +754,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", @@ -429,6 +764,294 @@ class MCPServer: "count": len(answers) } + # 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: + # 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, + "debug": f"Found {len(card_tables)} card tables, using first one with ID {card_table['id']}" + } + except Exception as e: + 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") + card_table_id = arguments.get("card_table_id") + columns = client.get_columns(project_id, card_table_id) + return { + "status": "success", + "columns": columns, + "count": len(columns) + } + + elif tool_name == "get_column": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + column = client.get_column(project_id, column_id) + return { + "status": "success", + "column": column + } + + elif tool_name == "create_column": + project_id = arguments.get("project_id") + card_table_id = arguments.get("card_table_id") + title = arguments.get("title") + column = client.create_column(project_id, card_table_id, title) + return { + "status": "success", + "column": column, + "message": f"Column '{title}' created successfully" + } + + elif tool_name == "update_column": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + title = arguments.get("title") + column = client.update_column(project_id, column_id, title) + return { + "status": "success", + "column": column, + "message": "Column updated successfully" + } + + elif tool_name == "move_column": + project_id = arguments.get("project_id") + card_table_id = arguments.get("card_table_id") + column_id = arguments.get("column_id") + position = arguments.get("position") + client.move_column(project_id, column_id, position, card_table_id) + return { + "status": "success", + "message": f"Column moved to position {position}" + } + + elif tool_name == "update_column_color": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + color = arguments.get("color") + column = client.update_column_color(project_id, column_id, color) + return { + "status": "success", + "column": column, + "message": f"Column color updated to {color}" + } + + elif tool_name == "put_column_on_hold": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + client.put_column_on_hold(project_id, column_id) + return { + "status": "success", + "message": "Column put on hold" + } + + elif tool_name == "remove_column_hold": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + client.remove_column_hold(project_id, column_id) + return { + "status": "success", + "message": "Column hold removed" + } + + elif tool_name == "watch_column": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + client.watch_column(project_id, column_id) + return { + "status": "success", + "message": "Column notifications enabled" + } + + elif tool_name == "unwatch_column": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + client.unwatch_column(project_id, column_id) + return { + "status": "success", + "message": "Column notifications disabled" + } + + elif tool_name == "get_cards": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + cards = client.get_cards(project_id, column_id) + return { + "status": "success", + "cards": cards, + "count": len(cards) + } + + elif tool_name == "get_card": + project_id = arguments.get("project_id") + card_id = arguments.get("card_id") + card = client.get_card(project_id, card_id) + return { + "status": "success", + "card": card + } + + elif tool_name == "create_card": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + title = arguments.get("title") + content = arguments.get("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, + "message": f"Card '{title}' created successfully" + } + + elif tool_name == "update_card": + project_id = arguments.get("project_id") + card_id = arguments.get("card_id") + title = arguments.get("title") + content = arguments.get("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, + "message": "Card updated successfully" + } + + elif tool_name == "move_card": + project_id = arguments.get("project_id") + card_id = arguments.get("card_id") + column_id = arguments.get("column_id") + client.move_card(project_id, card_id, column_id) + message = "Card moved" + if column_id: + message = f"Card moved to column {column_id}" + 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", diff --git a/tests/test_card_tables.py b/tests/test_card_tables.py new file mode 100644 index 0000000..164591d --- /dev/null +++ b/tests/test_card_tables.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Test script for Card Table functionality in Basecamp MCP integration. +""" + +import json +import sys +import os +import unittest +from unittest.mock import Mock, patch, MagicMock + +# Add parent directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from mcp_server_cli import MCPServer +from basecamp_client import BasecampClient + + +class TestCardTableTools(unittest.TestCase): + """Test Card Table MCP tools.""" + + def setUp(self): + """Set up test fixtures.""" + self.server = MCPServer() + + def test_card_table_tools_registered(self): + """Test that all card table tools are registered.""" + tool_names = [tool['name'] for tool in self.server.tools] + + expected_tools = [ + 'get_card_table', + 'get_columns', + 'get_column', + 'create_column', + 'update_column', + 'move_column', + 'update_column_color', + 'put_column_on_hold', + 'remove_column_hold', + 'watch_column', + 'unwatch_column', + 'get_cards', + 'get_card', + 'create_card', + 'update_card', + 'move_card' + ] + + for tool in expected_tools: + self.assertIn(tool, tool_names, f"Tool '{tool}' not found in registered tools") + + def test_tool_schemas(self): + """Test that tool schemas are properly defined.""" + tools_by_name = {tool['name']: tool for tool in self.server.tools} + + # Test get_card_table schema + schema = tools_by_name['get_card_table']['inputSchema'] + self.assertEqual(schema['type'], 'object') + self.assertIn('project_id', schema['properties']) + self.assertIn('project_id', schema['required']) + + # Test create_card schema + schema = tools_by_name['create_card']['inputSchema'] + self.assertEqual(schema['type'], 'object') + self.assertIn('project_id', schema['properties']) + self.assertIn('column_id', schema['properties']) + self.assertIn('title', schema['properties']) + self.assertIn('content', schema['properties']) + self.assertIn('project_id', schema['required']) + self.assertIn('column_id', schema['required']) + self.assertIn('title', schema['required']) + self.assertNotIn('content', schema['required']) # content is optional + + @patch('mcp_server_cli.token_storage.get_token') + @patch('mcp_server_cli.token_storage.is_token_expired') + def test_execute_get_card_table(self, mock_expired, mock_get_token): + """Test executing get_card_table tool.""" + mock_expired.return_value = False + mock_get_token.return_value = { + 'access_token': 'test_token', + 'account_id': '12345' + } + + with patch.object(BasecampClient, 'get_card_table') as mock_get_table: + with patch.object(BasecampClient, 'get_card_table_details') as mock_get_details: + mock_get_table.return_value = {'id': '123', 'name': 'card_table'} + mock_get_details.return_value = { + 'id': '123', + 'name': 'card_table', + 'title': 'Card Table', + 'columns_count': 4 + } + + result = self.server._execute_tool('get_card_table', {'project_id': '456'}) + + self.assertEqual(result['status'], 'success') + self.assertIn('card_table', result) + self.assertEqual(result['card_table']['id'], '123') + + @patch('mcp_server_cli.token_storage.get_token') + @patch('mcp_server_cli.token_storage.is_token_expired') + def test_execute_create_card(self, mock_expired, mock_get_token): + """Test executing create_card tool.""" + mock_expired.return_value = False + mock_get_token.return_value = { + 'access_token': 'test_token', + 'account_id': '12345' + } + + with patch.object(BasecampClient, 'create_card') as mock_create: + mock_create.return_value = { + 'id': '789', + 'title': 'New Card', + 'content': 'Card content', + 'column_id': '456' + } + + result = self.server._execute_tool('create_card', { + 'project_id': '123', + 'column_id': '456', + 'title': 'New Card', + 'content': 'Card content' + }) + + self.assertEqual(result['status'], 'success') + self.assertIn('card', result) + self.assertEqual(result['card']['title'], 'New Card') + self.assertIn('created successfully', result['message']) + + +class TestBasecampClientCardTables(unittest.TestCase): + """Test BasecampClient card table methods.""" + + def setUp(self): + """Set up test fixtures.""" + self.client = BasecampClient( + access_token='test_token', + account_id='12345', + user_agent='Test Agent', + auth_mode='oauth' + ) + + def test_patch_method_exists(self): + """Test that patch method exists.""" + self.assertTrue(hasattr(self.client, 'patch')) + + @patch('requests.get') + def test_get_card_table(self, mock_get): + """Test getting card table from project dock.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'id': '123', + 'dock': [ + {'name': 'todoset', 'id': '111'}, + {'name': 'card_table', 'id': '222'}, + {'name': 'message_board', 'id': '333'} + ] + } + mock_get.return_value = mock_response + + result = self.client.get_card_table('123') + + self.assertEqual(result['name'], 'card_table') + self.assertEqual(result['id'], '222') + + @patch('requests.post') + def test_create_column(self, mock_post): + """Test creating a column.""" + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = { + 'id': '456', + 'title': 'New Column', + 'position': 5 + } + mock_post.return_value = mock_response + + result = self.client.create_column('123', '456', 'New Column') + + self.assertEqual(result['title'], 'New Column') + mock_post.assert_called_once() + call_args = mock_post.call_args + self.assertEqual(call_args[1]['json'], {'title': 'New Column'}) + + @patch('requests.patch') + def test_update_column_color(self, mock_patch): + """Test updating column color.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'id': '456', + 'title': 'Column', + 'color': '#FF0000' + } + mock_patch.return_value = mock_response + + result = self.client.update_column_color('123', '456', '#FF0000') + + self.assertEqual(result['color'], '#FF0000') + mock_patch.assert_called_once() + call_args = mock_patch.call_args + self.assertEqual(call_args[1]['json'], {'color': '#FF0000'}) + + +if __name__ == '__main__': + unittest.main(verbosity=2) \ No newline at end of file