diff --git a/README.md b/README.md index c231068..ee1ee5d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # Dolibarr MCP Server Dolibarr MCP delivers a Model Context Protocol (MCP) interface for the Dolibarr -ERP/CRM. The project mirrors the layout of [`prestashop-mcp`](https://github.com/latinogino/prestashop-mcp): +ERP/CRM. The project mirrors the project structure of [`prestashop-mcp`](https://github.com/latinogino/prestashop-mcp): an async API client, a production-ready STDIO server, and focused -documentation. Claude Desktop and other MCP-aware tools can use the server to +documentation. + +**Design Note:** While sharing the same architecture, this server implements **specialized search tools** (e.g., `search_products_by_ref`, `resolve_product_ref`) instead of a single unified `get_` tool. This design choice ensures efficient server-side filtering via Dolibarr's SQL API, preventing the agent from accidentally loading thousands of records and exceeding context limits. + +Claude Desktop and other MCP-aware tools can use the server to manage customers, products, invoices, orders, and contacts in a Dolibarr instance. @@ -13,7 +17,8 @@ configuration, API coverage, and contributor workflows. ## ✨ Features - **Full ERP coverage** – CRUD tools for users, customers, products, invoices, - orders, contacts, and raw API access. + orders, contacts, projects, and raw API access. +- **Advanced Search** – Server-side filtering for products, customers, and projects to minimize token usage and costs. - **Async/await HTTP client** – Efficient Dolibarr API wrapper with structured error handling. - **Ready for MCP hosts** – STDIO transport compatible with Claude Desktop out diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a0b19f..2b01714 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -21,11 +21,13 @@ implements for PrestaShop. | Resource | Endpoint(s) | Tool group | | --------------- | --------------------------- | --------------------------------------- | | Status | `GET /status` | `get_status`, `test_connection` | +| Search | `/products`, `/thirdparties`| `search_products_by_ref`, `search_customers`, `resolve_product_ref` | | Users | `/users` | CRUD helpers under the *Users* group | | Third parties | `/thirdparties` | Customer CRUD operations | | Products | `/products` | Product CRUD operations | | Invoices | `/invoices` | Invoice CRUD operations | | Orders | `/orders` | Order CRUD operations | +| Projects | `/projects` | Project CRUD operations & Search | | Contacts | `/contacts` | Contact CRUD operations | | Raw passthrough | Any relative path | `dolibarr_raw_api` tool for quick tests | diff --git a/docs/development.md b/docs/development.md index 6262687..bed6fb1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,18 +6,25 @@ kept separate in `docker/`. ## Install development dependencies +It is recommended to use a virtual environment to avoid conflicts with system packages (especially on Linux systems with externally managed environments). + ```bash -pip install -e '.[dev]' -``` +# Create a virtual environment +python3 -m venv .venv -### Windows PowerShell +# Activate the virtual environment +source .venv/bin/activate # On Linux/macOS +# .venv\Scripts\activate # On Windows -```powershell -pip install -e .`[dev`] +# Install dependencies +pip install -r requirements.txt +pip install -e . ``` ## Run the test suite +Once your virtual environment is active: + ```bash pytest ``` @@ -28,6 +35,12 @@ To gather coverage metrics: pytest --cov=src/dolibarr_mcp --cov-report=term-missing ``` +If you encounter "command not found" errors, ensure your virtual environment is activated or run via python module: + +```bash +python3 -m pytest +``` + ## Formatting and linting The project intentionally avoids heavy linting dependencies. Follow the coding diff --git a/src/dolibarr_mcp/__main__.py b/src/dolibarr_mcp/__main__.py index 6a41611..6f2110f 100644 --- a/src/dolibarr_mcp/__main__.py +++ b/src/dolibarr_mcp/__main__.py @@ -10,6 +10,7 @@ if src_dir not in sys.path: sys.path.insert(0, src_dir) from dolibarr_mcp.dolibarr_mcp_server import main +import asyncio if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/src/dolibarr_mcp/config.py b/src/dolibarr_mcp/config.py index da138e4..a2fc62d 100644 --- a/src/dolibarr_mcp/config.py +++ b/src/dolibarr_mcp/config.py @@ -19,7 +19,7 @@ class Config(BaseSettings): env_file_encoding="utf-8", case_sensitive=False, validate_assignment=True, - extra="forbid", + extra="ignore", ) dolibarr_url: str = Field( diff --git a/src/dolibarr_mcp/dolibarr_client.py b/src/dolibarr_mcp/dolibarr_client.py index 6ffd02b..3d1c8f7 100644 --- a/src/dolibarr_mcp/dolibarr_client.py +++ b/src/dolibarr_mcp/dolibarr_client.py @@ -272,6 +272,12 @@ class DolibarrClient: # CUSTOMER/THIRD PARTY MANAGEMENT # ============================================================================ + async def search_customers(self, sqlfilters: str, limit: int = 20) -> List[Dict[str, Any]]: + """Search customers using SQL filters.""" + params = {"limit": limit, "sqlfilters": sqlfilters} + result = await self.request("GET", "thirdparties", params=params) + return result if isinstance(result, list) else [] + async def get_customers(self, limit: int = 100, page: int = 1) -> List[Dict[str, Any]]: """Get list of customers/third parties.""" params = {"limit": limit} @@ -330,6 +336,12 @@ class DolibarrClient: # PRODUCT MANAGEMENT # ============================================================================ + async def search_products(self, sqlfilters: str, limit: int = 20) -> List[Dict[str, Any]]: + """Search products using SQL filters.""" + params = {"limit": limit, "sqlfilters": sqlfilters} + result = await self.request("GET", "products", params=params) + return result if isinstance(result, list) else [] + async def get_products(self, limit: int = 100) -> List[Dict[str, Any]]: """Get list of products.""" params = {"limit": limit} @@ -388,6 +400,20 @@ class DolibarrClient: ) -> Dict[str, Any]: """Create a new invoice.""" payload = self._merge_payload(data, **kwargs) + + # Fix: Map customer_id to socid + if "customer_id" in payload and "socid" not in payload: + payload["socid"] = payload.pop("customer_id") + + # Fix: Map product_id to fk_product in lines + if "lines" in payload and isinstance(payload["lines"], list): + for line in payload["lines"]: + if "product_id" in line: + line["fk_product"] = line.pop("product_id") + # Ensure product_type is passed if present (0=Product, 1=Service) + if "product_type" in line: + line["product_type"] = line["product_type"] + result = await self.request("POST", "invoices", data=payload) return self._extract_identifier(result) @@ -404,6 +430,44 @@ class DolibarrClient: async def delete_invoice(self, invoice_id: int) -> Dict[str, Any]: """Delete an invoice.""" return await self.request("DELETE", f"invoices/{invoice_id}") + + async def add_invoice_line( + self, + invoice_id: int, + data: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> Dict[str, Any]: + """Add a line to an invoice.""" + payload = self._merge_payload(data, **kwargs) + + # Map product_id to fk_product if present + if "product_id" in payload: + payload["fk_product"] = payload.pop("product_id") + + return await self.request("POST", f"invoices/{invoice_id}/lines", data=payload) + + async def update_invoice_line( + self, + invoice_id: int, + line_id: int, + data: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> Dict[str, Any]: + """Update a line in an invoice.""" + payload = self._merge_payload(data, **kwargs) + return await self.request("PUT", f"invoices/{invoice_id}/lines/{line_id}", data=payload) + + async def delete_invoice_line(self, invoice_id: int, line_id: int) -> Dict[str, Any]: + """Delete a line from an invoice.""" + return await self.request("DELETE", f"invoices/{invoice_id}/lines/{line_id}") + + async def validate_invoice(self, invoice_id: int, warehouse_id: int = 0, not_trigger: int = 0) -> Dict[str, Any]: + """Validate an invoice.""" + payload = { + "idwarehouse": warehouse_id, + "not_trigger": not_trigger + } + return await self.request("POST", f"invoices/{invoice_id}/validate", data=payload) # ============================================================================ # ORDER MANAGEMENT @@ -484,6 +548,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 9a4050f..a7b90fe 100644 --- a/src/dolibarr_mcp/dolibarr_mcp_server.py +++ b/src/dolibarr_mcp/dolibarr_mcp_server.py @@ -29,6 +29,11 @@ logging.basicConfig( server = Server("dolibarr-mcp") +def _escape_sqlfilter(value: str) -> str: + """Escape single quotes for SQL filters.""" + return value.replace("'", "''") + + @server.list_tools() async def handle_list_tools(): """List all available tools.""" @@ -37,38 +42,146 @@ async def handle_list_tools(): Tool( name="test_connection", description="Test Dolibarr API connection", - inputSchema={"type": "object", "properties": {}, "additionalProperties": False} + inputSchema={"type": "object", "properties": {}, "additionalProperties": False}, ), Tool( name="get_status", description="Get Dolibarr system status and version information", - inputSchema={"type": "object", "properties": {}, "additionalProperties": False} + inputSchema={"type": "object", "properties": {}, "additionalProperties": False}, ), - + + # Search Tools + Tool( + name="search_products_by_ref", + description=( + "Search products by (partial) reference. Use this when a product reference appears in the text " + "but may be incomplete or slightly uncertain. This tool returns a small, filtered list and should " + "be preferred over get_products for any kind of lookup by reference." + ), + inputSchema={ + "type": "object", + "properties": { + "ref_prefix": { + "type": "string", + "description": "Prefix of the product reference", + }, + "limit": { + "type": "integer", + "description": "Maximum number of results", + "default": 20, + }, + }, + "required": ["ref_prefix"], + "additionalProperties": False, + }, + ), + Tool( + name="search_customers", + description=( + "Search customers/third parties by name or alias. Use this whenever you need to find a customer " + "from a name in text instead of loading a full list. Pay attention to legal suffixes and exact matches " + "(e.g. 'GmbH' vs 'OG', 'Inc', etc.). Do not use get_customers for name-based search." + ), + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search term for name or alias", + }, + "limit": { + "type": "integer", + "description": "Maximum number of results", + "default": 20, + }, + }, + "required": ["query"], + "additionalProperties": False, + }, + ), + Tool( + name="search_products_by_label", + description=( + "Search products by label/description text. Use this when you only know the human-readable product " + "name or part of it. Prefer this over get_products for any label-based lookup to keep result sets small." + ), + inputSchema={ + "type": "object", + "properties": { + "label_search": { + "type": "string", + "description": "Search term in product label", + }, + "limit": { + "type": "integer", + "description": "Maximum number of results", + "default": 20, + }, + }, + "required": ["label_search"], + "additionalProperties": False, + }, + ), + Tool( + name="resolve_product_ref", + description=( + "Resolve an exact product reference (ref) to a single product. Use this only when the exact reference " + "string is known and you need a deterministic mapping to a product ID before creating orders or invoices. " + "Returns a structured result with status 'ok', 'not_found', or 'ambiguous'. Do not use this for fuzzy search." + ), + inputSchema={ + "type": "object", + "properties": { + "ref": {"type": "string", "description": "Exact product reference"} + }, + "required": ["ref"], + "additionalProperties": False, + }, + ), + # User Management CRUD Tool( name="get_users", - description="Get list of users from Dolibarr", + description=( + "Get an unfiltered paginated list of users from Dolibarr. " + "Use this only when you explicitly need a page of users for inspection or debugging. " + "Do not use this tool to search by name, login or email (there is no server-side filter here)." + ), inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Maximum number of users to return (default: 100)", "default": 100}, - "page": {"type": "integer", "description": "Page number for pagination (default: 1)", "default": 1} + "limit": { + "type": "integer", + "description": "Maximum number of users to return (default: 100)", + "default": 100, + }, + "page": { + "type": "integer", + "description": "Page number for pagination (default: 1)", + "default": 1, + }, }, - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="get_user_by_id", - description="Get specific user details by ID", + description=( + "Get the details of exactly one user by numeric ID. " + "Use this only when you already know the internal Dolibarr user_id. " + "Do not pass login, email or name here." + ), inputSchema={ "type": "object", "properties": { - "user_id": {"type": "integer", "description": "User ID to retrieve"} + "user_id": { + "type": "integer", + "description": "Exact numeric Dolibarr user ID (not login, not email).", + } }, "required": ["user_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="create_user", @@ -81,11 +194,15 @@ async def handle_list_tools(): "firstname": {"type": "string", "description": "First name"}, "email": {"type": "string", "description": "Email address"}, "password": {"type": "string", "description": "Password"}, - "admin": {"type": "integer", "description": "Admin level (0=No, 1=Yes)", "default": 0} + "admin": { + "type": "integer", + "description": "Admin level (0=No, 1=Yes)", + "default": 0, + }, }, "required": ["login", "lastname"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="update_user", @@ -98,11 +215,14 @@ async def handle_list_tools(): "lastname": {"type": "string", "description": "Last name"}, "firstname": {"type": "string", "description": "First name"}, "email": {"type": "string", "description": "Email address"}, - "admin": {"type": "integer", "description": "Admin level (0=No, 1=Yes)"} + "admin": { + "type": "integer", + "description": "Admin level (0=No, 1=Yes)", + }, }, "required": ["user_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="delete_user", @@ -113,34 +233,53 @@ async def handle_list_tools(): "user_id": {"type": "integer", "description": "User ID to delete"} }, "required": ["user_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), - + # Customer/Third Party Management CRUD Tool( name="get_customers", - description="Get list of customers/third parties from Dolibarr", + description=( + "Get an unfiltered paginated list of customers/third parties from Dolibarr. " + "Intended for debugging or browsing only. DO NOT use this tool to search by name or alias " + "(use the dedicated search_* tools such as search_customers instead)." + ), inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Maximum number of customers to return (default: 100)", "default": 100}, - "page": {"type": "integer", "description": "Page number for pagination (default: 1)", "default": 1} + "limit": { + "type": "integer", + "description": "Maximum number of customers to return (default: 100)", + "default": 100, + }, + "page": { + "type": "integer", + "description": "Page number for pagination (default: 1)", + "default": 1, + }, }, - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="get_customer_by_id", - description="Get specific customer details by ID", + description=( + "Get the details of exactly one customer by numeric ID. " + "Use this only when you already know the internal Dolibarr customer_id. " + "Do not pass name or email here." + ), inputSchema={ "type": "object", "properties": { - "customer_id": {"type": "integer", "description": "Customer ID to retrieve"} + "customer_id": { + "type": "integer", + "description": "Exact numeric Dolibarr customer ID (not name).", + } }, "required": ["customer_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="create_customer", @@ -154,13 +293,25 @@ async def handle_list_tools(): "address": {"type": "string", "description": "Customer address"}, "town": {"type": "string", "description": "City/Town"}, "zip": {"type": "string", "description": "Postal code"}, - "country_id": {"type": "integer", "description": "Country ID (default: 1)", "default": 1}, - "type": {"type": "integer", "description": "Customer type (1=Customer, 2=Supplier, 3=Both)", "default": 1}, - "status": {"type": "integer", "description": "Status (1=Active, 0=Inactive)", "default": 1} + "country_id": { + "type": "integer", + "description": "Country ID (default: 1)", + "default": 1, + }, + "type": { + "type": "integer", + "description": "Customer type (1=Customer, 2=Supplier, 3=Both)", + "default": 1, + }, + "status": { + "type": "integer", + "description": "Status (1=Active, 0=Inactive)", + "default": 1, + }, }, "required": ["name"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="update_customer", @@ -168,18 +319,24 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "customer_id": {"type": "integer", "description": "Customer ID to update"}, + "customer_id": { + "type": "integer", + "description": "Customer ID to update", + }, "name": {"type": "string", "description": "Customer name"}, "email": {"type": "string", "description": "Email address"}, "phone": {"type": "string", "description": "Phone number"}, "address": {"type": "string", "description": "Customer address"}, "town": {"type": "string", "description": "City/Town"}, "zip": {"type": "string", "description": "Postal code"}, - "status": {"type": "integer", "description": "Status (1=Active, 0=Inactive)"} + "status": { + "type": "integer", + "description": "Status (1=Active, 0=Inactive)", + }, }, "required": ["customer_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="delete_customer", @@ -187,36 +344,54 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "customer_id": {"type": "integer", "description": "Customer ID to delete"} + "customer_id": { + "type": "integer", + "description": "Customer ID to delete", + } }, "required": ["customer_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), - + # Product Management CRUD Tool( name="get_products", - description="Get list of products from Dolibarr", + description=( + "Get an unfiltered list of products from Dolibarr. " + "Intended for debugging or bulk inspection only. DO NOT use this tool to search by reference or label " + "(use search_products_by_ref or search_products_by_label instead)." + ), inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Maximum number of products to return (default: 100)", "default": 100} + "limit": { + "type": "integer", + "description": "Maximum number of products to return (default: 100)", + "default": 100, + } }, - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="get_product_by_id", - description="Get specific product details by ID", + description=( + "Get the details of exactly one product by numeric ID. " + "Use this only when you already know the internal Dolibarr product_id. " + "Do not pass reference or label here." + ), inputSchema={ "type": "object", "properties": { - "product_id": {"type": "integer", "description": "Product ID to retrieve"} + "product_id": { + "type": "integer", + "description": "Exact numeric Dolibarr product ID (not ref).", + } }, "required": ["product_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="create_product", @@ -227,11 +402,14 @@ async def handle_list_tools(): "label": {"type": "string", "description": "Product name/label"}, "price": {"type": "number", "description": "Product price"}, "description": {"type": "string", "description": "Product description"}, - "stock": {"type": "integer", "description": "Initial stock quantity"} + "stock": { + "type": "integer", + "description": "Initial stock quantity", + }, }, "required": ["label", "price"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="update_product", @@ -239,14 +417,20 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "product_id": {"type": "integer", "description": "Product ID to update"}, + "product_id": { + "type": "integer", + "description": "Product ID to update", + }, "label": {"type": "string", "description": "Product name/label"}, "price": {"type": "number", "description": "Product price"}, - "description": {"type": "string", "description": "Product description"} + "description": { + "type": "string", + "description": "Product description", + }, }, "required": ["product_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="delete_product", @@ -254,67 +438,124 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "product_id": {"type": "integer", "description": "Product ID to delete"} + "product_id": { + "type": "integer", + "description": "Product ID to delete", + } }, "required": ["product_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), - + # Invoice Management CRUD Tool( name="get_invoices", - description="Get list of invoices from Dolibarr", + description=( + "Get a paginated list of invoices from Dolibarr, optionally filtered by status. " + "Use this only if you really need a list of many invoices (e.g. overviews, reports). " + "Do not use this as a search-by-customer or search-by-reference tool." + ), inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Maximum number of invoices to return (default: 100)", "default": 100}, - "status": {"type": "string", "description": "Invoice status filter (draft, unpaid, paid, etc.)"} + "limit": { + "type": "integer", + "description": "Maximum number of invoices to return (default: 100)", + "default": 100, + }, + "status": { + "type": "string", + "description": "Invoice status filter (draft, unpaid, paid, etc.)", + }, }, - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="get_invoice_by_id", - description="Get specific invoice details by ID", + description=( + "Get the details of exactly one invoice by numeric ID. " + "Use this only when you already know the internal Dolibarr invoice_id. " + "Do not pass invoice reference here." + ), inputSchema={ "type": "object", "properties": { - "invoice_id": {"type": "integer", "description": "Invoice ID to retrieve"} + "invoice_id": { + "type": "integer", + "description": "Exact numeric Dolibarr invoice ID.", + } }, "required": ["invoice_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="create_invoice", - description="Create a new invoice", + description=( + "ALWAYS creates a new invoice. Do not use this tool to modify an existing invoice. " + "Before calling this, resolve the correct customer and product IDs using the appropriate search_* tools " + "(e.g. search_customers, search_products_by_ref, resolve_product_ref). " + "For lines: Use product_id for existing products whenever possible and set product_type=0 for goods " + "and product_type=1 for services. Use free-text lines only if no matching product exists in Dolibarr." + ), inputSchema={ "type": "object", "properties": { - "customer_id": {"type": "integer", "description": "Customer ID (socid)"}, - "date": {"type": "string", "description": "Invoice date (YYYY-MM-DD)"}, - "due_date": {"type": "string", "description": "Due date (YYYY-MM-DD)"}, + "customer_id": { + "type": "integer", + "description": "Customer ID (Dolibarr socid of the third party to invoice)", + }, + "date": { + "type": "string", + "description": "Invoice date (YYYY-MM-DD)", + }, + "due_date": { + "type": "string", + "description": "Due date (YYYY-MM-DD)", + }, "lines": { "type": "array", "description": "Invoice lines", "items": { "type": "object", "properties": { - "desc": {"type": "string", "description": "Line description"}, + "desc": { + "type": "string", + "description": "Line description", + }, "qty": {"type": "number", "description": "Quantity"}, - "subprice": {"type": "number", "description": "Unit price"}, - "total_ht": {"type": "number", "description": "Total excluding tax"}, - "total_ttc": {"type": "number", "description": "Total including tax"}, - "vat": {"type": "number", "description": "VAT rate"} + "subprice": { + "type": "number", + "description": "Unit price", + }, + "total_ht": { + "type": "number", + "description": "Total excluding tax", + }, + "total_ttc": { + "type": "number", + "description": "Total including tax", + }, + "vat": {"type": "number", "description": "VAT rate"}, + "product_id": { + "type": "integer", + "description": "Product ID to link (optional)", + }, + "product_type": { + "type": "integer", + "description": "Type of line (0=Product, 1=Service)", + }, }, - "required": ["desc", "qty", "subprice"] - } - } + "required": ["desc", "qty", "subprice"], + "additionalProperties": False, + }, + }, }, "required": ["customer_id", "lines"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="update_invoice", @@ -322,13 +563,22 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "invoice_id": {"type": "integer", "description": "Invoice ID to update"}, - "date": {"type": "string", "description": "Invoice date (YYYY-MM-DD)"}, - "due_date": {"type": "string", "description": "Due date (YYYY-MM-DD)"} + "invoice_id": { + "type": "integer", + "description": "Invoice ID to update", + }, + "date": { + "type": "string", + "description": "Invoice date (YYYY-MM-DD)", + }, + "due_date": { + "type": "string", + "description": "Due date (YYYY-MM-DD)", + }, }, "required": ["invoice_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="delete_invoice", @@ -336,50 +586,241 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "invoice_id": {"type": "integer", "description": "Invoice ID to delete"} + "invoice_id": { + "type": "integer", + "description": "Invoice ID to delete", + } }, "required": ["invoice_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), - - # Order Management CRUD + Tool( - name="get_orders", - description="Get list of orders from Dolibarr", + name="create_invoice_draft", + description=( + "Create a new invoice draft (header only). " + "Use this to start a new invoice, then use add_invoice_line to add items. " + "Returns the new invoice_id." + ), inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Maximum number of orders to return (default: 100)", "default": 100}, - "status": {"type": "string", "description": "Order status filter"} + "customer_id": { + "type": "integer", + "description": "Customer ID (Dolibarr socid)", + }, + "date": { + "type": "string", + "description": "Invoice date (YYYY-MM-DD)", + }, + "project_id": { + "type": "integer", + "description": "Linked project ID (optional)", + }, }, - "additionalProperties": False - } + "required": ["customer_id", "date"], + "additionalProperties": False, + }, + ), + Tool( + name="add_invoice_line", + description="Add a line item to an existing draft invoice.", + inputSchema={ + "type": "object", + "properties": { + "invoice_id": { + "type": "integer", + "description": "Invoice ID", + }, + "desc": { + "type": "string", + "description": "Line description", + }, + "qty": { + "type": "number", + "description": "Quantity", + }, + "subprice": { + "type": "number", + "description": "Unit price (net)", + }, + "product_id": { + "type": "integer", + "description": "Product ID (optional)", + }, + "product_type": { + "type": "integer", + "description": "Type (0=Product, 1=Service)", + "default": 0, + }, + "vat": { + "type": "number", + "description": "VAT rate (optional)", + }, + }, + "required": ["invoice_id", "desc", "qty", "subprice"], + "additionalProperties": False, + }, + ), + Tool( + name="update_invoice_line", + description="Update an existing line in a draft invoice.", + inputSchema={ + "type": "object", + "properties": { + "invoice_id": { + "type": "integer", + "description": "Invoice ID", + }, + "line_id": { + "type": "integer", + "description": "Line ID to update", + }, + "desc": { + "type": "string", + "description": "New description", + }, + "qty": { + "type": "number", + "description": "New quantity", + }, + "subprice": { + "type": "number", + "description": "New unit price", + }, + "vat": { + "type": "number", + "description": "New VAT rate", + }, + }, + "required": ["invoice_id", "line_id"], + "additionalProperties": False, + }, + ), + Tool( + name="delete_invoice_line", + description="Delete a line from a draft invoice.", + inputSchema={ + "type": "object", + "properties": { + "invoice_id": { + "type": "integer", + "description": "Invoice ID", + }, + "line_id": { + "type": "integer", + "description": "Line ID to delete", + }, + }, + "required": ["invoice_id", "line_id"], + "additionalProperties": False, + }, + ), + Tool( + name="set_invoice_project", + description="Link an invoice to a project.", + inputSchema={ + "type": "object", + "properties": { + "invoice_id": { + "type": "integer", + "description": "Invoice ID", + }, + "project_id": { + "type": "integer", + "description": "Project ID", + }, + }, + "required": ["invoice_id", "project_id"], + "additionalProperties": False, + }, + ), + Tool( + name="validate_invoice", + description="Validate a draft invoice (change status to unpaid).", + inputSchema={ + "type": "object", + "properties": { + "invoice_id": { + "type": "integer", + "description": "Invoice ID", + }, + "warehouse_id": { + "type": "integer", + "description": "Warehouse ID for stock decrease (optional)", + "default": 0, + }, + }, + "required": ["invoice_id"], + "additionalProperties": False, + }, + ), + + # Order Management CRUD + Tool( + name="get_orders", + description=( + "Get a paginated list of orders from Dolibarr, optionally filtered by status. " + "Use this for overviews or reporting. Not suitable for searching specific orders by customer, project " + "or reference (there is no server-side search here)." + ), + inputSchema={ + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Maximum number of orders to return (default: 100)", + "default": 100, + }, + "status": { + "type": "string", + "description": "Order status filter", + }, + }, + "additionalProperties": False, + }, ), Tool( name="get_order_by_id", - description="Get specific order details by ID", + description=( + "Get the details of exactly one order by numeric ID. " + "Use this only when you already know the internal Dolibarr order_id. " + "Do not pass order reference here." + ), inputSchema={ "type": "object", "properties": { - "order_id": {"type": "integer", "description": "Order ID to retrieve"} + "order_id": { + "type": "integer", + "description": "Exact numeric Dolibarr order ID.", + } }, "required": ["order_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="create_order", - description="Create a new order", + description=( + "Create a new customer order. Use this only when you have already resolved the correct customer " + "ID (socid) using search_customers or related tools. This tool does not update existing orders." + ), inputSchema={ "type": "object", "properties": { - "customer_id": {"type": "integer", "description": "Customer ID (socid)"}, - "date": {"type": "string", "description": "Order date (YYYY-MM-DD)"} + "customer_id": { + "type": "integer", + "description": "Customer ID (socid)", + }, + "date": { + "type": "string", + "description": "Order date (YYYY-MM-DD)", + }, }, "required": ["customer_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="update_order", @@ -387,12 +828,18 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "order_id": {"type": "integer", "description": "Order ID to update"}, - "date": {"type": "string", "description": "Order date (YYYY-MM-DD)"} + "order_id": { + "type": "integer", + "description": "Order ID to update", + }, + "date": { + "type": "string", + "description": "Order date (YYYY-MM-DD)", + }, }, "required": ["order_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="delete_order", @@ -400,36 +847,54 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "order_id": {"type": "integer", "description": "Order ID to delete"} + "order_id": { + "type": "integer", + "description": "Order ID to delete", + } }, "required": ["order_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), - + # Contact Management CRUD Tool( name="get_contacts", - description="Get list of contacts from Dolibarr", + description=( + "Get a paginated list of contacts from Dolibarr. " + "Use this only if you need a generic list of contacts. " + "Do not treat this as a name search; if you need search-by-name, a dedicated search tool should be used." + ), inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Maximum number of contacts to return (default: 100)", "default": 100} + "limit": { + "type": "integer", + "description": "Maximum number of contacts to return (default: 100)", + "default": 100, + } }, - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="get_contact_by_id", - description="Get specific contact details by ID", + description=( + "Get the details of exactly one contact by numeric ID. " + "Use this only when you already know the internal Dolibarr contact_id. " + "Do not pass name or email here." + ), inputSchema={ "type": "object", "properties": { - "contact_id": {"type": "integer", "description": "Contact ID to retrieve"} + "contact_id": { + "type": "integer", + "description": "Exact numeric Dolibarr contact ID.", + } }, "required": ["contact_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="create_contact", @@ -441,11 +906,14 @@ async def handle_list_tools(): "lastname": {"type": "string", "description": "Last name"}, "email": {"type": "string", "description": "Email address"}, "phone": {"type": "string", "description": "Phone number"}, - "socid": {"type": "integer", "description": "Associated company ID"} + "socid": { + "type": "integer", + "description": "Associated company ID (thirdparty socid)", + }, }, "required": ["firstname", "lastname"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="update_contact", @@ -453,15 +921,18 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "contact_id": {"type": "integer", "description": "Contact ID to update"}, + "contact_id": { + "type": "integer", + "description": "Contact ID to update", + }, "firstname": {"type": "string", "description": "First name"}, "lastname": {"type": "string", "description": "Last name"}, "email": {"type": "string", "description": "Email address"}, - "phone": {"type": "string", "description": "Phone number"} + "phone": {"type": "string", "description": "Phone number"}, }, "required": ["contact_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), Tool( name="delete_contact", @@ -469,29 +940,191 @@ async def handle_list_tools(): inputSchema={ "type": "object", "properties": { - "contact_id": {"type": "integer", "description": "Contact ID to delete"} + "contact_id": { + "type": "integer", + "description": "Contact ID to delete", + } }, "required": ["contact_id"], - "additionalProperties": False - } + "additionalProperties": False, + }, ), - - # Raw API Access + + # Project Management CRUD Tool( - name="dolibarr_raw_api", - description="Make raw API call to any Dolibarr endpoint", + name="get_projects", + description=( + "Get a paginated list of projects from Dolibarr, optionally filtered by status. " + "Use this for overviews or when you need to iterate through project pages. " + "Do not use this to search for a project by name or reference (use search_projects instead)." + ), inputSchema={ "type": "object", "properties": { - "method": {"type": "string", "description": "HTTP method", "enum": ["GET", "POST", "PUT", "DELETE"]}, - "endpoint": {"type": "string", "description": "API endpoint (e.g., /thirdparties, /invoices)"}, - "params": {"type": "object", "description": "Query parameters"}, - "data": {"type": "object", "description": "Request payload for POST/PUT requests"} + "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 the details of exactly one project by numeric ID. " + "Use this only when you already know the internal Dolibarr project_id. " + "Do not pass project reference here." + ), + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "integer", + "description": "Exact numeric Dolibarr project ID.", + } + }, + "required": ["project_id"], + "additionalProperties": False, + }, + ), + Tool( + name="search_projects", + description=( + "Search projects by reference or title. Use this when you have a partial or full project ref/title " + "and need to find matching projects without loading full project lists." + ), + 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( + name="dolibarr_raw_api", + description=( + "Low-level escape hatch to call any Dolibarr REST endpoint directly. " + "Use this ONLY if there is no dedicated high-level tool available for your use case. " + "You must pass a valid Dolibarr API path and parameters yourself; the server does not validate them. " + "Incorrect usage can cause errors or side effects (such as creating or deleting unexpected data)." + ), + inputSchema={ + "type": "object", + "properties": { + "method": { + "type": "string", + "description": "HTTP method", + "enum": ["GET", "POST", "PUT", "DELETE"], + }, + "endpoint": { + "type": "string", + "description": "Dolibarr API endpoint path (e.g. '/thirdparties', '/invoices/123'). Must be a valid existing endpoint.", + }, + "params": { + "type": "object", + "description": "Query parameters", + }, + "data": { + "type": "object", + "description": "Request payload for POST/PUT requests", + }, }, "required": ["method", "endpoint"], - "additionalProperties": False - } - ) + "additionalProperties": False, + }, + ), ] @@ -514,6 +1147,43 @@ async def handle_call_tool(name: str, arguments: dict): elif name == "get_status": result = await client.get_status() + # Search Tools + elif name == "search_products_by_ref": + ref_prefix = _escape_sqlfilter(arguments['ref_prefix']) + limit = arguments.get('limit', 20) + sqlfilters = f"(t.ref:like:'{ref_prefix}%')" + result = await client.search_products(sqlfilters=sqlfilters, limit=limit) + + elif name == "search_customers": + query = _escape_sqlfilter(arguments['query']) + limit = arguments.get('limit', 20) + sqlfilters = f"((t.nom:like:'%{query}%') OR (t.name_alias:like:'%{query}%'))" + result = await client.search_customers(sqlfilters=sqlfilters, limit=limit) + + elif name == "search_products_by_label": + label_search = _escape_sqlfilter(arguments['label_search']) + limit = arguments.get('limit', 20) + sqlfilters = f"(t.label:like:'%{label_search}%')" + result = await client.search_products(sqlfilters=sqlfilters, limit=limit) + + elif name == "resolve_product_ref": + ref = arguments['ref'] + ref_esc = _escape_sqlfilter(ref) + sqlfilters = f"(t.ref:like:'{ref_esc}')" + products = await client.search_products(sqlfilters=sqlfilters, limit=2) + + if not products: + result = {"status": "not_found", "message": f"Product with ref '{ref}' not found"} + elif len(products) == 1: + result = {"status": "ok", "product": products[0]} + else: + # Check if one is exact match + exact_matches = [p for p in products if p.get('ref') == ref] + if len(exact_matches) == 1: + result = {"status": "ok", "product": exact_matches[0]} + else: + result = {"status": "ambiguous", "message": f"Multiple products found for ref '{ref}'", "products": products} + # User Management elif name == "get_users": result = await client.get_users( @@ -590,6 +1260,40 @@ async def handle_call_tool(name: str, arguments: dict): elif name == "delete_invoice": result = await client.delete_invoice(arguments['invoice_id']) + + elif name == "create_invoice_draft": + # Map customer_id to socid for the API + if "customer_id" in arguments: + arguments["socid"] = arguments.pop("customer_id") + + # Map project_id to fk_project if present + if "project_id" in arguments: + arguments["fk_project"] = arguments.pop("project_id") + + result = await client.create_invoice(**arguments) + + elif name == "add_invoice_line": + invoice_id = arguments.pop("invoice_id") + result = await client.add_invoice_line(invoice_id, **arguments) + + elif name == "update_invoice_line": + invoice_id = arguments.pop("invoice_id") + line_id = arguments.pop("line_id") + result = await client.update_invoice_line(invoice_id, line_id, **arguments) + + elif name == "delete_invoice_line": + invoice_id = arguments.pop("invoice_id") + line_id = arguments.pop("line_id") + result = await client.delete_invoice_line(invoice_id, line_id) + + elif name == "set_invoice_project": + invoice_id = arguments.pop("invoice_id") + project_id = arguments.pop("project_id") + result = await client.update_invoice(invoice_id, fk_project=project_id) + + elif name == "validate_invoice": + invoice_id = arguments.pop("invoice_id") + result = await client.validate_invoice(invoice_id, **arguments) # Order Management elif name == "get_orders": @@ -628,6 +1332,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_crud_operations.py b/tests/test_crud_operations.py index fde7a5a..7724087 100644 --- a/tests/test_crud_operations.py +++ b/tests/test_crud_operations.py @@ -140,6 +140,52 @@ class TestCRUDOperations: mock_request.return_value = {"success": True} result = await client.delete_invoice(100) assert result["success"] is True + + @pytest.mark.asyncio + async def test_invoice_creation_with_product_lines(self, client): + """Test invoice creation with linked product lines.""" + with patch.object(client, 'request') as mock_request: + mock_request.return_value = {"id": 101} + + # Create invoice with product link and service type + await client.create_invoice({ + "socid": 1, + "lines": [ + { + "desc": "Linked Product", + "qty": 1, + "subprice": 50, + "product_id": 10, + "product_type": 0 + }, + { + "desc": "Service Line", + "qty": 2, + "subprice": 100, + "product_type": 1 + } + ] + }) + + # Verify payload transformation + call_args = mock_request.call_args + assert call_args is not None + method, endpoint = call_args[0] + kwargs = call_args[1] + + assert method == "POST" + assert endpoint == "invoices" + + payload = kwargs["data"] + lines = payload["lines"] + + # Check first line (Product) + assert lines[0]["fk_product"] == 10 + assert "product_id" not in lines[0] + assert lines[0]["product_type"] == 0 + + # Check second line (Service) + assert lines[1]["product_type"] == 1 # Order CRUD Tests diff --git a/tests/test_invoice_atomic.py b/tests/test_invoice_atomic.py new file mode 100644 index 0000000..902bd2c --- /dev/null +++ b/tests/test_invoice_atomic.py @@ -0,0 +1,90 @@ +import pytest +from unittest.mock import AsyncMock, patch +from dolibarr_mcp.config import Config +from dolibarr_mcp.dolibarr_client import DolibarrClient + +@pytest.mark.asyncio +class TestInvoiceAtomic: + + @pytest.fixture + def client(self): + config = Config( + dolibarr_url="https://test.dolibarr.com/api/index.php", + api_key="test_key" + ) + return DolibarrClient(config) + + @patch('aiohttp.ClientSession.request') + async def test_add_invoice_line(self, mock_request, client): + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text.return_value = '123' # Returns line ID usually + mock_request.return_value.__aenter__.return_value = mock_response + + async with client: + await client.add_invoice_line( + invoice_id=1, + desc="Test Line", + qty=1, + subprice=100, + product_id=99 + ) + + # Verify call + args, kwargs = mock_request.call_args + assert args[0] == "POST" + assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/lines" + assert kwargs['json'] == { + "desc": "Test Line", + "qty": 1, + "subprice": 100, + "fk_product": 99 + } + + @patch('aiohttp.ClientSession.request') + async def test_update_invoice_line(self, mock_request, client): + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text.return_value = '{"success": 1}' + mock_request.return_value.__aenter__.return_value = mock_response + + async with client: + await client.update_invoice_line( + invoice_id=1, + line_id=10, + qty=5 + ) + + args, kwargs = mock_request.call_args + assert args[0] == "PUT" + assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/lines/10" + assert kwargs['json'] == {"qty": 5} + + @patch('aiohttp.ClientSession.request') + async def test_delete_invoice_line(self, mock_request, client): + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text.return_value = '{"success": 1}' + mock_request.return_value.__aenter__.return_value = mock_response + + async with client: + await client.delete_invoice_line(invoice_id=1, line_id=10) + + args, kwargs = mock_request.call_args + assert args[0] == "DELETE" + assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/lines/10" + + @patch('aiohttp.ClientSession.request') + async def test_validate_invoice(self, mock_request, client): + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text.return_value = '{"success": 1}' + mock_request.return_value.__aenter__.return_value = mock_response + + async with client: + await client.validate_invoice(invoice_id=1, warehouse_id=5) + + args, kwargs = mock_request.call_args + assert args[0] == "POST" + assert args[1] == "https://test.dolibarr.com/api/index.php/invoices/1/validate" + assert kwargs['json'] == {"idwarehouse": 5, "not_trigger": 0} 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 diff --git a/tests/test_search_tools.py b/tests/test_search_tools.py new file mode 100644 index 0000000..6f86f46 --- /dev/null +++ b/tests/test_search_tools.py @@ -0,0 +1,79 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from dolibarr_mcp.dolibarr_mcp_server import handle_call_tool +from dolibarr_mcp.dolibarr_client import DolibarrClient + +@pytest.mark.asyncio +async def test_search_products_by_ref(): + # Mock DolibarrClient + with patch("dolibarr_mcp.dolibarr_mcp_server.DolibarrClient") as MockClient: + mock_instance = MockClient.return_value + mock_instance.__aenter__.return_value = mock_instance + + # Mock search_products response + mock_instance.search_products = AsyncMock(return_value=[ + {"id": 1, "ref": "PRJ-123", "label": "Project 123"} + ]) + + # Call the tool + result = await handle_call_tool("search_products_by_ref", {"ref_prefix": "PRJ"}) + + # Verify the call + mock_instance.search_products.assert_called_once() + call_args = mock_instance.search_products.call_args + assert "sqlfilters" in call_args.kwargs + assert call_args.kwargs["sqlfilters"] == "(t.ref:like:'PRJ%')" + + # Verify result + assert "PRJ-123" in result[0].text + +@pytest.mark.asyncio +async def test_resolve_product_ref_exact(): + with patch("dolibarr_mcp.dolibarr_mcp_server.DolibarrClient") as MockClient: + mock_instance = MockClient.return_value + mock_instance.__aenter__.return_value = mock_instance + + # Mock search_products response (exact match) + mock_instance.search_products = AsyncMock(return_value=[ + {"id": 1, "ref": "PRJ-123", "label": "Project 123"} + ]) + + result = await handle_call_tool("resolve_product_ref", {"ref": "PRJ-123"}) + + mock_instance.search_products.assert_called_once() + assert "ok" in result[0].text + assert "PRJ-123" in result[0].text + +@pytest.mark.asyncio +async def test_resolve_product_ref_ambiguous(): + with patch("dolibarr_mcp.dolibarr_mcp_server.DolibarrClient") as MockClient: + mock_instance = MockClient.return_value + mock_instance.__aenter__.return_value = mock_instance + + # Mock search_products response (multiple matches, none exact) + mock_instance.search_products = AsyncMock(return_value=[ + {"id": 1, "ref": "PRJ-123-A", "label": "Project 123 A"}, + {"id": 2, "ref": "PRJ-123-B", "label": "Project 123 B"} + ]) + + # Search for "PRJ-123" which matches both partially (hypothetically) but neither exactly + result = await handle_call_tool("resolve_product_ref", {"ref": "PRJ-123"}) + + assert "ambiguous" in result[0].text + +@pytest.mark.asyncio +async def test_search_customers(): + with patch("dolibarr_mcp.dolibarr_mcp_server.DolibarrClient") as MockClient: + mock_instance = MockClient.return_value + mock_instance.__aenter__.return_value = mock_instance + + mock_instance.search_customers = AsyncMock(return_value=[ + {"id": 1, "nom": "Acme Corp"} + ]) + + result = await handle_call_tool("search_customers", {"query": "Acme"}) + + mock_instance.search_customers.assert_called_once() + call_args = mock_instance.search_customers.call_args + assert "sqlfilters" in call_args.kwargs + assert "Acme" in call_args.kwargs["sqlfilters"]