mirror of
https://github.com/latinogino/dolibarr-mcp.git
synced 2026-05-01 05:45:35 +02:00
Improve validation, correlation IDs, and client safeguards
This commit is contained in:
@@ -69,17 +69,92 @@ empty lists until records are created.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Common Error Shape
|
### Structured Error Shapes
|
||||||
|
|
||||||
|
The MCP server and wrapper normalize Dolibarr errors into predictable JSON so
|
||||||
|
callers can surface actionable messages and debugging breadcrumbs.
|
||||||
|
|
||||||
|
#### Validation errors (`400`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": {
|
"error": "Bad Request",
|
||||||
"code": 404,
|
"status": 400,
|
||||||
"message": "Not found"
|
"message": "Validation failed",
|
||||||
}
|
"missing_fields": ["ref", "socid"],
|
||||||
|
"invalid_fields": [
|
||||||
|
{ "field": "price", "message": "must be a positive number" }
|
||||||
|
],
|
||||||
|
"endpoint": "/api/index.php/products",
|
||||||
|
"timestamp": "2026-01-02T12:34:56Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Dolibarr communicates detailed failure information in the `error` object. The
|
#### Unexpected errors (`500`)
|
||||||
client wrapper turns these payloads into Python exceptions with the same
|
|
||||||
metadata so MCP hosts can display friendly error messages.
|
```json
|
||||||
|
{
|
||||||
|
"error": "Internal Server Error",
|
||||||
|
"status": 500,
|
||||||
|
"message": "An unexpected error occurred while creating project",
|
||||||
|
"correlation_id": "abc123-uuid",
|
||||||
|
"endpoint": "/api/index.php/projects",
|
||||||
|
"timestamp": "2026-01-02T12:35:06Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Successful create
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 201,
|
||||||
|
"id": 42,
|
||||||
|
"ref": "FREELANCE_HOUR_TEST",
|
||||||
|
"message": "Product created"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Include the `correlation_id` from a 500 response when opening support tickets
|
||||||
|
so logs can be located quickly.
|
||||||
|
|
||||||
|
### Create Endpoint Requirements & Examples
|
||||||
|
|
||||||
|
The wrapper validates payloads before sending them to Dolibarr. Required fields:
|
||||||
|
|
||||||
|
| Endpoint | Required fields | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /products` | `ref`, `label`, `type`, `price` | `type` accepts `product`/`0` or `service`/`1`. |
|
||||||
|
| `POST /projects` | `ref`, `name`, `socid` | `title` is accepted as an alias for `name`. |
|
||||||
|
| `POST /invoices` | `socid` | Provide `lines` for invoice content. |
|
||||||
|
| `POST /time` (timesheets) | `ref`, `task_id`, `duration`, `fk_project` | Provide `ref` or enable auto-generation. |
|
||||||
|
|
||||||
|
#### Example: product create without `ref` (expected 400)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://dolibarr.example/api/index.php/products" \
|
||||||
|
-H "DOLAPIKEY: <REDACTED>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"label":"Freelance hourly rate test — Gino test","type":"service","price":110.00,"tva_tx":19.0}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example: corrected product payload
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://dolibarr.example/api/index.php/products" \
|
||||||
|
-H "DOLAPIKEY: <REDACTED>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ref":"FREELANCE_HOUR_TEST","label":"Freelance hourly rate test — Gino test","type":"service","price":110.00,"tva_tx":19.0}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example: project create (minimal payload)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://dolibarr.example/api/index.php/projects" \
|
||||||
|
-H "DOLAPIKEY: <REDACTED>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ref":"PERCISION_TEST_PROJECT","name":"Percision test — Project test","socid":8}'
|
||||||
|
```
|
||||||
|
|
||||||
|
If server-side reference auto-generation is enabled, omitting `ref` results in a
|
||||||
|
predictable `AUTO_<timestamp>` reference. Otherwise, the wrapper will raise a
|
||||||
|
client-side validation error before sending the request.
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ host application that will launch the server.
|
|||||||
| `DOLIBARR_URL` / `DOLIBARR_SHOP_URL` | Base API URL, e.g. `https://your-dolibarr.example.com/api/index.php` (legacy configs that still export `DOLIBARR_BASE_URL` are also honoured). |
|
| `DOLIBARR_URL` / `DOLIBARR_SHOP_URL` | Base API URL, e.g. `https://your-dolibarr.example.com/api/index.php` (legacy configs that still export `DOLIBARR_BASE_URL` are also honoured). |
|
||||||
| `DOLIBARR_API_KEY` | Personal Dolibarr API token assigned to your user. |
|
| `DOLIBARR_API_KEY` | Personal Dolibarr API token assigned to your user. |
|
||||||
| `LOG_LEVEL` | Optional logging level (`INFO`, `DEBUG`, `WARNING`, …). |
|
| `LOG_LEVEL` | Optional logging level (`INFO`, `DEBUG`, `WARNING`, …). |
|
||||||
|
| `ALLOW_REF_AUTOGEN` | When `true`, the wrapper auto-generates missing `ref` values for create operations. |
|
||||||
|
| `REF_AUTOGEN_PREFIX` | Prefix used for generated references (default `AUTO`). |
|
||||||
|
| `DEBUG_MODE` | When `true`, request/response bodies are logged without secrets. |
|
||||||
|
| `MAX_RETRIES` | Retries for transient HTTP errors (default `2`). |
|
||||||
|
| `RETRY_BACKOFF_SECONDS` | Base backoff for retries (default `0.5`). |
|
||||||
|
|
||||||
## Example `.env`
|
## Example `.env`
|
||||||
|
|
||||||
@@ -16,6 +21,9 @@ host application that will launch the server.
|
|||||||
DOLIBARR_URL=https://your-dolibarr.example.com/api/index.php
|
DOLIBARR_URL=https://your-dolibarr.example.com/api/index.php
|
||||||
DOLIBARR_API_KEY=your_api_key
|
DOLIBARR_API_KEY=your_api_key
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
ALLOW_REF_AUTOGEN=true
|
||||||
|
REF_AUTOGEN_PREFIX=AUTO
|
||||||
|
DEBUG_MODE=false
|
||||||
```
|
```
|
||||||
|
|
||||||
The [`Config` class](../src/dolibarr_mcp/config.py) is built with
|
The [`Config` class](../src/dolibarr_mcp/config.py) is built with
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ The project intentionally avoids heavy linting dependencies. Follow the coding
|
|||||||
style already present in the repository and run the test-suite before opening a
|
style already present in the repository and run the test-suite before opening a
|
||||||
pull request.
|
pull request.
|
||||||
|
|
||||||
|
## Debugging 500 responses with correlation IDs
|
||||||
|
|
||||||
|
Unexpected Dolibarr API failures return a `correlation_id` in the JSON body.
|
||||||
|
Include this value when filing issues or investigating user reports.
|
||||||
|
|
||||||
|
1. Capture the `correlation_id` from the HTTP 500 response body.
|
||||||
|
2. Search the MCP server logs (stdout/stderr) for that ID to locate the full
|
||||||
|
stack trace. For docker users:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs <container> 2>&1 | grep "<correlation_id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. The log entry contains the failing endpoint and sanitized payload details.
|
||||||
|
Avoid logging `DOLAPIKEY` or other secrets in bug reports.
|
||||||
|
|
||||||
## Docker tooling
|
## Docker tooling
|
||||||
|
|
||||||
Container assets live in `docker/`:
|
Container assets live in `docker/`:
|
||||||
|
|||||||
@@ -53,6 +53,31 @@ class Config(BaseSettings):
|
|||||||
default=8080,
|
default=8080,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
allow_ref_autogen: bool = Field(
|
||||||
|
description="Allow automatic generation of reference fields when missing",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
ref_autogen_prefix: str = Field(
|
||||||
|
description="Prefix to use when auto-generating references",
|
||||||
|
default="AUTO",
|
||||||
|
)
|
||||||
|
|
||||||
|
debug_mode: bool = Field(
|
||||||
|
description="Enable verbose debug logging without exposing secrets",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
max_retries: int = Field(
|
||||||
|
description="Maximum retries for transient HTTP errors",
|
||||||
|
default=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_backoff_seconds: float = Field(
|
||||||
|
description="Base backoff (seconds) for retry strategy",
|
||||||
|
default=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("dolibarr_url")
|
@field_validator("dolibarr_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_dolibarr_url(cls, v: str) -> str:
|
def validate_dolibarr_url(cls, v: str) -> str:
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Professional Dolibarr API client with comprehensive CRUD operations."""
|
"""Professional Dolibarr API client with comprehensive CRUD operations."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import ClientSession, ClientTimeout
|
from aiohttp import ClientSession, ClientTimeout
|
||||||
@@ -20,6 +23,10 @@ class DolibarrAPIError(Exception):
|
|||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
|
class DolibarrValidationError(DolibarrAPIError):
|
||||||
|
"""Raised for client-side validation failures before hitting the API."""
|
||||||
|
|
||||||
|
|
||||||
class DolibarrClient:
|
class DolibarrClient:
|
||||||
"""Professional Dolibarr API client with comprehensive functionality."""
|
"""Professional Dolibarr API client with comprehensive functionality."""
|
||||||
|
|
||||||
@@ -30,9 +37,15 @@ class DolibarrClient:
|
|||||||
self.api_key = config.api_key
|
self.api_key = config.api_key
|
||||||
self.session: Optional[ClientSession] = None
|
self.session: Optional[ClientSession] = None
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.debug_mode = getattr(config, "debug_mode", False)
|
||||||
|
self.allow_ref_autogen = getattr(config, "allow_ref_autogen", False)
|
||||||
|
self.ref_autogen_prefix = getattr(config, "ref_autogen_prefix", "AUTO")
|
||||||
|
self.max_retries = getattr(config, "max_retries", 2)
|
||||||
|
self.retry_backoff_seconds = getattr(config, "retry_backoff_seconds", 0.5)
|
||||||
|
|
||||||
# Configure timeout
|
# Configure timeout
|
||||||
self.timeout = ClientTimeout(total=30, connect=10)
|
self.timeout = ClientTimeout(total=30, connect=10)
|
||||||
|
self.logger.setLevel(config.log_level)
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
"""Async context manager entry."""
|
"""Async context manager entry."""
|
||||||
@@ -104,6 +117,113 @@ class DolibarrClient:
|
|||||||
|
|
||||||
return f"{base}/{endpoint}"
|
return f"{base}/{endpoint}"
|
||||||
|
|
||||||
|
def _mask_api_key(self) -> str:
|
||||||
|
"""Return a masked representation of the API key for logging."""
|
||||||
|
if not self.api_key:
|
||||||
|
return "<not-set>"
|
||||||
|
if len(self.api_key) <= 6:
|
||||||
|
return "*" * len(self.api_key)
|
||||||
|
return f"{self.api_key[:2]}***{self.api_key[-2:]}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _now_iso() -> str:
|
||||||
|
"""Return current UTC timestamp in ISO format with Z suffix."""
|
||||||
|
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_correlation_id() -> str:
|
||||||
|
"""Create a unique correlation identifier."""
|
||||||
|
return str(uuid4())
|
||||||
|
|
||||||
|
def _build_validation_error(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
missing_fields: Optional[List[str]] = None,
|
||||||
|
invalid_fields: Optional[List[Dict[str, str]]] = None,
|
||||||
|
message: str = "Validation failed",
|
||||||
|
status: int = 400,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Build a structured validation error response."""
|
||||||
|
return {
|
||||||
|
"error": "Bad Request",
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"missing_fields": missing_fields or [],
|
||||||
|
"invalid_fields": invalid_fields or [],
|
||||||
|
"endpoint": f"/{endpoint.lstrip('/')}",
|
||||||
|
"timestamp": self._now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_internal_error(self, endpoint: str, message: str, correlation_id: str) -> Dict[str, Any]:
|
||||||
|
"""Build a structured internal server error response."""
|
||||||
|
return {
|
||||||
|
"error": "Internal Server Error",
|
||||||
|
"status": 500,
|
||||||
|
"message": message,
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"endpoint": f"/{endpoint.lstrip('/')}",
|
||||||
|
"timestamp": self._now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_aliases(self, payload: Dict[str, Any], aliases: Dict[str, List[str]]) -> None:
|
||||||
|
"""Promote alias fields to canonical names."""
|
||||||
|
for target, options in aliases.items():
|
||||||
|
if target not in payload:
|
||||||
|
for alias in options:
|
||||||
|
if alias in payload and payload[alias] not in (None, ""):
|
||||||
|
payload[target] = payload.pop(alias)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _validate_payload(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
required_fields: List[str],
|
||||||
|
aliases: Optional[Dict[str, List[str]]] = None,
|
||||||
|
numeric_positive: Optional[List[str]] = None,
|
||||||
|
enum_fields: Optional[Dict[str, List[Any]]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Validate payload before sending to Dolibarr and optionally auto-generate refs."""
|
||||||
|
aliases = aliases or {}
|
||||||
|
numeric_positive = numeric_positive or []
|
||||||
|
enum_fields = enum_fields or {}
|
||||||
|
|
||||||
|
self._apply_aliases(payload, aliases)
|
||||||
|
|
||||||
|
missing_fields = [
|
||||||
|
field
|
||||||
|
for field in required_fields
|
||||||
|
if field not in payload or payload[field] in (None, "")
|
||||||
|
]
|
||||||
|
|
||||||
|
invalid_fields: List[Dict[str, str]] = []
|
||||||
|
|
||||||
|
for field in numeric_positive:
|
||||||
|
if field in payload and isinstance(payload[field], (int, float)) and payload[field] < 0:
|
||||||
|
invalid_fields.append({"field": field, "message": "must be a positive number"})
|
||||||
|
|
||||||
|
for field, values in enum_fields.items():
|
||||||
|
if field in payload and payload[field] not in values:
|
||||||
|
invalid_fields.append({"field": field, "message": f"must be one of {values}"})
|
||||||
|
|
||||||
|
if "ref" in missing_fields and self.allow_ref_autogen:
|
||||||
|
payload["ref"] = f"{self.ref_autogen_prefix}_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
|
||||||
|
missing_fields = [f for f in missing_fields if f != "ref"]
|
||||||
|
|
||||||
|
if missing_fields or invalid_fields:
|
||||||
|
error_data = self._build_validation_error(
|
||||||
|
endpoint=endpoint,
|
||||||
|
missing_fields=missing_fields,
|
||||||
|
invalid_fields=invalid_fields,
|
||||||
|
)
|
||||||
|
raise DolibarrValidationError(
|
||||||
|
message=error_data["message"],
|
||||||
|
status_code=error_data["status"],
|
||||||
|
response_data=error_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
async def _make_request(
|
async def _make_request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -117,76 +237,155 @@ class DolibarrClient:
|
|||||||
|
|
||||||
url = self._build_url(endpoint)
|
url = self._build_url(endpoint)
|
||||||
|
|
||||||
try:
|
last_exception: Optional[Exception] = None
|
||||||
self.logger.debug(f"Making {method} request to {url}")
|
|
||||||
|
for attempt in range(self.max_retries + 1):
|
||||||
kwargs = {
|
try:
|
||||||
"params": params or {},
|
if self.debug_mode:
|
||||||
}
|
self.logger.debug(
|
||||||
|
"Making %s request to %s with params=%s payload_keys=%s api_key=%s",
|
||||||
if data and method.upper() in ["POST", "PUT"]:
|
method,
|
||||||
kwargs["json"] = data
|
url,
|
||||||
|
params or {},
|
||||||
async with self.session.request(method, url, **kwargs) as response:
|
list((data or {}).keys()),
|
||||||
response_text = await response.text()
|
self._mask_api_key(),
|
||||||
|
|
||||||
# Log response for debugging
|
|
||||||
self.logger.debug(f"Response status: {response.status}")
|
|
||||||
self.logger.debug(f"Response text: {response_text[:500]}...")
|
|
||||||
|
|
||||||
# Try to parse JSON response
|
|
||||||
try:
|
|
||||||
response_data = json.loads(response_text) if response_text else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
response_data = {"raw_response": response_text}
|
|
||||||
|
|
||||||
# Handle error responses
|
|
||||||
if response.status >= 400:
|
|
||||||
error_msg = f"HTTP {response.status}: {response.reason}"
|
|
||||||
if isinstance(response_data, dict):
|
|
||||||
if "error" in response_data:
|
|
||||||
error_details = response_data["error"]
|
|
||||||
if isinstance(error_details, dict):
|
|
||||||
error_msg = error_details.get("message", error_msg)
|
|
||||||
if "code" in error_details:
|
|
||||||
error_msg = f"{error_msg} (Code: {error_details['code']})"
|
|
||||||
else:
|
|
||||||
error_msg = str(error_details)
|
|
||||||
elif "message" in response_data:
|
|
||||||
error_msg = response_data["message"]
|
|
||||||
|
|
||||||
raise DolibarrAPIError(
|
|
||||||
message=error_msg,
|
|
||||||
status_code=response.status,
|
|
||||||
response_data=response_data
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return response_data
|
kwargs = {
|
||||||
|
"params": params or {},
|
||||||
|
}
|
||||||
|
|
||||||
except aiohttp.ClientError as e:
|
if data and method.upper() in ["POST", "PUT"]:
|
||||||
# For status endpoint, try alternative URL if first attempt fails
|
kwargs["json"] = data
|
||||||
if endpoint == "status" and not url.endswith("/api/status"):
|
|
||||||
try:
|
async with self.session.request(method, url, **kwargs) as response:
|
||||||
# Try with /api/index.php/setup/modules as alternative
|
response_text = await response.text()
|
||||||
alt_url = f"{self.base_url}/setup/modules"
|
|
||||||
self.logger.debug(f"Status failed, trying alternative: {alt_url}")
|
|
||||||
|
|
||||||
async with self.session.get(alt_url) as response:
|
# Log response for debugging without leaking secrets
|
||||||
if response.status == 200:
|
if self.debug_mode:
|
||||||
# Return a status-like response
|
self.logger.debug("Response status: %s", response.status)
|
||||||
return {
|
self.logger.debug("Response body (truncated): %s", response_text[:500])
|
||||||
"success": 1,
|
|
||||||
"dolibarr_version": "API Available",
|
# Try to parse JSON response
|
||||||
"api_version": "1.0"
|
try:
|
||||||
}
|
response_data = json.loads(response_text) if response_text else {}
|
||||||
except:
|
except json.JSONDecodeError:
|
||||||
pass
|
response_data = {"raw_response": response_text}
|
||||||
|
|
||||||
raise DolibarrAPIError(f"HTTP client error: {endpoint}")
|
# Handle error responses
|
||||||
except Exception as e:
|
if response.status >= 400:
|
||||||
if isinstance(e, DolibarrAPIError):
|
if response.status == 400:
|
||||||
|
missing = []
|
||||||
|
invalid: List[Dict[str, str]] = []
|
||||||
|
if isinstance(response_data, dict):
|
||||||
|
if "missing_fields" in response_data:
|
||||||
|
missing = response_data.get("missing_fields") or []
|
||||||
|
if "invalid_fields" in response_data:
|
||||||
|
invalid = response_data.get("invalid_fields") or []
|
||||||
|
# Heuristic: derive missing ref from message
|
||||||
|
if not missing and isinstance(response_data.get("error"), str):
|
||||||
|
if "ref" in response_data.get("error").lower():
|
||||||
|
missing.append("ref")
|
||||||
|
if not missing and "message" in response_data and "ref" in str(response_data["message"]).lower():
|
||||||
|
missing.append("ref")
|
||||||
|
error_data = self._build_validation_error(
|
||||||
|
endpoint=endpoint,
|
||||||
|
missing_fields=missing,
|
||||||
|
invalid_fields=invalid,
|
||||||
|
message="Validation failed",
|
||||||
|
)
|
||||||
|
raise DolibarrValidationError(
|
||||||
|
message=error_data["message"],
|
||||||
|
status_code=400,
|
||||||
|
response_data=error_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status >= 500:
|
||||||
|
correlation_id = self._generate_correlation_id()
|
||||||
|
internal_error = self._build_internal_error(
|
||||||
|
endpoint=endpoint,
|
||||||
|
message=response_data.get("message", f"An unexpected error occurred while processing {endpoint}"),
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
self.logger.error(
|
||||||
|
"Server error %s for %s (correlation_id=%s): %s",
|
||||||
|
response.status,
|
||||||
|
endpoint,
|
||||||
|
correlation_id,
|
||||||
|
response_text[:500],
|
||||||
|
)
|
||||||
|
raise DolibarrAPIError(
|
||||||
|
message=internal_error["message"],
|
||||||
|
status_code=response.status,
|
||||||
|
response_data=internal_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
error_msg = f"HTTP {response.status}: {response.reason}"
|
||||||
|
if isinstance(response_data, dict):
|
||||||
|
if "message" in response_data:
|
||||||
|
error_msg = response_data["message"]
|
||||||
|
elif "error" in response_data and isinstance(response_data["error"], str):
|
||||||
|
error_msg = response_data["error"]
|
||||||
|
raise DolibarrAPIError(
|
||||||
|
message=error_msg,
|
||||||
|
status_code=response.status,
|
||||||
|
response_data=response_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
last_exception = e
|
||||||
|
if endpoint == "status" and not url.endswith("/api/status"):
|
||||||
|
try:
|
||||||
|
alt_url = f"{self.base_url}/setup/modules"
|
||||||
|
self.logger.debug(f"Status failed, trying alternative: {alt_url}")
|
||||||
|
|
||||||
|
async with self.session.get(alt_url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return {
|
||||||
|
"success": 1,
|
||||||
|
"dolibarr_version": "API Available",
|
||||||
|
"api_version": "1.0"
|
||||||
|
}
|
||||||
|
except Exception as alt_exc: # pylint: disable=broad-except
|
||||||
|
last_exception = alt_exc
|
||||||
|
|
||||||
|
if attempt < self.max_retries and isinstance(e, aiohttp.ClientResponseError) and e.status in {502, 503, 504}:
|
||||||
|
backoff = self.retry_backoff_seconds * (2 ** attempt)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
except DolibarrAPIError:
|
||||||
raise
|
raise
|
||||||
raise DolibarrAPIError(f"Unexpected error: {str(e)}")
|
except Exception as e: # pylint: disable=broad-except
|
||||||
|
last_exception = e
|
||||||
|
break
|
||||||
|
|
||||||
|
if isinstance(last_exception, DolibarrAPIError):
|
||||||
|
raise last_exception
|
||||||
|
|
||||||
|
if isinstance(last_exception, Exception):
|
||||||
|
correlation_id = self._generate_correlation_id()
|
||||||
|
internal_error = self._build_internal_error(
|
||||||
|
endpoint=endpoint,
|
||||||
|
message=str(last_exception),
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
self.logger.error(
|
||||||
|
"Unexpected error during %s %s (correlation_id=%s): %s",
|
||||||
|
method,
|
||||||
|
endpoint,
|
||||||
|
correlation_id,
|
||||||
|
last_exception,
|
||||||
|
)
|
||||||
|
raise DolibarrAPIError(
|
||||||
|
message=internal_error["message"],
|
||||||
|
status_code=500,
|
||||||
|
response_data=internal_error,
|
||||||
|
) from last_exception
|
||||||
|
|
||||||
|
raise DolibarrAPIError(f"HTTP client error: {endpoint}")
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SYSTEM ENDPOINTS
|
# SYSTEM ENDPOINTS
|
||||||
@@ -359,6 +558,14 @@ class DolibarrClient:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Create a new product or service."""
|
"""Create a new product or service."""
|
||||||
payload = self._merge_payload(data, **kwargs)
|
payload = self._merge_payload(data, **kwargs)
|
||||||
|
payload = self._validate_payload(
|
||||||
|
endpoint="products",
|
||||||
|
payload=payload,
|
||||||
|
required_fields=["ref", "label", "type", "price"],
|
||||||
|
aliases={"label": ["name"]},
|
||||||
|
numeric_positive=["price"],
|
||||||
|
enum_fields={"type": ["product", "service", 0, 1]},
|
||||||
|
)
|
||||||
result = await self.request("POST", "products", data=payload)
|
result = await self.request("POST", "products", data=payload)
|
||||||
return self._extract_identifier(result)
|
return self._extract_identifier(result)
|
||||||
|
|
||||||
@@ -414,6 +621,12 @@ class DolibarrClient:
|
|||||||
if "product_type" in line:
|
if "product_type" in line:
|
||||||
line["product_type"] = line["product_type"]
|
line["product_type"] = line["product_type"]
|
||||||
|
|
||||||
|
payload = self._validate_payload(
|
||||||
|
endpoint="invoices",
|
||||||
|
payload=payload,
|
||||||
|
required_fields=["socid"],
|
||||||
|
)
|
||||||
|
|
||||||
result = await self.request("POST", "invoices", data=payload)
|
result = await self.request("POST", "invoices", data=payload)
|
||||||
return self._extract_identifier(result)
|
return self._extract_identifier(result)
|
||||||
|
|
||||||
@@ -573,6 +786,12 @@ class DolibarrClient:
|
|||||||
async def create_project(self, data: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
async def create_project(self, data: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
||||||
"""Create a new project."""
|
"""Create a new project."""
|
||||||
payload = self._merge_payload(data, **kwargs)
|
payload = self._merge_payload(data, **kwargs)
|
||||||
|
payload = self._validate_payload(
|
||||||
|
endpoint="projects",
|
||||||
|
payload=payload,
|
||||||
|
required_fields=["ref", "name", "socid"],
|
||||||
|
aliases={"name": ["title"]},
|
||||||
|
)
|
||||||
result = await self.request("POST", "projects", data=payload)
|
result = await self.request("POST", "projects", data=payload)
|
||||||
return self._extract_identifier(result)
|
return self._extract_identifier(result)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
# Import MCP components
|
# Import MCP components
|
||||||
@@ -1377,12 +1379,24 @@ async def handle_call_tool(name: str, arguments: dict):
|
|||||||
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
||||||
|
|
||||||
except DolibarrAPIError as e:
|
except DolibarrAPIError as e:
|
||||||
error_result = {"error": f"Dolibarr API Error: {str(e)}", "type": "api_error"}
|
error_payload = e.response_data or {
|
||||||
return [TextContent(type="text", text=json.dumps(error_result, indent=2))]
|
"error": "Dolibarr API Error",
|
||||||
|
"status": e.status_code or 500,
|
||||||
|
"message": str(e),
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
|
return [TextContent(type="text", text=json.dumps(error_payload, indent=2))]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_result = {"error": f"Tool execution failed: {str(e)}", "type": "internal_error"}
|
correlation_id = str(uuid.uuid4())
|
||||||
print(f"🔥 Tool execution error: {e}", file=sys.stderr) # Debug logging
|
error_result = {
|
||||||
|
"error": "Internal Server Error",
|
||||||
|
"status": 500,
|
||||||
|
"message": f"Tool execution failed: {str(e)}",
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
|
print(f"🔥 Tool execution error ({correlation_id}): {e}", file=sys.stderr) # Debug logging
|
||||||
return [TextContent(type="text", text=json.dumps(error_result, indent=2))]
|
return [TextContent(type="text", text=json.dumps(error_result, indent=2))]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import os
|
|||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||||
|
|
||||||
from dolibarr_mcp import DolibarrClient, Config
|
from dolibarr_mcp import DolibarrClient, Config
|
||||||
|
from dolibarr_mcp.dolibarr_client import DolibarrValidationError
|
||||||
|
|
||||||
|
|
||||||
class TestCRUDOperations:
|
class TestCRUDOperations:
|
||||||
@@ -78,7 +79,9 @@ class TestCRUDOperations:
|
|||||||
# Create
|
# Create
|
||||||
mock_request.return_value = {"id": 10}
|
mock_request.return_value = {"id": 10}
|
||||||
product_id = await client.create_product({
|
product_id = await client.create_product({
|
||||||
|
"ref": "TEST-PROD",
|
||||||
"label": "Test Product",
|
"label": "Test Product",
|
||||||
|
"type": "service",
|
||||||
"price": 99.99,
|
"price": 99.99,
|
||||||
"description": "Test product description"
|
"description": "Test product description"
|
||||||
})
|
})
|
||||||
@@ -330,7 +333,7 @@ class TestCRUDOperations:
|
|||||||
|
|
||||||
# Test validation error
|
# Test validation error
|
||||||
mock_request.side_effect = Exception("Validation Error: Missing required field")
|
mock_request.side_effect = Exception("Validation Error: Missing required field")
|
||||||
with pytest.raises(Exception, match="Validation"):
|
with pytest.raises(DolibarrValidationError):
|
||||||
await client.create_product({})
|
await client.create_product({})
|
||||||
|
|
||||||
# Test connection error
|
# Test connection error
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import pytest
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from dolibarr_mcp.config import Config
|
from dolibarr_mcp.config import Config
|
||||||
from dolibarr_mcp.dolibarr_client import DolibarrClient, DolibarrAPIError
|
from dolibarr_mcp.dolibarr_client import DolibarrClient, DolibarrAPIError, DolibarrValidationError
|
||||||
|
|
||||||
|
|
||||||
class TestDolibarrClient:
|
class TestDolibarrClient:
|
||||||
@@ -110,6 +110,66 @@ class TestDolibarrClient:
|
|||||||
|
|
||||||
assert exc_info.value.status_code == 404
|
assert exc_info.value.status_code == 404
|
||||||
assert "Object not found" in str(exc_info.value)
|
assert "Object not found" in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validation_error_on_missing_ref(self):
|
||||||
|
"""Ensure client-side validation catches missing product ref."""
|
||||||
|
config = Config(
|
||||||
|
dolibarr_url="https://test.dolibarr.com/api/index.php",
|
||||||
|
api_key="test_key",
|
||||||
|
allow_ref_autogen=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = DolibarrClient(config)
|
||||||
|
client.request = AsyncMock(return_value={"id": 1}) # Should not be called
|
||||||
|
|
||||||
|
with pytest.raises(DolibarrValidationError) as exc_info:
|
||||||
|
await client.create_product({"label": "No Ref", "type": "service", "price": 12.5})
|
||||||
|
|
||||||
|
assert exc_info.value.response_data["missing_fields"] == ["ref"]
|
||||||
|
client.request.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_autogen_ref_when_enabled(self):
|
||||||
|
"""Auto-generate refs when allowed by configuration."""
|
||||||
|
config = Config(
|
||||||
|
dolibarr_url="https://test.dolibarr.com/api/index.php",
|
||||||
|
api_key="test_key",
|
||||||
|
allow_ref_autogen=True,
|
||||||
|
ref_autogen_prefix="AUTOREF",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = DolibarrClient(config)
|
||||||
|
client.request = AsyncMock(return_value={"id": 42, "ref": "AUTOREF_123"})
|
||||||
|
|
||||||
|
await client.create_product({"label": "Generated Ref Product", "type": "service", "price": 10})
|
||||||
|
|
||||||
|
assert client.request.await_count == 1
|
||||||
|
sent_payload = client.request.call_args.kwargs["data"]
|
||||||
|
assert "ref" in sent_payload
|
||||||
|
assert sent_payload["ref"].startswith("AUTOREF_")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch('aiohttp.ClientSession.request')
|
||||||
|
async def test_internal_error_correlation_id(self, mock_request):
|
||||||
|
"""Include correlation IDs for unexpected server errors."""
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 500
|
||||||
|
mock_response.reason = "Internal Server Error"
|
||||||
|
mock_response.text.return_value = '{"message": "Database unavailable"}'
|
||||||
|
mock_request.return_value.__aenter__.return_value = mock_response
|
||||||
|
|
||||||
|
config = Config(
|
||||||
|
dolibarr_url="https://test.dolibarr.com/api/index.php",
|
||||||
|
api_key="test_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with DolibarrClient(config) as client:
|
||||||
|
with pytest.raises(DolibarrAPIError) as exc_info:
|
||||||
|
await client.get_project_by_id(1)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
assert "correlation_id" in exc_info.value.response_data
|
||||||
|
|
||||||
def test_url_building(self):
|
def test_url_building(self):
|
||||||
"""Test URL building functionality."""
|
"""Test URL building functionality."""
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class TestProjectOperations:
|
|||||||
# Create
|
# Create
|
||||||
mock_request.return_value = {"id": 200}
|
mock_request.return_value = {"id": 200}
|
||||||
project_id = await client.create_project({
|
project_id = await client.create_project({
|
||||||
|
"ref": "PRJ-NEW-WEBSITE",
|
||||||
"title": "New Website",
|
"title": "New Website",
|
||||||
"description": "Website redesign project",
|
"description": "Website redesign project",
|
||||||
"socid": 1,
|
"socid": 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user