refactor: add service layer architecture with Pydantic validation
- Add services layer (PartyService, ScheduleService, ProductService, ServiceCenterService) - Add ServiceProvider for dependency injection - Add Pydantic input validation on all MCP tools - Add thread-safety to TrytonSettings with double-checked locking - Update tests to mock ServiceProvider instead of direct client calls - Add pydantic dependency
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
from tryton_mcp.config import settings, TrytonSettings
|
||||
from tryton_mcp.server import mcp
|
||||
|
||||
__all__ = ["settings", "TrytonSettings", "mcp"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,10 @@ load_dotenv(project_root / ".env")
|
||||
|
||||
@dataclass
|
||||
class TrytonSettings:
|
||||
_lock = threading.Lock()
|
||||
_instance = None
|
||||
_client: Optional[Client] = field(default=None, init=False, repr=False)
|
||||
|
||||
hostname: str = field(
|
||||
default_factory=lambda: os.environ.get("TRYTON_HOSTNAME", "localhost")
|
||||
)
|
||||
@@ -26,7 +31,6 @@ class TrytonSettings:
|
||||
port: int = field(
|
||||
default_factory=lambda: int(os.environ.get("TRYTON_PORT", "8000"))
|
||||
)
|
||||
_client: Optional[Client] = field(default=None, init=False, repr=False)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -39,7 +43,9 @@ class TrytonSettings:
|
||||
|
||||
def get_client(self) -> Client:
|
||||
if self._client is None:
|
||||
self._client = Client(**self.to_dict())
|
||||
with self._lock:
|
||||
if self._client is None:
|
||||
self._client = Client(**self.to_dict())
|
||||
return self._client
|
||||
|
||||
def connect(self):
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
MCP Server for Naliia Module
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastmcp import FastMCP
|
||||
from typing import List, Dict, AsyncIterator, Any
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from tryton_mcp.config import settings
|
||||
import logging
|
||||
from tryton_mcp.services.provider import ServiceProvider
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
@@ -18,7 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def server_lifespan(server: FastMCP) -> AsyncIterator[None]:
|
||||
async def server_lifespan(server: FastMCP):
|
||||
settings.connect()
|
||||
yield
|
||||
settings.disconnect()
|
||||
@@ -26,112 +29,132 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[None]:
|
||||
|
||||
mcp = FastMCP("Tryton MCP Server", lifespan=server_lifespan)
|
||||
|
||||
_service_provider: Optional[ServiceProvider] = None
|
||||
|
||||
def tryton_call(name: str, args: list) -> Dict[str, Any]:
|
||||
try:
|
||||
client = settings.get_client()
|
||||
response = client.call(name, args)
|
||||
return {"success": True, "data": response}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_service_provider() -> ServiceProvider:
|
||||
global _service_provider
|
||||
if _service_provider is None:
|
||||
_service_provider = ServiceProvider(settings.get_client())
|
||||
return _service_provider
|
||||
|
||||
|
||||
class FindCustomerInput(BaseModel):
|
||||
identifier: str = Field(min_length=1, description="Customer identifier code")
|
||||
|
||||
|
||||
class CreateCustomerInput(BaseModel):
|
||||
name: str = Field(min_length=1, description="Customer name")
|
||||
identifiers: List[tuple[str, List[Dict[str, Any]]]] = Field(
|
||||
default_factory=list, description="List of identifier tuples"
|
||||
)
|
||||
|
||||
|
||||
class CreateScheduleInput(BaseModel):
|
||||
professional: int = Field(gt=0, description="Professional ID")
|
||||
description: str = Field(
|
||||
min_length=1, max_length=500, description="Appointment description"
|
||||
)
|
||||
customer: int = Field(gt=0, description="Customer ID")
|
||||
date: str = Field(description="Date in ISO format (e.g., 2026-03-15T10:00:00)")
|
||||
service_center: int = Field(gt=0, description="Service center ID")
|
||||
|
||||
@field_validator("date")
|
||||
@classmethod
|
||||
def validate_date(cls, v: str) -> str:
|
||||
try:
|
||||
datetime.fromisoformat(v)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid date format: '{v}'. Expected ISO format (e.g., '2026-03-15T10:00:00' or '2026-03-15T10:00:00+00:00')"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class FindServiceCentersInput(BaseModel):
|
||||
offset: int = Field(default=0, ge=0, description="Offset for pagination")
|
||||
limit: Optional[int] = Field(default=None, ge=1, description="Limit for pagination")
|
||||
|
||||
|
||||
class FindProductsInput(BaseModel):
|
||||
offset: int = Field(default=0, ge=0, description="Offset for pagination")
|
||||
limit: Optional[int] = Field(default=None, ge=1, description="Limit for pagination")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_customer_by_identifier(identifier: str) -> List[Dict]:
|
||||
result = tryton_call(
|
||||
"model.party.identifier.search_read",
|
||||
[
|
||||
[["code", "=", identifier]],
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
["party", "party.name"],
|
||||
{"company": 1},
|
||||
],
|
||||
)
|
||||
FindCustomerInput(identifier=identifier)
|
||||
|
||||
if not result["success"]:
|
||||
services = get_service_provider()
|
||||
result = services.party.find_by_identifier(identifier)
|
||||
|
||||
if result.is_error:
|
||||
raise Exception(f"Customer with identifier {identifier} not found.")
|
||||
|
||||
return result["data"]
|
||||
return result.data
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_customer(
|
||||
name: str, identifiers: List[tuple[str, List[Dict[str, Any]]]]
|
||||
) -> List[int]:
|
||||
CreateCustomerInput(name=name, identifiers=identifiers)
|
||||
|
||||
result = tryton_call(
|
||||
"model.party.party.create",
|
||||
[{"name": name, "identifiers": identifiers}, {}],
|
||||
)
|
||||
services = get_service_provider()
|
||||
result = services.party.create(name, identifiers)
|
||||
|
||||
if not result["success"]:
|
||||
raise Exception(f"Failed to create {name} as customer")
|
||||
if result.is_error:
|
||||
raise Exception(f"Failed to create {name} as customer: {result.error}")
|
||||
|
||||
return result["data"]
|
||||
return result.data
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_schedule(
|
||||
professional: int, description: str, customer: int, date: str, service_center: int
|
||||
) -> List[int]:
|
||||
CreateScheduleInput(
|
||||
professional=professional,
|
||||
description=description,
|
||||
customer=customer,
|
||||
date=date,
|
||||
service_center=service_center,
|
||||
)
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(date)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid date format: '{date}'. Expected ISO format (e.g., '2026-03-15T10:00:00' or '2026-03-15T10:00:00+00:00')"
|
||||
)
|
||||
services = get_service_provider()
|
||||
result = services.schedule.create(
|
||||
professional, description, customer, date, service_center
|
||||
)
|
||||
|
||||
utc_dt = dt.astimezone(timezone.utc)
|
||||
if result.is_error:
|
||||
raise Exception(f"Failed to create schedule: {result.error}")
|
||||
|
||||
example_schedule = [
|
||||
{
|
||||
"professional": professional,
|
||||
"description": description,
|
||||
"customer": customer,
|
||||
"date": utc_dt,
|
||||
"service_center": service_center,
|
||||
}
|
||||
]
|
||||
|
||||
result = tryton_call("model.naliia.schedule.create", [example_schedule, {}])
|
||||
|
||||
if not result["success"]:
|
||||
raise Exception(f"Failed to create schedule: {result['error']}")
|
||||
|
||||
return result["data"]
|
||||
return result.data
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_service_centers():
|
||||
result = tryton_call(
|
||||
"model.naliia.service_center.search_read",
|
||||
[[[]], 0, None, None, ["name", "address.street"], {}],
|
||||
)
|
||||
def find_service_centers(offset: int = 0, limit: Optional[int] = None) -> List[Dict]:
|
||||
FindServiceCentersInput(offset=offset, limit=limit)
|
||||
|
||||
if not result["success"]:
|
||||
raise Exception(f"Failed to find service centers: {result['error']}")
|
||||
services = get_service_provider()
|
||||
result = services.service_center.find_all(offset=offset, limit=limit)
|
||||
|
||||
return result["data"]
|
||||
if result.is_error:
|
||||
raise Exception(f"Failed to find service centers: {result.error}")
|
||||
|
||||
return result.data
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_products_and_services():
|
||||
result = tryton_call(
|
||||
"model.product.product.search_read",
|
||||
[
|
||||
[["active", "=", True], ["salable", "=", True]],
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
["name", "list_price", "description"],
|
||||
{"company": 1},
|
||||
],
|
||||
)
|
||||
def find_products_and_services(
|
||||
offset: int = 0, limit: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
FindProductsInput(offset=offset, limit=limit)
|
||||
|
||||
if not result["success"]:
|
||||
raise Exception(f"Failed to find products: {result['error']}")
|
||||
services = get_service_provider()
|
||||
result = services.product.find_salable(offset=offset, limit=limit)
|
||||
|
||||
return result["data"]
|
||||
if result.is_error:
|
||||
raise Exception(f"Failed to find products: {result.error}")
|
||||
|
||||
return result.data
|
||||
|
||||
14
src/tryton_mcp/services/__init__.py
Normal file
14
src/tryton_mcp/services/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from tryton_mcp.services.base import BaseService, TrytonResponse
|
||||
from tryton_mcp.services.party import PartyService
|
||||
from tryton_mcp.services.schedule import ScheduleService
|
||||
from tryton_mcp.services.product import ProductService
|
||||
from tryton_mcp.services.service_center import ServiceCenterService
|
||||
|
||||
__all__ = [
|
||||
"BaseService",
|
||||
"TrytonResponse",
|
||||
"PartyService",
|
||||
"ScheduleService",
|
||||
"ProductService",
|
||||
"ServiceCenterService",
|
||||
]
|
||||
28
src/tryton_mcp/services/base.py
Normal file
28
src/tryton_mcp/services/base.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrytonResponse:
|
||||
success: bool
|
||||
data: Any = None
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
return not self.success
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self, client: Any):
|
||||
self._client = client
|
||||
|
||||
def call(self, method: str, args: List[Any]) -> TrytonResponse:
|
||||
try:
|
||||
response = self._client.call(method, args)
|
||||
return TrytonResponse(success=True, data=response)
|
||||
except Exception as e:
|
||||
return TrytonResponse(success=False, error=str(e))
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
return self._client
|
||||
43
src/tryton_mcp/services/party.py
Normal file
43
src/tryton_mcp/services/party.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from tryton_mcp.services.base import BaseService, TrytonResponse
|
||||
|
||||
|
||||
class PartyService(BaseService):
|
||||
def find_by_identifier(self, identifier: str) -> TrytonResponse:
|
||||
return self.call(
|
||||
"model.party.identifier.search_read",
|
||||
[
|
||||
[["code", "=", identifier]],
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
["party", "party.name"],
|
||||
{"company": 1},
|
||||
],
|
||||
)
|
||||
|
||||
def create(
|
||||
self, name: str, identifiers: List[tuple[str, List[Dict[str, Any]]]]
|
||||
) -> TrytonResponse:
|
||||
return self.call(
|
||||
"model.party.party.create",
|
||||
[{"name": name, "identifiers": identifiers}, {}],
|
||||
)
|
||||
|
||||
def search_parties(
|
||||
self,
|
||||
domain: Optional[List[Any]] = None,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> TrytonResponse:
|
||||
if domain is None:
|
||||
domain = []
|
||||
if fields is None:
|
||||
fields = ["name", "id"]
|
||||
|
||||
return self.call(
|
||||
"model.party.party.search_read",
|
||||
[domain, offset, limit, None, fields, {}],
|
||||
)
|
||||
39
src/tryton_mcp/services/product.py
Normal file
39
src/tryton_mcp/services/product.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from tryton_mcp.services.base import BaseService, TrytonResponse
|
||||
|
||||
|
||||
class ProductService(BaseService):
|
||||
def find_salable(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
) -> TrytonResponse:
|
||||
return self.call(
|
||||
"model.product.product.search_read",
|
||||
[
|
||||
[["active", "=", True], ["salable", "=", True]],
|
||||
offset,
|
||||
limit,
|
||||
None,
|
||||
["name", "list_price", "description"],
|
||||
{"company": 1},
|
||||
],
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
domain: Optional[List[Any]] = None,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> TrytonResponse:
|
||||
if domain is None:
|
||||
domain = []
|
||||
if fields is None:
|
||||
fields = ["id", "name", "list_price"]
|
||||
|
||||
return self.call(
|
||||
"model.product.product.search_read",
|
||||
[domain, offset, limit, None, fields, {}],
|
||||
)
|
||||
37
src/tryton_mcp/services/provider.py
Normal file
37
src/tryton_mcp/services/provider.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from tryton_mcp.services.party import PartyService
|
||||
from tryton_mcp.services.schedule import ScheduleService
|
||||
from tryton_mcp.services.product import ProductService
|
||||
from tryton_mcp.services.service_center import ServiceCenterService
|
||||
|
||||
|
||||
class ServiceProvider:
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
self._party_service: PartyService | None = None
|
||||
self._schedule_service: ScheduleService | None = None
|
||||
self._product_service: ProductService | None = None
|
||||
self._service_center_service: ServiceCenterService | None = None
|
||||
|
||||
@property
|
||||
def party(self) -> PartyService:
|
||||
if self._party_service is None:
|
||||
self._party_service = PartyService(self._client)
|
||||
return self._party_service
|
||||
|
||||
@property
|
||||
def schedule(self) -> ScheduleService:
|
||||
if self._schedule_service is None:
|
||||
self._schedule_service = ScheduleService(self._client)
|
||||
return self._schedule_service
|
||||
|
||||
@property
|
||||
def product(self) -> ProductService:
|
||||
if self._product_service is None:
|
||||
self._product_service = ProductService(self._client)
|
||||
return self._product_service
|
||||
|
||||
@property
|
||||
def service_center(self) -> ServiceCenterService:
|
||||
if self._service_center_service is None:
|
||||
self._service_center_service = ServiceCenterService(self._client)
|
||||
return self._service_center_service
|
||||
53
src/tryton_mcp/services/schedule.py
Normal file
53
src/tryton_mcp/services/schedule.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from tryton_mcp.services.base import BaseService, TrytonResponse
|
||||
|
||||
|
||||
class ScheduleService(BaseService):
|
||||
def create(
|
||||
self,
|
||||
professional: int,
|
||||
description: str,
|
||||
customer: int,
|
||||
date: str,
|
||||
service_center: int,
|
||||
) -> TrytonResponse:
|
||||
try:
|
||||
dt = datetime.fromisoformat(date)
|
||||
except ValueError as e:
|
||||
return TrytonResponse(
|
||||
success=False,
|
||||
error=f"Invalid date format: '{date}'. Expected ISO format. Error: {e}",
|
||||
)
|
||||
|
||||
utc_dt = dt.astimezone(timezone.utc)
|
||||
|
||||
schedule_data = [
|
||||
{
|
||||
"professional": professional,
|
||||
"description": description,
|
||||
"customer": customer,
|
||||
"date": utc_dt,
|
||||
"service_center": service_center,
|
||||
}
|
||||
]
|
||||
|
||||
return self.call("model.naliia.schedule.create", [schedule_data, {}])
|
||||
|
||||
def search(
|
||||
self,
|
||||
domain: Optional[List[Any]] = None,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> TrytonResponse:
|
||||
if domain is None:
|
||||
domain = []
|
||||
if fields is None:
|
||||
fields = ["id", "professional", "description", "customer", "date"]
|
||||
|
||||
return self.call(
|
||||
"model.naliia.schedule.search_read",
|
||||
[domain, offset, limit, None, fields, {}],
|
||||
)
|
||||
32
src/tryton_mcp/services/service_center.py
Normal file
32
src/tryton_mcp/services/service_center.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from tryton_mcp.services.base import BaseService, TrytonResponse
|
||||
|
||||
|
||||
class ServiceCenterService(BaseService):
|
||||
def find_all(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
) -> TrytonResponse:
|
||||
return self.call(
|
||||
"model.naliia.service_center.search_read",
|
||||
[[[]], offset, limit, None, ["name", "address.street"], {}],
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
domain: Optional[List[Any]] = None,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> TrytonResponse:
|
||||
if domain is None:
|
||||
domain = []
|
||||
if fields is None:
|
||||
fields = ["id", "name", "address.street"]
|
||||
|
||||
return self.call(
|
||||
"model.naliia.service_center.search_read",
|
||||
[domain, offset, limit, None, fields, {}],
|
||||
)
|
||||
Reference in New Issue
Block a user