diff --git a/basecamp_client.py b/basecamp_client.py index e4eab37..fc1e697 100644 --- a/basecamp_client.py +++ b/basecamp_client.py @@ -159,6 +159,141 @@ class BasecampClient: else: raise Exception(f"Failed to get todo: {response.status_code} - {response.text}") + def create_todo(self, project_id, todolist_id, content, description=None, assignee_ids=None, + completion_subscriber_ids=None, notify=False, due_on=None, starts_on=None): + """ + Create a new todo item in a todolist. + + Args: + project_id (str): Project ID + todolist_id (str): Todolist ID + content (str): The todo item's text (required) + description (str, optional): HTML description + assignee_ids (list, optional): List of person IDs to assign + completion_subscriber_ids (list, optional): List of person IDs to notify on completion + notify (bool, optional): Whether to notify assignees + due_on (str, optional): Due date in YYYY-MM-DD format + starts_on (str, optional): Start date in YYYY-MM-DD format + + Returns: + dict: The created todo + """ + endpoint = f'buckets/{project_id}/todolists/{todolist_id}/todos.json' + data = {'content': content} + + if description: + data['description'] = description + if assignee_ids: + data['assignee_ids'] = assignee_ids + if completion_subscriber_ids: + data['completion_subscriber_ids'] = completion_subscriber_ids + if notify: + data['notify'] = notify + if due_on: + data['due_on'] = due_on + if starts_on: + data['starts_on'] = starts_on + + response = self.post(endpoint, data) + if response.status_code == 201: + return response.json() + else: + raise Exception(f"Failed to create todo: {response.status_code} - {response.text}") + + def update_todo(self, project_id, todo_id, content=None, description=None, assignee_ids=None, + completion_subscriber_ids=None, due_on=None, starts_on=None): + """ + Update an existing todo item. + + Args: + project_id (str): Project ID + todo_id (str): Todo ID + content (str, optional): The todo item's text + description (str, optional): HTML description + assignee_ids (list, optional): List of person IDs to assign + completion_subscriber_ids (list, optional): List of person IDs to notify on completion + due_on (str, optional): Due date in YYYY-MM-DD format + starts_on (str, optional): Start date in YYYY-MM-DD format + + Returns: + dict: The updated todo + """ + endpoint = f'buckets/{project_id}/todos/{todo_id}.json' + data = {} + + if content: + data['content'] = content + if description: + data['description'] = description + if assignee_ids: + data['assignee_ids'] = assignee_ids + if completion_subscriber_ids: + data['completion_subscriber_ids'] = completion_subscriber_ids + if due_on: + data['due_on'] = due_on + if starts_on: + data['starts_on'] = starts_on + + response = self.put(endpoint, data) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to update todo: {response.status_code} - {response.text}") + + def delete_todo(self, project_id, todo_id): + """ + Delete a todo item. + + Args: + project_id (str): Project ID + todo_id (str): Todo ID + + Returns: + bool: True if successful + """ + endpoint = f'buckets/{project_id}/todos/{todo_id}.json' + response = self.delete(endpoint) + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to delete todo: {response.status_code} - {response.text}") + + def complete_todo(self, project_id, todo_id): + """ + Mark a todo as complete. + + Args: + project_id (str): Project ID + todo_id (str): Todo ID + + Returns: + dict: Completion details + """ + endpoint = f'buckets/{project_id}/todos/{todo_id}/completion.json' + response = self.post(endpoint) + if response.status_code == 201: + return response.json() + else: + raise Exception(f"Failed to complete todo: {response.status_code} - {response.text}") + + def uncomplete_todo(self, project_id, todo_id): + """ + Mark a todo as incomplete. + + Args: + project_id (str): Project ID + todo_id (str): Todo ID + + Returns: + bool: True if successful + """ + endpoint = f'buckets/{project_id}/todos/{todo_id}/completion.json' + response = self.delete(endpoint) + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to uncomplete todo: {response.status_code} - {response.text}") + # People methods def get_people(self): """Get all people in the account.""" diff --git a/basecamp_fastmcp.py b/basecamp_fastmcp.py index 0934f2a..8b2af4f 100644 --- a/basecamp_fastmcp.py +++ b/basecamp_fastmcp.py @@ -254,6 +254,206 @@ async def get_todos(project_id: str, todolist_id: str) -> Dict[str, Any]: "message": str(e) } +@mcp.tool() +async def create_todo(project_id: str, todolist_id: str, content: str, + description: Optional[str] = None, + assignee_ids: Optional[List[str]] = None, + completion_subscriber_ids: Optional[List[str]] = None, + notify: bool = False, + due_on: Optional[str] = None, + starts_on: Optional[str] = None) -> Dict[str, Any]: + """Create a new todo item in a todo list. + + Args: + project_id: Project ID + todolist_id: The todo list ID + content: The todo item's text (required) + description: HTML description of the todo + assignee_ids: List of person IDs to assign + completion_subscriber_ids: List of person IDs to notify on completion + notify: Whether to notify assignees + due_on: Due date in YYYY-MM-DD format + starts_on: Start date in YYYY-MM-DD format + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + # Use lambda to properly handle keyword arguments + todo = await _run_sync( + lambda: client.create_todo( + project_id, todolist_id, content, + description=description, + assignee_ids=assignee_ids, + completion_subscriber_ids=completion_subscriber_ids, + notify=notify, + due_on=due_on, + starts_on=starts_on + ) + ) + return { + "status": "success", + "todo": todo, + "message": f"Todo '{content}' created successfully" + } + except Exception as e: + logger.error(f"Error creating todo: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def update_todo(project_id: str, todo_id: str, + content: Optional[str] = None, + description: Optional[str] = None, + assignee_ids: Optional[List[str]] = None, + completion_subscriber_ids: Optional[List[str]] = None, + due_on: Optional[str] = None, + starts_on: Optional[str] = None) -> Dict[str, Any]: + """Update an existing todo item. + + Args: + project_id: Project ID + todo_id: The todo ID + content: The todo item's text + description: HTML description of the todo + assignee_ids: List of person IDs to assign + completion_subscriber_ids: List of person IDs to notify on completion + due_on: Due date in YYYY-MM-DD format + starts_on: Start date in YYYY-MM-DD format + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + # Use lambda to properly handle keyword arguments + todo = await _run_sync( + lambda: client.update_todo( + project_id, todo_id, + content=content, + description=description, + assignee_ids=assignee_ids, + completion_subscriber_ids=completion_subscriber_ids, + due_on=due_on, + starts_on=starts_on + ) + ) + return { + "status": "success", + "todo": todo, + "message": "Todo updated successfully" + } + except Exception as e: + logger.error(f"Error updating todo: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def delete_todo(project_id: str, todo_id: str) -> Dict[str, Any]: + """Delete a todo item. + + Args: + project_id: Project ID + todo_id: The todo ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + await _run_sync(client.delete_todo, project_id, todo_id) + return { + "status": "success", + "message": "Todo deleted successfully" + } + except Exception as e: + logger.error(f"Error deleting todo: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def complete_todo(project_id: str, todo_id: str) -> Dict[str, Any]: + """Mark a todo item as complete. + + Args: + project_id: Project ID + todo_id: The todo ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + completion = await _run_sync(client.complete_todo, project_id, todo_id) + return { + "status": "success", + "completion": completion, + "message": "Todo marked as complete" + } + except Exception as e: + logger.error(f"Error completing todo: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def uncomplete_todo(project_id: str, todo_id: str) -> Dict[str, Any]: + """Mark a todo item as incomplete. + + Args: + project_id: Project ID + todo_id: The todo ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + await _run_sync(client.uncomplete_todo, project_id, todo_id) + return { + "status": "success", + "message": "Todo marked as incomplete" + } + except Exception as e: + logger.error(f"Error uncompleting todo: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + @mcp.tool() async def global_search(query: str) -> Dict[str, Any]: """Search projects, todos and campfire messages across all projects. diff --git a/mcp_server_cli.py b/mcp_server_cli.py index 8a938bc..f115216 100755 --- a/mcp_server_cli.py +++ b/mcp_server_cli.py @@ -88,6 +88,79 @@ class MCPServer: "required": ["project_id", "todolist_id"] } }, + { + "name": "create_todo", + "description": "Create a new todo item in a todo list", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "Project ID"}, + "todolist_id": {"type": "string", "description": "The todo list ID"}, + "content": {"type": "string", "description": "The todo item's text (required)"}, + "description": {"type": "string", "description": "HTML description of the todo"}, + "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "List of person IDs to assign"}, + "completion_subscriber_ids": {"type": "array", "items": {"type": "string"}, "description": "List of person IDs to notify on completion"}, + "notify": {"type": "boolean", "description": "Whether to notify assignees"}, + "due_on": {"type": "string", "description": "Due date in YYYY-MM-DD format"}, + "starts_on": {"type": "string", "description": "Start date in YYYY-MM-DD format"} + }, + "required": ["project_id", "todolist_id", "content"] + } + }, + { + "name": "update_todo", + "description": "Update an existing todo item", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "Project ID"}, + "todo_id": {"type": "string", "description": "The todo ID"}, + "content": {"type": "string", "description": "The todo item's text"}, + "description": {"type": "string", "description": "HTML description of the todo"}, + "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "List of person IDs to assign"}, + "completion_subscriber_ids": {"type": "array", "items": {"type": "string"}, "description": "List of person IDs to notify on completion"}, + "due_on": {"type": "string", "description": "Due date in YYYY-MM-DD format"}, + "starts_on": {"type": "string", "description": "Start date in YYYY-MM-DD format"} + }, + "required": ["project_id", "todo_id"] + } + }, + { + "name": "delete_todo", + "description": "Delete a todo item", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "Project ID"}, + "todo_id": {"type": "string", "description": "The todo ID"} + }, + "required": ["project_id", "todo_id"] + } + }, + { + "name": "complete_todo", + "description": "Mark a todo item as complete", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "Project ID"}, + "todo_id": {"type": "string", "description": "The todo ID"} + }, + "required": ["project_id", "todo_id"] + } + }, + { + "name": "uncomplete_todo", + "description": "Mark a todo item as incomplete", + "inputSchema": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "Project ID"}, + "todo_id": {"type": "string", "description": "The todo ID"} + }, + "required": ["project_id", "todo_id"] + } + }, { "name": "search_basecamp", "description": "Search across Basecamp projects, todos, and messages", @@ -813,6 +886,85 @@ class MCPServer: "count": len(todos) } + elif tool_name == "create_todo": + project_id = arguments.get("project_id") + todolist_id = arguments.get("todolist_id") + content = arguments.get("content") + description = arguments.get("description") + assignee_ids = arguments.get("assignee_ids") + completion_subscriber_ids = arguments.get("completion_subscriber_ids") + notify = bool(arguments.get("notify", False)) + due_on = arguments.get("due_on") + starts_on = arguments.get("starts_on") + + todo = client.create_todo( + project_id, todolist_id, content, + description=description, + assignee_ids=assignee_ids, + completion_subscriber_ids=completion_subscriber_ids, + notify=notify, + due_on=due_on, + starts_on=starts_on + ) + return { + "status": "success", + "todo": todo, + "message": f"Todo '{content}' created successfully" + } + + elif tool_name == "update_todo": + project_id = arguments.get("project_id") + todo_id = arguments.get("todo_id") + content = arguments.get("content") + description = arguments.get("description") + assignee_ids = arguments.get("assignee_ids") + completion_subscriber_ids = arguments.get("completion_subscriber_ids") + due_on = arguments.get("due_on") + starts_on = arguments.get("starts_on") + + todo = client.update_todo( + project_id, todo_id, + content=content, + description=description, + assignee_ids=assignee_ids, + completion_subscriber_ids=completion_subscriber_ids, + due_on=due_on, + starts_on=starts_on + ) + return { + "status": "success", + "todo": todo, + "message": "Todo updated successfully" + } + + elif tool_name == "delete_todo": + project_id = arguments.get("project_id") + todo_id = arguments.get("todo_id") + client.delete_todo(project_id, todo_id) + return { + "status": "success", + "message": "Todo deleted successfully" + } + + elif tool_name == "complete_todo": + project_id = arguments.get("project_id") + todo_id = arguments.get("todo_id") + completion = client.complete_todo(project_id, todo_id) + return { + "status": "success", + "completion": completion, + "message": "Todo marked as complete" + } + + elif tool_name == "uncomplete_todo": + project_id = arguments.get("project_id") + todo_id = arguments.get("todo_id") + client.uncomplete_todo(project_id, todo_id) + return { + "status": "success", + "message": "Todo marked as incomplete" + } + elif tool_name == "search_basecamp": query = arguments.get("query") project_id = arguments.get("project_id")