From 4e466c20dab5e2d7c0dbf205d2ec6de02de737d1 Mon Sep 17 00:00:00 2001 From: George Antonopoulos Date: Thu, 26 Jun 2025 22:34:01 +0100 Subject: [PATCH 1/3] Add card table functionality to Basecamp client and MCP server CLI - Implement methods for managing card tables and columns in BasecampClient. - Add corresponding tools in MCP server CLI for card table operations. - Update README with new card table tools and usage examples. - Create example script demonstrating card table API usage. - Add unit tests for card table functionality. --- .gitignore | 2 + README.md | 26 +++ basecamp_client.py | 161 ++++++++++++++ examples/card_table_example.py | 153 +++++++++++++ mcp_server_cli.py | 380 +++++++++++++++++++++++++++++++++ tests/test_card_tables.py | 207 ++++++++++++++++++ 6 files changed, 929 insertions(+) create mode 100644 examples/card_table_example.py create mode 100644 tests/test_card_tables.py 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 From a439865ef4f1c13b9330f6bad6f1e97869a6bfba Mon Sep 17 00:00:00 2001 From: George Antonopoulos Date: Sun, 29 Jun 2025 08:22:09 +0100 Subject: [PATCH 2/3] 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", From b740378a906a28b554982d607934f8b0b740ac1f Mon Sep 17 00:00:00 2001 From: George Antonopoulos Date: Sun, 29 Jun 2025 08:37:41 +0100 Subject: [PATCH 3/3] Update MCP server CLI and example script to include card_table_id in request handling - Add card_table_id to the required properties in MCPServer class. - Modify the move_column method to retrieve card_table_id from arguments. - Refactor subprocess call in card_table_example.py to use subprocess.run for better error handling. --- examples/card_table_example.py | 12 ++++++------ mcp_server_cli.py | 9 ++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/examples/card_table_example.py b/examples/card_table_example.py index bf84927..1f108af 100644 --- a/examples/card_table_example.py +++ b/examples/card_table_example.py @@ -25,20 +25,20 @@ def send_mcp_request(method, params=None): } # Run the MCP server with the request - process = subprocess.Popen( + result = subprocess.run( [sys.executable, "mcp_server_cli.py"], - stdin=subprocess.PIPE, + input=json.dumps(request), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - stdout, stderr = process.communicate(input=json.dumps(request)) - - if process.returncode != 0: - print(f"Error: {stderr}") + if result.returncode != 0: + print(f"Error: {result.stderr}") return None + stdout = result.stdout + try: return json.loads(stdout) except json.JSONDecodeError as e: diff --git a/mcp_server_cli.py b/mcp_server_cli.py index 71b2f3a..357607d 100755 --- a/mcp_server_cli.py +++ b/mcp_server_cli.py @@ -240,10 +240,11 @@ class MCPServer: "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", "column_id", "position"] + "required": ["project_id", "card_table_id", "column_id", "position"] } }, { @@ -840,16 +841,14 @@ class MCPServer: return { "status": "success", "column": column, - "message": f"Column updated successfully" + "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") - # 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",