diff --git a/src/dolibarr_mcp/dolibarr_client.py b/src/dolibarr_mcp/dolibarr_client.py index d5ee24a..ae491e2 100644 --- a/src/dolibarr_mcp/dolibarr_client.py +++ b/src/dolibarr_mcp/dolibarr_client.py @@ -510,6 +510,43 @@ class DolibarrClient: """Delete a contact.""" return await self.request("DELETE", f"contacts/{contact_id}") + # ============================================================================ + # PROJECT MANAGEMENT + # ============================================================================ + + async def get_projects(self, limit: int = 100, page: int = 1, status: Optional[int] = None) -> List[Dict[str, Any]]: + """Get list of projects.""" + params: Dict[str, Any] = {"limit": limit, "page": page} + if status is not None: + params["status"] = status + result = await self.request("GET", "projects", params=params) + return result if isinstance(result, list) else [] + + async def get_project_by_id(self, project_id: int) -> Dict[str, Any]: + """Get specific project by ID.""" + return await self.request("GET", f"projects/{project_id}") + + async def search_projects(self, sqlfilters: str, limit: int = 20) -> List[Dict[str, Any]]: + """Search projects using SQL filters.""" + params = {"limit": limit, "sqlfilters": sqlfilters} + result = await self.request("GET", "projects", params=params) + return result if isinstance(result, list) else [] + + async def create_project(self, data: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]: + """Create a new project.""" + payload = self._merge_payload(data, **kwargs) + result = await self.request("POST", "projects", data=payload) + return self._extract_identifier(result) + + async def update_project(self, project_id: int, data: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]: + """Update an existing project.""" + payload = self._merge_payload(data, **kwargs) + return await self.request("PUT", f"projects/{project_id}", data=payload) + + async def delete_project(self, project_id: int) -> Dict[str, Any]: + """Delete a project.""" + return await self.request("DELETE", f"projects/{project_id}") + # ============================================================================ # RAW API CALL # ============================================================================ diff --git a/src/dolibarr_mcp/dolibarr_mcp_server.py b/src/dolibarr_mcp/dolibarr_mcp_server.py index a1989b4..7bdd896 100644 --- a/src/dolibarr_mcp/dolibarr_mcp_server.py +++ b/src/dolibarr_mcp/dolibarr_mcp_server.py @@ -535,6 +535,89 @@ async def handle_list_tools(): "additionalProperties": False } ), + + # Project Management CRUD + Tool( + name="get_projects", + description="Get list of projects from Dolibarr", + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Maximum number of projects to return (default: 100)", "default": 100}, + "page": {"type": "integer", "description": "Page number for pagination (default: 1)", "default": 1}, + "status": {"type": "integer", "description": "Project status filter (e.g. 0=draft, 1=open, 2=closed)", "default": 1} + }, + "additionalProperties": False + } + ), + Tool( + name="get_project_by_id", + description="Get specific project details by ID", + inputSchema={ + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "Project ID to retrieve"} + }, + "required": ["project_id"], + "additionalProperties": False + } + ), + Tool( + name="search_projects", + description="Search projects by reference or title", + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search term for project ref or title"}, + "limit": {"type": "integer", "description": "Maximum number of results", "default": 20} + }, + "required": ["query"], + "additionalProperties": False + } + ), + Tool( + name="create_project", + description="Create a new project", + inputSchema={ + "type": "object", + "properties": { + "ref": {"type": "string", "description": "Project reference (optional, if Dolibarr auto-generates)"}, + "title": {"type": "string", "description": "Project title"}, + "description": {"type": "string", "description": "Project description"}, + "socid": {"type": "integer", "description": "Linked customer ID (thirdparty)"}, + "status": {"type": "integer", "description": "Project status (e.g. 1=open)", "default": 1} + }, + "required": ["title"], + "additionalProperties": False + } + ), + Tool( + name="update_project", + description="Update an existing project", + inputSchema={ + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "Project ID to update"}, + "title": {"type": "string", "description": "Project title"}, + "description": {"type": "string", "description": "Project description"}, + "status": {"type": "integer", "description": "Project status"} + }, + "required": ["project_id"], + "additionalProperties": False + } + ), + Tool( + name="delete_project", + description="Delete a project", + inputSchema={ + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "Project ID to delete"} + }, + "required": ["project_id"], + "additionalProperties": False + } + ), # Raw API Access Tool( @@ -725,6 +808,33 @@ async def handle_call_tool(name: str, arguments: dict): elif name == "delete_contact": result = await client.delete_contact(arguments['contact_id']) + # Project Management + elif name == "get_projects": + result = await client.get_projects( + limit=arguments.get("limit", 100), + page=arguments.get("page", 1), + status=arguments.get("status") + ) + + elif name == "get_project_by_id": + result = await client.get_project_by_id(arguments["project_id"]) + + elif name == "search_projects": + query = _escape_sqlfilter(arguments["query"]) + limit = arguments.get("limit", 20) + sqlfilters = f"((t.ref:like:'%{query}%') OR (t.title:like:'%{query}%'))" + result = await client.search_projects(sqlfilters=sqlfilters, limit=limit) + + elif name == "create_project": + result = await client.create_project(**arguments) + + elif name == "update_project": + project_id = arguments.pop("project_id") + result = await client.update_project(project_id, **arguments) + + elif name == "delete_project": + result = await client.delete_project(arguments["project_id"]) + # Raw API Access elif name == "dolibarr_raw_api": result = await client.dolibarr_raw_api(**arguments) diff --git a/tests/test_project_operations.py b/tests/test_project_operations.py new file mode 100644 index 0000000..71fdce6 --- /dev/null +++ b/tests/test_project_operations.py @@ -0,0 +1,112 @@ +""" +Project Management Integration Tests for Dolibarr MCP Server. + +These tests verify complete CRUD operations for Dolibarr projects. +Run with: pytest tests/test_project_operations.py -v +""" + +import pytest +import asyncio +from datetime import datetime +from unittest.mock import Mock, patch, AsyncMock + +# Add src to path for imports +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from dolibarr_mcp import DolibarrClient, Config + + +class TestProjectOperations: + """Test complete CRUD operations for Dolibarr projects.""" + + @pytest.fixture + def config(self): + """Create a test configuration.""" + return Config( + dolibarr_url="https://test.dolibarr.com", + dolibarr_api_key="test_api_key", + log_level="INFO" + ) + + @pytest.fixture + def client(self, config): + """Create a test client instance.""" + return DolibarrClient(config) + + @pytest.mark.asyncio + async def test_project_crud_lifecycle(self, client): + """Test complete project CRUD lifecycle.""" + with patch.object(client, 'request') as mock_request: + # Create + mock_request.return_value = {"id": 200} + project_id = await client.create_project({ + "title": "New Website", + "description": "Website redesign project", + "socid": 1, + "status": 1 + }) + assert project_id == 200 + + # Read + mock_request.return_value = { + "id": 200, + "ref": "PJ2401-001", + "title": "New Website", + "description": "Website redesign project" + } + project = await client.get_project_by_id(200) + assert project["title"] == "New Website" + assert project["ref"] == "PJ2401-001" + + # Update + mock_request.return_value = {"id": 200, "title": "Updated Website Project"} + result = await client.update_project(200, {"title": "Updated Website Project"}) + assert result["title"] == "Updated Website Project" + + # Delete + mock_request.return_value = {"success": True} + result = await client.delete_project(200) + assert result["success"] is True + + @pytest.mark.asyncio + async def test_search_projects(self, client): + """Test searching projects.""" + with patch.object(client, 'request') as mock_request: + mock_request.return_value = [ + {"id": 200, "ref": "PJ2401-001", "title": "Website Redesign"}, + {"id": 201, "ref": "PJ2401-002", "title": "Mobile App"} + ] + + # Search by query + results = await client.search_projects(sqlfilters="(t.title:like:'%Website%')", limit=10) + + assert len(results) == 2 + assert results[0]["title"] == "Website Redesign" + + # Verify call arguments + call_args = mock_request.call_args + assert call_args is not None + method, endpoint = call_args[0] + kwargs = call_args[1] + + assert method == "GET" + assert endpoint == "projects" + assert kwargs["params"]["sqlfilters"] == "(t.title:like:'%Website%')" + + @pytest.mark.asyncio + async def test_get_projects_with_filters(self, client): + """Test getting projects with status filter.""" + with patch.object(client, 'request') as mock_request: + mock_request.return_value = [] + + await client.get_projects(limit=50, page=2, status=1) + + call_args = mock_request.call_args + kwargs = call_args[1] + params = kwargs["params"] + + assert params["limit"] == 50 + assert params["page"] == 2 + assert params["status"] == 1