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..98b01b3 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,28 @@ 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 and/or position ### Example Cursor Usage @@ -85,6 +107,10 @@ 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" ## Architecture diff --git a/basecamp_client.py b/basecamp_client.py index 7ef5070..61aa6e3 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,159 @@ class BasecampClient: if response.status_code != 200: raise Exception("Failed to read question answers") 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).""" + 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}") + + 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() + 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}") + + 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): + """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) + 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): + """Create a new card in a column.""" + data = {"title": title} + if content: + data["content"] = content + 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): + """Update a card.""" + data = {} + if title: + data["title"] = title + if content: + data["content"] = content + 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 + 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}") diff --git a/examples/card_table_example.py b/examples/card_table_example.py new file mode 100644 index 0000000..bf84927 --- /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 + process = subprocess.Popen( + [sys.executable, "mcp_server_cli.py"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + stdout, stderr = process.communicate(input=json.dumps(request)) + + if process.returncode != 0: + print(f"Error: {stderr}") + return None + + 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..8d491e3 100755 --- a/mcp_server_cli.py +++ b/mcp_server_cli.py @@ -159,6 +159,208 @@ class MCPServer: } }, "required": ["project_id", "question_id"] + }, + # Card Table tools + { + "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"}, + "column_id": {"type": "string", "description": "The column ID"}, + "position": {"type": "integer", "description": "The new 1-based position"} + }, + "required": ["project_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"} + }, + "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"} + }, + "required": ["project_id", "card_id"] + } + }, + { + "name": "move_card", + "description": "Move a card to a new column and/or position", + "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)"} + }, + "required": ["project_id", "card_id"] + } } ] @@ -429,6 +631,184 @@ class MCPServer: "count": len(answers) } + # Card Table tools implementation + 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 + card_table_details = client.get_card_table_details(project_id, card_table['id']) + return { + "status": "success", + "card_table": card_table_details + } + 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 + + 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": f"Column updated successfully" + } + + elif tool_name == "move_column": + project_id = arguments.get("project_id") + column_id = arguments.get("column_id") + position = arguments.get("position") + client.move_column(project_id, column_id, position) + 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") + card = client.create_card(project_id, column_id, title, content) + 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") + card = client.update_card(project_id, card_id, title, content) + 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") + position = arguments.get("position") + client.move_card(project_id, card_id, column_id, position) + message = "Card moved" + if column_id and position: + message = f"Card moved to column {column_id} at position {position}" + elif column_id: + message = f"Card moved to column {column_id}" + elif position: + message = f"Card moved to position {position}" + return { + "status": "success", + "message": message + } + 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